302 lines
12 KiB
JavaScript
302 lines
12 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, att_esc } from '../helpers.js?v=9';
|
||
import { apiSubmit } from '../api.js?v=9';
|
||
import { createDom } from '../vdom.js?v=9';
|
||
|
||
/**
|
||
* Render Hoover VNodes into a modal content element.
|
||
* VDOM is not diffed across modal re-render — modals are transient and
|
||
* innerHTML is cleared/repainted each time (avoids lifecycle baggage).
|
||
*/
|
||
export function modalVNodes(inner, vnodes) {
|
||
inner.innerHTML = '';
|
||
const nodes = Array.isArray(vnodes) ? vnodes : [vnodes];
|
||
for (const vnode of nodes) {
|
||
if (vnode) inner.appendChild(createDom(vnode));
|
||
}
|
||
}
|
||
|
||
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) {
|
||
if (m._processing) return;
|
||
if (m._hasInputs && !confirm('Discard changes?')) return;
|
||
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|object} content – Either:
|
||
* - renderFn(contentEl, idx) => void (legacy innerHTML path)
|
||
* - VNode / VNode[] (new VDOM path — uses modalVNodes)
|
||
*/
|
||
export function openModal(content) {
|
||
const entry = typeof content === 'function'
|
||
? { renderFn: content, id: _modalQueue.length, _processing: false, _hasInputs: false }
|
||
: { id: _modalQueue.length, renderFn: (inner) => modalVNodes(inner, content), _processing: false, _hasInputs: false };
|
||
_modalQueue.push(entry);
|
||
_renderModals();
|
||
}
|
||
|
||
/**
|
||
* Check if the topmost modal (or specified index) has an active async operation.
|
||
*
|
||
* @param {number} [idx] – Modal index (defaults to topmost)
|
||
* @returns {boolean}
|
||
*/
|
||
export function isModalProcessing(idx) {
|
||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||
if (idx < 0 || idx >= _modalQueue.length) return false;
|
||
return _modalQueue[idx]._processing;
|
||
}
|
||
|
||
/**
|
||
* Set the processing flag on the topmost modal (or specified index).
|
||
*
|
||
* @param {boolean} flag – Whether the modal is currently processing
|
||
* @param {number} [idx] – Modal index (defaults to topmost)
|
||
*/
|
||
export function setModalProcessing(flag, idx) {
|
||
if (idx === undefined) idx = _modalQueue.length - 1;
|
||
if (idx < 0 || idx >= _modalQueue.length) return;
|
||
_modalQueue[idx]._processing = flag;
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
}
|
||
|
||
/** Re-render all open modals. Used by long-lived modals that update in place. */
|
||
export function refreshModals() {
|
||
_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] }
|
||
* - options can be string[], [value, selected][] tuples, or objects with { group, options }
|
||
* for <optgroup> grouping. Nested options follow the same string/tuple format.
|
||
*
|
||
* 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(function(o) {
|
||
if (o === null || o === undefined) return '';
|
||
if (typeof o === 'string')
|
||
return '<option value="' + att_esc(o) + '">' + esc(o) + '</option>';
|
||
if (Array.isArray(o)) {
|
||
// Support [value, label] and [value, selectedBoolean]
|
||
if (o.length < 2)
|
||
return '<option value="' + att_esc(o[0]) + '">' + att_esc(o[0]) + '</option>';
|
||
if (typeof o[1] === 'boolean')
|
||
return '<option value="' + att_esc(o[0]) + '"' + (o[1] ? ' selected' : '') + '>' + att_esc(o[0]) + '</option>';
|
||
return '<option value="' + att_esc(o[0]) + '">' + esc(o[1]) + '</option>';
|
||
}
|
||
if ('group' in o) {
|
||
return '<optgroup label="' + att_esc(o.group) + '">'
|
||
+ o.options.map(function(item) {
|
||
return typeof item === 'string'
|
||
? '<option value="' + att_esc(item) + '">' + esc(item) + '</option>'
|
||
: '<option value="' + att_esc(item[0]) + '">' + esc(item[1]) + '</option>';
|
||
}).join('') + '</optgroup>';
|
||
}
|
||
return '';
|
||
}).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) + '"' : '')
|
||
+ (f.checked ? ' checked' : '')
|
||
+ '></' + tag + '></div>';
|
||
}).join('') + '</div><div class="modal-actions"></div>';
|
||
|
||
// Mark modal as having editable inputs
|
||
const topEntry = _modalQueue[_modalQueue.length - 1];
|
||
if (topEntry) topEntry._hasInputs = true;
|
||
|
||
// Build action buttons with processing-aware rendering
|
||
const actionsBar = inner.querySelector('.modal-actions');
|
||
const actionBtns = [];
|
||
for (const a of actions) {
|
||
const actionId = 'am-' + a.action + '-' + (_modalQueue.length - 1);
|
||
const btn = document.createElement('button');
|
||
btn.className = 'btn ' + a.cls;
|
||
btn.id = actionId;
|
||
if (a.processing && isModalProcessing(_modalQueue.length - 1)) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="btn-spinner"></span>';
|
||
} else {
|
||
btn.appendChild(document.createTextNode(a.label));
|
||
}
|
||
// Store reference for later re-binding
|
||
actionBtns.push({ btn, action: a });
|
||
if (a.handler) {
|
||
const origHandler = a.handler;
|
||
btn.addEventListener('click', () => {
|
||
refreshModals();
|
||
origHandler();
|
||
});
|
||
}
|
||
actionsBar.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||
* @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',
|
||
refresh: props.refresh,
|
||
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 {string|string[]} [props.refresh] - Model name(s) to refresh via modelFetch
|
||
* @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'),
|
||
refresh: props.refresh || undefined,
|
||
closeModal: () => closeModal(),
|
||
}),
|
||
];
|
||
}
|
||
formModal(inner, title, fields, actions);
|
||
if (props.postRender) props.postRender(inner, data);
|
||
});
|
||
};
|
||
}
|