From d287dd3119fd3c83ded32e88925e6e90d6f328f1 Mon Sep 17 00:00:00 2001 From: Vladislav Kopnin Date: Thu, 22 Oct 2026 22:41:35 +0300 Subject: [PATCH] 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 --- .gitignore | 2 + .../ysu/lms/web/admin/AdminGroupController.kt | 10 +- .../ysu/lms/web/admin/AdminLabController.kt | 10 +- .../lms/web/admin/ContentAdminController.kt | 56 ++-- .../kotlin/ru/ysu/lms/web/api/ApiClient.kt | 68 +++- .../kotlin/ru/ysu/lms/web/api/ApiException.kt | 13 +- .../lms/web/config/FormFeedbackInterceptor.kt | 38 +++ .../kotlin/ru/ysu/lms/web/config/WebConfig.kt | 2 + .../ru/ysu/lms/web/form/FormFeedback.kt | 129 ++++++++ web/src/main/resources/static/css/app.css | 13 + .../main/resources/static/js/admin-group.js | 42 ++- web/src/main/resources/static/js/admin-lab.js | 5 + .../main/resources/static/js/form-feedback.js | 292 ++++++++++++++++++ web/src/main/resources/static/js/mdeditor.js | 7 + .../main/resources/templates/admin/group.html | 2 +- .../main/resources/templates/admin/lab.html | 2 +- .../resources/templates/fragments/base.html | 6 +- .../resources/templates/learn/article.html | 2 +- .../main/resources/templates/learn/index.html | 2 +- .../main/resources/templates/student/lab.html | 1 + 20 files changed, 636 insertions(+), 66 deletions(-) create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptor.kt create mode 100644 web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt create mode 100644 web/src/main/resources/static/js/form-feedback.js diff --git a/.gitignore b/.gitignore index 618fe7f..1422e8b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ build/ .env.prod /data/ node_modules/ +# Локальные сессии Kilo Code — не часть репозитория. +.kilo/ # Каталог с боевыми данными и паролями (не коммитить!) examples/ __pycache__/ diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt index 8eef69b..3fd6d23 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminGroupController.kt @@ -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( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt index 91df8cc..e5202fd 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/AdminLabController.kt @@ -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( diff --git a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt index c5cdffb..0cb171b 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/admin/ContentAdminController.kt @@ -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") } /** Возврат туда, откуда пришли: принимаем только внутренние пути. */ diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt index 385bb8a..3e0df22 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiClient.kt @@ -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, + ) + internal fun downloadOf(entity: org.springframework.http.ResponseEntity): DownloadDto { val disposition = entity.headers.getFirst(HttpHeaders.CONTENT_DISPOSITION).orEmpty() val rawName = disposition.substringAfter("UTF-8''", "").substringBefore(';').ifBlank { "submission.zip" } diff --git a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiException.kt b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiException.kt index 0615f4b..36bbf89 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/api/ApiException.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/api/ApiException.kt @@ -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 = emptyList(), + cause: Throwable? = null, +) : RuntimeException(message, cause) { + init { + // Пока запрос ещё привязан к потоку, запоминаем введённые значения и ошибки: + // контроллеры ниже превратят это в flash/модель для повторного показа формы. + FormFeedback.capture(this) + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptor.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptor.kt new file mode 100644 index 0000000..6a33aba --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/FormFeedbackInterceptor.kt @@ -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) + } + } +} diff --git a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt index bbc8865..f8b7fcb 100644 --- a/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt +++ b/web/src/main/kotlin/ru/ysu/lms/web/config/WebConfig.kt @@ -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("/**") diff --git a/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt new file mode 100644 index 0000000..a21efe2 --- /dev/null +++ b/web/src/main/kotlin/ru/ysu/lms/web/form/FormFeedback.kt @@ -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>, + val errors: Map, + 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, + ) + + /** + * Сообщения доменных исключений часто не привязаны к полю. Небольшие правила + * помогают показать их рядом с конкретным 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>() + 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() + 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>, + ): List> { + 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() + } + } +} diff --git a/web/src/main/resources/static/css/app.css b/web/src/main/resources/static/css/app.css index 179e80e..53fef0a 100644 --- a/web/src/main/resources/static/css/app.css +++ b/web/src/main/resources/static/css/app.css @@ -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, кнопки, ссылки ===== */ diff --git a/web/src/main/resources/static/js/admin-group.js b/web/src/main/resources/static/js/admin-group.js index 1170d81..c3c2321 100644 --- a/web/src/main/resources/static/js/admin-group.js +++ b/web/src/main/resources/static/js/admin-group.js @@ -684,9 +684,49 @@ document.addEventListener('submit', function (ev) { // flash-сообщение забираем из отданного HTML var err = /]*class="[^"]*toast-item toast-error[^"]*"[^>]*>\s*([^<]*)([^<]*)<\/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'); +}); diff --git a/web/src/main/resources/static/js/admin-lab.js b/web/src/main/resources/static/js/admin-lab.js index 423359b..84e545c 100644 --- a/web/src/main/resources/static/js/admin-lab.js +++ b/web/src/main/resources/static/js/admin-lab.js @@ -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); + }); })(); diff --git a/web/src/main/resources/static/js/form-feedback.js b/web/src/main/resources/static/js/form-feedback.js new file mode 100644 index 0000000..71d7f33 --- /dev/null +++ b/web/src/main/resources/static/js/form-feedback.js @@ -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); + }); +})(); diff --git a/web/src/main/resources/static/js/mdeditor.js b/web/src/main/resources/static/js/mdeditor.js index 970a28a..704f39f 100644 --- a/web/src/main/resources/static/js/mdeditor.js +++ b/web/src/main/resources/static/js/mdeditor.js @@ -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(); + } + }); })(); diff --git a/web/src/main/resources/templates/admin/group.html b/web/src/main/resources/templates/admin/group.html index 62df6f2..aefeda4 100644 --- a/web/src/main/resources/templates/admin/group.html +++ b/web/src/main/resources/templates/admin/group.html @@ -345,7 +345,7 @@
- + diff --git a/web/src/main/resources/templates/admin/lab.html b/web/src/main/resources/templates/admin/lab.html index 5075122..d7cced6 100644 --- a/web/src/main/resources/templates/admin/lab.html +++ b/web/src/main/resources/templates/admin/lab.html @@ -196,7 +196,7 @@ - + diff --git a/web/src/main/resources/templates/fragments/base.html b/web/src/main/resources/templates/fragments/base.html index e8218ff..9c194b6 100644 --- a/web/src/main/resources/templates/fragments/base.html +++ b/web/src/main/resources/templates/fragments/base.html @@ -14,10 +14,10 @@ - + - + @@ -27,6 +27,7 @@ + @@ -77,6 +78,7 @@ LMS v1.1.0 +
… diff --git a/web/src/main/resources/templates/learn/article.html b/web/src/main/resources/templates/learn/article.html index 298b724..924e2ad 100644 --- a/web/src/main/resources/templates/learn/article.html +++ b/web/src/main/resources/templates/learn/article.html @@ -138,7 +138,7 @@
- +
diff --git a/web/src/main/resources/templates/learn/index.html b/web/src/main/resources/templates/learn/index.html index 1dfacf2..7076c7c 100644 --- a/web/src/main/resources/templates/learn/index.html +++ b/web/src/main/resources/templates/learn/index.html @@ -236,7 +236,7 @@ - + diff --git a/web/src/main/resources/templates/student/lab.html b/web/src/main/resources/templates/student/lab.html index 492505c..c2ae1de 100644 --- a/web/src/main/resources/templates/student/lab.html +++ b/web/src/main/resources/templates/student/lab.html @@ -93,6 +93,7 @@