Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plans/ux-overhaul.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
28 changes: 28 additions & 0 deletions src/components/PropertyEditor/common/listItemValidation.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
24 changes: 24 additions & 0 deletions src/components/PropertyEditor/common/listItemValidation.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>,
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;
}
37 changes: 30 additions & 7 deletions src/components/PropertyEditor/editors/ChipGroupEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -152,6 +153,17 @@ const ChipGroupEditor: React.FC<ChipGroupEditorProps> = ({ 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 (
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
Expand Down Expand Up @@ -240,6 +252,12 @@ const ChipGroupEditor: React.FC<ChipGroupEditorProps> = ({ element, onChange })
</Button>
</Box>

{element.chips.length === 0 && (
<Typography variant="body2" color="text.secondary" sx={{ py: 1 }}>
Noch keine Chips — über „Chip hinzufügen" anlegen.
</Typography>
)}

<List>
{element.chips.map((chip, index) => (
<Card key={index} variant="outlined" sx={{ mb: 1 }}>
Expand Down Expand Up @@ -352,6 +370,8 @@ const ChipGroupEditor: React.FC<ChipGroupEditorProps> = ({ element, onChange })
size="small"
fullWidth
value={newChip.field_id?.field_name || ''}
error={!!chipKeyError}
helperText={chipKeyError || undefined}
onChange={(e) => {
setNewChip({
...newChip,
Expand All @@ -362,17 +382,15 @@ const ChipGroupEditor: React.FC<ChipGroupEditorProps> = ({ element, onChange })
}}
/>

<TextField
label="Icon"
size="small"
fullWidth
<IconField
value={newChip.icon || ''}
onChange={(e) => {
onChange={(value) => {
setNewChip({
...newChip,
icon: e.target.value,
icon: value,
});
}}
fullWidth
/>

<FormControlLabel
Expand Down Expand Up @@ -431,7 +449,12 @@ const ChipGroupEditor: React.FC<ChipGroupEditorProps> = ({ element, onChange })
</DialogContent>
<DialogActions>
<Button onClick={handleCloseDialog}>Abbrechen</Button>
<Button onClick={handleSaveChip} variant="contained" color="primary">
<Button
onClick={handleSaveChip}
variant="contained"
color="primary"
disabled={!!chipKeyError}
>
Speichern
</Button>
</DialogActions>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
}

/**
Expand Down
Loading