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:
@@ -92,6 +92,21 @@ class SoftwareProductRepository
|
||||
];
|
||||
}
|
||||
|
||||
public function updateHandoverSchema(string $code, ?string $schemaJson): bool
|
||||
{
|
||||
if (trim($code) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$result = DB::update(
|
||||
'UPDATE helpdesk_software_products SET handover_protocol_schema = ? WHERE code = ?',
|
||||
$schemaJson,
|
||||
$code
|
||||
);
|
||||
|
||||
return $result !== false;
|
||||
}
|
||||
|
||||
public function updateName(string $code, string $name): bool
|
||||
{
|
||||
if (trim($code) === '') {
|
||||
|
||||
@@ -28,6 +28,141 @@ class SoftwareProductService
|
||||
return $this->repository->listPaged($filters);
|
||||
}
|
||||
|
||||
private const ALLOWED_FIELD_TYPES = ['heading', 'paragraph', 'text', 'textarea', 'number', 'date', 'checkbox', 'select'];
|
||||
private const DISPLAY_ONLY_TYPES = ['heading', 'paragraph'];
|
||||
private const MAX_SCHEMA_FIELDS = 50;
|
||||
|
||||
/**
|
||||
* Validate and save a handover protocol schema for a software product.
|
||||
*
|
||||
* @param list<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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user