Scrum4Me/docs/patterns/dialog.md

17 KiB
Raw Blame History

title status audience language last_updated when_to_read
Entity Dialog active
ai-agent
contributor
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/task-dialog.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/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-form of useActionState. 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/ (of components/entity-dialog/). Drie keer is te laat.

3.2 Entity-specifieke laag (components/<domain>/<entity>-dialog.tsx)

Per entiteit één wrapper-bestand dat:

  1. De juiste form/body rendert
  2. De juiste server actions koppelt (save<Entity>Action, delete<Entity>Action)
  3. 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 (6401024px) 90vw max-h-[85vh]
Desktop (≥ 1024px) max-w-[50vw], min-w-[480px] max-h-[85vh]

Verplicht:

  • Padding p-6 rondom (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-t in outline-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 is fieldErrors ongedefinieerd.

5.3 Field-level rendering

  • Errors onder het veld, in text-error, met border-error op 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:

  1. Middleware-guard in proxy.ts — blokkeert demo-sessies op write-routes vóór de server action loopt. Returnt 403.
  2. session.isDemo-check binnen elke save<Entity>Action / delete<Entity>Action zelf — defense-in-depth voor het geval een actie buiten een proxy-route loopt. Returnt 403.
  3. <DemoTooltip show={isDemo}> rond de submit- en delete-knoppen — UI-laag: knoppen disabled met 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 usePlannerStore al 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 triggeren AlertDialog: "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/react doet 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-titel
  • body-large (16px) — form-input tekst
  • body-medium (14px) — helptext, counter

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)

  1. Klik "Verwijderen" → AlertDialog: "Weet je zeker? Dit kan niet ongedaan worden."
  2. Bevestigen → delete<Entity>Action (zelfde auth-scoping én demo-checks als save) → revalidatePath op context-route → dialog sluit → toast " verwijderd"
  3. Geen undo in v1

11 — Triggers & URL-state

Elke dialog wordt geopend vanuit een context-pagina (sprint, backlog, dashboard, …). Twee patronen zijn toegestaan:

?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.ts heeft 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.48.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, Validatie
  • docs/styling.md — MD3-tokens, kleurklassen
  • docs/architecture.md — Demo user policy, scope-helpers
  • docs/patterns/server-action.md — Server Action template (auth + Zod)
  • docs/patterns/zustand-optimistic.md — voor lijst-views die de dialog aanroepen
  • docs/task-dialog.md — voorbeeld-profile voor entiteit "Task"