633505e7dc
- Add quick modal, table, service status, and confirmation dialog components - Refactor all pages (certs, dhcp, proxy, etc.) to use new component patterns - Introduce refactor load utility and render guard for consistent UX - Add hoover documentation and update AGENTS.md, architecture, overview
206 lines
8.4 KiB
JavaScript
206 lines
8.4 KiB
JavaScript
/**
|
||
* Hoover — components/modal.js
|
||
*
|
||
* Modal overlay system: openModal, closeModal, closeAllModals, formModal.
|
||
* Renders directly into #modal-root using DOM manipulation (not vdom) to
|
||
* avoid fighting with the main render cycle.
|
||
*/
|
||
|
||
import { esc } from '../helpers.js?v=6';
|
||
import { att_esc } from '../helpers.js?v=6';
|
||
import { apiSubmit } from '../api.js?v=6';
|
||
|
||
const _modalQueue = [];
|
||
|
||
function _renderModals() {
|
||
const root = document.getElementById('modal-root');
|
||
if (!root) return;
|
||
root.innerHTML = '';
|
||
_modalQueue.forEach((m, idx) => {
|
||
const wrap = document.createElement('div');
|
||
wrap.className = 'modal-overlay active';
|
||
wrap.onclick = (e) => { if (e.target === wrap) closeModal(idx); };
|
||
const content = document.createElement('div');
|
||
content.className = 'modal';
|
||
content.onclick = (e) => e.stopPropagation();
|
||
if (m.renderFn) {
|
||
try { m.renderFn(content, idx); }
|
||
catch (err) { content.textContent = err.message; }
|
||
}
|
||
wrap.appendChild(content);
|
||
root.appendChild(wrap);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Open a modal dialog.
|
||
*
|
||
* @param {function} renderFn – (contentEl, idx) => void, renders into contentEl
|
||
*/
|
||
export function openModal(renderFn) {
|
||
_modalQueue.push({ renderFn, id: _modalQueue.length });
|
||
_renderModals();
|
||
}
|
||
|
||
/**
|
||
* Close a modal by index. Closes the topmost modal if index is omitted.
|
||
*
|
||
* @param {number} [idx]
|
||
*/
|
||
export function closeModal(idx) {
|
||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||
if (idx >= 0 && idx < _modalQueue.length) _modalQueue.splice(idx, 1);
|
||
_renderModals();
|
||
}
|
||
|
||
/**
|
||
* Close all open modals.
|
||
*/
|
||
export function closeAllModals() {
|
||
_modalQueue.length = 0;
|
||
_renderModals();
|
||
}
|
||
|
||
/**
|
||
* Render a standard modal layout: title, form fields, action buttons.
|
||
*
|
||
* @param {HTMLElement} inner – Modal content element to fill
|
||
* @param {string} title – Modal title
|
||
* @param {object[]} fields – Form field descriptors
|
||
* @param {object[]} actions – Action button descriptors
|
||
*
|
||
* Field shape:
|
||
* { label, id, [tag: 'input'|'select'|'textarea'], [type], [value], [placeholder], [options] }
|
||
*
|
||
* Action shape:
|
||
* { label, cls, action, handler }
|
||
*/
|
||
export function formModal(inner, title, fields, actions) {
|
||
inner.innerHTML = '<h2 class="modal-title">' + esc(title) + '</h2><div class="modal-body">'
|
||
+ fields.map(f => {
|
||
if (f.tag === 'select')
|
||
return '<div class="form-group"><label>' + esc(f.label) + '</label><select id="' + att_esc(f.id) + '"'
|
||
+ (f.multiple ? ' multiple' : '') + '>'
|
||
+ (f.options || []).map(o =>
|
||
typeof o === 'string'
|
||
? '<option value="' + att_esc(o) + '">' + esc(o) + '</option>'
|
||
: '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + esc(o[1]) + '</option>',
|
||
).join('') + '</select></div>';
|
||
|
||
const tag = f.tag || 'input';
|
||
return '<div class="form-group"><label>' + esc(f.label) + '</label><' + tag + ' id="' + att_esc(f.id) + '"'
|
||
+ (f.type !== 'text' ? ' type="' + att_esc(f.type) + '"' : '')
|
||
+ (f.value !== undefined ? ' value="' + att_esc(f.value) + '"' : '')
|
||
+ (f.placeholder ? ' placeholder="' + att_esc(f.placeholder) + '"' : '')
|
||
+ '></' + tag + '></div>';
|
||
}).join('') + '</div><div class="modal-actions">'
|
||
+ actions.map(a =>
|
||
'<button class="btn ' + att_esc(a.cls) + '" data-action="' + att_esc(a.action) + '">' + esc(a.label) + '</button>',
|
||
).join('') + '</div>';
|
||
|
||
actions.forEach(a => {
|
||
const btn = inner.querySelector('[data-action="' + att_esc(a.action) + '"]');
|
||
if (btn) btn.addEventListener('click', a.handler);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Factory that returns a function to open a multi-select modal.
|
||
*
|
||
* @param {object} props
|
||
* @param {string} props.title - Modal title
|
||
* @param {string} props.url - API POST URL
|
||
* @param {string[]} props.options - All selectable options
|
||
* @param {string[]} props.selected - Currently selected values
|
||
* @param {string} props.fieldKey - JSON key for the field
|
||
* @param {string} [props.successMsg] - Success toast message
|
||
* @param {function} [props.reload] - () => Promise, called on success
|
||
* @returns {function} () => void, calls openModal
|
||
*/
|
||
export function MultiSelectModal(props = {}) {
|
||
return () => {
|
||
const selectId = 'ms-' + props.fieldKey;
|
||
openModal((inner) => {
|
||
formModal(inner, props.title,
|
||
[{
|
||
label: props.fieldKey,
|
||
id: selectId,
|
||
tag: 'select',
|
||
multiple: true,
|
||
options: (props.options || []).map(o => [o, (props.selected || []).includes(o)]),
|
||
}],
|
||
[
|
||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||
...apiSubmit({
|
||
url: props.url,
|
||
body: () => ({
|
||
[props.fieldKey]: Array.from(document.getElementById(selectId).selectedOptions)
|
||
.map(o => o.value),
|
||
}),
|
||
successMsg: props.successMsg || 'Updated',
|
||
reload: props.reload,
|
||
closeModal: () => closeModal(),
|
||
}),
|
||
],
|
||
);
|
||
});
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Factory that returns a function to open a modal with form fields and apiSubmit.
|
||
* Accepts an optional `data` argument forwarded to title, fields, submit.url, submit.body resolvers.
|
||
*
|
||
* @param {object} props
|
||
* @param {string|function} props.title - Modal title or (data) => string
|
||
* @param {object[]|function} props.fields - Form field descriptors or (data) => object[]
|
||
* @param {object} props.submit - Submit configuration
|
||
* @param {string|function} props.submit.url - API URL or (data) => string
|
||
* @param {string} [props.submit.method] - HTTP method (default: 'POST')
|
||
* @param {function} [props.submit.body] - (data) => object
|
||
* @param {function} [props.submit.validate] - (body) => string|null
|
||
* @param {string|function} [props.submit.successMsg] - Toast message or (data) => string
|
||
* @param {function} [props.reload] - (data) => Promise, called on success with the data argument
|
||
* @param {function} [props.handler] - Custom submit handler override (bypasses apiSubmit)
|
||
* @param {string} [props.submitLabel] - Submit button label (default: 'Submit')
|
||
* @returns {function} (data) => void, calls openModal
|
||
*/
|
||
export function QuickModal(props = {}) {
|
||
return (data) => {
|
||
const title = typeof props.title === 'function' ? props.title(data) : props.title;
|
||
const fields = typeof props.fields === 'function' ? props.fields(data) : props.fields;
|
||
const url = typeof props.submit.url === 'function' ? props.submit.url(data) : props.submit.url;
|
||
|
||
openModal((inner) => {
|
||
let actions;
|
||
if (props.handler) {
|
||
actions = [
|
||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||
{
|
||
label: props.submitLabel || 'Submit',
|
||
cls: 'btn-primary',
|
||
action: 's',
|
||
handler: () => props.handler(data, () => closeModal()),
|
||
},
|
||
];
|
||
} else {
|
||
actions = [
|
||
{ label: 'Cancel', cls: 'btn-outline', action: 'c', handler: () => closeModal() },
|
||
...apiSubmit({
|
||
url,
|
||
method: props.submit.method || 'POST',
|
||
body: props.submit.body ? () => props.submit.body(data) : undefined,
|
||
validate: props.submit.validate,
|
||
successMsg: typeof props.submit.successMsg === 'function'
|
||
? props.submit.successMsg(data)
|
||
: (props.submit.successMsg || 'Done'),
|
||
reload: props.reload ? () => props.reload(data) : undefined,
|
||
closeModal: () => closeModal(),
|
||
}),
|
||
];
|
||
}
|
||
formModal(inner, title, fields, actions);
|
||
});
|
||
};
|
||
}
|