Files
breadcrumb-the-shire/modules/security/web/js/security-template-schema-editor.js

188 lines
7.7 KiB
JavaScript
Raw Normal View History

2026-06-22 13:19:05 +02:00
/**
* Security check template schema editor.
*
* Edits the per-product technical checklist as an ordered list of items:
* - section: a grouping heading (label only)
* - check: a tracked checkpoint (label + optional hint + optional Markdown guidance)
*
* Serializes the items to the hidden #security-schema-json input on every change
* and on form submit. Item keys are (re)generated server-side on save.
*/
import { resolveHost } from '/js/core/app-dom.js';
import { escapeHtml } from '/js/pages/app-list-utils.js';
const SELECTOR = '#security-schema-editor[data-app-component="security-template-schema-editor"]';
const EMPTY_API = Object.freeze({ destroy: () => {} });
const resolveEditor = (root) => {
const host = resolveHost(root);
if (host instanceof HTMLElement && host.matches(SELECTOR)) {
return host;
}
return host.querySelector(SELECTOR);
};
export function initSecurityTemplateSchemaEditor(root = document) {
const editor = resolveEditor(root);
if (!editor) {
return EMPTY_API;
}
const form = editor.closest('form');
const hidden = form?.querySelector('#security-schema-json');
if (!form || !hidden) {
return EMPTY_API;
}
const labels = {
section: editor.dataset.labelSection || 'Section',
check: editor.dataset.labelCheck || 'Check item',
addSection: editor.dataset.labelAddSection || 'Add section',
addCheck: editor.dataset.labelAddCheck || 'Add check item',
remove: editor.dataset.labelRemove || 'Remove',
moveUp: editor.dataset.labelMoveUp || 'Move up',
moveDown: editor.dataset.labelMoveDown || 'Move down',
phSection: editor.dataset.placeholderSection || 'Section title',
phCheck: editor.dataset.placeholderCheck || 'Check item label',
phHint: editor.dataset.placeholderHint || 'Hint (optional)',
phMarkdown: editor.dataset.placeholderMarkdown || 'Markdown (optional)',
empty: editor.dataset.emptyText || 'No checklist items yet.',
};
const listenerController = new AbortController();
/** @type {{type:string,label:string,hint:string,markdown:string}[]} */
let items = [];
try {
const initial = JSON.parse(document.getElementById('security-schema-initial')?.textContent || '[]');
if (Array.isArray(initial)) {
items = initial.map((it) => ({
type: it && it.type === 'section' ? 'section' : 'check',
label: String((it && it.label) || ''),
hint: String((it && it.hint) || ''),
markdown: String((it && it.markdown) || ''),
}));
}
} catch {
items = [];
}
const serialize = () => {
hidden.value = JSON.stringify(items.map((it) => (
it.type === 'section'
? { type: 'section', label: it.label }
: { type: 'check', label: it.label, hint: it.hint, markdown: it.markdown }
)));
};
const render = () => {
const list = document.createElement('div');
list.className = 'security-schema-items';
if (items.length === 0) {
const empty = document.createElement('p');
empty.className = 'app-muted';
empty.textContent = labels.empty;
list.appendChild(empty);
}
items.forEach((item, index) => {
const row = document.createElement('div');
row.className = `security-schema-item security-schema-item-${item.type}`;
row.dataset.index = String(index);
const topRow = document.createElement('div');
topRow.className = 'security-schema-item-row';
const labelInput = document.createElement('input');
labelInput.type = 'text';
labelInput.className = 'security-schema-label';
labelInput.value = item.label;
labelInput.placeholder = item.type === 'section' ? labels.phSection : labels.phCheck;
labelInput.addEventListener('input', () => { items[index].label = labelInput.value; serialize(); }, { signal: listenerController.signal });
const typeBadge = document.createElement('span');
typeBadge.className = 'security-schema-type badge';
typeBadge.dataset.variant = item.type === 'section' ? 'neutral' : 'info';
typeBadge.textContent = item.type === 'section' ? labels.section : labels.check;
topRow.appendChild(typeBadge);
topRow.appendChild(labelInput);
if (item.type === 'check') {
const hintInput = document.createElement('input');
hintInput.type = 'text';
hintInput.className = 'security-schema-hint';
hintInput.value = item.hint;
hintInput.placeholder = labels.phHint;
hintInput.addEventListener('input', () => { items[index].hint = hintInput.value; serialize(); }, { signal: listenerController.signal });
topRow.appendChild(hintInput);
}
const controls = document.createElement('div');
controls.className = 'security-schema-controls';
controls.innerHTML = `
<button type="button" class="app-icon-button" data-action="up" data-tooltip="${escapeHtml(labels.moveUp)}" aria-label="${escapeHtml(labels.moveUp)}"><i class="bi bi-arrow-up"></i></button>
<button type="button" class="app-icon-button" data-action="down" data-tooltip="${escapeHtml(labels.moveDown)}" aria-label="${escapeHtml(labels.moveDown)}"><i class="bi bi-arrow-down"></i></button>
<button type="button" class="app-icon-button" data-action="remove" data-tooltip="${escapeHtml(labels.remove)}" aria-label="${escapeHtml(labels.remove)}"><i class="bi bi-x-lg"></i></button>
`;
controls.querySelectorAll('button[data-action]').forEach((btn) => {
btn.addEventListener('click', () => {
const action = btn.dataset.action;
if (action === 'remove') {
items.splice(index, 1);
} else if (action === 'up' && index > 0) {
[items[index - 1], items[index]] = [items[index], items[index - 1]];
} else if (action === 'down' && index < items.length - 1) {
[items[index + 1], items[index]] = [items[index], items[index + 1]];
} else {
return;
}
serialize();
render();
}, { signal: listenerController.signal });
});
topRow.appendChild(controls);
row.appendChild(topRow);
if (item.type === 'check') {
const markdownInput = document.createElement('textarea');
markdownInput.className = 'security-schema-markdown';
markdownInput.rows = 2;
markdownInput.value = item.markdown;
markdownInput.placeholder = labels.phMarkdown;
markdownInput.addEventListener('input', () => { items[index].markdown = markdownInput.value; serialize(); }, { signal: listenerController.signal });
row.appendChild(markdownInput);
}
list.appendChild(row);
});
const toolbar = document.createElement('div');
toolbar.className = 'security-schema-toolbar';
const addSection = document.createElement('button');
addSection.type = 'button';
addSection.className = 'secondary outline small';
addSection.innerHTML = `<i class="bi bi-type-h3"></i> ${escapeHtml(labels.addSection)}`;
addSection.addEventListener('click', () => { items.push({ type: 'section', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
const addCheck = document.createElement('button');
addCheck.type = 'button';
addCheck.className = 'secondary outline small';
addCheck.innerHTML = `<i class="bi bi-check2-square"></i> ${escapeHtml(labels.addCheck)}`;
addCheck.addEventListener('click', () => { items.push({ type: 'check', label: '', hint: '', markdown: '' }); serialize(); render(); }, { signal: listenerController.signal });
toolbar.appendChild(addCheck);
toolbar.appendChild(addSection);
editor.replaceChildren(list, toolbar);
};
form.addEventListener('submit', serialize, { signal: listenerController.signal });
serialize();
render();
return {
destroy: () => listenerController.abort(),
};
}