188 lines
7.7 KiB
JavaScript
188 lines
7.7 KiB
JavaScript
|
|
/**
|
||
|
|
* 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(),
|
||
|
|
};
|
||
|
|
}
|