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:
@@ -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);
|
||||
|
||||
/* ---------- Большая модалка «Выдача вариантов» ---------- */
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
function ready() {
|
||||
document.querySelectorAll('.md-body pre').forEach(enhance);
|
||||
}
|
||||
window.__lmsInitMdContent = ready;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ready);
|
||||
|
||||
227
web/src/main/resources/static/js/modal-ajax.js
Normal file
227
web/src/main/resources/static/js/modal-ajax.js
Normal 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);
|
||||
})();
|
||||
@@ -14,6 +14,7 @@
|
||||
function ready() {
|
||||
document.querySelectorAll('table').forEach(wrap);
|
||||
}
|
||||
window.__lmsInitTableScroll = ready;
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', ready);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user