Applications, documents (+ audit log) and the brief move off static in-memory Dictionaries onto a real SQLite file via EF Core, so demo data survives a process restart or `docker compose restart api` for the first time. The three stores (ApplicationStore/DocumentStore/BriefStore) keep their exact public signatures and static-class shape — no DI, no async ripple into Program.cs's minimal-API handlers — each method just opens a short-lived AppDbContext via Db.Create() under the same lock it already had. Opaque nested shapes (a wizard's draft snapshot, a brief's sections/placeholders/status) are stored as JSON text columns rather than redesigned into relational tables, matching the existing "don't interpret it" posture. Found two things the WP's own text got wrong, corrected in docs/backlog/WP-22-durable-persistence.md's Deviations section: SeedData never seeded these three stores (only the read-only BRP/DUO-mimicking GETs, which stay in-memory) so there's no seed step; and no new docker-compose volume is needed since the existing bind mount already covers the SQLite file — verified against this environment's real podman-backed compose stack, not just by reading the file. Also: pinned SQLitePCLRaw.bundle_e_sqlite3 to 3.0.3 (EF Core Sqlite's own transitive default bundles a pre-3.50.2 SQLite with a known high-severity memory-corruption advisory); found and fixed a real xUnit test race where concurrent test-class hosts stomped a shared static connection-string field, fixed by disabling cross-class test parallelization rather than adding DI the stores don't otherwise need. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
206 lines
12 KiB
Markdown
206 lines
12 KiB
Markdown
# BIG-register Self-Service Portal — 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.
|
|
|
|
> 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.
|
|
|
|
---
|
|
|
|
## Run it
|
|
|
|
```bash
|
|
docker compose up # frontend + backend together → app http://localhost:4200, Swagger http://localhost:5000/swagger
|
|
```
|
|
|
|
Or run the two halves separately:
|
|
|
|
```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 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)
|
|
```
|
|
|
|
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)**.
|
|
|
|
> **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/ARCHITECTURE.md](docs/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)
|
|
|
|
Append `?scenario=` to any data page (e.g. `/dashboard`) to force an async state:
|
|
|
|
| URL | What you see |
|
|
| ----------------------------- | -------------------------------------------- |
|
|
| `/dashboard` | real data (fast) |
|
|
| `/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.
|
|
|
|
---
|
|
|
|
## 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.
|
|
|
|
### Dependency security
|
|
|
|
The **shipped app has 0 known vulnerabilities** (`npm audit --omit=dev`). All advisories
|
|
live in dev/build tooling (Storybook + the Angular build chain) and never reach the
|
|
bundle. `package.json` `overrides` pin patched transitive versions, taking the full
|
|
audit from 16 (incl. 3 high) down to **5 low** — the remainder all cascade from
|
|
`@babel/core`'s low-severity sourceMappingURL issue, which only "fixes" by jumping to
|
|
Babel 8 (a breaking change across the Storybook/Babel chain) and is deliberately left.
|
|
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.
|