forked from fa/breadcrumb-the-shire
109 lines
3.5 KiB
JavaScript
109 lines
3.5 KiB
JavaScript
/**
|
|
* Security check workspace — light client-side enhancement of the checklist form.
|
|
*
|
|
* Progress is authoritative on the server (recomputed on save); this only gives
|
|
* live visual feedback: toggles the done-styling on a row and updates the
|
|
* progress bar width as items are checked. No business logic.
|
|
*/
|
|
import { resolveHost } from '/js/core/app-dom.js';
|
|
|
|
const SELECTOR = '[data-app-component="security-check-workspace"]';
|
|
const EMPTY_API = Object.freeze({ destroy: () => {} });
|
|
|
|
const resolveForm = (root) => {
|
|
const host = resolveHost(root);
|
|
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
|
|
return host;
|
|
}
|
|
return host.querySelector(SELECTOR);
|
|
};
|
|
|
|
export function initSecurityCheckWorkspace(root = document) {
|
|
const form = resolveForm(root);
|
|
if (!form) {
|
|
return EMPTY_API;
|
|
}
|
|
|
|
const section = form.closest('section') || document;
|
|
const fill = section.querySelector('.security-check-progress-fill');
|
|
const doneToggles = () => Array.from(form.querySelectorAll('input[type="checkbox"][name$="[done]"]'));
|
|
|
|
// Step gating: a step's done-checkbox can only be ticked once all of its
|
|
// required fields are filled. Authoritative gate lives on the server; this
|
|
// just mirrors it so the box is disabled until the fields are complete.
|
|
const requiredFields = Array.from(form.querySelectorAll('[data-security-required="1"]'));
|
|
const readonly = requiredFields.length > 0 && requiredFields.every((field) => field.disabled);
|
|
|
|
const gateGroups = [];
|
|
new Set(requiredFields.map((field) => field.closest('.security-step'))).forEach((step) => {
|
|
if (!step) {
|
|
return;
|
|
}
|
|
const doneBox = step.querySelector('input[type="checkbox"][name$="[done]"]');
|
|
if (!doneBox) {
|
|
return;
|
|
}
|
|
gateGroups.push({
|
|
doneBox,
|
|
hint: step.querySelector('[data-security-gate-hint]'),
|
|
fields: requiredFields.filter((field) => field.closest('.security-step') === step),
|
|
});
|
|
});
|
|
|
|
const enforceGates = () => {
|
|
if (readonly) {
|
|
return;
|
|
}
|
|
gateGroups.forEach(({ doneBox, hint, fields }) => {
|
|
const met = fields.every((field) => field.value.trim() !== '');
|
|
doneBox.disabled = !met;
|
|
if (!met && doneBox.checked) {
|
|
doneBox.checked = false;
|
|
}
|
|
if (hint) {
|
|
hint.hidden = met;
|
|
}
|
|
});
|
|
};
|
|
|
|
const listenerController = new AbortController();
|
|
|
|
const refresh = () => {
|
|
const boxes = doneToggles();
|
|
const total = boxes.length;
|
|
const done = boxes.filter((b) => b.checked).length;
|
|
boxes.forEach((box) => {
|
|
const row = box.closest('.security-step, .security-tech-item');
|
|
row?.classList.toggle('is-done', box.checked);
|
|
});
|
|
if (fill && total > 0) {
|
|
fill.style.width = `${Math.floor((done / total) * 100)}%`;
|
|
}
|
|
};
|
|
|
|
form.addEventListener('change', (event) => {
|
|
const target = event.target;
|
|
if (target instanceof HTMLInputElement && target.type === 'checkbox' && target.name.endsWith('[done]')) {
|
|
refresh();
|
|
}
|
|
}, { signal: listenerController.signal });
|
|
|
|
if (!readonly && gateGroups.length > 0) {
|
|
const onFieldChange = (event) => {
|
|
if (event.target instanceof HTMLElement && event.target.matches('[data-security-required="1"]')) {
|
|
enforceGates();
|
|
refresh();
|
|
}
|
|
};
|
|
form.addEventListener('input', onFieldChange, { signal: listenerController.signal });
|
|
form.addEventListener('change', onFieldChange, { signal: listenerController.signal });
|
|
}
|
|
|
|
enforceGates();
|
|
refresh();
|
|
|
|
return {
|
|
destroy: () => listenerController.abort(),
|
|
};
|
|
}
|