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
4 changes: 2 additions & 2 deletions plans/ux-overhaul.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)*

---

Expand Down
70 changes: 49 additions & 21 deletions src/components/PageNavigator/EditPageDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,7 +69,8 @@ const EditPageDialog: React.FC<EditPageDialogProps> = ({
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<string>(page.layout ?? PAGE_LAYOUT_STANDARD);
const [moduleId, setModuleId] = useState(page.module_id || '');
const [iconSelectorOpen, setIconSelectorOpen] = useState(false);
const [visibilityCondition, setVisibilityCondition] = useState<VisibilityCondition | undefined>(page.visibility_condition);
Expand Down Expand Up @@ -254,7 +262,7 @@ const EditPageDialog: React.FC<EditPageDialogProps> = ({
en: finalTitleEn // short_title wird automatisch mit title synchronisiert
},
icon: icon,
layout: layout,
layout: layoutForPersistence(layout),
module_id: moduleId || undefined,
visibility_condition: visibilityCondition
};
Expand Down Expand Up @@ -464,25 +472,45 @@ const EditPageDialog: React.FC<EditPageDialogProps> = ({

{/* Kurztitel ausgeblendet, um die UI nicht zu überfrachten */}

{/* Layout-Auswahl */}
<FormControl fullWidth margin="dense" sx={{ mb: 3 }}>
<InputLabel id="layout-select-label">Layout</InputLabel>
<Select
labelId="layout-select-label"
id="layout-select"
value={layout}
label="Layout"
onChange={(e) => setLayout(e.target.value)}
>
<MenuItem value="2_COL_RIGHT_FILL">2-spaltig (rechts gefüllt)</MenuItem>
<MenuItem value="2_COL_RIGHT_WIDER">2-spaltig (rechts breiter)</MenuItem>
<MenuItem value="2_COL_LEFT_WIDER">2-spaltig (links breiter)</MenuItem>
<MenuItem value="1_COL">1-spaltig</MenuItem>
</Select>
<FormHelperText>
Wähle das Layout für diese Seite (empfohlen: 2_COL_RIGHT_FILL für Edit, 2_COL_RIGHT_WIDER für View)
</FormHelperText>
</FormControl>
{/* Layout-Auswahl mit Vorschau */}
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: 'flex-start',
mb: 3,
flexDirection: { xs: 'column', sm: 'row' },
}}
>
<FormControl fullWidth margin="dense" sx={{ flex: 1, mt: 0 }}>
<InputLabel id="layout-select-label">Layout</InputLabel>
<Select
labelId="layout-select-label"
id="layout-select"
value={layout}
label="Layout"
onChange={(e) => setLayout(e.target.value)}
>
{SUPPORTED_PAGE_LAYOUTS.map((opt) => (
<MenuItem key={opt.value || 'standard'} value={opt.value}>
{opt.label}
</MenuItem>
))}
{!isSupportedLayout(layout) && (
<MenuItem value={layout} disabled>
{`⚠ Nicht unterstützt: ${layout}`}
</MenuItem>
)}
</Select>
<FormHelperText>
{SUPPORTED_PAGE_LAYOUTS.find((o) => o.value === layout)?.description ||
'Dieses Layout wird vom portal nicht gerendert — bitte ein unterstütztes Layout wählen.'}
</FormHelperText>
</FormControl>
<Box sx={{ flexShrink: 0, pt: { xs: 0, sm: 1 } }}>
<LayoutPreview layout={isSupportedLayout(layout) ? layout : PAGE_LAYOUT_STANDARD} />
</Box>
</Box>

{/* Modul-Zuordnung (wenn der Flow Module deklariert oder die Seite bereits getaggt ist) */}
{((state.currentFlow?.modules?.length ?? 0) > 0 || moduleId) && (
Expand Down
99 changes: 99 additions & 0 deletions src/components/PageNavigator/LayoutPreview.tsx
Original file line number Diff line number Diff line change
@@ -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<LayoutPreviewProps> = ({ 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 (
<Box
component="svg"
viewBox={`0 0 ${width} ${height}`}
width={width}
height={height}
sx={{ display: 'block', borderRadius: 1 }}
role="img"
aria-label="Layout-Vorschau"
>
{/* Rahmen */}
<rect
x={0.5}
y={0.5}
width={width - 1}
height={height - 1}
rx={4}
fill={LAYOUT_PREVIEW_COLORS.muted}
stroke={LAYOUT_PREVIEW_COLORS.frame}
/>
{/* Linke Spalte: Formularzeilen */}
{Array.from({ length: rows }).map((_, i) => (
<rect
key={i}
x={pad}
y={pad + i * (rowH + rowGap)}
width={i % 3 === 2 ? Math.round(leftW * 0.6) : leftW}
height={rowH}
rx={2}
fill={LAYOUT_PREVIEW_COLORS.frame}
/>
))}
{/* Rechter content-Block */}
<rect
x={blockX}
y={pad}
width={Math.max(8, blockW)}
height={innerH}
rx={3}
fill={LAYOUT_PREVIEW_COLORS.block}
opacity={0.85}
/>
</Box>
);
};

export default LayoutPreview;
52 changes: 47 additions & 5 deletions src/components/PageNavigator/PageNavigator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import {
TextField,
Typography,
Snackbar,
Alert
Alert,
FormControl,
InputLabel,
Select,
MenuItem,
FormHelperText
} from '@mui/material';
import {
Add as AddIcon,
Expand All @@ -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 {
Expand All @@ -40,6 +47,7 @@ const PageNavigator: React.FC<PageNavigatorProps> = ({ pages, selectedPageId })
const { confirm, showWarning } = useFeedback();
const [openNewPageDialog, setOpenNewPageDialog] = React.useState(false);
const [newPageTitle, setNewPageTitle] = React.useState('');
const [newPageLayout, setNewPageLayout] = React.useState<string>('2_COL_RIGHT_FILL');
const [editPageDialogOpen, setEditPageDialogOpen] = React.useState(false);
const [pageToEdit, setPageToEdit] = React.useState<Page | null>(null);
const [importDialogOpen, setImportDialogOpen] = React.useState(false);
Expand Down Expand Up @@ -68,6 +76,7 @@ const PageNavigator: React.FC<PageNavigatorProps> = ({ pages, selectedPageId })
const handleCloseNewPageDialog = () => {
setOpenNewPageDialog(false);
setNewPageTitle('');
setNewPageLayout('2_COL_RIGHT_FILL');
};

const handleCreateNewPage = () => {
Expand All @@ -77,15 +86,14 @@ const PageNavigator: React.FC<PageNavigatorProps> = ({ 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[]) => {
Expand Down Expand Up @@ -274,7 +282,7 @@ const PageNavigator: React.FC<PageNavigatorProps> = ({ pages, selectedPageId })
</Snackbar>

{/* Dialog für neue Seite */}
<Dialog open={openNewPageDialog} onClose={handleCloseNewPageDialog}>
<Dialog open={openNewPageDialog} onClose={handleCloseNewPageDialog} maxWidth="sm" fullWidth>
<DialogTitle>Neue Seite erstellen</DialogTitle>
<DialogContent>
<TextField
Expand All @@ -288,6 +296,40 @@ const PageNavigator: React.FC<PageNavigatorProps> = ({ pages, selectedPageId })
value={newPageTitle}
onChange={(e) => setNewPageTitle(e.target.value)}
/>

{/* Layout-Auswahl mit Vorschau bereits bei der Anlage */}
<Box
sx={{
display: 'flex',
gap: 2,
alignItems: 'flex-start',
mt: 2,
flexDirection: { xs: 'column', sm: 'row' }
}}
>
<FormControl fullWidth margin="dense" sx={{ flex: 1, mt: 0 }}>
<InputLabel id="new-page-layout-label">Layout</InputLabel>
<Select
labelId="new-page-layout-label"
id="new-page-layout"
value={newPageLayout}
label="Layout"
onChange={(e) => setNewPageLayout(e.target.value)}
>
{SUPPORTED_PAGE_LAYOUTS.map((opt) => (
<MenuItem key={opt.value || 'standard'} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
<FormHelperText>
{SUPPORTED_PAGE_LAYOUTS.find((o) => o.value === newPageLayout)?.description}
</FormHelperText>
</FormControl>
<Box sx={{ flexShrink: 0, pt: { xs: 0, sm: 1 } }}>
<LayoutPreview layout={newPageLayout} />
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleCloseNewPageDialog}>Abbrechen</Button>
Expand Down
49 changes: 49 additions & 0 deletions src/components/PageNavigator/pageLayouts.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading
Loading