1
0

feat(helpdesk): polish handover schema editor UI/UX

Auto-generate field keys and option values from labels via slugify,
add live preview panel with hover-highlight, insert-between-fields
dividers, and various spacing/styling improvements.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 18:08:12 +02:00
parent 44153f513f
commit 2ec632ac8b
7 changed files with 494 additions and 221 deletions

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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.
*

View File

@@ -27,42 +27,47 @@ $handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchem
</div>
<div data-tab-panel="handover-protocol">
<fieldset>
<legend><?php e(t('Handover protocol schema')); ?></legend>
<p class="app-field-hint"><?php e(t('Define the fields that make up the handover protocol for this product.')); ?></p>
<div
id="handover-schema-editor"
data-initial-fields="<?php e(json_encode($handoverSchemaFields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>"
data-translations="<?php e(json_encode([
'add_field' => t('Add field'),
'remove_field' => t('Remove field'),
'move_up' => t('Move up'),
'move_down' => t('Move down'),
'field_key' => t('Field key'),
'field_label' => t('Field label'),
'field_type' => t('Field type'),
'required' => t('Required'),
'options' => t('Options'),
'add_option' => t('Add option'),
'remove_option' => t('Remove option'),
'option_value' => t('Option value'),
'option_label' => t('Option label'),
'no_fields' => t('No fields defined yet. Click "Add field" to get started.'),
'type_heading' => 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)); ?>"
></div>
<input type="hidden" name="handover_schema_json" id="handover-schema-json" value="">
</fieldset>
<div class="handover-schema-split">
<div class="handover-schema-split-editor">
<div
id="handover-schema-editor"
data-initial-fields="<?php e(json_encode($handoverSchemaFields, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); ?>"
data-translations="<?php e(json_encode([
'add_field' => t('Add field'),
'remove_field' => t('Remove field'),
'move_up' => t('Move up'),
'move_down' => t('Move down'),
'field_key' => t('Field key'),
'field_label' => t('Field label'),
'field_type' => t('Field type'),
'required' => t('Required'),
'options' => t('Options'),
'add_option' => t('Add option'),
'remove_option' => t('Remove option'),
'option_value' => t('Option value'),
'option_label' => t('Option label'),
'no_fields' => t('No fields defined yet. Click "Add field" to get started.'),
'type_heading' => 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)); ?>"
></div>
<input type="hidden" name="handover_schema_json" id="handover-schema-json" value="">
</div>
<div class="handover-schema-split-preview">
<div class="handover-schema-preview-header"><?php e(t('Preview')); ?></div>
<div id="handover-schema-preview" class="handover-schema-preview"></div>
</div>
</div>
</div>
</div>

View File

@@ -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']);

View File

@@ -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);
}
}

View File

@@ -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<object>} */
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 }));
}