Compare commits
143
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc2a3c348b | ||
|
|
551cabce5e | ||
|
|
b6bd7eea5a | ||
|
|
d4c5a9450a | ||
|
|
57fc72f254 | ||
|
|
43dc3210cd | ||
|
|
7a8eab917b | ||
|
|
bcac3789d3 | ||
|
|
cf1f641534 | ||
|
|
5aed15bb98 | ||
|
|
630d68045f | ||
|
|
d5a7a25a78 | ||
|
|
11e3191099 | ||
|
|
8e1de38c68 | ||
|
|
4b3e6a6cfd | ||
|
|
8a3e42015b | ||
|
|
dff5f96bb3 | ||
|
|
831940f1b9 | ||
|
|
a196a380ce | ||
|
|
8560746d15 | ||
|
|
dd11eafe50 | ||
|
|
3895588b9a | ||
|
|
fb2e58ab87 | ||
|
|
e221834f6e | ||
|
|
c36d9e3ff0 | ||
|
|
fb7b531fdf | ||
|
|
c45d1bc0ff | ||
|
|
a8c7a573fc | ||
|
|
c599fee8e2 | ||
|
|
8e5f48c5d2 | ||
|
|
02d41536df | ||
|
|
43f62ddfee | ||
|
|
827c655c1b | ||
|
|
11664d2efa | ||
|
|
84cbf3f7d8 | ||
|
|
d15943bb36 | ||
|
|
b8aced75e3 | ||
|
|
d0d6a3764b | ||
|
|
be1fcb4b40 | ||
|
|
d9c56b0c24 | ||
|
|
0c6fd37ed8 | ||
|
|
a71887a843 | ||
|
|
0697afb737 | ||
|
|
b9fd411283 | ||
|
|
b9d572cfdc | ||
|
|
97d9697a84 | ||
|
|
c8029d23e2 | ||
|
|
c7aed8d308 | ||
|
|
5977efe044 | ||
|
|
42e7a1e927 | ||
|
|
194cccfd02 | ||
|
|
faad772f85 | ||
|
|
637d500c96 | ||
|
|
c30d5ec5a5 | ||
|
|
3441dd4c4e | ||
|
|
dfc6c419f4 | ||
|
|
1a6652b417 | ||
|
|
dd5fd66fb8 | ||
|
|
6cfba81a39 | ||
|
|
0714b2af34 | ||
|
|
531817259e | ||
|
|
03c6e09306 | ||
|
|
c4a5d20202 | ||
|
|
a260af6120 | ||
|
|
e63db509ef | ||
|
|
6372d452a4 | ||
|
|
ce952941bb | ||
|
|
a0e2985fb3 | ||
|
|
adad4513d0 | ||
|
|
3162c755d6 | ||
|
|
95bb77395e | ||
|
|
e304211715 | ||
|
|
80b792caa9 | ||
|
|
ddd02f65bc | ||
|
|
07bb6277c0 | ||
|
|
693016445b | ||
|
|
9520d6c24e | ||
|
|
424ceb604b | ||
|
|
dc096d98e2 | ||
|
|
e270b8612f | ||
|
|
edd20c06df | ||
|
|
9f3814d8b9 | ||
|
|
d0fda08bcc | ||
|
|
05dff974bf | ||
|
|
7a29f5facc | ||
|
|
6c5c4cb249 | ||
|
|
9666790d65 | ||
|
|
7def4a7552 | ||
|
|
0adf831fb9 | ||
|
|
25a5d415a5 | ||
|
|
4631556e68 | ||
|
|
d518a1466c | ||
|
|
7fbac8fca5 | ||
|
|
bc5b2c4b2d | ||
|
|
2a28db4aac | ||
|
|
ab0ec62f6a | ||
|
|
f19185ed81 | ||
|
|
b617d2f09a | ||
|
|
2627799284 | ||
|
|
a93218e8ac | ||
|
|
ee0d449510 | ||
|
|
1c5442d797 | ||
|
|
80de261299 | ||
|
|
adfaa32a42 | ||
|
|
988612cd7e | ||
|
|
6bdfa35abb | ||
|
|
2fa96c300c | ||
|
|
c6bc6dd4c3 | ||
|
|
d089151dbd | ||
|
|
c336328cff | ||
|
|
436e18421b | ||
|
|
1fad740606 | ||
|
|
772c47ea43 | ||
|
|
b5432d2c63 | ||
|
|
4ac13f6cb5 | ||
|
|
9bff19a3e6 | ||
|
|
5118b0da95 | ||
|
|
3545023af8 | ||
|
|
de349e702e | ||
|
|
8b8b522052 | ||
|
|
494cee9d08 | ||
|
|
e89525eef6 | ||
|
|
d682f91b54 | ||
|
|
0298ecc506 | ||
|
|
5187bfa19a | ||
|
|
fbd27ed641 | ||
|
|
487818e67a | ||
|
|
6ffd3643b1 | ||
|
|
a2e935d1d8 | ||
|
|
176e5baef8 | ||
|
|
8dbfa83cd6 | ||
|
|
9440ce1345 | ||
|
|
4b94f8edb5 | ||
|
|
f2d4c900b4 | ||
|
|
4debf6614f | ||
|
|
664a43bf2d | ||
|
|
0ad02e651a | ||
|
|
ae7781efef | ||
|
|
6fa27d1c53 | ||
|
|
42f7bd651d | ||
|
|
6bc00a917c | ||
|
|
edaf1360c5 | ||
|
|
5d73ca21f6 |
@@ -100,15 +100,38 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName)
|
||||
{
|
||||
name: 'apiclient-infrastructure-only',
|
||||
comment:
|
||||
'The generated ApiClient is a value only inside infrastructure/ (+ shared/upload); elsewhere type-only.',
|
||||
'The generated ApiClient is a value only inside infrastructure/; elsewhere type-only.',
|
||||
severity: 'error',
|
||||
from: { pathNot: '/infrastructure/|^libs/shared/src/upload/' },
|
||||
from: { pathNot: '/infrastructure/' },
|
||||
to: {
|
||||
path: '^libs/shared/src/infrastructure/api-client\\.ts$',
|
||||
dependencyTypesNot: ['type-only'],
|
||||
},
|
||||
},
|
||||
|
||||
// --- Atomic ladder within libs/shared/src/ui (folder = layer, CLAUDE.md decision 2) ---
|
||||
{
|
||||
name: 'atoms-compose-nothing-above',
|
||||
comment: 'An atom composes nothing above it — no molecule or organism. See CLAUDE.md §2.',
|
||||
severity: 'error',
|
||||
from: { path: '^libs/shared/src/ui/atoms/' },
|
||||
to: { path: '^libs/shared/src/ui/(molecules|organisms)/' },
|
||||
},
|
||||
{
|
||||
name: 'molecules-below-organisms',
|
||||
comment: 'A molecule composes nothing above it — no organism. See CLAUDE.md §2.',
|
||||
severity: 'error',
|
||||
from: { path: '^libs/shared/src/ui/molecules/' },
|
||||
to: { path: '^libs/shared/src/ui/organisms/' },
|
||||
},
|
||||
{
|
||||
name: 'design-system-not-layout',
|
||||
comment: 'The design system (ui/) does not depend on layout/ templates. See CLAUDE.md §2.',
|
||||
severity: 'error',
|
||||
from: { path: '^libs/shared/src/ui/' },
|
||||
to: { path: '^libs/shared/src/layout/' },
|
||||
},
|
||||
|
||||
{
|
||||
name: 'no-testing-in-production',
|
||||
comment:
|
||||
|
||||
@@ -2,9 +2,13 @@
|
||||
// scaffolding one (see `gen:context`, WP-44).
|
||||
module.exports = require('./.dependency-cruiser.base.js')(
|
||||
{
|
||||
// Two sanctioned cross-feature edges, both pointing at registratie: the portal home
|
||||
// composes registratie's dashboard sections (RD-03), and herregistratie builds on a
|
||||
// registration. Every other context imports only libs/shared and libs/beheer.
|
||||
overzicht: ['registratie'],
|
||||
auth: [],
|
||||
registratie: [],
|
||||
herregistratie: ['registratie'], // the one sanctioned cross-feature edge
|
||||
herregistratie: ['registratie'],
|
||||
brief: [],
|
||||
showcase: null, // unrestricted — the sanctioned teaching page; nothing imports it
|
||||
},
|
||||
|
||||
@@ -202,6 +202,11 @@ jobs:
|
||||
# run manually against backend/openzaak/ (see its README), never in CI.
|
||||
- run: dotnet test backend/BigRegister.slnx --filter "Category!=Integration"
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
# RB-14/BIO-016: `npm audit --omit=dev` covers only the frontend; the .NET dependency
|
||||
# tree was entirely unscanned. The script — not a bare `dotnet list` — is the gate,
|
||||
# because `dotnet list package --vulnerable` exits 0 even on a High advisory.
|
||||
- run: ./scripts/dotnet-audit.sh
|
||||
if: needs.changes.outputs.backend == 'true'
|
||||
|
||||
e2e:
|
||||
needs: changes
|
||||
|
||||
@@ -58,3 +58,7 @@ backend/openzaak/seeded.env
|
||||
|
||||
# WP-55: render-prod-secrets.sh's output — the real client secret, never committed
|
||||
backend/openzaak/setup_configuration/data.prod.yaml
|
||||
|
||||
# Agent git worktrees (Claude Code `isolation: "worktree"`) — full checkouts of
|
||||
# this repo nested inside it; never commit one.
|
||||
.claude/worktrees/
|
||||
|
||||
@@ -4,6 +4,11 @@ storybook-static*/
|
||||
coverage/
|
||||
.angular/
|
||||
|
||||
# Agent git worktrees — full checkouts of this repo nested inside it, so an
|
||||
# unignored `prettier --check .` walks into every one of them (and reports the
|
||||
# vendored CIBG files that the top-level ignore already excludes).
|
||||
.claude/worktrees/
|
||||
|
||||
# Lockfile
|
||||
package-lock.json
|
||||
|
||||
@@ -21,3 +26,7 @@ plop-templates/
|
||||
|
||||
# Backend is formatted by `dotnet format`, not prettier
|
||||
backend/
|
||||
|
||||
# Agent prompts — their exact wording is the input, reflowing markdown edits the prompt
|
||||
docs/project/refactor-backlog-setup/agents/
|
||||
docs/project/refactor-backlog-setup/refactor-backlog/final-prompts/
|
||||
|
||||
@@ -124,8 +124,10 @@ than hardcoding one app's content — the two apps' primary nav genuinely differ
|
||||
should be **composition of existing blocks** — adding building blocks is the
|
||||
exception, not the default. Atoms are thin wrappers over CIBG Huisstijl (Bootstrap 5.2)
|
||||
CSS classes (`btn`, `form-control`, `card`, …); we own only a small typed `input()` API,
|
||||
the design system does the visuals. (Where CIBG lacks a class — e.g. `alert` — the atom is a
|
||||
small hand-rolled surface built from the token bridge; see ADR-0003.)
|
||||
the design system does the visuals. (Where CIBG lacks a class — e.g. `skeleton`,
|
||||
`spinner` — the atom is a small hand-rolled surface built from the token bridge and carries a
|
||||
`// CIBG-GAP EXTENSION:` marker; see ADR-0003. `alert` is **not** such a case: it wraps the
|
||||
vendored `.feedback feedback-*` classes.)
|
||||
|
||||
### 3. State: make illegal states unrepresentable
|
||||
|
||||
@@ -175,17 +177,28 @@ herregistratie eligibility) or _config value_ (server sends threshold, FE applie
|
||||
for instant feedback, server re-validates as authority — e.g. scholing threshold).
|
||||
FE keeps only **format** validation, never as authority.
|
||||
|
||||
DTO lives in `contracts/`; a hand-written `parse*`/`toDomain` in `infrastructure/`
|
||||
validates the untrusted shape and maps DTO → domain. Wiring a real .NET backend
|
||||
The generated client
|
||||
(`libs/shared/src/infrastructure/api-client.ts`, `npm run gen:api`, drift-checked in CI) **is**
|
||||
the wire contract — consume its types directly, as 19 of the 20 adapters do. A hand-written
|
||||
`contracts/*.dto.ts` is the exception, only where codegen does not reach the endpoint or types
|
||||
it too loosely (the four survivors are all the latter — the generator emits every property as
|
||||
optional and flattens unions); such a file must still import nothing. Either way a hand-written
|
||||
`parse*`/`toDomain` in `infrastructure/` validates the untrusted shape and maps DTO → domain —
|
||||
**a generated type is a compile-time claim about the wire, not a runtime guarantee.** Wiring a real .NET backend
|
||||
touches only `infrastructure/` + `contracts/` (see ARCHITECTURE §6). Server-owned
|
||||
rules stay in `domain/*.policy.ts` as reference impl + unit test, marked server-owned,
|
||||
but the FE doesn't call them.
|
||||
rules live **only** on the server, with no FE mirror to drift from it — the FE may
|
||||
mirror a server-supplied _value_ (a threshold, a bound) for instant feedback, but
|
||||
never reimplements the _algorithm_.
|
||||
|
||||
**Business-tunable reference data ("stamdata") is config-as-code, not a DB.** Tables the
|
||||
business controls (profession↔diploma map, thresholds, policy-question text) live as typed
|
||||
C# in `backend/.../Stamdata/`, validated at build by `StamdataValidationTests` (a bad edit
|
||||
fails CI, never prod) — never runtime-editable. Org-templates are the deliberate exception
|
||||
(operational per-org config in SQLite). UI copy is `$localize`. See ADR-0004.
|
||||
fails CI, never prod) — never runtime-editable. Operational configuration is the deliberate
|
||||
exception, and ADR-0004 states it as a four-part test rather than a list: the catalog lives in
|
||||
code, an unknown key fails closed, the value is operational rather than a shared business rule,
|
||||
and writes are admin-capability-gated **and** audited. Two surfaces pass it today —
|
||||
`OrgTemplateStore` (per-org letterhead) and `FeatureFlagStore` (rollout switches), both in
|
||||
SQLite. A third surface must pass the same test, not argue by analogy. UI copy is `$localize`. See ADR-0004.
|
||||
|
||||
### 5. Testing
|
||||
|
||||
@@ -197,10 +210,10 @@ each app has its **own Storybook instance** (`.storybook-ssp/`, `.storybook-beha
|
||||
WP-67 — a single merged tsconfig can't resolve both apps' `@auth/*` at once), each globbing
|
||||
its own app's stories plus both shared libraries'. **Story titles mirror the sidebar's
|
||||
Design System/Domein split** (see `libs/shared/docs/layers.mdx`): a `libs/shared/ui|layout`
|
||||
or `libs/beheer/ui` component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
||||
a component in an app context's `ui/` is titled `Domein/<Context>/<Name>` — full stop,
|
||||
regardless of which atomic layer it is (a context organism doesn't get its own
|
||||
`Organisms/` bucket).
|
||||
component is titled `Design System/<Atoms|Molecules|Organisms|Templates|Devtools>/<Name>`;
|
||||
a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled
|
||||
`Domein/<Context>/<Name>` — full stop, regardless of which atomic layer it is (a context
|
||||
organism doesn't get its own `Organisms/` bucket).
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -213,6 +226,17 @@ regardless of which atomic layer it is (a context organism doesn't get its own
|
||||
- **Naming:** shared/reusable UI is **English** (language-agnostic: `button`,
|
||||
`wizard-shell`); domain contexts are **Dutch** (`registratie`, `herregistratie`,
|
||||
`*.machine.ts`). Pick the language by which side of the seam the code is on.
|
||||
- **English prose uses Simplified Technical English (STE).** This covers documentation,
|
||||
code comments, commit messages, ADRs, and the backlog notes. One idea per sentence;
|
||||
20 words or fewer in a procedure, 25 in a description. Active voice, present tense.
|
||||
One word for one meaning — pick a term and repeat it, do not vary it for style. Keep
|
||||
articles ("the test fails"). Three nouns together at most. No idioms and no humour.
|
||||
Six sentences per paragraph at most. Write a procedure as numbered steps, one action
|
||||
per step.
|
||||
**STE governs form, not content.** Split a long sentence; never drop a caveat, a
|
||||
measurement, or a precise term to make it shorter.
|
||||
**STE does not apply to** Dutch identifiers, `$localize` copy, quoted output, or
|
||||
existing documents you are not already editing.
|
||||
- **User-facing copy = `$localize`.** Every user-visible string is wrapped in Angular's
|
||||
first-party `$localize` (no third-party i18n lib), with a stable custom id
|
||||
(`` $localize`:@@context.key:Tekst` ``). Source locale is `nl`; a second locale is a
|
||||
|
||||
@@ -1,205 +1,176 @@
|
||||
# BIG-register Self-Service Portal — Atomic Design POC
|
||||
# BIG-register Portals — Atomic Design POC
|
||||
|
||||
A small Angular app that shows how **atomic design** makes a frontend cheap to build,
|
||||
reuse and extend. The domain is the **BIG-register** self-service portal (the Dutch
|
||||
register of healthcare professionals, run by CIBG). It is styled with the **CIBG
|
||||
Huisstijl** design system (a customized Bootstrap 5.2 build, vendored — see ADR-0003),
|
||||
and demonstrates a robust **async-state pattern** where the UI can never reach an
|
||||
inconsistent state.
|
||||
A two-app Angular monorepo showing how **atomic design** plus **domain-driven boundaries** make
|
||||
a frontend cheap to build, reuse and extend. The domain is the **BIG-register** (the Dutch
|
||||
register of healthcare professionals, run by CIBG): a citizen self-service portal and a
|
||||
case-handler backoffice, sharing one design system and one backend.
|
||||
|
||||
> Demo / POC — **no real login** (DigiD is faked) and synthetic seed data. The
|
||||
> business rules and data _are_ served by a real **ASP.NET Core backend**
|
||||
> (`backend/`) consumed through a generated typed client, so the BFF + DDD design
|
||||
> is demonstrable, not hand-waved. A system-font stack stands in for the licensed
|
||||
> Rijksoverheid font and a text wordmark for the logo.
|
||||
It is styled with the **CIBG Huisstijl** (a customized Bootstrap 5.2 build, vendored — ADR-0003)
|
||||
and built around one idea: **make illegal states unrepresentable** — in the UI's async states, in
|
||||
the domain types, and in the tests.
|
||||
|
||||
> **Demo / POC** — **no real login** (DigiD is faked) and synthetic seed data. But the business
|
||||
> rules and data _are_ served by a real **ASP.NET Core backend** (`backend/`) through a generated
|
||||
> typed client, so the BFF + DDD design is demonstrable rather than hand-waved. A system-font
|
||||
> stack stands in for the licensed Rijksoverheid font, and a text wordmark for the logo.
|
||||
|
||||
> **New here?** Run `npm run storybook` and open **Foundations → Learning Path** — a paced,
|
||||
> hands-on three-day route through the codebase, written for a strong programmer who is new to
|
||||
> frontend functional programming. For everything else, **[`docs/README.md`](docs/README.md)** is
|
||||
> the documentation index.
|
||||
|
||||
---
|
||||
|
||||
## Run it
|
||||
## Quick start
|
||||
|
||||
Everything at once — API, both portals:
|
||||
|
||||
```bash
|
||||
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
|
||||
docker compose up
|
||||
# self-service portal → http://localhost:4200
|
||||
# behandelportal → http://localhost:4201
|
||||
# API + Swagger → http://localhost:5000/swagger
|
||||
```
|
||||
|
||||
Or run the two halves separately:
|
||||
This does not start OpenZaak. The BFF uses a local, in-memory case store instead. To run
|
||||
against a real OpenZaak, see [backend/openzaak/README.md](backend/openzaak/README.md).
|
||||
|
||||
Or run the pieces yourself:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start # app → http://localhost:4200 (proxies /api → backend, proxy.conf.json)
|
||||
# in another terminal:
|
||||
cd backend && dotnet run --project src/BigRegister.Api # API → http://localhost:5000/swagger
|
||||
npm start # self-service portal → :4200 (proxies /api → backend)
|
||||
npm run start:behandelportal # case-handler portal → :4201
|
||||
|
||||
npm run storybook # component library, organized by atomic layer
|
||||
npm run gen:api # regenerate the typed API client from the backend OpenAPI doc
|
||||
npm run e2e # Playwright smoke tests against the running app + backend (both must be up)
|
||||
# in another terminal:
|
||||
cd backend && dotnet run --project src/BigRegister.Api # API → :5000/swagger
|
||||
|
||||
npm run storybook # the design system + Foundations curriculum
|
||||
npm run e2e # Playwright — starts the backend and app itself
|
||||
```
|
||||
|
||||
Flow: **Login → Dashboard → Mijn gegevens (wijziging) → Herregistratie → Intake**.
|
||||
The backend hosts the business rules (profession derivation, policy questions,
|
||||
eligibility, thresholds); see **[backend/README.md](backend/README.md)**.
|
||||
**Self-service flow:** Login → Dashboard → Mijn gegevens → Registreren → Herregistratie → Intake
|
||||
→ Brief. **Behandelportal:** Login → Werkvoorraad → Beoordeling (approve / reject / ask for more).
|
||||
Admin pages (`/beheer/*`, `/brief/huisstijl`) need the `admin` role — see
|
||||
[roles and access](docs/reference/roles-and-access.md).
|
||||
|
||||
> **New here:** a **branching intake questionnaire** (`/intake`) where later questions
|
||||
> appear based on earlier answers and progress survives a page reload, plus a visual
|
||||
> walkthrough of the state-management ideas. See
|
||||
> **[docs/reference/architecture/ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md)** for diagrams (atomic-design pyramid,
|
||||
> the dispatch→reduce→view loop, RemoteData states, and "why not just signals") and a
|
||||
> section on **connecting to a .NET backend**.
|
||||
---
|
||||
|
||||
### See every data state (scenario toggle)
|
||||
## Where to find things
|
||||
|
||||
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
|
||||
| I want to… | Go to |
|
||||
| --------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| learn the codebase from scratch | `npm run storybook` → **Foundations → Learning Path** |
|
||||
| find any document | **[docs/README.md](docs/README.md)** — the full index |
|
||||
| understand the architecture | [ARCHITECTURE.md](docs/reference/architecture/ARCHITECTURE.md) |
|
||||
| know _why_ a decision was made | [the ADRs](docs/reference/architecture/) — BFF-lite, contexts, huisstijl, stamdata, ZGW, test data |
|
||||
| work on the backend / BFF | [backend/README.md](backend/README.md) |
|
||||
| run OpenZaak locally | [backend/openzaak/README.md](backend/openzaak/README.md) |
|
||||
| see what shipped, or pick up work | [docs/project/backlog/README.md](docs/project/backlog/README.md) |
|
||||
| build a feature the house way | [`.claude/skills/`](.claude/skills/) — invocable recipes (`new-feature`, `form-machine`, …) |
|
||||
| know the import rules | [dependencies.md](docs/reference/architecture/dependencies.md) — enforced by `dep:check` |
|
||||
| work on this repo as an AI agent | [CLAUDE.md](CLAUDE.md) |
|
||||
|
||||
## Repo map
|
||||
|
||||
| Path | What lives there |
|
||||
| ---------------------- | ----------------------------------------------------------------------- |
|
||||
| `apps/ssp/` | Zorgverlener self-service portal (:4200) |
|
||||
| `apps/behandelportal/` | Behandelaar backoffice (:4201) |
|
||||
| `libs/shared/` | Design system, kernel, generated API client, Storybook Foundations docs |
|
||||
| `libs/beheer/` | Admin/stamdata context, used identically by both apps |
|
||||
| `backend/` | ASP.NET Core BFF (.NET 10, EF Core/SQLite), OpenZaak seam |
|
||||
| `e2e/` | Playwright specs |
|
||||
| `docs/` | `reference/` (how + why) and `project/` (backlog, PRDs) |
|
||||
| `public/` | Vendored CIBG Huisstijl + assets |
|
||||
| `scripts/` | CI gate, drift checks, generators |
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run ci # ← run this before pushing: the whole gate, exactly what CI runs
|
||||
```
|
||||
|
||||
| Task | Command |
|
||||
| -------- | ------------------------------------------------------------------------------------------- |
|
||||
| Run | `npm start`, `npm run start:behandelportal`, `docker compose up` |
|
||||
| Test | `npm test`, `npm run e2e`, `npm run test-storybook` (axe on every story) |
|
||||
| Check | `npm run lint`, `typecheck`, `dep:check`, `check:tokens`, `check:seam` |
|
||||
| Build | `npm run build`, `npm run build-storybook` |
|
||||
| Generate | `npm run gen:api` (typed client), `npm run gen` (plop: value object, form machine, context) |
|
||||
| Docs | `npm run storybook`, `npm run dep:graph`, `npm run gen:behaviour-spec` |
|
||||
|
||||
`npm run ci` chains lint, typecheck, dependency boundaries, formatting, token and seam drift
|
||||
checks, all four test projects, both localized builds, the backend suite, and the generated-artifact
|
||||
drift gates. The full script list is in `package.json`; `CLAUDE.md` explains the traps.
|
||||
|
||||
---
|
||||
|
||||
## Where atomic design pays off
|
||||
|
||||
**Two orthogonal axes, and that is the point.** On disk, code is grouped by **ownership** —
|
||||
bounded context, then layer (`domain/` → `application/` → `infrastructure/` → `ui/`, dependencies
|
||||
pointing inward). In Storybook, the same components are grouped by **atomic level** — story titles
|
||||
put them under `Design System/Atoms|Molecules|Organisms|Templates`. So `libs/shared/src/ui/` is a
|
||||
flat folder of 26 components, and the atomic ladder lives in the sidebar where you actually browse
|
||||
it. See [Atomic design](libs/shared/docs/atomic-design.mdx) and
|
||||
[Domain-driven design](libs/shared/docs/layers.mdx) in Foundations.
|
||||
|
||||
**Reuse is the payoff.** `button`, `form-field`, `async`, `page-shell`, `site-header` appear on
|
||||
essentially every screen across _both_ apps. Change one, every screen follows.
|
||||
|
||||
**A new page is composition, not new components.** The branching intake wizard — the most complex
|
||||
flow in the app — needed exactly **one** new atom (`radio-group`) and one new organism
|
||||
(`intake-wizard`). Everything else was already there.
|
||||
|
||||
**Theming is one stylesheet and a token bridge.** `libs/shared/styles.scss` maps the app's
|
||||
semantic `--rhc-*` vocabulary onto CIBG/`--bs-*` values, so components reference tokens, never
|
||||
colours. Re-point the bridge to re-theme both apps with no component changes (ADR-0003).
|
||||
`npm run check:tokens` fails the build on a hardcoded colour.
|
||||
|
||||
---
|
||||
|
||||
## Try it: dev affordances
|
||||
|
||||
Append `?scenario=` to any data page to force an async state — the states are mutually exclusive
|
||||
by construction, via the `<app-async>` molecule:
|
||||
|
||||
| URL | What you see |
|
||||
| ----------------------------- | -------------------------------------------- |
|
||||
| `/dashboard` | real data (fast) |
|
||||
| `/dashboard` | real data |
|
||||
| `/dashboard?scenario=slow` | skeletons for ~2.5s, then data |
|
||||
| `/dashboard?scenario=loading` | the loading state, held open |
|
||||
| `/dashboard?scenario=empty` | "geen gegevens" empty state |
|
||||
| `/dashboard?scenario=error` | error message + **Opnieuw proberen** (retry) |
|
||||
|
||||
---
|
||||
|
||||
## How atomic design works here (folder = layer)
|
||||
|
||||
Atomic design organizes UI into five layers, each built from the one below. In this repo
|
||||
the folder structure _is_ the hierarchy (`src/app/`):
|
||||
|
||||
| Layer | What it is | Examples here |
|
||||
| -------------- | ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| **atoms/** | smallest building blocks; wrap one design-system element | `button`, `text-input`, `heading`, `link`, `alert`, `status-badge`, `spinner`, `skeleton` |
|
||||
| **molecules/** | a few atoms combined into a unit | `form-field` (label + input + error), `data-row`, `async` (state wrapper) |
|
||||
| **organisms/** | larger, self-contained sections | `site-header`, `site-footer`, `login-form`, `registration-summary`, `registration-table`, `change-request-form` |
|
||||
| **templates/** | page skeletons that define layout; content is projected in | `page-layout` (header/content/footer chrome), `page-shell` (back-link + heading + intro + content) |
|
||||
| **pages/** | a template filled with real data | `login`, `dashboard`, `registration-detail`, `herregistratie` |
|
||||
|
||||
Each atom is a thin Angular standalone component that applies CIBG Huisstijl
|
||||
(Bootstrap 5.2) CSS classes (`btn`, `form-control`, `card`, …) — so the design system
|
||||
does the visual work and we only own a small, typed component API.
|
||||
Append `?role=drafter|approver|admin` to switch the dev role stand-in and unlock the admin pages.
|
||||
Both toggles are **dev-only** — neither interceptor is wired into production builds.
|
||||
|
||||
---
|
||||
|
||||
## Where you actually notice the benefit
|
||||
|
||||
**1. Reuse — the same blocks appear everywhere.**
|
||||
|
||||
| Component | Appears in |
|
||||
| ----------------------------- | ------------------------------------------------------------- |
|
||||
| `button` | login, change-request, herregistratie, async retry, Storybook |
|
||||
| `form-field` + `text-input` | login form _and_ change-request _and_ herregistratie |
|
||||
| `status-badge` | dashboard summary, detail summary |
|
||||
| `page-shell` / `page-layout` | all four pages |
|
||||
| `site-header` / `site-footer` | every page |
|
||||
| `async` + `skeleton` | dashboard, detail |
|
||||
|
||||
Change a component once and every screen that uses it updates.
|
||||
|
||||
**2. A whole new page = composition, no new components.**
|
||||
`pages/herregistratie/herregistratie.page.ts` is a complete new flow assembled entirely
|
||||
from existing atoms/molecules/templates — zero new building blocks. The branching
|
||||
**intake wizard** went further: it needed only **one new atom** (`radio-group`) and **one
|
||||
new organism** (`intake-wizard`); the form fields, buttons, alerts, spinner and page shell
|
||||
were all reused. That's the payoff: new screens cost almost nothing.
|
||||
|
||||
**3. Templates remove per-page boilerplate.**
|
||||
Every page used to repeat its own back-link + heading + intro markup. `page-shell`
|
||||
captures that once; pages now read like `<app-page-shell heading="…" backLink="…">…`.
|
||||
|
||||
**4. Theming is one stylesheet + a token bridge.**
|
||||
The look comes from **CIBG Huisstijl**, vendored under `public/cibg-huisstijl/` and
|
||||
loaded via a `<link>` in `index.html`; `body.brand--cibg` activates CIBG's
|
||||
robijn/lintblauw palette. `src/styles.scss` is a **token bridge** mapping the app's
|
||||
semantic `--rhc-*` token vocabulary onto CIBG/`--bs-*` values, so components keep
|
||||
referencing tokens — swap the vendored CSS and re-point the bridge to re-theme the
|
||||
whole app, no component changes (ADR-0003). `npm run check:tokens` fails the build
|
||||
on any hardcoded colour outside that bridge.
|
||||
|
||||
---
|
||||
|
||||
## State management (no impossible states)
|
||||
|
||||
Data fetching uses Angular's native, signal-based **`resource`** over the generated
|
||||
typed client (no NgRx, no extra dependency). Each context's `infrastructure/*.adapter.ts`
|
||||
exposes a resource that carries `status()`, `value()`, `error()` and `reload()` as
|
||||
signals, and a `parse*` function validates the response at the trust boundary
|
||||
(DTO → domain). The screen-shaped ("BFF-lite") endpoints return server-computed
|
||||
decisions the FE renders rather than recomputes (see ADR-0001).
|
||||
|
||||
The molecule **`<app-async>`** turns those signals into UI. It renders **exactly one** of
|
||||
four slots, chosen by a single `computed` — so loading, empty, error and loaded are
|
||||
mutually exclusive _by construction_. You cannot render data and an error at the same
|
||||
time, or show stale content during a hard failure: those states are unrepresentable.
|
||||
|
||||
```html
|
||||
<app-async [resource]="reg" [isEmpty]="regEmpty">
|
||||
<ng-template appAsyncLoaded let-r> <app-registration-summary [reg]="r" /> </ng-template>
|
||||
<ng-template appAsyncLoading> <app-skeleton [count]="6" /> </ng-template>
|
||||
<!-- appAsyncEmpty / appAsyncError are optional → sensible defaults -->
|
||||
</app-async>
|
||||
```
|
||||
|
||||
- **Loaded** — your content, with the value.
|
||||
- **Loading** — your skeleton, or a default **delayed spinner** (only appears after
|
||||
~250ms, so fast connections never flash a spinner; slow ones get feedback). Skeletons
|
||||
are also delay-gated. → _handles slow vs fast connections._
|
||||
- **Empty** — your message, or a default "Geen gegevens gevonden" (driven by an
|
||||
`isEmpty` predicate).
|
||||
- **Error** — your template, or a default alert + a **retry** button that calls
|
||||
`resource.reload()`.
|
||||
|
||||
Because each data-fetching page wraps its content in `<app-async>`, correct
|
||||
loading/empty/error handling is automatic and consistent across the app.
|
||||
|
||||
---
|
||||
|
||||
## Page transitions
|
||||
|
||||
The chrome (`templates/shell` — header + footer) is **persistent**: it mounts once and
|
||||
hosts the `<router-outlet>`, so navigating doesn't re-create it (no white flash). Only
|
||||
the routed content cross-fades, via Angular's native **`withViewTransitions()`** — the
|
||||
header/footer get a stable `view-transition-name` in `styles.scss` so they're excluded
|
||||
from the fade. `prefers-reduced-motion` disables the animation; non-Chromium browsers
|
||||
degrade to an instant navigation.
|
||||
|
||||
## Tech notes
|
||||
|
||||
- Angular 22 (standalone components, signals, `httpResource`, view transitions,
|
||||
control flow `@if/@for`).
|
||||
- Styling: **CIBG Huisstijl** (customized Bootstrap 5.2) vendored in
|
||||
`public/cibg-huisstijl/`, loaded via `<link>`; `src/styles.scss` holds the
|
||||
`--rhc-*` → CIBG/`--bs-*` token bridge (ADR-0003). No styling npm dependency.
|
||||
- Data: ASP.NET Core backend (`backend/`, EF Core/SQLite-persisted; BRP/DUO
|
||||
reference data stays in-memory-seeded) exposed via an OpenAPI contract; the FE
|
||||
consumes an **NSwag-generated** typed client (`npm run gen:api`).
|
||||
The `?scenario=` toggle (`shared/infrastructure/scenario.interceptor.ts`) is
|
||||
**dev-only** — it is not wired into production builds.
|
||||
- `.npmrc` sets `legacy-peer-deps=true` because `@storybook/angular`'s peer range lags
|
||||
Angular 22; the builder runs fine (build verified).
|
||||
- **i18n**: every user-facing string is `$localize`-wrapped with a stable `@@id`
|
||||
(source locale `nl`). `npx ng build --localize` (CI runs this) builds both `nl` and
|
||||
`en` — a genuine second-locale build, not just an unexercised claim — into
|
||||
`dist/atomic-design-poc/browser/{nl,en}/`; `ng serve --configuration=en` serves the
|
||||
English build locally. `src/locale/messages.en.xlf` is real (if demo-quality)
|
||||
English, not machine-untranslated placeholders; `angular.json`'s
|
||||
`i18nMissingTranslation: "error"` fails the build if a new `$localize` string ships
|
||||
without a translation. `npm run extract-i18n` regenerates the `nl` reference file.
|
||||
- **Angular 22** — standalone components, signals, `resource()`, native control flow, view
|
||||
transitions. No NgRx: shared state is a root singleton store with a pure reducer.
|
||||
- **Backend** — ASP.NET Core (.NET 10), EF Core/SQLite for applications, documents, the brief and
|
||||
the audit trail; BRP/DUO reference data stays in-memory seeded. Screen-shaped ("BFF-lite")
|
||||
endpoints return server-computed decisions the frontend renders rather than recomputes
|
||||
(ADR-0001). Cases can be sourced from **OpenZaak/ZGW** behind the same seam (ADR-0005).
|
||||
- **Typed client** — NSwag-generated from the backend's OpenAPI doc (`npm run gen:api`); CI fails
|
||||
on drift.
|
||||
- **Boundaries are enforced, not hoped for** — `dep:check` fails the build if `domain/` imports
|
||||
Angular, a context imports upward, or an app reaches into the other app.
|
||||
- **i18n** — every user-facing string is `$localize`-wrapped with a stable id (source locale `nl`).
|
||||
`ng build --localize` builds both `nl` and `en`, and `i18nMissingTranslation: "error"` fails the
|
||||
build if a string ships untranslated.
|
||||
- **Dependencies** — the shipped bundle audits clean (`npm audit --omit=dev`: 0 vulnerabilities).
|
||||
`.npmrc` sets `legacy-peer-deps=true` because Storybook's peer range lags Angular 22. Never run
|
||||
`npm audit fix --force` — it downgrades Angular 22 → 21.
|
||||
|
||||
### Dependency security
|
||||
### Deliberately out of scope
|
||||
|
||||
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`) — and, since the
|
||||
`@babel/core` pin below, the **full dev audit is 0 too**. All advisories live(d) in
|
||||
dev/build tooling (Storybook + the Angular build chain) and never reach the bundle.
|
||||
`package.json` `overrides` pin patched transitive versions; the last remaining cluster
|
||||
cascaded from `@babel/core`'s low-severity sourceMappingURL issue, closed by pinning
|
||||
`@babel/core` to a patched **7.x** (`^7.29.7`) — no jump to Babel 8, no breaking change.
|
||||
We do **not** run `npm audit fix --force`: its proposed fix downgrades Angular 22 → 21.
|
||||
|
||||
### Deliberately out of scope (POC)
|
||||
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database (Postgres/SQL
|
||||
Server — SQLite persists applications/documents/the brief + a real audit table,
|
||||
see `backend/README.md`, WP-22), NgRx, licensed RO/Rijks fonts + logo (system-font
|
||||
stack; text wordmark). (The backend itself _is_ implemented.) i18n's build seam is
|
||||
proven (see above) but
|
||||
production-quality translation, a runtime locale switcher, and RTL/pluralization
|
||||
edge cases are not — the `en` file is demo-quality, and locale is a build-time
|
||||
choice, not a switch in the running app.
|
||||
Real auth/DigiD, real BRP/DUO upstreams, a production-grade database, NgRx, licensed RO/Rijks
|
||||
fonts and logo. The i18n build seam is proven, but the `en` translation is demo-quality and locale
|
||||
is a build-time choice, not a runtime switch.
|
||||
|
||||
+4
-3
@@ -106,7 +106,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"libs/shared/src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"apps/ssp/src/main.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -235,7 +235,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"libs/shared/src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"apps/behandelportal/src/main.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -293,7 +293,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"src/infrastructure/api-client.ts",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"src/test-entry.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
@@ -332,6 +332,7 @@
|
||||
"**/*.spec.ts",
|
||||
"**/*.stories.ts",
|
||||
"**/contracts/**",
|
||||
"**/infrastructure/api-client.ts",
|
||||
"src/test-entry.ts",
|
||||
"**/*.testing.ts",
|
||||
"**/*.d.ts"
|
||||
|
||||
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
|
||||
import { medewerkerInterceptor } from '@auth/infrastructure/medewerker.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
@@ -55,7 +56,9 @@ export const appConfig: ApplicationConfig = {
|
||||
// a query param could otherwise force errors on the live app.
|
||||
provideHttpClient(
|
||||
withInterceptors(
|
||||
isDevMode() ? [scenarioInterceptor, roleInterceptor, medewerkerInterceptor] : [],
|
||||
isDevMode()
|
||||
? [scenarioInterceptor, roleInterceptor, subjectInterceptor, medewerkerInterceptor]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
provideApiClient(),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'aanvraag/:id',
|
||||
// Same capability the werkvoorraad list itself is gated by (WP-64/65) — the
|
||||
// Same capability the werkvoorraad list itself is gated by — the
|
||||
// detail page is reachable only from a row already filtered to that capability.
|
||||
canActivate: [capabilityGuard('aanvraag:beoordelen')],
|
||||
loadComponent: () =>
|
||||
@@ -36,14 +36,14 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'beheer/audit',
|
||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
||||
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||
canActivate: [capabilityGuard('cases:manage')],
|
||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/functies',
|
||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
||||
// Admin-only feature-flag toggles, gated by `flags:manage`.
|
||||
canActivate: [capabilityGuard('flags:manage')],
|
||||
loadComponent: () =>
|
||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||
|
||||
@@ -1,58 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { MedewerkerAdapter } from '../infrastructure/medewerker.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The shape validation (G2 — there is no BSN here, so no G1 to enforce) lives in
|
||||
`parseStoredPrincipal` (`../domain/principal`) — pure, spec'd, and testable
|
||||
without stubbing `localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current medewerker principal for the whole backoffice app. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* which is safe to do verbatim here: a medewerker principal carries no BSN or
|
||||
* other national identifier, unlike the SSP's `SessionStore`, whose equivalent
|
||||
* comment explains why *that* app strips a field before writing. A real
|
||||
* deployment keeps auth in an httpOnly cookie/token, not web storage, regardless.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private medewerker = inject(MedewerkerAdapter);
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
const p = this._session();
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
/** Effectful command: authenticate via the SSO stand-in, then store the
|
||||
resulting principal. No credential to pass in, and nothing that can fail
|
||||
today — see `MedewerkerAdapter`. */
|
||||
async login(): Promise<Principal> {
|
||||
const p = await this.medewerker.authenticate();
|
||||
this._session.set(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
logout() {
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||
* routing asks the auth context for its guards, which is the right direction to read.
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseRollen, parseStoredPrincipal, Principal } from './principal';
|
||||
|
||||
const principal: Principal = {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['behandelaar'],
|
||||
};
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present principal to Principal', () => {
|
||||
expect(isAuthenticated(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no principal as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredPrincipal', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredPrincipal(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(JSON.stringify({ kind: 'medewerker', medewerkerId: 'medewerker-1' })),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when kind is not medewerker', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'zorgverlener',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: [],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when rollen holds an unrecognized token', () => {
|
||||
expect(
|
||||
parseStoredPrincipal(
|
||||
JSON.stringify({
|
||||
kind: 'medewerker',
|
||||
medewerkerId: 'medewerker-1',
|
||||
naam: 'Test',
|
||||
rollen: ['geen'],
|
||||
}),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('restores a well-shaped stored principal as-is (no BSN to strip)', () => {
|
||||
const restored = parseStoredPrincipal(JSON.stringify(principal));
|
||||
expect(restored).toEqual(principal);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRollen', () => {
|
||||
it('parses a single recognized rol', () => {
|
||||
expect(parseRollen('behandelaar')).toEqual(['behandelaar']);
|
||||
});
|
||||
|
||||
it('is case-insensitive and trims whitespace', () => {
|
||||
expect(parseRollen(' Behandelaar , behandelaar ')).toEqual(['behandelaar', 'behandelaar']);
|
||||
});
|
||||
|
||||
it('drops unrecognized tokens (the deny-path toggle, e.g. ?rollen=geen)', () => {
|
||||
expect(parseRollen('geen')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty list for an empty string', () => {
|
||||
expect(parseRollen('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Who is logged in. Framework-free domain type.
|
||||
*
|
||||
* The `medewerker` variant of ADR-0002 §3's `Principal` union — the backoffice has
|
||||
* exactly one actor kind (an employee, authenticated via SSO), so this app's own copy
|
||||
* of the union only ever holds this one member. Unlike the SSP's `zorgverlener`
|
||||
* variant, there is no BSN: a Behandelaar is not a citizen, and §3 names this
|
||||
* unrepresentable-by-construction distinction as the whole point of the union.
|
||||
* `rollen` is the FE-visible echo of the same dev stand-in `medewerker.interceptor.ts`
|
||||
* already stamps onto every backend request — it does not itself grant anything;
|
||||
* `AccessStore`/`GET /me` (server-resolved capabilities) is still the sole authority
|
||||
* on what this principal may do (ADR-0001, ADR-0002 §3).
|
||||
*/
|
||||
export type Rol = 'behandelaar';
|
||||
|
||||
const ROLLEN: readonly Rol[] = ['behandelaar'];
|
||||
export const isRol = (v: unknown): v is Rol => typeof v === 'string' && ROLLEN.includes(v as Rol);
|
||||
|
||||
export interface Principal {
|
||||
readonly kind: 'medewerker';
|
||||
readonly medewerkerId: string;
|
||||
readonly naam: string;
|
||||
readonly rollen: readonly Rol[];
|
||||
}
|
||||
|
||||
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||
return p !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the raw `X-Rollen` stand-in value (`medewerker.ts`'s `currentRollen()`) into
|
||||
* typed `Rol[]`, mirroring the backend's own `StubIdentityProvider.ParseRollen`:
|
||||
* comma-separated, case-insensitive, unrecognized tokens dropped — so
|
||||
* `?rollen=geen` (the deny-path toggle) yields an empty list here too, rather than
|
||||
* a fabricated recognized role. Pure so `MedewerkerAdapter` (infrastructure) can
|
||||
* stay a thin wire-up instead of holding logic of its own.
|
||||
*/
|
||||
export function parseRollen(raw: string): Rol[] {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(isRol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the shape
|
||||
* before trusting it. Unlike the zorgverlener variant there is no G1 field to strip
|
||||
* — a medewerker carries no national identifier — so a well-shaped record is
|
||||
* restored as-is rather than reconstructed field-by-field.
|
||||
*/
|
||||
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||
return parsed?.kind === 'medewerker' &&
|
||||
typeof parsed.medewerkerId === 'string' &&
|
||||
typeof parsed.naam === 'string' &&
|
||||
Array.isArray(parsed.rollen) &&
|
||||
parsed.rollen.every(isRol)
|
||||
? {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: parsed.medewerkerId,
|
||||
naam: parsed.naam,
|
||||
rollen: parsed.rollen,
|
||||
}
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Principal, parseRollen } from '../domain/principal';
|
||||
import { MEDEWERKER_ID, currentRollen } from './medewerker';
|
||||
|
||||
/**
|
||||
* Infrastructure: resolves the current medewerker identity into a `Principal`
|
||||
* (ADR-C-004). Stands in for a real employee-SSO redirect flow (ADR-0002 §3,
|
||||
* "out of scope here") — there is no credential to enter and, unlike `DigidAdapter`'s
|
||||
* BSN check, no format to reject, so `authenticate()` takes no input and returns the
|
||||
* `Principal` directly rather than a `Result` with an error variant that can never
|
||||
* actually occur. A real SSO callback (which *can* fail — session expired, access
|
||||
* denied) swaps in behind this same method; that is the point where this return
|
||||
* type would gain a `Result`, not before.
|
||||
*
|
||||
* Resolves the same `MEDEWERKER_ID` + `currentRollen()` the dev-only
|
||||
* `medewerkerInterceptor` already stamps onto every backend request as
|
||||
* `X-Medewerker`/`X-Rollen` — this only makes that identity visible on the
|
||||
* frontend (the guard, the header, `SessionStore`'s persisted principal), it does
|
||||
* not change what the backend resolves or authorizes.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MedewerkerAdapter {
|
||||
// ponytail: fake employee SSO — a fixed medewerker, no credential exchange.
|
||||
async authenticate(): Promise<Principal> {
|
||||
return {
|
||||
kind: 'medewerker',
|
||||
medewerkerId: MEDEWERKER_ID,
|
||||
naam: 'H. (Hassan) Bakker',
|
||||
rollen: parseRollen(currentRollen()),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,50 +1,27 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
/**
|
||||
* Organism: employee-SSO-style mock login (ADR-C-004). No real auth — and,
|
||||
* unlike the SSP's DigiD form, no credential to enter at all: a Behandelaar has no
|
||||
* BSN, and this app has no password of its own to check either way. There is
|
||||
* nothing to compose beyond one button, which is itself evidence for the ADR — the
|
||||
* two apps' login flows are meant to look this different.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-form',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent],
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<form (ngSubmit)="submitted.emit(bsn)" class="form-horizontal">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-form-field
|
||||
i18n-label="@@login.bsnLabel"
|
||||
label="BSN"
|
||||
fieldId="bsn"
|
||||
required
|
||||
i18n-description="@@login.bsnDescription"
|
||||
description="9-cijferig BSN, elfproef-geldig (demo: 123456782)"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="bsn"
|
||||
hasDescription
|
||||
[(ngModel)]="bsn"
|
||||
name="bsn"
|
||||
placeholder="123456782"
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
<app-form-field i18n-label="@@login.wachtwoordLabel" label="Wachtwoord" fieldId="pw" required>
|
||||
<app-text-input inputId="pw" type="password" [(ngModel)]="password" name="pw" />
|
||||
</app-form-field>
|
||||
|
||||
<app-button type="submit" variant="primary" i18n="@@login.submit"
|
||||
>Inloggen met DigiD</app-button
|
||||
>
|
||||
</form>
|
||||
<div class="form-horizontal">
|
||||
<p i18n="@@login.ssoExplainer">
|
||||
U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.
|
||||
</p>
|
||||
<app-button type="button" variant="primary" (click)="submitted.emit()" i18n="@@login.submit">
|
||||
Inloggen met SSO
|
||||
</app-button>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class LoginFormComponent {
|
||||
bsn = '';
|
||||
password = '';
|
||||
submitted = output<string>();
|
||||
submitted = output<void>();
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
/**
|
||||
* No error alert here — unlike the SSP's DigiD form, `SessionStore.login()` has
|
||||
* nothing to fail on (see `MedewerkerAdapter`). A real SSO integration is where
|
||||
* this page would grow one back.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-login-page',
|
||||
imports: [PageShellComponent, AlertComponent, LoginFormComponent],
|
||||
imports: [PageShellComponent, LoginFormComponent],
|
||||
template: `
|
||||
<app-page-shell
|
||||
i18n-heading="@@login.heading"
|
||||
heading="Inloggen"
|
||||
heading="Inloggen bij het behandelportal"
|
||||
width="narrow"
|
||||
i18n-intro="@@login.intro"
|
||||
intro="Log in op uw persoonlijke BIG-register omgeving."
|
||||
intro="Voor medewerkers die aanvragen beoordelen."
|
||||
>
|
||||
@if (error()) {
|
||||
<app-alert type="error">{{ error() }}</app-alert>
|
||||
}
|
||||
<app-login-form (submitted)="login($event)" />
|
||||
<app-login-form (submitted)="login()" />
|
||||
</app-page-shell>
|
||||
`,
|
||||
})
|
||||
export class LoginPage {
|
||||
private store = inject(SessionStore);
|
||||
private router = inject(Router);
|
||||
error = signal('');
|
||||
|
||||
async login(bsn: string) {
|
||||
const r = await this.store.login(bsn);
|
||||
if (r.ok) this.router.navigate(['/dashboard']);
|
||||
else this.error.set(r.error);
|
||||
async login() {
|
||||
await this.store.login();
|
||||
this.router.navigate(['/dashboard']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/** One aanvraag's beoordeling detail (WP-65) — a root singleton like `WerkvoorraadStore`.
|
||||
/** One aanvraag's beoordeling detail — a root singleton like `WerkvoorraadStore`.
|
||||
Keyed by id: navigating to a different case resets to Loading. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BeoordelingStore {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/** The behandelaar's queue (WP-64) — a root singleton like `AdminCasesStore`'s ssp
|
||||
/** The behandelaar's queue — a root singleton like `AdminCasesStore`'s ssp
|
||||
counterpart. Fetch + parse at the trust boundary, publish as RemoteData. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WerkvoorraadStore {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { AanvraagType } from './werkvoorraad-item';
|
||||
import { BeoordelingStatus, BeoordelingView } from './beoordeling';
|
||||
|
||||
/** View-model mapping shared by the werkvoorraad list (WP-64) and the beoordeling
|
||||
detail screen (WP-65): type/status → labels. Pure, no Angular. Lives here (not in
|
||||
/** View-model mapping shared by the werkvoorraad list and the beoordeling
|
||||
detail screen: type/status → labels. Pure, no Angular. Lives here (not in
|
||||
`werkvoorraad-item-view.ts`) because `BeoordelingStatus` is the wider of the two
|
||||
status unions — `werkvoorraad-item-view.ts` re-exports these for its own use. */
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AanvraagType } from './werkvoorraad-item';
|
||||
|
||||
/**
|
||||
* A case's full status lifecycle as the beoordeling detail screen sees it (WP-65) —
|
||||
* wider than `WerkvoorraadStatus` (WP-64), which only ever sees the two "still open"
|
||||
* A case's full status lifecycle as the beoordeling detail screen sees it —
|
||||
* wider than `WerkvoorraadStatus`, which only ever sees the two "still open"
|
||||
* tags. This is the same five-tag union ssp's `AanvraagStatus` models (minus `Concept`
|
||||
* — the detail endpoint 404s a Concept, it isn't a case a behandelaar can treat yet).
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import { BesluitState, reduce, initial } from './besluit.machine';
|
||||
|
||||
const editingWith = (besluit: string, toelichting = ''): BesluitState => ({
|
||||
tag: 'Editing',
|
||||
draft: { besluit, toelichting },
|
||||
errors: {},
|
||||
});
|
||||
import { reduce, initial } from './besluit.machine';
|
||||
import { givenBesluit } from './besluit.testing';
|
||||
|
||||
describe('besluit reduce', () => {
|
||||
it('SetField updates the draft while editing', () => {
|
||||
@@ -15,17 +10,23 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('Submit with no besluit chosen stays Editing and reports a field error', () => {
|
||||
const s = reduce(editingWith(''), { tag: 'Submit' });
|
||||
const s = reduce(initial, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Editing').errors.besluit).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit Afwijzen without a toelichting stays Editing and reports a field error', () => {
|
||||
const s = reduce(editingWith('Afwijzen'), { tag: 'Submit' });
|
||||
const editingAfwijzen = givenBesluit({ tag: 'SetField', key: 'besluit', value: 'Afwijzen' });
|
||||
const s = reduce(editingAfwijzen, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Editing').errors.toelichting).toBeTruthy();
|
||||
});
|
||||
|
||||
it('Submit Goedkeuren with no toelichting moves to Submitting (optional there)', () => {
|
||||
const s = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const editingGoedkeuren = givenBesluit({
|
||||
tag: 'SetField',
|
||||
key: 'besluit',
|
||||
value: 'Goedkeuren',
|
||||
});
|
||||
const s = reduce(editingGoedkeuren, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||
besluit: 'Goedkeuren',
|
||||
toelichting: undefined,
|
||||
@@ -33,7 +34,11 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('Submit Afwijzen with a toelichting moves to Submitting with the trimmed value', () => {
|
||||
const s = reduce(editingWith('Afwijzen', ' niet erkend '), { tag: 'Submit' });
|
||||
const editingAfwijzenWithToelichting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Afwijzen' },
|
||||
{ tag: 'SetField', key: 'toelichting', value: ' niet erkend ' },
|
||||
);
|
||||
const s = reduce(editingAfwijzenWithToelichting, { tag: 'Submit' });
|
||||
expect(expectTag(s, 'Submitting').data).toEqual({
|
||||
besluit: 'Afwijzen',
|
||||
toelichting: 'niet erkend',
|
||||
@@ -41,24 +46,36 @@ describe('besluit reduce', () => {
|
||||
});
|
||||
|
||||
it('SubmitConfirmed maps Submitting to Submitted', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
expect(reduce(submitting, { tag: 'SubmitConfirmed' }).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('SubmitFailed maps Submitting to Failed with the error', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(failed).toMatchObject({ tag: 'Failed', error: 'boom' });
|
||||
});
|
||||
|
||||
it('Retry re-submits a failure', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
const failed = reduce(submitting, { tag: 'SubmitFailed', error: 'boom' });
|
||||
expect(reduce(failed, { tag: 'Retry' }).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Reset returns to the initial editing state', () => {
|
||||
const submitting = reduce(editingWith('Goedkeuren'), { tag: 'Submit' });
|
||||
const submitting = givenBesluit(
|
||||
{ tag: 'SetField', key: 'besluit', value: 'Goedkeuren' },
|
||||
{ tag: 'Submit' },
|
||||
);
|
||||
expect(reduce(submitting, { tag: 'Reset' })).toEqual(initial);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Result, assertNever } from '@shared/kernel/fp';
|
||||
|
||||
/** The three actions the beoordeling screen offers a behandelaar (WP-65b) — mirrors the
|
||||
/** The three actions the beoordeling screen offers a behandelaar — mirrors the
|
||||
backend's `Besluit` enum member names 1:1 (the wire convention: a string, not a raw
|
||||
enum — see `RecordBesluitRequest`). */
|
||||
const BESLUIT_TAGS = ['Goedkeuren', 'Afwijzen', 'MeerInfoOpvragen'] as const;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { given } from '@shared/testing/machine';
|
||||
import { reduce, initial } from './besluit.machine';
|
||||
|
||||
/** Replay real `BesluitMsg`s through the real `reduce`, starting from `initial`.
|
||||
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
|
||||
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
|
||||
export const givenBesluit = given(reduce, initial);
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* A queue entry as the behandelportal sees it (WP-64) — the parsed, domain-side view
|
||||
* A queue entry as the behandelportal sees it — the parsed, domain-side view
|
||||
* of the backend's cross-owner `GET /werkvoorraad`. Pure types, no Angular.
|
||||
*
|
||||
* The status union is narrower than the SSP's full `AanvraagStatus` (ssp's
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { AanvraagType } from '@behandeling/domain/werkvoorraad-item';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the beoordeling detail read (WP-65) — the only place its
|
||||
* Infrastructure adapter for the beoordeling detail read — the only place its
|
||||
* HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response is validated +
|
||||
* mapped to domain by the parse* boundary below.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { Valid } from '@behandeling/domain/besluit.machine';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for recording a behandelaar's decision (WP-65b) — the single
|
||||
* Infrastructure adapter for recording a behandelaar's decision — the single
|
||||
* place its HTTP lives. No return value: a successful call means the server accepted
|
||||
* the transition; the caller reloads `BeoordelingStore` to see the new status (the
|
||||
* server, not this adapter, re-validates and is the authority).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient, ApplicationSummaryDto } from '@shared/infrastructure/api-client';
|
||||
import { ApiClient, AanvraagSummaryDto } from '@shared/infrastructure/api-client';
|
||||
import {
|
||||
WerkvoorraadItem,
|
||||
WerkvoorraadStatus,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@behandeling/domain/werkvoorraad-item';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for the behandelportal's queue read (WP-64) — the only
|
||||
* Infrastructure adapter for the behandelportal's queue read — the only
|
||||
* place its HTTP lives (ADR-0001 anti-corruption boundary). The untrusted response
|
||||
* is validated + mapped to the (narrower) queue domain shape by the parse* boundary
|
||||
* below; a case whose status isn't `Ingediend`/`InBehandeling` is a parse error, not
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
export class WerkvoorraadAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list(): Promise<ApplicationSummaryDto[]> {
|
||||
list(): Promise<AanvraagSummaryDto[]> {
|
||||
return this.client.werkvoorraad();
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export class WerkvoorraadAdapter {
|
||||
const AANVRAAG_TYPES: readonly string[] = ['registratie', 'herregistratie', 'intake'];
|
||||
|
||||
function parseWerkvoorraadStatus(
|
||||
s: ApplicationSummaryDto['status'] | undefined,
|
||||
s: AanvraagSummaryDto['status'] | undefined,
|
||||
): Result<string, WerkvoorraadStatus> {
|
||||
if (!s || typeof s.tag !== 'string') return err('werkvoorraad: missing status');
|
||||
switch (s.tag) {
|
||||
@@ -44,7 +44,7 @@ function parseWerkvoorraadStatus(
|
||||
|
||||
export function parseWerkvoorraadItem(json: unknown): Result<string, WerkvoorraadItem> {
|
||||
if (typeof json !== 'object' || json === null) return err('werkvoorraad: not an object');
|
||||
const dto = json as ApplicationSummaryDto;
|
||||
const dto = json as AanvraagSummaryDto;
|
||||
if (typeof dto.id !== 'string') return err('werkvoorraad: missing id');
|
||||
if (typeof dto.type !== 'string' || !AANVRAAG_TYPES.includes(dto.type))
|
||||
return err(`werkvoorraad: bad type ${dto.type}`);
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { BeoordelingDocument } from '@behandeling/domain/beoordeling';
|
||||
|
||||
/** Organism: the documents linked to an aanvraag (WP-65) — plain links to the existing
|
||||
/** Organism: the documents linked to an aanvraag — plain links to the existing
|
||||
(pre-existing, unauthenticated — same as ssp's own document previews) content
|
||||
endpoint. No new shared atom: a context-local list, not a reusable building block. */
|
||||
@Component({
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { successOf } from '@shared/application/remote-data';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { DataBlockComponent } from '@shared/ui/data-block/data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||
import { BeoordelingStore } from '@behandeling/application/beoordeling.store';
|
||||
import { detailRows } from '@behandeling/domain/beoordeling-view';
|
||||
import { BeoordelingDocumentenComponent } from '@behandeling/ui/beoordeling-documenten/beoordeling-documenten.component';
|
||||
import { BesluitFormComponent } from '@behandeling/ui/besluit-form/besluit-form.component';
|
||||
|
||||
/**
|
||||
* Page: one aanvraag's beoordeling detail (WP-65). The werkvoorraad list (WP-64) links
|
||||
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form (WP-65b) —
|
||||
* Page: one aanvraag's beoordeling detail. The werkvoorraad list links
|
||||
* here. `canBesluiten` (server-computed, ADR-0001) gates the decision form —
|
||||
* the page never recomputes the lifecycle itself. On a recorded decision the form emits
|
||||
* `decided`, and the page just reloads (the server is the authority on the new status).
|
||||
*/
|
||||
@@ -73,10 +74,7 @@ export class BeoordelingPage {
|
||||
protected retryText = $localize`:@@beoordeling.retry:Opnieuw proberen`;
|
||||
|
||||
protected rows = detailRows;
|
||||
protected readonly view = computed(() => {
|
||||
const rd = this.store.view();
|
||||
return rd.tag === 'Success' ? rd.value : undefined;
|
||||
});
|
||||
protected readonly view = computed(() => successOf(this.store.view()));
|
||||
|
||||
constructor() {
|
||||
void this.store.load(this.id);
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||
import {
|
||||
RadioGroupComponent,
|
||||
RadioOption,
|
||||
} from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||
import { DataBlockComponent } from '@shared/ui/molecules/data-block/data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BesluitState, BesluitMsg, initial, reduce } from '@behandeling/domain/besluit.machine';
|
||||
import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||
|
||||
/**
|
||||
* Organism: the decision form (WP-65b) — goedkeuren/afwijzen/meer-info-opvragen. Same
|
||||
* Organism: the decision form — goedkeuren/afwijzen/meer-info-opvragen. Same
|
||||
* idiom as every other form in this house (`change-request-form`): all state in one
|
||||
* signal driven by the pure `reduce` (besluit.machine.ts), submitted via a `submit-*`
|
||||
* command returning `Result`. The server re-validates the transition and is the
|
||||
@@ -29,10 +34,35 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
RadioGroupComponent,
|
||||
DataBlockComponent,
|
||||
DataRowComponent,
|
||||
],
|
||||
template: `
|
||||
@if (state().tag === 'Submitted') {
|
||||
<app-alert type="ok" i18n="@@besluit.success">Het besluit is vastgelegd.</app-alert>
|
||||
} @else if (state().tag === 'Failed') {
|
||||
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||
|
||||
<app-alert type="error"
|
||||
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
|
||||
{{ failedError() }}</app-alert
|
||||
>
|
||||
|
||||
<app-data-block class="app-section" [ariaLabel]="besluitLabelText">
|
||||
<div app-data-row [key]="besluitLabelText" [value]="besluitOptieLabel()"></div>
|
||||
@if (toelichting()) {
|
||||
<div app-data-row [key]="toelichtingLabelText" [value]="toelichting()"></div>
|
||||
}
|
||||
</app-data-block>
|
||||
|
||||
<div class="app-section">
|
||||
<app-button
|
||||
variant="secondary"
|
||||
(click)="dispatch({ tag: 'Retry' })"
|
||||
i18n="@@wizard.opnieuwProberen"
|
||||
>Opnieuw proberen</app-button
|
||||
>
|
||||
</div>
|
||||
} @else {
|
||||
<app-heading [level]="2" i18n="@@besluit.heading">Besluit vastleggen</app-heading>
|
||||
|
||||
@@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||
/>
|
||||
</app-form-field>
|
||||
|
||||
@if (failedError()) {
|
||||
<app-alert type="error"
|
||||
><ng-container i18n="@@besluit.failed">Het vastleggen is niet gelukt:</ng-container>
|
||||
{{ failedError() }}</app-alert
|
||||
>
|
||||
}
|
||||
|
||||
<app-button type="submit" variant="primary" [disabled]="state().tag === 'Submitting'">
|
||||
{{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }}
|
||||
</app-button>
|
||||
@@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit';
|
||||
})
|
||||
export class BesluitFormComponent {
|
||||
private submit = createSubmitBesluit();
|
||||
private store = createStore<BesluitState, BesluitMsg>(initial, reduce);
|
||||
// Effect fires once, on Editing -> Submitting (RD-05's tag-transition rule; `Seed` is
|
||||
// exempt, so a story mounting straight into `Submitting` does not call the network).
|
||||
private store = createStore<BesluitState, BesluitMsg>(initial, reduce, {
|
||||
Submitting: async (s, store) => {
|
||||
const r = await this.submit(this.id(), s.data);
|
||||
if (r.ok) {
|
||||
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.decided.emit();
|
||||
} else {
|
||||
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
id = input.required<string>();
|
||||
decided = output<void>();
|
||||
@@ -109,12 +144,33 @@ export class BesluitFormComponent {
|
||||
protected readonly submitLabel = $localize`:@@besluit.submit:Besluit vastleggen`;
|
||||
protected readonly submitBezigLabel = $localize`:@@besluit.submitBezig:Bezig met vastleggen…`;
|
||||
|
||||
// Same ids as the form-field labels above, reused for the Failed data-block's row
|
||||
// keys (the pattern change-request-form already uses for its read-only BRP rows).
|
||||
protected readonly besluitLabelText = $localize`:@@besluit.besluitLabel:Besluit`;
|
||||
protected readonly toelichtingLabelText = $localize`:@@besluit.toelichtingLabel:Toelichting`;
|
||||
|
||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||
protected errors = computed(() => this.editing()?.errors ?? {});
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
protected besluit = computed(() => this.editing()?.draft.besluit ?? '');
|
||||
protected toelichting = computed(() => this.editing()?.draft.toelichting ?? '');
|
||||
/** The value shown in the field — the live draft while editing, the parsed value
|
||||
while submitting/failed (so the user sees what they sent, same idiom as
|
||||
change-request-form.telefoon()). */
|
||||
protected besluit = computed(() => {
|
||||
const s = this.state();
|
||||
if (s.tag === 'Editing') return s.draft.besluit;
|
||||
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.besluit;
|
||||
return '';
|
||||
});
|
||||
protected toelichting = computed(() => {
|
||||
const s = this.state();
|
||||
if (s.tag === 'Editing') return s.draft.toelichting;
|
||||
if (s.tag === 'Submitting' || s.tag === 'Failed') return s.data.toelichting ?? '';
|
||||
return '';
|
||||
});
|
||||
protected besluitOptieLabel = computed(
|
||||
() => this.BESLUIT_OPTIONS.find((o) => o.value === this.besluit())?.label ?? '',
|
||||
);
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: this.seed() }));
|
||||
@@ -122,19 +178,5 @@ export class BesluitFormComponent {
|
||||
|
||||
onSubmit() {
|
||||
this.dispatch({ tag: 'Submit' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
/** Effect: when we entered Submitting, call the command, then dispatch the outcome. */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
const r = await this.submit(this.id(), s.data);
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.decided.emit();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { ApplicationListComponent } from '@shared/ui/application-list/application-list.component';
|
||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||
import { ApplicationListComponent } from '@shared/ui/molecules/application-list/application-list.component';
|
||||
import { ApplicationLinkComponent } from '@shared/ui/molecules/application-link/application-link.component';
|
||||
import { WerkvoorraadItem } from '@behandeling/domain/werkvoorraad-item';
|
||||
import { werkvoorraadRow } from '@behandeling/domain/werkvoorraad-item-view';
|
||||
|
||||
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows (WP-64) — composition
|
||||
/** Organism: the behandelaar's queue as CIBG "aanvragen" rows — composition
|
||||
of the two existing shared/ui molecules, no new atom. Each row links to the
|
||||
beoordeling detail page (WP-65). */
|
||||
beoordeling detail page. */
|
||||
@Component({
|
||||
selector: 'app-werkvoorraad-list',
|
||||
imports: [ApplicationListComponent, ApplicationLinkComponent],
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { SkeletonComponent } from '@shared/ui/atoms/skeleton/skeleton.component';
|
||||
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { successOr } from '@shared/application/remote-data';
|
||||
import { WerkvoorraadStore } from '@behandeling/application/werkvoorraad.store';
|
||||
import { WerkvoorraadListComponent } from '@behandeling/ui/werkvoorraad-list/werkvoorraad-list.component';
|
||||
|
||||
/**
|
||||
* Page: the behandelaar's werkvoorraad (WP-64) — the behandelportal's landing page.
|
||||
* Page: the behandelaar's werkvoorraad — the behandelportal's landing page.
|
||||
* Deny-by-default capability gate (`aanvraag:beoordelen`), same idiom as ssp's
|
||||
* AdminCasesPage: a denial alert for a non-behandelaar, the queue for one. Opening
|
||||
* a case's detail is out of scope here (WP-65).
|
||||
* a case's detail is out of scope here.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-werkvoorraad-page',
|
||||
@@ -56,10 +57,7 @@ export class WerkvoorraadPage {
|
||||
protected access = inject(AccessStore);
|
||||
|
||||
protected canBeoordelen = computed(() => this.access.can('aanvraag:beoordelen'));
|
||||
protected items = computed(() => {
|
||||
const rd = this.store.items();
|
||||
return rd.tag === 'Success' ? rd.value : [];
|
||||
});
|
||||
protected items = computed(() => successOr(this.store.items(), []));
|
||||
|
||||
protected heading = $localize`:@@werkvoorraad.heading:Werkvoorraad`;
|
||||
protected intro = $localize`:@@werkvoorraad.intro:Aanvragen die op beoordeling wachten.`;
|
||||
@@ -71,7 +69,7 @@ export class WerkvoorraadPage {
|
||||
private loadRequested = false;
|
||||
constructor() {
|
||||
// Load once the capability resolves to allowed (a 403 GET would be wasted otherwise) —
|
||||
// same guard-against-the-loop idiom as AdminCasesPage (WP-26 lesson).
|
||||
// same guard-against-the-loop idiom as AdminCasesPage.
|
||||
effect(() => {
|
||||
if (this.canBeoordelen() && !this.loadRequested) {
|
||||
this.loadRequested = true;
|
||||
|
||||
@@ -8,7 +8,7 @@ export const NAV_ITEMS: readonly HeaderNavItem[] = [
|
||||
|
||||
/** This app's admin pages — provided to the shared site header via HEADER_ADMIN_LINKS.
|
||||
No huisstijl (that's the SSP's brief context) or zaken entry — inherited as-is from
|
||||
WP-61's bootstrap trim, not revisited by this migration. */
|
||||
the bootstrap trim, not revisited by this migration. */
|
||||
export const ADMIN_LINKS: readonly AdminLink[] = [
|
||||
{
|
||||
label: $localize`:@@header.nav.stamdata:Stamdata`,
|
||||
|
||||
@@ -26,65 +26,33 @@
|
||||
<context context-type="linenumber">27</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="form.verplichteVelden" datatype="html">
|
||||
<source>* verplichte velden</source>
|
||||
<target datatype="html">* required fields</target>
|
||||
<trans-unit id="login.ssoExplainer" datatype="html">
|
||||
<source>U meldt zich aan via de SSO van uw organisatie — er is geen wachtwoord nodig.</source>
|
||||
<target datatype="html">You sign in through your organization's SSO — no password is needed.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">15,18</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/registratie/ui/change-request-form/change-request-form.component.ts</context>
|
||||
<context context-type="linenumber">44,46</context>
|
||||
</context-group>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/shared/layout/wizard-shell/wizard-shell.component.ts</context>
|
||||
<context context-type="linenumber">90,92</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.bsnLabel" datatype="html">
|
||||
<source>BSN</source>
|
||||
<target datatype="html">BSN</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">22,23</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.bsnDescription" datatype="html">
|
||||
<source>9-cijferig BSN, elfproef-geldig (demo: 123456782)</source>
|
||||
<target datatype="html">9-digit BSN, valid eleven-test checksum (demo: 123456782)</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">25,28</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.wachtwoordLabel" datatype="html">
|
||||
<source>Wachtwoord</source>
|
||||
<target datatype="html">Password</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">36,37</context>
|
||||
<context context-type="linenumber">17,19</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.submit" datatype="html">
|
||||
<source>Inloggen met DigiD</source>
|
||||
<target datatype="html">Log in with DigiD</target>
|
||||
<source>Inloggen met SSO</source>
|
||||
<target datatype="html">Log in with SSO</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login-form/login-form.component.ts</context>
|
||||
<context context-type="linenumber">41,43</context>
|
||||
<context context-type="linenumber">20,21</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.heading" datatype="html">
|
||||
<source>Inloggen</source>
|
||||
<target datatype="html">Log in</target>
|
||||
<source>Inloggen bij het behandelportal</source>
|
||||
<target datatype="html">Log in to the treatment portal</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">14,16</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="login.intro" datatype="html">
|
||||
<source>Log in op uw persoonlijke BIG-register omgeving.</source>
|
||||
<target datatype="html">Log in to your personal BIG register environment.</target>
|
||||
<source>Voor medewerkers die aanvragen beoordelen.</source>
|
||||
<target datatype="html">For staff who assess applications.</target>
|
||||
<context-group purpose="location">
|
||||
<context context-type="sourcefile">src/app/auth/ui/login.page.ts</context>
|
||||
<context context-type="linenumber">17,19</context>
|
||||
@@ -3878,6 +3846,10 @@
|
||||
<source>De functievlaggen konden niet worden geladen.</source>
|
||||
<target datatype="html">The feature flags could not be loaded.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.set.failed" datatype="html">
|
||||
<source>De functievlag kon niet worden opgeslagen.</source>
|
||||
<target datatype="html">The feature flag could not be saved.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="flags.retry" datatype="html">
|
||||
<source>Opnieuw proberen</source>
|
||||
<target datatype="html">Try again</target>
|
||||
|
||||
@@ -14,6 +14,7 @@ import localeEn from '@angular/common/locales/en';
|
||||
import { routes } from './app.routes';
|
||||
import { scenarioInterceptor } from '@shared/infrastructure/scenario.interceptor';
|
||||
import { roleInterceptor } from '@shared/infrastructure/role.interceptor';
|
||||
import { subjectInterceptor } from '@shared/infrastructure/subject.interceptor';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
@@ -54,7 +55,11 @@ export const appConfig: ApplicationConfig = {
|
||||
),
|
||||
// Dev-only: the ?scenario= toggle must never reach a production build, where
|
||||
// a query param could otherwise force errors on the live app.
|
||||
provideHttpClient(withInterceptors(isDevMode() ? [scenarioInterceptor, roleInterceptor] : [])),
|
||||
provideHttpClient(
|
||||
withInterceptors(
|
||||
isDevMode() ? [scenarioInterceptor, roleInterceptor, subjectInterceptor] : [],
|
||||
),
|
||||
),
|
||||
provideApiClient(),
|
||||
{ provide: SESSION_PORT, useExisting: SessionStore },
|
||||
// Per-bundle locale: the localize build sets `$localize.locale` ('nl'/'en'); the
|
||||
|
||||
@@ -14,9 +14,11 @@ export const routes: Routes = [
|
||||
loadComponent: () => import('@auth/ui/login.page').then((m) => m.LoginPage),
|
||||
},
|
||||
{
|
||||
// Path stays 'dashboard' on purpose: it is a user-visible URL and four e2e
|
||||
// specs assert it. The context is `overzicht`; only the path string differs.
|
||||
path: 'dashboard',
|
||||
canActivate: [authGuard],
|
||||
loadComponent: () => import('@registratie/ui/dashboard.page').then((m) => m.DashboardPage),
|
||||
loadComponent: () => import('@overzicht/ui/overzicht.page').then((m) => m.OverzichtPage),
|
||||
},
|
||||
{
|
||||
path: 'registratie',
|
||||
@@ -59,7 +61,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'brief/huisstijl',
|
||||
// Admin-only org-template editor (WP-26): capabilityGuard denies-by-default
|
||||
// Admin-only org-template editor: capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `orgtemplate:edit` (Admin role). Backend re-enforces
|
||||
// via the OrgAdmin gate — the guard just avoids loading a page that would 403.
|
||||
canActivate: [capabilityGuard('orgtemplate:edit')],
|
||||
@@ -76,7 +78,7 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'beheer/zaken',
|
||||
// Admin-only cases overview + delete (WP-36): capabilityGuard denies-by-default
|
||||
// Admin-only cases overview + delete: capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (Admin role). Backend re-enforces via the
|
||||
// CasesAdmin gate — the guard just avoids loading a page that would 403. The page
|
||||
// lives in registratie/ui (which owns the Aanvraag aggregate); routed under /beheer.
|
||||
@@ -86,14 +88,14 @@ export const routes: Routes = [
|
||||
},
|
||||
{
|
||||
path: 'beheer/audit',
|
||||
// Admin-only authz/PII-reveal audit trail (WP-41/42). capabilityGuard denies-by-default
|
||||
// Admin-only authz/PII-reveal audit trail. capabilityGuard denies-by-default
|
||||
// unless GET /me resolved `cases:manage` (reused for audit read). Backend re-enforces.
|
||||
canActivate: [capabilityGuard('cases:manage')],
|
||||
loadComponent: () => import('@beheer/ui/audit.page').then((m) => m.AuditPage),
|
||||
},
|
||||
{
|
||||
path: 'beheer/functies',
|
||||
// Admin-only feature-flag toggles (WP-47), gated by `flags:manage`.
|
||||
// Admin-only feature-flag toggles, gated by `flags:manage`.
|
||||
canActivate: [capabilityGuard('flags:manage')],
|
||||
loadComponent: () =>
|
||||
import('@beheer/ui/feature-flags.page').then((m) => m.FeatureFlagsPage),
|
||||
|
||||
@@ -1,55 +1,50 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Session } from '../domain/session';
|
||||
import { Principal, parseStoredPrincipal } from '../domain/principal';
|
||||
import { DigidAdapter } from '../infrastructure/digid.adapter';
|
||||
|
||||
const STORAGE_KEY = 'session-v1';
|
||||
|
||||
/** Restore a persisted session (best-effort; corrupt entry → logged out).
|
||||
G2: validate the shape before trusting it. G1: the BSN is never persisted
|
||||
(see the effect below), so a restored session carries an empty one — it is
|
||||
unused after login; only `naam` is shown in the chrome. */
|
||||
function restore(): Session | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Session>;
|
||||
return typeof parsed?.naam === 'string' ? { bsn: '', naam: parsed.naam } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Restore a persisted principal (best-effort; corrupt entry → logged out).
|
||||
The parse + shape validation (G1/G2) lives in `parseStoredPrincipal`
|
||||
(`../domain/principal`) — pure, spec'd, and testable without stubbing
|
||||
`localStorage`; this just supplies the raw value. */
|
||||
function restore(): Principal | null {
|
||||
return parseStoredPrincipal(localStorage.getItem(STORAGE_KEY));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the current session for the whole app. Because it is providedIn:'root'
|
||||
* there is exactly one instance — every component that injects it sees the same
|
||||
* session signal, so logging in is instantly visible everywhere (the guard, the
|
||||
* header, etc.). The session is mirrored to localStorage so a refresh, a deep-link,
|
||||
* or the full-page navigation the language switch performs (nl at `/` ⇄ en at `/en/`,
|
||||
* separate bundles) keeps you logged in. ponytail: localStorage, not sessionStorage —
|
||||
* sessionStorage's per-tab clearing dropped the login on the cross-bundle language
|
||||
* switch. Trade-off: the demo session now survives tab close; a real portal keeps auth
|
||||
* in an httpOnly cookie/token, not web storage.
|
||||
* Holds the current zorgverlener principal for the whole SSP. One
|
||||
* `providedIn: 'root'` instance, so logging in is instantly visible everywhere
|
||||
* (the guard, the header). Persisted to localStorage — a refresh or the
|
||||
* cross-bundle language switch (nl at `/` ⇄ en at `/en/`) keeps you logged in —
|
||||
* but never the BSN itself (G1 in the `effect` below): this principal carries a
|
||||
* citizen's national identifier, which the behandelportal's equivalent store does
|
||||
* not have to guard against, because its `medewerker` principal has no BSN.
|
||||
* ponytail: localStorage, not sessionStorage — sessionStorage's per-tab clearing
|
||||
* dropped the login on the cross-bundle language switch. Trade-off: the demo
|
||||
* session now survives tab close; a real portal keeps auth in an httpOnly
|
||||
* cookie/token, not web storage.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SessionStore {
|
||||
private digid = inject(DigidAdapter);
|
||||
private _session = signal<Session | null>(restore());
|
||||
private _session = signal<Principal | null>(restore());
|
||||
|
||||
readonly session = this._session.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this._session() !== null);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const s = this._session();
|
||||
const p = this._session();
|
||||
// G1: persist only `naam` — never write the BSN (national ID) to storage.
|
||||
if (s) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: s.naam }));
|
||||
if (p) localStorage.setItem(STORAGE_KEY, JSON.stringify({ naam: p.naam }));
|
||||
else localStorage.removeItem(STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
|
||||
/** Effectful command: authenticate, then store the session on success. */
|
||||
async login(bsn: string): Promise<Result<string, Session>> {
|
||||
/** Effectful command: authenticate, then store the principal on success. */
|
||||
async login(bsn: string): Promise<Result<string, Principal>> {
|
||||
const r = await this.digid.authenticate(bsn);
|
||||
if (r.ok) this._session.set(r.value);
|
||||
return r;
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { Router } from '@angular/router';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { SessionStore } from './application/session.store';
|
||||
import { authGuard, capabilityGuard } from './auth.guard';
|
||||
|
||||
type Opts = {
|
||||
authed: boolean;
|
||||
can?: (c: string) => boolean;
|
||||
whenReady?: () => Promise<void>;
|
||||
};
|
||||
|
||||
function setup({ authed, can = () => false, whenReady = () => Promise.resolve() }: Opts) {
|
||||
const createUrlTree = vi.fn((cmds: string[]) => ({ tree: cmds }));
|
||||
const readySpy = vi.fn(whenReady);
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: SessionStore, useValue: { isAuthenticated: () => authed } },
|
||||
{ provide: AccessStore, useValue: { whenReady: readySpy, can } },
|
||||
{ provide: Router, useValue: { createUrlTree } },
|
||||
],
|
||||
});
|
||||
return { createUrlTree, readySpy };
|
||||
}
|
||||
|
||||
// The guards ignore their (route, state) args; cast to call with none.
|
||||
const call = <T>(fn: unknown) => TestBed.runInInjectionContext(() => (fn as () => T)());
|
||||
|
||||
describe('authGuard', () => {
|
||||
it('allows an authenticated user', () => {
|
||||
setup({ authed: true });
|
||||
expect(call(authGuard)).toBe(true);
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login', () => {
|
||||
const { createUrlTree } = setup({ authed: false });
|
||||
expect(call(authGuard)).toEqual({ tree: ['/login'] });
|
||||
expect(createUrlTree).toHaveBeenCalledWith(['/login']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('capabilityGuard', () => {
|
||||
const guard = () => capabilityGuard('stamdata:edit');
|
||||
|
||||
it('waits for /me, then allows an entitled admin', async () => {
|
||||
const { readySpy } = setup({ authed: true, can: (c) => c === 'stamdata:edit' });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toBe(true);
|
||||
expect(readySpy).toHaveBeenCalledOnce(); // it awaited caps before deciding
|
||||
});
|
||||
|
||||
it('sends an authenticated-but-unentitled user to /dashboard (not a login loop)', async () => {
|
||||
setup({ authed: true, can: () => false });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/dashboard'] });
|
||||
});
|
||||
|
||||
it('redirects an anonymous user to /login without waiting for caps', async () => {
|
||||
const { readySpy } = setup({ authed: false, can: () => true });
|
||||
await expect(call<Promise<unknown>>(guard())).resolves.toEqual({ tree: ['/login'] });
|
||||
expect(readySpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,34 +1,10 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { SessionStore } from './application/session.store';
|
||||
|
||||
/** Route guard: only let authenticated users in; otherwise redirect to /login. */
|
||||
export const authGuard: CanActivateFn = () => {
|
||||
const store = inject(SessionStore);
|
||||
const router = inject(Router);
|
||||
return store.isAuthenticated() ? true : router.createUrlTree(['/login']);
|
||||
};
|
||||
|
||||
/**
|
||||
* Route guard factory (PRD-0002 §6): authenticated AND holding `capability`, else
|
||||
* redirect. Used by the admin pages (`/brief/huisstijl`, `/beheer/stamdata`).
|
||||
* The route guards live in `libs/shared` (ADR-C-006) — they are actor-agnostic, reading
|
||||
* only `SESSION_PORT` and `AccessStore`, so both apps share one copy and one spec.
|
||||
* Re-exported here so `app.routes.ts` keeps importing them from `@auth/auth.guard`:
|
||||
* routing asks the auth context for its guards, which is the right direction to read.
|
||||
*
|
||||
* **Async on purpose:** `can()` is deny-by-default, so it must not be read while `/me`
|
||||
* is still loading — it would deny an entitled admin and bounce them. We await
|
||||
* `AccessStore.whenReady()` (caps resolved) before deciding. An unauthenticated user
|
||||
* goes to `/login`; an authenticated-but-unentitled user goes to `/dashboard` (they're
|
||||
* logged in, just not allowed here — no re-login loop). The backend re-enforces
|
||||
* regardless (403); this guard is the UX pre-gate.
|
||||
* ADR-0002 §3's "auth stays duplicated" still holds for what it actually scopes —
|
||||
* `Principal`, the login flow, `SessionStore`. A guard is neither.
|
||||
*/
|
||||
export function capabilityGuard(capability: Capability): CanActivateFn {
|
||||
return async () => {
|
||||
const session = inject(SessionStore);
|
||||
const access = inject(AccessStore);
|
||||
const router = inject(Router);
|
||||
if (!session.isAuthenticated()) return router.createUrlTree(['/login']);
|
||||
await access.whenReady();
|
||||
return access.can(capability) ? true : router.createUrlTree(['/dashboard']);
|
||||
};
|
||||
}
|
||||
export { authGuard, capabilityGuard } from '@shared/application/auth.guard';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, parseStoredPrincipal, Principal } from './principal';
|
||||
|
||||
const principal: Principal = { kind: 'zorgverlener', bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present principal to Principal', () => {
|
||||
expect(isAuthenticated(principal)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no principal as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseStoredPrincipal', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(parseStoredPrincipal(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-JSON string', () => {
|
||||
expect(parseStoredPrincipal('not json')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the stored shape is wrong (no naam)', () => {
|
||||
expect(parseStoredPrincipal(JSON.stringify({ bsn: '19012345601' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('G1: a stored bsn is never restored, even if present in the raw value', () => {
|
||||
const restored = parseStoredPrincipal(JSON.stringify({ bsn: '19012345601', naam: 'Test' }));
|
||||
expect(restored).toEqual({ kind: 'zorgverlener', bsn: '', naam: 'Test' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Who is logged in. Framework-free domain type.
|
||||
*
|
||||
* The `zorgverlener` variant of ADR-0002 §3's `Principal` union — the SSP has exactly
|
||||
* one actor kind (a citizen, authenticated via DigiD/BSN), so this app's own copy of
|
||||
* the union only ever holds this one member. `kind` is still a discriminant, not
|
||||
* decoration: it is what makes `apps/behandelportal`'s `medewerker` variant a
|
||||
* genuinely different type rather than a same-shaped coincidence, and what a future
|
||||
* third actor (§4 — admin/auditor/institution-rep) would add a member to.
|
||||
*/
|
||||
export interface Principal {
|
||||
readonly kind: 'zorgverlener';
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(p: Principal | null): p is Principal {
|
||||
return p !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a persisted principal out of a raw `localStorage` string (best-effort;
|
||||
* anything that isn't a well-shaped record → logged out). G2: validate the
|
||||
* shape before trusting it. G1: even if a stored entry carries a `bsn`, the
|
||||
* restored principal's `bsn` is always `''` — the BSN is never persisted (see
|
||||
* the `SessionStore` effect that writes it), so a legacy or tampered entry
|
||||
* cannot resurrect one.
|
||||
*/
|
||||
export function parseStoredPrincipal(raw: string | null): Principal | null {
|
||||
try {
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<Principal>;
|
||||
return typeof parsed?.naam === 'string'
|
||||
? { kind: 'zorgverlener', bsn: '', naam: parsed.naam }
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { isAuthenticated, Session } from './session';
|
||||
|
||||
const session: Session = { bsn: '19012345601', naam: 'Test' };
|
||||
|
||||
describe('isAuthenticated', () => {
|
||||
it('narrows a present session to Session', () => {
|
||||
expect(isAuthenticated(session)).toBe(true);
|
||||
});
|
||||
|
||||
it('reports no session as not authenticated', () => {
|
||||
expect(isAuthenticated(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +0,0 @@
|
||||
/** Who is logged in. Framework-free domain type. */
|
||||
export interface Session {
|
||||
readonly bsn: string;
|
||||
readonly naam: string;
|
||||
}
|
||||
|
||||
export function isAuthenticated(s: Session | null): s is Session {
|
||||
return s !== null;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { parseBsn } from '@shared/kernel/bsn';
|
||||
import { Session } from '../domain/session';
|
||||
import { Principal } from '../domain/principal';
|
||||
|
||||
/** Infrastructure: talks to the (mock) DigiD identity provider. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DigidAdapter {
|
||||
// ponytail: fake DigiD — any elfproef-valid BSN authenticates to a fixed identity.
|
||||
// Real BSN validation (parseBsn, WP-40) is the trust boundary; swap the fixed identity
|
||||
// Real BSN validation (parseBsn) is the trust boundary; swap the fixed identity
|
||||
// for a real OIDC redirect flow when there's an IdP.
|
||||
async authenticate(bsn: string): Promise<Result<string, Session>> {
|
||||
async authenticate(bsn: string): Promise<Result<string, Principal>> {
|
||||
const r = parseBsn(bsn);
|
||||
return r.ok ? ok({ bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
return r.ok ? ok({ kind: 'zorgverlener', bsn: r.value, naam: 'Dr. A. (Anna) de Vries' }) : r;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
|
||||
/** Organism: DigiD-style mock login. No real auth — just composes atoms/molecules. */
|
||||
@Component({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { LoginFormComponent } from '@auth/ui/login-form/login-form.component';
|
||||
import { SessionStore } from '@auth/application/session.store';
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||
import { Brief, BriefDecisions, CaseContext, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import {
|
||||
BRIEF_LOAD_FAILED,
|
||||
BriefAdapter,
|
||||
BriefLoadFailure,
|
||||
BriefView,
|
||||
} from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter, PREVIEW_FAILED } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { BriefStore } from './brief.store';
|
||||
@@ -48,8 +54,26 @@ const caseContext: CaseContext = {
|
||||
|
||||
const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext };
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>): BriefStore {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: BriefAdapter, useValue: adapter }] });
|
||||
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
|
||||
touching the DOM, so a spec can assert a command's success path directly. */
|
||||
function fakeBlobPresenter() {
|
||||
const opened: Blob[] = [];
|
||||
const presenter: BlobPresenter = {
|
||||
open: (blob) => opened.push(blob),
|
||||
download: () => {
|
||||
throw new Error('not used by BriefStore');
|
||||
},
|
||||
};
|
||||
return { presenter, opened };
|
||||
}
|
||||
|
||||
function setup(adapter: Partial<BriefAdapter>, blobPresenter?: BlobPresenter): BriefStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: BriefAdapter, useValue: adapter },
|
||||
...(blobPresenter ? [{ provide: BLOB_PRESENTER, useValue: blobPresenter }] : []),
|
||||
],
|
||||
});
|
||||
return TestBed.inject(BriefStore);
|
||||
}
|
||||
|
||||
@@ -60,7 +84,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
@@ -79,7 +104,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
brief: { ...brief, status: { tag: 'approved', approvedBy: 'u2', approvedAt: 't' } },
|
||||
};
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: approved }),
|
||||
@@ -93,7 +119,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
|
||||
it('goes Busy then Failed on a failing transition, surfacing the error', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: false, error: 'niet toegestaan' }),
|
||||
@@ -108,7 +135,8 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
it('a subsequent successful transition clears a prior Failed state', async () => {
|
||||
let approveResult: Result<string, BriefView> = { ok: false, error: 'eerste poging mislukt' };
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
save: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
approve: (): Promise<Result<string, BriefView>> => Promise.resolve(approveResult),
|
||||
});
|
||||
@@ -130,7 +158,7 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- WP-27: undo/redo history + rejection diff ---
|
||||
// --- Undo/redo history + rejection diff ---
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return {
|
||||
@@ -151,13 +179,15 @@ const filledView: BriefView = { ...view, brief: filledBrief };
|
||||
|
||||
function loadedBrief(store: BriefStore): Brief {
|
||||
const s = store.model();
|
||||
if (s.tag !== 'loaded') throw new Error('not loaded');
|
||||
if (s.tag !== 'Loaded') throw new Error('not loaded');
|
||||
return s.brief;
|
||||
}
|
||||
|
||||
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
// Untyped return (inferred as the narrow `{ ok: true; value }` literal) so this one
|
||||
// helper satisfies both `load` (error channel `BriefLoadFailure`) and `save` (error
|
||||
// channel `string`) — it only ever produces the `ok: true` branch.
|
||||
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||
await store.load();
|
||||
return store;
|
||||
@@ -255,8 +285,7 @@ describe('BriefStore rejection diff', () => {
|
||||
...filledBrief,
|
||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||
};
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: v });
|
||||
const ok = (v: BriefView) => Promise.resolve({ ok: true, value: v } as const);
|
||||
const store = setup({
|
||||
load: () => ok({ ...filledView, brief: submitted }),
|
||||
save: () => ok(filledView),
|
||||
@@ -277,41 +306,46 @@ describe('BriefStore rejection diff', () => {
|
||||
});
|
||||
|
||||
describe('BriefStore.previewLetter', () => {
|
||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('opens the composed letter in a new tab on success', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
it('opens the composed letter via BLOB_PRESENTER on success', async () => {
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: true,
|
||||
value: blob,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).toHaveBeenCalledWith('blob:mock', '_blank');
|
||||
expect(opened).toEqual([blob]);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
const store = setup({
|
||||
load: (): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: view }),
|
||||
});
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
load: (): Promise<Result<BriefLoadFailure, BriefView>> =>
|
||||
Promise.resolve({ ok: true, value: view }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
const open = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
vi.spyOn(TestBed.inject(LetterPreviewAdapter), 'preview').mockResolvedValue({
|
||||
ok: false,
|
||||
error: PREVIEW_FAILED,
|
||||
});
|
||||
|
||||
await store.previewLetter();
|
||||
expect(open).not.toHaveBeenCalled();
|
||||
expect(opened).toHaveLength(0);
|
||||
expect(store.lastError()).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
});
|
||||
@@ -377,3 +411,42 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => {
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// --- CQ-007's expand half: a 404 from GET /brief tolerates by calling the
|
||||
// existing reset() command, exactly once. Today's backend never 404s yet;
|
||||
// this fake adapter is what exercises the branch until then. ---
|
||||
|
||||
describe('BriefStore.load — 404 tolerance', () => {
|
||||
const notFound: Result<BriefLoadFailure, BriefView> = { ok: false, error: { tag: 'notFound' } };
|
||||
const resetOk: Result<string, BriefView> = { ok: true, value: view };
|
||||
|
||||
it('a 404 drives exactly one reset(), which populates the store', async () => {
|
||||
// Given GET /brief 404s (no brief exists yet) and reset() succeeds.
|
||||
const load = vi.fn(() => Promise.resolve(notFound));
|
||||
const reset = vi.fn(() => Promise.resolve(resetOk));
|
||||
const store = setup({ load, reset });
|
||||
|
||||
// When the store loads...
|
||||
await store.load();
|
||||
|
||||
// Then reset() ran exactly once, and the store ends up loaded from its result.
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(store.model().tag).toBe('Loaded');
|
||||
});
|
||||
|
||||
it('a second 404 does not drive a second reset()', async () => {
|
||||
// Given every load() attempt 404s (e.g. the brief still fails to appear).
|
||||
const load = vi.fn(() => Promise.resolve(notFound));
|
||||
const reset = vi.fn(() => Promise.resolve(resetOk));
|
||||
const store = setup({ load, reset });
|
||||
|
||||
// When the store loads twice...
|
||||
await store.load();
|
||||
await store.load();
|
||||
|
||||
// Then reset() ran exactly once — the once-only bound holds across calls, not
|
||||
// just within one — and the second 404 surfaces as an ordinary load failure.
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createHistory } from '@shared/application/history';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||
import {
|
||||
Brief,
|
||||
CaseContext,
|
||||
@@ -16,11 +15,12 @@ import {
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { BRIEF_LOAD_FAILED, BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.adapter';
|
||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||
import { uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
/**
|
||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
||||
@@ -28,29 +28,35 @@ import { PendingSave, registerPendingSave } from '@shared/application/pending-sa
|
||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore implements PendingSave {
|
||||
private adapter = inject(BriefAdapter);
|
||||
private previewAdapter = inject(LetterPreviewAdapter);
|
||||
private revealAdapter = inject(RevealBigNummerAdapter);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<BriefState, BriefMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
|
||||
these stay as plain `computed`s so the render seam (four `busy = input(...)`
|
||||
components, two page templates) keeps a byte-identical boolean/string API. */
|
||||
readonly busy = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'Loaded' && s.action.tag === 'Busy';
|
||||
});
|
||||
readonly lastError = computed(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
const s = this.model();
|
||||
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
|
||||
});
|
||||
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
|
||||
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
|
||||
/** Undo/redo is SHELL state, not machine state: a `createHistory` stack of
|
||||
`Brief` snapshots (the mechanics live in a shared helper). Only CONTENT edits are recorded
|
||||
(they flow through `edit()`); status transitions never enter history, or undo would
|
||||
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
||||
changes. */
|
||||
@@ -58,7 +64,7 @@ export class BriefStore implements PendingSave {
|
||||
readonly canUndo = this.history.canUndo;
|
||||
readonly canRedo = this.history.canRedo;
|
||||
|
||||
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
||||
/** The letter as it stood when it was REJECTED, captured shell-side. The
|
||||
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
||||
full page reload loses it — a real system would persist the rejected revision. */
|
||||
private rejectionSnapshot = signal<Brief | null>(null);
|
||||
@@ -75,7 +81,7 @@ export class BriefStore implements PendingSave {
|
||||
);
|
||||
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
|
||||
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||
/** The org template the letter renders with. Server-owned appearance data,
|
||||
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||
stays untouched by design). Set from every server view that carries it. */
|
||||
readonly orgTemplate = signal<OrgTemplate | null>(null);
|
||||
@@ -93,11 +99,11 @@ export class BriefStore implements PendingSave {
|
||||
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||
|
||||
private brief = computed<Brief | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
return s.tag === 'Loaded' ? s.brief : null;
|
||||
});
|
||||
|
||||
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||
@@ -109,7 +115,7 @@ export class BriefStore implements PendingSave {
|
||||
|
||||
private decisions = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.decisions : null;
|
||||
return s.tag === 'Loaded' ? s.decisions : null;
|
||||
});
|
||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||
@@ -119,13 +125,40 @@ export class BriefStore implements PendingSave {
|
||||
return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics());
|
||||
});
|
||||
|
||||
/** True once a 404-triggered recovery has been attempted (CQ-007's expand
|
||||
half — see `recoverFromMissingBrief`). This is the structural once-only bound:
|
||||
a repeated 404 falls straight to the `error` branch below and can never reach
|
||||
`adapter.reset()` a second time, regardless of how many times `load()` runs. */
|
||||
private hasRecoveredFromMissingBrief = false;
|
||||
|
||||
async load() {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.history.clear();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
this.applyLoadedView(r.value);
|
||||
} else if (r.error.tag === 'notFound' && !this.hasRecoveredFromMissingBrief) {
|
||||
this.hasRecoveredFromMissingBrief = true;
|
||||
await this.recoverFromMissingBrief();
|
||||
} else {
|
||||
const reason = r.error.tag === 'notFound' ? BRIEF_LOAD_FAILED : r.error.reason;
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason });
|
||||
}
|
||||
}
|
||||
|
||||
private applyLoadedView(view: BriefView) {
|
||||
this.orgTemplate.set(view.orgTemplate);
|
||||
this.caseContext.set(view.caseContext);
|
||||
this.history.clear();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...view });
|
||||
}
|
||||
|
||||
/** `GET /brief` 404'd — no brief exists yet for this owner. Recover by calling the
|
||||
existing `reset()` command directly (the same POST `resetDemo()` uses) and
|
||||
applying whatever it returns; this NEVER calls `load()` again, so a second 404
|
||||
(e.g. `reset()` itself failing) cannot loop back into this method. */
|
||||
private async recoverFromMissingBrief() {
|
||||
const r = await this.adapter.reset();
|
||||
if (r.ok) {
|
||||
this.applyLoadedView(r.value);
|
||||
} else {
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
@@ -153,7 +186,7 @@ export class BriefStore implements PendingSave {
|
||||
}
|
||||
private restore(step: (current: Brief) => Brief | undefined) {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded') return;
|
||||
if (s.tag !== 'Loaded') return;
|
||||
const target = step(s.brief);
|
||||
if (target === undefined) return;
|
||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||
@@ -167,7 +200,7 @@ export class BriefStore implements PendingSave {
|
||||
}
|
||||
|
||||
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
||||
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
|
||||
// the shared helper; `flushSave` below is the store-specific write + save-state.
|
||||
private debouncedSave = createDebouncedSave({
|
||||
canSave: () => this.canEdit(),
|
||||
flush: () => this.flushSave(),
|
||||
@@ -183,31 +216,33 @@ export class BriefStore implements PendingSave {
|
||||
if (r.ok) {
|
||||
this.saveState.set({ tag: 'Saved' });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
// The autosave failure legitimately surfaces in two places: the small save
|
||||
// indicator below (kept as-is) and the action error line (RD-12).
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
this.saveState.set({ tag: 'Error' });
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
|
||||
/** Retry a failed autosave — reuses the existing flush path, no new state. */
|
||||
retrySave() {
|
||||
void this.flushSave();
|
||||
}
|
||||
|
||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||
async resetDemo() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.reset();
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.history.clear();
|
||||
this.rejectionSnapshot.set(null);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,27 +252,31 @@ export class BriefStore implements PendingSave {
|
||||
send = () => this.transition(() => this.adapter.send());
|
||||
|
||||
/** Explicit action, never a live re-render (PRD §8): opens the server-composed
|
||||
letter in a new tab. ponytail: the blob URL is never revoked — it's cheap and
|
||||
the tab outlives this call; not worth a teardown hook for a POC. */
|
||||
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
||||
object URL is never revoked. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
/** Reveal the masked case BIG-nummer (PRD-0002 §5c). Server re-checks the capability
|
||||
+ step-up and audits the attempt; on success we swap the masked value in the
|
||||
already-loaded caseContext (a field update, not a reload). The step-up gesture
|
||||
itself is the UI's concern — this command just runs the audited server call. */
|
||||
itself is the UI's concern (`behandel-scherm.component.ts`'s `onReveal()` confirm)
|
||||
— this command is only reachable once that gesture has happened, so it is the one
|
||||
that tells the adapter to send `X-Step-Up` (BIO-006a: the adapter itself no longer
|
||||
hardcodes the header). */
|
||||
async revealBigNummer() {
|
||||
const r = await this.revealAdapter.reveal();
|
||||
const r = await this.revealAdapter.reveal(true);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
||||
@@ -246,15 +285,15 @@ export class BriefStore implements PendingSave {
|
||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.applyServerStatus(r.value);
|
||||
}
|
||||
|
||||
@@ -272,7 +311,7 @@ export class BriefStore implements PendingSave {
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||
break;
|
||||
case 'rejected':
|
||||
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
|
||||
// Capture the letter as-rejected for the resubmission diff. This is the
|
||||
// "before" snapshot the approver later compares against.
|
||||
this.rejectionSnapshot.set(brief);
|
||||
this.store.dispatch({
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Result, ok } from '@shared/kernel/fp';
|
||||
import { BLOB_PRESENTER, BlobPresenter } from '@shared/application/blob-presenter';
|
||||
import { UploadAdapter } from '@shared/infrastructure/upload.adapter';
|
||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||
import { OrgTemplate, OrgTemplateAdminView, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { OrgTemplateStore } from './org-template.store';
|
||||
|
||||
const template: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
orgName: 'CIBG — Registers',
|
||||
returnAddress: 'Postbus 00000\n2500 AA Den Haag',
|
||||
footerContact: 'info@voorbeeld.example',
|
||||
footerLegal: 'KvK 00000000',
|
||||
signatureName: 'A. de Vries',
|
||||
signatureRole: 'Hoofd Registratie',
|
||||
signatureClosing: 'Met vriendelijke groet,',
|
||||
margins: { topMm: 25, rightMm: 25, bottomMm: 25, leftMm: 25 },
|
||||
version: 1,
|
||||
};
|
||||
|
||||
const view: OrgTemplateAdminView = {
|
||||
draft: template,
|
||||
publishedVersion: 1,
|
||||
history: [],
|
||||
unsentBriefs: 0,
|
||||
};
|
||||
|
||||
const subOrgs: SubOrgSummary[] = [
|
||||
{ subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 },
|
||||
];
|
||||
|
||||
/** A recording fake of BLOB_PRESENTER (TE-006) — records every call instead of
|
||||
touching the DOM, so a spec can assert a command's success path directly. */
|
||||
function fakeBlobPresenter() {
|
||||
const opened: Blob[] = [];
|
||||
const presenter: BlobPresenter = {
|
||||
open: (blob) => opened.push(blob),
|
||||
download: () => {
|
||||
throw new Error('not used by OrgTemplateStore');
|
||||
},
|
||||
};
|
||||
return { presenter, opened };
|
||||
}
|
||||
|
||||
/** A no-op categories resource: the logo-upload sub-state is untouched by these
|
||||
tests, so 'idle' (never resolved) keeps the constructor effect from dispatching. */
|
||||
function fakeCategoriesResource(): ReturnType<UploadAdapter['categoriesResource']> {
|
||||
const fake = { status: () => 'idle' as const, value: () => undefined };
|
||||
return fake as unknown as ReturnType<UploadAdapter['categoriesResource']>;
|
||||
}
|
||||
|
||||
function setup(
|
||||
adapter: Partial<OrgTemplateAdapter>,
|
||||
blobPresenter: BlobPresenter,
|
||||
): OrgTemplateStore {
|
||||
const uploadAdapter: Partial<UploadAdapter> = {
|
||||
categoriesResource: () => fakeCategoriesResource(),
|
||||
};
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: OrgTemplateAdapter, useValue: adapter },
|
||||
{ provide: UploadAdapter, useValue: uploadAdapter },
|
||||
{ provide: UploadShellService, useValue: {} },
|
||||
{ provide: BLOB_PRESENTER, useValue: blobPresenter },
|
||||
],
|
||||
});
|
||||
return TestBed.inject(OrgTemplateStore);
|
||||
}
|
||||
|
||||
// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw
|
||||
// window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. ---
|
||||
|
||||
describe('OrgTemplateStore.proefbrief', () => {
|
||||
it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => {
|
||||
// Given a loaded sub-org template.
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const blob = new Blob(['<html></html>'], { type: 'text/html' });
|
||||
const store = setup(
|
||||
{
|
||||
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||
proefbrief: (): Promise<Result<string, Blob>> => Promise.resolve(ok(blob)),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
|
||||
// When proefbrief() is called...
|
||||
await store.proefbrief();
|
||||
|
||||
// Then the presenter receives exactly the rendered blob, and no error surfaces.
|
||||
expect(opened).toEqual([blob]);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces the error without opening a tab on failure', async () => {
|
||||
// Given a loaded sub-org template whose proefbrief call fails server-side.
|
||||
const { presenter, opened } = fakeBlobPresenter();
|
||||
const store = setup(
|
||||
{
|
||||
list: (): Promise<Result<string, SubOrgSummary[]>> => Promise.resolve(ok(subOrgs)),
|
||||
load: (): Promise<Result<string, OrgTemplateAdminView>> => Promise.resolve(ok(view)),
|
||||
proefbrief: (): Promise<Result<string, Blob>> =>
|
||||
Promise.resolve({ ok: false, error: 'mislukt' }),
|
||||
},
|
||||
presenter,
|
||||
);
|
||||
await store.load();
|
||||
|
||||
// When proefbrief() is called...
|
||||
await store.proefbrief();
|
||||
|
||||
// Then the presenter is never reached and the error is surfaced.
|
||||
expect(opened).toHaveLength(0);
|
||||
expect(store.lastError()).toBe('mislukt');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import {
|
||||
OrgTemplateActionState,
|
||||
OrgTemplateMsg,
|
||||
OrgTemplateState,
|
||||
initial,
|
||||
@@ -20,14 +20,15 @@ import {
|
||||
} from '@brief/domain/org-template.machine';
|
||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||
|
||||
/**
|
||||
* Root singleton for the admin org-template editor (WP-26). The Elm machine owns the
|
||||
* Root singleton for the admin org-template editor. The Elm machine owns the
|
||||
* editable draft; commands here do the debounced save, publish (impact-confirm),
|
||||
* rollback and proefbrief, then dispatch the outcome — the reducer stays pure. The
|
||||
* logo upload reuses the shared upload transport; its completion mutates the draft
|
||||
@@ -38,6 +39,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
private adapter = inject(OrgTemplateAdapter);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private shell = inject(UploadShellService);
|
||||
private blobPresenter = inject(BLOB_PRESENTER);
|
||||
private store = createStore<OrgTemplateState, OrgTemplateMsg>(initial, reduce);
|
||||
|
||||
readonly model = this.store.model;
|
||||
@@ -45,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
|
||||
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||
readonly selectedSubOrgId = signal<string | null>(null);
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
/** The one-shot action lifecycle and the publish impact-confirm gate now live on
|
||||
the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
|
||||
plain `computed`s so the render seam (the editor organism's `input()`s, the
|
||||
page template) keeps a byte-identical boolean/string API. */
|
||||
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
|
||||
readonly busy = computed(() => this.action().tag === 'Busy');
|
||||
readonly lastError = computed(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
const a = this.action();
|
||||
return a.tag === 'Failed' ? a.error : null;
|
||||
});
|
||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
|
||||
Before RD-13 this was an independent boolean, so it could be `true` at the same
|
||||
time `busy` was `true` — representable and meaningless. It is now derived from
|
||||
the same union `busy` reads, so the two are mutually exclusive by construction. */
|
||||
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||
readonly pendingPublish = signal(false);
|
||||
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||
|
||||
private loaded = computed<LoadedState | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : null;
|
||||
return s.tag === 'Loaded' ? s : null;
|
||||
});
|
||||
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||
@@ -72,6 +80,9 @@ export class OrgTemplateStore implements PendingSave {
|
||||
return id ? this.uploadAdapter.contentUrl(id) : null;
|
||||
});
|
||||
|
||||
/** Preview/download link for any completed upload in the editor's document list. */
|
||||
readonly previewUrlFor = (documentId: string): string | undefined => uploadContentUrl(documentId);
|
||||
|
||||
/** Client-side mirror of the server rules (`OrgTemplateRules`) for instant feedback;
|
||||
the server re-validates and stays the authority — publish is gated on this. */
|
||||
readonly draftValid = computed(() => {
|
||||
@@ -96,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
// the length guard makes it idempotent (no dispatch loop).
|
||||
effect(() => {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
|
||||
const status = this.categoriesRes.status();
|
||||
if (status === 'resolved' || status === 'local')
|
||||
this.dispatchUpload({
|
||||
@@ -140,7 +151,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
this.debouncedSave.schedule();
|
||||
}
|
||||
|
||||
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
|
||||
// 600ms debounced autosave (same idiom as BriefStore). Timer mechanics live in the
|
||||
// shared helper; `flushSave` below is the store-specific write + save-state.
|
||||
private debouncedSave = createDebouncedSave({
|
||||
canSave: () => this.loaded() !== null,
|
||||
@@ -160,61 +171,65 @@ export class OrgTemplateStore implements PendingSave {
|
||||
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||
} else {
|
||||
this.saveState.set({ tag: 'Error' });
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||
|
||||
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
|
||||
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
|
||||
// construction (the reducer's own guard), so behaviour is unchanged.
|
||||
requestPublish() {
|
||||
this.pendingPublish.set(true);
|
||||
this.store.dispatch({ tag: 'PublishRequested' });
|
||||
}
|
||||
cancelPublish() {
|
||||
this.pendingPublish.set(false);
|
||||
this.store.dispatch({ tag: 'PublishCancelled' });
|
||||
}
|
||||
async confirmPublish() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.pendingPublish.set(false);
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
// ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
|
||||
// Busy are never simultaneously true (RD-13).
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||
const r = await this.adapter.publish(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||
}
|
||||
|
||||
async rollback(version: number) {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||
}
|
||||
|
||||
async proefbrief() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // the proefbrief renders the server's draft
|
||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
window.open(URL.createObjectURL(r.value), '_blank');
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
// --- logo upload (reuses the shared upload transport; single `org-logo` file) ---
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Brief, LetterBlock, allBlocks } from './brief';
|
||||
|
||||
/**
|
||||
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
|
||||
* teaching payload of WP-27: because state is one value, "what changed since the letter
|
||||
* teaching payload here: because state is one value, "what changed since the letter
|
||||
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
|
||||
*
|
||||
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Besluit, Brief, BriefDecisions, BriefStatus, LibraryPassage } from './b
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { BriefState, reduce } from './brief.machine';
|
||||
import { givenBrief } from './brief.testing';
|
||||
|
||||
const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'naam', label: 'Naam', autoResolvable: true },
|
||||
@@ -64,18 +65,18 @@ const decisions: BriefDecisions = {
|
||||
canRevealBigNummer: true,
|
||||
};
|
||||
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
// Replays a real `BriefLoaded` message through the real `reduce` (ADR-0006 §2)
|
||||
// instead of hand-assembling the 'loaded' state directly.
|
||||
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState =>
|
||||
givenBrief({
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
|
||||
const passageIds = (s: BriefState, key: string) =>
|
||||
sectionBlocks(s, key)
|
||||
@@ -91,12 +92,12 @@ describe('brief.machine reduce', () => {
|
||||
availablePassages: [],
|
||||
decisions,
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
).toBe('Loaded');
|
||||
});
|
||||
|
||||
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
tag: 'failed',
|
||||
tag: 'Failed',
|
||||
reason: 'x',
|
||||
});
|
||||
});
|
||||
@@ -128,12 +129,12 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('BesluitSelected deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('intro', 'kern'); // shared → offered for any besluit
|
||||
const st: BriefState = {
|
||||
tag: 'loaded',
|
||||
const st = givenBrief({
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [passage],
|
||||
decisions,
|
||||
};
|
||||
});
|
||||
const s = reduce(st, besluit('positief'));
|
||||
// Mutate the source passage object after composition.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
@@ -209,7 +210,7 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'graag aanpassen',
|
||||
});
|
||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -219,7 +220,7 @@ describe('brief.machine reduce', () => {
|
||||
// fill the required section via the besluit, then submit
|
||||
const filled = reduce(loaded(), besluit('positief'));
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
submittedAt: 't',
|
||||
@@ -231,7 +232,7 @@ describe('brief.machine reduce', () => {
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
@@ -247,7 +248,7 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'nee',
|
||||
decisions,
|
||||
});
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't2',
|
||||
@@ -261,7 +262,40 @@ describe('brief.machine reduce', () => {
|
||||
// send from submitted is a no-op
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
});
|
||||
|
||||
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
|
||||
|
||||
it('ActionStarted moves a loaded brief to Busy', () => {
|
||||
const s = reduce(loaded(), { tag: 'ActionStarted' });
|
||||
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
|
||||
});
|
||||
|
||||
it('ActionFailed carries the error', () => {
|
||||
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
|
||||
});
|
||||
|
||||
it('ActionFinished returns to Idle', () => {
|
||||
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||
const s = reduce(busy, { tag: 'ActionFinished' });
|
||||
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
|
||||
});
|
||||
|
||||
it('BriefLoaded resets a stale action error to Idle', () => {
|
||||
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||
const reloaded = reduce(failed, {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: lib,
|
||||
decisions,
|
||||
});
|
||||
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
|
||||
});
|
||||
|
||||
it('an action message is a no-op when the brief is not loaded', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
|
||||
});
|
||||
|
||||
it('a status transition replaces decisions with the fresh server value', () => {
|
||||
@@ -279,10 +313,10 @@ describe('brief.machine reduce', () => {
|
||||
at: 't2',
|
||||
decisions: staleApprover,
|
||||
});
|
||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
|
||||
});
|
||||
});
|
||||
|
||||
function initialLoading(): BriefState {
|
||||
return { tag: 'loading' };
|
||||
return { tag: 'Loading' };
|
||||
}
|
||||
|
||||
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
|
||||
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
||||
*/
|
||||
|
||||
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
|
||||
owned by the reducer instead of an imperative store-level signal (RD-12). */
|
||||
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||
|
||||
export type BriefState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'Loading' }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
tag: 'Loaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
decisions: BriefDecisions;
|
||||
action: BriefActionState;
|
||||
}
|
||||
| { tag: 'failed'; reason: string };
|
||||
| { tag: 'Failed'; reason: string };
|
||||
|
||||
export const initial: BriefState = { tag: 'loading' };
|
||||
export const initial: BriefState = { tag: 'Loading' };
|
||||
|
||||
export type BriefMsg =
|
||||
| {
|
||||
@@ -65,7 +70,10 @@ export type BriefMsg =
|
||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||
| { tag: 'Seed'; state: BriefState };
|
||||
| { tag: 'Seed'; state: BriefState }
|
||||
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
|
||||
| { tag: 'ActionFinished' } // it completed successfully
|
||||
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||
|
||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||
function isEditable(status: BriefStatus): boolean {
|
||||
@@ -110,7 +118,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
|
||||
|
||||
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
||||
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
|
||||
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
|
||||
let brief = f(s.brief);
|
||||
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||
return { ...s, brief };
|
||||
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
switch (m.tag) {
|
||||
case 'BriefLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
tag: 'Loaded',
|
||||
brief: m.brief,
|
||||
availablePassages: m.availablePassages,
|
||||
decisions: m.decisions,
|
||||
// A fresh load clears a stale action error rather than letting it outlive
|
||||
// the reload (RD-12, decision 4).
|
||||
action: { tag: 'Idle' },
|
||||
};
|
||||
case 'BriefLoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
return { tag: 'Failed', reason: m.reason };
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
|
||||
@@ -203,7 +214,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||
case 'BesluitSelected':
|
||||
return withEdit(s, (b) =>
|
||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
||||
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
|
||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||
: b,
|
||||
);
|
||||
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||
|
||||
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
|
||||
// nothing to attach the action state to otherwise.
|
||||
case 'ActionStarted':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||
case 'ActionFinished':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||
case 'ActionFailed':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
@@ -275,6 +295,6 @@ function transition(
|
||||
decisions: BriefDecisions,
|
||||
guard: (b: Brief) => boolean = () => true,
|
||||
): BriefState {
|
||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { given } from '@shared/testing/machine';
|
||||
import { reduce, initial } from './brief.machine';
|
||||
|
||||
/** Replay real `BriefMsg`s through the real `reduce`, starting from `initial`.
|
||||
Pure TS only (no Angular) — domain/ stays framework-free (dependency-cruiser
|
||||
`domain-is-pure`). See `libs/shared/src/testing/machine.ts`. */
|
||||
export const givenBrief = given(reduce, initial);
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import { OrgTemplate, OrgTemplateAdminView } from './org-template';
|
||||
import { OrgTemplateState, reduce } from './org-template.machine';
|
||||
import { DocumentCategory } from '@shared/upload/upload.machine';
|
||||
import { DocumentCategory } from '@shared/domain/upload.machine';
|
||||
|
||||
const template: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
|
||||
});
|
||||
|
||||
const loaded = (): OrgTemplateState =>
|
||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
|
||||
const logoCategory: DocumentCategory = {
|
||||
categoryId: 'org-logo',
|
||||
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
|
||||
|
||||
describe('org-template.machine', () => {
|
||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||
const s = expectTag(loaded(), 'loaded');
|
||||
const s = expectTag(loaded(), 'Loaded');
|
||||
expect(s.draft.orgName).toBe('CIBG');
|
||||
expect(s.subOrgId).toBe('cibg-registers');
|
||||
expect(s.unsentBriefs).toBe(2);
|
||||
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
|
||||
});
|
||||
|
||||
it('LoadFailed carries the reason', () => {
|
||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
|
||||
});
|
||||
|
||||
it('FieldEdited edits the draft and marks dirty', () => {
|
||||
const s = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
||||
expect(s.dirty).toBe(true);
|
||||
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
|
||||
it('MarginEdited edits one edge and marks dirty', () => {
|
||||
const s = expectTag(
|
||||
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(s.draft.margins.topMm).toBe(40);
|
||||
expect(s.draft.margins.leftMm).toBe(20);
|
||||
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
|
||||
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||
const edited = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
|
||||
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
|
||||
expect(s.dirty).toBe(false);
|
||||
expect(s.draft.orgName).toBe('X');
|
||||
});
|
||||
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
|
||||
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||
const editing = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
const savedDraft = editing.draft;
|
||||
// a further edit changes the draft reference before the save resolves
|
||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
|
||||
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
|
||||
expect(s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('edits are no-ops in non-loaded states', () => {
|
||||
expect(
|
||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
).toEqual({
|
||||
tag: 'loading',
|
||||
tag: 'Loading',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(done.draft.logoDocumentId).toBe('doc-1');
|
||||
expect(done.dirty).toBe(true);
|
||||
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(removed.draft.logoDocumentId).toBeUndefined();
|
||||
expect(removed.dirty).toBe(true);
|
||||
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
|
||||
tag: 'DraftLoaded',
|
||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(switched.upload.categories).toHaveLength(1);
|
||||
expect(switched.upload.uploads).toHaveLength(0);
|
||||
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||
});
|
||||
|
||||
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
|
||||
|
||||
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
|
||||
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
|
||||
});
|
||||
|
||||
it('PublishCancelled returns to Idle', () => {
|
||||
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
|
||||
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
|
||||
expect(s.action).toEqual({ tag: 'Idle' });
|
||||
});
|
||||
|
||||
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
|
||||
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||
expect(confirming.action.tag).toBe('ConfirmingPublish');
|
||||
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
|
||||
expect(s.action).toEqual({ tag: 'Busy' });
|
||||
});
|
||||
|
||||
it('ActionFailed carries the error', () => {
|
||||
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
|
||||
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
|
||||
});
|
||||
|
||||
it('DraftLoaded resets a stale action error to Idle', () => {
|
||||
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
|
||||
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
|
||||
expect(s.action).toEqual({ tag: 'Idle' });
|
||||
});
|
||||
|
||||
it('an action message is a no-op when the template is not loaded', () => {
|
||||
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
|
||||
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
|
||||
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
|
||||
tag: 'Loading',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from './org-template';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/upload/upload.machine';
|
||||
import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/domain/upload.machine';
|
||||
|
||||
/**
|
||||
* The admin org-template editor as one Elm-style machine (WP-26, PRD Brief v2 §5) —
|
||||
* The admin org-template editor as one Elm-style machine (PRD Brief v2 §5) —
|
||||
* the same idiom as the wizards. The DRAFT org template is form state (edited in
|
||||
* place on the canvas); publish/rollback are effects that come back as `DraftLoaded`.
|
||||
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||
*
|
||||
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
|
||||
* gate as ONE four-variant union, replacing two independent store-level signals
|
||||
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
|
||||
* === true` was representable and meaningless — the confirm dialog could show while a
|
||||
* publish was already in flight. A single field with one tag at a time makes that
|
||||
* combination unrepresentable.
|
||||
*/
|
||||
|
||||
/** The org-identity text fields editable directly on the letter canvas. */
|
||||
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
|
||||
| 'signatureRole'
|
||||
| 'signatureClosing';
|
||||
|
||||
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
|
||||
impact-confirm gate, owned by the reducer instead of two independent store-level
|
||||
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
|
||||
"confirming a publish while one is already in flight" is unrepresentable — no
|
||||
state can ever carry both at once. */
|
||||
export type OrgTemplateActionState =
|
||||
| { tag: 'Idle' }
|
||||
| { tag: 'ConfirmingPublish' }
|
||||
| { tag: 'Busy' }
|
||||
| { tag: 'Failed'; error: string };
|
||||
|
||||
export type OrgTemplateState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Failed'; reason: string }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
tag: 'Loaded';
|
||||
subOrgId: string;
|
||||
draft: OrgTemplate;
|
||||
publishedVersion: number;
|
||||
@@ -34,9 +52,10 @@ export type OrgTemplateState =
|
||||
dirty: boolean;
|
||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||
upload: UploadState;
|
||||
action: OrgTemplateActionState;
|
||||
};
|
||||
|
||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||
export const initial: OrgTemplateState = { tag: 'Loading' };
|
||||
|
||||
export type OrgTemplateMsg =
|
||||
| { tag: 'Loading' }
|
||||
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
|
||||
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||
| { tag: 'Upload'; msg: UploadMsg };
|
||||
| { tag: 'Upload'; msg: UploadMsg }
|
||||
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
|
||||
| { tag: 'PublishCancelled' } // closes it without publishing
|
||||
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
|
||||
| { tag: 'ActionFinished' } // it completed successfully
|
||||
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||
|
||||
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
}
|
||||
|
||||
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
return { tag: 'Loading' };
|
||||
case 'LoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
return { tag: 'Failed', reason: m.reason };
|
||||
case 'DraftLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
tag: 'Loaded',
|
||||
subOrgId: m.view.draft.subOrgId,
|
||||
draft: m.view.draft,
|
||||
publishedVersion: m.view.publishedVersion,
|
||||
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
||||
dirty: false,
|
||||
// Keep the loaded logo category across sub-org switches (it's the same
|
||||
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
// A fresh load clears a stale action error rather than letting it outlive
|
||||
// the reload (RD-13, same as brief's RD-12).
|
||||
action: { tag: 'Idle' },
|
||||
};
|
||||
case 'FieldEdited':
|
||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||
case 'MarginEdited':
|
||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||
case 'DraftSaved':
|
||||
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
case 'Upload': {
|
||||
if (s.tag !== 'loaded') return s;
|
||||
if (s.tag !== 'Loaded') return s;
|
||||
const upload = reduceUpload(s.upload, m.msg);
|
||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||
if (m.msg.type === 'UploadComplete')
|
||||
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
||||
}
|
||||
return { ...s, upload };
|
||||
}
|
||||
|
||||
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
|
||||
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
|
||||
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
|
||||
// what makes the two mutually exclusive by construction — not by convention.
|
||||
case 'PublishRequested':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
|
||||
case 'PublishCancelled':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||
case 'ActionStarted':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||
case 'ActionFinished':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||
case 'ActionFailed':
|
||||
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||
|
||||
default:
|
||||
return assertNever(m);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* The organization template (Brief v2 PRD §3, WP-23/24): the SECOND template axis —
|
||||
* The organization template (Brief v2 PRD §3): the SECOND template axis —
|
||||
* appearance/identity per sub-organization (letterhead, footer, signature, margins).
|
||||
* Orthogonal to the case-type template (sections + placeholders); the two only meet
|
||||
* at render time, on the letter canvas. Server-owned: the FE renders it verbatim,
|
||||
* never edits it here (the admin editor is WP-26).
|
||||
* never edits it here (the admin editor does).
|
||||
*/
|
||||
|
||||
export interface Margins {
|
||||
@@ -30,7 +30,7 @@ export interface OrgTemplate {
|
||||
readonly version: number;
|
||||
}
|
||||
|
||||
// --- admin editor (WP-26) ---
|
||||
// --- admin editor ---
|
||||
|
||||
/** A published snapshot in the version history: who is faked, `publishedAt` is real. */
|
||||
export interface OrgTemplateVersion {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Brief } from './brief';
|
||||
|
||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||
while editing (content itself is not the admin's to change). Production content —
|
||||
the letter the org-template editor previews — not a test fixture, so it lives here
|
||||
rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production
|
||||
rule forbids production code from reaching any `*.testing.ts`). */
|
||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
briefId: 'VOORBEELD-0001',
|
||||
beroep: 'arts',
|
||||
templateId: 'sample',
|
||||
drafterId: 'sample',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'body',
|
||||
title: 'Voorbeeldinhoud',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'sample-1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import {
|
||||
ApiClient,
|
||||
BriefDecisionsDto,
|
||||
@@ -33,8 +34,13 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric
|
||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||
* uses FLAT unions (a `type`/`tag` string + nullable fields, the repo convention);
|
||||
* the `parse*` boundary narrows them into the domain's proper discriminated unions
|
||||
* and rejects malformed shapes. Mutations go through `runSubmit` (ProblemDetails →
|
||||
* error string), then parse the returned brief.
|
||||
* and rejects malformed shapes. Every mutation folds through `runSubmit`
|
||||
* (ProblemDetails → error string, plus the Idempotency-Key mint), then parses the
|
||||
* returned brief. `load` (the only read) does its own try/catch instead of the
|
||||
* shared `runResult` fold, because it needs one extra bit `runResult` throws away:
|
||||
* whether the failure was an HTTP 404 (see `BriefLoadFailure` — CQ-007's
|
||||
* expand half). Today's backend never 404s `GET /brief`, so the
|
||||
* `notFound` branch is unreached until it does; this adapter is ready in advance.
|
||||
*/
|
||||
|
||||
export interface BriefView {
|
||||
@@ -45,16 +51,39 @@ export interface BriefView {
|
||||
readonly caseContext: CaseContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why `load()` did not return a brief. `notFound` is a bare HTTP 404 — kept
|
||||
* distinct from every other failure so `BriefStore.load()` can tolerate it (call
|
||||
* `reset()` instead of showing an error banner) without conflating it with a real
|
||||
* failure. See the class docstring above.
|
||||
*/
|
||||
export type BriefLoadFailure =
|
||||
{ readonly tag: 'notFound' } | { readonly tag: 'error'; readonly reason: string };
|
||||
|
||||
export const BRIEF_LOAD_FAILED = $localize`:@@brief.load.failed:De brief kon niet worden geladen.`;
|
||||
export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
|
||||
/** True when the thrown value carries an HTTP 404 status — matches both the
|
||||
generic `SwaggerException` (today's shape, since `GET /brief` declares no 404
|
||||
response yet) and a parsed `ProblemDetails` (RFC 7807 `status`, the shape once
|
||||
the endpoint gets a documented 404 response). */
|
||||
function isHttpNotFound(e: unknown): boolean {
|
||||
return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async load(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefGET(), BRIEF_LOAD_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
async load(): Promise<Result<BriefLoadFailure, BriefView>> {
|
||||
try {
|
||||
const dto = await this.client.briefGET();
|
||||
const parsed = parseBriefView(dto);
|
||||
return parsed.ok ? ok(parsed.value) : err({ tag: 'error', reason: parsed.error });
|
||||
} catch (e) {
|
||||
if (isHttpNotFound(e)) return err({ tag: 'notFound' });
|
||||
return err({ tag: 'error', reason: problemDetail(e, BRIEF_LOAD_FAILED) });
|
||||
}
|
||||
}
|
||||
|
||||
async save(sections: readonly LetterSection[]): Promise<Result<string, BriefView>> {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { errorMessage, PREVIEW_FAILED, LetterPreviewAdapter } from './letter-preview.adapter';
|
||||
|
||||
// Minimal Response stand-in — errorMessage only calls `.json()`. Avoids stubbing
|
||||
// globalThis.fetch to reach this trust boundary (TE-002).
|
||||
const fakeResponse = (body: unknown): Response =>
|
||||
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||
|
||||
describe('errorMessage (TE-002 trust boundary)', () => {
|
||||
it('surfaces the ProblemDetails detail when present', async () => {
|
||||
expect(await errorMessage(fakeResponse({ detail: 'Geen toegang.', status: 403 }))).toBe(
|
||||
'Geen toegang.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to PREVIEW_FAILED when the body has no detail', async () => {
|
||||
expect(await errorMessage(fakeResponse({ status: 500 }))).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
|
||||
it('falls back to PREVIEW_FAILED when the body is not JSON', async () => {
|
||||
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||
expect(await errorMessage(res)).toBe(PREVIEW_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||
// production build. There is no ambient type for it in app code, so this is
|
||||
// accessed through an untyped bag rather than a `declare const`.
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
const originalNgDevMode = globals['ngDevMode'];
|
||||
const setDevMode = (on: boolean) => {
|
||||
globals['ngDevMode'] = on;
|
||||
};
|
||||
|
||||
describe('LetterPreviewAdapter.preview (BIO-012)', () => {
|
||||
const okResponse = () =>
|
||||
({ ok: true, blob: () => Promise.resolve(new Blob()) }) as unknown as Response;
|
||||
|
||||
afterEach(() => {
|
||||
globals['ngDevMode'] = originalNgDevMode;
|
||||
vi.unstubAllGlobals();
|
||||
history.pushState({}, '', '/');
|
||||
sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('sends no X-Role/X-Subject headers outside isDevMode()', async () => {
|
||||
setDevMode(false);
|
||||
history.pushState({}, '', '/?subject=111222333');
|
||||
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await new LetterPreviewAdapter().preview();
|
||||
|
||||
expect(fetchSpy.mock.calls[0][1].headers).toEqual({});
|
||||
});
|
||||
|
||||
it('sends X-Role (and X-Subject when known) under isDevMode()', async () => {
|
||||
setDevMode(true);
|
||||
history.pushState({}, '', '/?subject=111222333');
|
||||
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await new LetterPreviewAdapter().preview();
|
||||
|
||||
const headers = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headers['X-Role']).toBeDefined();
|
||||
expect(headers['X-Subject']).toBe('111222333');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, isDevMode } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { currentSubject } from '@shared/infrastructure/subject';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
@@ -12,15 +13,42 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning
|
||||
* `/brief/preview` returns `text/html`, not JSON, and is `.ExcludeFromDescription()`'d
|
||||
* to keep the NSwag-generated client JSON-only (same seam as uploads) — so this is a
|
||||
* hand-written fetch, not the `ApiClient`. That also means it bypasses `HttpClient`'s
|
||||
* `roleInterceptor`, so `X-Role` is set here explicitly.
|
||||
* `roleInterceptor` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set
|
||||
* here explicitly (without `X-Subject` this always previewed
|
||||
* `DocumentStore.DemoOwner`'s letter regardless of who was actually logged in). Both are
|
||||
* dev-only identity stand-ins (`role.ts`/`subject.ts`) and are sent only under
|
||||
* `isDevMode()`, mirroring how the interceptors themselves are only registered in dev
|
||||
* (`app.config.ts`) — a production build sends neither header from this call (BIO-012).
|
||||
*
|
||||
* `cache: 'no-store'`: the endpoint has no `Cache-Control`, only a CORS-driven
|
||||
* `Vary: Origin`, and its content changes at the SAME URL as the letter moves
|
||||
* draft → sent. Explicitly bypassing the HTTP cache is the correct default for any
|
||||
* mutable resource served under one unversioned URL — independent of the
|
||||
* identity work above, and not a complete fix by itself: see the KNOWN GAP note below.
|
||||
*
|
||||
* KNOWN GAP (not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`,
|
||||
* this repo's own e2e run against a real backend observed this endpoint's SENT
|
||||
* response still carrying the draft watermark, even though (a) the outgoing request
|
||||
* carried the correct `X-Subject`, and (b) `curl` against the same backend at the
|
||||
* same moment correctly returned the frozen, unwatermarked archive. `cache: 'no-store'`
|
||||
* did not change the outcome, so it is very unlikely a client-side caching artifact —
|
||||
* it looks like a genuine backend-side staleness/race in `BriefStore`'s SQLite-backed
|
||||
* read path, reproducible for MULTIPLE distinct owners and NOT reproducible for
|
||||
* `DemoOwner`, which needs backend-side investigation (out of this file's scope —
|
||||
* see `e2e/brief-v2.spec.ts`'s header comment, which keeps that spec on the shared
|
||||
* `zorgverlener` identity until this is root-caused).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LetterPreviewAdapter {
|
||||
async preview(): Promise<Result<string, Blob>> {
|
||||
let res: Response;
|
||||
try {
|
||||
const subject = currentSubject();
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/preview`, {
|
||||
headers: { 'X-Role': currentRole() },
|
||||
cache: 'no-store',
|
||||
headers: isDevMode()
|
||||
? { 'X-Role': currentRole(), ...(subject ? { 'X-Subject': subject } : {}) }
|
||||
: {},
|
||||
});
|
||||
} catch {
|
||||
return err(PREVIEW_FAILED);
|
||||
@@ -30,7 +58,9 @@ export class LetterPreviewAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
/** Trust boundary (TE-002): maps a non-OK response to a message. Exported so a spec
|
||||
can call it directly instead of stubbing `globalThis.fetch`. */
|
||||
export async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), PREVIEW_FAILED);
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { OrgTemplateAdminViewDto, OrgTemplateDto } from '@shared/infrastructure/api-client';
|
||||
import { parseOrgTemplateAdminView } from './org-template.adapter';
|
||||
import {
|
||||
parseOrgTemplateAdminView,
|
||||
proefbriefErrorMessage,
|
||||
PROEFBRIEF_FAILED,
|
||||
} from './org-template.adapter';
|
||||
|
||||
const draft: OrgTemplateDto = {
|
||||
subOrgId: 'cibg-registers',
|
||||
@@ -54,3 +58,25 @@ describe('parseOrgTemplateAdminView', () => {
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Minimal Response stand-in — proefbriefErrorMessage only calls `.json()`. Avoids
|
||||
// stubbing globalThis.fetch to reach this trust boundary (TE-002).
|
||||
const fakeResponse = (body: unknown): Response =>
|
||||
({ json: () => Promise.resolve(body) }) as unknown as Response;
|
||||
|
||||
describe('proefbriefErrorMessage (TE-002 trust boundary)', () => {
|
||||
it('surfaces the ProblemDetails detail when present', async () => {
|
||||
expect(
|
||||
await proefbriefErrorMessage(fakeResponse({ detail: 'Niet gevonden.', status: 404 })),
|
||||
).toBe('Niet gevonden.');
|
||||
});
|
||||
|
||||
it('falls back to PROEFBRIEF_FAILED when the body has no detail', async () => {
|
||||
expect(await proefbriefErrorMessage(fakeResponse({ status: 500 }))).toBe(PROEFBRIEF_FAILED);
|
||||
});
|
||||
|
||||
it('falls back to PROEFBRIEF_FAILED when the body is not JSON', async () => {
|
||||
const res = { json: () => Promise.reject(new Error('not json')) } as unknown as Response;
|
||||
expect(await proefbriefErrorMessage(res)).toBe(PROEFBRIEF_FAILED);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Injectable, inject, isDevMode } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { runSubmit } from '@shared/application/submit';
|
||||
import { runResult, runSubmit } from '@shared/application/submit';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
@@ -26,17 +26,20 @@ import { parseOrgTemplate } from '@brief/infrastructure/brief.adapter';
|
||||
* rollback go through the generated client (X-Role added by `roleInterceptor`);
|
||||
* `parse*` narrows the untrusted wire shape. The proefbrief is `text/html` and
|
||||
* `ExcludeFromDescription`'d — a hand-written fetch, same seam as `letter-preview.adapter`.
|
||||
* `X-Role` there is a dev-only identity stand-in (`role.ts`) and is sent only under
|
||||
* `isDevMode()`, mirroring `roleInterceptor`'s own dev-only registration — a production
|
||||
* build never sends it from this hand-written call either (BIO-012).
|
||||
*/
|
||||
|
||||
const FAILED = $localize`:@@orgTemplate.action.failed:De actie is niet gelukt. Probeer het later opnieuw.`;
|
||||
const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||
export const PROEFBRIEF_FAILED = $localize`:@@orgTemplate.proefbrief.failed:De proefbrief kon niet worden geopend.`;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class OrgTemplateAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
async list(): Promise<Result<string, SubOrgSummary[]>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplates(), FAILED);
|
||||
const r = await runResult(() => this.client.orgTemplates(), FAILED);
|
||||
if (!r.ok) return r;
|
||||
const out: SubOrgSummary[] = [];
|
||||
for (const s of r.value ?? []) {
|
||||
@@ -48,7 +51,7 @@ export class OrgTemplateAdapter {
|
||||
}
|
||||
|
||||
async load(subOrgId: string): Promise<Result<string, OrgTemplateAdminView>> {
|
||||
const r = await runSubmit(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||
const r = await runResult(() => this.client.orgTemplateGET(subOrgId), FAILED);
|
||||
return r.ok ? parseAdminView(r.value) : r;
|
||||
}
|
||||
|
||||
@@ -76,22 +79,26 @@ export class OrgTemplateAdapter {
|
||||
try {
|
||||
res = await fetch(
|
||||
`${environment.apiBaseUrl}/api/v1/admin/org-template/${encodeURIComponent(subOrgId)}/preview`,
|
||||
{ headers: { 'X-Role': currentRole() } },
|
||||
{ headers: isDevMode() ? { 'X-Role': currentRole() } : {} },
|
||||
);
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
if (!res.ok) {
|
||||
try {
|
||||
return err(problemDetail(await res.json(), PROEFBRIEF_FAILED));
|
||||
} catch {
|
||||
return err(PROEFBRIEF_FAILED);
|
||||
}
|
||||
}
|
||||
if (!res.ok) return err(await proefbriefErrorMessage(res));
|
||||
return ok(await res.blob());
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust boundary (TE-002): maps a non-OK proefbrief response to a message. Exported
|
||||
so a spec can call it directly instead of stubbing `globalThis.fetch`. */
|
||||
export async function proefbriefErrorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), PROEFBRIEF_FAILED);
|
||||
} catch {
|
||||
return PROEFBRIEF_FAILED;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire → domain, validating at the boundary ---
|
||||
|
||||
function parseSubOrg(dto: SubOrgSummaryDto): Result<string, SubOrgSummary> {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { parseRevealed, REVEAL_FAILED, RevealBigNummerAdapter } from './reveal-bignummer.adapter';
|
||||
|
||||
describe('parseRevealed (TE-002 trust boundary)', () => {
|
||||
it('accepts a well-formed body', () => {
|
||||
const r = parseRevealed({ bigNummer: '12345678' });
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.value).toBe('12345678');
|
||||
});
|
||||
|
||||
// The finding's own named case: a numeric bigNummer must be rejected, not
|
||||
// coerced — this is a PII reveal, not a display formatter.
|
||||
it('rejects a bigNummer sent as a number', () => {
|
||||
const r = parseRevealed({ bigNummer: 42 });
|
||||
expect(r).toEqual({ ok: false, error: REVEAL_FAILED });
|
||||
});
|
||||
|
||||
it('rejects a missing bigNummer field', () => {
|
||||
expect(parseRevealed({}).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects null and non-object bodies', () => {
|
||||
expect(parseRevealed(null).ok).toBe(false);
|
||||
expect(parseRevealed(undefined).ok).toBe(false);
|
||||
expect(parseRevealed('12345678').ok).toBe(false);
|
||||
expect(parseRevealed(42).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// isDevMode() reads the `ngDevMode` global the Angular CLI defines away in a
|
||||
// production build. There is no ambient type for it in app code, so this is
|
||||
// accessed through an untyped bag rather than a `declare const`.
|
||||
const globals = globalThis as Record<string, unknown>;
|
||||
const originalNgDevMode = globals['ngDevMode'];
|
||||
const setDevMode = (on: boolean) => {
|
||||
globals['ngDevMode'] = on;
|
||||
};
|
||||
|
||||
describe('RevealBigNummerAdapter.reveal (BIO-006a + BIO-012)', () => {
|
||||
const okResponse = () =>
|
||||
({ ok: true, json: () => Promise.resolve({ bigNummer: '12345678' }) }) as unknown as Response;
|
||||
|
||||
beforeEach(() => setDevMode(true));
|
||||
afterEach(() => {
|
||||
globals['ngDevMode'] = originalNgDevMode;
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('sends X-Step-Up only when the caller passes stepUp: true', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
await new RevealBigNummerAdapter().reveal(false);
|
||||
const headersWithoutStepUp = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(headersWithoutStepUp['X-Step-Up']).toBeUndefined();
|
||||
|
||||
await new RevealBigNummerAdapter().reveal(true);
|
||||
const headersWithStepUp = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||
expect(headersWithStepUp['X-Step-Up']).toBe('true');
|
||||
});
|
||||
|
||||
it('sends X-Role only under isDevMode()', async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(okResponse());
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
setDevMode(false);
|
||||
await new RevealBigNummerAdapter().reveal(true);
|
||||
const prodHeaders = fetchSpy.mock.calls[0][1].headers as Record<string, string>;
|
||||
expect(prodHeaders['X-Role']).toBeUndefined();
|
||||
expect(prodHeaders['X-Step-Up']).toBe('true'); // step-up is not a dev-only hatch
|
||||
|
||||
setDevMode(true);
|
||||
await new RevealBigNummerAdapter().reveal(true);
|
||||
const devHeaders = fetchSpy.mock.calls[1][1].headers as Record<string, string>;
|
||||
expect(devHeaders['X-Role']).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,47 +1,62 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, isDevMode } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { currentRole } from '@shared/infrastructure/role';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||
/** Exported so specs can assert against the same message id instead of retyping the
|
||||
Dutch sentence (matches `letter-preview.adapter.ts`'s `PREVIEW_FAILED`). */
|
||||
export const REVEAL_FAILED = $localize`:@@brief.reveal.failed:Het BIG-nummer kon niet worden getoond.`;
|
||||
|
||||
/**
|
||||
* Field-level PII reveal (PRD-0002 §5c). The case screen ships the BIG-nummer masked;
|
||||
* this unmasks it, gated server-side by the reveal capability AND a step-up. The
|
||||
* step-up is stubbed as the `X-Step-Up` header — the caller sends it only after the
|
||||
* user's confirm gesture, so a plain call (or a role without the capability) 403s.
|
||||
* step-up is stubbed as the `X-Step-Up` header, sent only when the caller passes
|
||||
* `stepUp: true` — `BriefStore.revealBigNummer()` is the only caller and it is only
|
||||
* ever reachable after `behandel-scherm.component.ts`'s `onReveal()` confirm gesture,
|
||||
* so the header now reflects that gesture instead of being a constant baked into this
|
||||
* adapter (BIO-006a — a call that skips confirmation sends no step-up at all).
|
||||
*
|
||||
* Hand-written fetch (not the `ApiClient`) because the call needs a per-request header;
|
||||
* `.ExcludeFromDescription()` on the endpoint keeps the generated client JSON-only, the
|
||||
* same seam as `/brief/preview` and uploads — which also means `X-Role` is set here.
|
||||
* same seam as `/brief/preview` and uploads. `X-Role` is a dev-only identity stand-in
|
||||
* (see `role.ts`) and is therefore only sent under `isDevMode()`, mirroring the
|
||||
* `roleInterceptor` registration in `app.config.ts` — a production build never sends it
|
||||
* from this hand-written call either (BIO-012).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RevealBigNummerAdapter {
|
||||
async reveal(): Promise<Result<string, string>> {
|
||||
async reveal(stepUp: boolean): Promise<Result<string, string>> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${environment.apiBaseUrl}/api/v1/brief/reveal-bignummer`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Role': currentRole(), 'X-Step-Up': 'true' },
|
||||
headers: {
|
||||
...(isDevMode() ? { 'X-Role': currentRole() } : {}),
|
||||
...(stepUp ? { 'X-Step-Up': 'true' } : {}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
if (!res.ok) return err(await errorMessage(res));
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
// Trust boundary: validate the shape before handing back a plain string.
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
||||
) {
|
||||
return ok((body as { bigNummer: string }).bigNummer);
|
||||
}
|
||||
return err(REVEAL_FAILED);
|
||||
return parseRevealed(await res.json().catch(() => null));
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust boundary: validate the untrusted response shape before handing back a plain
|
||||
string (TE-002) — exported so a spec can call it without stubbing `globalThis.fetch`. */
|
||||
export function parseRevealed(body: unknown): Result<string, string> {
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body !== null &&
|
||||
typeof (body as { bigNummer?: unknown }).bigNummer === 'string'
|
||||
) {
|
||||
return ok((body as { bigNummer: string }).bigNummer);
|
||||
}
|
||||
return err(REVEAL_FAILED);
|
||||
}
|
||||
|
||||
async function errorMessage(res: Response): Promise<string> {
|
||||
try {
|
||||
return problemDetail(await res.json(), REVEAL_FAILED);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, ElementRef, computed, input, output, viewChild } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { MaskedValueComponent } from '@shared/ui/masked-value/masked-value.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { MaskedValueComponent } from '@shared/ui/atoms/masked-value/masked-value.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { StepperComponent } from '@shared/ui/molecules/stepper/stepper.component';
|
||||
import { Besluit, Brief, CaseContext, LibraryPassage } from '@brief/domain/brief';
|
||||
import { besluitGuidance, inferSelection } from '@brief/domain/besluit';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Component, computed, input, linkedSignal, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { RadioGroupComponent, RadioOption } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
|
||||
import {
|
||||
RadioGroupComponent,
|
||||
RadioOption,
|
||||
} from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { Besluit, LibraryPassage } from '@brief/domain/brief';
|
||||
import { redenenFor } from '@brief/domain/besluit';
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||
import { BriefStore } from '@brief/application/brief.store';
|
||||
import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-composer.component';
|
||||
import { BehandelSchermComponent } from '@brief/ui/behandel-scherm/behandel-scherm.component';
|
||||
@@ -167,19 +167,19 @@ export class BriefPage {
|
||||
void this.store.resetDemo();
|
||||
}
|
||||
|
||||
/** Typed narrowing for the `<app-async>` loaded slot — see WP-06: a structural
|
||||
/** Typed narrowing for the `<app-async>` loaded slot: a structural
|
||||
directive's context can't inherit a generic from a sibling host input, so the
|
||||
Success value is unwrapped here instead of through `let-`. */
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
return s.tag === 'Loaded' ? s : undefined;
|
||||
});
|
||||
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
|
||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo. Ignored while focus is in the
|
||||
rich-text editor or a form control, so the browser's own text undo keeps working
|
||||
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
||||
protected onKey(e: KeyboardEvent) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** Molecule: lists all letter diagnostics grouped by severity. Errors block
|
||||
|
||||
@@ -3,8 +3,8 @@ import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import {
|
||||
RichTextEditorComponent,
|
||||
PlaceholderOption,
|
||||
} from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
} from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { LetterBlock } from '@brief/domain/brief';
|
||||
|
||||
/** Molecule: one block in a section — its editor plus provenance + block controls.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/* eslint-disable max-lines */ // 77 lines of CSS + one letter's markup; splitting it into
|
||||
// letterhead/body/signature/footer makes "what does the letter look like" a five-file
|
||||
// question for no behavioural seam. Deliberate, not deferred.
|
||||
import {
|
||||
Component,
|
||||
DestroyRef,
|
||||
@@ -11,9 +14,7 @@ import {
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { formatDatumNl } from '@shared/kernel/datum';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterLineComponent } from './letter-line.component';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = {
|
||||
@@ -39,12 +41,6 @@ function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** A4 height in CSS px (1in = 96px = 25.4mm) — for the approximate page-break marks. */
|
||||
const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
@@ -52,12 +48,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
footer around the case-type template's sections. `editableRegions` picks who edits
|
||||
what: `'content'` hosts the editable letter-sections in place (drafter), `'none'`
|
||||
renders everything read-only (approver/locked, absorbs the old letter-preview),
|
||||
`'template'` reserves the org-identity regions for the admin editor (WP-26).
|
||||
`'template'` reserves the org-identity regions for the admin editor.
|
||||
Letter typography/geometry come from the shared `public/letter.css` contract —
|
||||
the same file the backend preview renderer inlines (WP-25). */
|
||||
the same file the backend preview renderer inlines. Each rendered line is its own
|
||||
`app-letter-line` (RD-26), replacing the outlet-template indirection that stood
|
||||
in for it. */
|
||||
@Component({
|
||||
selector: 'app-letter-canvas',
|
||||
imports: [NgTemplateOutlet, ButtonComponent, PlaceholderChipComponent],
|
||||
imports: [ButtonComponent, LetterLineComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -81,7 +79,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
|
||||
/* Rejection-diff badge: a small pill above a changed/added block. */
|
||||
.diff-block.diff-changed {
|
||||
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
||||
padding-inline-start: var(--rhc-space-max-sm);
|
||||
@@ -97,7 +95,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
background: var(--rhc-color-oranje-500);
|
||||
}
|
||||
.diff-badge.added {
|
||||
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (WP-29 axe). */
|
||||
/* added = white on groen-700 (6.4:1); dark text on any green fails 4.5:1 (axe). */
|
||||
color: var(--rhc-color-wit);
|
||||
background: var(--rhc-color-groen-700);
|
||||
}
|
||||
@@ -136,37 +134,19 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (editableRegions() !== 'template') {
|
||||
<div class="toolbar">
|
||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
||||
<div
|
||||
class="zoom"
|
||||
role="group"
|
||||
aria-label="Zoomniveau"
|
||||
i18n-aria-label="@@brief.canvas.zoom"
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() <= 0.5"
|
||||
[attr.aria-label]="zoomOutLabel()"
|
||||
aria-label="Uitzoomen"
|
||||
i18n-aria-label="@@brief.canvas.zoomOut"
|
||||
(click)="zoomBy(-0.1)"
|
||||
>−</app-button
|
||||
>
|
||||
@@ -174,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() >= 1.5"
|
||||
[attr.aria-label]="zoomInLabel()"
|
||||
aria-label="Inzoomen"
|
||||
i18n-aria-label="@@brief.canvas.zoomIn"
|
||||
(click)="zoomBy(0.1)"
|
||||
>+</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{
|
||||
zoomResetLabel()
|
||||
}}</app-button>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)" i18n="@@brief.canvas.zoomReset"
|
||||
>100%</app-button
|
||||
>
|
||||
</div>
|
||||
@if (editableRegions() === 'none') {
|
||||
<app-button
|
||||
@@ -188,7 +169,13 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
@if (showSample()) {
|
||||
<ng-container i18n="@@brief.preview.hideSample">Testwaarden verbergen</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="@@brief.preview.showSample"
|
||||
>Voorbeeld met testwaarden</ng-container
|
||||
>
|
||||
}
|
||||
</app-button>
|
||||
}
|
||||
</div>
|
||||
@@ -200,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead">
|
||||
@if (logoUrl()) {
|
||||
<img class="org-logo" [src]="logoUrl()" [alt]="logoAlt()" />
|
||||
<img
|
||||
class="org-logo"
|
||||
[src]="logoUrl()"
|
||||
alt="Logo van de organisatie"
|
||||
i18n-alt="@@brief.canvas.logoAlt"
|
||||
/>
|
||||
}
|
||||
@if (editing()) {
|
||||
<input
|
||||
class="tmpl-input org-wordmark"
|
||||
[value]="orgTemplate().orgName"
|
||||
[attr.aria-label]="orgNameLabel()"
|
||||
aria-label="Organisatienaam"
|
||||
i18n-aria-label="@@brief.canvas.orgName"
|
||||
(input)="emitEdit('orgName', $event)"
|
||||
/>
|
||||
<textarea
|
||||
class="tmpl-textarea return-address"
|
||||
rows="2"
|
||||
[value]="orgTemplate().returnAddress"
|
||||
[attr.aria-label]="returnAddressLabel()"
|
||||
aria-label="Retouradres"
|
||||
i18n-aria-label="@@brief.canvas.returnAddress"
|
||||
(input)="emitEdit('returnAddress', $event)"
|
||||
></textarea>
|
||||
} @else {
|
||||
@@ -223,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<address class="address-window">{{ recipientText() }}</address>
|
||||
<dl class="reference">
|
||||
<div>
|
||||
<dt>{{ referenceLabel() }}</dt>
|
||||
<dt i18n="@@brief.canvas.reference">Ons kenmerk</dt>
|
||||
<dd>{{ brief().briefId }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ dateLabel() }}</dt>
|
||||
<dt i18n="@@brief.canvas.date">Datum</dt>
|
||||
<dd>{{ letterDate }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
@@ -241,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">
|
||||
@if (diffKind === 'added') {
|
||||
<ng-container i18n="@@brief.diff.added">nieuw</ng-container>
|
||||
} @else {
|
||||
<ng-container i18n="@@brief.diff.changed"
|
||||
>gewijzigd sinds afwijzing</ng-container
|
||||
>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
<app-letter-line
|
||||
[nodes]="para.nodes"
|
||||
[showSample]="showSample()"
|
||||
[placeholders]="brief().placeholders"
|
||||
[diagnostics]="diagnostics()"
|
||||
[sampleDate]="letterDate"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
@@ -261,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<ol>
|
||||
@for (para of seg.items; track $index) {
|
||||
<li>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: para.nodes }"
|
||||
<app-letter-line
|
||||
[nodes]="para.nodes"
|
||||
[showSample]="showSample()"
|
||||
[placeholders]="brief().placeholders"
|
||||
[diagnostics]="diagnostics()"
|
||||
[sampleDate]="letterDate"
|
||||
/>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
} @else {
|
||||
<p>
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="line"
|
||||
[ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }"
|
||||
<app-letter-line
|
||||
[nodes]="seg.items[0].nodes"
|
||||
[showSample]="showSample()"
|
||||
[placeholders]="brief().placeholders"
|
||||
[diagnostics]="diagnostics()"
|
||||
[sampleDate]="letterDate"
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
@@ -288,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureClosing"
|
||||
[attr.aria-label]="signatureClosingLabel()"
|
||||
aria-label="Afsluiting"
|
||||
i18n-aria-label="@@brief.canvas.signatureClosing"
|
||||
(input)="emitEdit('signatureClosing', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input signature-name"
|
||||
[value]="orgTemplate().signatureName"
|
||||
[attr.aria-label]="signatureNameLabel()"
|
||||
aria-label="Naam ondertekenaar"
|
||||
i18n-aria-label="@@brief.canvas.signatureName"
|
||||
(input)="emitEdit('signatureName', $event)"
|
||||
/>
|
||||
<input
|
||||
class="tmpl-input"
|
||||
[value]="orgTemplate().signatureRole"
|
||||
[attr.aria-label]="signatureRoleLabel()"
|
||||
aria-label="Functie ondertekenaar"
|
||||
i18n-aria-label="@@brief.canvas.signatureRole"
|
||||
(input)="emitEdit('signatureRole', $event)"
|
||||
/>
|
||||
} @else {
|
||||
@@ -316,13 +328,15 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
class="tmpl-textarea footer-contact"
|
||||
rows="2"
|
||||
[value]="orgTemplate().footerContact"
|
||||
[attr.aria-label]="footerContactLabel()"
|
||||
aria-label="Contactgegevens (voettekst)"
|
||||
i18n-aria-label="@@brief.canvas.footerContact"
|
||||
(input)="emitEdit('footerContact', $event)"
|
||||
></textarea>
|
||||
<input
|
||||
class="tmpl-input footer-legal"
|
||||
[value]="orgTemplate().footerLegal"
|
||||
[attr.aria-label]="footerLegalLabel()"
|
||||
aria-label="Juridische voettekst"
|
||||
i18n-aria-label="@@brief.canvas.footerLegal"
|
||||
(input)="emitEdit('footerLegal', $event)"
|
||||
/>
|
||||
} @else {
|
||||
@@ -333,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
|
||||
@for (top of pageBreaks(); track $index) {
|
||||
<div class="letter__page-break" [style.top.px]="top" aria-hidden="true">
|
||||
<span>{{ pageBreakCaption() }}</span>
|
||||
<span i18n="@@brief.canvas.pageBreak">±pagina-einde — afdrukvoorbeeld is leidend</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -344,12 +358,12 @@ export class LetterCanvasComponent {
|
||||
brief = input.required<Brief>();
|
||||
orgTemplate = input.required<OrgTemplate>();
|
||||
/** Who edits what on the surface: read-only ('none', the drafter preview + approver
|
||||
view) or admin editor ('template', WP-26). Authoring moved to letter-editor. */
|
||||
view) or admin editor ('template'). Authoring moved to letter-editor. */
|
||||
editableRegions = input<'template' | 'none'>('none');
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||
/** Initial zoom; the in-canvas controls take over from here. */
|
||||
zoom = input(1);
|
||||
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
|
||||
/** Blocks changed/added/removed since the letter was rejected; badged when
|
||||
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
||||
exist in the letter — the composer surfaces them as a count. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
@@ -359,30 +373,13 @@ export class LetterCanvasComponent {
|
||||
/** An in-place edit to an org-identity field (only in `editableRegions='template'`). */
|
||||
templateEdit = output<{ field: OrgTemplateTextField; value: string }>();
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
pageBreakCaption = input(
|
||||
$localize`:@@brief.canvas.pageBreak:±pagina-einde — afdrukvoorbeeld is leidend`,
|
||||
);
|
||||
/** The one label kept as an `input()` rather than inlined `i18n` (RD-26, decision 2):
|
||||
its message embeds a literal `\n`. As template text that `\n` becomes a source
|
||||
line break, a different string to Angular's extractor — so inlining it would
|
||||
change the extracted source text, unlike the other 19 labels this ticket inlines. */
|
||||
recipientText = input(
|
||||
$localize`:@@brief.canvas.recipient:Adres van de geadresseerde\n(wordt ingevuld bij verzending)`,
|
||||
);
|
||||
referenceLabel = input($localize`:@@brief.canvas.reference:Ons kenmerk`);
|
||||
dateLabel = input($localize`:@@brief.canvas.date:Datum`);
|
||||
logoAlt = input($localize`:@@brief.canvas.logoAlt:Logo van de organisatie`);
|
||||
orgNameLabel = input($localize`:@@brief.canvas.orgName:Organisatienaam`);
|
||||
returnAddressLabel = input($localize`:@@brief.canvas.returnAddress:Retouradres`);
|
||||
signatureClosingLabel = input($localize`:@@brief.canvas.signatureClosing:Afsluiting`);
|
||||
signatureNameLabel = input($localize`:@@brief.canvas.signatureName:Naam ondertekenaar`);
|
||||
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
||||
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
||||
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
||||
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
|
||||
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
|
||||
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
|
||||
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
|
||||
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
|
||||
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
protected letterDate = formatDatumNl(new Date());
|
||||
@@ -394,9 +391,6 @@ export class LetterCanvasComponent {
|
||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
||||
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
||||
}
|
||||
protected diffLabel = (kind: BlockDiffKind) =>
|
||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||
|
||||
/** Admin edit-in-place: the org-identity regions render as controls. */
|
||||
protected editing = computed(() => this.editableRegions() === 'template');
|
||||
|
||||
@@ -417,25 +411,9 @@ export class LetterCanvasComponent {
|
||||
};
|
||||
});
|
||||
|
||||
// --- read-only rendering helpers (migrated from the superseded letter-preview) ---
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of this.diagnostics()) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
});
|
||||
// --- read-only rendering helper (migrated from the superseded letter-preview) ---
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) =>
|
||||
SAMPLE_VALUES[key] ?? (key === 'datum' ? this.letterDate : this.labelFor(key));
|
||||
|
||||
// --- approximate page-break marks (PRD §2b: honest "±", print preview is leading) ---
|
||||
|
||||
@@ -444,7 +422,7 @@ export class LetterCanvasComponent {
|
||||
|
||||
constructor() {
|
||||
// ponytail: whole-surface height / A4-interval — ignores that a break never truly
|
||||
// falls mid-line; the caption says "±" and WP-25's server preview is authoritative.
|
||||
// falls mid-line; the caption says "±" and the server preview is authoritative.
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
// ~1cm tolerance so a letter ending on a page boundary gets no edge-hugging mark.
|
||||
const pages = Math.ceil((entry.target.scrollHeight - 40) / A4_HEIGHT_PX);
|
||||
|
||||
@@ -127,12 +127,12 @@ export const ReadOnlyZonderBevindingen: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [] },
|
||||
};
|
||||
|
||||
/** Admin editor focus (consumer arrives in WP-26): body read-only, no "not yours" tint. */
|
||||
/** Admin editor focus: body read-only, no "not yours" tint. */
|
||||
export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
|
||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged. */
|
||||
export const WithDiff: Story = {
|
||||
args: {
|
||||
editableRegions: 'none',
|
||||
@@ -150,14 +150,14 @@ export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
};
|
||||
|
||||
// Inline SVG so the story needs no backend/upload round-trip (WP-26 logo upload).
|
||||
// Inline SVG so the story needs no backend/upload round-trip (the 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. */
|
||||
/** Published org logo: the letterhead shows it above the org name. */
|
||||
export const MetLogo: Story = {
|
||||
args: { editableRegions: 'none', diagnostics: [], logoUrl: sampleLogo },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/atoms/placeholder-chip/placeholder-chip.component';
|
||||
import { RichTextNode } from '@shared/kernel/rich-text';
|
||||
import { Diagnostic, PlaceholderDef } from '@brief/domain/placeholders';
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** `brief().placeholders` keyed by `key` for O(1) lookup from a node. */
|
||||
export function placeholderDefs(
|
||||
placeholders: readonly PlaceholderDef[],
|
||||
): Map<string, PlaceholderDef> {
|
||||
return new Map(placeholders.map((p) => [p.key, p]));
|
||||
}
|
||||
|
||||
/** The worst (error over warning) diagnostic severity per placeholder key. */
|
||||
export function worstSeverities(
|
||||
diagnostics: readonly Diagnostic[],
|
||||
): Map<string, 'error' | 'warning'> {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
for (const d of diagnostics) {
|
||||
if (!d.placeholderKey) continue;
|
||||
if (d.severity === 'error') m.set(d.placeholderKey, 'error');
|
||||
else if (!m.has(d.placeholderKey)) m.set(d.placeholderKey, 'warning');
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
export function resolveLabel(defs: ReadonlyMap<string, PlaceholderDef>, key: string): string {
|
||||
return defs.get(key)?.label ?? key;
|
||||
}
|
||||
|
||||
export function resolveAuto(defs: ReadonlyMap<string, PlaceholderDef>, key: string): boolean {
|
||||
return defs.get(key)?.autoResolvable ?? false;
|
||||
}
|
||||
|
||||
export function resolveState(
|
||||
worst: ReadonlyMap<string, 'error' | 'warning'>,
|
||||
key: string,
|
||||
): 'ok' | 'warning' | 'error' {
|
||||
return worst.get(key) ?? 'ok';
|
||||
}
|
||||
|
||||
/** The "Voorbeeld" toggle's stand-in for an auto-resolvable placeholder: a canned
|
||||
sample, the caller's sample date for `datum`, or the field's own label. */
|
||||
export function resolveSample(
|
||||
defs: ReadonlyMap<string, PlaceholderDef>,
|
||||
sampleDate: string,
|
||||
key: string,
|
||||
): string {
|
||||
return SAMPLE_VALUES[key] ?? (key === 'datum' ? sampleDate : resolveLabel(defs, key));
|
||||
}
|
||||
|
||||
/** Organism: one rendered line of letter content — text runs, line breaks and
|
||||
placeholder chips (an auto-resolvable one swaps to a sample value when
|
||||
`showSample` is on). Extracted from `letter-canvas` (RD-26): the `#line`
|
||||
template plus the label/auto/state/sample helpers it needs, so the canvas's
|
||||
three `ngTemplateOutlet` incantations become one tag each. */
|
||||
@Component({
|
||||
selector: 'app-letter-line',
|
||||
imports: [PlaceholderChipComponent],
|
||||
template: `
|
||||
@for (node of nodes(); track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') {
|
||||
<span>{{ node.text }}</span>
|
||||
}
|
||||
@case ('lineBreak') {
|
||||
<br />
|
||||
}
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class LetterLineComponent {
|
||||
nodes = input.required<readonly RichTextNode[]>();
|
||||
showSample = input(false);
|
||||
placeholders = input<readonly PlaceholderDef[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** The canvas's `formatDatumNl(new Date())`, passed down so every line agrees. */
|
||||
sampleDate = input('');
|
||||
|
||||
private defs = computed(() => placeholderDefs(this.placeholders()));
|
||||
private worst = computed(() => worstSeverities(this.diagnostics()));
|
||||
|
||||
protected labelFor = (key: string) => resolveLabel(this.defs(), key);
|
||||
protected autoFor = (key: string) => resolveAuto(this.defs(), key);
|
||||
protected stateFor = (key: string) => resolveState(this.worst(), key);
|
||||
protected sampleFor = (key: string) => resolveSample(this.defs(), this.sampleDate(), key);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PlaceholderDef, Diagnostic } from '@brief/domain/placeholders';
|
||||
import {
|
||||
placeholderDefs,
|
||||
resolveAuto,
|
||||
resolveLabel,
|
||||
resolveSample,
|
||||
resolveState,
|
||||
worstSeverities,
|
||||
} from './letter-line.component';
|
||||
|
||||
const DEFS: readonly PlaceholderDef[] = [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'reden_besluit', label: 'Reden besluit', autoResolvable: false },
|
||||
];
|
||||
|
||||
const LOCATION = { blockId: 'b1', paragraphIndex: 0, nodeIndex: 0 };
|
||||
|
||||
function diagnostic(placeholderKey: string, severity: 'error' | 'warning'): Diagnostic {
|
||||
return { severity, code: 'unresolved-at-send', message: 'x', placeholderKey, location: LOCATION };
|
||||
}
|
||||
|
||||
describe('placeholderDefs', () => {
|
||||
it('keys the placeholder list by its key', () => {
|
||||
const defs = placeholderDefs(DEFS);
|
||||
expect(defs.get('naam_zorgverlener')?.label).toBe('Naam zorgverlener');
|
||||
expect(defs.get('unknown')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('worstSeverities', () => {
|
||||
it('ignores a diagnostic with no placeholder key', () => {
|
||||
const worst = worstSeverities([
|
||||
{ severity: 'error', code: 'malformed', message: 'x', location: LOCATION },
|
||||
]);
|
||||
expect(worst.size).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps error over a warning already recorded for the same key', () => {
|
||||
const worst = worstSeverities([
|
||||
diagnostic('naam_zorgverlener', 'warning'),
|
||||
diagnostic('naam_zorgverlener', 'error'),
|
||||
]);
|
||||
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||
});
|
||||
|
||||
it('does not let a later warning downgrade an error', () => {
|
||||
const worst = worstSeverities([
|
||||
diagnostic('naam_zorgverlener', 'error'),
|
||||
diagnostic('naam_zorgverlener', 'warning'),
|
||||
]);
|
||||
expect(worst.get('naam_zorgverlener')).toBe('error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLabel', () => {
|
||||
it('returns the field label for a known key', () => {
|
||||
expect(resolveLabel(placeholderDefs(DEFS), 'reden_besluit')).toBe('Reden besluit');
|
||||
});
|
||||
|
||||
it('falls back to the bare key when the field is unknown', () => {
|
||||
expect(resolveLabel(placeholderDefs(DEFS), 'unknown')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAuto', () => {
|
||||
it('reads autoResolvable off the field', () => {
|
||||
const defs = placeholderDefs(DEFS);
|
||||
expect(resolveAuto(defs, 'naam_zorgverlener')).toBe(true);
|
||||
expect(resolveAuto(defs, 'reden_besluit')).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to false for an unknown key', () => {
|
||||
expect(resolveAuto(placeholderDefs(DEFS), 'unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveState', () => {
|
||||
it('defaults to ok when the key has no diagnostic', () => {
|
||||
expect(resolveState(worstSeverities([]), 'naam_zorgverlener')).toBe('ok');
|
||||
});
|
||||
|
||||
it('surfaces the worst recorded severity', () => {
|
||||
const worst = worstSeverities([diagnostic('naam_zorgverlener', 'warning')]);
|
||||
expect(resolveState(worst, 'naam_zorgverlener')).toBe('warning');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSample', () => {
|
||||
it('prefers the canned sample value over the label', () => {
|
||||
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'naam_zorgverlener')).toBe(
|
||||
'J. Jansen',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves datum to the caller-supplied sample date', () => {
|
||||
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'datum')).toBe(
|
||||
'4 september 2026',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the field label for anything else', () => {
|
||||
expect(resolveSample(placeholderDefs(DEFS), '4 september 2026', 'reden_besluit')).toBe(
|
||||
'Reden besluit',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/atoms/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
@@ -137,7 +137,7 @@ export class LetterComposerComponent {
|
||||
canReject = input(false);
|
||||
canSend = input(false);
|
||||
busy = input(false);
|
||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||
/** Rejection diff: the changed/added/removed blocks and their count. The
|
||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
removedCount = input(0);
|
||||
|
||||
@@ -181,7 +181,7 @@ export const Sent: Story = {
|
||||
}),
|
||||
};
|
||||
|
||||
/** Approver's "Toon wijzigingen" (WP-27): a resubmitted letter with blocks changed,
|
||||
/** Approver's "Toon wijzigingen": a resubmitted letter with blocks changed,
|
||||
added and removed since the last rejection. */
|
||||
export const RejectionDiff: Story = {
|
||||
render: () =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { PlaceholderOption } from '@shared/ui/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { PlaceholderOption } from '@shared/ui/molecules/rich-text-editor/rich-text-editor.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { LetterSection } from '@brief/domain/brief';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { LetterBlockComponent } from '@brief/ui/letter-block/letter-block.component';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { FileInputComponent } from '@shared/ui/atoms/upload/file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '@shared/ui/molecules/upload/single-upload/single-upload.component';
|
||||
import { UploadState } from '@shared/domain/upload.machine';
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
|
||||
/**
|
||||
* Organism: the org-template editor's logo-upload block, split out of
|
||||
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||
* mutation clusters. Presentational: every mutation is an output the parent
|
||||
* re-emits unchanged.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-logo-upload',
|
||||
imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.section {
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<section class="section">
|
||||
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
|
||||
@if (logoCategory()) {
|
||||
<app-file-input
|
||||
inputId="org-logo-input"
|
||||
[accept]="logoCategory()!.acceptedTypes"
|
||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||
i18n-label="@@orgTemplate.logo"
|
||||
label="Logo"
|
||||
(filesSelected)="logoSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (logoRejection()) {
|
||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||
}
|
||||
@if (logoUploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of logoUploads(); track u.localId) {
|
||||
<li
|
||||
app-single-upload
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="logoRemoved.emit(u.localId)"
|
||||
(retry)="logoRetry.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class LogoUploadComponent {
|
||||
logoUrl = input<string | null>(null);
|
||||
uploadState = input.required<UploadState>();
|
||||
previewUrlFor = input<(documentId: string) => string | undefined>();
|
||||
|
||||
logoSelected = output<File[]>();
|
||||
logoRemoved = output<string>();
|
||||
logoRetry = output<string>();
|
||||
|
||||
protected logoCategory = computed(() =>
|
||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoUploads = computed(() =>
|
||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component';
|
||||
import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component';
|
||||
import { UploadState } from '@shared/upload/upload.machine';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { UploadState } from '@shared/domain/upload.machine';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
|
||||
import {
|
||||
MARGIN_MAX_MM,
|
||||
MARGIN_MIN_MM,
|
||||
@@ -17,74 +14,29 @@ import {
|
||||
} from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { LogoUploadComponent } from './logo-upload.component';
|
||||
import { VersionHistoryComponent } from './version-history.component';
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm'];
|
||||
|
||||
/** A minimal read-only sample letter, so the admin sees the org identity in context
|
||||
while editing (content itself is not the admin's to change). */
|
||||
export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
briefId: 'VOORBEELD-0001',
|
||||
beroep: 'arts',
|
||||
templateId: 'sample',
|
||||
drafterId: 'sample',
|
||||
status: { tag: 'draft' },
|
||||
placeholders: [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true },
|
||||
{ key: 'datum', label: 'Datum', autoResolvable: true },
|
||||
],
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'body',
|
||||
title: 'Voorbeeldinhoud',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'sample-1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Organism (WP-26): the admin org-template editor. The mirror of the drafter's
|
||||
* Organism: the admin org-template editor. The mirror of the drafter's
|
||||
* composer — the letter canvas runs in `editableRegions='template'` so the
|
||||
* letterhead/signature/footer are edited in place, while the content is a read-only
|
||||
* sample. Margins, logo upload, version history and the publish bar sit around it.
|
||||
* Presentational: every mutation is an output the store turns into a command.
|
||||
* sample. Margins and the publish bar sit around it; the logo uploader and version
|
||||
* history are their own children (`app-logo-upload`, `app-version-history`, RD-25) —
|
||||
* each a self-contained mutation cluster. Presentational: every mutation is an
|
||||
* output the store turns into a command, whether sourced here or re-emitted from
|
||||
* a child.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-org-template-editor',
|
||||
imports: [
|
||||
DatePipe,
|
||||
HeadingComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
FileInputComponent,
|
||||
SingleUploadComponent,
|
||||
LetterCanvasComponent,
|
||||
LogoUploadComponent,
|
||||
VersionHistoryComponent,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
@@ -122,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
.margins input {
|
||||
width: 6rem;
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
padding-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -153,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
template: `
|
||||
<div class="toolbar">
|
||||
<label class="field">
|
||||
<span>{{ subOrgLabel() }}</span>
|
||||
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
|
||||
<select class="form-select" (change)="onSelectSubOrg($event)">
|
||||
@for (o of subOrgs(); track o.subOrgId) {
|
||||
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
|
||||
@@ -190,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
|
||||
}
|
||||
</fieldset>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
|
||||
@if (logoCategory()) {
|
||||
<app-file-input
|
||||
inputId="org-logo-input"
|
||||
[accept]="logoCategory()!.acceptedTypes"
|
||||
[maxSizeMb]="logoCategory()!.maxSizeMb"
|
||||
[label]="logoHeading()"
|
||||
(filesSelected)="logoSelected.emit($event)"
|
||||
/>
|
||||
}
|
||||
@if (logoRejection()) {
|
||||
<app-alert type="error">{{ logoRejection() }}</app-alert>
|
||||
}
|
||||
@if (logoUploads().length) {
|
||||
<ul class="file-list">
|
||||
@for (u of logoUploads(); track u.localId) {
|
||||
<li
|
||||
app-single-upload
|
||||
[upload]="u"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(remove)="logoRemoved.emit(u.localId)"
|
||||
(retry)="logoRetry.emit(u.localId)"
|
||||
></li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
<app-logo-upload
|
||||
[logoUrl]="logoUrl()"
|
||||
[uploadState]="uploadState()"
|
||||
[previewUrlFor]="previewUrlFor()"
|
||||
(logoSelected)="logoSelected.emit($event)"
|
||||
(logoRemoved)="logoRemoved.emit($event)"
|
||||
(logoRetry)="logoRetry.emit($event)"
|
||||
/>
|
||||
|
||||
<section class="section">
|
||||
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
|
||||
@if (history().length === 0) {
|
||||
<p class="published">{{ noHistory() }}</p>
|
||||
} @else {
|
||||
<ul class="history-list">
|
||||
@for (v of history(); track v.version) {
|
||||
<li class="history-row">
|
||||
<span
|
||||
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
|
||||
>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
|
||||
{{ rollbackLabel() }}
|
||||
</app-button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
<app-version-history
|
||||
[history]="history()"
|
||||
[publishedVersion]="publishedVersion()"
|
||||
[busy]="busy()"
|
||||
(rollback)="rollback.emit($event)"
|
||||
/>
|
||||
|
||||
<div class="bar">
|
||||
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
|
||||
<span class="published"
|
||||
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
|
||||
{{ publishedVersion() }}</span
|
||||
>
|
||||
@if (pendingPublish()) {
|
||||
<app-alert type="warning">{{ impactText() }}</app-alert>
|
||||
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
|
||||
{{ confirmLabel() }}
|
||||
</app-button>
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
|
||||
{{ cancelLabel() }}
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="busy()"
|
||||
(click)="confirmPublish.emit()"
|
||||
i18n="@@orgTemplate.publish.confirm"
|
||||
>Bevestigen</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="busy()"
|
||||
(click)="cancelPublish.emit()"
|
||||
i18n="@@orgTemplate.publish.cancel"
|
||||
>Annuleren</app-button
|
||||
>
|
||||
} @else {
|
||||
<app-button
|
||||
variant="primary"
|
||||
[disabled]="!draftValid() || busy()"
|
||||
(click)="requestPublish.emit()"
|
||||
i18n="@@orgTemplate.publish"
|
||||
>Publiceren</app-button
|
||||
>
|
||||
{{ publishLabel() }}
|
||||
</app-button>
|
||||
@if (!draftValid()) {
|
||||
<span class="published">{{ invalidHint() }}</span>
|
||||
}
|
||||
}
|
||||
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
|
||||
{{ proefbriefLabel() }}
|
||||
</app-button>
|
||||
<app-button
|
||||
variant="secondary"
|
||||
[disabled]="busy()"
|
||||
(click)="proefbrief.emit()"
|
||||
i18n="@@orgTemplate.proefbrief"
|
||||
>Proefbrief</app-button
|
||||
>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -299,14 +217,6 @@ export class OrgTemplateEditorComponent {
|
||||
protected readonly MIN = MARGIN_MIN_MM;
|
||||
protected readonly MAX = MARGIN_MAX_MM;
|
||||
|
||||
protected logoCategory = computed(() =>
|
||||
this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoUploads = computed(() =>
|
||||
this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY),
|
||||
);
|
||||
protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]);
|
||||
|
||||
protected onSelectSubOrg(event: Event) {
|
||||
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
|
||||
}
|
||||
@@ -333,20 +243,9 @@ export class OrgTemplateEditorComponent {
|
||||
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
|
||||
);
|
||||
|
||||
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
|
||||
protected marginsLegend = input(
|
||||
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
|
||||
);
|
||||
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
|
||||
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
|
||||
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
|
||||
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
|
||||
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
|
||||
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
|
||||
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
|
||||
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
|
||||
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
|
||||
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
|
||||
protected invalidHint = input(
|
||||
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { OrgTemplateEditorComponent } from './org-template-editor.component';
|
||||
import { OrgTemplate, OrgTemplateVersion, SubOrgSummary } from '@brief/domain/org-template';
|
||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
||||
import { UploadState, initialUpload } from '@shared/domain/upload.machine';
|
||||
|
||||
const draft: OrgTemplate = {
|
||||
subOrgId: 'cibg-registers',
|
||||
@@ -87,12 +87,12 @@ const sampleLogo =
|
||||
'<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. */
|
||||
/** Published logo: 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
|
||||
/** Client-side upload rejection (existing `rejectReason`) — type/size caught
|
||||
before the file ever reaches the backend. */
|
||||
export const LogoUploadFout: Story = {
|
||||
args: {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/atoms/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { OrgTemplateVersion } from '@brief/domain/org-template';
|
||||
|
||||
/**
|
||||
* Organism: the org-template editor's version-history block, split out of
|
||||
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
|
||||
* mutation clusters. Presentational: `rollback` is the parent's own output,
|
||||
* re-emitted unchanged.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-version-history',
|
||||
imports: [DatePipe, HeadingComponent, ButtonComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.section {
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
.history-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.history-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--rhc-space-max-md);
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default);
|
||||
padding-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.published {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<section class="section">
|
||||
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
|
||||
@if (history().length === 0) {
|
||||
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
|
||||
} @else {
|
||||
<ul class="history-list">
|
||||
@for (v of history(); track v.version) {
|
||||
<li class="history-row">
|
||||
<span
|
||||
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
|
||||
{{ v.publishedAt | date: 'longDate' }}</span
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="busy()"
|
||||
(click)="rollback.emit(v.version)"
|
||||
i18n="@@orgTemplate.rollback"
|
||||
>Terugzetten in concept</app-button
|
||||
>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
})
|
||||
export class VersionHistoryComponent {
|
||||
history = input<readonly OrgTemplateVersion[]>([]);
|
||||
publishedVersion = input(0);
|
||||
busy = input(false);
|
||||
|
||||
rollback = output<number>();
|
||||
}
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Component, computed, effect, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { OrgTemplateStore } from '@brief/application/org-template.store';
|
||||
import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-template-editor.component';
|
||||
|
||||
/** Page: thin container for the admin org-template editor (WP-26). Deny-by-default
|
||||
/** Page: thin container for the admin org-template editor. Deny-by-default
|
||||
capability gate (`orgtemplate:edit`) — a denial alert for non-admins, the editor
|
||||
for admins. Loads once the capability resolves; wires store commands to the organism. */
|
||||
@Component({
|
||||
@@ -86,10 +85,9 @@ import { OrgTemplateEditorComponent } from '@brief/ui/org-template-editor/org-te
|
||||
export class OrgTemplatePage {
|
||||
protected store = inject(OrgTemplateStore);
|
||||
protected access = inject(AccessStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
|
||||
protected canEdit = computed(() => this.access.can('orgtemplate:edit'));
|
||||
protected previewUrlFor = (documentId: string) => this.uploadAdapter.contentUrl(documentId);
|
||||
protected previewUrlFor = this.store.previewUrlFor;
|
||||
|
||||
protected heading = $localize`:@@orgTemplate.page.heading:Huisstijl beheren`;
|
||||
protected intro = $localize`:@@orgTemplate.page.intro:Beheer per organisatieonderdeel het uiterlijk van de brief: logo, afzender, ondertekening, voettekst en marges.`;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { CheckboxComponent } from '@shared/ui/atoms/checkbox/checkbox.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||
import { textOf } from '@shared/kernel/rich-text';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
|
||||
@@ -10,8 +10,8 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
inserts ALL checked passages at once (a single message upstream) — there is no
|
||||
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
|
||||
Superseded by `besluit-panel`'s guided drafting: no consumer left in
|
||||
`src/app` outside its own story. Kept for now rather than deleted
|
||||
in-flight of an unrelated WP; a future cleanup can remove it. */
|
||||
@Component({
|
||||
selector: 'app-passage-picker',
|
||||
@@ -87,7 +87,7 @@ export class PassagePickerComponent {
|
||||
protected checked = signal<Record<string, boolean>>({});
|
||||
protected query = signal('');
|
||||
/** Client-side filter on label + rendered content text — the library is small, so no
|
||||
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
|
||||
server search. Placeholder keys are searchable too (see `textOf`). */
|
||||
protected filtered = computed(() => {
|
||||
const q = this.query().trim().toLowerCase();
|
||||
if (!q) return this.passages();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
|
||||
/** Molecule: shows the rejection comments (drafter view) or collects them from the
|
||||
approver. The approver rejects WITH comments; they never edit the letter. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
primary,
|
||||
resolve,
|
||||
reduce,
|
||||
WizardState,
|
||||
@@ -104,6 +105,25 @@ describe('wizard.machine', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary', () => {
|
||||
it('Primary advances to the next step from a non-final step', () => {
|
||||
const s = toStep2('4160', '200'); // Editing, step 2 — not the final step
|
||||
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Next' }));
|
||||
expect(expectTag(primary(s), 'Editing').step).toBe(3);
|
||||
});
|
||||
|
||||
it('Primary submits from the final step', () => {
|
||||
const s = toStep3('4160', '200'); // Editing, step 3 — the final step
|
||||
expect(reduce(s, { tag: 'Primary' })).toEqual(reduce(s, { tag: 'Submit' }));
|
||||
expect(primary(s).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Primary is a no-op from a non-editing state', () => {
|
||||
const submitting = submit(toStep3('4160', '200'));
|
||||
expect(primary(submitting)).toBe(submitting);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven)', () => {
|
||||
it('drives the full happy path via messages', () => {
|
||||
let s: WizardState = initial;
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
reduceUpload,
|
||||
requiredCategoriesSatisfied,
|
||||
deliveryRefs,
|
||||
} from '@shared/upload/upload.machine';
|
||||
digitalDocumentIds,
|
||||
} from '@shared/domain/upload.machine';
|
||||
|
||||
/** What the user is typing (raw, possibly invalid). */
|
||||
export interface Draft {
|
||||
@@ -53,7 +54,7 @@ export function hasProgress(s: Extract<WizardState, { tag: 'Editing' }>): boolea
|
||||
!!s.draft.uren ||
|
||||
!!s.draft.jaren ||
|
||||
!!s.draft.punten ||
|
||||
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||
digitalDocumentIds(s.upload).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,6 +122,13 @@ export function submit(s: WizardState): WizardState {
|
||||
return result.ok ? { tag: 'Submitting', data: result.value } : { ...s, errors: result.error };
|
||||
}
|
||||
|
||||
/** The primary button's action: advance, or submit from the last step. No-op
|
||||
outside Editing — this is the one decision the old component-side handler used to make. */
|
||||
export function primary(s: WizardState): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
return s.step === 3 ? submit(s) : next(s);
|
||||
}
|
||||
|
||||
/** Route an upload sub-message through the pure upload reducer (Editing only). */
|
||||
export function upload(s: WizardState, msg: UploadMsg): WizardState {
|
||||
if (s.tag !== 'Editing') return s;
|
||||
@@ -152,6 +160,7 @@ export type WizardMsg =
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; step: 1 | 2 | 3 }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Primary' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
@@ -170,6 +179,8 @@ export function reduce(s: WizardState, m: WizardMsg): WizardState {
|
||||
return gaNaarStap(s, m.step);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Primary':
|
||||
return primary(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
|
||||
@@ -147,6 +147,35 @@ describe('intake acceptance journeys', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('raising uren above the threshold after answering scholing drops both fields', () => {
|
||||
// Given a journey that answered the scholing question while uren was low.
|
||||
const atReview = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||
{ tag: 'Next' },
|
||||
);
|
||||
expect(atReview.tag).toBe('Answering');
|
||||
expect(atReview.tag === 'Answering' && atReview.cursor).toBe(2); // review
|
||||
|
||||
// When the user goes back and raises uren above the threshold, then submits...
|
||||
const done = given(reduce, atReview)(
|
||||
{ tag: 'GaNaarStap', cursor: 1 },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'Next' }, // the now-hidden scholing question no longer blocks
|
||||
{ tag: 'Submit' },
|
||||
{ tag: 'SubmitConfirmed' },
|
||||
);
|
||||
|
||||
// Then the submission succeeds, and BOTH the stale answer and its punten are gone —
|
||||
// exactly the crafted-POST-shaped payload the server rule rejects.
|
||||
expect(done.tag).toBe('Submitted');
|
||||
expect(done.tag === 'Submitted' && done.data.aanvullendeScholing).toBeUndefined();
|
||||
expect(done.tag === 'Submitted' && done.data.punten).toBeUndefined();
|
||||
});
|
||||
|
||||
it('SetPolicy (server-owned threshold) can turn an already-answered uren into one that now requires scholing', () => {
|
||||
const atWerkStep = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
|
||||
@@ -2,7 +2,6 @@ import { describe, it, expect } from 'vitest';
|
||||
import { ok, err } from '@shared/kernel/fp';
|
||||
import { expectTag } from '@shared/testing/expect-tag';
|
||||
import {
|
||||
Answers,
|
||||
initial,
|
||||
STEPS,
|
||||
lageUren,
|
||||
@@ -11,18 +10,12 @@ import {
|
||||
back,
|
||||
gaNaarStap,
|
||||
submit,
|
||||
primary,
|
||||
resolve,
|
||||
reduce,
|
||||
IntakeState,
|
||||
} from './intake.machine';
|
||||
|
||||
const answering = (answers: Answers, cursor = 0, scholingThreshold = 1000): IntakeState => ({
|
||||
tag: 'Answering',
|
||||
answers,
|
||||
cursor,
|
||||
errors: {},
|
||||
scholingThreshold,
|
||||
});
|
||||
import { givenIntake } from './intake.testing';
|
||||
|
||||
describe('STEPS (fixed) and inline questions', () => {
|
||||
it('always has the same three steps', () => {
|
||||
@@ -31,12 +24,12 @@ describe('STEPS (fixed) and inline questions', () => {
|
||||
|
||||
it('reveals the buitenland detail questions inline only when worked abroad', () => {
|
||||
// No new step; instead these fields become required within the buitenland step.
|
||||
expect(next(answering({ buitenlandGewerkt: 'ja' })).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect(
|
||||
expectTag(next(answering({ buitenlandGewerkt: 'ja' })), 'Answering').errors.land,
|
||||
).toBeTruthy();
|
||||
expect(next(answering({ buitenlandGewerkt: 'nee' })).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect(expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering').cursor).toBe(1);
|
||||
const abroad = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' });
|
||||
expect(next(abroad).tag).toBe('Answering'); // land/uren missing -> blocked
|
||||
expect(expectTag(next(abroad), 'Answering').errors.land).toBeTruthy();
|
||||
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
expect(next(domestic).tag).toBe('Answering'); // valid, advances (cursor moves)
|
||||
expect(expectTag(next(domestic), 'Answering').cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('reveals the scholing question only when NL-hours are below the threshold', () => {
|
||||
@@ -49,9 +42,13 @@ describe('STEPS (fixed) and inline questions', () => {
|
||||
expect(lageUren({ uren: '1500' }, 1000)).toBe(false);
|
||||
expect(lageUren({ uren: '1500' }, 2000)).toBe(true);
|
||||
// And the threshold from state flows through submit:
|
||||
const lowThreshold = submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '1500', punten: '200' }, 0, 2000),
|
||||
const lowThresholdState = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||
{ tag: 'SetPolicy', scholingThreshold: 2000 },
|
||||
);
|
||||
const lowThreshold = submit(lowThresholdState);
|
||||
expect(lowThreshold.tag).toBe('Answering'); // scholing now required (1500 < 2000), unanswered → blocked
|
||||
expect(expectTag(lowThreshold, 'Answering').errors.scholingGevolgd).toBeTruthy();
|
||||
});
|
||||
@@ -65,18 +62,21 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('Next advances once the step is valid', () => {
|
||||
const s = expectTag(next(answering({ buitenlandGewerkt: 'nee' })), 'Answering');
|
||||
const domestic = givenIntake({ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' });
|
||||
const s = expectTag(next(domestic), 'Answering');
|
||||
expect(s.cursor).toBe(1);
|
||||
expect(currentStep(s)).toBe('werk');
|
||||
});
|
||||
|
||||
it('editing an answer leaves the cursor fixed (steps never collapse)', () => {
|
||||
const atWerk = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'land', value: 'België' },
|
||||
{ tag: 'SetAnswer', key: 'buitenlandseUren', value: '300' },
|
||||
{ tag: 'Next' }, // buitenland step valid -> cursor 0 -> 1
|
||||
);
|
||||
const edited = expectTag(
|
||||
reduce(answering({ buitenlandGewerkt: 'ja' }, 1), {
|
||||
tag: 'SetAnswer',
|
||||
key: 'buitenlandGewerkt',
|
||||
value: 'nee',
|
||||
}),
|
||||
reduce(atWerk, { tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' }),
|
||||
'Answering',
|
||||
);
|
||||
expect(edited.cursor).toBe(1); // cursor untouched; only inline questions change
|
||||
@@ -87,72 +87,154 @@ describe('navigation', () => {
|
||||
});
|
||||
|
||||
it('gaNaarStap jumps back to an earlier step, clearing errors', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 2);
|
||||
const s = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
{ tag: 'Next' }, // cursor 1 -> 2
|
||||
);
|
||||
expect(expectTag(gaNaarStap(s, 0), 'Answering').cursor).toBe(0);
|
||||
});
|
||||
|
||||
it('gaNaarStap ignores a same/forward jump and jumps outside Answering', () => {
|
||||
const s = answering({ buitenlandGewerkt: 'nee' }, 1);
|
||||
const s = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
);
|
||||
expect(gaNaarStap(s, 1)).toBe(s); // same step -> no-op
|
||||
expect(gaNaarStap(s, 2)).toBe(s); // forward -> no-op
|
||||
const submitting = submit(answering({ buitenlandGewerkt: 'nee', uren: '4160' }, 2));
|
||||
const atReview = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'Next' }, // cursor 0 -> 1
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
{ tag: 'Next' }, // cursor 1 -> 2
|
||||
);
|
||||
const submitting = submit(atReview);
|
||||
expect(gaNaarStap(submitting, 0)).toBe(submitting); // not Answering -> no-op
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
// High hours: no scholing question, so no punten is asked or collected.
|
||||
const complete: Answers = { buitenlandGewerkt: 'nee', uren: '4160' };
|
||||
const highUren = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
);
|
||||
|
||||
it('reaches Submitting ONLY with valid answers', () => {
|
||||
// Bad punten only blocks when scholing was followed (otherwise punten is ignored).
|
||||
expect(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: 'x' }),
|
||||
).tag,
|
||||
).toBe('Answering');
|
||||
const good = expectTag(submit(answering(complete)), 'Submitting');
|
||||
const badPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: 'x' },
|
||||
);
|
||||
expect(submit(badPunten).tag).toBe('Answering');
|
||||
const good = expectTag(submit(highUren), 'Submitting');
|
||||
expect(good.data.uren).toBe(4160);
|
||||
expect(good.data.punten).toBeUndefined(); // not collected without scholing
|
||||
});
|
||||
|
||||
it('punten is required only when aanvullende scholing was gevolgd', () => {
|
||||
// scholing = ja but punten missing -> blocked on punten.
|
||||
const missing = expectTag(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja' })),
|
||||
'Answering',
|
||||
const scholingJaNoPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
);
|
||||
const missing = expectTag(submit(scholingJaNoPunten), 'Answering');
|
||||
expect(missing.errors.punten).toBeTruthy();
|
||||
// scholing = nee -> punten not required, submits without it.
|
||||
expect(
|
||||
submit(answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'nee' })).tag,
|
||||
).toBe('Submitting');
|
||||
const scholingNee = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'nee' },
|
||||
);
|
||||
expect(submit(scholingNee).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('low hours requires the scholing answer before submit', () => {
|
||||
const noScholing = submit(answering({ buitenlandGewerkt: 'nee', uren: '500' }));
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const withScholing = expectTag(
|
||||
submit(
|
||||
answering({ buitenlandGewerkt: 'nee', uren: '500', scholingGevolgd: 'ja', punten: '200' }),
|
||||
),
|
||||
'Submitting',
|
||||
const lowUrenNoScholing = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
);
|
||||
const noScholing = submit(lowUrenNoScholing);
|
||||
expect(noScholing.tag).toBe('Answering'); // scholing question is required, unanswered
|
||||
const lowUrenWithScholing = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '200' },
|
||||
);
|
||||
const withScholing = expectTag(submit(lowUrenWithScholing), 'Submitting');
|
||||
expect(withScholing.data.aanvullendeScholing).toBe(true);
|
||||
expect(withScholing.data.punten).toBe(200);
|
||||
});
|
||||
|
||||
it('does not require punten for a hidden question', () => {
|
||||
// scholingGevolgd is a stale 'ja' from when uren was low, but uren is now above
|
||||
// threshold — the template hides the question, so punten must not be required either.
|
||||
const staleScholingNoPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
);
|
||||
const good = expectTag(submit(staleScholingNoPunten), 'Submitting');
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops punten when raising uren hides the question', () => {
|
||||
// Same stale answer, but this time punten was also filled in while uren was low.
|
||||
const staleScholingWithPunten = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '1500' },
|
||||
{ tag: 'SetAnswer', key: 'scholingGevolgd', value: 'ja' },
|
||||
{ tag: 'SetAnswer', key: 'punten', value: '150' },
|
||||
);
|
||||
const good = expectTag(submit(staleScholingWithPunten), 'Submitting');
|
||||
// ValidIntake stays honest: neither the stale 'ja' nor its punten leak through.
|
||||
expect(good.data.aanvullendeScholing).toBeUndefined();
|
||||
expect(good.data.punten).toBeUndefined();
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Submitted on a successful submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
const submitting = submit(highUren);
|
||||
expect(resolve(submitting, ok(undefined)).tag).toBe('Submitted');
|
||||
});
|
||||
|
||||
it('resolve maps Submitting to Failed on a failed submit', () => {
|
||||
const submitting = submit(answering(complete));
|
||||
const submitting = submit(highUren);
|
||||
expect(resolve(submitting, err('boom')).tag).toBe('Failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('primary', () => {
|
||||
// Same fixture as the 'submit' describe block above: buitenland answered 'nee',
|
||||
// uren high enough to skip the scholing question.
|
||||
const highUren = givenIntake(
|
||||
{ tag: 'SetAnswer', key: 'buitenlandGewerkt', value: 'nee' },
|
||||
{ tag: 'SetAnswer', key: 'uren', value: '4160' },
|
||||
);
|
||||
|
||||
it('Primary advances to the next step from a non-final step', () => {
|
||||
expect(currentStep(expectTag(highUren, 'Answering'))).toBe('buitenland'); // not the final step
|
||||
expect(reduce(highUren, { tag: 'Primary' })).toEqual(reduce(highUren, { tag: 'Next' }));
|
||||
expect(expectTag(primary(highUren), 'Answering').cursor).toBe(1);
|
||||
});
|
||||
|
||||
it('Primary submits from the final step', () => {
|
||||
const atReview = reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' });
|
||||
expect(currentStep(expectTag(atReview, 'Answering'))).toBe('review'); // the final step
|
||||
expect(reduce(atReview, { tag: 'Primary' })).toEqual(reduce(atReview, { tag: 'Submit' }));
|
||||
expect(primary(atReview).tag).toBe('Submitting');
|
||||
});
|
||||
|
||||
it('Primary is a no-op from a non-editing state', () => {
|
||||
const submitting = submit(reduce(reduce(highUren, { tag: 'Next' }), { tag: 'Next' }));
|
||||
expect(primary(submitting)).toBe(submitting);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reduce (message-driven happy path)', () => {
|
||||
it('drives abroad branch end to end', () => {
|
||||
let s: IntakeState = initial;
|
||||
|
||||
@@ -60,7 +60,7 @@ export const STEPS: StepId[] = ['buitenland', 'werk', 'review'];
|
||||
// #endregion showcase:steps
|
||||
|
||||
/** Per-field error map: one message per question, since a step holds several. */
|
||||
type Errors = Partial<Record<keyof Answers, string>>;
|
||||
export type Errors = Partial<Record<keyof Answers, string>>;
|
||||
|
||||
export type IntakeState =
|
||||
| {
|
||||
@@ -112,8 +112,12 @@ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Resu
|
||||
if (!u.ok) errors.uren = u.error;
|
||||
if (lageUren(a, scholingThreshold) && !a.scholingGevolgd)
|
||||
errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`;
|
||||
// Nascholingspunten are only asked (and required) when scholing was followed.
|
||||
if (a.scholingGevolgd === 'ja') {
|
||||
// Nascholingspunten are only asked (and required) when the question is actually
|
||||
// visible (lageUren) AND scholing was followed — matching the template's
|
||||
// `@if (scholingZichtbaar())`. Without the `lageUren` guard, answering 'ja' and then
|
||||
// raising uren above the threshold left an error on a field the template no longer
|
||||
// renders.
|
||||
if (lageUren(a, scholingThreshold) && a.scholingGevolgd === 'ja') {
|
||||
const p = parseUren(a.punten ?? '');
|
||||
if (!p.ok) errors.punten = p.error;
|
||||
}
|
||||
@@ -142,14 +146,19 @@ function validateAll(a: Answers, scholingThreshold: number): Result<Errors, Vali
|
||||
|
||||
const werktBuitenland = a.buitenlandGewerkt === 'ja';
|
||||
const buitenland = parseUren(a.buitenlandseUren ?? '');
|
||||
// Punten are only collected when aanvullende scholing was gevolgd.
|
||||
const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined;
|
||||
const aanvullendeScholing = lageUren(a, scholingThreshold)
|
||||
? a.scholingGevolgd === 'ja'
|
||||
: undefined;
|
||||
// Punten are derived from aanvullendeScholing, NOT the raw scholingGevolgd answer —
|
||||
// a stale 'ja' left over from when uren was low, after uren was raised above the
|
||||
// threshold, must not leak a punten value into the parsed, submitted ValidIntake.
|
||||
const punten = aanvullendeScholing === true ? parseUren(a.punten ?? '') : undefined;
|
||||
return ok({
|
||||
werktBuitenland,
|
||||
land: werktBuitenland ? a.land : undefined,
|
||||
buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined,
|
||||
uren: uren.value,
|
||||
aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined,
|
||||
aanvullendeScholing,
|
||||
punten: punten?.ok ? punten.value : undefined,
|
||||
});
|
||||
}
|
||||
@@ -191,6 +200,13 @@ export function submit(s: IntakeState): IntakeState {
|
||||
return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error };
|
||||
}
|
||||
|
||||
/** The primary button's action: advance, or submit from the review step. No-op
|
||||
outside Answering — this is the one decision the old component-side handler used to make. */
|
||||
export function primary(s: IntakeState): IntakeState {
|
||||
if (s.tag !== 'Answering') return s;
|
||||
return currentStep(s) === 'review' ? submit(s) : next(s);
|
||||
}
|
||||
|
||||
export function resolve(s: IntakeState, r: Result<string, void>): IntakeState {
|
||||
if (s.tag !== 'Submitting') return s;
|
||||
return r.ok
|
||||
@@ -204,6 +220,7 @@ export type IntakeMsg =
|
||||
| { tag: 'Back' }
|
||||
| { tag: 'GaNaarStap'; cursor: number }
|
||||
| { tag: 'Submit' }
|
||||
| { tag: 'Primary' }
|
||||
| { tag: 'Retry' }
|
||||
| { tag: 'SubmitConfirmed' }
|
||||
| { tag: 'SubmitFailed'; error: string }
|
||||
@@ -222,6 +239,8 @@ export function reduce(s: IntakeState, m: IntakeMsg): IntakeState {
|
||||
return gaNaarStap(s, m.cursor);
|
||||
case 'Submit':
|
||||
return submit(s);
|
||||
case 'Primary':
|
||||
return primary(s);
|
||||
case 'Retry':
|
||||
return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s;
|
||||
case 'SubmitConfirmed':
|
||||
|
||||
+76
-88
@@ -1,15 +1,16 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
WizardPhase,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
@@ -22,10 +23,9 @@ import {
|
||||
hasProgress,
|
||||
} from '@herregistratie/domain/herregistratie.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/upload/upload-controller';
|
||||
import { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||
import { DocumentUploadComponent } from '@shared/ui/organisms/upload/document-upload/document-upload.component';
|
||||
import { createUploadController } from '@shared/application/upload-controller';
|
||||
import { UploadState, initialUpload, digitalDocumentIds } from '@shared/domain/upload.machine';
|
||||
|
||||
/** Organism: multi-step herregistratie wizard. ALL state lives in one signal
|
||||
driven by the pure `reduce` function (see herregistratie.machine.ts) via an
|
||||
@@ -51,15 +51,14 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@herregWizard.processName"
|
||||
processName="Herregistratie aanvragen"
|
||||
[status]="shellStatus()"
|
||||
[phase]="phase()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="step() > 1"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(primary)="dispatch({ tag: 'Primary' })"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(retry)="dispatch({ tag: 'Retry' })"
|
||||
(goToStep)="goToStep($event)"
|
||||
>
|
||||
@switch (step()) {
|
||||
@@ -149,37 +148,31 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.
|
||||
})
|
||||
export class HerregistratieWizardComponent {
|
||||
private profile = inject(BigProfileStore);
|
||||
private uploadAdapter = inject(UploadAdapter);
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce);
|
||||
|
||||
/** Preview/download link for a completed upload; dev-simulation `demo-*` ids have
|
||||
no stored bytes, so they get no link. */
|
||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||
documentId.startsWith('demo-') ? undefined : this.uploadAdapter.contentUrl(documentId);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<WizardState>(initial);
|
||||
|
||||
// --- The store: all state in one signal, changed only by a pure reduce -----
|
||||
// The effect fires once, on the `Editing -> Submitting` transition. `Seed` is exempt,
|
||||
// so a story that mounts straight into `Submitting` does not call the network.
|
||||
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
||||
private store = createStore<WizardState, WizardMsg>(initial, reduce, {
|
||||
Submitting: async (s, store) => {
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
||||
if (r.ok) {
|
||||
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
},
|
||||
});
|
||||
readonly state = this.store.model; // public so the showcase can highlight the live state
|
||||
protected dispatch = this.store.dispatch;
|
||||
|
||||
// Backend draft-sync (new persistence for this wizard): create a Concept on first
|
||||
// progress, debounced-sync the snapshot, resume by `?aanvraag=<id>`.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'herregistratie',
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
||||
const documentIds = deliveryRefs(s.upload)
|
||||
.filter((r) => r.channel === 'digital' && r.documentId)
|
||||
.map((r) => r.documentId!);
|
||||
return { draft: s, stepIndex: s.step - 1, stepCount: this.stepLabels.length, documentIds };
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
|
||||
// Stepper labels + per-step heading titles (presentational only).
|
||||
// --- Static copy: stepper labels and per-step headings ---------------------
|
||||
readonly stepLabels = [
|
||||
$localize`:@@herregWizard.step.werkervaring:Werkervaring`,
|
||||
$localize`:@@herregWizard.step.nascholing:Nascholing`,
|
||||
@@ -191,6 +184,7 @@ export class HerregistratieWizardComponent {
|
||||
$localize`:@@herregWizard.title.documenten:Documenten aanleveren`,
|
||||
];
|
||||
|
||||
// --- State projections: one narrow, then read-only views of it -------------
|
||||
private editing = computed(() => whenTag(this.state(), 'Editing'));
|
||||
protected step = computed(() => this.editing()?.step ?? 1);
|
||||
protected draft = computed<Draft>(
|
||||
@@ -201,12 +195,35 @@ export class HerregistratieWizardComponent {
|
||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Controllers: persistence and uploads ----------------------------------
|
||||
// Create a Concept on first progress, then debounced-sync the snapshot.
|
||||
// `?aanvraag=<id>` resumes it.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'herregistratie',
|
||||
snapshot: () => {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing' || !hasProgress(s)) return null;
|
||||
return {
|
||||
draft: s,
|
||||
stepIndex: s.step - 1,
|
||||
stepCount: this.stepLabels.length,
|
||||
documentIds: digitalDocumentIds(s.upload),
|
||||
};
|
||||
},
|
||||
onResume: (draft) => this.dispatch({ tag: 'Seed', state: draft as WizardState }),
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
protected uploadCtl = createUploadController({
|
||||
wizardId: 'herregistratie',
|
||||
getUpload: () => this.upload(),
|
||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
||||
});
|
||||
/** Preview/download link for a completed upload; delegates to the upload
|
||||
controller (application layer), which knows the dev-simulation `demo-*` ids
|
||||
have no stored bytes and returns no link for them. */
|
||||
protected previewUrlFor = (documentId: string): string | undefined =>
|
||||
this.uploadCtl.previewUrlFor(documentId);
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step() - 1]);
|
||||
@@ -216,33 +233,32 @@ export class HerregistratieWizardComponent {
|
||||
? naarStapLabel(step + 1, this.stepLabels[step])
|
||||
: $localize`:@@herregWizard.indienen:Herregistratie aanvragen`;
|
||||
});
|
||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||
protected phase = computed<WizardPhase>(() => {
|
||||
const s = this.state();
|
||||
switch (s.tag) {
|
||||
case 'Editing':
|
||||
return { tag: 'Editing' };
|
||||
case 'Submitting':
|
||||
return { tag: 'Submitting' };
|
||||
case 'Submitted':
|
||||
return { tag: 'Submitted' };
|
||||
case 'Failed':
|
||||
return {
|
||||
tag: 'Failed',
|
||||
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. */
|
||||
protected errorList = computed<WizardError[]>(() => toWizardErrors(this.editing()?.errors ?? {}));
|
||||
|
||||
// --- Event handlers: narrow a child event into a message -------------------
|
||||
/** Stepper emits a 0-based index for an earlier (visited) step. */
|
||||
protected goToStep(index: number) {
|
||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||
}
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
case 'Editing':
|
||||
return 'editing';
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.editing()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof typeof e)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
@@ -253,37 +269,9 @@ export class HerregistratieWizardComponent {
|
||||
);
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Editing') return;
|
||||
this.dispatch(s.step < 3 ? { tag: 'Next' } : { tag: 'Submit' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
/** Reset the wizard to a fresh, empty start. */
|
||||
restart() {
|
||||
this.draftSync.reset();
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we entered Submitting, submit through the aanvraag lifecycle,
|
||||
flip the optimistic cross-page flag, then dispatch the result (commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren, documents: s.data.documents });
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideApiClient } from '@shared/infrastructure/api-client.provider';
|
||||
import { HerregistratieWizardComponent } from './herregistratie-wizard.component';
|
||||
import { WizardState } from '@herregistratie/domain/herregistratie.machine';
|
||||
import { initialUpload } from '@shared/upload/upload.machine';
|
||||
import { initialUpload } from '@shared/domain/upload.machine';
|
||||
import { Uren } from '@registratie/domain/value-objects/uren';
|
||||
|
||||
const validData = { uren: 4160 as Uren, jaren: 5, punten: 200, documents: [] };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { ASYNC } from '@shared/ui/molecules/async/async.component';
|
||||
import { map } from '@shared/application/remote-data';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
import { HerregistratieWizardComponent } from '@herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/molecules/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/atoms/text-input/text-input.component';
|
||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/atoms/radio-group/radio-group.component';
|
||||
import { Answers, Errors } from '@herregistratie/domain/intake.machine';
|
||||
|
||||
/** Step: the intake wizard's first screen (foreign work in the last 5 years).
|
||||
Pure & presentational — values in via `answers`/`errors`, every keystroke out
|
||||
via `answerChange`. No store, no services, no internal state; the parent owns
|
||||
the Model and decides what a change means. */
|
||||
@Component({
|
||||
selector: 'app-intake-buitenland-step',
|
||||
imports: [FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent],
|
||||
template: `
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenland"
|
||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||
fieldId="buitenlandGewerkt"
|
||||
required
|
||||
[error]="err('buitenlandGewerkt')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="buitenlandGewerkt"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||
(ngModelChange)="answerChange.emit({ key: 'buitenlandGewerkt', value: $event })"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.land"
|
||||
label="In welk land?"
|
||||
fieldId="land"
|
||||
required
|
||||
[error]="err('land')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="land"
|
||||
[ngModel]="answers().land ?? ''"
|
||||
(ngModelChange)="answerChange.emit({ key: 'land', value: $event })"
|
||||
name="land"
|
||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||
placeholder="bijv. België"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenlandseUren"
|
||||
label="Hoeveel uur heeft u daar gewerkt?"
|
||||
fieldId="buitenlandseUren"
|
||||
required
|
||||
[error]="err('buitenlandseUren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="buitenlandseUren"
|
||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||
(ngModelChange)="answerChange.emit({ key: 'buitenlandseUren', value: $event })"
|
||||
name="buitenlandseUren"
|
||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||
placeholder="bijv. 800"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class BuitenlandStep {
|
||||
answers = input.required<Answers>();
|
||||
errors = input.required<Errors>();
|
||||
answerChange = output<{ key: keyof Answers; value: string }>();
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
protected err = (k: keyof Answers) => this.errors()[k] ?? '';
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import { Component, computed, effect, inject, input, untracked } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { RadioGroupComponent, JA_NEE } from '@shared/ui/radio-group/radio-group.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
import { ReviewSectionComponent } from '@shared/ui/review-section/review-section.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
import { ButtonComponent } from '@shared/ui/atoms/button/button.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/molecules/confirmation/confirmation.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
WizardPhase,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { toWizardErrors } from '@shared/layout/wizard-shell/wizard-errors';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { whenTag } from '@shared/kernel/fp';
|
||||
import { BigProfileStore } from '@registratie/application/big-profile.store';
|
||||
@@ -21,35 +15,35 @@ import {
|
||||
IntakeState,
|
||||
IntakeMsg,
|
||||
Answers,
|
||||
Errors,
|
||||
StepId,
|
||||
initial,
|
||||
reduce,
|
||||
STEPS,
|
||||
lageUren,
|
||||
hasProgress,
|
||||
SCHOLING_THRESHOLD_DEFAULT,
|
||||
} from '@herregistratie/domain/intake.machine';
|
||||
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||
import { IntakePolicyStore } from '@herregistratie/application/intake-policy.store';
|
||||
import { BuitenlandStep } from './buitenland.step';
|
||||
import { WerkStep } from './werk.step';
|
||||
import { ReviewStep } from './review.step';
|
||||
|
||||
/** Organism: a BRANCHING intake questionnaire. All state lives in one signal
|
||||
driven by the pure `reduce` (intake.machine.ts). Which step renders is derived
|
||||
from the answers via `visibleSteps`, never stored — so editing an earlier
|
||||
answer immediately changes the remaining steps. Answers are persisted to
|
||||
sessionStorage so a page reload keeps the user's progress (cleared on tab close). */
|
||||
answer immediately changes the remaining steps. The draft persists to the
|
||||
backend as a Concept aanvraag (createDraftSync), so a reload — or a "Verder
|
||||
gaan" from the dashboard via `?aanvraag=<id>` — resumes progress. */
|
||||
@Component({
|
||||
selector: 'app-intake-wizard',
|
||||
imports: [
|
||||
FormsModule,
|
||||
FormFieldComponent,
|
||||
TextInputComponent,
|
||||
RadioGroupComponent,
|
||||
ButtonComponent,
|
||||
AlertComponent,
|
||||
DataRowComponent,
|
||||
ReviewSectionComponent,
|
||||
ConfirmationComponent,
|
||||
WizardShellComponent,
|
||||
BuitenlandStep,
|
||||
WerkStep,
|
||||
ReviewStep,
|
||||
],
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
@@ -58,193 +52,38 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@intake.processName"
|
||||
processName="Herregistratie-intake"
|
||||
[status]="shellStatus()"
|
||||
[phase]="phase()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="onPrimary()"
|
||||
(primary)="dispatch({ tag: 'Primary' })"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
(retry)="onRetry()"
|
||||
(retry)="dispatch({ tag: 'Retry' })"
|
||||
(goToStep)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||
>
|
||||
@switch (step()) {
|
||||
@case ('buitenland') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenland"
|
||||
label="Heeft u de afgelopen 5 jaar buiten Nederland gewerkt?"
|
||||
fieldId="buitenlandGewerkt"
|
||||
required
|
||||
[error]="err('buitenlandGewerkt')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="buitenlandGewerkt"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().buitenlandGewerkt ?? ''"
|
||||
(ngModelChange)="set('buitenlandGewerkt', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.land"
|
||||
label="In welk land?"
|
||||
fieldId="land"
|
||||
required
|
||||
[error]="err('land')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="land"
|
||||
[ngModel]="answers().land ?? ''"
|
||||
(ngModelChange)="set('land', $event)"
|
||||
name="land"
|
||||
i18n-placeholder="@@intake.q.landPlaceholder"
|
||||
placeholder="bijv. België"
|
||||
/>
|
||||
</app-form-field>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.buitenlandseUren"
|
||||
label="Hoeveel uur heeft u daar gewerkt?"
|
||||
fieldId="buitenlandseUren"
|
||||
required
|
||||
[error]="err('buitenlandseUren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="buitenlandseUren"
|
||||
[ngModel]="answers().buitenlandseUren ?? ''"
|
||||
(ngModelChange)="set('buitenlandseUren', $event)"
|
||||
name="buitenlandseUren"
|
||||
i18n-placeholder="@@intake.q.buitenlandseUrenPlaceholder"
|
||||
placeholder="bijv. 800"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
<app-intake-buitenland-step
|
||||
[answers]="answers()"
|
||||
[errors]="errors()"
|
||||
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||
/>
|
||||
}
|
||||
@case ('werk') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.urenNl"
|
||||
label="Gewerkte uren in Nederland (afgelopen 5 jaar)"
|
||||
fieldId="uren"
|
||||
required
|
||||
[error]="err('uren')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="uren"
|
||||
[ngModel]="answers().uren ?? ''"
|
||||
(ngModelChange)="set('uren', $event)"
|
||||
name="uren"
|
||||
i18n-placeholder="@@intake.q.urenNlPlaceholder"
|
||||
placeholder="bijv. 4160"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
@if (scholingZichtbaar()) {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.scholing"
|
||||
label="U werkte relatief weinig uren. Heeft u aanvullende scholing gevolgd?"
|
||||
fieldId="scholingGevolgd"
|
||||
required
|
||||
[error]="err('scholingGevolgd')"
|
||||
>
|
||||
<app-radio-group
|
||||
name="scholingGevolgd"
|
||||
[options]="jaNee"
|
||||
[ngModel]="answers().scholingGevolgd ?? ''"
|
||||
(ngModelChange)="set('scholingGevolgd', $event)"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<fieldset>
|
||||
<app-form-field
|
||||
i18n-label="@@intake.q.punten"
|
||||
label="Behaalde nascholingspunten"
|
||||
fieldId="punten"
|
||||
required
|
||||
[error]="err('punten')"
|
||||
>
|
||||
<app-text-input
|
||||
inputId="punten"
|
||||
[ngModel]="answers().punten ?? ''"
|
||||
(ngModelChange)="set('punten', $event)"
|
||||
name="punten"
|
||||
i18n-placeholder="@@intake.q.puntenPlaceholder"
|
||||
placeholder="bijv. 200"
|
||||
/>
|
||||
</app-form-field>
|
||||
</fieldset>
|
||||
}
|
||||
<app-intake-werk-step
|
||||
[answers]="answers()"
|
||||
[errors]="errors()"
|
||||
[scholingThreshold]="scholingThreshold()"
|
||||
(answerChange)="dispatch({ tag: 'SetAnswer', key: $event.key, value: $event.value })"
|
||||
/>
|
||||
}
|
||||
@case ('review') {
|
||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||
>
|
||||
<app-review-section
|
||||
i18n-heading="@@intake.sectie.buitenland"
|
||||
heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||
editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 0 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenNl"
|
||||
key="Buiten NL gewerkt"
|
||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||
></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.land"
|
||||
key="Land"
|
||||
[value]="answers().land ?? ''"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenlandseUren"
|
||||
key="Buitenlandse uren"
|
||||
[value]="answers().buitenlandseUren ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-review-section
|
||||
class="app-section"
|
||||
i18n-heading="@@intake.sectie.werk"
|
||||
heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||
editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: 1 })"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.urenNl"
|
||||
key="Uren NL"
|
||||
[value]="answers().uren ?? ''"
|
||||
></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.scholing"
|
||||
key="Aanvullende scholing"
|
||||
[value]="answers().scholingGevolgd ?? ''"
|
||||
></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.punten"
|
||||
key="Nascholingspunten"
|
||||
[value]="answers().punten ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-intake-review-step
|
||||
[answers]="answers()"
|
||||
[scholingThreshold]="scholingThreshold()"
|
||||
(edit)="dispatch({ tag: 'GaNaarStap', cursor: $event })"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -268,16 +107,66 @@ export class IntakeWizardComponent {
|
||||
// Server-owned policy (scholing threshold): fetched from the backend via the
|
||||
// application facade, not hardcoded. The backend stays the authority on submit.
|
||||
private policyStore = inject(IntakePolicyStore);
|
||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce);
|
||||
|
||||
/** Optional seed so Storybook / the showcase can mount any state directly. */
|
||||
seed = input<IntakeState>(initial);
|
||||
|
||||
readonly jaNee = JA_NEE;
|
||||
// --- The store: all state in one signal, changed only by a pure reduce -----
|
||||
// The effect fires once, on the `Answering -> Submitting` transition. `Seed` is exempt,
|
||||
// so a story that mounts straight into `Submitting` does not call the network.
|
||||
// `draftSync` is declared below (both callbacks are deferred, so the cycle is safe).
|
||||
private store = createStore<IntakeState, IntakeMsg>(initial, reduce, {
|
||||
Submitting: async (s, store) => {
|
||||
this.profile.beginHerregistratie();
|
||||
// The scholing answer rides along so the server can re-validate it as the
|
||||
// authority (IntakePolicy.RejectIncompleteScholing) — undefined members are dropped by
|
||||
// JSON.stringify, so a wizard above the threshold sends neither field.
|
||||
const r = await this.draftSync.submit({
|
||||
uren: s.data.uren,
|
||||
aanvullendeScholing: s.data.aanvullendeScholing,
|
||||
scholingPunten: s.data.punten,
|
||||
});
|
||||
if (r.ok) {
|
||||
store.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
store.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
readonly state = this.store.model;
|
||||
readonly dispatch = this.store.dispatch;
|
||||
|
||||
// Backend draft-sync (replaces sessionStorage); the intake has no uploads.
|
||||
// --- Static copy: stepper labels and per-step headings ---------------------
|
||||
readonly stepLabels = [
|
||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||
$localize`:@@intake.step.werk:Werk`,
|
||||
$localize`:@@intake.step.controle:Controle`,
|
||||
];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
||||
};
|
||||
|
||||
// --- State projections: one narrow, then read-only views of it -------------
|
||||
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||
readonly steps = STEPS;
|
||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||
protected scholingThreshold = computed(
|
||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
protected errors = computed<Errors>(() => this.answering()?.errors ?? {});
|
||||
|
||||
// --- Controllers: persistence and uploads ----------------------------------
|
||||
// Create a Concept on first progress, then debounced-sync the snapshot.
|
||||
// `?aanvraag=<id>` resumes it. The intake has no uploads.
|
||||
private draftSync = createDraftSync({
|
||||
type: 'intake',
|
||||
snapshot: () => {
|
||||
@@ -289,64 +178,36 @@ export class IntakeWizardComponent {
|
||||
enabled: () => this.seed() === initial,
|
||||
});
|
||||
|
||||
private answering = computed(() => whenTag(this.state(), 'Answering'));
|
||||
/** Public so the showcase can render the (fixed) step list next to the wizard. */
|
||||
readonly steps = STEPS;
|
||||
protected cursor = computed(() => this.answering()?.cursor ?? 0);
|
||||
protected answers = computed<Answers>(() => this.answering()?.answers ?? {});
|
||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||
/** Server-owned threshold from the policy endpoint (mirrored into machine state). */
|
||||
protected scholingThreshold = computed(
|
||||
() => this.answering()?.scholingThreshold ?? SCHOLING_THRESHOLD_DEFAULT,
|
||||
);
|
||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = [
|
||||
$localize`:@@intake.step.buitenland:Buitenland`,
|
||||
$localize`:@@intake.step.werk:Werk`,
|
||||
$localize`:@@intake.step.controle:Controle`,
|
||||
];
|
||||
private stepTitles: Record<StepId, string> = {
|
||||
buitenland: $localize`:@@intake.title.buitenland:Werken in het buitenland`,
|
||||
werk: $localize`:@@intake.title.werk:Werkervaring in Nederland`,
|
||||
review: $localize`:@@intake.title.review:Controleren en indienen`,
|
||||
};
|
||||
protected stepTitle = computed(() => this.stepTitles[this.step()]);
|
||||
protected primaryLabel = computed(() => {
|
||||
if (this.step() === 'review') return $localize`:@@intake.indienen:Aanvraag indienen`;
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||
protected phase = computed<WizardPhase>(() => {
|
||||
const s = this.state();
|
||||
switch (s.tag) {
|
||||
case 'Answering':
|
||||
return 'editing';
|
||||
return { tag: 'Editing' };
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
return { tag: 'Submitting' };
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
return { tag: 'Submitted' };
|
||||
case 'Failed':
|
||||
return 'failed';
|
||||
return {
|
||||
tag: 'Failed',
|
||||
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||
};
|
||||
}
|
||||
});
|
||||
/** Current step's field errors, flattened for the shell's error summary. The
|
||||
field ids match the answer keys, so the summary anchors jump to the field. */
|
||||
protected errorList = computed<WizardError[]>(() => {
|
||||
const e = this.answering()?.errors ?? {};
|
||||
return (Object.keys(e) as (keyof Answers)[])
|
||||
.filter((k) => e[k])
|
||||
.map((k) => ({ id: k, message: e[k]! }));
|
||||
});
|
||||
|
||||
protected err = (k: keyof Answers) => this.answering()?.errors[k] ?? '';
|
||||
protected set = (key: keyof Answers, value: string) =>
|
||||
this.dispatch({ tag: 'SetAnswer', key, value });
|
||||
protected errorList = computed<WizardError[]>(() =>
|
||||
toWizardErrors(this.answering()?.errors ?? {}),
|
||||
);
|
||||
|
||||
constructor() {
|
||||
// An explicit seed (stories/tests) wins; otherwise resume the backend draft
|
||||
@@ -364,36 +225,8 @@ export class IntakeWizardComponent {
|
||||
});
|
||||
}
|
||||
|
||||
onPrimary() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Answering') return;
|
||||
this.dispatch(this.step() === 'review' ? { tag: 'Submit' } : { tag: 'Next' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
onRetry() {
|
||||
this.dispatch({ tag: 'Retry' });
|
||||
this.runIfSubmitting();
|
||||
}
|
||||
|
||||
restart() {
|
||||
this.draftSync.reset();
|
||||
this.dispatch({ tag: 'Seed', state: initial });
|
||||
}
|
||||
|
||||
/** The effect: when we enter Submitting, submit through the aanvraag lifecycle,
|
||||
flip the optimistic cross-page flag, then dispatch the outcome (commit/rollback). */
|
||||
private async runIfSubmitting() {
|
||||
const s = this.state();
|
||||
if (s.tag !== 'Submitting') return;
|
||||
this.profile.beginHerregistratie();
|
||||
const r = await this.draftSync.submit({ uren: s.data.uren });
|
||||
if (r.ok) {
|
||||
this.dispatch({ tag: 'SubmitConfirmed' });
|
||||
this.profile.confirmHerregistratie();
|
||||
} else {
|
||||
this.dispatch({ tag: 'SubmitFailed', error: r.error });
|
||||
this.profile.rollbackHerregistratie();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { AlertComponent } from '@shared/ui/atoms/alert/alert.component';
|
||||
import { DataRowComponent } from '@shared/ui/molecules/data-row/data-row.component';
|
||||
import { ReviewSectionComponent } from '@shared/ui/molecules/review-section/review-section.component';
|
||||
import { Answers, lageUren } from '@herregistratie/domain/intake.machine';
|
||||
|
||||
/** Step: the intake wizard's review screen. Pure & presentational — values in via
|
||||
`answers`/`scholingThreshold`, the cursor to jump back to out via `edit`. No
|
||||
store, no services, no internal state; the parent maps the cursor onto its own
|
||||
`GaNaarStap` message. */
|
||||
@Component({
|
||||
selector: 'app-intake-review-step',
|
||||
imports: [AlertComponent, DataRowComponent, ReviewSectionComponent],
|
||||
template: `
|
||||
<app-alert type="info" i18n="@@intake.review.controleer"
|
||||
>Controleer uw antwoorden en dien de aanvraag in.</app-alert
|
||||
>
|
||||
<app-review-section
|
||||
i18n-heading="@@intake.sectie.buitenland"
|
||||
heading="Buitenland"
|
||||
i18n-editAriaLabel="@@intake.buitenlandWijzigenAria"
|
||||
editAriaLabel="Wijzigen buitenland"
|
||||
(edit)="edit.emit(0)"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenNl"
|
||||
key="Buiten NL gewerkt"
|
||||
[value]="answers().buitenlandGewerkt ?? '—'"
|
||||
></div>
|
||||
@if (answers().buitenlandGewerkt === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.land"
|
||||
key="Land"
|
||||
[value]="answers().land ?? ''"
|
||||
></div>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.buitenlandseUren"
|
||||
key="Buitenlandse uren"
|
||||
[value]="answers().buitenlandseUren ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
<app-review-section
|
||||
class="app-section"
|
||||
i18n-heading="@@intake.sectie.werk"
|
||||
heading="Werk in Nederland"
|
||||
i18n-editAriaLabel="@@intake.werkWijzigenAria"
|
||||
editAriaLabel="Wijzigen werk in Nederland"
|
||||
(edit)="edit.emit(1)"
|
||||
>
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.urenNl"
|
||||
key="Uren NL"
|
||||
[value]="answers().uren ?? ''"
|
||||
></div>
|
||||
@if (scholingZichtbaar()) {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.scholing"
|
||||
key="Aanvullende scholing"
|
||||
[value]="answers().scholingGevolgd ?? ''"
|
||||
></div>
|
||||
}
|
||||
@if (answers().scholingGevolgd === 'ja') {
|
||||
<div
|
||||
app-data-row
|
||||
i18n-key="@@intake.review.punten"
|
||||
key="Nascholingspunten"
|
||||
[value]="answers().punten ?? ''"
|
||||
></div>
|
||||
}
|
||||
</app-review-section>
|
||||
`,
|
||||
})
|
||||
export class ReviewStep {
|
||||
answers = input.required<Answers>();
|
||||
scholingThreshold = input.required<number>();
|
||||
edit = output<number>();
|
||||
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user