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 <noreply@anthropic.com>
This commit is contained in:
@@ -375,5 +375,39 @@
|
|||||||
"Product details": "Produktdetails",
|
"Product details": "Produktdetails",
|
||||||
"Enter product name...": "Produktname eingeben...",
|
"Enter product name...": "Produktname eingeben...",
|
||||||
"Name must not exceed 255 characters": "Name darf maximal 255 Zeichen lang sein",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -375,5 +375,39 @@
|
|||||||
"Product details": "Product details",
|
"Product details": "Product details",
|
||||||
"Enter product name...": "Enter product name...",
|
"Enter product name...": "Enter product name...",
|
||||||
"Name must not exceed 255 characters": "Name must not exceed 255 characters",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
public function updateName(string $code, string $name): bool
|
||||||
{
|
{
|
||||||
if (trim($code) === '') {
|
if (trim($code) === '') {
|
||||||
|
|||||||
@@ -28,6 +28,141 @@ class SoftwareProductService
|
|||||||
return $this->repository->listPaged($filters);
|
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<array<string, mixed>> $fields
|
||||||
|
* @return array{ok: bool, errors: array<string, string>}
|
||||||
|
*/
|
||||||
|
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<array<string, mixed>> $fields
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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.
|
* Update the user-managed name field for a software product.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE `helpdesk_software_products`
|
||||||
|
ADD COLUMN IF NOT EXISTS `handover_protocol_schema` TEXT DEFAULT NULL AFTER `name`;
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
$values = is_array($values ?? null) ? $values : [];
|
$values = is_array($values ?? null) ? $values : [];
|
||||||
$formId = $formId ?? 'software-product-form';
|
$formId = $formId ?? 'software-product-form';
|
||||||
|
$handoverSchemaFields = is_array($handoverSchemaFields ?? null) ? $handoverSchemaFields : [];
|
||||||
?>
|
?>
|
||||||
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
|
||||||
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-software-product-form">
|
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="helpdesk-software-product-form">
|
||||||
<div class="app-tabs-nav">
|
<div class="app-tabs-nav">
|
||||||
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
|
||||||
|
<button type="button" data-tab="handover-protocol"><?php e(t('Handover protocol')); ?></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-tab-panel="basic">
|
<div data-tab-panel="basic">
|
||||||
@@ -23,6 +25,45 @@ $formId = $formId ?? 'software-product-form';
|
|||||||
>
|
>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
<?php \MintyPHP\Session::getCsrfInput(); ?>
|
||||||
|
|||||||
@@ -43,7 +43,21 @@ if ($request->isMethod('POST')) {
|
|||||||
$form = $result['form'] ?? $form;
|
$form = $result['form'] ?? $form;
|
||||||
$errorBag->merge($result['errors'] ?? []);
|
$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');
|
$action = (string) $request->body('action', 'save');
|
||||||
if ($action === 'save_close') {
|
if ($action === 'save_close') {
|
||||||
Flash::success(t('Software product updated'), $closeTarget, 'software_product_updated');
|
Flash::success(t('Software product updated'), $closeTarget, 'software_product_updated');
|
||||||
@@ -67,3 +81,13 @@ $breadcrumbs = [
|
|||||||
['label' => t('Software products'), 'path' => 'helpdesk/software-products'],
|
['label' => t('Software products'), 'path' => 'helpdesk/software-products'],
|
||||||
['label' => $titleText],
|
['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'] ?? [];
|
||||||
|
|||||||
@@ -87,3 +87,4 @@ $active = (int) ($values['active'] ?? 1);
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
<script type="module" src="<?php e(assetVersion('modules/helpdesk/js/handover-schema-editor.js')); ?>"></script>
|
||||||
|
|||||||
@@ -94,6 +94,176 @@ class SoftwareProductServiceTest extends TestCase
|
|||||||
$this->assertTrue($result['ok']);
|
$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
|
public function testListPagedDelegatesToRepository(): void
|
||||||
{
|
{
|
||||||
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];
|
$expected = ['total' => 2, 'rows' => [['code' => 'A'], ['code' => 'B']]];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
294
modules/helpdesk/web/js/handover-schema-editor.js
Normal file
294
modules/helpdesk/web/js/handover-schema-editor.js
Normal file
@@ -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<object>} */
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user