---
title: "Entity Dialog"
status: active
audience: [ai-agent, contributor]
language: nl
last_updated: 2026-05-08
when_to_read: "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, Idea, 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](#per-entiteit-profile-verplicht)). 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` vs `inspector`) wordt afgeleid uit één input — een prop, een `state`-object of een `searchParam`. **Niet** twee aparte componenten. Voor `inspector`: zie § 4a. | 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-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 (640–1024px) | `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`

---

## 4a — Inspector-mode (hybrid detail + inline-edit)

Een inspector-dialog is een **detail-overlay met inline-bewerkbare velden** voor een lopend record (typisch een taak, run of job). Onderscheidt zich op drie punten van create/edit/detail:

| Aspect | Create/Edit/Detail | Inspector |
|---|---|---|
| Persistence | submit-knop in footer roept één Server Action aan | per-veld blur-save via Route Handler (`PATCH`) of fine-grained Server Actions |
| Footer | statisch (`Annuleren` + `Opslaan`/`Aanmaken`/`Verwijderen`) | dynamisch — bevat status-indicatoren en context-knoppen (bv. "Voer uit", "Annuleer agent", "Open PR") afhankelijk van een job/run-status |
| Body | sequentieel form (één entiteit invullen) | gegroepeerde secties: read-only metadata + bewerkbare controls + activity-status |
| Dirty-guard | verplicht (§8.1) | n.v.t. — wijzigingen worden direct gepersisteerd |
| Submit-shortcut | Cmd/Ctrl+Enter verplicht (§8.2) | n.v.t. — geen submit |
| Validatie | 422-fieldErrors in form | toast bij PATCH-fout, optimistisch terugdraaien |

**Wanneer kiezen voor inspector i.p.v. detail-mode?**
- Het record is "actief" (bv. agent draait erop) en meta-edits moeten direct effect hebben zonder save-cycle
- Verschillende velden gaan naar verschillende endpoints en willen niet gebundeld worden
- De footer toont liveness-info (job-status) i.p.v. acties op het hele record

**Layout-eisen (verplicht, gelijk aan §4):**
- Bouw op `components/ui/dialog.tsx`
- `<DialogContent className={entityDialogContentClasses}>`
- Sticky header met `entityDialogHeaderClasses` of equivalent (`shrink-0` + `border-b border-outline-variant`)
- Body in `entityDialogBodyClasses` (`flex-1 overflow-y-auto px-6 py-6 space-y-6`)
- Footer in `entityDialogFooterClasses` + extra modifiers voor wrap-gedrag bij dynamische knoppen (`flex flex-wrap items-center gap-2`)

**Wat blijft hetzelfde als bij andere modi:**
- Drielaagse demo-policy (§6) — proxy-guard, server/route-handler `session.isDemo`-check, `<DemoTooltip>` rond bewerkbare controls
- MD3-tokens (§9), motion (§8.4), backdrop (§8.5), focus return (§8.3)
- Auth-scoping op elke write (§1.4)
- Eén entity-profile in `docs/specs/dialogs/<entity>.md`

**Wat je expliciet niet doet in inspector-mode:**
- ❌ Geen `useDirtyCloseGuard` (geen dirty-state) — Esc/backdrop sluit direct
- ❌ Geen `useDialogSubmitShortcut` (geen submit)
- ❌ Geen verplichte `lib/schemas/<entity>.ts` voor het hele record — wél schema's per PATCH-veld of per fine-grained action
- ❌ Geen footer met statische save/cancel-knoppen — die suggereren bundle-save

**Voorbeeld in deze codebase:** `components/solo/task-detail-dialog.tsx` — opent een lopende solo-taak, plan-textarea slaat op blur op via `PATCH /api/tasks/:id`, verify-toggles direct via dezelfde route, footer toont job-status met context-acties (Voer uit / Wacht op agent / Annuleer / Open PR / Mislukt).

Profiel: `docs/specs/dialogs/task-detail.md`.

---

## 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)

```ts
// 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 — 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)

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 "<Entity> 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:

### 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)

```tsx
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

```md
# <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.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, Validatie
- `docs/design/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/specs/dialogs/task.md` — voorbeeld-profile voor entiteit "Task"
