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