diff --git a/.dependency-cruiser.base.js b/.dependency-cruiser.base.js index b6f1e0b..e80da5e 100644 --- a/.dependency-cruiser.base.js +++ b/.dependency-cruiser.base.js @@ -109,6 +109,29 @@ module.exports = function buildConfig(contextAllowed, appName, tsConfigFileName) }, }, + // --- 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: diff --git a/.dependency-cruiser.ssp.js b/.dependency-cruiser.ssp.js index 52df921..3fc53b1 100644 --- a/.dependency-cruiser.ssp.js +++ b/.dependency-cruiser.ssp.js @@ -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 }, diff --git a/CLAUDE.md b/CLAUDE.md index 3c7401a..f81bc1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -210,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//`; -a component in an app context's `ui/` is titled `Domein//` — full stop, -regardless of which atomic layer it is (a context organism doesn't get its own -`Organisms/` bucket). +component is titled `Design System//`; +a component in an app context's `ui/`, or in `libs/beheer/ui`, is titled +`Domein//` — full stop, regardless of which atomic layer it is (a context +organism doesn't get its own `Organisms/` bucket). ## Conventions diff --git a/apps/behandelportal/src/app/app.routes.ts b/apps/behandelportal/src/app/app.routes.ts index 96c85a2..80106c6 100644 --- a/apps/behandelportal/src/app/app.routes.ts +++ b/apps/behandelportal/src/app/app.routes.ts @@ -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), diff --git a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts index 85cc7ee..9b3493a 100644 --- a/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts +++ b/apps/behandelportal/src/app/auth/infrastructure/medewerker.adapter.ts @@ -4,7 +4,7 @@ import { MEDEWERKER_ID, currentRollen } from './medewerker'; /** * Infrastructure: resolves the current medewerker identity into a `Principal` - * (ADR-C-004/RB-13). Stands in for a real employee-SSO redirect flow (ADR-0002 §3, + * (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 diff --git a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts index 6d9b5ce..0065e4c 100644 --- a/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/behandelportal/src/app/auth/ui/login-form/login-form.component.ts @@ -1,8 +1,8 @@ import { Component, output } from '@angular/core'; -import { ButtonComponent } from '@shared/ui/button/button.component'; +import { ButtonComponent } from '@shared/ui/atoms/button/button.component'; /** - * Organism: employee-SSO-style mock login (ADR-C-004/RB-13). No real auth — and, + * 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 diff --git a/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts index da5befc..c908b0b 100644 --- a/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts +++ b/apps/behandelportal/src/app/behandeling/application/beoordeling.store.ts @@ -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 { diff --git a/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts index 19b2509..e5ba8ca 100644 --- a/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts +++ b/apps/behandelportal/src/app/behandeling/application/werkvoorraad.store.ts @@ -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 { diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts index 091ee01..1594c86 100644 --- a/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling-view.ts @@ -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. */ diff --git a/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts index 5a1bb7e..5c42963 100644 --- a/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts +++ b/apps/behandelportal/src/app/behandeling/domain/beoordeling.ts @@ -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). */ diff --git a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts index ef9a56c..3462602 100644 --- a/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts +++ b/apps/behandelportal/src/app/behandeling/domain/besluit.machine.ts @@ -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; diff --git a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts index b5fcb35..1aed5a7 100644 --- a/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts +++ b/apps/behandelportal/src/app/behandeling/domain/werkvoorraad-item.ts @@ -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 diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts index fbb0d5d..85a379b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/beoordeling.adapter.ts @@ -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. */ diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts index 0af191f..bee51d2 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/besluit.adapter.ts @@ -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). diff --git a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts index c48346b..99a099b 100644 --- a/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts +++ b/apps/behandelportal/src/app/behandeling/infrastructure/werkvoorraad.adapter.ts @@ -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 diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts index d8c4210..5acfc73 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling-documenten/beoordeling-documenten.component.ts @@ -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({ diff --git a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts index cf8971e..f6771ce 100644 --- a/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/beoordeling.page.ts @@ -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); diff --git a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts index e26a30f..37b3c1b 100644 --- a/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/besluit-form/besluit-form.component.ts @@ -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') { Het besluit is vastgelegd. + } @else if (state().tag === 'Failed') { + Besluit vastleggen + + Het vastleggen is niet gelukt: + {{ failedError() }} + + +
+ @if (toelichting()) { +
+ } +
+ +
+ Opnieuw proberen +
} @else { Besluit vastleggen @@ -70,13 +100,6 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; /> - @if (failedError()) { - Het vastleggen is niet gelukt: - {{ failedError() }} - } - {{ state().tag === 'Submitting' ? submitBezigLabel : submitLabel }} @@ -86,7 +109,19 @@ import { createSubmitBesluit } from '@behandeling/application/submit-besluit'; }) export class BesluitFormComponent { private submit = createSubmitBesluit(); - private store = createStore(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(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(); decided = output(); @@ -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 }); - } } } diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts index c40cf68..86acd68 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad-list/werkvoorraad-list.component.ts @@ -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], diff --git a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts index ce8ed3f..69844b0 100644 --- a/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts +++ b/apps/behandelportal/src/app/behandeling/ui/werkvoorraad.page.ts @@ -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; diff --git a/apps/behandelportal/src/app/shell/nav.config.ts b/apps/behandelportal/src/app/shell/nav.config.ts index 456019b..c8b4e6b 100644 --- a/apps/behandelportal/src/app/shell/nav.config.ts +++ b/apps/behandelportal/src/app/shell/nav.config.ts @@ -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`, diff --git a/apps/ssp/src/app/app.routes.ts b/apps/ssp/src/app/app.routes.ts index 67618a5..0a546fa 100644 --- a/apps/ssp/src/app/app.routes.ts +++ b/apps/ssp/src/app/app.routes.ts @@ -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), diff --git a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts index 652622c..52d6c60 100644 --- a/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts +++ b/apps/ssp/src/app/auth/infrastructure/digid.adapter.ts @@ -7,7 +7,7 @@ import { Principal } from '../domain/principal'; @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> { const r = parseBsn(bsn); diff --git a/apps/ssp/src/app/auth/ui/login-form/login-form.component.ts b/apps/ssp/src/app/auth/ui/login-form/login-form.component.ts index d11c5d3..614e3dd 100644 --- a/apps/ssp/src/app/auth/ui/login-form/login-form.component.ts +++ b/apps/ssp/src/app/auth/ui/login-form/login-form.component.ts @@ -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({ diff --git a/apps/ssp/src/app/auth/ui/login.page.ts b/apps/ssp/src/app/auth/ui/login.page.ts index 59671f9..8f68b09 100644 --- a/apps/ssp/src/app/auth/ui/login.page.ts +++ b/apps/ssp/src/app/auth/ui/login.page.ts @@ -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'; diff --git a/apps/ssp/src/app/brief/application/brief.store.spec.ts b/apps/ssp/src/app/brief/application/brief.store.spec.ts index 2a493f8..94d7f63 100644 --- a/apps/ssp/src/app/brief/application/brief.store.spec.ts +++ b/apps/ssp/src/app/brief/application/brief.store.spec.ts @@ -54,7 +54,7 @@ const caseContext: CaseContext = { const view: BriefView = { brief, availablePassages: [], decisions, orgTemplate, caseContext }; -/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of +/** 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[] = []; @@ -158,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 { @@ -179,7 +179,7 @@ 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; } @@ -308,7 +308,7 @@ describe('BriefStore rejection diff', () => { describe('BriefStore.previewLetter', () => { afterEach(() => vi.restoreAllMocks()); - it('opens the composed letter via BLOB_PRESENTER on success (RB-28)', async () => { + it('opens the composed letter via BLOB_PRESENTER on success', async () => { const { presenter, opened } = fakeBlobPresenter(); const store = setup( { @@ -412,11 +412,11 @@ describe('BriefStore.flushPending (CanDeactivate guard / beforeunload)', () => { }); }); -// --- RB-22 (CQ-007 expand half): a 404 from GET /brief tolerates by calling the -// existing reset() command, exactly once. Today's backend never 404s (RB-23 adds -// that); this fake adapter is what exercises the branch until then. --- +// --- 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 (RB-22)', () => { +describe('BriefStore.load — 404 tolerance', () => { const notFound: Result = { ok: false, error: { tag: 'notFound' } }; const resetOk: Result = { ok: true, value: view }; @@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => { // Then reset() ran exactly once, and the store ends up loaded from its result. expect(reset).toHaveBeenCalledTimes(1); - expect(store.model().tag).toBe('loaded'); + expect(store.model().tag).toBe('Loaded'); }); it('a second 404 does not drive a second reset()', async () => { @@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => { // 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 }); + expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED }); }); }); diff --git a/apps/ssp/src/app/brief/application/brief.store.ts b/apps/ssp/src/app/brief/application/brief.store.ts index 80ab564..7ab3bc1 100644 --- a/apps/ssp/src/app/brief/application/brief.store.ts +++ b/apps/ssp/src/app/brief/application/brief.store.ts @@ -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, @@ -29,7 +28,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter'; * 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 { @@ -41,18 +40,23 @@ export class BriefStore implements PendingSave { readonly model = this.store.model; - private actionState = signal({ 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({ 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. */ @@ -60,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(null); @@ -77,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(null); @@ -95,11 +99,11 @@ export class BriefStore implements PendingSave { /** The load lifecycle as `RemoteData`, for `` — 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(() => { 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); @@ -111,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()!) : [])); @@ -121,7 +125,7 @@ export class BriefStore implements PendingSave { return !!b && canSubmit(b) && !hasBlockingErrors(this.diagnostics()); }); - /** True once a 404-triggered recovery has been attempted (RB-22, CQ-007's expand + /** 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. */ @@ -182,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 } }); @@ -196,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(), @@ -212,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 }); } } @@ -249,13 +255,13 @@ export class BriefStore implements PendingSave { 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' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.blobPresenter.open(r.value); } @@ -269,7 +275,8 @@ export class BriefStore implements PendingSave { async revealBigNummer() { 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)); @@ -278,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>) { - 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); } @@ -304,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({ diff --git a/apps/ssp/src/app/brief/application/org-template.store.spec.ts b/apps/ssp/src/app/brief/application/org-template.store.spec.ts index f5decef..014a2fe 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.spec.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.spec.ts @@ -32,7 +32,7 @@ const subOrgs: SubOrgSummary[] = [ { subOrgId: 'cibg-registers', orgName: 'CIBG', publishedVersion: 1 }, ]; -/** A recording fake of BLOB_PRESENTER (RB-28/TE-006) — records every call instead of +/** 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[] = []; @@ -70,10 +70,10 @@ function setup( return TestBed.inject(OrgTemplateStore); } -// --- RB-28 (TE-006): proefbrief() ends in BLOB_PRESENTER.open, not a raw +// --- TE-006: proefbrief() ends in BLOB_PRESENTER.open, not a raw // window.open(URL.createObjectURL(...)) call, so both outcomes are assertable. --- -describe('OrgTemplateStore.proefbrief (RB-28)', () => { +describe('OrgTemplateStore.proefbrief', () => { it('opens the rendered proefbrief via BLOB_PRESENTER on success', async () => { // Given a loaded sub-org template. const { presenter, opened } = fakeBlobPresenter(); diff --git a/apps/ssp/src/app/brief/application/org-template.store.ts b/apps/ssp/src/app/brief/application/org-template.store.ts index 67bb1fe..1ec5f0d 100644 --- a/apps/ssp/src/app/brief/application/org-template.store.ts +++ b/apps/ssp/src/app/brief/application/org-template.store.ts @@ -1,8 +1,7 @@ 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 { 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'; @@ -13,6 +12,7 @@ import { SubOrgSummary, } from '@brief/domain/org-template'; import { + OrgTemplateActionState, OrgTemplateMsg, OrgTemplateState, initial, @@ -22,13 +22,13 @@ 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; +type LoadedState = Extract; 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 @@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave { readonly subOrgs = signal([]); readonly selectedSubOrgId = signal(null); - private actionState = signal({ 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(() => 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({ 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(() => { const s = this.model(); - return s.tag === 'loaded' ? s : null; + return s.tag === 'Loaded' ? s : null; }); readonly draft = computed(() => this.loaded()?.draft ?? null); readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload); @@ -101,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({ @@ -145,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, @@ -165,60 +171,64 @@ 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' }); + this.store.dispatch({ tag: 'ActionFinished' }); this.blobPresenter.open(r.value); } diff --git a/apps/ssp/src/app/brief/domain/brief-diff.ts b/apps/ssp/src/app/brief/domain/brief-diff.ts index d41aa63..b21b947 100644 --- a/apps/ssp/src/app/brief/domain/brief-diff.ts +++ b/apps/ssp/src/app/brief/domain/brief-diff.ts @@ -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): diff --git a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts index 5ce6a1d..230e74a 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.spec.ts @@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio }); 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) @@ -92,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', }); }); @@ -210,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); }); @@ -220,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', @@ -232,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', @@ -248,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', @@ -262,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', () => { @@ -280,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' }; } diff --git a/apps/ssp/src/app/brief/domain/brief.machine.ts b/apps/ssp/src/app/brief/domain/brief.machine.ts index 0821e7a..cf824da 100644 --- a/apps/ssp/src/app/brief/domain/brief.machine.ts +++ b/apps/ssp/src/app/brief/domain/brief.machine.ts @@ -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 }; } diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts index 1b8f24b..e94519a 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.spec.ts @@ -26,7 +26,7 @@ const view = (over: Partial = {}): 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', + }); + }); }); diff --git a/apps/ssp/src/app/brief/domain/org-template.machine.ts b/apps/ssp/src/app/brief/domain/org-template.machine.ts index 7932941..96a5ca2 100644 --- a/apps/ssp/src/app/brief/domain/org-template.machine.ts +++ b/apps/ssp/src/app/brief/domain/org-template.machine.ts @@ -3,12 +3,19 @@ import { Margins, OrgTemplate, OrgTemplateAdminView, OrgTemplateVersion } from ' 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); } diff --git a/apps/ssp/src/app/brief/domain/org-template.ts b/apps/ssp/src/app/brief/domain/org-template.ts index 0153f43..b8f05ef 100644 --- a/apps/ssp/src/app/brief/domain/org-template.ts +++ b/apps/ssp/src/app/brief/domain/org-template.ts @@ -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 { diff --git a/apps/ssp/src/app/brief/domain/sample-letter.ts b/apps/ssp/src/app/brief/domain/sample-letter.ts new file mode 100644 index 0000000..0b2c1c7 --- /dev/null +++ b/apps/ssp/src/app/brief/domain/sample-letter.ts @@ -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.', + }, + ], + }, + ], + }, + }, + ], + }, + ], +}; diff --git a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts index 02d3fad..dd0f174 100644 --- a/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/brief.adapter.ts @@ -38,9 +38,9 @@ import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/ric * (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` — RB-22, CQ-007's - * expand half). Today's backend never 404s `GET /brief` (RB-23 adds that), so the - * `notFound` branch is unreached until RB-23 ships; this adapter is ready in advance. + * 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 { @@ -66,7 +66,7 @@ export const BRIEF_ACTION_FAILED = $localize`:@@brief.action.failed:De actie is /** 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 - RB-23 gives the endpoint a documented 404 response). */ + the endpoint gets a documented 404 response). */ function isHttpNotFound(e: unknown): boolean { return !!e && typeof e === 'object' && (e as { status?: unknown }).status === 404; } diff --git a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts index d777d7d..3b7f9b9 100644 --- a/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts +++ b/apps/ssp/src/app/brief/infrastructure/letter-preview.adapter.ts @@ -14,19 +14,19 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * 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` AND `subjectInterceptor`, so both `X-Role` and `X-Subject` are set - * here explicitly (WP-74 — without `X-Subject` this always previewed + * 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'` (WP-74): the endpoint has no `Cache-Control`, only a CORS-driven + * `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 WP-74's - * identity work, and not a complete fix by itself: see the KNOWN GAP note below. + * 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 (WP-74, not fixed here): under a non-`DocumentStore.DemoOwner` `X-Subject`, + * 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 @@ -34,7 +34,7 @@ export const PREVIEW_FAILED = $localize`:@@brief.preview.failed:De voorvertoning * 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 WP-74's file scope — + * `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). */ diff --git a/apps/ssp/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts b/apps/ssp/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts index 2d5d66e..1122b94 100644 --- a/apps/ssp/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts +++ b/apps/ssp/src/app/brief/ui/behandel-scherm/behandel-scherm.component.ts @@ -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'; diff --git a/apps/ssp/src/app/brief/ui/besluit-panel/besluit-panel.component.ts b/apps/ssp/src/app/brief/ui/besluit-panel/besluit-panel.component.ts index 7099564..79276f7 100644 --- a/apps/ssp/src/app/brief/ui/besluit-panel/besluit-panel.component.ts +++ b/apps/ssp/src/app/brief/ui/besluit-panel/besluit-panel.component.ts @@ -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'; diff --git a/apps/ssp/src/app/brief/ui/brief.page.ts b/apps/ssp/src/app/brief/ui/brief.page.ts index 3022fd7..e24d0dc 100644 --- a/apps/ssp/src/app/brief/ui/brief.page.ts +++ b/apps/ssp/src/app/brief/ui/brief.page.ts @@ -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 `` loaded slot — see WP-06: a structural + /** Typed narrowing for the `` 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) { diff --git a/apps/ssp/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts b/apps/ssp/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts index 27391c9..2deab80 100644 --- a/apps/ssp/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts +++ b/apps/ssp/src/app/brief/ui/diagnostics-panel/diagnostics-panel.component.ts @@ -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 diff --git a/apps/ssp/src/app/brief/ui/letter-block/letter-block.component.ts b/apps/ssp/src/app/brief/ui/letter-block/letter-block.component.ts index 937079f..8dd1f45 100644 --- a/apps/ssp/src/app/brief/ui/letter-block/letter-block.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-block/letter-block.component.ts @@ -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. diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts index 07ac186..103bbad 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.component.ts @@ -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 = { - 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: ` - - @for (node of nodes; track $index) { - @switch (node.type) { - @case ('text') { - {{ node.text }} - } - @case ('lineBreak') { -
- } - @case ('placeholder') { - @if (showSample() && autoFor(node.key)) { - {{ sampleFor(node.key) }} - } @else { - - } - } - } - } -
- @if (editableRegions() !== 'template') {
-
+
− @@ -174,13 +154,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; + - {{ - zoomResetLabel() - }} + 100%
@if (editableRegions() === 'none') { - {{ showSample() ? hideSampleLabel() : showSampleLabel() }} + @if (showSample()) { + Testwaarden verbergen + } @else { + Voorbeeld met testwaarden + } }
@@ -200,20 +187,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; (robijn footer background) — the letter surface must stay letter.css-only. -->
@if (logoUrl()) { - + } @if (editing()) { } @else { @@ -223,11 +217,11 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
{{ recipientText() }}
-
{{ referenceLabel() }}
+
Ons kenmerk
{{ brief().briefId }}
-
{{ dateLabel() }}
+
Datum
{{ letterDate }}
@@ -241,18 +235,27 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; @let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
@if (diffKind) { - {{ - diffLabel(diffKind) - }} + + @if (diffKind === 'added') { + nieuw + } @else { + gewijzigd sinds afwijzing + } + } @for (seg of segmentsOf(block); track $index) { @if (seg.list === 'bullet') {
    @for (para of seg.items; track $index) {
  • -
  • } @@ -261,18 +264,24 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
      @for (para of seg.items; track $index) {
    1. -
    2. }
    } @else {

    -

    } @@ -288,19 +297,22 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; } @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)" > } @else { @@ -333,7 +347,7 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4; @for (top of pageBreaks(); track $index) { }
@@ -344,12 +358,12 @@ export class LetterCanvasComponent { brief = input.required(); orgTemplate = input.required(); /** 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([]); - /** 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>(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(); - 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); diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts index 8d57118..e137fb9 100644 --- a/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-canvas.stories.ts @@ -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( 'CIBG', ); -/** 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 }, }; diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts new file mode 100644 index 0000000..9cb322c --- /dev/null +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.component.ts @@ -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 = { + 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 { + 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 { + const m = new Map(); + 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, key: string): string { + return defs.get(key)?.label ?? key; +} + +export function resolveAuto(defs: ReadonlyMap, key: string): boolean { + return defs.get(key)?.autoResolvable ?? false; +} + +export function resolveState( + worst: ReadonlyMap, + 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, + 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') { + {{ node.text }} + } + @case ('lineBreak') { +
+ } + @case ('placeholder') { + @if (showSample() && autoFor(node.key)) { + {{ sampleFor(node.key) }} + } @else { + + } + } + } + } + `, +}) +export class LetterLineComponent { + nodes = input.required(); + showSample = input(false); + placeholders = input([]); + diagnostics = input([]); + /** 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); +} diff --git a/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts new file mode 100644 index 0000000..ccaf29a --- /dev/null +++ b/apps/ssp/src/app/brief/ui/letter-canvas/letter-line.spec.ts @@ -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', + ); + }); +}); diff --git a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts index 6e36532..a8f66d7 100644 --- a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.component.ts @@ -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>(new Map()); removedCount = input(0); diff --git a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts index 3dc7314..ef93868 100644 --- a/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts +++ b/apps/ssp/src/app/brief/ui/letter-composer/letter-composer.stories.ts @@ -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: () => diff --git a/apps/ssp/src/app/brief/ui/letter-editor/letter-editor.component.ts b/apps/ssp/src/app/brief/ui/letter-editor/letter-editor.component.ts index 1f6789f..10f4dd1 100644 --- a/apps/ssp/src/app/brief/ui/letter-editor/letter-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-editor/letter-editor.component.ts @@ -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'; diff --git a/apps/ssp/src/app/brief/ui/letter-section/letter-section.component.ts b/apps/ssp/src/app/brief/ui/letter-section/letter-section.component.ts index 9d1e52e..5ddbb8c 100644 --- a/apps/ssp/src/app/brief/ui/letter-section/letter-section.component.ts +++ b/apps/ssp/src/app/brief/ui/letter-section/letter-section.component.ts @@ -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'; diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts new file mode 100644 index 0000000..1be7972 --- /dev/null +++ b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts @@ -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: ` +
+ Logo + @if (logoCategory()) { + + } + @if (logoRejection()) { + {{ logoRejection() }} + } + @if (logoUploads().length) { +
    + @for (u of logoUploads(); track u.localId) { +
  • + } +
+ } +
+ `, +}) +export class LogoUploadComponent { + logoUrl = input(null); + uploadState = input.required(); + previewUrlFor = input<(documentId: string) => string | undefined>(); + + logoSelected = output(); + logoRemoved = output(); + logoRetry = output(); + + 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]); +} diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 2690e0f..3a88424 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -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 { 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: `