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'),
+ '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