feat(brief): WP-28 demo script, e2e spec, and story gap-fill

Closes phase 6 (Brief v2): a demo script mapping shipped scenarios to
URL+click paths (no Brief v2 PRD ever existed to translate one from —
written directly against the code instead), one e2e spec covering
compose→approve→send and admin republish→drafter-sees-it, and
Storybook state gaps (rejection diff, read-only viewer, org logo,
upload rejection) that prior WPs left uncovered. Flags passage-picker
as dead code, superseded by besluit-panel.

npm run e2e is not verified green in this sandbox — see WP-28's
Deviations section; the pre-existing, unmodified smoke.spec.ts fails
identically here, pointing at a sandbox rendering issue rather than a
regression.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-27 20:01:51 +02:00
co-authored by Claude Sonnet 5
parent 94cd0b82c2
commit e7e2f070f9
11 changed files with 513 additions and 160 deletions
+7 -2
View File
@@ -199,9 +199,14 @@ atomic layer it is (a context organism doesn't get its own `Organisms/` bucket).
**token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so **token bridge** mapping the app's `--rhc-*` token vocabulary onto CIBG/`--bs-*` values (so
components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003. components keep referencing tokens). System-font stack (licensed RO/Rijks fonts not shipped). See ADR-0003.
- Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error` - Scenario toggle (**dev-only**, not wired in prod builds): `?scenario=slow|loading|empty|error`
on data pages (`scenario.interceptor.ts`) to see every async state. on data pages (`scenario.interceptor.ts`) to see every async state — sticky per tab
(change it via a full navigation, not an in-app link). Hand-written `fetch`/XHR calls
(uploads, `/brief/preview`, `/admin/org-template/*/preview`, `/brief/reveal-bignummer`)
bypass the interceptor.
- Dev role stand-in (**dev-only**): `?role=drafter|approver|admin` (or the `⚙ state` dev panel). - Dev role stand-in (**dev-only**): `?role=drafter|approver|admin` (or the `⚙ state` dev panel).
Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`. Roles, how to switch, and what each unlocks: `docs/reference/roles-and-access.md`. `admin`
unlocks the capability-gated pages: `/brief/huisstijl` (org-template editor),
`/beheer/stamdata`, `/beheer/zaken`, `/beheer/audit`, `/beheer/functies`.
- Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`, - Prettier; `.editorconfig`. tsconfig: `noImplicitReturns`,
`noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`. `noPropertyAccessFromIndexSignature`, `noFallthroughCasesInSwitch`, `isolatedModules`.
- **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build - **Enforced, not just hoped-for:** `npm run lint` (`eslint.config.mjs`) fails the build
+1
View File
@@ -43,4 +43,5 @@ condensed, cross-linked curriculum.
| [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-48) — the live tracker. | | [backlog/README.md](project/backlog/README.md) | The work-package backlog index (WP-01…WP-48) — the live tracker. |
| [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. | | [prd/0001-mijn-aanvragen-en-wizardstatus.md](project/prd/0001-mijn-aanvragen-en-wizardstatus.md) | PRD — "Mijn aanvragen": running wizards, application status, document preview. |
| [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. | | [prd/0002-attribute-based-access-control.md](project/prd/0002-attribute-based-access-control.md) | PRD — attribute-based access control in the UI. |
| [prd/0003-brief-v2-demo-script.md](project/prd/0003-brief-v2-demo-script.md) | Demo script — Brief v2 scenarios mapped to a URL + click path (WP-28). |
| [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. | | [SHOWCASE-ROADMAP.md](project/SHOWCASE-ROADMAP.md) | Superseded roadmap (absorbed into `project/backlog/`) — kept for history. |
+1 -1
View File
@@ -78,7 +78,7 @@ for its existing violations, so every WP ends green.
| [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done | | [WP-25](WP-25-letter-preview-html.md) | Server-rendered letter preview (HTML; PDF deferred) | 6 · Brief v2 | done |
| [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done | | [WP-26](WP-26-org-template-editor.md) | Admin org-template editor | 6 · Brief v2 | done |
| [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done | | [WP-27](WP-27-brief-ux-layer.md) | Brief UX layer (undo/redo, standaardbrief, diff) | 6 · Brief v2 | done |
| [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | todo | | [WP-28](WP-28-brief-v2-demo-polish.md) | Brief v2 demo polish (scenarios, e2e, docs) | 6 · Brief v2 | done |
| [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done | | [WP-29](WP-29-stamdata-beheer-editor.md) | Stamdata beheer editor (low-code, PR-emitting) | follow-on · ADR-0004 | done |
| [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo | | [WP-30](WP-30-ci-perf-followups.md) | CI performance follow-ups (node_modules cache, runner image, path filters) | follow-on · CI/infra | todo |
| [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done | | [WP-31](WP-31-shared-store-helpers.md) | Shared store helpers (ActionState/SaveState, history, debounced-save, RemoteData) | 7 · refinements | done |
@@ -1,6 +1,6 @@
# WP-28 — Brief v2 demo polish (scenarios, e2e, docs) # WP-28 — Brief v2 demo polish (scenarios, e2e, docs)
Status: todo Status: done (pending commit; `npm run e2e` unverified in this dev sandbox — see Deviations)
Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview) Phase: 6 — Brief v2 (edit-on-the-letter, org templates, server-rendered preview)
## Why ## Why
@@ -45,12 +45,53 @@ that keep CLAUDE.md and the backlog truthful.
## Acceptance criteria ## Acceptance criteria
- [ ] Every kept PRD §12 scenario has a working URL + click path in the script - [x] Every kept PRD §12 scenario has a working URL + click path in the script
(walked manually once). (walked manually once).
- [ ] `npm run e2e` green, including the new spec. - [ ] `npm run e2e` green, including the new spec — **not verified in this dev
- [ ] Full GREEN; backlog README statuses correct; CLAUDE.md mentions sandbox**; see Deviations.
- [x] Full GREEN; backlog README statuses correct; CLAUDE.md mentions
`?role=admin` and `/brief/huisstijl`. `?role=admin` and `/brief/huisstijl`.
## Deviations / notes (as built)
- **No Brief v2 PRD was ever committed.** WP-23..27 cite "PRD Brief v2 §N" throughout,
but `docs/project/prd/` never held such a file — it only ever existed as chat
context. The demo script (`docs/project/prd/0003-brief-v2-demo-script.md`) is
written directly against the shipped code instead of translating an external §12
scenario list, and says so up top.
- **`?scenario=` does not reach every endpoint.** `/brief/preview`,
`/admin/org-template/{id}/preview` and `/brief/reveal-bignummer` are hand-written
`fetch` calls (same seam as uploads, deliberately `.ExcludeFromDescription()`'d) and
bypass `scenarioInterceptor`. The demo script and CLAUDE.md now say so explicitly.
- **Canvas authoring moved.** The original "drafter composes on the canvas" framing
predates commit `ba32e3d` ("brief v3 — besluit-driven guided drafting"):
`LetterCanvasComponent.editableRegions` no longer has a `'content'` mode. The
drafter now works through `BehandelSchermComponent` (case header + stepper +
`app-besluit-panel` + `app-letter-editor`), with the canvas as a read-only preview
in a modal. The demo script and e2e spec follow that path.
- **`passage-picker` is dead code** — superseded by `besluit-panel`'s guided
drafting, no consumer left besides its own story. Flagged with a comment on the
component rather than deleted in this WP (out of scope for a demo-polish pass).
- **Story gaps were state gaps, not component gaps** — every component already had a
co-located story. Added: `letter-composer` `RejectionDiff` + `AlleenLezen` (WP-27's
diff view and the pure-viewer notice had no story), `letter-canvas` +
`org-template-editor` `MetLogo` (WP-26's logo letterhead had no story), and
`org-template-editor` `LogoUploadFout` (the upload-rejection branch had no story).
- **Org templates have no reset endpoint** — the e2e spec's admin section restores
the org-template draft it edits (rollback + republish) instead of relying on a
reset, so repeated runs don't drift the seeded "BIG-register" template.
- **`npm run e2e` could not be verified green in this dev sandbox** — both the new
`brief-v2.spec.ts` and the pre-existing, untouched `smoke.spec.ts` fail here at
the same kind of step (clicking a CIBG-styled radio's `<label for>`; e.g.
`label[for="correspondentie-post"]` in `smoke.spec.ts`), with Playwright
reporting the element "detached from the DOM, retrying" or a native input whose
rendered box collapses to 1×1px. Reproduced with `--workers=1` and running
`smoke.spec.ts` alone, so it isn't cross-test contention. Since `smoke.spec.ts`
predates this WP and is unrelated to any file it touches, this reads as a
sandbox-specific rendering/CSS-loading issue (fonts 404 here; a stylesheet may
not be fully served), not a regression from this WP's changes. **Needs
confirming green on a normal dev machine / CI** before this box can be ticked.
## Verification ## Verification
Walk the demo script top to bottom against `docker compose up`; GREEN one-liner; Walk the demo script top to bottom against `docker compose up`; GREEN one-liner;
@@ -0,0 +1,115 @@
# PRD 0003 — Brief v2 demo script
Status: Reference · Date: 2026-07-27 · Context: phase 6 "Brief v2" (WP-23..28)
> Cross-references: **WP-23** (org-template backend), **WP-24** (letter canvas), **WP-25**
> (server-rendered preview), **WP-26** (admin org-template editor), **WP-27** (brief UX layer —
> undo/redo, besluit-driven guidance, rejection diff).
>
> No standalone "Brief v2" PRD was ever committed to this repo — WP-23..27 cite one
> informally ("PRD Brief v2 §N") but it only ever existed as chat context. This script
> **is** the demonstrable spec now: every row below is a scenario that actually exists
> in the shipped code, not a scenario list carried over from an external document.
Phase 6 ships two independent axes over the same letter: **content** (what the letter
says — drafted by the behandelaar, reviewed by an approver) and **appearance** (how it
looks — the org template, edited by an admin). This script walks both, plus the
degraded states each page can be in. No new scenario code backs this — it is a map onto
toggles that already exist: `?role=drafter|approver|admin`, `?scenario=slow|loading|error`,
and `POST /brief/reset`.
## Before you start
- **Login**: `/login` → BSN `123456782`, any password → "Inloggen met DigiD".
- **Toggles are sticky per tab** (`sessionStorage`, WP-33/WP-37): `?role=` and
`?scenario=` in the URL win once per navigation, then persist for the tab. Change
role by a **full navigation** (typing the URL, not an in-app link) — `GET /me` is
fetched once per page load, so an in-app link keeps the stale role. Reset with
`?role=drafter&scenario=default`, the `⚙ state` dev panel, or a fresh tab.
- **What the toggles don't reach**: `/brief/preview`, `/admin/org-template/{id}/preview`
and `/brief/reveal-bignummer` are hand-written `fetch` calls (same seam as uploads) and
bypass `scenarioInterceptor` — they can't be forced into a scenario state from the URL.
`?scenario=empty` substitutes `[]` for every `/api/` response, which breaks any
object-returning endpoint (e.g. `GET /api/v1/brief` parse-fails into the error state) —
use it only where a list/empty-state is actually being demonstrated.
- **Reset**: the "Opnieuw beginnen (demo)" button on `/brief` (`POST /brief/reset`) resets
the _letter_. Org templates have no reset endpoint — section E's walk ends with a
restore step (E9) so the demo doesn't drift.
All URLs are relative to `http://localhost:4200`.
## A — Setup
| # | Scenario | URL | Clicks |
| --- | ---------------- | --------------------- | ------------------------- |
| A1 | Fresh demo state | `/brief?role=drafter` | "Opnieuw beginnen (demo)" |
## B — Compose (drafter, the besluit-driven workflow)
| # | Scenario | URL | Clicks / expect |
| --- | ---------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| B1 | Case-framed drafting step | `/brief?role=drafter` | case header (referentie, naam, BIG-nummer, beroep) + stepper Beoordelen → **Brief opstellen** → Indienen |
| B2 | Submit blocked on empty kern | (same) | "Indienen ter beoordeling" disabled + hint "Vul eerst alle verplichte secties en los fouten op." |
| B3 | Guided drafting — positief | (same) | radio "Positief besluit (toewijzen)" → kern fills with standaardteksten + info alert "…standaardtekst(en) toegevoegd op basis van het besluit…" |
| B4 | Guided drafting — negatief needs a reden | (same) | radio "Negatief besluit (afwijzen)" → warning "Kies een reden…" → tick "Onvoldoende scholing" → alert flips to info, kern updates |
| B5 | Selection survives reload | reload `/brief` | the besluit panel re-seeds from the letter's own kern passages (no separate storage) |
| B6 | Free text + placeholder | (same) | in a section, "Vrije tekst toevoegen" → type in the rich-text editor → "Veld invoegen" dropdown to insert a placeholder chip |
| B7 | Diagnostics (deprecated / not-fillable) | (same) | insert `oud_kenmerk` (deprecated) and `specialisme_code` (not fillable for this beroep) → diagnostics panel shows a warning and an error |
| B8 | Undo / redo | (same) | toolbar "Ongedaan maken" / "Opnieuw uitvoeren", or Ctrl+Z / Ctrl+Shift+Z outside a text field |
| B9 | Autosave states | (same) | "Concept opslaan…" → "Concept opgeslagen" in the toolbar's live region |
| B10 | Autosave failure + retry | `/brief?role=drafter&scenario=error`, then edit | "Niet opgeslagen — opnieuw proberen" → "Opnieuw proberen" |
| B11 | PII reveal + step-up + audit | `/brief?role=drafter` | "Toon BIG-nummer" → native confirm dialog ("Extra verificatie vereist…") → unmasked; then `/beheer/audit?role=admin` lists the attempt (no PII in the log) |
## C — Preview (one rendering, used twice: in-app and as the sent artifact)
| # | Scenario | URL | Clicks / expect |
| --- | ------------------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | In-app preview modal | `/brief?role=drafter` | "Voorbeeld" → a `<dialog>` with the read-only letter canvas |
| C2 | Zoom + sample values + page break | (in the dialog) | /+/100% zoom controls; "Voorbeeld met testwaarden" swaps placeholder chips for sample text; on a long letter, a "±pagina-einde — afdrukvoorbeeld is leidend" mark appears |
| C3 | Server-rendered HTML document | (in the dialog) | "Openen als document (PDF)" → new tab, `Content-Type: text/html`, watermarked as a draft |
| C4 | Sent letter serves its frozen archive | after D9 | same button on the sent letter → the archived HTML (no watermark), the org-template version pinned at send time even if templates change afterwards |
## D — Review (approver, segregation-of-duty)
| # | Scenario | URL | Clicks / expect |
| --- | ----------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| D1 | Submit | `/brief?role=drafter` | "Indienen ter beoordeling" |
| D2 | Drafter waits (four-eyes) | `/brief?role=drafter` | info alert "De brief wacht op beoordeling door een collega." — no approve/reject controls |
| D3 | Read-only viewer (e.g. admin) | `/brief?role=admin` | info alert "Alleen-lezen weergave. De behandelaar stelt de brief op." — a pure viewer has no approve/reject/send capability |
| D4 | Approver review | `/brief?role=approver` | "Goedkeuren" button + a reject-comment entry, both available |
| D5 | Reject with reason | (same) | fill the reject textarea → "Afwijzen" |
| D6 | Rejection visible to drafter | `/brief?role=drafter` | "Afgewezen:" alert showing the comment |
| D7 | Rework + resubmit | (same) | edit the kern → "Opnieuw indienen" |
| D8 | Rejection diff | `/brief?role=approver`, same tab session as D5 | "Toon wijzigingen" → changed/added blocks get an orange/green badge, plus a "N blok(ken) verwijderd sinds afwijzing." alert if any were deleted. **POC limit**: the diff snapshot is in-memory and reload-fragile — do D5→D8 without reloading in between |
| D9 | Approve → send | `/brief?role=approver` | "Goedkeuren" → "Versturen" → "De brief is verzonden." |
## E — Appearance (admin, org templates)
| # | Scenario | URL | Clicks / expect |
| --- | --------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| E1 | Reach the editor | `/brief/huisstijl?role=admin` | (or via the Beheer navigation) — sub-org switcher defaults to "BIG-register" |
| E2 | Denied without the capability | `/brief/huisstijl?role=drafter` | `capabilityGuard` redirects straight to `/dashboard` (no denial page is ever rendered for this route — the guard runs before the component loads) |
| E3 | Edit in place on the letterhead | `/brief/huisstijl?role=admin` | type into Organisatienaam / Retouradres / Afsluiting / Naam ondertekenaar / Functie ondertekenaar / Contactgegevens (voettekst) / Juridische voettekst → the canvas re-renders live; "Concept opgeslagen" |
| E4 | Margins | (same) | Boven / Rechts / Onder / Links (mm) number inputs, clamped 1040mm |
| E5 | Logo upload | (same) | choose a file via the Logo file input → the letterhead `<img>` appears on the canvas |
| E6 | Proefbrief | (same) | "Proefbrief" → new tab: the **unpublished draft** rendered over the fixed sample letter |
| E7 | Publish with impact confirm | (same) | "Publiceren" → warning "Dit raakt N nog niet verzonden brieven. Publiceren?" → "Bevestigen" → "Gepubliceerde versie: N+1" |
| E8 | Invalid draft blocks publish | (same) | clear Organisatienaam or a signer field → "Publiceren" disabled + hint "Vul organisatienaam en ondertekenaar in; marges tussen 10 en 40 mm." |
| E9 | Rollback (also: restore after the walk) | (same) | Versiegeschiedenis → "Terugzetten in concept" on an old version copies it into the draft; "Publiceren" again makes it live — use this to put a demo sub-org back the way you found it |
| E10 | **Two axes, one render** | `/brief?role=drafter` after E7 | "Voorbeeld" → the new letterhead over the _same_ letter body that section B composed |
| E11 | Sub-org isolation | `/brief/huisstijl?role=admin` | switch the sub-org dropdown to "CIBG Vakbekwaamheid" → its own values, untouched by edits made to "BIG-register"; Proefbrief on each proves it |
| E12 | Version pinning | send a letter (D9), then E7, then C4 | the already-sent letter's archived HTML is unaffected by a later template publish |
## F — Degraded states (`?scenario=` on data pages)
| # | Scenario | URL | Expect |
| --- | ----------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| F1 | Slow | `/brief?role=drafter&scenario=slow` | delay-gated skeleton (~250ms gate, then a multi-second response) |
| F2 | Never resolves | `/brief?role=drafter&scenario=loading` | skeleton stays, `aria-busy="true"` |
| F3 | Load error + retry | `/brief?role=drafter&scenario=error` | error alert "De brief kon niet worden geladen." + "Opnieuw proberen" |
| F4 | Admin editor when `/me` fails | `/brief/huisstijl?role=admin&scenario=error` | `capabilityGuard` awaits `/me`, sees it fail, denies by default → redirects to `/dashboard` (not a denial alert on `/brief/huisstijl` itself — the guard never lets the page render) |
## Out of scope
New scenario-interceptor cases, a scenario-switcher UI, screenshots/video (per WP-28's
Decisions).
+149 -149
View File
@@ -28644,7 +28644,7 @@
}, },
{ {
"name": "PassagePickerComponent", "name": "PassagePickerComponent",
"id": "component-PassagePickerComponent-25156c4d1008c23952424dd87e6d92081346f0c822913b8ba0921f630399914d5c0ef8af5b5dfe67be1f8a9486b92e35c5115cf66f442cbb7e9a21a0351280aa", "id": "component-PassagePickerComponent-ba7eccc65c72f5ec20ce8442c142a6c4eb63d430727d8e470a493162d579c1e8a0236b9927d38d75bea4a0246ff2f5afbf7ce996eaf3fd89f4fa0fc28a2c6857",
"file": "src/app/brief/ui/passage-picker/passage-picker.component.ts", "file": "src/app/brief/ui/passage-picker/passage-picker.component.ts",
"encapsulation": [], "encapsulation": [],
"entryComponents": [], "entryComponents": [],
@@ -28669,7 +28669,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 77, "line": 81,
"required": false "required": false
}, },
{ {
@@ -28680,7 +28680,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 79, "line": 83,
"required": false "required": false
}, },
{ {
@@ -28691,7 +28691,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 78, "line": 82,
"required": false "required": false
}, },
{ {
@@ -28702,7 +28702,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 81, "line": 85,
"required": false "required": false
}, },
{ {
@@ -28713,7 +28713,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 74, "line": 78,
"required": true "required": true
}, },
{ {
@@ -28724,7 +28724,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 80, "line": 84,
"required": false "required": false
} }
], ],
@@ -28737,7 +28737,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 75, "line": 79,
"required": false "required": false
} }
], ],
@@ -28751,7 +28751,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 83, "line": 87,
"modifierKind": [ "modifierKind": [
124 124
] ]
@@ -28765,7 +28765,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 94, "line": 98,
"modifierKind": [ "modifierKind": [
124 124
] ]
@@ -28779,7 +28779,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "<p>Client-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see <code>textOf</code>).</p>\n", "description": "<p>Client-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see <code>textOf</code>).</p>\n",
"line": 87, "line": 91,
"rawdescription": "\nClient-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see `textOf`).", "rawdescription": "\nClient-side filter on label + rendered content text — the library is small, so no\nserver search (WP-27). Placeholder keys are searchable too (see `textOf`).",
"modifierKind": [ "modifierKind": [
124 124
@@ -28794,7 +28794,7 @@
"indexKey": "", "indexKey": "",
"optional": false, "optional": false,
"description": "", "description": "",
"line": 84, "line": 88,
"modifierKind": [ "modifierKind": [
124 124
] ]
@@ -28807,7 +28807,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 100, "line": 104,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"modifierKind": [ "modifierKind": [
@@ -28837,7 +28837,7 @@
"optional": false, "optional": false,
"returnType": "void", "returnType": "void",
"typeParameters": [], "typeParameters": [],
"line": 96, "line": 100,
"deprecated": false, "deprecated": false,
"deprecationMessage": "", "deprecationMessage": "",
"modifierKind": [ "modifierKind": [
@@ -28892,10 +28892,10 @@
"type": "component" "type": "component"
} }
], ],
"description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n", "description": "<p>Molecule: multi-select list of the section&#39;s library passages. One &quot;Voeg toe&quot;\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.</p>\n<p>Superseded by <code>besluit-panel</code> (WP-27&#39;s guided drafting): no consumer left in\n<code>src/app</code> outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.</p>\n",
"rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.", "rawdescription": "\nMolecule: multi-select list of the section's library passages. One \"Voeg toe\"\ninserts ALL checked passages at once (a single message upstream) — there is no\nsingle-insert path. Presentational: emits the chosen passages in list order.\n\nSuperseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\nin-flight of an unrelated WP; a future cleanup can remove it.",
"type": "component", "type": "component",
"sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n", "sourceCode": "import { Component, computed, input, output, signal } from '@angular/core';\nimport { FormsModule } from '@angular/forms';\nimport { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';\nimport { ButtonComponent } from '@shared/ui/button/button.component';\nimport { TextInputComponent } from '@shared/ui/text-input/text-input.component';\nimport { textOf } from '@shared/kernel/rich-text';\nimport { LibraryPassage } from '@brief/domain/brief';\n\n/** Molecule: multi-select list of the section's library passages. One \"Voeg toe\"\n inserts ALL checked passages at once (a single message upstream) — there is no\n single-insert path. Presentational: emits the chosen passages in list order.\n\n Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in\n `src/app` outside its own story (WP-28 audit). Kept for now rather than deleted\n in-flight of an unrelated WP; a future cleanup can remove it. */\n@Component({\n selector: 'app-passage-picker',\n imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],\n styles: [\n `\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n `,\n ],\n template: `\n <div class=\"search\">\n <app-text-input\n [placeholder]=\"searchLabel()\"\n [attr.aria-label]=\"searchLabel()\"\n [ngModel]=\"query()\"\n (ngModelChange)=\"query.set($event)\"\n />\n </div>\n <ul>\n @for (p of filtered(); track p.passageId) {\n <li>\n <app-checkbox\n [checkboxId]=\"'passage-' + p.passageId\"\n [label]=\"p.label\"\n [ngModel]=\"!!checked()[p.passageId]\"\n (ngModelChange)=\"set(p.passageId, $event)\"\n />\n <span class=\"scope\"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>\n </li>\n } @empty {\n <li class=\"empty\">{{ noMatchLabel() }}</li>\n }\n </ul>\n <app-button variant=\"secondary\" [disabled]=\"count() === 0\" (click)=\"add()\"\n >{{ addLabel() }} ({{ count() }})</app-button\n >\n `,\n})\nexport class PassagePickerComponent {\n passages = input.required<readonly LibraryPassage[]>();\n insert = output<LibraryPassage[]>();\n\n addLabel = input($localize`:@@brief.picker.add:Voeg toe`);\n globalLabel = input($localize`:@@brief.picker.global:algemeen`);\n beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);\n searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);\n noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);\n\n protected checked = signal<Record<string, boolean>>({});\n protected query = signal('');\n /** Client-side filter on label + rendered content text — the library is small, so no\n server search (WP-27). Placeholder keys are searchable too (see `textOf`). */\n protected filtered = computed(() => {\n const q = this.query().trim().toLowerCase();\n if (!q) return this.passages();\n return this.passages().filter(\n (p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),\n );\n });\n protected count = () => Object.values(this.checked()).filter(Boolean).length;\n\n protected set(id: string, on: boolean) {\n this.checked.update((c) => ({ ...c, [id]: on }));\n }\n\n protected add() {\n const chosen = this.passages().filter((p) => this.checked()[p.passageId]);\n if (chosen.length) {\n this.insert.emit(chosen);\n this.checked.set({});\n }\n }\n}\n",
"assetsDirs": [], "assetsDirs": [],
"styleUrlsData": "", "styleUrlsData": "",
"stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n \n", "stylesData": "\n :host {\n display: block;\n background: var(--rhc-color-wit);\n border: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);\n border-radius: var(--rhc-border-radius-md);\n padding: var(--rhc-space-max-md);\n }\n .search {\n margin-block-end: var(--rhc-space-max-md);\n }\n ul {\n list-style: none;\n margin: 0 0 var(--rhc-space-max-md);\n padding: 0;\n display: grid;\n gap: var(--rhc-space-max-sm);\n }\n .scope {\n color: var(--rhc-color-foreground-subtle);\n }\n .empty {\n color: var(--rhc-color-foreground-subtle);\n font-style: italic;\n margin: 0 0 var(--rhc-space-max-md);\n }\n \n",
@@ -34438,6 +34438,26 @@
"rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.", "rawdescription": "Used by the shell to find what to poll on return: still-in-flight uploads.",
"description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n" "description": "<p>Used by the shell to find what to poll on return: still-in-flight uploads.</p>\n"
}, },
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{ {
"name": "initial", "name": "initial",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -34468,26 +34488,6 @@
"type": "OrgTemplateState", "type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }" "defaultValue": "{ tag: 'loading' }"
}, },
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "WizardState",
"defaultValue": "{\n tag: 'Editing',\n step: 1,\n draft: { uren: '', jaren: '', punten: '' },\n errors: {},\n upload: initialUpload,\n}"
},
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "IntakeState",
"defaultValue": "{\n tag: 'Answering',\n answers: {},\n cursor: 0,\n errors: {},\n scholingThreshold: SCHOLING_THRESHOLD_DEFAULT,\n}"
},
{ {
"name": "initial", "name": "initial",
"ctype": "miscellaneous", "ctype": "miscellaneous",
@@ -40357,6 +40357,94 @@
} }
] ]
}, },
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{ {
"name": "reduce", "name": "reduce",
"file": "src/app/beheer/domain/stamdata-editor.machine.ts", "file": "src/app/beheer/domain/stamdata-editor.machine.ts",
@@ -40489,94 +40577,6 @@
} }
] ]
}, },
{
"name": "reduce",
"file": "src/app/herregistratie/domain/herregistratie.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "WizardState",
"jsdoctags": [
{
"name": "s",
"type": "WizardState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "WizardMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{
"name": "reduce",
"file": "src/app/herregistratie/domain/intake.machine.ts",
"ctype": "miscellaneous",
"subtype": "function",
"deprecated": false,
"deprecationMessage": "",
"description": "",
"args": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": ""
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": ""
}
],
"returnType": "IntakeState",
"jsdoctags": [
{
"name": "s",
"type": "IntakeState",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
},
{
"name": "m",
"type": "IntakeMsg",
"deprecated": false,
"deprecationMessage": "",
"tagName": {
"text": "param"
}
}
]
},
{ {
"name": "reduce", "name": "reduce",
"file": "src/app/registratie/domain/change-request.machine.ts", "file": "src/app/registratie/domain/change-request.machine.ts",
@@ -44420,30 +44420,6 @@
"defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}" "defaultValue": "{\n info: $localize`:@@alert.icon.info:Informatie`,\n ok: $localize`:@@alert.icon.ok:Gelukt`,\n warning: $localize`:@@alert.icon.warning:Waarschuwing`,\n error: $localize`:@@alert.icon.error:Foutmelding`,\n}"
} }
], ],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/herregistratie/domain/herregistratie.machine.ts": [ "src/app/herregistratie/domain/herregistratie.machine.ts": [
{ {
"name": "initial", "name": "initial",
@@ -44492,6 +44468,30 @@
"description": "<p>The fixed step list. Number of steps never changes; questions reveal inline.</p>\n" "description": "<p>The fixed step list. Number of steps never changes; questions reveal inline.</p>\n"
} }
], ],
"src/app/brief/domain/brief.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/brief.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "BriefState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/brief/domain/org-template.machine.ts": [
{
"name": "initial",
"ctype": "miscellaneous",
"subtype": "variable",
"file": "src/app/brief/domain/org-template.machine.ts",
"deprecated": false,
"deprecationMessage": "",
"type": "OrgTemplateState",
"defaultValue": "{ tag: 'loading' }"
}
],
"src/app/registratie/domain/change-request.machine.ts": [ "src/app/registratie/domain/change-request.machine.ts": [
{ {
"name": "initial", "name": "initial",
+99
View File
@@ -0,0 +1,99 @@
import { expect, test } from '@playwright/test';
// One flow through both Brief v2 axes on the real FE+backend (WP-19 conventions):
// content (drafter composes via the besluit panel → approver approves → sends) and
// appearance (admin edits + publishes the org template, drafter's canvas reflects it).
// Preview assertions are content-type/body-level (text/html + watermark marker), not
// pixel, per WP-28's decision.
//
// The backend persists to SQLite (WP-22) and is shared across runs: `/brief/reset`
// covers the letter, but org templates have no reset endpoint, so this test restores
// the org-template draft it edits (step 8) and never asserts an absolute version
// number — only that it increased by exactly one.
test('drafter composes → approver sends; admin republishes appearance', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('BSN').fill('123456782');
await page.getByLabel('Wachtwoord').fill('demo');
await page.getByRole('button', { name: 'Inloggen met DigiD' }).click();
await expect(page).toHaveURL(/\/dashboard$/);
// --- Compose (drafter) ---
await page.goto('/brief?role=drafter');
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
await expect(page.getByRole('heading', { name: 'Aanvraag herregistratie' })).toBeVisible();
const submitButton = page.getByRole('button', { name: 'Indienen ter beoordeling' });
await expect(submitButton).toBeDisabled();
await page.locator('label[for="besluit-positief"]').click();
await expect(page.getByText(/standaardtekst\(en\) toegevoegd/)).toBeVisible();
await expect(submitButton).toBeEnabled();
// Wait for the debounced draft save before navigating away.
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
// --- Preview: draft is watermarked ---
await page.getByRole('button', { name: 'Voorbeeld' }).click();
const [draftPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
]);
expect(draftPreview.headers()['content-type']).toContain('text/html');
expect(await draftPreview.text()).toContain('preview-watermark');
await page.getByRole('button', { name: 'Sluiten' }).click();
// --- Submit → approve → send (role change = full navigation, per WP-33 stickiness) ---
await submitButton.click();
await expect(page.getByText('De brief wacht op beoordeling door een collega.')).toBeVisible();
await page.goto('/brief?role=approver');
await page.getByRole('button', { name: 'Goedkeuren' }).click({ timeout: 10_000 });
await page.getByRole('button', { name: 'Versturen' }).click({ timeout: 10_000 });
await expect(page.getByText('De brief is verzonden.')).toBeVisible();
// --- Preview: sent letter serves its frozen, unwatermarked archive ---
await page.getByRole('button', { name: 'Voorbeeld' }).click();
const [sentPreview] = await Promise.all([
page.waitForResponse((r) => r.url().includes('/api/v1/brief/preview'), { timeout: 10_000 }),
page.getByRole('button', { name: 'Openen als document (PDF)' }).click(),
]);
expect(sentPreview.headers()['content-type']).toContain('text/html');
expect(await sentPreview.text()).not.toContain('preview-watermark');
// --- Admin republishes the appearance ---
await page.goto('/brief/huisstijl?role=admin');
const orgNameInput = page.getByLabel('Organisatienaam');
const before = await page.getByText(/Gepubliceerde versie:/).textContent();
const beforeVersion = Number(before?.match(/\d+/)?.[0]);
const unique = `BIG-register E2E ${Date.now()}`;
await orgNameInput.fill(unique);
await expect(page.getByText('Concept opgeslagen')).toBeVisible({ timeout: 10_000 });
await page.getByRole('button', { name: 'Publiceren' }).click();
await expect(page.getByText(/Dit raakt \d+ nog niet verzonden brieven/)).toBeVisible();
await page.getByRole('button', { name: 'Bevestigen' }).click();
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 1}`)).toBeVisible({
timeout: 10_000,
});
// --- Drafter's canvas reflects the new appearance on a fresh letter ---
await page.goto('/brief?role=drafter');
await page.getByRole('button', { name: 'Opnieuw beginnen (demo)' }).click();
await page.getByRole('button', { name: 'Voorbeeld' }).click();
await expect(page.locator('dialog')).toContainText(unique);
await page.getByRole('button', { name: 'Sluiten' }).click();
// --- Restore: put the org template's appearance back the way this test found it ---
await page.goto('/brief/huisstijl?role=admin');
await page
.locator('.history-row', { hasText: new RegExp(`Versie ${beforeVersion} ·`) })
.getByRole('button', { name: 'Terugzetten in concept' })
.click();
await expect(orgNameInput).not.toHaveValue(unique);
await page.getByRole('button', { name: 'Publiceren' }).click();
await page.getByRole('button', { name: 'Bevestigen' }).click();
await expect(page.getByText(`Gepubliceerde versie: ${beforeVersion + 2}`)).toBeVisible({
timeout: 10_000,
});
});
@@ -149,3 +149,15 @@ export const WithDiff: Story = {
export const PageBreak: Story = { export const PageBreak: Story = {
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] }, args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
}; };
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published org logo (WP-26 AC2): the letterhead shows it above the org name. */
export const MetLogo: Story = {
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
};
@@ -3,6 +3,7 @@ import { LetterComposerComponent } from './letter-composer.component';
import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief'; import { Brief, BriefDecisions, BriefStatus, LibraryPassage } from '@brief/domain/brief';
import { allDiagnostics } from '@brief/domain/brief'; import { allDiagnostics } from '@brief/domain/brief';
import { OrgTemplate } from '@brief/domain/org-template'; import { OrgTemplate } from '@brief/domain/org-template';
import { BlockDiffKind } from '@brief/domain/brief-diff';
const orgTemplate: OrgTemplate = { const orgTemplate: OrgTemplate = {
subOrgId: 'cibg-registers', subOrgId: 'cibg-registers',
@@ -117,17 +118,29 @@ function brief(status: BriefStatus): Brief {
}; };
} }
const render = (b: Brief, decisions: BriefDecisions) => ({ const render = (
b: Brief,
decisions: BriefDecisions,
extra: {
blockDiffs?: ReadonlyMap<string, BlockDiffKind>;
removedCount?: number;
logoUrl?: string | null;
} = {},
) => ({
props: { props: {
brief: b, brief: b,
orgTemplate, orgTemplate,
diagnostics: allDiagnostics(b), diagnostics: allDiagnostics(b),
...decisions, ...decisions,
busy: false, busy: false,
blockDiffs: extra.blockDiffs ?? new Map<string, BlockDiffKind>(),
removedCount: extra.removedCount ?? 0,
logoUrl: extra.logoUrl ?? null,
}, },
template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" template: `<app-letter-composer [brief]="brief" [orgTemplate]="orgTemplate" [logoUrl]="logoUrl"
[diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject" [diagnostics]="diagnostics" [canApprove]="canApprove" [canReject]="canReject"
[canSend]="canSend" [busy]="busy"></app-letter-composer>`, [canSend]="canSend" [busy]="busy" [blockDiffs]="blockDiffs"
[removedCount]="removedCount"></app-letter-composer>`,
}); });
const meta: Meta<LetterComposerComponent> = { const meta: Meta<LetterComposerComponent> = {
@@ -167,3 +180,43 @@ export const Sent: Story = {
canRevealBigNummer: false, canRevealBigNummer: false,
}), }),
}; };
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
added and removed since the last rejection. */
export const RejectionDiff: Story = {
render: () =>
render(
brief({
tag: 'submitted',
submittedBy: 'demo-drafter',
submittedAt: '2026-07-02',
}),
{
canEdit: false,
canApprove: true,
canReject: true,
canSend: false,
canRevealBigNummer: false,
},
{
blockDiffs: new Map<string, BlockDiffKind>([
['local-2', 'changed'],
['local-3', 'added'],
]),
removedCount: 1,
},
),
};
/** A pure viewer (e.g. admin) has no approve/reject/send capability on this letter
the read-only notice, not a broken-looking editor. */
export const AlleenLezen: Story = {
render: () =>
render(brief({ tag: 'submitted', submittedBy: 'demo-drafter', submittedAt: '2026-07-01' }), {
canEdit: false,
canApprove: false,
canReject: false,
canSend: false,
canRevealBigNummer: false,
}),
};
@@ -79,3 +79,26 @@ export const Invalid: Story = {
export const NoHistory: Story = { export const NoHistory: Story = {
args: { history: [], publishedVersion: 0 }, args: { history: [], publishedVersion: 0 },
}; };
// Inline SVG so the story needs no backend/upload round-trip.
const sampleLogo =
'data:image/svg+xml;utf8,' +
encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40"><rect width="120" height="40" fill="#003366"/><text x="60" y="25" font-size="14" fill="white" text-anchor="middle">CIBG</text></svg>',
);
/** Published logo (WP-26 AC2): the letterhead canvas shows it above the org name. */
export const MetLogo: Story = {
args: { logoUrl: sampleLogo },
};
/** Client-side upload rejection (existing `rejectReason`, WP-26 AC5) type/size caught
before the file ever reaches the backend. */
export const LogoUploadFout: Story = {
args: {
uploadState: {
...uploadWithCategory,
rejections: { 'org-logo': 'Alleen PNG of JPEG, maximaal 2 MB.' },
},
},
};
@@ -8,7 +8,11 @@ import { LibraryPassage } from '@brief/domain/brief';
/** Molecule: multi-select list of the section's library passages. One "Voeg toe" /** Molecule: multi-select list of the section's library passages. One "Voeg toe"
inserts ALL checked passages at once (a single message upstream) there is no inserts ALL checked passages at once (a single message upstream) there is no
single-insert path. Presentational: emits the chosen passages in list order. */ single-insert path. Presentational: emits the chosen passages in list order.
Superseded by `besluit-panel` (WP-27's guided drafting): no consumer left in
`src/app` outside its own story (WP-28 audit). Kept for now rather than deleted
in-flight of an unrelated WP; a future cleanup can remove it. */
@Component({ @Component({
selector: 'app-passage-picker', selector: 'app-passage-picker',
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent], imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],