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
57 changes: 57 additions & 0 deletions plans/schema-drift-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Schema-Drift-Audit: flow-ui-toolkit ↔ portal-applications

**Stand:** 2026-06-15 · **Autoritativ:** `portal-applications/api/rest/listing-flow-api.yaml` (REST/JSON = Vertrag,
snake_case), gestützt durch `api/graphql/schema/flow-config.graphqls` (BFF, camelCase) und
`customers/schema/readme.md`.

Geprüft: `src/models/uiElements.ts`, `src/models/listingFlow.ts`, `src/components/PropertyEditor/editors/*`,
`src/utils/normalizeUtils.ts`. Ziel: Felder/Enums, die zwischen Toolkit und portal auseinanderlaufen
(„Schema-Drift ist ein Bug", siehe CLAUDE.md).

Severity: **HOCH** = bricht/verliert Daten beim Round-Trip oder erzeugt ungültiges JSON ·
**MITTEL** = fehlende Editierbarkeit/Enum-Werte, aber Round-Trip-safe · **NIEDRIG** = kosmetisch/Doku.

---

## ✅ In diesem PR behoben

| Befund | Sev | Fix |
|---|---|---|
| **NumberUIElement verliert `default`/Grenzen** — Modell/Editor nutzten `min`/`max`/`step`/`default_value`; portal kennt nur `minimum`/`maximum`/`default`. `normalizeUtils` wandelte importiertes `default` → `default_value` ohne Rück-Mapping → Default/Grenzen gingen beim Export verloren. | HOCH | Modell auf `minimum`/`maximum`/`default` umgestellt (`step`/`default_value` entfernt), Editor angepasst (Schrittweite entfernt — portal kennt keine), `normalizeUtils` migriert Alt-Exporte beim Import auf die Schema-Felder. Test: `numberNormalization.test.ts`. |
| **SingleSelection schreibt `BUTTONGROUP`** statt `BUTTON_GROUP` — der aktive Enhanced-Editor erzeugte einen ungültigen Enum-Wert (Schema/Modell: `BUTTON_GROUP`). | HOCH | MenuItem-Wert in `SingleSelectionElementEditorEnhanced.tsx` auf `BUTTON_GROUP` korrigiert. |
| **KeyValueList `type`** kannte nur `TABLE`, Schema hat `COUNTER_BAR` + `TABLE`. | MITTEL | Union auf `'COUNTER_BAR' \| 'TABLE'` erweitert. |
| **FieldText** ohne `align`/`unit` (Schema: `align: LEFT\|CENTER`, `unit`; GraphQL `horizontalAlignment`). | MITTEL | `align?`/`unit?` ergänzt (Round-Trip-Erhalt). |
| **Table** ohne `hide_index_column` (Schema/GraphQL: `hideIndexColumn`). | MITTEL | `hide_index_column?` ergänzt. |
| `Page.layout`-Kommentar irreführend. | NIEDRIG | Kommentar verweist nun auf `pageLayouts.ts` (Standard/`2_COL_RIGHT_WIDER`/`2_COL_RIGHT_FILL`). |

> Hinweis: `Page.layout`-Drift (`1_COL`/`2_COL_LEFT_WIDER`) wurde bereits in PR #21 (Phase 5a) behoben.

---

## ⏭️ Bewusst aufgeschoben (Begründung)

| Befund | Sev | Warum nicht jetzt |
|---|---|---|
| **BooleanUIElement `type` (SWITCH/CHECKBOX/…) + `options.{true/false_label}`** sind toolkit-erfunden — portal kennt sie nicht (Boolean ist immer ein Switch). | HOCH* | **Feature-Entfernung** mit sichtbarer Editor-UI → braucht Produkt-Sign-off. Daten gehen nicht verloren (portal ignoriert die Felder); irreführend ist nur, dass der Autor glaubt, eine Darstellung zu wählen. Empfehlung: `type`/`options` aus Boolean-Typ + Editor entfernen. |
| **StringUIElement `default_value`/`placeholder`/`multiline`/`pattern`** nicht im Schema. | MITTEL | Ebenfalls Feature-Entfernung; Round-Trip-safe. Entweder entfernen oder bewusst als portal-Erweiterung abstimmen. |
| **RCO-Context `LIST`** — nur GraphQL kennt `LIST` zusätzlich zu CREATE/EDIT/VIEW/BACK_OFFICE; REST-YAML (autoritativ fürs JSON) nicht. | MITTEL | Unklar, ob `LIST` im JSON-Vertrag vorkommt. Erst mit portal verifizieren, dann ggf. `RelationalContextOperator.context` + VisibilityConditionEditor ergänzen. |
| **CustomUIElement `PROJECT_CONFIGURATOR`** als Typ-Option in Editor/Palette. | NIEDRIG | Gehört thematisch in die Listen-/Editor-Vereinheitlichung; `type: string` lässt den Wert ohnehin zu (kein Bruch). |
| **Module `name`/`description` required** im Toolkit, optional in YAML. | NIEDRIG | Strenger als der Vertrag (kein Round-Trip-Bruch). Optional-Machen würde TS-Zugriffe in `ModuleManagerDialog` berühren → separat. |
| Erfundene, harmlose Zusatzfelder: `Group.field_id`, `Date.default_value`, `ImageGallery.field_id`, `Table.type`, `ListingFlow.version`. | NIEDRIG | Round-Trip-safe; Aufräumen ohne Dringlichkeit. |

\* Severity-Einstufung des Audits; da kein Datenverlust, eher „irreführende UI".

---

## 🔎 Unklar / manuell mit portal verifizieren

- **FileUIElement `id_field_*`**: YAML `required` nennt `id_field_name`, definiert aber `id_field_id` (YAML-interner Widerspruch). Toolkit nutzt `id_field_id` (konsistent mit readme.md + GraphQL `idField`) → vermutlich korrekt, YAML-`required` ist der Bug. Außerdem prüfen, ob der Export das toolkit-interne `FileUIElement.field_id` strippt.
- **`caption_field_id` vs `name_element`**: REST nutzt `caption_field_id`, GraphQL `nameElement: StringUIElement` — klären, welche Form das JSON erwartet.
- **`preferred_position` vs `display_position`** bei View-Elementen (Text/Table/KeyValueList/FieldText): mögliche Namens-Drift — klären, welches Feld portal im JSON liest.

---

## ℹ️ Portal-seitig (kein Toolkit-Fix — YAML hinkt GraphQL/readme hinterher)

- `SubFlow.type`-Enum im YAML listet nur 5 Werte; readme.md hat die Vollmenge (die der Toolkit korrekt führt). YAML-Enum nachziehen.
- `ImageGallery.preferred_size` im YAML nur `[M, L]`; GraphQL/Toolkit kennen `S, M, M_NARROW, L`. YAML nachziehen.
4 changes: 2 additions & 2 deletions src/components/PropertyEditor/PropertyEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ const PropertyEditor: React.FC<PropertyEditorProps> = ({ element, onUpdate }) =>
size="small"
fullWidth
type="number"
value={numberElement.default_value || ''}
value={numberElement.default ?? ''}
onChange={(e) => {
const updatedCustomElement = { ...element.element as CustomUIElement };

Expand All @@ -652,7 +652,7 @@ const PropertyEditor: React.FC<PropertyEditorProps> = ({ element, onUpdate }) =>
elementPath,
{
...numberElement,
default_value: parseFloat(e.target.value)
default: parseFloat(e.target.value)
}
);

Expand Down
44 changes: 15 additions & 29 deletions src/components/PropertyEditor/editors/NumberElementEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const NumberElementEditor: React.FC<NumberElementEditorProps> = ({ element, onUp

// Konvertiere Werte zu Zahlen, wenn sie numerisch sind
const value = event.target.value;
if (field === 'min' || field === 'max' || field === 'step' || field === 'default_value') {
if (field === 'minimum' || field === 'maximum' || field === 'default') {
elementAny[field] = value === '' ? '' : Number(value);
} else {
elementAny[field] = value;
Expand All @@ -101,12 +101,10 @@ const NumberElementEditor: React.FC<NumberElementEditorProps> = ({ element, onUp
};

// Berechne den Slider-Bereich
const min = numberElement.min !== undefined ? numberElement.min : 0;
const max = numberElement.max !== undefined ? numberElement.max : 100;
const step = numberElement.step || 1;
const defaultValue = numberElement.default_value !== undefined
? numberElement.default_value
: (numberElement.default !== undefined ? numberElement.default : min);
const min = numberElement.minimum !== undefined ? numberElement.minimum : 0;
const max = numberElement.maximum !== undefined ? numberElement.maximum : 100;
const step = 1; // portal kennt keine Schrittweite — nur für die Slider-Vorschau
const defaultValue = numberElement.default !== undefined ? numberElement.default : min;

return (
<>
Expand Down Expand Up @@ -215,10 +213,8 @@ const NumberElementEditor: React.FC<NumberElementEditorProps> = ({ element, onUp
<TextField
label="Default-Wert"
type="number"
value={numberElement.default_value !== undefined
? numberElement.default_value
: (numberElement.default !== undefined ? numberElement.default : '')}
onChange={handleTextChange('default_value')}
value={numberElement.default !== undefined ? numberElement.default : ''}
onChange={handleTextChange('default')}
fullWidth
size="small"
InputProps={{
Expand Down Expand Up @@ -251,32 +247,22 @@ const NumberElementEditor: React.FC<NumberElementEditorProps> = ({ element, onUp
<TextField
label="Minimum"
type="number"
value={numberElement.min !== undefined ? numberElement.min : ''}
onChange={handleTextChange('min')}
value={numberElement.minimum !== undefined ? numberElement.minimum : ''}
onChange={handleTextChange('minimum')}
fullWidth
size="small"
/>
<TextField
label="Maximum"
type="number"
value={numberElement.max !== undefined ? numberElement.max : ''}
onChange={handleTextChange('max')}
value={numberElement.maximum !== undefined ? numberElement.maximum : ''}
onChange={handleTextChange('maximum')}
fullWidth
size="small"
/>
</Box>

<TextField
label="Schrittweite"
type="number"
value={numberElement.step || ''}
onChange={handleTextChange('step')}
fullWidth
size="small"
helperText={`Erlaubte Werte: ${min}, ${min + step}, ${min + 2 * step}, ...`}
/>

{numberElement.min !== undefined && numberElement.max !== undefined && (
{numberElement.minimum !== undefined && numberElement.maximum !== undefined && (
<Box sx={{ px: 2, mt: 1 }}>
<Typography gutterBottom>Wertebereich-Vorschau</Typography>
<Slider
Expand Down Expand Up @@ -311,9 +297,9 @@ const NumberElementEditor: React.FC<NumberElementEditorProps> = ({ element, onUp
<InputAdornment position="end">{getUnitDisplayLabel(numberElement.unit)}</InputAdornment>
) : null,
inputProps: {
min: numberElement.min,
max: numberElement.max,
step: numberElement.step || 1
min: numberElement.minimum,
max: numberElement.maximum,
step: 1
}
}}
disabled
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export const SingleSelectionElementEditorEnhanced: React.FC<SingleSelectionEleme
onChange={(e) => handleTypeChange(e as any)}
>
<MenuItem value="DROPDOWN">Dropdown</MenuItem>
<MenuItem value="BUTTONGROUP">Button Gruppe</MenuItem>
<MenuItem value="BUTTON_GROUP">Button Gruppe</MenuItem>
</Select>
</FormControl>

Expand Down
2 changes: 1 addition & 1 deletion src/models/listingFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export interface Module {
export interface Page {
pattern_type: string; // Im alten Schema "Page", im doorbit_esg.json Schema "CustomUIElement"
id: string;
layout?: string; // z.B. "2_COL_RIGHT_FILL" für Edit-Seiten, "2_COL_RIGHT_WIDER" für View-Seiten
layout?: string; // unterstützt (portal): (absent)=Standard, 2_COL_RIGHT_WIDER, 2_COL_RIGHT_FILL — siehe pageLayouts.ts
related_pages?: RelatedPage[]; // Verknüpfung zu korrespondierenden View- oder Edit-Seiten
short_title?: TranslatableString;
title?: TranslatableString;
Expand Down
16 changes: 11 additions & 5 deletions src/models/uiElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,12 @@ export interface NumberUIElement extends UIElementEdit {
type?: 'INTEGER' | 'DOUBLE';
field_id: FieldId;
unit?: string;
min?: number;
max?: number;
step?: number;
// Schema-Vertrag (portal): minimum/maximum/default. Früher nutzte der Toolkit
// min/max/step/default_value — diese sind kein portal-Feld und wurden entfernt;
// normalizeUtils migriert Alt-Exporte beim Import auf diese Namen.
minimum?: number;
maximum?: number;
default?: number;
default_value?: number;
}

export interface DateUIElement extends UIElementEdit {
Expand Down Expand Up @@ -163,7 +164,7 @@ export interface ChipGroupUIElement extends UIElementEdit {

export interface KeyValueListUIElement extends UIElementEdit {
pattern_type: 'KeyValueListUIElement';
type: 'TABLE';
type: 'COUNTER_BAR' | 'TABLE';
items: KeyValueListItem[];
}

Expand Down Expand Up @@ -197,6 +198,9 @@ export interface FieldTextUIElement extends UIElementEdit {
pattern_type: 'FieldTextUIElement';
type: 'HEADING' | 'PARAGRAPH';
field_value: { field_id: FieldId };
// Textausrichtung (portal: align; GraphQL: horizontalAlignment).
align?: 'LEFT' | 'CENTER';
unit?: string;
}

/**
Expand All @@ -206,6 +210,8 @@ export interface FieldTextUIElement extends UIElementEdit {
export interface TableUIElement extends UIElementEdit {
pattern_type: 'TableUIElement';
type?: 'TABLE';
// Blendet die Index-/Laufnummern-Spalte aus (portal: hide_index_column).
hide_index_column?: boolean;
columns: TableColumn[];
}

Expand Down
22 changes: 18 additions & 4 deletions src/utils/normalizeUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,24 @@ const normalizeElement = (element: any, ensureFieldId: boolean = true): any => {
delete element.items;
}

// Normalisiere NumberUIElement default zu default_value
if (patternType === 'NumberUIElement' && element.default !== undefined && element.default_value === undefined) {
element.default_value = element.default;
delete element.default;
// Normalisiere NumberUIElement auf die Schema-Feldnamen (minimum/maximum/default).
// portal kennt nur minimum/maximum/default; frühere Toolkit-Exporte nutzten
// min/max/step/default_value. Beim Import migrieren, damit Default-/Grenzwerte nicht
// verloren gehen (und kein vom Schema unbekanntes Feld zurück-exportiert wird).
if (patternType === 'NumberUIElement') {
if (element.min !== undefined && element.minimum === undefined) {
element.minimum = element.min;
}
if (element.max !== undefined && element.maximum === undefined) {
element.maximum = element.max;
}
if (element.default_value !== undefined && element.default === undefined) {
element.default = element.default_value;
}
delete element.min;
delete element.max;
delete element.step;
delete element.default_value;
}

// Normalisiere visibility_condition
Expand Down
79 changes: 79 additions & 0 deletions src/utils/numberNormalization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { normalizeElementTypes } from './normalizeUtils';
import { ListingFlow } from '../models/listingFlow';

const flowWith = (numberElement: any): any => ({
id: 'test-flow',
'url-key': 'test',
name: 'Test Flow',
title: { de: 'Test', en: 'Test' },
icon: 'test',
pages_edit: [
{
pattern_type: 'Page',
id: 'page1',
elements: [{ element: numberElement }],
},
],
pages_view: [],
});

const numberOf = (result: ListingFlow): any =>
(result.pages_edit[0].elements[0] as any).element;

describe('NumberUIElement-Normalisierung (Schema-Drift-Fix)', () => {
it('migriert Alt-Felder min/max/step/default_value auf minimum/maximum/default', () => {
const result = normalizeElementTypes(
flowWith({
pattern_type: 'NumberUIElement',
field_id: { field_name: 'n' },
min: 1,
max: 9,
step: 2,
default_value: 5,
}) as ListingFlow
);
const el = numberOf(result);
expect(el.minimum).toBe(1);
expect(el.maximum).toBe(9);
expect(el.default).toBe(5);
// Alt-Felder werden entfernt, damit sie nicht zurück-exportiert werden.
expect(el.min).toBeUndefined();
expect(el.max).toBeUndefined();
expect(el.step).toBeUndefined();
expect(el.default_value).toBeUndefined();
});

it('lässt portal-konforme Felder minimum/maximum/default unverändert', () => {
const result = normalizeElementTypes(
flowWith({
pattern_type: 'NumberUIElement',
field_id: { field_name: 'n' },
minimum: 0,
maximum: 100,
default: 42,
}) as ListingFlow
);
const el = numberOf(result);
expect(el.minimum).toBe(0);
expect(el.maximum).toBe(100);
expect(el.default).toBe(42);
});

it('überschreibt vorhandene Schema-Felder nicht mit Alt-Werten', () => {
const result = normalizeElementTypes(
flowWith({
pattern_type: 'NumberUIElement',
field_id: { field_name: 'n' },
minimum: 3,
min: 999,
default: 7,
default_value: 999,
}) as ListingFlow
);
const el = numberOf(result);
expect(el.minimum).toBe(3);
expect(el.default).toBe(7);
expect(el.min).toBeUndefined();
expect(el.default_value).toBeUndefined();
});
});
Loading