fix: validate grade input and save modal without reload

- require a score and return a clear message for empty/non-numeric values
- handle unreadable API request bodies instead of falling back to a bare 400
- submit the grade modal via fetch, show errors in place and update the cell
- keep the page scroll position; restore the form feedback tests
This commit is contained in:
2026-10-22 23:29:47 +03:00
parent d287dd3119
commit 2e43f79256
10 changed files with 305 additions and 7 deletions

View File

@@ -615,8 +615,95 @@ function focusLab() {
});
}
/* ---------- Оценка: отправка без перезагрузки страницы ----------
* Модалка не закрывается при ошибке, значения остаются на месте,
* позиция прокрутки страницы не меняется. */
function submitGradeForm(form) {
if (form.dataset.saving === '1') return;
form.dataset.saving = '1';
var btn = form.querySelector('button[type="submit"]');
if (btn) btn.disabled = true;
fetch(form.action, {
method: 'POST',
body: new FormData(form),
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.text();
})
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
// Сервер вернул форму с ошибкой — показываем её без перезагрузки.
var feedbackNode = doc.getElementById('form-feedback');
if (feedbackNode) {
var feedback = null;
try {
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
} catch (e) {
feedback = null;
}
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
window.lmsApplyFormFeedback(feedback);
}
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
showToast(errorToast
? errorToast.textContent.trim()
: ((feedback && feedback.general) || 'Не удалось сохранить оценку'), 'error');
return;
}
var student = form.querySelector('[name="studentId"]').value;
var lab = form.querySelector('[name="labId"]').value;
var fresh = doc.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var current = document.querySelector('.score-link[data-student="' + student + '"][data-lab="' + lab + '"]');
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
var errorWithoutFeedback = doc.querySelector('.toast-item.toast-error .toast-text');
if (errorWithoutFeedback) {
showToast(errorWithoutFeedback.textContent.trim(), 'error');
return;
}
if (!fresh && !notice) {
showToast('Не удалось сохранить оценку. Обновите страницу и попробуйте ещё раз.', 'error');
return;
}
if (fresh && current) {
['data-score', 'data-general-comment', 'data-comments', 'title'].forEach(function (attr) {
if (fresh.hasAttribute(attr)) current.setAttribute(attr, fresh.getAttribute(attr));
else current.removeAttribute(attr);
});
var freshLabel = fresh.querySelector('span');
var currentLabel = current.querySelector('span');
if (freshLabel && currentLabel) currentLabel.textContent = freshLabel.textContent;
current.classList.toggle('none', fresh.classList.contains('none'));
}
var openDialog = form.closest('dialog');
if (openDialog) {
var staleToasts = openDialog.querySelector('.toast-host');
if (staleToasts) staleToasts.remove();
}
if (window.closeModal) window.closeModal('modal-grade');
showToast(notice ? notice.textContent.trim() : 'Оценка сохранена', 'success');
})
.catch(function (e) {
showToast('Не удалось сохранить оценку: ' + e.message, 'error');
})
.finally(function () {
form.dataset.saving = '';
if (btn) btn.disabled = false;
});
}
document.addEventListener('submit', function (ev) {
if (ev.target && ev.target.id === 'grade-form') csSerialize();
if (!ev.target || ev.target.id !== 'grade-form') return;
csSerialize();
ev.preventDefault();
submitGradeForm(ev.target);
}, true);
window.addEventListener('DOMContentLoaded', function () {

View File

@@ -193,6 +193,16 @@
form.insertBefore(box, form.firstChild);
}
function clearAllErrors(form) {
form.querySelectorAll('.' + FIELD_ERROR).forEach(function (node) { node.remove(); });
form.querySelectorAll('.' + INVALID).forEach(function (el) {
el.classList.remove(INVALID);
el.removeAttribute('aria-invalid');
});
var summary = form.querySelector('.' + SUMMARY);
if (summary) summary.remove();
}
function focusFirstInvalid(form) {
var el = form.querySelector('.' + INVALID);
if (!el || typeof el.focus !== 'function') return;
@@ -223,6 +233,7 @@
if (!form) return;
restoreValues(form, feedback.values);
clearAllErrors(form);
if (form.hidden) form.hidden = false;
var dialog = form.closest('dialog');
@@ -248,6 +259,8 @@
function validationMessage(el) {
var v = el.validity || {};
if (v.valueMissing) {
var requiredMessage = el.getAttribute('data-error-required');
if (requiredMessage) return requiredMessage;
if (el.type === 'file') return 'Выберите файл';
if (el.tagName === 'SELECT') return 'Выберите значение';
if (el.type === 'checkbox' || el.type === 'radio') return 'Отметьте этот пункт';
@@ -285,6 +298,9 @@
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
window.lmsApplyFormFeedback = applyFeedback;
document.addEventListener('DOMContentLoaded', function () {
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);

View File

@@ -159,7 +159,8 @@
<input type="hidden" name="commentsJson" id="grade-comments" value="[]">
<div><label class="label">Студент</label><input class="input" id="grade-who" readonly></div>
<div><label class="label" for="grade-score">Балл (0–5)</label>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1"></div>
<input class="input" id="grade-score" name="score" type="number" min="0" max="5" step="0.1" required
data-error-required="Укажите оценку"></div>
<div><label class="label" for="grade-general">Общий комментарий по лабораторной</label>
<textarea class="input" id="grade-general" name="generalComment" rows="4"
placeholder="Итоговый комментарий по работе в целом…"></textarea></div>
@@ -345,7 +346,7 @@
<div class="dlg-body" id="assign-modal-body"></div>
</dialog>
<script th:src="@{/js/admin-group.js(v='37')}" defer></script>
<script th:src="@{/js/admin-group.js(v='38')}" defer></script>
</div>
</body>
</html>

View File

@@ -27,7 +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/form-feedback.js(v='38')}" 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>