feat(js): add app-http contracts and migrate helpdesk runtime layer

This commit is contained in:
2026-04-20 22:31:13 +02:00
parent ec2f03e0cf
commit f189ef9df6
34 changed files with 2023 additions and 1134 deletions

View File

@@ -4,15 +4,19 @@
* 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: () => {} });
/** @type {HTMLElement|null} */
const container = document.getElementById('handover-schema-editor');
if (container) {
init(container);
}
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).
@@ -25,10 +29,19 @@ function clearElement(el) {
}
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 hiddenInput = document.getElementById('handover-schema-json');
const previewContainer = document.getElementById('handover-schema-preview');
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<object>} */
@@ -38,9 +51,7 @@ function init(root) {
renderPreview();
syncHiddenInput();
if (form) {
form.addEventListener('submit', () => syncHiddenInput());
}
on(form, 'submit', () => syncHiddenInput());
function render() {
clearElement(root);
@@ -56,7 +67,7 @@ function init(root) {
addButton.className = 'secondary outline';
addButton.appendChild(createIcon('bi-plus-lg'));
addButton.appendChild(document.createTextNode(' ' + (t.add_field || 'Add field')));
addButton.addEventListener('click', () => {
on(addButton, 'click', () => {
fields.push({ type: 'text', key: '', label: '', required: false, options: [] });
render();
notifyChange();
@@ -86,7 +97,7 @@ function init(root) {
button.setAttribute('data-tooltip', t.add_field || 'Add field');
button.setAttribute('aria-label', t.add_field || 'Add field');
button.appendChild(createIcon('bi-plus'));
button.addEventListener('click', () => {
on(button, 'click', () => {
fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] });
render();
notifyChange();
@@ -102,8 +113,8 @@ function init(root) {
const row = document.createElement('div');
row.className = 'handover-schema-row';
row.dataset.fieldIndex = index;
row.addEventListener('mouseenter', () => highlightPreview(index, true));
row.addEventListener('mouseleave', () => highlightPreview(index, false));
on(row, 'mouseenter', () => highlightPreview(index, true));
on(row, 'mouseleave', () => highlightPreview(index, false));
// --- Header: "Feld #N" title + action icons ---
const header = document.createElement('div');
@@ -162,7 +173,7 @@ function init(root) {
if (ft === field.type) opt.selected = true;
typeSelect.appendChild(opt);
});
typeSelect.addEventListener('change', () => {
on(typeSelect, 'change', () => {
field.type = typeSelect.value;
if (DISPLAY_ONLY_TYPES.includes(field.type)) {
delete field.key;
@@ -194,7 +205,7 @@ function init(root) {
}
labelInput.value = field.label || '';
labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1));
labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); });
on(labelInput, 'input', () => { field.label = labelInput.value; notifyChange(); });
labelGroup.appendChild(labelInput);
body.appendChild(labelGroup);
@@ -209,7 +220,7 @@ function init(root) {
const reqInput = document.createElement('input');
reqInput.type = 'checkbox';
reqInput.checked = !!field.required;
reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); });
on(reqInput, 'change', () => { field.required = reqInput.checked; notifyChange(); });
reqLabel.appendChild(reqInput);
reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required')));
reqRow.appendChild(reqLabel);
@@ -233,7 +244,7 @@ function init(root) {
addOptButton.setAttribute('data-tooltip', t.add_option || 'Add option');
addOptButton.setAttribute('aria-label', t.add_option || 'Add option');
addOptButton.appendChild(createIcon('bi-plus-lg'));
addOptButton.addEventListener('click', () => {
on(addOptButton, 'click', () => {
field.options.push({ value: '', label: '' });
render();
notifyChange();
@@ -252,7 +263,7 @@ function init(root) {
lblInput.value = opt.label || '';
lblInput.placeholder = (t.option_label || 'Label') + ' ' + (optIndex + 1);
lblInput.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1));
lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); });
on(lblInput, 'input', () => { opt.label = lblInput.value; notifyChange(); });
optRow.appendChild(lblInput);
optRow.appendChild(createIconButton(
@@ -294,7 +305,7 @@ function init(root) {
button.className = buttonClass;
button.setAttribute('aria-label', ariaLabel);
button.appendChild(createIcon(iconClass));
button.addEventListener('click', onClick);
on(button, 'click', onClick);
return button;
}
@@ -337,7 +348,7 @@ function init(root) {
if (field.type === 'checkbox') {
const input = document.createElement('input');
input.type = 'checkbox';
input.addEventListener('click', (e) => e.preventDefault());
on(input, 'click', (e) => e.preventDefault());
group.appendChild(input);
const label = document.createElement('label');
label.textContent = field.label || '';
@@ -408,4 +419,19 @@ function init(root) {
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);
}