/** * Handover protocol schema editor. * * Renders a structured field list from JSON and serializes changes * back to a hidden input on form submit. */ import { resolveHost } from '/js/core/app-dom.js'; const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; const EMPTY_API = Object.freeze({ destroy: () => {} }); const resolveEditor = (root) => { const host = resolveHost(root); if (host instanceof HTMLElement && host.matches('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]')) { return host; } return host.querySelector('#handover-schema-editor[data-app-component="helpdesk-handover-schema-editor"]'); }; /** * Remove all child nodes from an element (safe alternative to setting markup). * @param {HTMLElement} el */ function clearElement(el) { while (el.firstChild) { el.removeChild(el.firstChild); } } function init(root) { const listenerController = new AbortController(); const on = (target, type, handler, options = {}) => { if (!target || typeof target.addEventListener !== 'function') { return; } target.addEventListener(type, handler, { ...options, signal: listenerController.signal }); }; const t = JSON.parse(root.dataset.translations || '{}'); const initialFields = JSON.parse(root.dataset.initialFields || '[]'); const scope = root.closest('.app-details-container') || document; const hiddenInput = scope.querySelector('#handover-schema-json'); const previewContainer = scope.querySelector('#handover-schema-preview'); const form = hiddenInput?.closest('form'); /** @type {Array} */ let fields = initialFields.map((f) => ({ ...f, options: f.options ? f.options.map((o) => ({ ...o })) : [] })); render(); renderPreview(); syncHiddenInput(); on(form, 'submit', () => syncHiddenInput()); function render() { clearElement(root); if (fields.length === 0) { const empty = document.createElement('div'); empty.className = 'handover-schema-empty-state'; const emptyText = document.createElement('p'); emptyText.textContent = t.no_fields || 'No fields defined yet.'; empty.appendChild(emptyText); const addButton = document.createElement('button'); addButton.type = 'button'; addButton.className = 'secondary outline'; addButton.appendChild(createIcon('bi-plus-lg')); addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field'))); on(addButton, 'click', () => { fields.push({ type: 'text', key: '', label: '', required: false, options: [] }); render(); notifyChange(); }); empty.appendChild(addButton); root.appendChild(empty); } else { const list = document.createElement('div'); list.className = 'handover-schema-list'; fields.forEach((field, index) => { if (index > 0) { list.appendChild(buildInsertDivider(index)); } list.appendChild(buildFieldRow(field, index)); }); root.appendChild(list); root.appendChild(buildInsertDivider(fields.length)); } } function buildInsertDivider(insertIndex) { const divider = document.createElement('div'); divider.className = 'handover-schema-insert-divider'; const button = document.createElement('button'); button.type = 'button'; button.className = 'secondary outline small handover-schema-insert-button'; button.setAttribute('data-tooltip', t.add_field || 'Add field'); button.setAttribute('aria-label', t.add_field || 'Add field'); button.appendChild(createIcon('bi-plus')); on(button, 'click', () => { fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] }); render(); notifyChange(); }); divider.appendChild(button); return divider; } function buildFieldRow(field, index) { const isDisplayOnly = DISPLAY_ONLY_TYPES.includes(field.type); const isSelect = field.type === 'select'; const row = document.createElement('div'); row.className = 'handover-schema-row'; row.dataset.fieldIndex = index; on(row, 'mouseenter', () => highlightPreview(index, true)); on(row, 'mouseleave', () => highlightPreview(index, false)); // --- Header: "Feld #N" title + action icons --- const header = document.createElement('div'); header.className = 'handover-schema-row-header'; const title = document.createElement('span'); title.className = 'handover-schema-row-title'; title.textContent = (t.field || 'Field') + ' #' + (index + 1); header.appendChild(title); const actions = document.createElement('div'); actions.className = 'handover-schema-row-actions'; if (index > 0) { actions.appendChild(createIconButton( 'bi-arrow-up', 'secondary outline small', () => { [fields[index - 1], fields[index]] = [fields[index], fields[index - 1]]; render(); notifyChange(); }, (t.move_up || 'Move up') + ' ' + (t.field_label || 'field') + ' ' + (index + 1), )); } if (index < fields.length - 1) { actions.appendChild(createIconButton( 'bi-arrow-down', 'secondary outline small', () => { [fields[index], fields[index + 1]] = [fields[index + 1], fields[index]]; render(); notifyChange(); }, (t.move_down || 'Move down') + ' ' + (t.field_label || 'field') + ' ' + (index + 1), )); } actions.appendChild(createIconButton( 'bi-copy', 'secondary outline small', () => { const clone = { ...field, label: field.label, key: field.key || '' }; if (field.options) { clone.options = field.options.map((o) => ({ ...o })); } fields.splice(index + 1, 0, clone); render(); notifyChange(); }, (t.duplicate_field || 'Duplicate field') + ' ' + (index + 1), )); actions.appendChild(createIconButton( 'bi-trash', 'danger outline small', () => { fields.splice(index, 1); render(); notifyChange(); }, (t.remove_field || 'Remove field') + ' ' + (index + 1), )); header.appendChild(actions); row.appendChild(header); // --- Type select (full width) --- const typeGroup = createGroup(t.field_type || 'Field type'); const typeSelect = document.createElement('select'); typeSelect.setAttribute('aria-label', (t.field_type || 'Field type') + ' ' + (index + 1)); FIELD_TYPES.forEach((ft) => { const opt = document.createElement('option'); opt.value = ft; opt.textContent = t['type_' + ft] || ft; if (ft === field.type) opt.selected = true; typeSelect.appendChild(opt); }); on(typeSelect, 'change', () => { field.type = typeSelect.value; if (DISPLAY_ONLY_TYPES.includes(field.type)) { delete field.key; delete field.required; } else { if (!field.key) field.key = ''; if (field.required === undefined) field.required = false; } if (field.type === 'select' && (!field.options || field.options.length === 0)) { field.options = [{ value: '', label: '' }]; } render(); notifyChange(); }); typeGroup.appendChild(typeSelect); row.appendChild(typeGroup); // --- Body: label input --- const body = document.createElement('div'); body.className = 'handover-schema-row-body'; const labelKey = field.type === 'paragraph' ? (t.text_content || 'Text content') : (t.field_label || 'Label'); const labelGroup = createGroup(labelKey); const labelInput = document.createElement(field.type === 'paragraph' ? 'textarea' : 'input'); if (field.type !== 'paragraph') { labelInput.type = 'text'; } else { labelInput.rows = 2; } labelInput.value = field.label || ''; labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1)); on(labelInput, 'input', () => { field.label = labelInput.value; notifyChange(); }); labelGroup.appendChild(labelInput); body.appendChild(labelGroup); row.appendChild(body); // --- Required toggle — own row below key/label --- if (!isDisplayOnly) { const reqRow = document.createElement('div'); reqRow.className = 'handover-schema-row-required'; const reqLabel = document.createElement('label'); reqLabel.className = 'handover-schema-checkbox-label'; const reqInput = document.createElement('input'); reqInput.type = 'checkbox'; reqInput.checked = !!field.required; on(reqInput, 'change', () => { field.required = reqInput.checked; notifyChange(); }); reqLabel.appendChild(reqInput); reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required'))); reqRow.appendChild(reqLabel); row.appendChild(reqRow); } // --- Select options --- if (isSelect) { const optionsSection = document.createElement('div'); optionsSection.className = 'handover-schema-options'; const optionsHeader = document.createElement('div'); optionsHeader.className = 'handover-schema-options-header'; const optionsTitle = document.createElement('strong'); optionsTitle.textContent = t.options || 'Options'; optionsHeader.appendChild(optionsTitle); const addOptButton = document.createElement('button'); addOptButton.type = 'button'; addOptButton.className = 'secondary outline small'; addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option'); addOptButton.setAttribute('aria-label', t.add_option || 'Add option'); addOptButton.appendChild(createIcon('bi-plus-lg')); on(addOptButton, 'click', () => { field.options.push({ value: '', label: '' }); render(); notifyChange(); }); optionsHeader.appendChild(addOptButton); optionsSection.appendChild(optionsHeader); if (!field.options) field.options = []; field.options.forEach((opt, optIndex) => { const optRow = document.createElement('div'); optRow.className = 'handover-schema-option-row'; const lblInput = document.createElement('input'); lblInput.type = 'text'; lblInput.value = opt.label || ''; lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1); lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1)); on(lblInput, 'input', () => { opt.label = lblInput.value; notifyChange(); }); optRow.appendChild(lblInput); optRow.appendChild(createIconButton( 'bi-x-lg', 'danger outline small', () => { field.options.splice(optIndex, 1); render(); notifyChange(); }, (t.remove_option || 'Remove option') + ' ' + (optIndex + 1), )); optionsSection.appendChild(optRow); }); row.appendChild(optionsSection); } return row; } function createGroup(labelText) { const group = document.createElement('div'); group.className = 'handover-schema-field-group'; if (labelText) { const label = document.createElement('span'); label.className = 'handover-schema-field-label'; label.textContent = labelText; group.appendChild(label); } return group; } function createIcon(iconClass) { const icon = document.createElement('i'); icon.className = 'bi ' + iconClass; return icon; } function createIconButton(iconClass, buttonClass, onClick, ariaLabel) { const button = document.createElement('button'); button.type = 'button'; button.className = buttonClass; button.setAttribute('aria-label', ariaLabel); button.appendChild(createIcon(iconClass)); on(button, 'click', onClick); return button; } function renderPreview() { if (!previewContainer) return; clearElement(previewContainer); if (fields.length === 0) { const empty = document.createElement('p'); empty.className = 'handover-schema-preview-empty'; empty.textContent = t.preview_empty || 'Add fields to see a live preview.'; previewContainer.appendChild(empty); return; } const formEl = document.createElement('div'); formEl.className = 'handover-schema-preview-form'; fields.forEach((field, index) => { if (field.type === 'heading') { const h = document.createElement('h3'); h.dataset.fieldIndex = index; h.textContent = field.label || ''; formEl.appendChild(h); return; } if (field.type === 'paragraph') { const p = document.createElement('p'); p.dataset.fieldIndex = index; p.textContent = field.label || ''; formEl.appendChild(p); return; } const group = document.createElement('div'); group.className = 'handover-schema-preview-field'; group.dataset.fieldIndex = index; if (field.type === 'checkbox') { const input = document.createElement('input'); input.type = 'checkbox'; on(input, 'click', (e) => e.preventDefault()); group.appendChild(input); const label = document.createElement('label'); label.textContent = field.label || ''; if (field.required) { const star = document.createElement('span'); star.className = 'handover-schema-preview-required'; star.textContent = ' *'; label.appendChild(star); } group.appendChild(label); } else { const label = document.createElement('label'); label.textContent = field.label || ''; if (field.required) { const star = document.createElement('span'); star.className = 'handover-schema-preview-required'; star.textContent = ' *'; label.appendChild(star); } group.appendChild(label); let input; if (field.type === 'textarea') { input = document.createElement('textarea'); input.rows = 3; } else if (field.type === 'select') { input = document.createElement('select'); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.textContent = ''; input.appendChild(placeholder); (field.options || []).forEach((opt) => { const option = document.createElement('option'); option.textContent = opt.label || ''; input.appendChild(option); }); } else { input = document.createElement('input'); input.type = field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : 'text'; } input.disabled = true; group.appendChild(input); } formEl.appendChild(group); }); previewContainer.appendChild(formEl); } function highlightPreview(index, active) { if (!previewContainer) return; const el = previewContainer.querySelector('[data-field-index="' + index + '"]'); if (el) { el.classList.toggle('handover-schema-preview-highlight', active); } } function syncHiddenInput() { if (!hiddenInput) return; hiddenInput.value = fields.length > 0 ? JSON.stringify(fields) : ''; } function notifyChange() { syncHiddenInput(); renderPreview(); if (hiddenInput) { hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); } } return { destroy: () => { listenerController.abort(); }, }; } export function initHandoverSchemaEditor(root = document) { const container = resolveEditor(root); if (!container) { return EMPTY_API; } return init(container); }