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:
2026-10-22 22:41:35 +03:00
parent 3827955909
commit d287dd3119
20 changed files with 636 additions and 66 deletions

2
.gitignore vendored
View File

@@ -7,6 +7,8 @@ build/
.env.prod
/data/
node_modules/
# Локальные сессии Kilo Code — не часть репозитория.
.kilo/
# Каталог с боевыми данными и паролями (не коммитить!)
examples/
__pycache__/

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,11 +61,10 @@ 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). */
@@ -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")
}
/** Возврат туда, откуда пришли: принимаем только внутренние пути. */

View File

@@ -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 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)
val parsed =
match
return match
?.groupValues
?.get(1)
?.replace("\\\"", "\"")
?.replace("\\n", "\n")
return parsed?.takeIf { it.isNotBlank() } ?: "Ошибка запроса (${ex.statusCode.value()})"
?.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" }

View File

@@ -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)
}
}

View File

@@ -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)
}
}
}

View File

@@ -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("/**")

View 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()
}
}
}

View File

@@ -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, кнопки, ссылки ===== */

View File

@@ -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');
});

View File

@@ -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);
});
})();

View 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);
});
})();

View File

@@ -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();
}
});
})();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>