From 44153f513fbab8c31e18046ef5f801c4590baee5 Mon Sep 17 00:00:00 2001 From: fs Date: Wed, 15 Apr 2026 16:44:12 +0200 Subject: [PATCH] feat(helpdesk): add handover protocol schema editor to software products Add a JSON-based schema editor as a new tab on the software product edit page. Admins can define protocol fields (heading, paragraph, text, textarea, number, date, checkbox, select) via a structured UI with add/remove/reorder controls. The schema is stored as a versioned JSON column on the product row. Includes DB migration, server-side validation (type whitelist, key uniqueness, key format, max 50 fields, select option checks), i18n (DE+EN), CSS, and 10 PHPUnit tests. Co-Authored-By: Claude Opus 4.6 --- modules/helpdesk/i18n/default_de.json | 36 ++- modules/helpdesk/i18n/default_en.json | 36 ++- .../Repository/SoftwareProductRepository.php | 15 + .../Service/SoftwareProductService.php | 135 ++++++++ .../004_add_handover_protocol_schema.sql | 2 + .../helpdesk/software-products/_form.phtml | 41 +++ .../software-products/edit($code).php | 26 +- .../software-products/edit(default).phtml | 1 + .../Service/SoftwareProductServiceTest.php | 170 ++++++++++ modules/helpdesk/web/css/helpdesk.css | 109 +++++++ .../helpdesk/web/js/handover-schema-editor.js | 294 ++++++++++++++++++ 11 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 modules/helpdesk/migrations/004_add_handover_protocol_schema.sql create mode 100644 modules/helpdesk/web/js/handover-schema-editor.js diff --git a/modules/helpdesk/i18n/default_de.json b/modules/helpdesk/i18n/default_de.json index 8b24595..c98f06d 100644 --- a/modules/helpdesk/i18n/default_de.json +++ b/modules/helpdesk/i18n/default_de.json @@ -375,5 +375,39 @@ "Product details": "Produktdetails", "Enter product name...": "Produktname eingeben...", "Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein", - "Failed to save": "Speichern fehlgeschlagen" + "Failed to save": "Speichern fehlgeschlagen", + "Handover protocol": "Übergabeprotokoll", + "Handover protocol schema": "Übergabeprotokoll-Schema", + "Define the fields that make up the handover protocol for this product.": "Definieren Sie die Felder, die das Übergabeprotokoll für dieses Produkt bilden.", + "Add field": "Feld hinzufügen", + "Remove field": "Feld entfernen", + "Move up": "Nach oben", + "Move down": "Nach unten", + "Field key": "Feld-Schlüssel", + "Field label": "Feld-Bezeichnung", + "Field type": "Feldtyp", + "Required": "Pflichtfeld", + "Options": "Optionen", + "Add option": "Option hinzufügen", + "Remove option": "Option entfernen", + "Option value": "Optionswert", + "Option label": "Optionsbezeichnung", + "No fields defined yet. Click \"Add field\" to get started.": "Noch keine Felder definiert. Klicken Sie auf \"Feld hinzufügen\" um zu beginnen.", + "Heading": "Überschrift", + "Paragraph": "Absatz", + "Text": "Text", + "Textarea": "Textbereich", + "Number": "Zahl", + "Date": "Datum", + "Checkbox": "Checkbox", + "Select": "Auswahl", + "Text content": "Textinhalt", + "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: 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" } diff --git a/modules/helpdesk/i18n/default_en.json b/modules/helpdesk/i18n/default_en.json index 8ce3ede..746ef8e 100644 --- a/modules/helpdesk/i18n/default_en.json +++ b/modules/helpdesk/i18n/default_en.json @@ -375,5 +375,39 @@ "Product details": "Product details", "Enter product name...": "Enter product name...", "Name must not exceed 255 characters": "Name must not exceed 255 characters", - "Failed to save": "Failed to save" + "Failed to save": "Failed to save", + "Handover protocol": "Handover protocol", + "Handover protocol schema": "Handover protocol schema", + "Define the fields that make up the handover protocol for this product.": "Define the fields that make up the handover protocol for this product.", + "Add field": "Add field", + "Remove field": "Remove field", + "Move up": "Move up", + "Move down": "Move down", + "Field key": "Field key", + "Field label": "Field label", + "Field type": "Field type", + "Required": "Required", + "Options": "Options", + "Add option": "Add option", + "Remove option": "Remove option", + "Option value": "Option value", + "Option label": "Option label", + "No fields defined yet. Click \"Add field\" to get started.": "No fields defined yet. Click \"Add field\" to get started.", + "Heading": "Heading", + "Paragraph": "Paragraph", + "Text": "Text", + "Textarea": "Textarea", + "Number": "Number", + "Date": "Date", + "Checkbox": "Checkbox", + "Select": "Select", + "Text content": "Text content", + "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: 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" } diff --git a/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php index 9064259..2efe52a 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Repository/SoftwareProductRepository.php @@ -92,6 +92,21 @@ class SoftwareProductRepository ]; } + public function updateHandoverSchema(string $code, ?string $schemaJson): bool + { + if (trim($code) === '') { + return false; + } + + $result = DB::update( + 'UPDATE helpdesk_software_products SET handover_protocol_schema = ? WHERE code = ?', + $schemaJson, + $code + ); + + return $result !== false; + } + public function updateName(string $code, string $name): bool { if (trim($code) === '') { diff --git a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php index 80e8053..f187c7d 100644 --- a/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php +++ b/modules/helpdesk/lib/Module/Helpdesk/Service/SoftwareProductService.php @@ -28,6 +28,141 @@ class SoftwareProductService return $this->repository->listPaged($filters); } + private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; + private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; + private const MAX_SCHEMA_FIELDS = 50; + + /** + * Validate and save a handover protocol schema for a software product. + * + * @param list> $fields + * @return array{ok: bool, errors: array} + */ + public function saveHandoverSchema(string $code, array $fields): array + { + $errors = []; + + $product = $this->repository->findByCode($code); + if ($product === null) { + return ['ok' => false, 'errors' => ['schema' => t('Software product not found')]]; + } + + if ($fields === []) { + $this->repository->updateHandoverSchema($code, null); + + return ['ok' => true, 'errors' => []]; + } + + if (count($fields) > self::MAX_SCHEMA_FIELDS) { + $errors['schema'] = t('Maximum %d fields allowed', self::MAX_SCHEMA_FIELDS); + + return ['ok' => false, 'errors' => $errors]; + } + + $seenKeys = []; + + foreach ($fields as $index => $field) { + $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)) { + $errors["field_{$fieldNum}_type"] = t('Field %d: unknown type "%s"', $fieldNum, $type); + continue; + } + + if ($label === '') { + $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); + } else { + $seenKeys[$key] = true; + } + } + + if ($type === 'select') { + $options = $field['options'] ?? []; + if (!is_array($options) || $options === []) { + $errors["field_{$fieldNum}_options"] = t('Field %d: select must have at least one option', $fieldNum); + } 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 ($errors !== []) { + return ['ok' => false, 'errors' => $errors]; + } + + $normalized = $this->normalizeSchemaFields($fields); + + $currentSchema = $product['handover_protocol_schema'] ?? null; + $currentVersion = 0; + if ($currentSchema !== null) { + $decoded = json_decode($currentSchema, true); + $currentVersion = (int) ($decoded['version'] ?? 0); + } + + $schema = [ + 'version' => $currentVersion + 1, + 'fields' => $normalized, + ]; + + $json = json_encode($schema, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $this->repository->updateHandoverSchema($code, $json); + + return ['ok' => true, 'errors' => []]; + } + + /** + * @param list> $fields + * @return list> + */ + private function normalizeSchemaFields(array $fields): array + { + $normalized = []; + + foreach ($fields as $field) { + $type = (string) ($field['type'] ?? ''); + $isDisplayOnly = in_array($type, self::DISPLAY_ONLY_TYPES, true); + + $entry = ['type' => $type, 'label' => trim((string) ($field['label'] ?? ''))]; + + if (!$isDisplayOnly) { + $entry['key'] = trim((string) ($field['key'] ?? '')); + $entry['required'] = !empty($field['required']); + } + + if ($type === 'select' && is_array($field['options'] ?? null)) { + $entry['options'] = []; + foreach ($field['options'] as $opt) { + $entry['options'][] = [ + 'value' => trim((string) ($opt['value'] ?? '')), + 'label' => trim((string) ($opt['label'] ?? '')), + ]; + } + } + + $normalized[] = $entry; + } + + return $normalized; + } + /** * Update the user-managed name field for a software product. * diff --git a/modules/helpdesk/migrations/004_add_handover_protocol_schema.sql b/modules/helpdesk/migrations/004_add_handover_protocol_schema.sql new file mode 100644 index 0000000..99dd60f --- /dev/null +++ b/modules/helpdesk/migrations/004_add_handover_protocol_schema.sql @@ -0,0 +1,2 @@ +ALTER TABLE `helpdesk_software_products` + ADD COLUMN IF NOT EXISTS `handover_protocol_schema` TEXT DEFAULT NULL AFTER `name`; diff --git a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml index d2d4725..9f77736 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/_form.phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/_form.phtml @@ -1,11 +1,13 @@
+
@@ -23,6 +25,45 @@ $formId = $formId ?? 'software-product-form'; >
+ +
+
+ +

+ +
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)); ?>" + >
+ + +
+
diff --git a/modules/helpdesk/pages/helpdesk/software-products/edit($code).php b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php index 9457e1b..020d23f 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/edit($code).php +++ b/modules/helpdesk/pages/helpdesk/software-products/edit($code).php @@ -43,7 +43,21 @@ if ($request->isMethod('POST')) { $form = $result['form'] ?? $form; $errorBag->merge($result['errors'] ?? []); - if (($result['ok'] ?? false) && !$errorBag->hasAny()) { + $schemaJson = (string) $request->body('handover_schema_json', ''); + $schemaFields = []; + if ($schemaJson !== '') { + $decoded = json_decode($schemaJson, true); + if (is_array($decoded)) { + $schemaFields = $decoded; + } + } + $schemaResult = $service->saveHandoverSchema($productCode, $schemaFields); + $errorBag->merge($schemaResult['errors'] ?? []); + + $allOk = ($result['ok'] ?? false) && ($schemaResult['ok'] ?? false); + + if ($allOk && !$errorBag->hasAny()) { + $product = $service->findByCode($productCode); $action = (string) $request->body('action', 'save'); if ($action === 'save_close') { Flash::success(t('Software product updated'), $closeTarget, 'software_product_updated'); @@ -67,3 +81,13 @@ $breadcrumbs = [ ['label' => t('Software products'), 'path' => 'helpdesk/software-products'], ['label' => $titleText], ]; + +$handoverSchemaRaw = $product['handover_protocol_schema'] ?? null; +$handoverSchema = null; +if ($handoverSchemaRaw !== null) { + $decoded = json_decode($handoverSchemaRaw, true); + if (is_array($decoded)) { + $handoverSchema = $decoded; + } +} +$handoverSchemaFields = $handoverSchema['fields'] ?? []; diff --git a/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml b/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml index 3293e2c..a2f5841 100644 --- a/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml +++ b/modules/helpdesk/pages/helpdesk/software-products/edit(default).phtml @@ -87,3 +87,4 @@ $active = (int) ($values['active'] ?? 1); + diff --git a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php index 125d997..1fc7cd1 100644 --- a/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php +++ b/modules/helpdesk/tests/Module/Helpdesk/Service/SoftwareProductServiceTest.php @@ -94,6 +94,176 @@ class SoftwareProductServiceTest extends TestCase $this->assertTrue($result['ok']); } + // --- Handover schema tests --- + + private function createServiceWithProduct(array $product = []): array + { + $product = array_merge([ + 'id' => 1, + 'code' => 'INFO', + 'name' => 'Infopoints', + 'active' => 1, + 'handover_protocol_schema' => null, + ], $product); + + $repository = $this->createMock(SoftwareProductRepository::class); + $repository->method('findByCode')->willReturn($product); + + return [$this->createService($repository), $repository]; + } + + public function testSaveHandoverSchemaWithValidFields(): void + { + [$service, $repository] = $this->createServiceWithProduct(); + $repository->expects($this->once()) + ->method('updateHandoverSchema') + ->with('INFO', $this->callback(function ($json) { + $data = json_decode($json, true); + + return $data['version'] === 1 + && count($data['fields']) === 2 + && $data['fields'][0]['key'] === 'handover_date' + && $data['fields'][1]['key'] === 'notes'; + })) + ->willReturn(true); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'date', 'key' => 'handover_date', 'label' => 'Handover date', 'required' => true], + ['type' => 'textarea', 'key' => 'notes', 'label' => 'Notes', 'required' => false], + ]); + + $this->assertTrue($result['ok']); + $this->assertEmpty($result['errors']); + } + + public function testSaveHandoverSchemaEmptyFieldsSetsNull(): void + { + [$service, $repository] = $this->createServiceWithProduct(); + $repository->expects($this->once()) + ->method('updateHandoverSchema') + ->with('INFO', null) + ->willReturn(true); + + $result = $service->saveHandoverSchema('INFO', []); + + $this->assertTrue($result['ok']); + } + + public function testSaveHandoverSchemaRejectsDuplicateKeys(): void + { + [$service] = $this->createServiceWithProduct(); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'text', 'key' => 'name', 'label' => 'Name 1'], + ['type' => 'text', 'key' => 'name', 'label' => 'Name 2'], + ]); + + $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'], + ]); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('field_1_type', $result['errors']); + } + + public function testSaveHandoverSchemaRejectsMoreThan50Fields(): void + { + [$service] = $this->createServiceWithProduct(); + + $fields = []; + for ($i = 0; $i < 51; $i++) { + $fields[] = ['type' => 'text', 'key' => 'field_' . $i, 'label' => 'Field ' . $i]; + } + + $result = $service->saveHandoverSchema('INFO', $fields); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('schema', $result['errors']); + } + + public function testSaveHandoverSchemaRejectsSelectWithNoOptions(): void + { + [$service] = $this->createServiceWithProduct(); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'select', 'key' => 'choice', 'label' => 'Choice', 'options' => []], + ]); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('field_1_options', $result['errors']); + } + + public function testSaveHandoverSchemaAllowsHeadingWithoutKey(): void + { + [$service, $repository] = $this->createServiceWithProduct(); + $repository->expects($this->once()) + ->method('updateHandoverSchema') + ->willReturn(true); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'heading', 'label' => 'Section Title'], + ]); + + $this->assertTrue($result['ok']); + } + + public function testSaveHandoverSchemaIncrementsVersion(): void + { + $existingSchema = json_encode(['version' => 3, 'fields' => []], JSON_THROW_ON_ERROR); + [$service, $repository] = $this->createServiceWithProduct([ + 'handover_protocol_schema' => $existingSchema, + ]); + + $repository->expects($this->once()) + ->method('updateHandoverSchema') + ->with('INFO', $this->callback(function ($json) { + $data = json_decode($json, true); + + return $data['version'] === 4; + })) + ->willReturn(true); + + $result = $service->saveHandoverSchema('INFO', [ + ['type' => 'text', 'key' => 'name', 'label' => 'Name'], + ]); + + $this->assertTrue($result['ok']); + } + + public function testSaveHandoverSchemaRejectsMissingProduct(): void + { + $repository = $this->createMock(SoftwareProductRepository::class); + $repository->method('findByCode')->willReturn(null); + + $service = $this->createService($repository); + $result = $service->saveHandoverSchema('NONEXISTENT', [ + ['type' => 'text', 'key' => 'name', 'label' => 'Name'], + ]); + + $this->assertFalse($result['ok']); + $this->assertArrayHasKey('schema', $result['errors']); + } + public function testListPagedDelegatesToRepository(): void { $expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]]; diff --git a/modules/helpdesk/web/css/helpdesk.css b/modules/helpdesk/web/css/helpdesk.css index 60d1ce8..7494d56 100644 --- a/modules/helpdesk/web/css/helpdesk.css +++ b/modules/helpdesk/web/css/helpdesk.css @@ -2336,4 +2336,113 @@ } } + /* Handover protocol schema editor */ + .handover-schema-empty { + color: var(--app-muted-color); + font-style: italic; + padding: calc(var(--app-spacing) * 0.5) 0; + } + + .handover-schema-list { + display: grid; + gap: calc(var(--app-spacing) * 0.5); + margin-bottom: calc(var(--app-spacing) * 0.75); + } + + .handover-schema-row { + border: 1px solid var(--app-card-border-color); + border-radius: var(--app-border-radius); + padding: calc(var(--app-spacing) * 0.5); + background: var(--app-card-background-color); + } + + .handover-schema-row-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: calc(var(--app-spacing) * 0.5); + margin-bottom: calc(var(--app-spacing) * 0.5); + } + + .handover-schema-row-actions { + display: flex; + gap: calc(var(--app-spacing) * 0.25); + flex-shrink: 0; + } + + .handover-schema-row-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: calc(var(--app-spacing) * 0.5); + align-items: end; + } + + .handover-schema-field-group { + display: flex; + flex-direction: column; + gap: calc(var(--app-spacing) * 0.15); + } + + .handover-schema-field-label { + font-size: 0.8125rem; + font-weight: 500; + color: var(--app-muted-color); + } + + .handover-schema-field-group input[type="text"], + .handover-schema-field-group textarea, + .handover-schema-field-group select { + width: 100%; + } + + .handover-schema-checkbox-label { + display: inline-flex; + 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); + border-top: 1px solid var(--app-card-border-color); + } + + .handover-schema-options > strong { + display: block; + 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); + align-items: center; + margin-bottom: calc(var(--app-spacing) * 0.25); + } + + .handover-schema-option-row input[type="text"] { + flex: 1; + 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 new file mode 100644 index 0000000..9d9d7a2 --- /dev/null +++ b/modules/helpdesk/web/js/handover-schema-editor.js @@ -0,0 +1,294 @@ +/** + * Handover protocol schema editor. + * + * Renders a structured field list from JSON and serializes changes + * back to a hidden input on form submit. + */ + +const FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select']; +const DISPLAY_ONLY_TYPES = ['heading', 'paragraph']; + +/** @type {HTMLElement|null} */ +const container = document.getElementById('handover-schema-editor'); +if (container) { + init(container); +} + +/** + * Remove all child nodes from an element (safe alternative to innerHTML = ''). + * @param {HTMLElement} el + */ +function clearElement(el) { + while (el.firstChild) { + el.removeChild(el.firstChild); + } +} + +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 form = hiddenInput?.closest('form'); + + /** @type {Array} */ + let fields = initialFields.map((f) => ({ ...f, options: f.options ? f.options.map((o) => ({ ...o })) : [] })); + + render(); + syncHiddenInput(); + + if (form) { + form.addEventListener('submit', () => syncHiddenInput()); + } + + function render() { + clearElement(root); + + if (fields.length === 0) { + const empty = document.createElement('p'); + empty.className = 'handover-schema-empty'; + empty.textContent = t.no_fields || 'No fields defined yet.'; + root.appendChild(empty); + } else { + const list = document.createElement('div'); + list.className = 'handover-schema-list'; + fields.forEach((field, 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: [] }); + render(); + notifyChange(); + }); + root.appendChild(addButton); + } + + 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'; + + // --- Header with type + actions --- + const header = document.createElement('div'); + header.className = 'handover-schema-row-header'; + + // Type select + const typeGroup = createGroup(t.field_type || 'Type'); + const typeSelect = document.createElement('select'); + typeSelect.setAttribute('aria-label', (t.field_type || '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); + }); + typeSelect.addEventListener('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); + header.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 --- + 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'); + if (field.type !== 'paragraph') { + labelInput.type = 'text'; + } else { + labelInput.rows = 2; + } + labelInput.value = field.label || ''; + labelInput.setAttribute('aria-label', labelKey + ' ' + (index + 1)); + labelInput.addEventListener('input', () => { + field.label = labelInput.value; + notifyChange(); + }); + labelGroup.appendChild(labelInput); + body.appendChild(labelGroup); + + // Required toggle (hidden for display-only types) + if (!isDisplayOnly) { + const reqGroup = createGroup(''); + 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(); + }); + reqLabel.appendChild(reqInput); + reqLabel.appendChild(document.createTextNode(' ' + (t.required || 'Required'))); + reqGroup.appendChild(reqLabel); + body.appendChild(reqGroup); + } + + row.appendChild(body); + + // --- Select options --- + if (isSelect) { + const optionsSection = document.createElement('div'); + optionsSection.className = 'handover-schema-options'; + + const optionsTitle = document.createElement('strong'); + optionsTitle.textContent = t.options || 'Options'; + optionsSection.appendChild(optionsTitle); + + if (!field.options) field.options = []; + + field.options.forEach((opt, optIndex) => { + 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.setAttribute('aria-label', (t.option_label || 'Label') + ' ' + (optIndex + 1)); + 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))); + + 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); + } + + 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 createActionButton(text, className, onClick, ariaLabel) { + const button = document.createElement('button'); + button.type = 'button'; + button.className = className; + button.textContent = text; + if (ariaLabel) button.setAttribute('aria-label', ariaLabel); + button.addEventListener('click', onClick); + return button; + } + + function syncHiddenInput() { + if (!hiddenInput) return; + hiddenInput.value = fields.length > 0 ? JSON.stringify(fields) : ''; + } + + function notifyChange() { + syncHiddenInput(); + if (hiddenInput) { + hiddenInput.dispatchEvent(new Event('input', { bubbles: true })); + } + } +}