17 KiB
| title | status | audience | language | last_updated | when_to_read | ||
|---|---|---|---|---|---|---|---|
| Entity Dialog | active |
|
nl | 2026-05-03 | Before building any create/edit/detail dialog component. |
Pattern — Entity Dialog
Deze pagina is bindend voor elke create/edit/detail-dialog in Scrum4Me, ongeacht het achterliggende dataobject (PBI, Story, Task, Todo, Sprint, Product, User, of toekomstige entiteiten). Een nieuwe dialog die hier niet aan voldoet, hoort niet gemerged te worden.
Doel: elke dialog voelt identiek aan voor de gebruiker, hergebruikt dezelfde primitives, en heeft de drielaagse demo-policy + auth-scoping standaard ingebakken.
Voor entity-specifieke afwijkingen of velden: schrijf één begeleidende doc per entiteit (zie sectie § Per-entiteit profile). Voorbeeld: docs/specs/dialogs/task.md is het Task-profiel.
1 — Verplichte uitgangspunten
| # | Regel | Bron / waarom |
|---|---|---|
| 1.1 | Bouw op components/ui/dialog.tsx (de bestaande shadcn/@base-ui/react-wrapper). Geen directe imports van dialog-primitives uit @base-ui/react. |
Voorkomt twee parallelle dialog-implementaties met inconsistente animatie/focus-trap/theming |
| 1.2 | Gebruik composition via de render-prop (zie CLAUDE.md "UI Library Conventions"). Nooit Radix' asChild. |
Project gebruikt @base-ui/react, niet Radix |
| 1.3 | Mode (create vs edit vs detail) wordt afgeleid uit één input — een prop, een state-object of een searchParam. Niet twee aparte componenten. |
Voorkomt code-duplicatie en inconsistente labels/footer-layouts |
| 1.4 | Auth-scoping op elke server action via productAccessFilter(userId) (of het scope-helper-equivalent). Cross-tenant writes mogen onmogelijk zijn. |
CLAUDE.md "Toegangsmodel" + docs/patterns/server-action.md |
| 1.5 | Drielaagse demo-policy (verplicht — zie § 6) op elke write-actie. | CLAUDE.md "Demo-check" + docs/architecture.md#demo-user-policy |
| 1.6 | Validatie via één gedeeld zod-schema (lib/schemas/<entity>.ts) — gebruikt door zowel form als server action. |
CLAUDE.md "Validatie" |
| 1.7 | Foutcodes volgen de project-conventie (§ 5). | CLAUDE.md "Foutcodes API" |
| 1.8 | Geen willekeurige Tailwind-kleuren (bg-blue-500 etc.). Alleen MD3-tokens uit app/styles/theme.css. |
docs/design/styling.md |
2 — Stack & dependencies
Toegestane runtime-deps voor dialog-werk (al aanwezig of standaard pattern):
| Doel | Voorkeur | Acceptabele alternatief |
|---|---|---|
| Form-state | react-hook-form + @hookform/resolvers/zod |
useActionState + useFormStatus (Server Actions, native React) |
| Auto-grow textarea | react-textarea-autosize |
— |
| Markdown-rendering (preview) | react-markdown + remark-gfm (via bestaande <Markdown>-wrapper) |
— |
| Toasts | sonner |
— |
| Iconen | lucide-react |
— |
Per-dialog mag je kiezen tussen
react-hook-formofuseActionState. Beide patronen draaien al in deze codebase. Kies één per dialog en blijf consistent binnen dat bestand. Mix ze niet.
Verboden in dialog-context (v1):
material-color-utilities(dynamic color valt buiten v1)- Nieuwe form-libraries — geen
formik,final-form, etc.
3 — Component-architectuur
3.1 Reusables (components/entity-dialog/ of components/shared/)
Deze primitives kennen géén entity-specifieke types en mogen door élke dialog gebruikt worden:
| Primitive | Locatie | Verantwoordelijkheid |
|---|---|---|
<Dialog> + <DialogContent> etc. |
components/ui/dialog.tsx |
Shell, motion, focus-trap, backdrop |
<PrioritySelect> / <PrioritySegmented> |
components/shared/priority-select.tsx |
P1-P4 — identiek over alle entiteiten |
<DemoTooltip> |
components/shared/demo-tooltip.tsx |
Wrapper rond write-knoppen voor demo-modus (laag 3 van 3) |
| Auto-grow textarea | (toe te voegen wanneer nodig in components/shared/) |
Wrapper rond react-textarea-autosize met char-counter + markdown-hint |
| Dirty-close-guard | (gedeelde AlertDialog-flow) | "Wijzigingen niet opgeslagen — weggooien?" |
<Markdown> |
components/markdown.tsx |
react-markdown + remark-gfm voor description/criteria-preview |
Wanneer je een primitive twee keer kopieert tussen entity-dialogs, promote 'm meteen naar
components/shared/(ofcomponents/entity-dialog/). Drie keer is te laat.
3.2 Entity-specifieke laag (components/<domain>/<entity>-dialog.tsx)
Per entiteit één wrapper-bestand dat:
- De juiste form/body rendert
- De juiste server actions koppelt (
save<Entity>Action,delete<Entity>Action) - Entiteit-specifieke labels levert ("Story bewerken", "PBI aanmaken", etc.)
Een entity-dialog bevat geen layout-mechanica, motion-config of dirty-check zelf — die komen uit § 3.1.
4 — Layout & responsive gedrag
Identiek voor élke dialog (geen entity-specifieke variaties tenzij expliciet beargumenteerd in het entity-profile):
| Breakpoint | Breedte | Hoogte |
|---|---|---|
| Mobiel (< 640px) | full-screen | full-screen |
| Tablet (640–1024px) | 90vw |
max-h-[85vh] |
| Desktop (≥ 1024px) | max-w-[50vw], min-w-[480px] |
max-h-[85vh] |
Verplicht:
- Padding
p-6rondom (24px) - Veld-spacing in body
space-y-6(24px) - Sticky header (titel + close) en sticky footer (knoppen)
- Body scrollt onafhankelijk; geneste scrolls vermijden
- Footer heeft
border-tinoutline-variant
5 — Validatie & foutcodes
5.1 zod-schema
Eén lib/schemas/<entity>.ts per entiteit. Geïmporteerd door zowel form als server action — geen aparte definities.
5.2 Foutcodes (verplicht)
| Code | Wanneer | UI-respons |
|---|---|---|
| 422 | zod-validatiefout (server-side dubbelcheck) | fieldErrors mappen naar form.setError(), géén toast |
| 403 | demo-sessie probeert te schrijven, of cross-tenant write geblokkeerd | toast "Niet toegestaan in demo-modus" of "Geen toegang", form blijft open |
| 400 | malformed JSON-body (request.json() faalt) — alleen bij REST-route-handlers |
toast "Ongeldige aanvraag" |
| 500 | onverwachte serverfout | toast met "Opnieuw proberen"-knop, form-state behouden |
Field-level errors zijn alleen geldig bij
code: 422. Bij andere codes isfieldErrorsongedefinieerd.
5.3 Field-level rendering
- Errors onder het veld, in
text-error, metborder-errorop het input-element - Géén toast voor field-level errors
- Submit-knop blijft enabled bij errors — klik scrollt naar eerste error-veld + focus
- react-hook-form mode:
onTouched(eerste validatie bij blur, daarna onChange)
6 — Drielaagse demo-policy (verplicht voor write-dialogs)
Elke dialog die schrijft (create / edit / delete) MOET door alle drie de lagen heen:
- Middleware-guard in
proxy.ts— blokkeert demo-sessies op write-routes vóór de server action loopt. Returnt 403. session.isDemo-check binnen elkesave<Entity>Action/delete<Entity>Actionzelf — defense-in-depth voor het geval een actie buiten een proxy-route loopt. Returnt 403.<DemoTooltip show={isDemo}>rond de submit- en delete-knoppen — UI-laag: knoppendisabledmet tooltip "Demo-modus: opslaan uitgeschakeld".
Eén laag missen = bug. Reviewers moeten alle drie de lagen kunnen aanwijzen in de PR.
7 — Submission-flow
7.1 Server Action (template)
// actions/<entity>.ts
'use server'
export async function save<Entity>Action(
input: <Entity>Input,
context: { /* ids voor revalidatePath en scope */ },
): Promise<Save<Entity>Result> {
const session = await getSession()
if (!session.userId) return { ok: false, code: 403, error: 'forbidden' }
if (session.isDemo) return { ok: false, code: 403, error: 'demo_readonly' }
const scope = await productAccessFilter(session.userId) // verplicht
const parsed = <entity>Schema.safeParse(input)
if (!parsed.success) {
return { ok: false, code: 422, error: 'validation', fieldErrors: parsed.error.flatten().fieldErrors }
}
// ... Prisma write binnen `scope` ...
// revalidatePath(...) op de context-route
return { ok: true, <entity>: row }
}
type Save<Entity>Result =
| { ok: true; <entity>: <Entity> }
| { ok: false; code: 422; error: 'validation'; fieldErrors: Record<string, string[]> }
| { ok: false; code: 403; error: 'demo_readonly' | 'forbidden' }
| { ok: false; code: 500; error: 'server_error' }
7.2 Revalidation
revalidatePath op de context-route waarin de dialog werd geopend, niet op een statisch path. Context wordt door de aanroepende client meegegeven (geen hard-coded paths in de action).
7.3 Submit-flow
- Synchroon (geen optimistic update in v1, behalve waar het store-patroon
usePlannerStoreal bestaat) - Tijdens submit: cancel- en submit-knop disabled, spinner of "…" in submit-knop, velden blijven enabled
- Server saniteert en valideert opnieuw met hetzelfde zod-schema
8 — Dialog-gedrag (UX-regels)
8.1 Sluiten met dirty state
- Form niet aangeraakt → Esc / backdrop-klik / Cancel sluiten direct
- Form
isDirty→ Esc / backdrop-klik / Cancel triggerenAlertDialog: "Wijzigingen niet opgeslagen — weggooien?"
8.2 Keyboard shortcuts
| Toets | Actie |
|---|---|
| Esc | Sluiten (met dirty-check) |
| Cmd/Ctrl + Enter | Submit vanuit elk veld |
Enter in <input type=text> |
Geen submit (alleen Cmd/Ctrl+Enter) |
Enter in <textarea> |
Newline (default browser, niet overriden) |
| Tab | Top-naar-bottom door velden, dan Cancel → Submit (en Delete in edit-mode) |
8.3 Focus management
- Bij openen: focus op het eerste tekstveld (meestal
title/name) - Edit-mode: cursor aan het einde van bestaande waarde, geen auto-select (anders typt user per ongeluk weg)
- Bij sluiten: focus terug naar het element dat de dialog opende (
@base-ui/reactdoet dit by default — niet breken) - Bij submit-error: focus naar eerste error-veld
8.4 Motion (MD3-conform)
- Open: 250ms, easing
cubic-bezier(0.2, 0, 0, 1), scale 0.95→1 + opacity 0→1 - Close: 200ms, easing
cubic-bezier(0.4, 0, 1, 1)
8.5 Backdrop
rgba(0,0,0,0.4) (iets sterker dan MD3-default 0.32 voor contrast op zowel licht als donker).
9 — Theming (MD3-tokens)
| Surface | Token |
|---|---|
| Dialog-background | surface-container-high + shadow-2xl (getemperde opacity) |
| Form input-background | surface-container-low, geen shadow |
| Footer top-border | outline-variant |
| Submit-knop (filled) | primary background + on-primary tekst |
| Cancel-knop (text) | geen background + primary tekst |
| Delete-knop (tonal error) | error-container background + on-error-container tekst |
Density: comfortable (geen compact). Single-line input h-14 (56px MD3-default).
Typography:
headline-small(24px) — dialog-titelbody-large(16px) — form-input tekstbody-medium(14px) — helptext, counter
10 — Footer
10.1 Edit-mode
[ Verwijderen ] [ Annuleren ] [ Opslaan ]
tonal (error-container) text filled (primary)
10.2 Create-mode
[ Annuleren ] [ Aanmaken ]
text filled (primary)
10.3 Detail-mode (read-only)
[ Sluiten ]
filled (primary)
10.4 Delete-flow (verplicht patroon)
- Klik "Verwijderen" →
AlertDialog: "Weet je zeker? Dit kan niet ongedaan worden." - Bevestigen →
delete<Entity>Action(zelfde auth-scoping én demo-checks als save) →revalidatePathop context-route → dialog sluit → toast " verwijderd" - Geen undo in v1
11 — Triggers & URL-state
Elke dialog wordt geopend vanuit een context-pagina (sprint, backlog, dashboard, …). Twee patronen zijn toegestaan:
11.1 URL-based (voorkeur voor deep-link-bare dialogen)
?new<Entity>=1 → create-dialog open
?edit<Entity>=<id> → edit-dialog open
Kies dit patroon wanneer de dialog gedeeld of gebookmarkt moet kunnen worden, of wanneer Suspense + skeleton voor edit-mode gewenst is.
Sluiten = dezelfde route opnieuw pushen zonder de query-params.
11.2 State-based (voor pure UI-dialogen binnen één client-component)
const [dialogState, setDialogState] = useState<DialogState | null>(null)
<EntityDialog state={dialogState} onClose={() => setDialogState(null)} />
Kies dit patroon voor dialogen die altijd binnen één parent-component leven en geen deep-linking nodig hebben (bv. PBI-dialog binnen PbiList, Story-dialog binnen StoryPanel).
Mix de twee patronen niet binnen één entity-dialog. Documenteer in het entity-profile welk patroon gekozen is en waarom.
11.3 Server-side fetch in edit-mode
Bij URL-based pattern: server component fetcht het record vóór render — inclusief auth-scoping. Bestaat het record niet of valt het buiten scope → toast + redirect naar context-route zonder query-param.
Voor state-based pattern: het record komt al uit de parent (in-memory store of server-prop), dus geen aparte fetch nodig.
12 — Per-entiteit profile (verplicht)
Voor elke entiteit met een dialog hoort één profiel-doc te bestaan: docs/<<entity>-dialog>.md (of vergelijkbaar). Het profiel vult de generieke spec aan en bevat alleen de entity-specifieke onderdelen.
Verplichte secties van het entity-profile
# <Entity>Dialog Profiel
> Volgt `docs/patterns/dialog.md`. Dit document beschrijft alleen de afwijkingen en entity-specifieke keuzes.
## Velden
| Veld | Type | Mode | Validatie |
|---|---|---|---|
| ... | ... | create / edit / both | ... |
## URL- of state-pattern
- Gekozen: <URL-based | state-based>
- Reden: <korte motivatie>
- Routes (indien URL-based): `?new<Entity>=1` op `<route>`, `?edit<Entity>=<id>` op `<route>`
## Status-veld (indien aanwezig)
- Enum: ...
- Dot-kleur-mapping: ...
- Default bij create: ...
## Server actions
- `save<Entity>Action` — locatie, context-arg, revalidate-paden
- `delete<Entity>Action` — locatie, context-arg, revalidate-paden
## Speciale gedragingen (alleen indien de generieke spec niet volstaat)
- ... (bv. story-log-tab, claude-question-historie, file-upload, etc.)
## Bewust NIET in v1
- ...
Geen entity-profile = geen merge. Reviewers checken op het bestaan van het profiel-bestand bij een PR die een nieuwe dialog introduceert.
13 — Bewust niet in deze pattern (out of scope)
Deze items zijn over het algemeen niet welkom in een dialog. Een entity-profile mag ze toevoegen, maar moet dan de afweging documenteren:
- ❌ Bulk-edit (meerdere records tegelijk)
- ❌ Drag-and-drop binnen de dialog (drag hoort op de lijst-view)
- ❌ Tabs voor secties — alleen spacing-gebaseerde groepering
- ❌ Section-headers — implicit via spacing, geen labels
- ❌ Sub-entiteiten (parent-child binnen dezelfde dialog)
- ❌ File uploads (uitzondering: avatar — eigen pattern)
- ❌ Comments / activity log binnen de dialog (mag wel als read-only side-panel; zie
StoryLog) - ❌ Optimistic locking — last-write-wins binnen scope
- ❌ Cmd+K / quick-create zonder dialog
- ❌ Templates voor terugkerende records
- ❌ Telemetrie / per-veld analytics
14 — Verificatie-checklist (per nieuwe dialog)
Reviewer en bouwer lopen deze door vóór merge:
- Bouwt op
components/ui/dialog.tsx(geen directe@base-ui/react-imports) - Composition via
render-prop waar nodig - Layout volgt § 4 (responsive breakpoints, padding, sticky header/footer)
- zod-schema in
lib/schemas/<entity>.ts, gedeeld door form en action - Server action heeft
productAccessFilter(userId)+session.isDemo-check proxy.tsheeft een guard voor de bijbehorende write-route(s) (laag 1)- Submit/Delete-knoppen omwikkeld met
<DemoTooltip show={isDemo}>(laag 3) - Foutcodes 422/403/500 correct teruggemapt naar UI (zie § 5)
- Dirty-close-guard actief
- Cmd/Ctrl+Enter submit, Esc sluit (met dirty-check)
- Focus management: opent op eerste veld, geen auto-select in edit-mode
- Motion + backdrop conform § 8.4–8.5
- Alleen MD3-tokens; geen
bg-blue-500-style classes - Footer-layout per mode conform § 10
- Delete-flow: AlertDialog → action → toast
- URL- of state-pattern gekozen + gedocumenteerd in entity-profile
- Entity-profile bestaat in
docs/en is up-to-date - Test gedekt: server action met validatie + demo + scope, en component-test op render + submit-flow
15 — Referenties
CLAUDE.md— UI Library Conventions, Demo-check, Foutcodes API, Validatiedocs/design/styling.md— MD3-tokens, kleurklassendocs/architecture.md— Demo user policy, scope-helpersdocs/patterns/server-action.md— Server Action template (auth + Zod)docs/patterns/zustand-optimistic.md— voor lijst-views die de dialog aanroependocs/specs/dialogs/task.md— voorbeeld-profile voor entiteit "Task"