Rename 10 docs/scrum4me-*.md files to unprefixed kebab-case names. Update every internal link in docs/, CLAUDE.md, AGENTS.md, README.md.
387 lines
17 KiB
Markdown
387 lines
17 KiB
Markdown
# 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](#per-entiteit-profile-verplicht)). 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 (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`
|
||
|
||
---
|
||
|
||
## 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/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"
|