--- 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/.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 ``-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 | |---|---|---| | `` + `` etc. | `components/ui/dialog.tsx` | Shell, motion, focus-trap, backdrop | | `` / `` | `components/shared/priority-select.tsx` | P1-P4 — identiek over alle entiteiten | | `` | `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?" | | `` | `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//-dialog.tsx`) Per entiteit één wrapper-bestand dat: 1. De juiste form/body rendert 2. De juiste server actions koppelt (`saveAction`, `deleteAction`) 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` - `` - 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, `` 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/.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/.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/.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 `saveAction` / `deleteAction` zelf — defense-in-depth voor het geval een actie buiten een proxy-route loopt. Returnt **403**. 3. **``** 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/.ts 'use server' export async function saveAction( input: Input, context: { /* ids voor revalidatePath en scope */ }, ): PromiseResult> { 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 = 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, : row } } type SaveResult = | { ok: true; : } | { ok: false; code: 422; error: 'validation'; fieldErrors: Record } | { 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 ``** | **Geen** submit (alleen Cmd/Ctrl+Enter) | | **Enter in `