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

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>