diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index c98f06d..1c0eca5 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -405,9 +405,10 @@ "Maximum %d fields allowed": "Maximal %d Felder erlaubt", "Field %d: unknown type \"%s\"": "Feld %d: unbekannter Typ \"%s\"", "Field %d: label is required": "Feld %d: Bezeichnung ist erforderlich", - "Field %d: key is required": "Feld %d: Schlüssel ist erforderlich", - "Field %d: key must start with a letter and contain only lowercase letters, digits, and underscores": "Feld %d: Schlüssel muss mit einem Buchstaben beginnen und darf nur Kleinbuchstaben, Ziffern und Unterstriche enthalten", - "Field %d: duplicate key \"%s\"": "Feld %d: doppelter Schlüssel \"%s\"", + "Field %d: generates duplicate key \"%s\" — use a more distinct label": "Feld %d: erzeugt doppelten Schlüssel \"%s\" — verwenden Sie eine eindeutigere Bezeichnung", "Field %d: select must have at least one option": "Feld %d: Auswahl muss mindestens eine Option haben", - "Field %d, option %d: value is required": "Feld %d, Option %d: Wert ist erforderlich" + "Field %d, option %d: label is required": "Feld %d, Option %d: Bezeichnung ist erforderlich", + "Preview": "Vorschau", + "Add fields to see a live preview.": "Felder hinzufügen, um eine Vorschau zu sehen.", + "Field": "Feld" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 746ef8e..a17ff2a 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -405,9 +405,10 @@ "Maximum %d fields allowed": "Maximum %d fields allowed", "Field %d: unknown type \"%s\"": "Field %d: unknown type \"%s\"", "Field %d: label is required": "Field %d: label is required", - "Field %d: key is required": "Field %d: key is required", - "Field %d: key must start with a letter and contain only lowercase letters, digits, and underscores": "Field %d: key must start with a letter and contain only lowercase letters, digits, and underscores", - "Field %d: duplicate key \"%s\"": "Field %d: duplicate key \"%s\"", + "Field %d: generates duplicate key \"%s\" — use a more distinct label": "Field %d: generates duplicate key \"%s\" — use a more distinct label", "Field %d: select must have at least one option": "Field %d: select must have at least one option", - "Field %d, option %d: value is required": "Field %d, option %d: value is required" + "Field %d, option %d: label is required": "Field %d, option %d: label is required", + "Preview": "Preview", + "Add fields to see a live preview.": "Add fields to see a live preview.", + "Field": "Field" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php index f187c7d..0bb98ba 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php @@ -65,7 +65,6 @@ class SoftwareProductService $fieldNum = $index + 1; $type = (string) ($field['type'] ?? ''); $label = trim((string) ($field['label'] ?? '')); - $key = trim((string) ($field['key'] ?? '')); $isDisplayOnly = in_array($type, self::DISPLAY_ONLY_TYPES, true); if (!in_array($type, self::ALLOWED_FIELD_TYPES, true)) { @@ -77,13 +76,10 @@ class SoftwareProductService $errors["field_{$fieldNum}_label"] = t('Field %d: label is required', $fieldNum); } - if (!$isDisplayOnly) { - if ($key === '') { - $errors["field_{$fieldNum}_key"] = t('Field %d: key is required', $fieldNum); - } elseif (!preg_match('/^[a-z][a-z0-9_]*$/', $key)) { - $errors["field_{$fieldNum}_key"] = t('Field %d: key must start with a letter and contain only lowercase letters, digits, and underscores', $fieldNum); - } elseif (isset($seenKeys[$key])) { - $errors["field_{$fieldNum}_key"] = t('Field %d: duplicate key "%s"', $fieldNum, $key); + if (!$isDisplayOnly && $label !== '') { + $key = self::slugify($label); + if (isset($seenKeys[$key])) { + $errors["field_{$fieldNum}_key"] = t('Field %d: generates duplicate key "%s" — use a more distinct label', $fieldNum, $key); } else { $seenKeys[$key] = true; } @@ -96,8 +92,8 @@ class SoftwareProductService } else { foreach ($options as $optIdx => $opt) { $optNum = $optIdx + 1; - if (trim((string) ($opt['value'] ?? '')) === '') { - $errors["field_{$fieldNum}_option_{$optNum}"] = t('Field %d, option %d: value is required', $fieldNum, $optNum); + if (trim((string) ($opt['label'] ?? '')) === '') { + $errors["field_{$fieldNum}_option_{$optNum}"] = t('Field %d, option %d: label is required', $fieldNum, $optNum); } } } @@ -143,16 +139,17 @@ class SoftwareProductService $entry = ['type' => $type, 'label' => trim((string) ($field['label'] ?? ''))]; if (!$isDisplayOnly) { - $entry['key'] = trim((string) ($field['key'] ?? '')); + $entry['key'] = self::slugify($entry['label']); $entry['required'] = !empty($field['required']); } if ($type === 'select' && is_array($field['options'] ?? null)) { $entry['options'] = []; foreach ($field['options'] as $opt) { + $label = trim((string) ($opt['label'] ?? '')); $entry['options'][] = [ - 'value' => trim((string) ($opt['value'] ?? '')), - 'label' => trim((string) ($opt['label'] ?? '')), + 'value' => self::slugify($label), + 'label' => $label, ]; } } @@ -163,6 +160,19 @@ class SoftwareProductService return $normalized; } + private static function slugify(string $text): string + { + $slug = mb_strtolower($text); + $slug = preg_replace('/[äÄ]/', 'ae', $slug); + $slug = preg_replace('/[öÖ]/', 'oe', $slug); + $slug = preg_replace('/[üÜ]/', 'ue', $slug); + $slug = preg_replace('/ß/', 'ss', $slug); + $slug = preg_replace('/[^a-z0-9]+/', '_', $slug); + $slug = trim($slug, '_'); + + return $slug === '' ? 'option' : $slug; + } + /** * Update the user-managed name field for a software product. * diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml index 9f77736..e64aa61 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -27,42 +27,47 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
-
- -

- -
t('Heading'), - 'type_paragraph' => t('Paragraph'), - 'type_text' => t('Text'), - 'type_textarea' => t('Textarea'), - 'type_number' => t('Number'), - 'type_date' => t('Date'), - 'type_checkbox' => t('Checkbox'), - 'type_select' => t('Select'), - 'text_content' => t('Text content'), - ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>" - >
- - -
+
+
+
t('Heading'), + 'type_paragraph' => t('Paragraph'), + 'type_text' => t('Text'), + 'type_textarea' => t('Textarea'), + 'type_number' => t('Number'), + 'type_date' => t('Date'), + 'type_checkbox' => t('Checkbox'), + 'type_select' => t('Select'), + 'text_content' => t('Text content'), + 'field' => t('Field'), + 'preview' => t('Preview'), + 'preview_empty' => t('Add fields to see a live preview.'), + ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>" + >
+ +
+
+
+
+
+
diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php index 1fc7cd1..cd3658e 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php @@ -128,8 +128,8 @@ class SoftwareProductServiceTest extends TestCase ->willReturn(true); $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'date', 'key' => 'handover_date', 'label' => 'Handover date', 'required' => true], - ['type' => 'textarea', 'key' => 'notes', 'label' => 'Notes', 'required' => false], + ['type' => 'date', 'label' => 'Handover date', 'required' => true], + ['type' => 'textarea', 'label' => 'Notes', 'required' => false], ]); $this->assertTrue($result['ok']); @@ -149,37 +149,25 @@ class SoftwareProductServiceTest extends TestCase $this->assertTrue($result['ok']); } - public function testSaveHandoverSchemaRejectsDuplicateKeys(): void + public function testSaveHandoverSchemaRejectsDuplicateGeneratedKeys(): void { [$service] = $this->createServiceWithProduct(); $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'text', 'key' => 'name', 'label' => 'Name 1'], - ['type' => 'text', 'key' => 'name', 'label' => 'Name 2'], + ['type' => 'text', 'label' => 'Name'], + ['type' => 'text', 'label' => 'Name'], ]); $this->assertFalse($result['ok']); $this->assertArrayHasKey('field_2_key', $result['errors']); } - public function testSaveHandoverSchemaRejectsInvalidKeyFormat(): void - { - [$service] = $this->createServiceWithProduct(); - - $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'text', 'key' => '1invalid', 'label' => 'Bad key'], - ]); - - $this->assertFalse($result['ok']); - $this->assertArrayHasKey('field_1_key', $result['errors']); - } - public function testSaveHandoverSchemaRejectsUnknownType(): void { [$service] = $this->createServiceWithProduct(); $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'unknown_type', 'key' => 'field', 'label' => 'Field'], + ['type' => 'unknown_type', 'label' => 'Field'], ]); $this->assertFalse($result['ok']); @@ -192,7 +180,7 @@ class SoftwareProductServiceTest extends TestCase $fields = []; for ($i = 0; $i < 51; $i++) { - $fields[] = ['type' => 'text', 'key' => 'field_' . $i, 'label' => 'Field ' . $i]; + $fields[] = ['type' => 'text', 'label' => 'Field ' . $i]; } $result = $service->saveHandoverSchema('INFO', $fields); @@ -206,13 +194,54 @@ class SoftwareProductServiceTest extends TestCase [$service] = $this->createServiceWithProduct(); $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'select', 'key' => 'choice', 'label' => 'Choice', 'options' => []], + ['type' => 'select', 'label' => 'Choice', 'options' => []], ]); $this->assertFalse($result['ok']); $this->assertArrayHasKey('field_1_options', $result['errors']); } + public function testSaveHandoverSchemaRejectsSelectOptionWithEmptyLabel(): void + { + [$service] = $this->createServiceWithProduct(); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'select', 'label' => 'Choice', 'options' => [ + ['label' => ''], + ]], + ]); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('field_1_option_1', $result['errors']); + } + + public function testSaveHandoverSchemaAutoGeneratesOptionValues(): void + { + [$service, $repository] = $this->createServiceWithProduct(); + $repository->expects($this->once()) + ->method('updateHandoverSchema') + ->with('INFO', $this->callback(function ($json) { + $data = json_decode($json, true); + $options = $data['fields'][0]['options'] ?? []; + + return count($options) === 2 + && $options[0]['value'] === 'niedrig' + && $options[0]['label'] === 'Niedrig' + && $options[1]['value'] === 'sehr_hoch' + && $options[1]['label'] === 'Sehr hoch'; + })) + ->willReturn(true); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'select', 'label' => 'Risk', 'options' => [ + ['label' => 'Niedrig'], + ['label' => 'Sehr hoch'], + ]], + ]); + + $this->assertTrue($result['ok']); + } + public function testSaveHandoverSchemaAllowsHeadingWithoutKey(): void { [$service, $repository] = $this->createServiceWithProduct(); @@ -244,7 +273,7 @@ class SoftwareProductServiceTest extends TestCase ->willReturn(true); $result = $service->saveHandoverSchema('INFO', [ - ['type' => 'text', 'key' => 'name', 'label' => 'Name'], + ['type' => 'text', 'label' => 'Name'], ]); $this->assertTrue($result['ok']); @@ -257,7 +286,7 @@ class SoftwareProductServiceTest extends TestCase $service = $this->createService($repository); $result = $service->saveHandoverSchema('NONEXISTENT', [ - ['type' => 'text', 'key' => 'name', 'label' => 'Name'], + ['type' => 'text', 'label' => 'Name'], ]); $this->assertFalse($result['ok']); diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 7494d56..7859efb 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -2336,7 +2336,140 @@ } } - /* Handover protocol schema editor */ + /* Handover protocol schema editor — split layout */ + .handover-schema-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--app-spacing); + align-items: start; + } + + @media (max-width: 960px) { + .handover-schema-split { + grid-template-columns: 1fr; + } + } + + .handover-schema-split-preview { + border: 1px solid var(--app-card-border-color); + border-radius: var(--app-border-radius); + background: var(--app-card-background-color); + position: sticky; + top: calc(var(--app-topbar-height) + var(--app-spacing) * 5); + } + + .handover-schema-preview-header { + font-weight: 600; + padding: calc(var(--app-spacing) * 0.6) calc(var(--app-spacing) * 0.75); + border-bottom: 1px solid var(--app-card-border-color); + font-size: 0.875rem; + } + + .handover-schema-preview { + padding: calc(var(--app-spacing) * 0.75); + } + + .handover-schema-preview-empty { + color: var(--app-muted-color); + font-style: italic; + } + + .handover-schema-preview-form { + display: grid; + gap: calc(var(--app-spacing) * 0.6); + } + + .handover-schema-preview-form h3 { + margin: calc(var(--app-spacing) * 0.25) 0 0; + font-size: 1rem; + } + + .handover-schema-preview-form p { + margin: 0; + color: var(--app-muted-color); + } + + .handover-schema-preview-field { + display: flex; + flex-direction: column; + gap: calc(var(--app-spacing) * 0.15); + } + + .handover-schema-preview-field label { + font-size: 0.8125rem; + font-weight: 500; + } + + .handover-schema-preview-field input:not([type="checkbox"]), + .handover-schema-preview-field textarea, + .handover-schema-preview-field select { + width: 100%; + } + + .handover-schema-preview-field:has(> input[type="checkbox"]) { + flex-direction: row; + align-items: center; + gap: 0; + } + + .handover-schema-preview-field > input[type="checkbox"] { + width: 1.25em; + height: 1.25em; + flex-shrink: 0; + margin-bottom: 0; + } + + .handover-schema-preview-form [data-field-index] { + transition: background-color 0.2s ease, box-shadow 0.2s ease; + border-radius: var(--app-border-radius); + padding: calc(var(--app-spacing) * 0.3); + margin: calc(var(--app-spacing) * -0.3); + } + + .handover-schema-preview-highlight { + background-color: var(--app-secondary-focus); + box-shadow: inset 0 0 0 1px var(--app-card-border-color); + } + + .handover-schema-preview-required { + color: var(--app-danger-color, hsl(0 70% 55%)); + } + + /* Handover protocol schema editor — reset core margin on buttons/inputs/selects */ + .handover-schema-split-editor button[type="button"], + .handover-schema-row select, + .handover-schema-row input, + .handover-schema-row textarea, + .handover-schema-row label { + margin-bottom: 0; + } + + /* Handover protocol schema editor — insert divider */ + .handover-schema-insert-divider { + display: flex; + align-items: center; + gap: calc(var(--app-spacing) * 0.5); + padding: calc(var(--app-spacing) * 0.25) 0; + } + + .handover-schema-insert-divider::before, + .handover-schema-insert-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--app-card-border-color); + } + + .handover-schema-insert-button { + width: 1.5rem; + height: 1.5rem; + padding: 0; + border-radius: 50%; + font-size: 0.75rem; + flex-shrink: 0; + } + + /* Handover protocol schema editor — field cards */ .handover-schema-empty { color: var(--app-muted-color); font-style: italic; @@ -2345,31 +2478,41 @@ .handover-schema-list { display: grid; - gap: calc(var(--app-spacing) * 0.5); - margin-bottom: calc(var(--app-spacing) * 0.75); + gap: calc(var(--app-spacing) * 0.75); + margin-bottom: var(--app-spacing); } .handover-schema-row { border: 1px solid var(--app-card-border-color); border-radius: var(--app-border-radius); - padding: calc(var(--app-spacing) * 0.5); + padding: calc(var(--app-spacing) * 0.75); background: var(--app-card-background-color); } .handover-schema-row-header { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; gap: calc(var(--app-spacing) * 0.5); margin-bottom: calc(var(--app-spacing) * 0.5); } + .handover-schema-row-title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--app-muted-color); + } + .handover-schema-row-actions { display: flex; gap: calc(var(--app-spacing) * 0.25); flex-shrink: 0; } + .handover-schema-row > .handover-schema-field-group { + margin-bottom: calc(var(--app-spacing) * 0.5); + } + .handover-schema-row-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); @@ -2377,10 +2520,14 @@ align-items: end; } + .handover-schema-row-required { + margin-top: calc(var(--app-spacing) * 0.4); + } + .handover-schema-field-group { display: flex; flex-direction: column; - gap: calc(var(--app-spacing) * 0.15); + gap: calc(var(--app-spacing) * 0.2); } .handover-schema-field-label { @@ -2400,26 +2547,30 @@ align-items: center; gap: calc(var(--app-spacing) * 0.25); cursor: pointer; - padding-top: calc(var(--app-spacing) * 0.25); } .handover-schema-options { - margin-top: calc(var(--app-spacing) * 0.5); - padding-top: calc(var(--app-spacing) * 0.5); + margin-top: calc(var(--app-spacing) * 0.65); + padding-top: calc(var(--app-spacing) * 0.65); border-top: 1px solid var(--app-card-border-color); } - .handover-schema-options > strong { - display: block; + .handover-schema-options-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: calc(var(--app-spacing) * 0.4); + } + + .handover-schema-options-header > strong { font-size: 0.8125rem; - margin-bottom: calc(var(--app-spacing) * 0.25); } .handover-schema-option-row { display: flex; - gap: calc(var(--app-spacing) * 0.25); + gap: calc(var(--app-spacing) * 0.35); align-items: center; - margin-bottom: calc(var(--app-spacing) * 0.25); + margin-bottom: calc(var(--app-spacing) * 0.35); } .handover-schema-option-row input[type="text"] { @@ -2427,22 +2578,4 @@ min-width: 0; } - .handover-schema-add-button, - .handover-schema-add-option-button { - margin-top: calc(var(--app-spacing) * 0.25); - } - - .handover-schema-button-move, - .handover-schema-button-remove, - .handover-schema-button-remove-option { - padding: calc(var(--app-spacing) * 0.15) calc(var(--app-spacing) * 0.35); - font-size: 0.8125rem; - cursor: pointer; - } - - .handover-schema-button-remove, - .handover-schema-button-remove-option { - color: var(--app-del-color, #c62828); - } - } diff --git a/modules/helpdesk/web/js/handover-schema-editor.js b/modules/helpdesk/web/js/handover-schema-editor.js index 9d9d7a2..35bc986 100644 --- a/modules/helpdesk/web/js/handover-schema-editor.js +++ b/modules/helpdesk/web/js/handover-schema-editor.js @@ -15,7 +15,7 @@ if (container) { } /** - * Remove all child nodes from an element (safe alternative to innerHTML = ''). + * Remove all child nodes from an element (safe alternative to setting markup). * @param {HTMLElement} el */ function clearElement(el) { @@ -28,12 +28,14 @@ function init(root) { 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 form = hiddenInput?.closest('form'); /** @type {Array} */ let fields = initialFields.map((f) => ({ ...f, options: f.options ? f.options.map((o) => ({ ...o })) : [] })); render(); + renderPreview(); syncHiddenInput(); if (form) { @@ -52,21 +54,33 @@ function init(root) { 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); } - const addButton = document.createElement('button'); - addButton.type = 'button'; - addButton.className = 'handover-schema-add-button'; - addButton.textContent = t.add_field || 'Add field'; - addButton.addEventListener('click', () => { - fields.push({ type: 'text', key: '', label: '', required: false, options: [] }); + 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')); + button.addEventListener('click', () => { + fields.splice(insertIndex, 0, { type: 'text', key: '', label: '', required: false, options: [] }); render(); notifyChange(); }); - root.appendChild(addButton); + divider.appendChild(button); + return divider; } function buildFieldRow(field, index) { @@ -75,15 +89,49 @@ 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)); - // --- Header with type + actions --- + // --- Header: "Feld #N" title + action icons --- const header = document.createElement('div'); header.className = 'handover-schema-row-header'; - // Type select - const typeGroup = createGroup(t.field_type || 'Type'); + 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-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 || 'Type') + ' ' + (index + 1)); + typeSelect.setAttribute('aria-label', (t.field_type || 'Field type') + ' ' + (index + 1)); FIELD_TYPES.forEach((ft) => { const opt = document.createElement('option'); opt.value = ft; @@ -107,57 +155,12 @@ function init(root) { notifyChange(); }); typeGroup.appendChild(typeSelect); - header.appendChild(typeGroup); + row.appendChild(typeGroup); - // Actions - const actions = document.createElement('div'); - actions.className = 'handover-schema-row-actions'; - - if (index > 0) { - actions.appendChild(createActionButton(t.move_up || 'Up', 'handover-schema-button-move', () => { - [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(createActionButton(t.move_down || 'Down', 'handover-schema-button-move', () => { - [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(createActionButton(t.remove_field || 'Remove', 'handover-schema-button-remove', () => { - fields.splice(index, 1); - render(); - notifyChange(); - }, (t.remove_field || 'Remove field') + ' ' + (index + 1))); - - header.appendChild(actions); - row.appendChild(header); - - // --- Body fields --- + // --- Body: label input --- const body = document.createElement('div'); body.className = 'handover-schema-row-body'; - // Key (hidden for display-only types) - if (!isDisplayOnly) { - const keyGroup = createGroup(t.field_key || 'Key'); - const keyInput = document.createElement('input'); - keyInput.type = 'text'; - keyInput.value = field.key || ''; - keyInput.placeholder = 'e.g. handover_date'; - keyInput.pattern = '[a-z][a-z0-9_]*'; - keyInput.setAttribute('aria-label', (t.field_key || 'Key') + ' ' + (index + 1)); - keyInput.addEventListener('input', () => { - field.key = keyInput.value; - notifyChange(); - }); - keyGroup.appendChild(keyInput); - body.appendChild(keyGroup); - } - - // Label (or text content for paragraph) 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'); @@ -168,41 +171,52 @@ function init(root) { } labelInput.value = field.label || ''; labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1)); - labelInput.addEventListener('input', () => { - field.label = labelInput.value; - notifyChange(); - }); + labelInput.addEventListener('input', () => { field.label = labelInput.value; notifyChange(); }); labelGroup.appendChild(labelInput); body.appendChild(labelGroup); - // Required toggle (hidden for display-only types) + row.appendChild(body); + + // --- Required toggle — own row below key/label --- if (!isDisplayOnly) { - const reqGroup = createGroup(''); + 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; - reqInput.addEventListener('change', () => { - field.required = reqInput.checked; - notifyChange(); - }); + reqInput.addEventListener('change', () => { field.required = reqInput.checked; notifyChange(); }); reqLabel.appendChild(reqInput); reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required'))); - reqGroup.appendChild(reqLabel); - body.appendChild(reqGroup); + reqRow.appendChild(reqLabel); + row.appendChild(reqRow); } - row.appendChild(body); - // --- 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'; - optionsSection.appendChild(optionsTitle); + 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')); + addOptButton.addEventListener('click', () => { + field.options.push({ value: '', label: '' }); + render(); + notifyChange(); + }); + optionsHeader.appendChild(addOptButton); + optionsSection.appendChild(optionsHeader); if (!field.options) field.options = []; @@ -210,48 +224,23 @@ function init(root) { const optRow = document.createElement('div'); optRow.className = 'handover-schema-option-row'; - const valInput = document.createElement('input'); - valInput.type = 'text'; - valInput.value = opt.value || ''; - valInput.placeholder = t.option_value || 'Value'; - valInput.setAttribute('aria-label', (t.option_value || 'Value') + ' ' + (optIndex + 1)); - valInput.addEventListener('input', () => { - opt.value = valInput.value; - notifyChange(); - }); - optRow.appendChild(valInput); - const lblInput = document.createElement('input'); lblInput.type = 'text'; lblInput.value = opt.label || ''; - lblInput.placeholder = t.option_label || '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(); - }); + lblInput.addEventListener('input', () => { opt.label = lblInput.value; notifyChange(); }); optRow.appendChild(lblInput); - optRow.appendChild(createActionButton(t.remove_option || 'Remove', 'handover-schema-button-remove-option', () => { - field.options.splice(optIndex, 1); - render(); - notifyChange(); - }, (t.remove_option || 'Remove option') + ' ' + (optIndex + 1))); + 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); }); - const addOptButton = document.createElement('button'); - addOptButton.type = 'button'; - addOptButton.className = 'handover-schema-add-option-button'; - addOptButton.textContent = t.add_option || 'Add option'; - addOptButton.addEventListener('click', () => { - field.options.push({ value: '', label: '' }); - render(); - notifyChange(); - }); - optionsSection.appendChild(addOptButton); - row.appendChild(optionsSection); } @@ -270,16 +259,120 @@ function init(root) { return group; } - function createActionButton(text, className, onClick, ariaLabel) { + 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 = className; - button.textContent = text; - if (ariaLabel) button.setAttribute('aria-label', ariaLabel); + button.className = buttonClass; + button.setAttribute('aria-label', ariaLabel); + button.appendChild(createIcon(iconClass)); button.addEventListener('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'; + input.disabled = true; + 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) : ''; @@ -287,6 +380,7 @@ function init(root) { function notifyChange() { syncHiddenInput(); + renderPreview(); if (hiddenInput) { hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); }