feat: preserve form values and show field errors on submit
- keep submitted values and API field violations across redirects - reopen the failed modal and highlight invalid fields - show inline HTML5 validation messages instead of browser bubbles - keep content-admin errors on the originating page
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -7,6 +7,8 @@ build/
|
||||
.env.prod
|
||||
/data/
|
||||
node_modules/
|
||||
# Локальные сессии Kilo Code — не часть репозитория.
|
||||
.kilo/
|
||||
# Каталог с боевыми данными и паролями (не коммитить!)
|
||||
examples/
|
||||
__pycache__/
|
||||
|
||||
@@ -113,12 +113,9 @@ class AdminGroupController(
|
||||
@RequestParam(name = "name", required = false) name: String?,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String {
|
||||
if (file.isEmpty) {
|
||||
redirect.addFlashAttribute("error", "Выберите файл со списком группы")
|
||||
return "redirect:/admin/groups"
|
||||
}
|
||||
return try {
|
||||
): String =
|
||||
try {
|
||||
if (file.isEmpty) throw ApiException(400, "Выберите файл со списком группы")
|
||||
val result =
|
||||
api.importGroup(
|
||||
token(session),
|
||||
@@ -132,7 +129,6 @@ class AdminGroupController(
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/groups"
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/groups/{id}")
|
||||
fun groupDetail(
|
||||
|
||||
@@ -60,12 +60,9 @@ class AdminLabController(
|
||||
@RequestParam("file") file: MultipartFile,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String {
|
||||
if (file.isEmpty) {
|
||||
redirect.addFlashAttribute("error", "Выберите файл")
|
||||
return "redirect:/admin/labs"
|
||||
}
|
||||
return try {
|
||||
): String =
|
||||
try {
|
||||
if (file.isEmpty) throw ApiException(400, "Выберите файл")
|
||||
val result =
|
||||
api.importLab(
|
||||
token(session),
|
||||
@@ -81,7 +78,6 @@ class AdminLabController(
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/labs"
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/labs/{id}/toggle-status")
|
||||
fun toggleStatus(
|
||||
|
||||
@@ -50,12 +50,9 @@ class ContentAdminController(
|
||||
@RequestParam(defaultValue = "false") sync: Boolean,
|
||||
session: HttpSession,
|
||||
redirect: RedirectAttributes,
|
||||
): String {
|
||||
if (file.isEmpty) {
|
||||
redirect.addFlashAttribute("error", "Выберите файл")
|
||||
return "redirect:/admin/content"
|
||||
}
|
||||
return try {
|
||||
): String =
|
||||
try {
|
||||
if (file.isEmpty) throw ApiException(400, "Выберите файл")
|
||||
val result = api.importContent(token(session), file.originalFilename ?: "content.zip", file.bytes, sync)
|
||||
val modeNote = if (sync) "" else " (режим «только добавить», удаления не было)"
|
||||
val removedNote = if (result.removed > 0) ", удалено устаревших: ${result.removed}" else ""
|
||||
@@ -64,12 +61,11 @@ class ContentAdminController(
|
||||
"Импортировано: глав ${result.chapters}, разделов ${result.sections}, " +
|
||||
"статей ${result.articles}, медиафайлов ${result.assets}$removedNote$modeNote",
|
||||
)
|
||||
"redirect:/admin/content"
|
||||
"redirect:/learn"
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
"redirect:/learn"
|
||||
}
|
||||
}
|
||||
|
||||
/** Сохранение порядка при drag & drop (запрос отправляется из JS). */
|
||||
@PostMapping("/reorder")
|
||||
@@ -122,7 +118,7 @@ class ContentAdminController(
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/learn"
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/save")
|
||||
@@ -135,10 +131,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.updateContentArticle(token(session), id, form.title, form.bodyMd)
|
||||
redirect.addFlashAttribute("notice", "Статья сохранена")
|
||||
redirectBack(form.back, "redirect:/admin/content")
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content/articles/$id/edit"
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/delete")
|
||||
@@ -151,10 +147,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.deleteContentArticle(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Статья удалена")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/chapters/create")
|
||||
@@ -168,10 +164,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.createChapter(token(session), title.trim(), slug)
|
||||
redirect.addFlashAttribute("notice", "Глава «${title.trim()}» создана")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/chapters/{id}/rename")
|
||||
@@ -185,10 +181,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.renameChapter(token(session), id, title.trim())
|
||||
redirect.addFlashAttribute("notice", "Глава переименована")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/chapters/{id}/delete")
|
||||
@@ -201,10 +197,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.deleteChapter(token(session), id)
|
||||
redirect.addFlashAttribute("notice", "Глава удалена вместе с содержимым")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/sections/create")
|
||||
@@ -216,10 +212,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.createSection(token(session), form.chapterId, form.title.trim(), form.slug)
|
||||
redirect.addFlashAttribute("notice", "Раздел «${form.title.trim()}» создан")
|
||||
redirectBack(form.back, "redirect:/admin/content")
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/sections/{id}/rename")
|
||||
@@ -233,10 +229,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.renameSection(token(session), id, title.trim())
|
||||
redirect.addFlashAttribute("notice", "Раздел переименован")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/sections/{id}/delete")
|
||||
@@ -255,10 +251,10 @@ class ContentAdminController(
|
||||
api.deleteSectionWithMove(token(session), id, moveTo)
|
||||
redirect.addFlashAttribute("notice", "Статьи перенесены, раздел удалён")
|
||||
}
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/articles/create")
|
||||
@@ -280,10 +276,10 @@ class ContentAdminController(
|
||||
),
|
||||
)
|
||||
redirect.addFlashAttribute("notice", "Статья «${form.title.trim()}» создана")
|
||||
redirectBack(form.back, "redirect:/admin/content")
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(form.back, "redirect:/learn")
|
||||
}
|
||||
|
||||
@PostMapping("/articles/{id}/move")
|
||||
@@ -297,10 +293,10 @@ class ContentAdminController(
|
||||
try {
|
||||
api.moveArticle(token(session), id, sectionId)
|
||||
redirect.addFlashAttribute("notice", "Статья перемещена")
|
||||
redirectBack(back, "redirect:/admin/content")
|
||||
redirectBack(back, "redirect:/learn")
|
||||
} catch (ex: ApiException) {
|
||||
redirect.addFlashAttribute("error", ex.message)
|
||||
"redirect:/admin/content"
|
||||
redirectBack(back, "redirect:/learn")
|
||||
}
|
||||
|
||||
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */
|
||||
|
||||
@@ -7,14 +7,15 @@ import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
import org.springframework.web.client.RestClientResponseException
|
||||
import java.net.URLDecoder
|
||||
import ru.ysu.lms.common.FieldViolation
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
import java.net.http.HttpClient
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.Duration
|
||||
|
||||
@Component
|
||||
class ApiClient(
|
||||
@Value("\${lms.gateway.base-url}") baseUrl: String,
|
||||
private val mapper: ObjectMapper,
|
||||
) {
|
||||
// readTimeout большой: через BFF ходят загрузки/выгрузки до 200 МБ (импорт ZIP, архивы сдач).
|
||||
internal val client: RestClient =
|
||||
@@ -45,23 +46,62 @@ class ApiClient(
|
||||
} catch (ex: ApiException) {
|
||||
throw ex
|
||||
} catch (ex: RestClientResponseException) {
|
||||
throw ApiException(ex.statusCode.value(), detail(ex))
|
||||
} catch (ignore: RestClientException) {
|
||||
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.")
|
||||
val error = parseError(ex)
|
||||
throw ApiException(ex.statusCode.value(), error.detail, error.violations, ex)
|
||||
} catch (ex: RestClientException) {
|
||||
throw ApiException(502, "Сервис недоступен. Проверьте, что gateway запущен.", cause = ex)
|
||||
}
|
||||
|
||||
internal fun detail(ex: RestClientResponseException): String {
|
||||
/** Разбирает ApiError сервиса: detail для тоста и violations для подсветки полей. */
|
||||
private fun parseError(ex: RestClientResponseException): ParsedError {
|
||||
val body = ex.responseBodyAsString
|
||||
val match = DETAIL_PATTERN.find(body)
|
||||
val parsed =
|
||||
match
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.replace("\\\"", "\"")
|
||||
?.replace("\\n", "\n")
|
||||
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
|
||||
val fallback = detailFromBody(body)
|
||||
return try {
|
||||
val root = mapper.readTree(body)
|
||||
val rawDetail = root.path("detail").asString("")
|
||||
val violationsNode = root.path("violations")
|
||||
val violations =
|
||||
buildList {
|
||||
if (violationsNode.isArray) {
|
||||
violationsNode.forEach { node ->
|
||||
val field = node.path("field").asString("")
|
||||
val message = node.path("message").asString("")
|
||||
if (field.isNotBlank() && message.isNotBlank()) {
|
||||
add(FieldViolation(field = field, message = message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Spring-валидация отвечает общим "Request validation failed" — показываем
|
||||
// конкретные сообщения из violations, а не техническую формулировку.
|
||||
val detail =
|
||||
when {
|
||||
rawDetail.isNotBlank() && !rawDetail.equals("Request validation failed", ignoreCase = true) -> rawDetail
|
||||
violations.isNotEmpty() -> violations.joinToString("; ") { it.message }
|
||||
fallback != null -> fallback
|
||||
else -> "Ошибка запроса (${ex.statusCode.value()})"
|
||||
}
|
||||
ParsedError(detail, violations)
|
||||
} catch (ignore: Exception) {
|
||||
ParsedError(fallback ?: "Ошибка запроса (${ex.statusCode.value()})", emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
private fun detailFromBody(body: String): String? {
|
||||
val match = DETAIL_PATTERN.find(body)
|
||||
return match
|
||||
?.groupValues
|
||||
?.get(1)
|
||||
?.replace("\\\"", "\"")
|
||||
?.replace("\\n", "\n")
|
||||
?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
private data class ParsedError(
|
||||
val detail: String,
|
||||
val violations: List<FieldViolation>,
|
||||
)
|
||||
|
||||
internal fun downloadOf(entity: org.springframework.http.ResponseEntity<ByteArray>): DownloadDto {
|
||||
val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty()
|
||||
val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" }
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
package ru.ysu.lms.web.api
|
||||
|
||||
import ru.ysu.lms.common.FieldViolation
|
||||
import ru.ysu.lms.web.form.FormFeedback
|
||||
|
||||
class ApiException(
|
||||
val status: Int,
|
||||
override val message: String,
|
||||
) : RuntimeException(message)
|
||||
val violations: List<FieldViolation> = emptyList(),
|
||||
cause: Throwable? = null,
|
||||
) : RuntimeException(message, cause) {
|
||||
init {
|
||||
// Пока запрос ещё привязан к потоку, запоминаем введённые значения и ошибки:
|
||||
// контроллеры ниже превратят это в flash/модель для повторного показа формы.
|
||||
FormFeedback.capture(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package ru.ysu.lms.web.config
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import org.springframework.web.servlet.ModelAndView
|
||||
import org.springframework.web.servlet.support.RequestContextUtils
|
||||
import ru.ysu.lms.web.form.FormFeedback
|
||||
import tools.jackson.databind.ObjectMapper
|
||||
|
||||
/**
|
||||
* Переносит снимок неудачной отправки формы на следующую страницу:
|
||||
* при redirect — во flash, при прямом рендере — в модель.
|
||||
*/
|
||||
@Component
|
||||
class FormFeedbackInterceptor(
|
||||
private val mapper: ObjectMapper,
|
||||
) : HandlerInterceptor {
|
||||
override fun postHandle(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
handler: Any,
|
||||
modelAndView: ModelAndView?,
|
||||
) {
|
||||
val feedback = request.getAttribute(FormFeedback.REQUEST_ATTR) as? FormFeedback ?: return
|
||||
request.removeAttribute(FormFeedback.REQUEST_ATTR)
|
||||
if (modelAndView == null) return
|
||||
val json = runCatching { mapper.writeValueAsString(feedback) }.getOrNull() ?: return
|
||||
|
||||
val viewName = modelAndView.viewName
|
||||
if (viewName != null && viewName.startsWith("redirect:")) {
|
||||
RequestContextUtils.getOutputFlashMap(request)?.put(FormFeedback.MODEL_ATTR, json)
|
||||
} else {
|
||||
modelAndView.addObject(FormFeedback.MODEL_ATTR, json)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,9 +32,11 @@ class WebConfig(
|
||||
private val adminInterceptor: AdminInterceptor,
|
||||
private val csrfInterceptor: CsrfInterceptor,
|
||||
private val mustChangePasswordInterceptor: MustChangePasswordInterceptor,
|
||||
private val formFeedbackInterceptor: FormFeedbackInterceptor,
|
||||
) : WebMvcConfigurer {
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(csrfInterceptor).addPathPatterns("/**")
|
||||
registry.addInterceptor(formFeedbackInterceptor).addPathPatterns("/**")
|
||||
registry
|
||||
.addInterceptor(NoCachePageInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
|
||||
129
web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt
Normal file
129
web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt
Normal file
@@ -0,0 +1,129 @@
|
||||
package ru.ysu.lms.web.form
|
||||
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
import ru.ysu.lms.web.api.ApiException
|
||||
|
||||
/**
|
||||
* Снимок неудачной отправки формы: адрес действия, введённые значения и ошибки полей.
|
||||
*
|
||||
* Снимок создаётся в момент [ApiException] (см. init в ApiException), кладётся в атрибут
|
||||
* запроса и затем [ru.ysu.lms.web.config.FormFeedbackInterceptor] переносит его во flash
|
||||
* при redirect или в модель при прямом рендере страницы. На клиенте form-feedback.js
|
||||
* находит форму по action, восстанавливает значения, раскрывает модалку и показывает
|
||||
* ошибки рядом с полями.
|
||||
*/
|
||||
data class FormFeedback(
|
||||
val action: String,
|
||||
val values: Map<String, List<String>>,
|
||||
val errors: Map<String, String>,
|
||||
val general: String?,
|
||||
) {
|
||||
companion object {
|
||||
const val REQUEST_ATTR = "lms.formFeedback"
|
||||
const val MODEL_ATTR = "formFeedbackJson"
|
||||
|
||||
private val IGNORED = setOf("_csrf")
|
||||
|
||||
/** Пароли не сохраняем: после ошибки их нужно ввести заново. */
|
||||
private fun isSensitive(name: String): Boolean {
|
||||
val n = name.lowercase()
|
||||
return n.contains("password") || n == "old" || n == "new" || n == "new2"
|
||||
}
|
||||
|
||||
private data class Rule(
|
||||
val pattern: Regex,
|
||||
val fields: List<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Сообщения доменных исключений часто не привязаны к полю. Небольшие правила
|
||||
* помогают показать их рядом с конкретным input, не меняя контракты сервисов.
|
||||
*/
|
||||
private val RULES =
|
||||
listOf(
|
||||
Rule(Regex("(?iu)логин.*(минимум|символ|пуст)"), listOf("login")),
|
||||
Rule(Regex("(?iu)(пароли не совпадают|повторите.*парол)"), listOf("password2", "new2")),
|
||||
Rule(Regex("(?iu)текущ.*парол"), listOf("old")),
|
||||
Rule(Regex("(?iu)нов.*парол"), listOf("new")),
|
||||
Rule(Regex("(?iu)парол"), listOf("password", "new")),
|
||||
Rule(Regex("(?iu)студенческ.*номер"), listOf("studentNo")),
|
||||
Rule(Regex("(?iu)(студент.*(групп|номер)|студент уже|студент не входит)"), listOf("studentId", "studentNo")),
|
||||
Rule(Regex("(?iu)фамили"), listOf("lastName")),
|
||||
Rule(Regex("(?iu)(название группы|группа.*существует)"), listOf("name")),
|
||||
Rule(Regex("(?iu)номер лабораторной"), listOf("number")),
|
||||
Rule(Regex("(?iu)номер варианта"), listOf("number")),
|
||||
Rule(Regex("(?iu)код уровня"), listOf("code", "newLevelCode", "levels")),
|
||||
Rule(Regex("(?iu)оценк"), listOf("score")),
|
||||
Rule(Regex("(?iu)(максимум баллов|балл)"), listOf("pointsMax", "score")),
|
||||
Rule(Regex("(?iu)(срок сдачи|дата сдачи|дата защиты)"), listOf("deadlineAt", "defenseAt")),
|
||||
Rule(
|
||||
Regex("(?iu)(приложите файлы|выберите файл|файл.*больш|архив|ожидается .zip|таблиц|столбец)"),
|
||||
listOf("files", "file"),
|
||||
),
|
||||
Rule(Regex("(?iu)код не найден"), listOf("code")),
|
||||
Rule(Regex("(?iu)главу"), listOf("chapterId", "newChapterTitle", "sectionId")),
|
||||
Rule(Regex("(?iu)раздел"), listOf("sectionId", "newSectionTitle", "moveTo", "chapterId")),
|
||||
Rule(Regex("(?iu)задани"), listOf("assignmentMd")),
|
||||
Rule(Regex("(?iu)(название|заголовок)"), listOf("title", "newChapterTitle", "newSectionTitle")),
|
||||
)
|
||||
|
||||
/** Вызывается из ApiException, пока запрос ещё доступен. */
|
||||
fun capture(ex: ApiException) {
|
||||
val request =
|
||||
(RequestContextHolder.getRequestAttributes() as? ServletRequestAttributes)
|
||||
?.request
|
||||
?: return
|
||||
if (!request.method.equals("POST", ignoreCase = true)) return
|
||||
|
||||
val values = LinkedHashMap<String, List<String>>()
|
||||
runCatching { request.parameterMap }.getOrNull()?.forEach { (name, raw) ->
|
||||
if (name in IGNORED) return@forEach
|
||||
// Чувствительные значения не запоминаем, но имя поля оставляем:
|
||||
// так ошибку можно показать рядом с нужным input.
|
||||
values[name] = if (isSensitive(name)) emptyList() else raw.toList()
|
||||
}
|
||||
|
||||
val errors = LinkedHashMap<String, String>()
|
||||
ex.violations.forEach { violation ->
|
||||
val field = normalizeField(violation.field)
|
||||
if (field.isNotBlank()) errors.putIfAbsent(field, violation.message)
|
||||
}
|
||||
if (errors.isEmpty()) {
|
||||
inferFields(ex.message, values).forEach { (field, message) -> errors[field] = message }
|
||||
}
|
||||
|
||||
request.setAttribute(
|
||||
REQUEST_ATTR,
|
||||
FormFeedback(
|
||||
action = request.requestURI,
|
||||
values = values,
|
||||
errors = errors,
|
||||
general = ex.message.takeIf { it.isNotBlank() },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeField(raw: String): String = raw.substringAfterLast('.').substringBefore('[').trim()
|
||||
|
||||
private fun inferFields(
|
||||
message: String,
|
||||
values: Map<String, List<String>>,
|
||||
): List<Pair<String, String>> {
|
||||
RULES.forEach { rule ->
|
||||
if (!rule.pattern.containsMatchIn(message)) return@forEach
|
||||
val present = rule.fields.filter { values.containsKey(it) }
|
||||
if (present.isNotEmpty()) {
|
||||
// У file-полей имя в форме может быть и file, и files — подсветим оба
|
||||
// варианта, лишний просто не найдётся на странице.
|
||||
val fields = if (present.any { it == "files" || it == "file" }) present else present.take(1)
|
||||
return fields.map { it to message }
|
||||
}
|
||||
val fileFields = rule.fields.filter { it == "files" || it == "file" }
|
||||
if (fileFields.isNotEmpty()) return fileFields.map { it to message }
|
||||
return@forEach
|
||||
}
|
||||
return emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,19 @@ p { margin-top: 0; }
|
||||
.label { display: block; margin-bottom: 0.25rem; font-size: 0.875rem; font-weight: 500; }
|
||||
.hint { margin-top: 0.25rem; font-size: 0.75rem; color: var(--c-text-muted); }
|
||||
|
||||
/* Ошибки формы: поле с ошибкой и сообщение под ним */
|
||||
.input.is-invalid,
|
||||
select.is-invalid,
|
||||
textarea.is-invalid,
|
||||
input.is-invalid { border-color: var(--c-tone-neg); box-shadow: 0 0 0 3px var(--c-accent-soft); }
|
||||
input[type="checkbox"].is-invalid,
|
||||
input[type="radio"].is-invalid { outline: 2px solid var(--c-tone-neg); outline-offset: 2px; }
|
||||
.field-error { margin: 0.25rem 0 0; font-size: 0.75rem; line-height: 1.35; color: var(--c-tone-neg); }
|
||||
.form-error-summary {
|
||||
margin: 0 0 0.25rem; padding: 0.625rem 0.75rem; border: 1px solid var(--c-tone-neg);
|
||||
background: var(--c-accent-soft); color: var(--c-tone-neg); font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ===== B3-тема поверх Bootstrap: navbar, offcanvas, кнопки, ссылки ===== */
|
||||
|
||||
@@ -684,9 +684,49 @@ document.addEventListener('submit', function (ev) {
|
||||
// flash-сообщение забираем из отданного HTML
|
||||
var err = /<div[^>]*class="[^"]*toast-item toast-error[^"]*"[^>]*>\s*<span class="toast-text">([^<]*)</.exec(html);
|
||||
var ok = /<span class="toast-text">([^<]*)<\/span>/.exec(html);
|
||||
showToast(err ? err[1].trim() : (ok ? ok[1].trim() : 'Сохранено'), err ? 'error' : 'success');
|
||||
if (err) {
|
||||
// не перерисовываем модалку: выбранные пользователем значения остаются на месте
|
||||
showToast(err[1].trim(), 'error');
|
||||
return;
|
||||
}
|
||||
showToast(ok ? ok[1].trim() : 'Сохранено', 'success');
|
||||
return refreshAssignModal(form.getAttribute('data-refresh'));
|
||||
})
|
||||
.catch(function (e) { showToast('Не удалось сохранить: ' + e.message, 'error'); })
|
||||
.finally(function () { if (btn) btn.disabled = false; });
|
||||
});
|
||||
|
||||
/* После возврата формы оценки с ошибкой сервера восстанавливаем подпись студента
|
||||
и динамические строки комментариев (значения уже подставлены form-feedback.js). */
|
||||
document.addEventListener('lms:form-restored', function (event) {
|
||||
var form = event.detail && event.detail.form;
|
||||
if (!form || form.id !== 'grade-form') return;
|
||||
|
||||
var student = $id('grade-student');
|
||||
var lab = $id('grade-lab');
|
||||
if (!student || !lab) return;
|
||||
|
||||
var btn = document.querySelector(
|
||||
'.score-link[data-student="' + student.value + '"][data-lab="' + lab.value + '"]'
|
||||
);
|
||||
if (btn) {
|
||||
var who = $id('grade-who');
|
||||
if (who) who.value = btn.getAttribute('data-student-name') || '';
|
||||
var title = $id('grade-title');
|
||||
if (title) {
|
||||
title.textContent = (btn.getAttribute('data-lab-name') || '') +
|
||||
' — ' + (btn.getAttribute('data-student-name') || '');
|
||||
}
|
||||
}
|
||||
|
||||
var raw = $id('grade-comments');
|
||||
var list = [];
|
||||
try { list = raw ? JSON.parse(raw.value || '[]') : []; } catch (e) { list = []; }
|
||||
var box = $id('cs-rows');
|
||||
if (!box) return;
|
||||
box.innerHTML = '';
|
||||
(Array.isArray(list) ? list : []).forEach(function (comment) {
|
||||
csRow(comment.text || '', comment.tone || 'neutral');
|
||||
});
|
||||
if (!list.length) csRow('', 'neutral');
|
||||
});
|
||||
|
||||
@@ -19,4 +19,9 @@
|
||||
if (kind === 'lab-edit') setMode(true);
|
||||
if (kind === 'lab-edit-cancel') setMode(false);
|
||||
});
|
||||
|
||||
/* Форма вернулась с ошибкой сервера — сразу открываем режим редактирования. */
|
||||
document.addEventListener('lms:form-restored', function (e) {
|
||||
if (e.detail && e.detail.form && e.detail.form.id === 'lab-edit-form') setMode(true);
|
||||
});
|
||||
})();
|
||||
|
||||
292
web/src/main/resources/static/js/form-feedback.js
Normal file
292
web/src/main/resources/static/js/form-feedback.js
Normal file
@@ -0,0 +1,292 @@
|
||||
/* Формы: восстановление введённых значений после ошибки сервера и показ ошибок
|
||||
рядом с конкретными полями. Сервер кладёт снимок в #form-feedback (data-feedback),
|
||||
форма находится по action; модалка с этой формой открывается автоматически. */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var FEEDBACK_ID = 'form-feedback';
|
||||
var INVALID = 'is-invalid';
|
||||
var FIELD_ERROR = 'field-error';
|
||||
var SUMMARY = 'form-error-summary';
|
||||
|
||||
function controls(form, name) {
|
||||
var out = [];
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
if (form.elements[i].name === name) out.push(form.elements[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
return path.length > 1 ? path.replace(/\/+$/, '') : path;
|
||||
}
|
||||
|
||||
function actionPath(form) {
|
||||
var action = form.getAttribute('action') || location.href;
|
||||
try {
|
||||
return normalizePath(new URL(action, location.href).pathname);
|
||||
} catch (e) {
|
||||
return normalizePath(action);
|
||||
}
|
||||
}
|
||||
|
||||
function sameValues(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (var i = 0; i < a.length; i++) {
|
||||
if (String(a[i]) !== String(b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function currentValues(els) {
|
||||
var out = [];
|
||||
els.forEach(function (el) {
|
||||
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||
if (el.checked) out.push(el.value);
|
||||
} else if (el.tagName === 'SELECT' && el.multiple) {
|
||||
for (var i = 0; i < el.options.length; i++) {
|
||||
if (el.options[i].selected) out.push(el.options[i].value);
|
||||
}
|
||||
} else {
|
||||
out.push(el.value);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function hasFileInput(form) {
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
if (form.elements[i].type === 'file') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/* Среди форм с тем же action выбираем ту, чьи значения похожи на отправленные:
|
||||
так различаются, например, модалки «добавить вариант» и «править вариант». */
|
||||
function formScore(form, values, errors) {
|
||||
var score = 0;
|
||||
var visibleMatches = 0;
|
||||
Object.keys(values || {}).forEach(function (name) {
|
||||
var els = controls(form, name);
|
||||
if (!els.length) return;
|
||||
var hidden = els.every(function (el) { return el.type === 'hidden'; });
|
||||
if (sameValues(currentValues(els), values[name] || [])) {
|
||||
score += hidden ? 2 : 1;
|
||||
if (!hidden) visibleMatches++;
|
||||
}
|
||||
});
|
||||
var errorMatches = 0;
|
||||
Object.keys(errors || {}).forEach(function (name) {
|
||||
if (controls(form, name).length) errorMatches++;
|
||||
});
|
||||
return { score: score, visibleMatches: visibleMatches, errorMatches: errorMatches };
|
||||
}
|
||||
|
||||
function hasEditableControl(form) {
|
||||
for (var i = 0; i < form.elements.length; i++) {
|
||||
var el = form.elements[i];
|
||||
if (el.disabled) continue;
|
||||
if (el.tagName === 'BUTTON' || el.type === 'hidden' || el.type === 'submit' ||
|
||||
el.type === 'button' || el.type === 'reset' || el.type === 'image') continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function findForm(feedback) {
|
||||
var forms = document.querySelectorAll('form');
|
||||
var target = normalizePath(feedback.action || '');
|
||||
var best = null;
|
||||
var bestScore = -1;
|
||||
for (var i = 0; i < forms.length; i++) {
|
||||
var form = forms[i];
|
||||
if ((form.getAttribute('method') || 'get').toLowerCase() !== 'post') continue;
|
||||
if (actionPath(form) !== target) continue;
|
||||
if (!hasEditableControl(form)) continue;
|
||||
var match = formScore(form, feedback.values, feedback.errors);
|
||||
// Не открываем случайную форму с тем же action, если в ней нет ни одного
|
||||
// совпавшего значения или поля с ошибкой. Исключение — загрузка файла:
|
||||
// файл нельзя восстановить, но модалку с ошибкой показать нужно.
|
||||
var acceptable = match.visibleMatches > 0 || match.errorMatches > 0 ||
|
||||
(hasFileInput(form) && !!feedback.general);
|
||||
if (!acceptable) continue;
|
||||
if (match.score > bestScore) {
|
||||
best = form;
|
||||
bestScore = match.score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function restoreValues(form, values) {
|
||||
Object.keys(values || {}).forEach(function (name) {
|
||||
var els = controls(form, name);
|
||||
if (!els.length) return;
|
||||
var stored = values[name] || [];
|
||||
els.forEach(function (el, index) {
|
||||
if (el.type === 'file' || el.type === 'password' || el.disabled) return;
|
||||
if (el.type === 'checkbox') {
|
||||
el.checked = stored.indexOf(el.value) >= 0 ||
|
||||
(el.value === 'on' && stored.length > 0);
|
||||
} else if (el.type === 'radio') {
|
||||
el.checked = stored.indexOf(el.value) >= 0;
|
||||
} else if (el.tagName === 'SELECT' && el.multiple) {
|
||||
for (var i = 0; i < el.options.length; i++) {
|
||||
el.options[i].selected = stored.indexOf(el.options[i].value) >= 0;
|
||||
}
|
||||
} else {
|
||||
var value = els.length > 1
|
||||
? (stored[index] !== undefined ? stored[index] : '')
|
||||
: (stored[0] || '');
|
||||
if (el.tagName === 'SELECT' && !hasOption(el, value)) return;
|
||||
el.value = value;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function hasOption(select, value) {
|
||||
for (var i = 0; i < select.options.length; i++) {
|
||||
if (select.options[i].value === value) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function errorHost(el) {
|
||||
if (el.type === 'checkbox' || el.type === 'radio') {
|
||||
return el.closest('label') || el;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function clearFieldErrors(form, name) {
|
||||
if (!form) return;
|
||||
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) {
|
||||
if (node.getAttribute('data-field') === name) node.remove();
|
||||
});
|
||||
controls(form, name).forEach(function (el) {
|
||||
el.classList.remove(INVALID);
|
||||
el.removeAttribute('aria-invalid');
|
||||
});
|
||||
}
|
||||
|
||||
function showFieldError(el, message) {
|
||||
var form = el.form;
|
||||
if (form) clearFieldErrors(form, el.name);
|
||||
el.classList.add(INVALID);
|
||||
el.setAttribute('aria-invalid', 'true');
|
||||
var error = document.createElement('p');
|
||||
error.className = FIELD_ERROR;
|
||||
error.setAttribute('data-field', el.name);
|
||||
error.textContent = message;
|
||||
errorHost(el).insertAdjacentElement('afterend', error);
|
||||
}
|
||||
|
||||
function showSummary(form, message) {
|
||||
var old = form.querySelector('.' + SUMMARY);
|
||||
if (old) old.remove();
|
||||
if (!message) return;
|
||||
var box = document.createElement('div');
|
||||
box.className = SUMMARY;
|
||||
box.setAttribute('role', 'alert');
|
||||
box.textContent = message;
|
||||
form.insertBefore(box, form.firstChild);
|
||||
}
|
||||
|
||||
function focusFirstInvalid(form) {
|
||||
var el = form.querySelector('.' + INVALID);
|
||||
if (!el || typeof el.focus !== 'function') return;
|
||||
try {
|
||||
el.focus({ preventScroll: true });
|
||||
} catch (e) {
|
||||
el.focus();
|
||||
}
|
||||
if (el.scrollIntoView) el.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function parseFeedback() {
|
||||
var holder = document.getElementById(FEEDBACK_ID);
|
||||
if (!holder) return null;
|
||||
var raw = holder.getAttribute('data-feedback');
|
||||
holder.remove();
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFeedback(feedback) {
|
||||
if (!feedback || !feedback.action) return;
|
||||
var form = findForm(feedback);
|
||||
if (!form) return;
|
||||
|
||||
restoreValues(form, feedback.values);
|
||||
|
||||
if (form.hidden) form.hidden = false;
|
||||
var dialog = form.closest('dialog');
|
||||
if (dialog && !dialog.open) {
|
||||
if (typeof dialog.showModal === 'function') dialog.showModal();
|
||||
else dialog.setAttribute('open', '');
|
||||
}
|
||||
|
||||
var errors = feedback.errors || {};
|
||||
Object.keys(errors).forEach(function (name) {
|
||||
var els = controls(form, name);
|
||||
if (els.length) showFieldError(els[0], errors[name]);
|
||||
});
|
||||
showSummary(form, feedback.general);
|
||||
|
||||
document.dispatchEvent(new CustomEvent('lms:form-restored', {
|
||||
detail: { form: form, feedback: feedback }
|
||||
}));
|
||||
setTimeout(function () { focusFirstInvalid(form); }, 0);
|
||||
}
|
||||
|
||||
/* Встроенная валидация: вместо браузерных пузырей — сообщение под полем. */
|
||||
function validationMessage(el) {
|
||||
var v = el.validity || {};
|
||||
if (v.valueMissing) {
|
||||
if (el.type === 'file') return 'Выберите файл';
|
||||
if (el.tagName === 'SELECT') return 'Выберите значение';
|
||||
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
|
||||
return 'Заполните поле';
|
||||
}
|
||||
if (v.typeMismatch) return el.type === 'email' ? 'Введите корректный e-mail' : 'Неверный формат';
|
||||
if (v.patternMismatch) return el.getAttribute('data-error-pattern') || 'Неверный формат';
|
||||
if (v.tooShort) return 'Минимум ' + el.minLength + ' символов';
|
||||
if (v.tooLong) return 'Максимум ' + el.maxLength + ' символов';
|
||||
if (v.rangeUnderflow) return 'Значение не меньше ' + el.min;
|
||||
if (v.rangeOverflow) return 'Значение не больше ' + el.max;
|
||||
if (v.stepMismatch) return 'Недопустимое значение';
|
||||
if (v.badInput) return 'Введите число';
|
||||
return el.validationMessage || 'Проверьте значение';
|
||||
}
|
||||
|
||||
document.addEventListener('invalid', function (event) {
|
||||
var el = event.target;
|
||||
if (!el || !el.form) return;
|
||||
event.preventDefault();
|
||||
showFieldError(el, validationMessage(el));
|
||||
if (!el.form.querySelector('.' + SUMMARY)) showSummary(el.form, 'Проверьте выделенные поля');
|
||||
setTimeout(function () { focusFirstInvalid(el.form); }, 0);
|
||||
}, true);
|
||||
|
||||
function clearOnEdit(el) {
|
||||
if (!el || !el.form || !el.name) return;
|
||||
clearFieldErrors(el.form, el.name);
|
||||
if (!el.form.querySelector('.' + INVALID)) {
|
||||
var summary = el.form.querySelector('.' + SUMMARY);
|
||||
if (summary) summary.remove();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
|
||||
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
|
||||
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
|
||||
});
|
||||
})();
|
||||
@@ -140,4 +140,11 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.location.search.indexOf('edit=1') >= 0) window.startArticleEdit();
|
||||
});
|
||||
|
||||
/* Форма вернулась с ошибкой сервера — сразу открываем режим правки. */
|
||||
document.addEventListener('lms:form-restored', function (event) {
|
||||
if (event.detail && event.detail.form && event.detail.form.id === 'article-edit') {
|
||||
window.startArticleEdit();
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -345,7 +345,7 @@
|
||||
<div class="dlg-body" id="assign-modal-body"></div>
|
||||
</dialog>
|
||||
|
||||
<script th:src="@{/js/admin-group.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/admin-group.js(v='37')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
</dialog>
|
||||
</th:block>
|
||||
</div>
|
||||
<script th:src="@{/js/admin-lab.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/admin-lab.js(v='37')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</noscript>
|
||||
<link rel="icon" type="image/svg+xml" th:href="@{/favicon.svg(v='36')}">
|
||||
<!-- v=35: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
|
||||
<!-- v=37: сброс кэша стилей после изменения геометрии; при изменении css поднимать версию -->
|
||||
<link rel="stylesheet" th:href="@{/css/bootstrap.min.css}">
|
||||
<link rel="stylesheet" th:href="@{/css/bootstrap-icons.min.css}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css(v='36')}">
|
||||
<link rel="stylesheet" th:href="@{/css/app.css(v='37')}">
|
||||
<link id="hljs-light" rel="stylesheet" th:href="@{/css/hljs-github.css(v='36')}">
|
||||
<link id="hljs-dark" rel="stylesheet" th:href="@{/css/hljs-github-dark.css(v='36')}" disabled>
|
||||
<script th:src="@{/js/highlight.min.js(v='36')}" defer></script>
|
||||
@@ -27,6 +27,7 @@
|
||||
<script th:src="@{/js/theme-init.js(v='36')}"></script>
|
||||
<script th:src="@{/js/app.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/ui-state.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/form-feedback.js(v='37')}" defer></script>
|
||||
<script th:src="@{/js/md-content.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/submit-guard.js(v='36')}" defer></script>
|
||||
</head>
|
||||
@@ -77,6 +78,7 @@
|
||||
LMS v1.1.0
|
||||
</footer>
|
||||
</div>
|
||||
<div id="form-feedback" hidden th:if="${formFeedbackJson != null}" th:attr="data-feedback=${formFeedbackJson}"></div>
|
||||
<div class="toast-host" id="toast-host" aria-live="polite">
|
||||
<div th:if="${notice}" class="toast-item toast-success" data-toast>
|
||||
<span class="toast-text" th:text="${notice}">…</span>
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
</div>
|
||||
|
||||
|
||||
<script th:src="@{/js/mdeditor.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/mdeditor.js(v='37')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -236,7 +236,7 @@
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<script th:src="@{/js/mdeditor.js(v='36')}" defer></script>
|
||||
<script th:src="@{/js/mdeditor.js(v='37')}" defer></script>
|
||||
<script th:src="@{/js/content-sort.js(v='36')}" defer></script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
<input type="hidden" name="_csrf" th:value="${csrfToken}">
|
||||
<input type="hidden" name="labId" th:value="${lab.id}">
|
||||
<input class="input w-260" name="code" placeholder="6-значный код" maxlength="6" pattern="[0-9]{6}"
|
||||
data-error-pattern="Введите 6 цифр"
|
||||
inputmode="numeric" autocomplete="one-time-code" required>
|
||||
<button class="btn btn-primary" type="submit">Приступить</button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user