/** * 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 = ` `; 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 = ` ${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 = ` ${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(), }; }