feat: submit all modal forms without page reload

- add a generic AJAX submitter for forms inside dialogs
- keep modal values and show field errors on server validation failure
- soft-refresh the page content on success, preserving scroll, search and filters
- share toast and re-init hooks for tables, markdown, sections and group page
- require passwords and add minlength/match validation on auth forms
This commit is contained in:
2026-10-23 01:22:05 +03:00
parent 2e43f79256
commit e22443b08e
11 changed files with 351 additions and 59 deletions

View File

@@ -208,43 +208,7 @@ function attachCommentAutocomplete(input) {
});
}
/* ---------- Удаление подсказки комментария и всплывающие уведомления ---------- */
function showToast(text, kind) {
// уведомления должны быть видны поверх открытой модалки: кладём их в сам <dialog>
// (элемент верхнего слоя), иначе они оказываются под ним
var dlg = document.querySelector('dialog[open]');
var host = null;
if (dlg) {
host = dlg.querySelector('.toast-host');
if (!host) {
host = document.createElement('div');
host.className = 'toast-host';
dlg.appendChild(host);
}
} else {
host = $id('toast-host');
}
if (!host) return;
var toast = document.createElement('div');
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
toast.setAttribute('data-toast', '');
var span = document.createElement('span');
span.className = 'toast-text';
span.textContent = text;
toast.appendChild(span);
var close = document.createElement('button');
close.type = 'button';
close.className = 'toast-close';
close.textContent = '✕';
close.addEventListener('click', function () { toast.remove(); });
toast.appendChild(close);
host.appendChild(toast);
setTimeout(function () { toast.remove(); }, 5000);
}
/* ---------- Удаление подсказки комментария ---------- */
function removeCommentTemplate(text, done) {
var form = $id('grade-form');
@@ -345,6 +309,8 @@ function updateEyeCounts() {
var DEFENSE_STATE = {};
var DEFENSE_POLL_MS = 10000;
var DEFENSE_TICK_MS = 1000;
var DEFENSE_POLL_TIMER = null;
var DEFENSE_TICK_TIMER = null;
function defenseBtn(sessionId) {
if (!sessionId) return null;
@@ -422,6 +388,14 @@ function syncDefenseStatuses(sessions, initial) {
}
function startDefenseWatch() {
if (DEFENSE_POLL_TIMER) {
clearInterval(DEFENSE_POLL_TIMER);
DEFENSE_POLL_TIMER = null;
}
if (DEFENSE_TICK_TIMER) {
clearInterval(DEFENSE_TICK_TIMER);
DEFENSE_TICK_TIMER = null;
}
var meta = $id('defense-meta');
if (!meta) return;
var labs = meta.getAttribute('data-labs') || '';
@@ -442,8 +416,8 @@ function startDefenseWatch() {
}
poll();
setInterval(poll, DEFENSE_POLL_MS);
setInterval(function () {
DEFENSE_POLL_TIMER = setInterval(poll, DEFENSE_POLL_MS);
DEFENSE_TICK_TIMER = setInterval(function () {
Object.keys(DEFENSE_STATE).forEach(function (id) {
var s = DEFENSE_STATE[id];
if (s.status === 'started') applyDefenseToCell(s);
@@ -706,11 +680,13 @@ document.addEventListener('submit', function (ev) {
submitGradeForm(ev.target);
}, true);
window.addEventListener('DOMContentLoaded', function () {
function initGroupPage() {
focusLab();
startDefenseWatch();
attachPublishToggles();
});
}
window.__lmsInitGroupPage = initGroupPage;
window.addEventListener('DOMContentLoaded', initGroupPage);
/* ---------- Большая модалка «Выдача вариантов» ---------- */

View File

@@ -75,6 +75,7 @@
function initSections() {
document.querySelectorAll('.sub-acc[data-section-id]').forEach(applySectionState);
}
window.__lmsInitSections = initSections;
window.toggleSection = function (btn) {
var acc = btn.closest('.sub-acc');
if (!acc) return;
@@ -114,6 +115,39 @@
});
}
/* Программные уведомления: показываем поверх открытой модалки, если она есть. */
window.showToast = function (text, kind) {
var dialog = document.querySelector('dialog[open]');
var host = null;
if (dialog) {
host = dialog.querySelector('.toast-host');
if (!host) {
host = document.createElement('div');
host.className = 'toast-host';
dialog.appendChild(host);
}
} else {
host = document.getElementById('toast-host');
}
if (!host) return;
var toast = document.createElement('div');
toast.className = 'toast-item ' + (kind === 'error' ? 'toast-error' : 'toast-success');
toast.setAttribute('data-toast', '');
var textEl = document.createElement('span');
textEl.className = 'toast-text';
textEl.textContent = text;
toast.appendChild(textEl);
var close = document.createElement('button');
close.type = 'button';
close.className = 'toast-close';
close.textContent = '✕';
close.addEventListener('click', function () { toast.remove(); });
toast.appendChild(close);
host.appendChild(toast);
setTimeout(function () { toast.remove(); }, 5000);
};
document.addEventListener('DOMContentLoaded', function () {
window.__hljsSet(document.documentElement.getAttribute('data-theme'));
window.__hljsAll();

View File

@@ -256,6 +256,14 @@
}
/* Встроенная валидация: вместо браузерных пузырей — сообщение под полем. */
function symbolWord(count) {
var last = count % 10;
var lastTwo = count % 100;
if (last === 1 && lastTwo !== 11) return 'символ';
if (last >= 2 && last <= 4 && (lastTwo < 12 || lastTwo > 14)) return 'символа';
return 'символов';
}
function validationMessage(el) {
var v = el.validity || {};
if (v.valueMissing) {
@@ -268,8 +276,8 @@
}
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.tooShort) return 'Минимум ' + el.minLength + ' ' + symbolWord(el.minLength);
if (v.tooLong) return 'Максимум ' + el.maxLength + ' ' + symbolWord(el.maxLength);
if (v.rangeUnderflow) return 'Значение не меньше ' + el.min;
if (v.rangeOverflow) return 'Значение не больше ' + el.max;
if (v.stepMismatch) return 'Недопустимое значение';
@@ -295,13 +303,49 @@
}
}
document.addEventListener('input', function (event) { clearOnEdit(event.target); }, true);
document.addEventListener('change', function (event) { clearOnEdit(event.target); }, true);
/* Проверка совпадения значений (например, повтор пароля). */
function checkMatch(el) {
var selector = el.getAttribute('data-match');
if (!selector) return;
var other = null;
try {
other = document.querySelector(selector);
} catch (e) {
return;
}
if (!other) return;
var message = el.getAttribute('data-error-match') || 'Значения не совпадают';
if (el.value && other.value && el.value !== other.value) {
el.setCustomValidity(message);
} else {
el.setCustomValidity('');
}
}
function recheckMatches(source) {
document.querySelectorAll('[data-match]').forEach(function (el) {
try {
if (source.matches(el.getAttribute('data-match'))) checkMatch(el);
} catch (e) { /* некорректный селектор в разметке — пропускаем */ }
});
}
document.addEventListener('input', function (event) {
checkMatch(event.target);
recheckMatches(event.target);
clearOnEdit(event.target);
}, true);
document.addEventListener('change', function (event) {
checkMatch(event.target);
recheckMatches(event.target);
clearOnEdit(event.target);
}, true);
// Для AJAX-отправки форм: показать ответ сервера без перезагрузки страницы.
window.lmsApplyFormFeedback = applyFeedback;
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('[data-match]').forEach(checkMatch);
// после ui-state.js: сначала восстанавливаем прокрутку, затем открываем модалку
setTimeout(function () { applyFeedback(parseFeedback()); }, 0);
});

View File

@@ -70,6 +70,7 @@
function ready() {
document.querySelectorAll('.md-body pre').forEach(enhance);
}
window.__lmsInitMdContent = ready;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready);

View File

@@ -0,0 +1,227 @@
/* Формы внутри модалок: отправка через fetch без перезагрузки страницы.
Ошибка показывается в самой форме, успех — закрывает модалку и мягко обновляет
содержимое страницы (позиция прокрутки и фильтры сохраняются). */
(function () {
'use strict';
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 isModalForm(form) {
if (!form || !form.closest('dialog')) return false;
if (form.classList.contains('js-assign-form')) return false;
if (form.id === 'grade-form') return false; // у оценки своя быстрая AJAX-логика
if (form.hasAttribute('data-no-ajax')) return false;
if ((form.getAttribute('method') || 'post').toLowerCase() !== 'post') return false;
return hasEditableControl(form);
}
function currentScrollY() {
if (typeof window.__pageScrollY === 'number') return window.__pageScrollY;
return window.scrollY || window.pageYOffset || 0;
}
function closeFormDialog(form) {
var dialog = form.closest('dialog');
if (dialog) {
if (window.closeModal && dialog.id) {
window.closeModal(dialog.id);
} else {
dialog.close();
}
}
// На всякий случай закрываем и остальные диалоги, чтобы не остался scroll-lock.
document.querySelectorAll('dialog[open]').forEach(function (open) {
if (open === dialog) return;
if (window.closeModal && open.id) window.closeModal(open.id);
else open.close();
});
}
function captureUiState() {
var searches = [];
document.querySelectorAll('input.table-search').forEach(function (input, index) {
searches.push({ index: index, value: input.value });
});
var filters = [];
document.querySelectorAll('table.tablekit').forEach(function (table, index) {
var values = [];
table.querySelectorAll('.filter-control').forEach(function (control) {
values.push(control.value);
});
filters.push({ index: index, values: values });
});
var labFocus = document.getElementById('lab-focus');
return {
searches: searches,
filters: filters,
labFocus: labFocus ? labFocus.value : null
};
}
function restoreUiState(state) {
if (state.labFocus != null) {
var focus = document.getElementById('lab-focus');
if (focus) focus.value = state.labFocus;
}
var searches = document.querySelectorAll('input.table-search');
state.searches.forEach(function (item) {
var input = searches[item.index];
if (!input) return;
input.value = item.value;
input.dispatchEvent(new Event('input', { bubbles: true }));
});
var tables = document.querySelectorAll('table.tablekit');
state.filters.forEach(function (item) {
var table = tables[item.index];
if (!table) return;
var controls = table.querySelectorAll('.filter-control');
item.values.forEach(function (value, index) {
var control = controls[index];
if (!control) return;
control.value = value;
control.dispatchEvent(new Event(control.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
});
});
}
function initContentModules() {
if (window.__lmsInitTableScroll) window.__lmsInitTableScroll();
if (window.__lmsInitTablekit) window.__lmsInitTablekit();
if (window.__lmsInitMdContent) window.__lmsInitMdContent();
if (window.__lmsInitSections) window.__lmsInitSections();
if (window.__hljsAll) window.__hljsAll();
}
function initPageSpecific() {
if (window.__lmsInitGroupPage) window.__lmsInitGroupPage();
if (window.startArticleEdit && location.search.indexOf('edit=1') >= 0) {
window.startArticleEdit();
}
}
function applyServerError(form, doc, feedbackNode) {
var feedback = null;
if (feedbackNode) {
try {
feedback = JSON.parse(feedbackNode.getAttribute('data-feedback') || 'null');
} catch (e) {
feedback = null;
}
}
if (feedback && typeof window.lmsApplyFormFeedback === 'function') {
window.lmsApplyFormFeedback(feedback);
}
var dialog = form.closest('dialog');
if (dialog) {
var oldToasts = dialog.querySelector('.toast-host');
if (oldToasts) oldToasts.remove();
}
var toast = doc.querySelector('.toast-item.toast-error .toast-text');
window.showToast(
toast
? toast.textContent.trim()
: ((feedback && feedback.general) || 'Не удалось сохранить изменения'),
'error'
);
}
function refreshSamePage(form, doc, target, scrollY, successText) {
var main = document.querySelector('main');
var nextMain = doc.querySelector('main');
if (!main || !nextMain) {
closeFormDialog(form);
window.location.assign(target.href);
return;
}
var uiState = captureUiState();
closeFormDialog(form);
main.innerHTML = nextMain.innerHTML;
document.title = doc.title;
if (location.pathname === target.pathname && location.search !== target.search) {
history.replaceState(history.state, '', target.href);
}
initContentModules();
restoreUiState(uiState);
initPageSpecific();
window.scrollTo(0, scrollY);
requestAnimationFrame(function () { window.scrollTo(0, scrollY); });
window.showToast(successText || 'Сохранено', 'success');
}
function send(form) {
if (form.dataset.ajaxSaving === '1') return;
form.dataset.ajaxSaving = '1';
var buttons = form.querySelectorAll('button[type="submit"], input[type="submit"]');
buttons.forEach(function (button) { button.disabled = true; });
var scrollY = currentScrollY();
fetch(form.action, {
method: (form.getAttribute('method') || 'post').toUpperCase(),
body: new FormData(form),
headers: { 'X-Requested-With': 'fetch' }
})
.then(function (response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text().then(function (html) {
return { html: html, url: response.url };
});
})
.then(function (response) {
var doc = new DOMParser().parseFromString(response.html, 'text/html');
var target = new URL(response.url, location.href);
var feedbackNode = doc.getElementById('form-feedback');
if (feedbackNode) {
applyServerError(form, doc, feedbackNode);
return;
}
// Ошибка без формы (например, истекла сессия) или успех с переходом
// на другую страницу — обычная навигация.
if (target.pathname !== location.pathname) {
closeFormDialog(form);
window.location.assign(target.href);
return;
}
var errorToast = doc.querySelector('.toast-item.toast-error .toast-text');
if (errorToast) {
window.showToast(errorToast.textContent.trim(), 'error');
return;
}
var notice = doc.querySelector('.toast-item.toast-success .toast-text');
var successText = notice ? notice.textContent.trim() : (form.getAttribute('data-success') || '');
refreshSamePage(form, doc, target, scrollY, successText);
})
.catch(function (error) {
window.showToast('Не удалось сохранить изменения: ' + error.message, 'error');
})
.finally(function () {
form.dataset.ajaxSaving = '';
buttons.forEach(function (button) { button.disabled = false; });
});
}
document.addEventListener('submit', function (event) {
var form = event.target;
if (!isModalForm(form)) return;
if (event.defaultPrevented) return;
event.preventDefault();
send(form);
}, true);
})();

View File

@@ -14,6 +14,7 @@
function ready() {
document.querySelectorAll('table').forEach(wrap);
}
window.__lmsInitTableScroll = ready;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready);

View File

@@ -192,8 +192,15 @@
});
}
document.addEventListener('DOMContentLoaded', function () {
function init() {
document.querySelectorAll('table.tablekit').forEach(bind);
document.querySelectorAll('input.table-search').forEach(bindSearch);
});
}
window.__lmsInitTablekit = init;
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();