From 0575c0c79997ba4fbda83b21c4881014e32067d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Jun 2026 17:45:43 +0000 Subject: [PATCH] refactor(ux): Listen-Editoren auf gemeinsame Schluessel-Validierung vereinheitlicht Geteilte common/listItemValidation.getDuplicateKeyError (unit-getestet) fuer Auswahlfeld-Optionen und Chip-Gruppen. SingleSelection nutzt sie statt eigener Logik (gleiche Meldungen). ChipGroup bekommt damit erstmals Eindeutigkeits-/ Leer-Pruefung der Chip-field_id (Fehler am Feld + Speichern gesperrt), den IconField-Picker fuers Chip-Icon und einen Leerzustand-Hinweis. Die unterschiedlichen Editiermodelle (Inline-Tabelle vs. Card+Dialog) bleiben bewusst - die Datenformen weichen zu stark ab; ein struktureller Voll-Merge waere ein groesserer Folge-Schritt. --- plans/ux-overhaul.md | 2 +- .../common/listItemValidation.test.ts | 28 ++++++++++++++ .../common/listItemValidation.ts | 24 ++++++++++++ .../editors/ChipGroupEditor.tsx | 37 +++++++++++++++---- .../SingleSelectionElementEditorEnhanced.tsx | 12 +++--- 5 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/components/PropertyEditor/common/listItemValidation.test.ts create mode 100644 src/components/PropertyEditor/common/listItemValidation.ts diff --git a/plans/ux-overhaul.md b/plans/ux-overhaul.md index 9f36791..3262c62 100644 --- a/plans/ux-overhaul.md +++ b/plans/ux-overhaul.md @@ -53,7 +53,7 @@ Editor-Factory-Muster, `AccordionSection`, `TabbedTranslatableFields`, `ElementP ## Phase 4 — Editor-Konsistenz & Wartbarkeit - [ ] `useElementUpdate`-Hook (dedupliziert Handler-Boilerplate über ~12 Editoren). -- [ ] Listen-Editoren vereinheitlichen (ChipGroup-Dialog vs. SingleSelection-Tabelle). +- [~] **Listen-Editoren vereinheitlicht (Verhaltensebene)** *(PR)*: Geteilte `common/listItemValidation.getDuplicateKeyError` (unit-getestet) für beide Listen-Editoren; SingleSelection nutzt sie jetzt (statt eigener Logik), ChipGroup erhält damit erstmals Eindeutigkeits-/Leer-Prüfung der Chip-`field_id` (Fehler am Feld + „Speichern" gesperrt). Zusätzlich: Chip-Icon über `IconField`-Picker (statt rohem Textfeld) und Leerzustand-Hinweis — konsistente Affordanzen. **Bewusst offen:** Die unterschiedlichen Editiermodelle (Inline-Tabelle für einfache Optionen vs. Card+Dialog für reichhaltige Chips) bleiben, da die Datenformen stark abweichen; ein struktureller Voll-Merge (inline für beide) wäre ein größerer Folge-Schritt. - [~] **Feld-ID-Prominenz** *(PR Phase 4)*: gemeinsame `common/FieldIdField` (Pflicht-Marker, Hilfetext, Leer-Warnung) **zentral** im `EnhancedElementEditorFactory` für alle wertführenden Typen (Boolean/String/Number/Date/SingleSelection). Befund: Feld-ID war zuvor uneinheitlich — bei Number/Date/Boolean/SingleSelection gar nicht editierbar, bei String als String statt `{field_name}`. Jetzt überall sichtbar + einheitlich als `{field_name}` geschrieben. Offen: breitere Pflichtfeld-Markierungen, `useElementUpdate`-Hook. - [x] **Duplikat-/Leer-Optionen verhindern** *(PR Phase 4)*: Auswahlfeld-Optionen-Schlüssel werden auf Eindeutigkeit und Nicht-Leere geprüft (`getOptionKeyError`, Unit-getestet). Befund: `handleUpdateOptionKey` erlaubte beliebige Keys ohne Prüfung — doppelte/leere `key`s sind vertragskritisch (portal speichert den Wert über `key`). Jetzt: Fehlerstatus + Hilfetext am Key-Feld, Sammel-Warnung über der Optionen-Tabelle, kollisionssichere Auto-Key-Generierung beim Hinzufügen. diff --git a/src/components/PropertyEditor/common/listItemValidation.test.ts b/src/components/PropertyEditor/common/listItemValidation.test.ts new file mode 100644 index 0000000..0d1d72d --- /dev/null +++ b/src/components/PropertyEditor/common/listItemValidation.test.ts @@ -0,0 +1,28 @@ +import { getDuplicateKeyError } from './listItemValidation'; + +describe('getDuplicateKeyError', () => { + it('akzeptiert eindeutige, nicht-leere Schlüssel', () => { + expect(getDuplicateKeyError(['a', 'b', 'c'], 0)).toBeNull(); + expect(getDuplicateKeyError(['a', 'b', 'c'], 2)).toBeNull(); + }); + + it('meldet leere bzw. nur aus Leerzeichen bestehende Schlüssel', () => { + expect(getDuplicateKeyError(['', 'b'], 0)).toBe('Schlüssel darf nicht leer sein'); + expect(getDuplicateKeyError([' ', 'b'], 0)).toBe('Schlüssel darf nicht leer sein'); + }); + + it('markiert beide Seiten eines Duplikats (getrimmt)', () => { + expect(getDuplicateKeyError(['dup', 'dup', 'x'], 0)).toBe('Schlüssel muss eindeutig sein'); + expect(getDuplicateKeyError(['x', ' x '], 1)).toBe('Schlüssel muss eindeutig sein'); + expect(getDuplicateKeyError(['dup', 'dup', 'x'], 2)).toBeNull(); + }); + + it('verwendet das übergebene Label in den Meldungen', () => { + expect(getDuplicateKeyError([''], 0, 'Feld-ID')).toBe('Feld-ID darf nicht leer sein'); + expect(getDuplicateKeyError(['f', 'f'], 1, 'Feld-ID')).toBe('Feld-ID muss eindeutig sein'); + }); + + it('behandelt undefined-Einträge wie leere Werte', () => { + expect(getDuplicateKeyError([undefined, 'b'], 0)).toBe('Schlüssel darf nicht leer sein'); + }); +}); diff --git a/src/components/PropertyEditor/common/listItemValidation.ts b/src/components/PropertyEditor/common/listItemValidation.ts new file mode 100644 index 0000000..972b375 --- /dev/null +++ b/src/components/PropertyEditor/common/listItemValidation.ts @@ -0,0 +1,24 @@ +/** + * Geteilte Validierung für Listen-Editoren (Auswahlfeld-Optionen, Chip-Gruppen-Chips, …). + * + * Beide Editor-Arten verwalten eine Liste von Einträgen mit einem eindeutigen Schlüssel + * (SingleSelection: `option.key`; ChipGroup: `chip.field_id.field_name`). Doppelte oder leere + * Schlüssel sind vertragskritisch — portal adressiert die Werte darüber. Diese Funktion + * vereinheitlicht die Prüfung über alle Listen-Editoren. + * + * @param keys Alle Schlüssel der Liste in Reihenfolge. + * @param index Index des zu prüfenden Schlüssels. + * @param label Bezeichnung des Schlüsselfeldes für die Fehlermeldung (Default „Schlüssel"). + * @returns Fehlertext oder `null`, wenn der Schlüssel gültig ist. + */ +export function getDuplicateKeyError( + keys: Array, + index: number, + label = 'Schlüssel' +): string | null { + const key = (keys[index] || '').trim(); + if (!key) return `${label} darf nicht leer sein`; + const duplicate = keys.some((k, i) => i !== index && (k || '').trim() === key); + if (duplicate) return `${label} muss eindeutig sein`; + return null; +} diff --git a/src/components/PropertyEditor/editors/ChipGroupEditor.tsx b/src/components/PropertyEditor/editors/ChipGroupEditor.tsx index 620541a..95ad4a8 100644 --- a/src/components/PropertyEditor/editors/ChipGroupEditor.tsx +++ b/src/components/PropertyEditor/editors/ChipGroupEditor.tsx @@ -29,6 +29,7 @@ import { ChipGroupUIElement, BooleanUIElement } from '../../../models/uiElements import TabbedTranslatableFields from '../common/TabbedTranslatableFields'; import IconField from '../common/IconField'; import { AccordionSection } from '../common/AccordionSection'; +import { getDuplicateKeyError } from '../common/listItemValidation'; import { VisibilityConditionEditor } from './VisibilityConditionEditor'; import { useSubflow } from '../../../context/SubflowContext'; import { v4 as uuidv4 } from 'uuid'; @@ -152,6 +153,17 @@ const ChipGroupEditor: React.FC = ({ element, onChange }) }; */ + // Eindeutigkeits-/Leer-Prüfung der Chip-Feld-ID (geteilt mit den anderen Listen-Editoren). + // Der aktuell bearbeitete Chip wird ausgeschlossen; der neue Wert ans Ende gestellt und dort geprüft. + const otherChipKeys = element.chips + .filter((_, i) => i !== editingChipIndex) + .map((c) => c.field_id?.field_name); + const chipKeyError = getDuplicateKeyError( + [...otherChipKeys, newChip.field_id?.field_name], + otherChipKeys.length, + 'Feld-ID' + ); + return ( @@ -240,6 +252,12 @@ const ChipGroupEditor: React.FC = ({ element, onChange }) + {element.chips.length === 0 && ( + + Noch keine Chips — über „Chip hinzufügen" anlegen. + + )} + {element.chips.map((chip, index) => ( @@ -352,6 +370,8 @@ const ChipGroupEditor: React.FC = ({ element, onChange }) size="small" fullWidth value={newChip.field_id?.field_name || ''} + error={!!chipKeyError} + helperText={chipKeyError || undefined} onChange={(e) => { setNewChip({ ...newChip, @@ -362,17 +382,15 @@ const ChipGroupEditor: React.FC = ({ element, onChange }) }} /> - { + onChange={(value) => { setNewChip({ ...newChip, - icon: e.target.value, + icon: value, }); }} + fullWidth /> = ({ element, onChange }) - diff --git a/src/components/PropertyEditor/editors/SingleSelectionElementEditorEnhanced.tsx b/src/components/PropertyEditor/editors/SingleSelectionElementEditorEnhanced.tsx index 45c889b..6a8a968 100644 --- a/src/components/PropertyEditor/editors/SingleSelectionElementEditorEnhanced.tsx +++ b/src/components/PropertyEditor/editors/SingleSelectionElementEditorEnhanced.tsx @@ -34,6 +34,7 @@ import VerifiedIcon from '@mui/icons-material/Verified'; import { SingleSelectionUIElement, StringUIElement } from '../../../models/uiElements'; import { v4 as uuidv4 } from 'uuid'; import { AccordionSection } from '../common/AccordionSection'; +import { getDuplicateKeyError } from '../common/listItemValidation'; import { ElementTypeIndicator } from '../common/ElementTypeIndicator'; import { ElementPreview } from '../common/ElementPreview'; @@ -55,13 +56,12 @@ export function getOptionKeyError( options: { key: string }[], index: number ): string | null { - const key = (options[index]?.key || '').trim(); - if (!key) return 'Schlüssel darf nicht leer sein'; - const duplicate = options.some( - (option, i) => i !== index && (option.key || '').trim() === key + // Delegiert an die geteilte Listen-Validierung (gleiche Meldungen wie zuvor). + return getDuplicateKeyError( + options.map((o) => o.key), + index, + 'Schlüssel' ); - if (duplicate) return 'Schlüssel muss eindeutig sein'; - return null; } /**