diff --git a/plans/ux-overhaul.md b/plans/ux-overhaul.md index 6a0999f..efdc49c 100644 --- a/plans/ux-overhaul.md +++ b/plans/ux-overhaul.md @@ -58,8 +58,8 @@ Editor-Factory-Muster, `AccordionSection`, `TabbedTranslatableFields`, `ElementP - [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. ## Phase 5 — Seiten & Flow-Metadaten -- [ ] `pattern_type`-Auswahl bei Seitenanlage; Layout-Vorschau (`PageNavigator.tsx`, `EditPageDialog.tsx`). -- [ ] Flow-Metadaten editierbar (id/url-key mit Validierung, Flow-Icon, Beschreibung); `related_pages` dokumentieren. +- [x] **Layout-Vorschau + Layout-Wahl bei Seitenanlage** *(PR Phase 5a)*: Zentrale `pageLayouts.ts` (Single Source of Truth, unit-getestet) + SVG-`LayoutPreview`. Schema-Abgleich mit portal (`d-fc-page-default.vue`): nur `null`/Standard, `2_COL_RIGHT_WIDER`, `2_COL_RIGHT_FILL` werden gerendert — der Editor bot zusätzlich `2_COL_LEFT_WIDER`/`1_COL` an (**Schema-Drift, entfernt**). Layout jetzt mit Vorschau in `EditPageDialog` **und** schon im Neue-Seite-Dialog wählbar; abwesendes Layout = „Standard"; nicht unterstützte Alt-Werte werden als deaktivierter „⚠ Nicht unterstützt"-Eintrag sichtbar gemacht. **`pattern_type`-Auswahl bewusst verworfen**: portal verzweigt nicht über den Page-`pattern_type` (alle Top-Level-Seiten laufen durch `d-fc-page-default`), ein Selektor wäre wirkungslos und driftgefährdet. +- [ ] Flow-Metadaten editierbar (id/url-key mit Validierung, Flow-Icon, Title de/en); `related_pages` dokumentieren. *(Phase 5b — Flow-`description` entfällt: nicht im `ListingFlow`-Schema von portal.)* --- diff --git a/src/components/PageNavigator/EditPageDialog.tsx b/src/components/PageNavigator/EditPageDialog.tsx index 9540ff5..9cdc783 100644 --- a/src/components/PageNavigator/EditPageDialog.tsx +++ b/src/components/PageNavigator/EditPageDialog.tsx @@ -39,6 +39,13 @@ import VisibilityConditionBuilder from '../HybridEditor/VisibilityConditionBuild import { toBuilderFormat, fromBuilderFormat, BuilderCondition } from '../../utils/visibilityConverters'; import { transformEditPageToViewPage } from '../../utils/viewModeTransformer'; import { tokens } from '../../theme/tokens'; +import LayoutPreview from './LayoutPreview'; +import { + SUPPORTED_PAGE_LAYOUTS, + PAGE_LAYOUT_STANDARD, + isSupportedLayout, + layoutForPersistence, +} from './pageLayouts'; interface EditPageDialogProps { open: boolean; @@ -62,7 +69,8 @@ const EditPageDialog: React.FC = ({ const [titleDe, setTitleDe] = useState(page.title?.de || ''); const [titleEn, setTitleEn] = useState(page.title?.en || ''); const [icon, setIcon] = useState(page.icon || ''); - const [layout, setLayout] = useState(page.layout || (isEditPage ? '2_COL_RIGHT_FILL' : '2_COL_RIGHT_WIDER')); + // Layout treu zum gespeicherten Wert; abwesendes layout = Standard (leerer String im Select). + const [layout, setLayout] = useState(page.layout ?? PAGE_LAYOUT_STANDARD); const [moduleId, setModuleId] = useState(page.module_id || ''); const [iconSelectorOpen, setIconSelectorOpen] = useState(false); const [visibilityCondition, setVisibilityCondition] = useState(page.visibility_condition); @@ -254,7 +262,7 @@ const EditPageDialog: React.FC = ({ en: finalTitleEn // short_title wird automatisch mit title synchronisiert }, icon: icon, - layout: layout, + layout: layoutForPersistence(layout), module_id: moduleId || undefined, visibility_condition: visibilityCondition }; @@ -464,25 +472,45 @@ const EditPageDialog: React.FC = ({ {/* Kurztitel ausgeblendet, um die UI nicht zu überfrachten */} - {/* Layout-Auswahl */} - - Layout - - - Wähle das Layout für diese Seite (empfohlen: 2_COL_RIGHT_FILL für Edit, 2_COL_RIGHT_WIDER für View) - - + {/* Layout-Auswahl mit Vorschau */} + + + Layout + + + {SUPPORTED_PAGE_LAYOUTS.find((o) => o.value === layout)?.description || + 'Dieses Layout wird vom portal nicht gerendert — bitte ein unterstütztes Layout wählen.'} + + + + + + {/* Modul-Zuordnung (wenn der Flow Module deklariert oder die Seite bereits getaggt ist) */} {((state.currentFlow?.modules?.length ?? 0) > 0 || moduleId) && ( diff --git a/src/components/PageNavigator/LayoutPreview.tsx b/src/components/PageNavigator/LayoutPreview.tsx new file mode 100644 index 0000000..62b6284 --- /dev/null +++ b/src/components/PageNavigator/LayoutPreview.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { PAGE_LAYOUT_STANDARD, LAYOUT_PREVIEW_COLORS } from './pageLayouts'; + +interface LayoutPreviewProps { + /** Layout-Wert ('' / undefined = Standard). */ + layout?: string; + width?: number; + height?: number; +} + +/** + * Schematische Mini-Vorschau eines Seiten-Layouts. + * + * Alle unterstützten Layouts sind zweispaltig: links die Formularfelder (angedeutet als + * graue Zeilen), rechts der content-Slot, dessen Darstellung sich je Layout unterscheidet — + * genau so, wie portal die rechte Spalte rendert: + * - Standard: zentrierter, schmaler Block + * - 2_COL_RIGHT_WIDER: zentrierter, breiter Block + * - 2_COL_RIGHT_FILL: ein Block füllt die gesamte rechte Spalte + */ +const LayoutPreview: React.FC = ({ layout, width = 132, height = 84 }) => { + const value = layout || PAGE_LAYOUT_STANDARD; + const pad = 6; + const gap = 6; + const leftW = Math.round(width * 0.4); + const rightX = pad + leftW + gap; + const rightW = width - rightX - pad; + const innerH = height - pad * 2; + + // Rechter Block je Layout: x-Offset + Breite. + let blockX = rightX; + let blockW = rightW; + if (value === '2_COL_RIGHT_FILL') { + blockX = rightX; + blockW = rightW; + } else if (value === '2_COL_RIGHT_WIDER') { + const inset = Math.round(rightW * 0.12); + blockX = rightX + inset; + blockW = rightW - inset * 2; + } else { + // Standard: schmaler, stärker eingerückt. + const inset = Math.round(rightW * 0.26); + blockX = rightX + inset; + blockW = rightW - inset * 2; + } + + // Angedeutete Formularzeilen links. + const rowH = 6; + const rowGap = 8; + const rows = Math.max(1, Math.floor((innerH + rowGap) / (rowH + rowGap))); + + return ( + + {/* Rahmen */} + + {/* Linke Spalte: Formularzeilen */} + {Array.from({ length: rows }).map((_, i) => ( + + ))} + {/* Rechter content-Block */} + + + ); +}; + +export default LayoutPreview; diff --git a/src/components/PageNavigator/PageNavigator.tsx b/src/components/PageNavigator/PageNavigator.tsx index d13cf69..e045c81 100644 --- a/src/components/PageNavigator/PageNavigator.tsx +++ b/src/components/PageNavigator/PageNavigator.tsx @@ -12,7 +12,12 @@ import { TextField, Typography, Snackbar, - Alert + Alert, + FormControl, + InputLabel, + Select, + MenuItem, + FormHelperText } from '@mui/material'; import { Add as AddIcon, @@ -27,6 +32,8 @@ import { evaluateVisibilityCondition } from '../../utils/visibilityUtils'; import PageTab from './PageTab'; import EditPageDialog from './EditPageDialog'; import ImportPagesDialog from './ImportPagesDialog'; +import LayoutPreview from './LayoutPreview'; +import { SUPPORTED_PAGE_LAYOUTS, layoutForPersistence } from './pageLayouts'; import { tokens } from '../../theme/tokens'; interface PageNavigatorProps { @@ -40,6 +47,7 @@ const PageNavigator: React.FC = ({ pages, selectedPageId }) const { confirm, showWarning } = useFeedback(); const [openNewPageDialog, setOpenNewPageDialog] = React.useState(false); const [newPageTitle, setNewPageTitle] = React.useState(''); + const [newPageLayout, setNewPageLayout] = React.useState('2_COL_RIGHT_FILL'); const [editPageDialogOpen, setEditPageDialogOpen] = React.useState(false); const [pageToEdit, setPageToEdit] = React.useState(null); const [importDialogOpen, setImportDialogOpen] = React.useState(false); @@ -68,6 +76,7 @@ const PageNavigator: React.FC = ({ pages, selectedPageId }) const handleCloseNewPageDialog = () => { setOpenNewPageDialog(false); setNewPageTitle(''); + setNewPageLayout('2_COL_RIGHT_FILL'); }; const handleCreateNewPage = () => { @@ -77,15 +86,14 @@ const PageNavigator: React.FC = ({ pages, selectedPageId }) const newPage: Page = { pattern_type: 'CustomUIElement', id: pageId, - layout: '2_COL_RIGHT_FILL', // Default-Layout für Edit-Seiten + layout: layoutForPersistence(newPageLayout), // gewähltes Layout (Standard = kein Feld) title: { de: pageTitleDe, en: pageTitleEn }, short_title: { de: pageTitleDe, en: pageTitleEn }, // short_title wird mit title synchronisiert elements: [] }; dispatch({ type: 'ADD_PAGE', page: newPage }); - setOpenNewPageDialog(false); - setNewPageTitle(''); + handleCloseNewPageDialog(); }; const handleImportPages = useCallback((editPages: Page[], viewPages: Page[]) => { @@ -274,7 +282,7 @@ const PageNavigator: React.FC = ({ pages, selectedPageId }) {/* Dialog für neue Seite */} - + Neue Seite erstellen = ({ pages, selectedPageId }) value={newPageTitle} onChange={(e) => setNewPageTitle(e.target.value)} /> + + {/* Layout-Auswahl mit Vorschau bereits bei der Anlage */} + + + Layout + + + {SUPPORTED_PAGE_LAYOUTS.find((o) => o.value === newPageLayout)?.description} + + + + + + diff --git a/src/components/PageNavigator/pageLayouts.test.ts b/src/components/PageNavigator/pageLayouts.test.ts new file mode 100644 index 0000000..2aa18fc --- /dev/null +++ b/src/components/PageNavigator/pageLayouts.test.ts @@ -0,0 +1,49 @@ +import { + SUPPORTED_PAGE_LAYOUTS, + SUPPORTED_LAYOUT_VALUES, + PAGE_LAYOUT_STANDARD, + isSupportedLayout, + layoutForPersistence, +} from './pageLayouts'; + +describe('pageLayouts', () => { + it('bietet genau die von portal gerenderten Layouts an (Standard + 2 Spaltenvarianten)', () => { + expect(SUPPORTED_PAGE_LAYOUTS.map((o) => o.value)).toEqual([ + PAGE_LAYOUT_STANDARD, + '2_COL_RIGHT_WIDER', + '2_COL_RIGHT_FILL', + ]); + expect(SUPPORTED_LAYOUT_VALUES).toEqual(['2_COL_RIGHT_WIDER', '2_COL_RIGHT_FILL']); + }); + + describe('isSupportedLayout', () => { + it('akzeptiert abwesendes/leeres Layout als Standard', () => { + expect(isSupportedLayout(undefined)).toBe(true); + expect(isSupportedLayout(null)).toBe(true); + expect(isSupportedLayout('')).toBe(true); + }); + + it('akzeptiert die unterstützten Spaltenlayouts', () => { + expect(isSupportedLayout('2_COL_RIGHT_FILL')).toBe(true); + expect(isSupportedLayout('2_COL_RIGHT_WIDER')).toBe(true); + }); + + it('lehnt früher angebotene, aber von portal nicht gerenderte Layouts ab', () => { + expect(isSupportedLayout('2_COL_LEFT_WIDER')).toBe(false); + expect(isSupportedLayout('1_COL')).toBe(false); + expect(isSupportedLayout('irgendwas')).toBe(false); + }); + }); + + describe('layoutForPersistence', () => { + it('mappt Standard (leer) auf undefined', () => { + expect(layoutForPersistence(PAGE_LAYOUT_STANDARD)).toBeUndefined(); + expect(layoutForPersistence('')).toBeUndefined(); + }); + + it('behält gesetzte Layout-Werte bei', () => { + expect(layoutForPersistence('2_COL_RIGHT_FILL')).toBe('2_COL_RIGHT_FILL'); + expect(layoutForPersistence('2_COL_RIGHT_WIDER')).toBe('2_COL_RIGHT_WIDER'); + }); + }); +}); diff --git a/src/components/PageNavigator/pageLayouts.ts b/src/components/PageNavigator/pageLayouts.ts new file mode 100644 index 0000000..4846be5 --- /dev/null +++ b/src/components/PageNavigator/pageLayouts.ts @@ -0,0 +1,64 @@ +import { tokens } from '../../theme/tokens'; + +/** + * Seiten-Layouts, die das portal tatsächlich rendert. + * + * Quelle der Wahrheit: `portal-applications` → `d-fc-page-default.vue` verzweigt ausschließlich auf + * `null` (= Standard), `2_COL_RIGHT_WIDER` und `2_COL_RIGHT_FILL`. Jeder andere Layout-Wert läuft + * dort in `d-fc-unknown-type` (unbekannter Typ). Der Editor darf deshalb nur diese Werte anbieten — + * früher angebotene Werte (`2_COL_LEFT_WIDER`, `1_COL`) waren Schema-Drift. + * + * Ein abwesendes `layout` entspricht „Standard"; im Select wird das über den leeren String + * repräsentiert und beim Speichern wieder zu `undefined` (statt `''`) gemappt. + */ +export const PAGE_LAYOUT_STANDARD = ''; + +export interface PageLayoutOption { + value: string; // '' = Standard (kein layout-Feld gesetzt) + label: string; + description: string; +} + +export const SUPPORTED_PAGE_LAYOUTS: PageLayoutOption[] = [ + { + value: PAGE_LAYOUT_STANDARD, + label: 'Standard (zentriert)', + description: 'Kein layout-Feld gesetzt. Die rechte Spalte wird zentriert dargestellt.', + }, + { + value: '2_COL_RIGHT_WIDER', + label: '2-spaltig, rechts breiter', + description: 'Zwei Spalten; die rechte Spalte wird breiter zentriert dargestellt.', + }, + { + value: '2_COL_RIGHT_FILL', + label: '2-spaltig, rechts gefüllt', + description: 'Zwei Spalten; genau ein rechtes Element füllt die gesamte rechte Spalte.', + }, +]; + +/** Layout-Werte, die ein gesetztes `layout`-Feld haben dürfen (ohne den Standard/leer-Fall). */ +export const SUPPORTED_LAYOUT_VALUES: string[] = SUPPORTED_PAGE_LAYOUTS.map((o) => o.value).filter( + (v) => v !== PAGE_LAYOUT_STANDARD +); + +/** + * Prüft, ob ein Layout-Wert von portal gerendert werden kann. + * Abwesend/leer (= Standard) gilt als unterstützt. + */ +export function isSupportedLayout(value?: string | null): boolean { + if (value == null || value === PAGE_LAYOUT_STANDARD) return true; + return SUPPORTED_LAYOUT_VALUES.includes(value); +} + +/** Normalisiert einen Select-Wert für die Persistenz: leerer String → `undefined`. */ +export function layoutForPersistence(value: string): string | undefined { + return value === PAGE_LAYOUT_STANDARD ? undefined : value; +} + +/** Farbpaar für die Layout-Vorschau (Rahmen + gefüllter Block). */ +export const LAYOUT_PREVIEW_COLORS = { + frame: tokens.neutral.border, + block: tokens.brand.green, + muted: tokens.surface.subtle, +} as const;