feat(WP-67): merge behandelportal into this repo as a monorepo
Restructures into apps/ssp + apps/behandelportal (two Angular projects) plus libs/shared + libs/beheer (cross-app libraries), replacing WP-61's separate sibling repo. That split had already produced real drift: a hand-vendored copy of the backend's OpenAPI doc, a shared/ui+layout tree forked and silently diverging (7 files), and beheer + the styles.scss token bridge duplicated byte-for-byte across both repos. - git mv the SSP's src/app/* into apps/ssp/; fold shared/, beheer/, environments/, the Storybook docs/*.mdx, and styles.scss into libs/shared + libs/beheer (all confirmed identical between the two repos before merging). auth stays deliberately duplicated per ADR-0002 (actor-specific, expected to diverge) - amended there. - One generated API client (libs/shared), no more vendored swagger.json. - .dependency-cruiser split into a base factory + one config per app, and Storybook into .storybook-ssp/.storybook-behandelportal - both forced by the @auth/* alias resolving to different directories per app. - SiteHeaderComponent/ShellComponent gained HEADER_NAV_ITEMS/ HEADER_ADMIN_LINKS/DEBUG_PANEL injection tokens so each app supplies its own nav/admin-links/dev-panel instead of one being hardcoded. - CLAUDE.md, ARCHITECTURE.md, dependencies.md, and ADR-0002 updated; WP-67 backlog entry documents the full decision trail. npm run ci green (lint, dep:check x2, 360 tests across ssp/ behandelportal/shared/beheer, both localized builds, backend tests, snippet + api-client drift); both dev servers, both Storybook instances, and docker compose verified working. The old sibling repo (/home/eho/repos/behandelportal) is left untouched, not deleted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import { Injectable, computed, inject } from '@angular/core';
|
||||
import { toObservable } from '@angular/core/rxjs-interop';
|
||||
import { filter, firstValueFrom } from 'rxjs';
|
||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { MeAdapter, parseMe } from '@shared/infrastructure/me.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* The current principal's capabilities (PRD-0002 §6) — one root singleton, like
|
||||
* `SessionStore`/`BigProfileStore`. Global capabilities load once from `GET /me`;
|
||||
* a screen's own decision DTO (e.g. `BriefViewDto.decisions`) covers anything tied
|
||||
* to a specific resource's live status — no extra round-trip needed for that.
|
||||
*
|
||||
* `can()` is deny-by-default: loading, failed, or an unrecognized capability all
|
||||
* resolve to `false`. This store never derives a capability from a role — it only
|
||||
* mirrors what the server already resolved.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AccessStore {
|
||||
private adapter = inject(MeAdapter);
|
||||
private meRes = this.adapter.meResource();
|
||||
|
||||
private capabilities = computed<RemoteData<Err, Capability[]>>(() => {
|
||||
const rd = fromResource(this.meRes);
|
||||
if (rd.tag !== 'Success') return rd;
|
||||
const parsed = parseMe(rd.value);
|
||||
return parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) };
|
||||
});
|
||||
|
||||
can(capability: Capability): boolean {
|
||||
const rd = this.capabilities();
|
||||
return rd.tag === 'Success' && rd.value.includes(capability);
|
||||
}
|
||||
|
||||
/** True once `/me` has resolved (success or failure) — lets a page-level gate tell
|
||||
"still loading" apart from "denied", so an admin doesn't flash the denial alert. */
|
||||
readonly ready = computed(() => {
|
||||
const tag = this.capabilities().tag;
|
||||
return tag === 'Success' || tag === 'Failure';
|
||||
});
|
||||
|
||||
private ready$ = toObservable(this.ready);
|
||||
/** Resolves once `/me` has settled (success or failure). The `capabilityGuard` awaits
|
||||
this before deciding — otherwise it reads `can()` while `/me` is still loading and
|
||||
wrongly denies (deny-by-default), bouncing even an entitled user. */
|
||||
async whenReady(): Promise<void> {
|
||||
if (this.ready()) return;
|
||||
await firstValueFrom(this.ready$.pipe(filter((r) => r)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
|
||||
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
|
||||
editor stores (WP-31). */
|
||||
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||
|
||||
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
|
||||
concern from ActionState (a stale autosave error doesn't block submit/approve), but
|
||||
tag-aligned with it for one consistent idiom. */
|
||||
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { createDebouncedSave } from './debounced-save';
|
||||
|
||||
describe('createDebouncedSave', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('flushes after the delay when canSave is true', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
expect(d.hasPendingSave()).toBe(true);
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('does not schedule when canSave is false', () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ canSave: () => false, flush });
|
||||
d.schedule();
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('coalesces rapid schedules into a single flush', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 100, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
d.schedule();
|
||||
d.schedule();
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('flushPending runs the save immediately and clears; no-op when idle', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
await d.flushPending();
|
||||
expect(flush).not.toHaveBeenCalled(); // idle
|
||||
d.schedule();
|
||||
await d.flushPending();
|
||||
expect(flush).toHaveBeenCalledTimes(1);
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
});
|
||||
|
||||
it('cancel drops a scheduled save without running it', async () => {
|
||||
const flush = vi.fn().mockResolvedValue(undefined);
|
||||
const d = createDebouncedSave({ delayMs: 600, canSave: () => true, flush });
|
||||
d.schedule();
|
||||
d.cancel();
|
||||
expect(d.hasPendingSave()).toBe(false);
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
expect(flush).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface DebouncedSave {
|
||||
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
|
||||
schedule(): void;
|
||||
/** True while a scheduled save hasn't run yet — implements `PendingSave.hasPendingSave`. */
|
||||
hasPendingSave(): boolean;
|
||||
/** Run a scheduled save now and await it; no-op when nothing is scheduled. */
|
||||
flushPending(): Promise<void>;
|
||||
/** Drop a scheduled save without running it (e.g. before an authoritative transition,
|
||||
which flushes explicitly, or a reset that discards the draft). */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
|
||||
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
|
||||
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
|
||||
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
|
||||
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
|
||||
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
|
||||
*/
|
||||
export function createDebouncedSave(opts: {
|
||||
delayMs?: number;
|
||||
canSave: () => boolean;
|
||||
flush: () => Promise<void>;
|
||||
}): DebouncedSave {
|
||||
const delay = opts.delayMs ?? 600;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
return {
|
||||
schedule() {
|
||||
if (!opts.canSave()) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = undefined;
|
||||
void opts.flush();
|
||||
}, delay);
|
||||
},
|
||||
hasPendingSave: () => timer !== undefined,
|
||||
async flushPending() {
|
||||
if (timer === undefined) return;
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
await opts.flush();
|
||||
},
|
||||
cancel() {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
import { FeatureFlagsAdapter, parseFlags } from '@shared/infrastructure/feature-flags.adapter';
|
||||
|
||||
type Err = Error | undefined;
|
||||
|
||||
/**
|
||||
* Runtime feature-flag state (WP-47) — one root singleton, mirroring `AccessStore`. Loads the
|
||||
* resolved flag set once from `GET /flags`; `enabled(key)` gates a feature (deny-by-default:
|
||||
* false until loaded / unknown key). `set()` is the admin toggle (PUT + reload). The catalog is
|
||||
* server-owned; the FE only mirrors + renders it.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagStore {
|
||||
private adapter = inject(FeatureFlagsAdapter);
|
||||
private state = signal<RemoteData<Err, FeatureFlag[]>>({ tag: 'Loading' });
|
||||
|
||||
readonly flags = this.state.asReadonly();
|
||||
/** The resolved list (empty until loaded) — for the admin toggle UI. */
|
||||
readonly all = computed(() => {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' ? rd.value : [];
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||
try {
|
||||
const parsed = parseFlags(await this.adapter.list());
|
||||
this.state.set(
|
||||
parsed.ok
|
||||
? { tag: 'Success', value: parsed.value }
|
||||
: { tag: 'Failure', error: new Error(parsed.error) },
|
||||
);
|
||||
} catch (e) {
|
||||
this.state.set({ tag: 'Failure', error: e as Error });
|
||||
}
|
||||
}
|
||||
|
||||
/** Deny-by-default: false while loading/failed or for an unknown key. Reactive (reads the signal). */
|
||||
enabled(key: string): boolean {
|
||||
const rd = this.state();
|
||||
return rd.tag === 'Success' && (rd.value.find((f) => f.key === key)?.enabled ?? false);
|
||||
}
|
||||
|
||||
/** Admin toggle: persist then reload so the state reflects the server. */
|
||||
async set(key: string, enabled: boolean) {
|
||||
try {
|
||||
await this.adapter.set(key, enabled);
|
||||
} finally {
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createHistory } from './history';
|
||||
|
||||
describe('createHistory', () => {
|
||||
it('starts empty; undo/redo are no-ops', () => {
|
||||
const h = createHistory<number>();
|
||||
expect(h.canUndo()).toBe(false);
|
||||
expect(h.canRedo()).toBe(false);
|
||||
expect(h.undo(1)).toBeUndefined();
|
||||
expect(h.redo(1)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('records pre-edit snapshots, then undoes and redoes through them', () => {
|
||||
const h = createHistory<string>();
|
||||
// document went a -> b (record a) -> c (record b); current is 'c'
|
||||
h.record('a');
|
||||
h.record('b');
|
||||
expect(h.canUndo()).toBe(true);
|
||||
|
||||
expect(h.undo('c')).toBe('b'); // current 'c' pushed to redo
|
||||
expect(h.canRedo()).toBe(true);
|
||||
expect(h.undo('b')).toBe('a');
|
||||
expect(h.canUndo()).toBe(false);
|
||||
|
||||
expect(h.redo('a')).toBe('b');
|
||||
expect(h.redo('b')).toBe('c');
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('record() clears the redo stack (no dead redo after a fresh edit)', () => {
|
||||
const h = createHistory<string>();
|
||||
h.record('a');
|
||||
h.undo('b'); // redo now holds 'b'
|
||||
expect(h.canRedo()).toBe(true);
|
||||
h.record('x');
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('caps the stack depth', () => {
|
||||
const h = createHistory<number>(3);
|
||||
for (let i = 0; i < 5; i++) h.record(i);
|
||||
let undos = 0;
|
||||
let cur = 99;
|
||||
while (h.canUndo()) {
|
||||
cur = h.undo(cur)!;
|
||||
undos++;
|
||||
}
|
||||
expect(undos).toBe(3);
|
||||
});
|
||||
|
||||
it('clear() empties both stacks', () => {
|
||||
const h = createHistory<number>();
|
||||
h.record(1);
|
||||
h.undo(2);
|
||||
h.clear();
|
||||
expect(h.canUndo()).toBe(false);
|
||||
expect(h.canRedo()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Signal, computed, signal } from '@angular/core';
|
||||
|
||||
export interface History<T> {
|
||||
readonly canUndo: Signal<boolean>;
|
||||
readonly canRedo: Signal<boolean>;
|
||||
/** Push a pre-edit snapshot onto the undo stack and drop the redo stack. */
|
||||
record(snapshot: T): void;
|
||||
/** Undo: pop the last recorded snapshot and return it (moving `current` onto the redo
|
||||
stack); returns undefined and changes nothing when there's nothing to undo. */
|
||||
undo(current: T): T | undefined;
|
||||
/** Redo: mirror of undo. */
|
||||
redo(current: T): T | undefined;
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic undo/redo history over an immutable "document" value `T`. Elm-store editors
|
||||
* restore a returned snapshot by re-dispatching a `Seed`-style Msg — this helper only
|
||||
* shuffles references, it never mutates them, so the caller must hold copy-on-write state
|
||||
* (every edit produces a fresh value). Both stacks are capped so a long session can't grow
|
||||
* unbounded. Extracted from BriefStore's WP-27 undo/redo (WP-31); reused by the stamdata
|
||||
* editor (WP-32).
|
||||
*/
|
||||
export function createHistory<T>(cap = 50): History<T> {
|
||||
const past = signal<readonly T[]>([]);
|
||||
const future = signal<readonly T[]>([]);
|
||||
return {
|
||||
canUndo: computed(() => past().length > 0),
|
||||
canRedo: computed(() => future().length > 0),
|
||||
record(snapshot) {
|
||||
past.update((p) => [...p, snapshot].slice(-cap));
|
||||
future.set([]);
|
||||
},
|
||||
undo(current) {
|
||||
const p = past();
|
||||
if (p.length === 0) return undefined;
|
||||
past.set(p.slice(0, -1));
|
||||
future.update((f) => [...f, current].slice(-cap));
|
||||
return p[p.length - 1];
|
||||
},
|
||||
redo(current) {
|
||||
const f = future();
|
||||
if (f.length === 0) return undefined;
|
||||
future.set(f.slice(0, -1));
|
||||
past.update((p) => [...p, current].slice(-cap));
|
||||
return f[f.length - 1];
|
||||
},
|
||||
clear() {
|
||||
past.set([]);
|
||||
future.set([]);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { machineRemoteData } from './machine-remote-data';
|
||||
|
||||
describe('machineRemoteData', () => {
|
||||
it('maps loading → Loading', () => {
|
||||
expect(machineRemoteData({ tag: 'loading' })).toEqual({ tag: 'Loading' });
|
||||
});
|
||||
|
||||
it('maps failed → Failure carrying an Error with the reason', () => {
|
||||
const rd = machineRemoteData({ tag: 'failed', reason: 'boom' });
|
||||
expect(rd.tag).toBe('Failure');
|
||||
if (rd.tag === 'Failure') expect(rd.error.message).toBe('boom');
|
||||
});
|
||||
|
||||
it('maps loaded → Success carrying the whole loaded state', () => {
|
||||
const loaded = { tag: 'loaded', foo: 42 } as const;
|
||||
expect(machineRemoteData(loaded)).toEqual({ tag: 'Success', value: loaded });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
|
||||
/** The standard load-lifecycle tags an editor machine exposes. */
|
||||
export type LoadLifecycle =
|
||||
{ tag: 'loading' } | { tag: 'failed'; reason: string } | { tag: 'loaded' };
|
||||
|
||||
/**
|
||||
* Project an Elm-machine state onto `RemoteData` for the `<app-async>` seam. The machine
|
||||
* keeps owning its own domain lifecycle (draft/submitted/…); this is purely the
|
||||
* loading/failed/loaded → async mapping, which was byte-identical across BriefStore,
|
||||
* OrgTemplateStore and StamdataStore (WP-31). Wrap the call in a `computed`.
|
||||
*/
|
||||
export function machineRemoteData<S extends LoadLifecycle>(
|
||||
s: S,
|
||||
): RemoteData<Error, Extract<S, { tag: 'loaded' }>> {
|
||||
switch (s.tag) {
|
||||
case 'loading':
|
||||
return { tag: 'Loading' };
|
||||
case 'failed':
|
||||
return { tag: 'Failure', error: new Error(s.reason) };
|
||||
default: // 'loaded'
|
||||
return { tag: 'Success', value: s as Extract<S, { tag: 'loaded' }> };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PendingSave, PendingSaves, flushPendingGuard } from './pending-saves';
|
||||
|
||||
/** A fake autosave owner whose pending-ness and flush are controllable. */
|
||||
function fakeOwner(pending: boolean): PendingSave & { flushPending: ReturnType<typeof vi.fn> } {
|
||||
return {
|
||||
hasPendingSave: () => pending,
|
||||
flushPending: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('PendingSaves registry', () => {
|
||||
it('hasPending is true only while some registered owner has a pending write', () => {
|
||||
const reg = new PendingSaves();
|
||||
const idle = fakeOwner(false);
|
||||
reg.register(idle);
|
||||
expect(reg.hasPending()).toBe(false);
|
||||
|
||||
const dirty = fakeOwner(true);
|
||||
reg.register(dirty);
|
||||
expect(reg.hasPending()).toBe(true);
|
||||
});
|
||||
|
||||
it('unregister removes an owner so it no longer counts', () => {
|
||||
const reg = new PendingSaves();
|
||||
const dirty = fakeOwner(true);
|
||||
const off = reg.register(dirty);
|
||||
expect(reg.hasPending()).toBe(true);
|
||||
off();
|
||||
expect(reg.hasPending()).toBe(false);
|
||||
});
|
||||
|
||||
it('flushAll flushes only the pending owners', async () => {
|
||||
const reg = new PendingSaves();
|
||||
const idle = fakeOwner(false);
|
||||
const dirty = fakeOwner(true);
|
||||
reg.register(idle);
|
||||
reg.register(dirty);
|
||||
|
||||
await reg.flushAll();
|
||||
|
||||
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
|
||||
expect(idle.flushPending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flushAll awaits every owner and swallows a rejected flush', async () => {
|
||||
const reg = new PendingSaves();
|
||||
const failing = fakeOwner(true);
|
||||
failing.flushPending.mockRejectedValue(new Error('save failed'));
|
||||
const ok = fakeOwner(true);
|
||||
reg.register(failing);
|
||||
reg.register(ok);
|
||||
|
||||
await expect(reg.flushAll()).resolves.toBeUndefined(); // never rejects
|
||||
expect(ok.flushPending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('flushPendingGuard', () => {
|
||||
it('flushes then allows navigation when a write is pending', async () => {
|
||||
const dirty = fakeOwner(true);
|
||||
TestBed.configureTestingModule({});
|
||||
const reg = TestBed.inject(PendingSaves);
|
||||
reg.register(dirty);
|
||||
|
||||
const result = TestBed.runInInjectionContext(() =>
|
||||
// the guard ignores its route args
|
||||
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
|
||||
);
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(dirty.flushPending).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('allows navigation immediately when nothing is pending', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
TestBed.inject(PendingSaves).register(fakeOwner(false));
|
||||
|
||||
const result = TestBed.runInInjectionContext(() =>
|
||||
(flushPendingGuard as (...a: unknown[]) => boolean | Promise<boolean>)(),
|
||||
);
|
||||
|
||||
expect(result).toBe(true); // synchronous, not a Promise
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { DestroyRef, ENVIRONMENT_INITIALIZER, Injectable, inject } from '@angular/core';
|
||||
import { CanDeactivateFn } from '@angular/router';
|
||||
|
||||
/**
|
||||
* A source of debounced, not-yet-flushed writes (autosave). The two autosave owners in
|
||||
* this app have different lifetimes — root singleton stores (`BriefStore`,
|
||||
* `OrgTemplateStore`) and per-wizard `createDraftSync` controllers living inside child
|
||||
* organisms — so both register here instead of the guard/unload handler needing to know
|
||||
* which page or store owns the pending write.
|
||||
*/
|
||||
export interface PendingSave {
|
||||
/** True while a debounced edit hasn't been written to the backend yet. */
|
||||
hasPendingSave(): boolean;
|
||||
/** Flush that pending write now and await it. No-op when nothing is pending. */
|
||||
flushPending(): Promise<void>;
|
||||
}
|
||||
|
||||
/** Registry of every active autosave owner. The `CanDeactivate` guard and the
|
||||
`beforeunload` handler flush through this — one seam, both callers. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class PendingSaves {
|
||||
private readonly owners = new Set<PendingSave>();
|
||||
|
||||
/** Register an owner; returns an unregister function. */
|
||||
register(owner: PendingSave): () => void {
|
||||
this.owners.add(owner);
|
||||
return () => this.owners.delete(owner);
|
||||
}
|
||||
|
||||
hasPending(): boolean {
|
||||
return [...this.owners].some((o) => o.hasPendingSave());
|
||||
}
|
||||
|
||||
/** Flush every owner that has a pending write, awaiting all. Best-effort: a rejected
|
||||
flush is swallowed (a failed autosave surfaces its own error state; navigation must
|
||||
not be blocked by it). */
|
||||
async flushAll(): Promise<void> {
|
||||
await Promise.allSettled(
|
||||
[...this.owners].filter((o) => o.hasPendingSave()).map((o) => o.flushPending()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the current injection context's owner for the life of its `DestroyRef`.
|
||||
Call from a constructor or field initializer (root store, or `createDraftSync`). */
|
||||
export function registerPendingSave(owner: PendingSave): void {
|
||||
const unregister = inject(PendingSaves).register(owner);
|
||||
inject(DestroyRef).onDestroy(unregister);
|
||||
}
|
||||
|
||||
/** `CanDeactivate` guard: flush any pending debounced write before an in-app route change,
|
||||
then allow navigation. Awaitable, so the write lands before the page tears down (which
|
||||
would otherwise drop a sub-debounce edit). We never block leaving — the flush is a
|
||||
guarantee of effort, not a gate. */
|
||||
export const flushPendingGuard: CanDeactivateFn<unknown> = () => {
|
||||
const pending = inject(PendingSaves);
|
||||
return pending.hasPending() ? pending.flushAll().then(() => true) : true;
|
||||
};
|
||||
|
||||
/** Wire a `beforeunload` handler that guards the last-mile save on a hard tab-close/reload.
|
||||
ponytail: the HTTP seam is Angular `HttpClient` (no `keepalive`/`sendBeacon`), so an
|
||||
async flush can't be guaranteed to finish as the page tears down — we fire it best-effort
|
||||
AND trigger the browser's native "unsaved changes" prompt, which lets the ~600ms debounce
|
||||
land if the user stays. Upgrade path: a `sendBeacon`/keepalive last-mile if this ever
|
||||
needs to be guaranteed. */
|
||||
export function provideUnloadFlush() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const pending = inject(PendingSaves);
|
||||
window.addEventListener('beforeunload', (e) => {
|
||||
if (!pending.hasPending()) return;
|
||||
void pending.flushAll();
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RemoteData, map2, map } from './remote-data';
|
||||
|
||||
const loading: RemoteData<string, number> = { tag: 'Loading' };
|
||||
const failure: RemoteData<string, number> = { tag: 'Failure', error: 'x' };
|
||||
const ok = (n: number): RemoteData<string, number> => ({ tag: 'Success', value: n });
|
||||
|
||||
describe('RemoteData combinators', () => {
|
||||
it('map only touches Success', () => {
|
||||
const times10 = (n: number) => n * 10;
|
||||
expect(map(ok(2), times10)).toEqual({ tag: 'Success', value: 20 });
|
||||
expect(map(loading, times10)).toEqual(loading);
|
||||
});
|
||||
|
||||
it('map2 precedence: Failure > Loading > Success', () => {
|
||||
const add = (a: number, b: number) => a + b;
|
||||
expect(map2(failure, ok(1), add)).toEqual(failure); // a failed
|
||||
expect(map2(ok(1), failure, add)).toEqual(failure); // b failed
|
||||
expect(map2(loading, ok(1), add)).toEqual({ tag: 'Loading' });
|
||||
expect(map2(ok(2), ok(3), add)).toEqual({ tag: 'Success', value: 5 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Resource } from '@angular/core';
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* The four mutually-exclusive states of an async fetch, as a tagged union.
|
||||
* Crucially the data lives ON the state: only `Failure` has an `error`, only
|
||||
* `Success` has a `value`. "Loaded but no value" or "error with stale value"
|
||||
* are unrepresentable — Richard Feldman's RemoteData.
|
||||
*/
|
||||
export type RemoteData<E, T> =
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Empty' }
|
||||
| { tag: 'Failure'; error: E }
|
||||
| { tag: 'Success'; value: T };
|
||||
|
||||
/** Project Angular's loosely-typed Resource into a RemoteData value. */
|
||||
export function fromResource<T>(
|
||||
r: Resource<T>,
|
||||
isEmpty: (v: T) => boolean = () => false,
|
||||
): RemoteData<Error | undefined, T> {
|
||||
if (r.status() === 'error') return { tag: 'Failure', error: r.error() };
|
||||
if (r.status() === 'loading') return { tag: 'Loading' };
|
||||
if (r.hasValue()) {
|
||||
const v = r.value();
|
||||
return isEmpty(v) ? { tag: 'Empty' } : { tag: 'Success', value: v };
|
||||
}
|
||||
return { tag: 'Loading' };
|
||||
}
|
||||
|
||||
// #region showcase:fold
|
||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||
export function foldRemote<E, T, R>(
|
||||
rd: RemoteData<E, T>,
|
||||
h: { loading: () => R; empty: () => R; failure: (e: E) => R; success: (v: T) => R },
|
||||
): R {
|
||||
switch (rd.tag) {
|
||||
case 'Loading':
|
||||
return h.loading();
|
||||
case 'Empty':
|
||||
return h.empty();
|
||||
case 'Failure':
|
||||
return h.failure(rd.error);
|
||||
case 'Success':
|
||||
return h.success(rd.value);
|
||||
default:
|
||||
return assertNever(rd); // add a variant → compile error until handled
|
||||
}
|
||||
}
|
||||
// #endregion showcase:fold
|
||||
|
||||
// --- Combinators -----------------------------------------------------------
|
||||
// Let several independent async sources be treated as one. When you combine
|
||||
// two streams the result is: a failure if EITHER failed, still loading if
|
||||
// either is loading, empty if either is empty, and only Success when BOTH
|
||||
// succeeded. Precedence: Failure > Loading > Empty > Success.
|
||||
|
||||
/** Transform the value inside a Success; pass other states through unchanged. */
|
||||
export function map<E, A, B>(rd: RemoteData<E, A>, f: (a: A) => B): RemoteData<E, B> {
|
||||
return rd.tag === 'Success' ? { tag: 'Success', value: f(rd.value) } : rd;
|
||||
}
|
||||
|
||||
/** Combine two sources into one. Use this to merge e.g. a BIG-register call
|
||||
and a BRP call into a single state the page can render. */
|
||||
export function map2<E, A, B, R>(
|
||||
a: RemoteData<E, A>,
|
||||
b: RemoteData<E, B>,
|
||||
f: (a: A, b: B) => R,
|
||||
): RemoteData<E, R> {
|
||||
if (a.tag === 'Failure') return a;
|
||||
if (b.tag === 'Failure') return b;
|
||||
if (a.tag === 'Loading' || b.tag === 'Loading') return { tag: 'Loading' };
|
||||
if (a.tag === 'Empty' || b.tag === 'Empty') return { tag: 'Empty' };
|
||||
return { tag: 'Success', value: f(a.value, b.value) };
|
||||
}
|
||||
|
||||
/** Chain a second source that depends on the first one's value. */
|
||||
export function andThen<E, A, B>(
|
||||
rd: RemoteData<E, A>,
|
||||
f: (a: A) => RemoteData<E, B>,
|
||||
): RemoteData<E, B> {
|
||||
return rd.tag === 'Success' ? f(rd.value) : rd;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { InjectionToken, Signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A shared seam for the chrome to show "who is logged in" + log out, WITHOUT
|
||||
* shared/ depending on the auth context (the import-direction rule forbids that).
|
||||
* Auth provides this token at the app root (see app.config.ts); the shared header
|
||||
* injects it. SessionStore satisfies this shape structurally.
|
||||
*/
|
||||
export interface SessionPort {
|
||||
readonly session: Signal<{ naam: string } | null>;
|
||||
logout(): void;
|
||||
}
|
||||
|
||||
export const SESSION_PORT = new InjectionToken<SessionPort>('SESSION_PORT');
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApplicationRef, effect } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { createStore } from './store';
|
||||
|
||||
describe('createStore', () => {
|
||||
it('applies the pure update on dispatch', () => {
|
||||
const store = createStore(0, (n: number, m: number) => n + m);
|
||||
store.dispatch(5);
|
||||
store.dispatch(3);
|
||||
expect(store.model()).toBe(8);
|
||||
});
|
||||
|
||||
// Regression: an effect that dispatches must NOT re-run because of its own write.
|
||||
// dispatch used to read `model()` reactively (`set(update(model(), msg))`), so an
|
||||
// effect calling dispatch subscribed to `model` and looped forever, livelocking the
|
||||
// main thread (crashed the upload wizards). With `.update` the read is untracked.
|
||||
it('dispatch from inside an effect does not self-loop', () => {
|
||||
const store = createStore(0, (n: number, _m: 'inc') => n + 1);
|
||||
let runs = 0;
|
||||
TestBed.runInInjectionContext(() => {
|
||||
effect(() => {
|
||||
runs++;
|
||||
if (runs < 100) store.dispatch('inc'); // bounded so the buggy version can't hang the test
|
||||
});
|
||||
});
|
||||
TestBed.inject(ApplicationRef).tick(); // flush effects
|
||||
|
||||
expect(runs).toBe(1); // effect ran once; its own dispatch did not retrigger it
|
||||
expect(store.model()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Signal, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* A tiny "Elm-style" store. The whole idea: all state lives in ONE value
|
||||
* (the Model). The only way to change it is to send a message (Msg) to a PURE
|
||||
* function `update(model, msg)` that returns the next Model. Nothing else
|
||||
* mutates state, so to understand the app you only read the update function.
|
||||
*
|
||||
* Side effects (HTTP, timers) do NOT go in `update` — that stays pure and easy
|
||||
* to test. Instead, effectful "command" functions call the network and then
|
||||
* `dispatch` a message describing what happened (e.g. Loaded / Failed).
|
||||
*/
|
||||
export interface Store<Model, Msg> {
|
||||
/** The current state, as a read-only Angular signal. */
|
||||
readonly model: Signal<Model>;
|
||||
/** Send a message; the model becomes update(model, msg). */
|
||||
dispatch(msg: Msg): void;
|
||||
}
|
||||
|
||||
export function createStore<Model, Msg>(
|
||||
init: Model,
|
||||
update: (model: Model, msg: Msg) => Model,
|
||||
): Store<Model, Msg> {
|
||||
const model = signal(init);
|
||||
return {
|
||||
model: model.asReadonly(),
|
||||
// Use `.update` (raw current value, no tracked read) not `set(update(model(), …))`:
|
||||
// dispatch is a command and must never subscribe its caller to `model`. Reading
|
||||
// `model()` here inside an effect that also dispatches makes the effect depend on
|
||||
// its own write and livelock the main thread (crashed the upload wizards).
|
||||
dispatch: (msg) => model.update((m) => update(m, msg)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { runSubmit } from './submit';
|
||||
|
||||
describe('runSubmit', () => {
|
||||
it('folds a resolved call into ok(value)', async () => {
|
||||
const r = await runSubmit(async () => 'BIG-123', 'fallback');
|
||||
expect(r).toEqual({ ok: true, value: 'BIG-123' });
|
||||
});
|
||||
|
||||
it('maps a ProblemDetails rejection to err(detail)', async () => {
|
||||
const r = await runSubmit(async () => {
|
||||
throw { detail: 'Aanvraag afgewezen.' };
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'Aanvraag afgewezen.' });
|
||||
});
|
||||
|
||||
it('falls back when the rejection has no detail', async () => {
|
||||
const r = await runSubmit(async () => {
|
||||
throw new Error('network');
|
||||
}, 'fallback');
|
||||
expect(r).toEqual({ ok: false, error: 'fallback' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { problemDetail } from '@shared/infrastructure/api-error';
|
||||
import { withIdempotencyKey } from '@shared/infrastructure/api-client.provider';
|
||||
|
||||
/**
|
||||
* Run a mutating API call and fold it into a `Result` — the one place the
|
||||
* try/catch + ProblemDetails-mapping lives, so every `submit-*` command is just
|
||||
* its own payload mapping. The backend re-validates and returns a 422
|
||||
* ProblemDetails on rejection, surfaced here as the error string.
|
||||
*
|
||||
* Also the one place a logical submit's Idempotency-Key is minted — once per
|
||||
* `runSubmit` call, not per HTTP attempt — so a retry of this same submit
|
||||
* dedupes on the backend (see `withIdempotencyKey`).
|
||||
*/
|
||||
export async function runSubmit<T>(
|
||||
fn: () => Promise<T>,
|
||||
fallback: string,
|
||||
): Promise<Result<string, T>> {
|
||||
try {
|
||||
return ok(await withIdempotencyKey(crypto.randomUUID(), fn));
|
||||
} catch (e) {
|
||||
return err(problemDetail(e, fallback));
|
||||
}
|
||||
}
|
||||
|
||||
// Single shared default for a failed submit; the @@id dedupes it at the
|
||||
// translation layer.
|
||||
export const SUBMIT_FAILED = $localize`:@@submit.failed:Het indienen is niet gelukt. Probeer het later opnieuw.`;
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* A stable, namespaced capability string (PRD-0002 §5a), e.g. `brief:approve`.
|
||||
* Server-resolved and opaque to the FE — never derived from a role client-side.
|
||||
*/
|
||||
export type Capability =
|
||||
| 'brief:approve'
|
||||
| 'brief:reject'
|
||||
| 'brief:send'
|
||||
| 'orgtemplate:edit'
|
||||
| 'stamdata:edit'
|
||||
| 'cases:manage'
|
||||
| 'flags:manage';
|
||||
@@ -0,0 +1,9 @@
|
||||
/** A runtime feature flag as the FE sees it (resolved: catalog default + admin override). */
|
||||
export interface FeatureFlag {
|
||||
key: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
/** Known flag keys the FE gates on — must match the backend `FeatureFlags` catalog. */
|
||||
export const FLAG_INSCHRIJVING_OPEN = 'inschrijving-open';
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* The letter workflow's acting role: drafter or approver for the two-person
|
||||
* compose/review flow, admin for org-template management (WP-23, Brief v2).
|
||||
* A pure domain type (no framework, no reading mechanism) — the `?role=` reader and
|
||||
* the X-Role header live in shared/infrastructure/role.ts. Consumers (brief.store,
|
||||
* letter-composer) depend on this type, not on how the role is obtained.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver' | 'admin';
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Production environment. `apiBaseUrl` stays relative ('') for a same-origin /
|
||||
* reverse-proxy deployment; set it to the API origin (e.g. 'https://api.example.nl')
|
||||
* when the SPA and backend are served from different hosts. This is the single
|
||||
* place the deployed API location is configured.
|
||||
*/
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Default (development) environment. `apiBaseUrl` is empty so requests are
|
||||
* relative to the current origin — in dev the ng-serve proxy forwards /api to the
|
||||
* backend (proxy.conf.json). Swapped for environment.prod.ts in production builds
|
||||
* (angular.json fileReplacements).
|
||||
*/
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: '',
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { HttpClient, HttpErrorResponse, HttpResponse } from '@angular/common/http';
|
||||
import { currentIdempotencyKey, httpClientFetch, withIdempotencyKey } from './api-client.provider';
|
||||
|
||||
/** Minimal stand-in for HttpClient — only `.request(...)` is ever called by the
|
||||
* adapter under test, so no TestBed/HttpClientTestingModule needed. */
|
||||
function fakeHttpClient(
|
||||
request: (method: string, url: string, options: { headers: Record<string, string> }) => unknown,
|
||||
): HttpClient {
|
||||
return { request } as unknown as HttpClient;
|
||||
}
|
||||
|
||||
describe('withIdempotencyKey / currentIdempotencyKey', () => {
|
||||
it('threads the key to every read made inside the wrapped fn', async () => {
|
||||
const seen: string[] = [];
|
||||
await withIdempotencyKey('fixed-key', async () => {
|
||||
seen.push(currentIdempotencyKey());
|
||||
seen.push(currentIdempotencyKey());
|
||||
});
|
||||
expect(seen).toEqual(['fixed-key', 'fixed-key']);
|
||||
});
|
||||
|
||||
it('clears the key once the wrapped fn settles', async () => {
|
||||
await withIdempotencyKey('fixed-key', async () => undefined);
|
||||
expect(currentIdempotencyKey()).not.toBe('fixed-key');
|
||||
});
|
||||
|
||||
it('falls back to a generated uuid-shaped key when none is pending', () => {
|
||||
expect(currentIdempotencyKey()).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('httpClientFetch', () => {
|
||||
it('sends the pending idempotency key as a header for a write, not a fresh one per attempt', async () => {
|
||||
let sentHeaders: Record<string, string> | undefined;
|
||||
const http = fakeHttpClient((_method, _url, opts) => {
|
||||
sentHeaders = opts.headers;
|
||||
return of(new HttpResponse({ status: 200, body: '' }));
|
||||
});
|
||||
|
||||
await withIdempotencyKey('logical-submit-key', () =>
|
||||
httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' }),
|
||||
);
|
||||
|
||||
expect(sentHeaders?.['Idempotency-Key']).toBe('logical-submit-key');
|
||||
});
|
||||
|
||||
// `http.request(...)` itself is only called once per `fetch()` — it returns a
|
||||
// cold Observable, and `retry` resubscribes to *that*, not to `.request()`
|
||||
// again (exactly how Angular's real HttpClient triggers a fresh network call
|
||||
// per subscription). So attempts are counted where the resubscription lands:
|
||||
// the `throwError` factory, not the outer mock call.
|
||||
it('retries a failing GET twice before giving up', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/notes', { method: 'GET' });
|
||||
|
||||
expect(attempts).toBe(3); // 1 original + 2 retries
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
|
||||
it('never retries a failing write', async () => {
|
||||
let attempts = 0;
|
||||
const http = fakeHttpClient(() =>
|
||||
throwError(() => {
|
||||
attempts++;
|
||||
return new HttpErrorResponse({ status: 500 });
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await httpClientFetch(http).fetch('/api/v1/change-requests', { method: 'POST' });
|
||||
|
||||
expect(attempts).toBe(1);
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Provider } from '@angular/core';
|
||||
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
|
||||
import { firstValueFrom, retry, timeout, TimeoutError } from 'rxjs';
|
||||
import { ApiClient, ProblemDetails } from './api-client';
|
||||
import { environment } from '@shared/environments/environment';
|
||||
|
||||
/** Single place every API call passes through: the seam for cross-cutting concerns. */
|
||||
const REQUEST_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* A stable Idempotency-Key threaded down from the command layer (one per logical
|
||||
* submit — see `runSubmit`) rather than minted per HTTP attempt, so a retried
|
||||
* submit dedupes on the backend instead of double-submitting. The NSwag-generated
|
||||
* `ApiClient` has no per-call header hook, so `withIdempotencyKey` bridges it here:
|
||||
* every non-GET call made synchronously inside `fn` picks up the same key.
|
||||
* ponytail: a module-level variable, not a proper async-context primitive — holds
|
||||
* up because every submit command calls its adapter synchronously (no await
|
||||
* before reaching this file); swap for `AsyncLocal`-equivalent if concurrent
|
||||
* submits ever become possible.
|
||||
*/
|
||||
let pendingIdempotencyKey: string | undefined;
|
||||
|
||||
export function withIdempotencyKey<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
pendingIdempotencyKey = key;
|
||||
return fn().finally(() => (pendingIdempotencyKey = undefined));
|
||||
}
|
||||
|
||||
export function currentIdempotencyKey(): string {
|
||||
return pendingIdempotencyKey ?? crypto.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapts Angular's HttpClient to the fetch-shaped interface the NSwag-generated
|
||||
* client expects, so every API call flows through HttpClient interceptors (the
|
||||
* `?scenario=` toggle) and the cross-cutting concerns below. The generated client
|
||||
* is the only place HTTP shapes are known; this is the only place it meets
|
||||
* Angular's HTTP stack — i.e. the one seam to add:
|
||||
* - timeout (done — REQUEST_TIMEOUT_MS),
|
||||
* - correlation id (done — X-Correlation-Id, echoed in backend logs),
|
||||
* - idempotency key for writes (done — Idempotency-Key, stable per logical
|
||||
* submit via `withIdempotencyKey`/`runSubmit`, so a retry dedupes),
|
||||
* - auth: attach `Authorization: Bearer …` here (one line) when real DigiD lands,
|
||||
* - retry/backoff (done — GET only, `retry({ count: 2, delay: 500 })`; writes are
|
||||
* never auto-retried, which is exactly what makes the idempotency key above
|
||||
* matter only for a future/manual retry, not routine traffic).
|
||||
*/
|
||||
export function httpClientFetch(http: HttpClient) {
|
||||
return {
|
||||
async fetch(url: RequestInfo, init?: RequestInit): Promise<Response> {
|
||||
const method = (init?.method ?? 'GET').toUpperCase();
|
||||
const headers: Record<string, string> = {
|
||||
...((init?.headers ?? {}) as Record<string, string>),
|
||||
'X-Correlation-Id': crypto.randomUUID(),
|
||||
};
|
||||
if (method !== 'GET') headers['Idempotency-Key'] = currentIdempotencyKey();
|
||||
try {
|
||||
const request$ = http
|
||||
.request(method, url as string, {
|
||||
body: init?.body as string | undefined,
|
||||
headers,
|
||||
observe: 'response',
|
||||
responseType: 'text',
|
||||
})
|
||||
.pipe(timeout(REQUEST_TIMEOUT_MS));
|
||||
const res = await firstValueFrom(
|
||||
method === 'GET' ? request$.pipe(retry({ count: 2, delay: 500 })) : request$,
|
||||
);
|
||||
// 204/205/304 are null-body statuses — new Response(body, …) throws for any non-null body.
|
||||
const nullBody = res.status === 204 || res.status === 205 || res.status === 304;
|
||||
return new Response(nullBody ? null : (res.body ?? ''), { status: res.status || 200 });
|
||||
} catch (e) {
|
||||
if (e instanceof TimeoutError) return new Response('', { status: 504 });
|
||||
const err = e as HttpErrorResponse;
|
||||
const body = typeof err.error === 'string' ? err.error : JSON.stringify(err.error ?? {});
|
||||
// ponytail: clamp to a Response-constructible status (an aborted/interceptor
|
||||
// request reports status 0, which `new Response` rejects).
|
||||
const status = err.status >= 200 && err.status <= 599 ? err.status : 500;
|
||||
return new Response(body, { status });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Provide a root ApiClient that talks through HttpClient. Base URL comes from the
|
||||
* environment (relative '' in dev → proxy; configurable per deployment). */
|
||||
export function provideApiClient(): Provider {
|
||||
return {
|
||||
provide: ApiClient,
|
||||
useFactory: (http: HttpClient) => new ApiClient(environment.apiBaseUrl, httpClientFetch(http)),
|
||||
deps: [HttpClient],
|
||||
};
|
||||
}
|
||||
|
||||
export type { ProblemDetails };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { problemDetail, problemFieldErrors } from './api-error';
|
||||
|
||||
describe('problemDetail', () => {
|
||||
it('extracts the detail from an RFC-7807 ProblemDetails', () => {
|
||||
expect(problemDetail({ detail: 'Afgewezen: 0 uren.', status: 422 }, 'fallback')).toBe(
|
||||
'Afgewezen: 0 uren.',
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back when there is no detail', () => {
|
||||
expect(problemDetail(new Error('boom'), 'fallback')).toBe('fallback');
|
||||
expect(problemDetail({ status: 500 }, 'fallback')).toBe('fallback');
|
||||
expect(problemDetail(undefined, 'fallback')).toBe('fallback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('problemFieldErrors (G4 seam)', () => {
|
||||
it('maps a ValidationProblemDetails errors dict to first-message-per-field', () => {
|
||||
expect(
|
||||
problemFieldErrors({ errors: { straat: ['Verplicht.'], postcode: ['Ongeldig.', 'x'] } }),
|
||||
).toEqual({ straat: 'Verplicht.', postcode: 'Ongeldig.' });
|
||||
});
|
||||
|
||||
it('returns {} when there is no errors envelope (the current backend shape)', () => {
|
||||
expect(problemFieldErrors({ detail: 'one banner' })).toEqual({});
|
||||
expect(problemFieldErrors(new Error('boom'))).toEqual({});
|
||||
expect(problemFieldErrors(undefined)).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ProblemDetails } from './api-client';
|
||||
|
||||
/**
|
||||
* Extract a human-readable message from a rejected API call. A 4xx/5xx with a
|
||||
* ProblemDetails body (RFC 7807) is thrown by the generated client as the parsed
|
||||
* object; anything else falls back to the given message.
|
||||
*/
|
||||
export function problemDetail(e: unknown, fallback: string): string {
|
||||
if (e && typeof e === 'object' && 'detail' in e) {
|
||||
const detail = (e as ProblemDetails).detail;
|
||||
if (typeof detail === 'string' && detail) return detail;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEAM (G4): map a server validation envelope to field-level errors.
|
||||
*
|
||||
* ASP.NET's ValidationProblemDetails carries `errors: { field: string[] }`. The
|
||||
* backend today returns only `detail` (one banner message), so this returns `{}`.
|
||||
* When the backend starts sending `errors`, a machine's `SubmitFailed` handler can
|
||||
* merge this into its own `errors` map — the field-keyed shape the wizards already
|
||||
* render — so a rejection shows inline per field, not just as a banner. The
|
||||
* consumer hook is the only thing left to wire; the contract boundary lives here.
|
||||
*/
|
||||
export function problemFieldErrors(e: unknown): Record<string, string> {
|
||||
if (!e || typeof e !== 'object' || !('errors' in e)) return {};
|
||||
const errors = (e as { errors?: unknown }).errors;
|
||||
if (!errors || typeof errors !== 'object') return {};
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, msgs] of Object.entries(errors as Record<string, unknown>)) {
|
||||
const first = Array.isArray(msgs) ? msgs[0] : msgs;
|
||||
if (typeof first === 'string') out[field] = first;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { stripDevParams } from './dev-params';
|
||||
|
||||
describe('stripDevParams (WP-37)', () => {
|
||||
it('removes ?scenario and ?role so the stored dev value wins on reload', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard?scenario=slow&role=admin')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps unrelated query params and the path/hash', () => {
|
||||
expect(stripDevParams('http://localhost:4200/beheer/zaken?scenario=error&tab=2#top')).toBe(
|
||||
'http://localhost:4200/beheer/zaken?tab=2#top',
|
||||
);
|
||||
});
|
||||
|
||||
it('is a no-op when neither param is present', () => {
|
||||
expect(stripDevParams('http://localhost:4200/dashboard')).toBe(
|
||||
'http://localhost:4200/dashboard',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Remove the dev-only `?scenario=` and `?role=` params from a URL (WP-37). Once the
|
||||
* dev switcher (debug-state) has been used, sessionStorage is the authoritative source
|
||||
* for both — `currentScenario()`/`currentRole()` read the URL FIRST, so a stale param
|
||||
* left in the address bar would override the switcher on reload (the "stuck on slow"
|
||||
* bug). Stripping the params before reload lets the stored value win. Pure: returns the
|
||||
* rewritten href, mutates nothing.
|
||||
*/
|
||||
export function stripDevParams(href: string): string {
|
||||
const url = new URL(href);
|
||||
url.searchParams.delete('scenario');
|
||||
url.searchParams.delete('role');
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
import { FeatureFlag } from '@shared/domain/feature-flag';
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for feature flags (WP-47): `GET /flags` (resolved set, drives FE gating)
|
||||
* and the admin `PUT /admin/flags/{key}`. The single place the ApiClient lives for flags; the
|
||||
* store parses at the boundary.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class FeatureFlagsAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
list() {
|
||||
return this.client.flagsAll();
|
||||
}
|
||||
set(key: string, enabled: boolean) {
|
||||
return this.client.flags(key, { enabled });
|
||||
}
|
||||
}
|
||||
|
||||
/** Trust-boundary parse of the flag set. */
|
||||
export function parseFlags(json: unknown): Result<string, FeatureFlag[]> {
|
||||
if (!Array.isArray(json)) return err('flags: not an array');
|
||||
const out: FeatureFlag[] = [];
|
||||
for (const f of json) {
|
||||
if (typeof f !== 'object' || f === null) return err('flags: row not an object');
|
||||
const d = f as Partial<FeatureFlag>;
|
||||
if (typeof d.key !== 'string' || typeof d.enabled !== 'boolean') return err('flags: bad shape');
|
||||
out.push({
|
||||
key: d.key,
|
||||
description: typeof d.description === 'string' ? d.description : '',
|
||||
enabled: d.enabled,
|
||||
});
|
||||
}
|
||||
return ok(out);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseMe } from './me.adapter';
|
||||
|
||||
describe('parseMe (trust boundary)', () => {
|
||||
it('parses a known capability list', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve', 'brief:reject', 'brief:send'] });
|
||||
});
|
||||
|
||||
it('parses an empty list (drafter — no capabilities)', () => {
|
||||
expect(parseMe({ capabilities: [] })).toEqual({ ok: true, value: [] });
|
||||
});
|
||||
|
||||
it('recognizes the admin org-template capability (WP-23)', () => {
|
||||
expect(parseMe({ capabilities: ['orgtemplate:edit'] })).toEqual({
|
||||
ok: true,
|
||||
value: ['orgtemplate:edit'],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops unrecognized capability strings instead of rejecting the response', () => {
|
||||
const r = parseMe({ capabilities: ['brief:approve', 'unknown:future-thing'] });
|
||||
expect(r).toEqual({ ok: true, value: ['brief:approve'] });
|
||||
});
|
||||
|
||||
it('rejects malformed responses instead of trusting them', () => {
|
||||
expect(parseMe(null).ok).toBe(false);
|
||||
expect(parseMe({}).ok).toBe(false);
|
||||
expect(parseMe({ capabilities: 'brief:approve' }).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Injectable, inject, resource } from '@angular/core';
|
||||
import { Result, ok, err } from '@shared/kernel/fp';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||
|
||||
const KNOWN: readonly Capability[] = [
|
||||
'brief:approve',
|
||||
'brief:reject',
|
||||
'brief:send',
|
||||
'orgtemplate:edit',
|
||||
'stamdata:edit',
|
||||
'cases:manage',
|
||||
'flags:manage',
|
||||
];
|
||||
|
||||
/**
|
||||
* Infrastructure adapter for `GET /me` (PRD-0002 §6): the current principal's
|
||||
* coarse, role-derived capabilities — nav/menu-level, not tied to any one screen's
|
||||
* live status (contrast a screen's own decision DTO, e.g. `BriefViewDto.decisions`).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MeAdapter {
|
||||
private client = inject(ApiClient);
|
||||
|
||||
meResource() {
|
||||
return resource({ loader: () => this.client.me() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trust-boundary parse. An unrecognized capability string is dropped rather than
|
||||
* rejecting the whole response — deny-by-default already covers it (AccessStore.can
|
||||
* returns false for anything not in the set), and it lets the backend grow the
|
||||
* capability list without breaking an older FE build.
|
||||
*/
|
||||
export function parseMe(json: unknown): Result<string, Capability[]> {
|
||||
if (typeof json !== 'object' || json === null) return err('me: not an object');
|
||||
const dto = json as { capabilities?: unknown };
|
||||
if (!Array.isArray(dto.capabilities)) return err('me: missing/invalid capabilities');
|
||||
return ok(dto.capabilities.filter((c): c is Capability => KNOWN.includes(c as Capability)));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { roleInterceptor } from './role.interceptor';
|
||||
|
||||
// currentRole() reads window.location.search; set it via the real URL rather than
|
||||
// vi.mock (the Angular unit-test system forbids mocking relative imports).
|
||||
beforeEach(() => window.history.replaceState({}, '', '/?role=admin'));
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, '', '/');
|
||||
sessionStorage.clear(); // currentRole() now persists the dev role; don't leak across tests
|
||||
});
|
||||
|
||||
// Minimal stand-in for HttpRequest — the interceptor only reads `url` and calls
|
||||
// `clone({ setHeaders })`. Avoids importing @angular/common/http (its XHR chunk needs
|
||||
// the JIT compiler under vitest).
|
||||
function fakeReq(url: string) {
|
||||
const make = (headers: Map<string, string>) => ({
|
||||
url,
|
||||
headers,
|
||||
clone(opts: { setHeaders: Record<string, string> }) {
|
||||
const next = new Map(headers);
|
||||
for (const [k, v] of Object.entries(opts.setHeaders)) next.set(k, v);
|
||||
return make(next);
|
||||
},
|
||||
});
|
||||
return make(new Map());
|
||||
}
|
||||
|
||||
/** Run the interceptor and return the request it forwarded to `next`. */
|
||||
function forward(url: string) {
|
||||
let seen!: ReturnType<typeof fakeReq>;
|
||||
const next = (r: ReturnType<typeof fakeReq>) => {
|
||||
seen = r;
|
||||
return undefined;
|
||||
};
|
||||
// Cast: the fake matches the shape the interceptor actually touches.
|
||||
(roleInterceptor as unknown as (req: unknown, next: unknown) => unknown)(fakeReq(url), next);
|
||||
return seen;
|
||||
}
|
||||
|
||||
describe('roleInterceptor', () => {
|
||||
it.each([
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/stamdata', // WP-29: the admin stamdata reads 403 without X-Role
|
||||
'/api/v1/stamdata/professions?peildatum=1999-01-01',
|
||||
'/api/v1/me',
|
||||
])('stamps X-Role on the role-aware endpoint %s', (url) => {
|
||||
expect(forward(url).headers.get('X-Role')).toBe('admin');
|
||||
});
|
||||
|
||||
it('leaves an unrelated endpoint untouched', () => {
|
||||
expect(forward('/api/v1/duo/diplomas').headers.has('X-Role')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { currentRole } from './role';
|
||||
|
||||
/**
|
||||
* Dev-only: stamps role-aware requests with the current `?role=` as an `X-Role`
|
||||
* header so the backend can enforce the drafter/approver/admin rules. Only the
|
||||
* brief, org-template, stamdata and /me endpoints carry it (WP-23 widened the set —
|
||||
* /me must see the role or `AccessStore` could never learn a capability; WP-29 added
|
||||
* /stamdata, whose admin-only reads 403 without it); everything else is untouched.
|
||||
* A new admin-gated endpoint MUST be added here or its page silently 403s.
|
||||
*/
|
||||
const ROLE_AWARE = [
|
||||
'/api/v1/brief',
|
||||
'/api/v1/admin/org-template',
|
||||
'/api/v1/admin/cases',
|
||||
'/api/v1/admin/audit',
|
||||
'/api/v1/admin/flags',
|
||||
'/api/v1/stamdata',
|
||||
'/api/v1/me',
|
||||
];
|
||||
|
||||
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!ROLE_AWARE.some((prefix) => req.url.includes(prefix))) return next(req);
|
||||
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Role } from '@shared/domain/role';
|
||||
|
||||
/**
|
||||
* Dev-only role stand-in (the reading MECHANISM; the `Role` type is domain). This
|
||||
* POC has one faked self-service user and no real identities, so the two-person
|
||||
* letter workflow (drafter vs approver) plus admin is driven by a `?role=` query
|
||||
* param. The backend receives it as an `X-Role` header (see role.interceptor),
|
||||
* resolves it into a `Principal` server-side, and is the sole authority on what that
|
||||
* principal may do (PRD-0002 phase P1, `Authz.Can`) — the FE only renders the
|
||||
* resulting decision flags, it no longer derives permission from this value itself.
|
||||
*
|
||||
* **Sticky within the tab (sessionStorage):** the interceptor reads this per request,
|
||||
* but navigation drops the query param (login redirects to /dashboard, RouterLinks
|
||||
* don't carry it), which would silently revert an admin to drafter mid-session and
|
||||
* 403 the admin endpoints. So a `?role=` seen in the URL is remembered for the tab;
|
||||
* later requests use the remembered value. Set `?role=drafter` (or a fresh tab) to
|
||||
* reset. Dev-only — the interceptor itself is only wired under `isDevMode()`.
|
||||
*/
|
||||
const STORAGE_KEY = 'dev-role';
|
||||
export const ROLES: readonly Role[] = ['drafter', 'approver', 'admin'];
|
||||
const isRole = (v: string | null): v is Role => !!v && ROLES.includes(v as Role);
|
||||
|
||||
export function currentRole(): Role {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('role');
|
||||
if (isRole(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isRole(stored) ? stored : 'drafter';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen role for the tab (WP-33). */
|
||||
export function setRole(r: Role): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, r);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn, HttpResponse } from '@angular/common/http';
|
||||
import { of, switchMap, throwError, timer } from 'rxjs';
|
||||
import { delay } from 'rxjs/operators';
|
||||
import { currentScenario } from './scenario';
|
||||
|
||||
/**
|
||||
* Demo-only: rewrites the timing/outcome of API data requests based on
|
||||
* ?scenario= so loading / empty / error states can be shown on demand.
|
||||
* Non-API requests are untouched.
|
||||
*/
|
||||
export const scenarioInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('/api/')) return next(req);
|
||||
|
||||
switch (currentScenario()) {
|
||||
case 'slow':
|
||||
return next(req).pipe(delay(2500));
|
||||
case 'loading':
|
||||
return next(req).pipe(delay(600_000)); // effectively never resolves
|
||||
case 'empty':
|
||||
// '[]' so the typed client parses it to an empty array (notes → Empty state).
|
||||
return of(new HttpResponse({ status: 200, body: '[]' })).pipe(delay(400));
|
||||
case 'error':
|
||||
return timer(400).pipe(
|
||||
switchMap(() =>
|
||||
throwError(
|
||||
() => new HttpErrorResponse({ status: 500, statusText: 'Demo-fout', url: req.url }),
|
||||
),
|
||||
),
|
||||
);
|
||||
default:
|
||||
return next(req);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { currentScenario, setScenario } from './scenario';
|
||||
|
||||
const setUrl = (search: string) => history.pushState({}, '', search || '/');
|
||||
|
||||
describe('scenario (dev mechanism)', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear();
|
||||
setUrl('/');
|
||||
});
|
||||
|
||||
it('reads a valid ?scenario= from the URL and persists it for the tab', () => {
|
||||
setUrl('?scenario=error');
|
||||
expect(currentScenario()).toBe('error');
|
||||
setUrl('/'); // navigation drops the query param — value stays sticky
|
||||
expect(currentScenario()).toBe('error');
|
||||
});
|
||||
|
||||
it('falls back to default when nothing is set or the value is invalid', () => {
|
||||
expect(currentScenario()).toBe('default');
|
||||
setUrl('?scenario=nonsense');
|
||||
expect(currentScenario()).toBe('default');
|
||||
});
|
||||
|
||||
it('setScenario persists the chosen scenario', () => {
|
||||
setScenario('slow');
|
||||
expect(currentScenario()).toBe('slow');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type Scenario =
|
||||
| 'default'
|
||||
| 'slow'
|
||||
| 'loading'
|
||||
| 'empty'
|
||||
| 'error'
|
||||
// upload-only (the multipart POST is hand-written XHR, so it bypasses the HTTP
|
||||
// interceptor — these are simulated in upload.adapter.ts instead):
|
||||
| 'upload-slow'
|
||||
| 'upload-fail';
|
||||
|
||||
export const SCENARIOS: readonly Scenario[] = [
|
||||
'default',
|
||||
'slow',
|
||||
'loading',
|
||||
'empty',
|
||||
'error',
|
||||
'upload-slow',
|
||||
'upload-fail',
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'dev-scenario';
|
||||
const isScenario = (v: string | null): v is Scenario => !!v && SCENARIOS.includes(v as Scenario);
|
||||
|
||||
/**
|
||||
* Reads the active demo scenario so a demo can force each async state.
|
||||
* Sticky within the tab (sessionStorage), mirroring `role.ts`: a `?scenario=` in the
|
||||
* URL sets it; later navigation (which drops the query param) keeps the remembered
|
||||
* value. Set `?scenario=default`, use the dev switcher, or open a fresh tab to reset.
|
||||
* Dev-only — the interceptor that consumes this is wired only under `isDevMode()`.
|
||||
*/
|
||||
export function currentScenario(): Scenario {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('scenario');
|
||||
if (isScenario(fromUrl)) {
|
||||
sessionStorage.setItem(STORAGE_KEY, fromUrl);
|
||||
return fromUrl;
|
||||
}
|
||||
const stored = sessionStorage.getItem(STORAGE_KEY);
|
||||
return isScenario(stored) ? stored : 'default';
|
||||
}
|
||||
|
||||
/** Dev switcher entry point: persist the chosen scenario for the tab (WP-33). */
|
||||
export function setScenario(s: Scenario): void {
|
||||
sessionStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseBsn } from './bsn';
|
||||
|
||||
describe('parseBsn (elfproef)', () => {
|
||||
it('accepts a valid BSN (passes the elfproef)', () => {
|
||||
const r = parseBsn('123456782'); // Σ d·w = 154, divisible by 11
|
||||
expect(r.ok && r.value).toBe('123456782');
|
||||
});
|
||||
|
||||
it('rejects a 9-digit number that fails the elfproef', () => {
|
||||
expect(parseBsn('123456789').ok).toBe(false); // sum 147, not divisible
|
||||
});
|
||||
|
||||
it('rejects wrong length / non-digits / all zeros', () => {
|
||||
expect(parseBsn('12345').ok).toBe(false);
|
||||
expect(parseBsn('abcdefghi').ok).toBe(false);
|
||||
expect(parseBsn('000000000').ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Brand, Result, ok, err } from '@shared/kernel/fp';
|
||||
|
||||
/**
|
||||
* Value object: a Dutch **BSN** (burgerservicenummer) — art. 9 GDPR/AVG special-category
|
||||
* data. "Parse, don't validate": a `Bsn` is a distinct type from a raw string, mintable only
|
||||
* via `parseBsn`, so holding one is proof it passed the **elfproef** (11-test) checksum, not
|
||||
* just a 9-digit shape. Format/checksum only — identity is still faked in this POC (DigiD stub).
|
||||
*/
|
||||
export type Bsn = Brand<string, 'Bsn'>;
|
||||
|
||||
// Positional weights for the elfproef: 9·d1 + 8·d2 + … + 2·d8 − 1·d9 ≡ 0 (mod 11).
|
||||
const WEIGHTS = [9, 8, 7, 6, 5, 4, 3, 2, -1];
|
||||
|
||||
// #region showcase:parseBsn
|
||||
export function parseBsn(raw: string): Result<string, Bsn> {
|
||||
const t = raw.trim();
|
||||
if (!/^\d{9}$/.test(t)) {
|
||||
return err($localize`:@@validation.bsn:Voer een geldig BSN van 9 cijfers in.`);
|
||||
}
|
||||
const sum = [...t].reduce((acc, ch, i) => acc + Number(ch) * WEIGHTS[i], 0);
|
||||
if (t === '000000000' || sum % 11 !== 0) {
|
||||
return err(
|
||||
$localize`:@@validation.bsnElfproef:Dit is geen geldig BSN (klopt niet met de elfproef).`,
|
||||
);
|
||||
}
|
||||
return ok(t as Bsn); // holding a Bsn is proof it passed the elfproef
|
||||
}
|
||||
// #endregion showcase:parseBsn
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatDatumNl } from './datum';
|
||||
|
||||
describe('formatDatumNl', () => {
|
||||
it('formats a Date in long Dutch form', () => {
|
||||
expect(formatDatumNl(new Date(2026, 6, 2))).toBe('2 juli 2026');
|
||||
});
|
||||
|
||||
it('formats an ISO string the same way', () => {
|
||||
expect(formatDatumNl('2026-07-02')).toBe('2 juli 2026');
|
||||
});
|
||||
|
||||
it('is empty-safe: undefined, null, and empty string all yield the empty string', () => {
|
||||
expect(formatDatumNl(undefined)).toBe('');
|
||||
expect(formatDatumNl(null)).toBe('');
|
||||
expect(formatDatumNl('')).toBe('');
|
||||
});
|
||||
|
||||
it('returns empty for an unparseable string rather than "Invalid Date"', () => {
|
||||
expect(formatDatumNl('not-a-date')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* The one hand-written date formatter for pure TS (non-template) code — a domain
|
||||
* rule or a `$localize` string can't reach for Angular's `DatePipe`, so this covers
|
||||
* that gap. Templates use `DatePipe` (`| date: 'longDate'`) instead; don't add a
|
||||
* second hand-rolled formatter for either case.
|
||||
*/
|
||||
export function formatDatumNl(d: Date | string | undefined | null): string {
|
||||
if (!d) return '';
|
||||
const date = typeof d === 'string' ? new Date(d) : d;
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('nl-NL', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Tiny native-TS functional toolkit. No dependency — this is the whole "library".
|
||||
* Reused by every "impossible states" concept in the POC.
|
||||
*/
|
||||
|
||||
/** Exhaustiveness guard: put in the `default` arm of a union switch. Adding a
|
||||
new variant without handling it then fails to compile (x is no longer never). */
|
||||
export function assertNever(x: never): never {
|
||||
throw new Error('Unexpected variant: ' + JSON.stringify(x));
|
||||
}
|
||||
|
||||
/** A computation that either succeeded with a value or failed with an error.
|
||||
Plain objects (no classes) to match the signal/httpResource ergonomics. */
|
||||
export type Result<E, T> =
|
||||
{ readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E };
|
||||
|
||||
export const ok = <T>(value: T): Result<never, T> => ({ ok: true, value });
|
||||
export const err = <E>(error: E): Result<E, never> => ({ ok: false, error });
|
||||
|
||||
/** Nominal typing: Brand<string, 'Postcode'> is assignable from a plain string
|
||||
only through an explicit cast — so a smart constructor is the only minter. */
|
||||
export type Brand<T, B extends string> = T & { readonly __brand: B };
|
||||
|
||||
/** Narrow a tagged union to one variant by its `tag`, or null. The single place
|
||||
the cast lives — TS can't narrow through a runtime tag argument, so callers get
|
||||
`whenTag(state, 'Editing')?.foo` instead of repeating `as Extract<…>`. */
|
||||
export function whenTag<U extends { tag: string }, K extends U['tag']>(
|
||||
u: U,
|
||||
tag: K,
|
||||
): Extract<U, { tag: K }> | null {
|
||||
return u.tag === tag ? (u as Extract<U, { tag: K }>) : null;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { maskBsn, maskTail } from './pii';
|
||||
|
||||
describe('pii maskers', () => {
|
||||
it('maskBsn keeps the last 3 digits', () => {
|
||||
expect(maskBsn('123456789')).toBe('******789');
|
||||
});
|
||||
|
||||
it('maskTail keeps the requested tail length', () => {
|
||||
expect(maskTail('abcdef', 2)).toBe('****ef');
|
||||
});
|
||||
|
||||
it('masks the whole value when it is not longer than the kept tail', () => {
|
||||
expect(maskBsn('12')).toBe('**');
|
||||
expect(maskBsn('')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* PII masking — pure functional core (WP-40). Data-minimisation helpers shared by the app
|
||||
* (dev state panel, the masked-value atom, anywhere sensitive data is shown). No framework,
|
||||
* no domain imports. The backend keeps a `MaskTail` twin in sync (see Program.cs).
|
||||
*/
|
||||
export const REDACTED = '‹redacted›';
|
||||
|
||||
// #region showcase:mask
|
||||
/** Keep the last `keep` characters, mask the rest with `*`. */
|
||||
export function maskTail(value: string, keep: number): string {
|
||||
if (value.length <= keep) return '*'.repeat(value.length);
|
||||
return '*'.repeat(value.length - keep) + value.slice(-keep);
|
||||
}
|
||||
|
||||
/** Mask a BSN / BIG-nummer for display: keep the last 3 digits, mask the rest. */
|
||||
export function maskBsn(value: string): string {
|
||||
return maskTail(value, 3);
|
||||
}
|
||||
// #endregion showcase:mask
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
RichTextBlock,
|
||||
deepCopyBlock,
|
||||
emptyBlock,
|
||||
isBlockEmpty,
|
||||
placeholderKeysIn,
|
||||
} from './rich-text';
|
||||
|
||||
const block = (): RichTextBlock => ({
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'placeholder', key: 'datum' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe('rich-text', () => {
|
||||
it('emptyBlock is one empty paragraph and reads as empty', () => {
|
||||
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
|
||||
expect(isBlockEmpty(emptyBlock())).toBe(true);
|
||||
});
|
||||
|
||||
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
|
||||
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
|
||||
});
|
||||
|
||||
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
|
||||
const original = block();
|
||||
const copy = deepCopyBlock(original);
|
||||
expect(copy).toEqual(original);
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
|
||||
// Mutating the copy must not touch the original — proves no shared reference.
|
||||
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = {
|
||||
type: 'text',
|
||||
text: 'CHANGED',
|
||||
};
|
||||
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
|
||||
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Rich text as a *serialisable value*, not opaque HTML.
|
||||
*
|
||||
* A block is a node tree. Because a placeholder is a first-class NODE (not a
|
||||
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
|
||||
* and linting it are all pure functions over data — no regex over markup. This
|
||||
* is the whole reason the letter feature stays in the "impossible states" style:
|
||||
* the value the app holds is always well-shaped, and the imperative editor is
|
||||
* quarantined behind one component that converts to/from this tree.
|
||||
*/
|
||||
|
||||
export type Mark = 'bold' | 'italic' | 'underline';
|
||||
|
||||
export type RichTextNode =
|
||||
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
|
||||
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
|
||||
| { readonly type: 'lineBreak' };
|
||||
|
||||
export interface Paragraph {
|
||||
readonly nodes: readonly RichTextNode[];
|
||||
// A line can be a plain paragraph (undefined) or an item in a bullet/numbered list.
|
||||
// Consecutive lines with the same list kind render as one <ul>/<ol>.
|
||||
readonly list?: 'bullet' | 'number';
|
||||
}
|
||||
|
||||
export interface RichTextBlock {
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
/** An empty editable block is one empty paragraph — never zero paragraphs, so the
|
||||
editor always has a caret line. */
|
||||
export function emptyBlock(): RichTextBlock {
|
||||
return { paragraphs: [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
/** True when the block carries no visible content (used for "required section empty"). */
|
||||
export function isBlockEmpty(block: RichTextBlock): boolean {
|
||||
return block.paragraphs.every((p) =>
|
||||
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
|
||||
passage into a letter copies its tree through here, so the letter never shares a
|
||||
reference with the library — later library edits can't mutate an existing letter. */
|
||||
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
|
||||
// a hand-rolled walk would be more code for the same result.
|
||||
return structuredClone(block) as RichTextBlock;
|
||||
}
|
||||
|
||||
/** All visible text of a block as one lowercased string — for client-side search over
|
||||
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
|
||||
export function textOf(block: RichTextBlock): string {
|
||||
return block.paragraphs
|
||||
.flatMap((p) =>
|
||||
p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')),
|
||||
)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every placeholder key used in a block, in document order (duplicates kept — the
|
||||
caller dedupes when it wants a set). */
|
||||
export function placeholderKeysIn(block: RichTextBlock): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const p of block.paragraphs) {
|
||||
for (const n of p.nodes) {
|
||||
if (n.type === 'placeholder') keys.push(n.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BreadcrumbItem } from './breadcrumb.component';
|
||||
|
||||
/** Route → breadcrumb label + parent. The app has a small fixed route set
|
||||
(see app.routes.ts), so a static map is enough — no per-page wiring.
|
||||
ponytail: static map, not a breadcrumb service; revisit if routes go dynamic. */
|
||||
interface Crumb {
|
||||
label: string;
|
||||
parent?: string;
|
||||
}
|
||||
|
||||
const ROUTES: Record<string, Crumb> = {
|
||||
'/dashboard': { label: $localize`:@@crumb.dashboard:Mijn overzicht` },
|
||||
'/registratie': { label: $localize`:@@crumb.registratie:Mijn gegevens`, parent: '/dashboard' },
|
||||
'/registreren': { label: $localize`:@@crumb.registreren:Inschrijven`, parent: '/dashboard' },
|
||||
'/herregistratie': {
|
||||
label: $localize`:@@crumb.herregistratie:Herregistratie`,
|
||||
parent: '/dashboard',
|
||||
},
|
||||
'/intake': { label: $localize`:@@crumb.intake:Herregistratie-intake`, parent: '/dashboard' },
|
||||
'/concepts': { label: $localize`:@@crumb.concepts:Functionele patronen`, parent: '/dashboard' },
|
||||
};
|
||||
|
||||
/** Build the breadcrumb trail for a router url (query/fragment stripped).
|
||||
Returns [] for unknown routes (e.g. /login) so the bar can hide itself. */
|
||||
export function trailFor(url: string): BreadcrumbItem[] {
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const trail: BreadcrumbItem[] = [];
|
||||
let cursor: string | undefined = path;
|
||||
while (cursor) {
|
||||
const node: Crumb | undefined = ROUTES[cursor];
|
||||
if (!node) break;
|
||||
trail.unshift({ label: node.label, link: cursor });
|
||||
cursor = node.parent;
|
||||
}
|
||||
// The current (last) page is not a link.
|
||||
if (trail.length) delete trail[trail.length - 1].link;
|
||||
return trail;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
export interface BreadcrumbItem {
|
||||
label: string;
|
||||
link?: string; // omit on the current (last) page
|
||||
}
|
||||
|
||||
/** Chrome: breadcrumb navigation, styled for the CIBG titlebar (`.titlebar .title`) —
|
||||
plain links with a chevron `::after` from the CIBG Icons font, current page as an
|
||||
unlinked, bold span. Domain-free — the caller supplies the trail. */
|
||||
@Component({
|
||||
selector: 'app-breadcrumb',
|
||||
imports: [RouterLink],
|
||||
// CIBG's global "header nav" background rule matches ANY nav inside a <header>
|
||||
// — including this one, wherever it's mounted. Override it so the breadcrumb
|
||||
// never carries its own background (it should show whatever's behind it, e.g.
|
||||
// the titlebar's robijn fill).
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
background: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav i18n-aria-label="@@breadcrumb.aria" aria-label="Kruimelpad">
|
||||
<span class="visually-hidden" i18n="@@breadcrumb.hier">U bevindt zich hier:</span>
|
||||
@for (item of items(); track item.label; let last = $last) {
|
||||
@if (item.link && !last) {
|
||||
<a [routerLink]="item.link">{{ item.label }}</a>
|
||||
} @else {
|
||||
<span aria-current="page">{{ item.label }}</span>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class BreadcrumbComponent {
|
||||
items = input.required<BreadcrumbItem[]>();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { BreadcrumbComponent } from './breadcrumb.component';
|
||||
|
||||
const meta: Meta<BreadcrumbComponent> = {
|
||||
title: 'Design System/Molecules/Breadcrumb',
|
||||
component: BreadcrumbComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rendered inside a mock .titlebar .title so the story reflects the real chrome.
|
||||
template: `<div class="titlebar" style="padding: 1rem"><div class="title"><app-breadcrumb [items]="items" /></div></div>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<BreadcrumbComponent>;
|
||||
|
||||
export const TweeNiveaus: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Inschrijven in het BIG-register' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export const DrieNiveaus: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ label: 'Mijn omgeving', link: '/dashboard' },
|
||||
{ label: 'Registratie', link: '/registratie' },
|
||||
{ label: 'Inschrijven' },
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Component, computed, inject, input } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
import { EMPTY, filter } from 'rxjs';
|
||||
import { Locale, localeLinks } from './locale-links';
|
||||
|
||||
// CIBG-GAP EXTENSION: "Taal instellen" (designsystem.cibg.nl/componenten/taal-instellen) — no
|
||||
// vendored Huisstijl class ships for it, so this is a small hand-rolled surface built from the
|
||||
// token bridge. See cibg-gaps.mdx.
|
||||
/**
|
||||
* Organism: CIBG "Taal instellen" language switcher. A `<nav>` region (screenreader heading +
|
||||
* aria-label) with one link per locale — the endonym, tagged with its `lang`/`hreflang`, the
|
||||
* active one marked `aria-current` and rendered as text (not a link).
|
||||
*
|
||||
* Compile-time $localize means each locale is a separate bundle under `/<locale>/`, so switching
|
||||
* is a plain full-page navigation to the sibling bundle (not a runtime toggle). The active locale
|
||||
* is read from the baked `<base href>` (`/en/` → en, else nl) — the deployment truth, independent
|
||||
* of the app-config `LOCALE_ID`. Only functional where both locale bundles are served (the
|
||||
* localized build, e.g. `npm run serve:i18n`), not under plain `ng serve` (nl-only at `/`).
|
||||
*
|
||||
* The shell (and this switcher within it) is a persistent parent — only the routed child
|
||||
* swaps — so `location.pathname` must be re-read on every completed navigation (same
|
||||
* `toSignal(router.events...)` idiom as `site-header.component.ts`'s breadcrumb `url`), or the
|
||||
* target link freezes at whichever route was active when the switcher was first constructed.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-language-switcher',
|
||||
styles: [
|
||||
`
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--rhc-space-max-md);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-2xl);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
a {
|
||||
color: var(--rhc-color-hemelblauw-700);
|
||||
}
|
||||
[aria-current] {
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
}
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<nav [attr.aria-label]="navLabel">
|
||||
<h2 class="sr-only">{{ heading }}</h2>
|
||||
@for (l of links(); track l.locale) {
|
||||
@if (l.active) {
|
||||
<span [attr.lang]="l.locale" aria-current="true">{{ l.label }}</span>
|
||||
} @else {
|
||||
<a [attr.lang]="l.locale" [attr.hreflang]="l.locale" [href]="l.href">{{ l.label }}</a>
|
||||
}
|
||||
}
|
||||
</nav>
|
||||
`,
|
||||
})
|
||||
export class LanguageSwitcherComponent {
|
||||
/** Override the detected locale (stories/tests); the app detects it from the base href. */
|
||||
activeLocale = input<Locale | undefined>(undefined);
|
||||
|
||||
private readonly detected: Locale = /\/en\//.test(document.baseURI) ? 'en' : 'nl';
|
||||
private readonly loc =
|
||||
typeof location !== 'undefined'
|
||||
? location
|
||||
: ({ pathname: '/', search: '', hash: '' } as Location);
|
||||
|
||||
private router = inject(Router, { optional: true });
|
||||
private nav = toSignal(
|
||||
this.router?.events.pipe(filter((e) => e instanceof NavigationEnd)) ?? EMPTY,
|
||||
{ initialValue: null },
|
||||
);
|
||||
|
||||
protected links = computed(() => {
|
||||
this.nav(); // recompute on every completed navigation — loc.pathname is read fresh below
|
||||
return localeLinks(
|
||||
this.loc.pathname,
|
||||
this.activeLocale() ?? this.detected,
|
||||
this.loc.search,
|
||||
this.loc.hash,
|
||||
);
|
||||
});
|
||||
|
||||
protected navLabel = $localize`:@@lang.navLabel:Taal / Language`;
|
||||
protected heading = $localize`:@@lang.heading:Kies een taal`;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { LanguageSwitcherComponent } from './language-switcher.component';
|
||||
|
||||
const meta: Meta<LanguageSwitcherComponent> = {
|
||||
title: 'Design System/Organisms/Language Switcher',
|
||||
component: LanguageSwitcherComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LanguageSwitcherComponent>;
|
||||
|
||||
/** Dutch active (the source locale). */
|
||||
export const NederlandsActive: Story = { args: { activeLocale: 'nl' } };
|
||||
|
||||
/** English active. */
|
||||
export const EnglishActive: Story = { args: { activeLocale: 'en' } };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { localeLinks } from './locale-links';
|
||||
|
||||
describe('localeLinks (nl at root, en under /en/)', () => {
|
||||
it('an nl route (no prefix) links nl to the bare path, en under /en, marks active', () => {
|
||||
const links = localeLinks('/dashboard', 'nl');
|
||||
expect(links.map((l) => [l.locale, l.href, l.active])).toEqual([
|
||||
['nl', '/dashboard', true],
|
||||
['en', '/en/dashboard', false],
|
||||
]);
|
||||
});
|
||||
|
||||
it('an en route strips the /en prefix for the nl target (deep path, en active)', () => {
|
||||
const links = localeLinks('/en/beheer/audit', 'en');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/beheer/audit');
|
||||
expect(links.find((l) => l.locale === 'en')!.active).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps query + hash on both targets', () => {
|
||||
const links = localeLinks('/registreren', 'nl', '?scenario=slow', '#top');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/registreren?scenario=slow#top');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/registreren?scenario=slow#top');
|
||||
});
|
||||
|
||||
it('the root maps nl → / and en → /en/', () => {
|
||||
const links = localeLinks('/', 'nl');
|
||||
expect(links.find((l) => l.locale === 'nl')!.href).toBe('/');
|
||||
expect(links.find((l) => l.locale === 'en')!.href).toBe('/en/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/** The app's two locales (Angular $localize: source `nl` + translation `en`). */
|
||||
export type Locale = 'nl' | 'en';
|
||||
|
||||
export interface LocaleLink {
|
||||
readonly locale: Locale;
|
||||
/** Endonym — each language named in its own language (CIBG "Taal instellen"), not a code. */
|
||||
readonly label: string;
|
||||
/** Absolute path into the other locale's bundle, preserving the current route. */
|
||||
readonly href: string;
|
||||
readonly active: boolean;
|
||||
}
|
||||
|
||||
const LOCALES: readonly { locale: Locale; label: string }[] = [
|
||||
{ locale: 'nl', label: 'Nederlands' },
|
||||
{ locale: 'en', label: 'English' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Build the two language links for the switcher. Compile-time i18n serves the source locale
|
||||
* (nl) at the ROOT (`subPath: ''`) and en under `/en/`, so switching is a full navigation to the
|
||||
* sibling bundle at the same route. Strips a leading `/en` from the current path, then targets nl
|
||||
* at the bare path and en under `/en`. Keeps query + hash. Pure — no DOM (the component passes
|
||||
* `location.*` in).
|
||||
*/
|
||||
export function localeLinks(
|
||||
pathname: string,
|
||||
active: Locale,
|
||||
search = '',
|
||||
hash = '',
|
||||
): LocaleLink[] {
|
||||
const rest = pathname.replace(/^\/en(?=\/|$)/, '') || '/';
|
||||
const href = (locale: Locale) => `${locale === 'en' ? `/en${rest}` : rest}${search}${hash}`;
|
||||
return LOCALES.map(({ locale, label }) => ({
|
||||
locale,
|
||||
label,
|
||||
href: href(locale),
|
||||
active: locale === active,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { LinkComponent } from '@shared/ui/link/link.component';
|
||||
|
||||
/** Template: standard page body — optional back-link, a heading, optional intro,
|
||||
and projected content. The breadcrumb lives in the site header (blue bar), so
|
||||
it's not repeated here. Rendered inside the persistent ShellComponent via the
|
||||
router outlet, so it owns only the content (not chrome). */
|
||||
@Component({
|
||||
selector: 'app-page-shell',
|
||||
imports: [HeadingComponent, LinkComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.body--narrow {
|
||||
max-inline-size: var(--app-form-narrow);
|
||||
}
|
||||
.back {
|
||||
margin: 0 0 var(--rhc-space-max-lg);
|
||||
}
|
||||
.intro {
|
||||
margin-block: var(--rhc-space-max-md) var(--rhc-space-max-2xl);
|
||||
max-inline-size: 42rem;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div [class.body--narrow]="width() === 'narrow'">
|
||||
@if (backLink()) {
|
||||
<p class="back">
|
||||
<app-link [to]="backLink()!">← {{ backLabel() }}</app-link>
|
||||
</p>
|
||||
}
|
||||
<app-heading [level]="1">{{ heading() }}</app-heading>
|
||||
@if (intro()) {
|
||||
<p class="intro">{{ intro() }}</p>
|
||||
}
|
||||
<ng-content />
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class PageShellComponent {
|
||||
heading = input.required<string>();
|
||||
intro = input<string>();
|
||||
backLink = input<string>();
|
||||
backLabel = input($localize`:@@pageShell.backLabel:Terug naar overzicht`);
|
||||
width = input<'default' | 'narrow'>('default');
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { PageShellComponent } from './page-shell.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
const meta: Meta<PageShellComponent> = {
|
||||
title: 'Design System/Templates/PageShell',
|
||||
component: PageShellComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ButtonComponent] }),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-page-shell [heading]="heading" [intro]="intro" [backLink]="backLink" [width]="width">
|
||||
<p class="rhc-paragraph">Pagina-inhoud wordt hier geprojecteerd.</p>
|
||||
<app-button variant="primary">Een actie</app-button>
|
||||
</app-page-shell>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PageShellComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { heading: 'Mijn BIG-registratie', intro: 'Overzicht van uw registratie.' },
|
||||
};
|
||||
export const WithBackLink: Story = {
|
||||
args: { heading: 'Mijn gegevens', backLink: '/dashboard' },
|
||||
};
|
||||
export const Narrow: Story = {
|
||||
args: { heading: 'Inloggen', width: 'narrow', intro: 'Log in op uw omgeving.' },
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
DOCUMENT,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
EnvironmentInjector,
|
||||
afterNextRender,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import { NavigationEnd, Router } from '@angular/router';
|
||||
|
||||
/** Template-layer wiring (not a component): on every route change after the
|
||||
initial load, moves focus to the new page's `<h1>` (page-shell always
|
||||
renders one) so screen-reader/keyboard users land on the new content
|
||||
instead of wherever focus happened to be. Falls back to `#main` (the
|
||||
shell's landmark) if a page has no heading. Deferred via `afterNextRender`
|
||||
so it doesn't race Angular's view-transition DOM swap. */
|
||||
export function provideRouteFocus() {
|
||||
return {
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
const router = inject(Router);
|
||||
const document = inject(DOCUMENT);
|
||||
const injector = inject(EnvironmentInjector);
|
||||
let isInitialLoad = true;
|
||||
|
||||
router.events.subscribe((event) => {
|
||||
if (!(event instanceof NavigationEnd)) return;
|
||||
if (isInitialLoad) {
|
||||
isInitialLoad = false;
|
||||
return;
|
||||
}
|
||||
afterNextRender(
|
||||
() => {
|
||||
const target =
|
||||
document.querySelector<HTMLElement>('#main h1') ?? document.getElementById('main');
|
||||
if (!target) return;
|
||||
target.setAttribute('tabindex', '-1');
|
||||
target.focus({ preventScroll: true });
|
||||
},
|
||||
{ injector },
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Component, InjectionToken, Type, inject, isDevMode } from '@angular/core';
|
||||
import { NgComponentOutlet } from '@angular/common';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { SiteHeaderComponent } from '@shared/layout/site-header/site-header.component';
|
||||
import { SiteFooterComponent } from '@shared/layout/site-footer/site-footer.component';
|
||||
import { LanguageSwitcherComponent } from '@shared/layout/language-switcher/language-switcher.component';
|
||||
|
||||
/** Each app may register its own dev-only "show the Model" panel component here (it's
|
||||
inherently app-specific — it inspects that app's own root stores). No provider →
|
||||
no panel, which is exactly today's behaviour for an app that never had one. */
|
||||
export const DEBUG_PANEL = new InjectionToken<Type<unknown> | null>('DEBUG_PANEL', {
|
||||
factory: () => null,
|
||||
});
|
||||
|
||||
/** Template: persistent app chrome. Header + footer mount once; only the routed
|
||||
content inside <router-outlet> changes (and cross-fades — see styles.scss). */
|
||||
@Component({
|
||||
selector: 'app-shell',
|
||||
imports: [
|
||||
RouterOutlet,
|
||||
SiteHeaderComponent,
|
||||
SiteFooterComponent,
|
||||
LanguageSwitcherComponent,
|
||||
NgComponentOutlet,
|
||||
],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: var(--app-skip-link-offset);
|
||||
z-index: 1030;
|
||||
}
|
||||
.skip:focus {
|
||||
left: var(--rhc-space-max-md);
|
||||
top: var(--rhc-space-max-md);
|
||||
background: var(--rhc-color-wit);
|
||||
padding: var(--rhc-space-max-sm) var(--rhc-space-max-md);
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
}
|
||||
.layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-block-size: 100vh;
|
||||
}
|
||||
.main {
|
||||
flex: 1;
|
||||
inline-size: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.content {
|
||||
max-inline-size: var(--app-content-max);
|
||||
margin-inline: auto;
|
||||
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<a href="#main" class="skip" i18n="@@shell.skipLink">Naar de inhoud</a>
|
||||
<app-language-switcher />
|
||||
<div class="layout">
|
||||
<app-site-header />
|
||||
<main id="main" class="main">
|
||||
<div class="content">
|
||||
<router-outlet />
|
||||
</div>
|
||||
</main>
|
||||
<app-site-footer />
|
||||
</div>
|
||||
@if (isDev && debugPanel) {
|
||||
<ng-container *ngComponentOutlet="debugPanel" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class ShellComponent {
|
||||
protected readonly isDev = isDevMode();
|
||||
protected readonly debugPanel = inject(DEBUG_PANEL);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { ShellComponent } from './shell.component';
|
||||
|
||||
const meta: Meta<ShellComponent> = {
|
||||
title: 'Design System/Templates/Shell',
|
||||
component: ShellComponent,
|
||||
// The persistent header injects AccessStore (for its capability-gated admin links) and
|
||||
// FeatureFlagStore (WP-47, for the Inschrijven nav gate); stub both so the story needs no
|
||||
// HTTP/ApiClient. `can` false → no admin links; `enabled` true → Inschrijven stays visible.
|
||||
decorators: [
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AccessStore, useValue: { can: () => false } },
|
||||
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||
],
|
||||
}),
|
||||
],
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ShellComponent>;
|
||||
|
||||
// No route matches, so <router-outlet> renders nothing — this story is about the
|
||||
// persistent chrome (skip-link, header, footer), not routed page content.
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
/** Organism: Rijksoverheid-style site footer — dark-blue, with the
|
||||
"De Rijksoverheid. Voor Nederland." tagline, responsible-ministry attribution,
|
||||
and a small "Over deze site" link column. ponytail: links point at the real
|
||||
rijksoverheid.nl pages, not a fabricated dead-link forest. */
|
||||
@Component({
|
||||
selector: 'app-site-footer',
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.bar {
|
||||
background: var(--rhc-color-layout);
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
margin-block-start: var(--rhc-space-max-5xl);
|
||||
inline-size: 100%;
|
||||
}
|
||||
.inner {
|
||||
max-inline-size: var(--app-content-max);
|
||||
margin-inline: auto;
|
||||
padding: var(--rhc-space-max-3xl) var(--rhc-space-max-2xl);
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
gap: var(--rhc-space-max-3xl);
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.tagline {
|
||||
font-style: italic;
|
||||
font-weight: var(--rhc-text-font-weight-semi-bold);
|
||||
font-size: var(--rhc-text-font-size-lg);
|
||||
max-inline-size: 18rem;
|
||||
}
|
||||
.ministry {
|
||||
margin-block-start: var(--rhc-space-max-md);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
/* CIBG's vendored h2 tag rule (dark navy, for light backgrounds) beats inherited
|
||||
color regardless of specificity — restate on-primary explicitly for this dark bar. */
|
||||
.col h2 {
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
font-weight: var(--rhc-text-font-weight-bold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
.links {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
}
|
||||
.links a {
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
.links a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
/* CIBG's vendored .meta class (unrelated component, coincidental name) sets a
|
||||
dark grey — override rather than rename to keep the CIBG-mirroring class name. */
|
||||
.meta {
|
||||
inline-size: 100%;
|
||||
border-block-start: var(--rhc-border-width-sm) solid
|
||||
color-mix(in srgb, var(--rhc-color-foreground-on-primary) 25%, transparent);
|
||||
margin-block-start: var(--rhc-space-max-xl);
|
||||
padding-block-start: var(--rhc-space-max-lg);
|
||||
font-size: var(--rhc-text-font-size-sm);
|
||||
opacity: 0.85;
|
||||
color: var(--rhc-color-foreground-on-primary);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<footer class="bar">
|
||||
<div class="inner">
|
||||
<div>
|
||||
<div class="tagline" i18n="@@footer.tagline">De Rijksoverheid. Voor Nederland.</div>
|
||||
<div class="ministry" i18n="@@footer.ministry">
|
||||
CIBG — Ministerie van Volksgezondheid, Welzijn en Sport
|
||||
</div>
|
||||
</div>
|
||||
<nav class="col" i18n-aria-label="@@footer.overSiteAria" aria-label="Over deze site">
|
||||
<h2 i18n="@@footer.overSite">Over deze site</h2>
|
||||
<ul class="links">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/privacy"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.privacy"
|
||||
>Privacy</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/cookies"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.cookies"
|
||||
>Cookies</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.rijksoverheid.nl/toegankelijkheid"
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
i18n="@@footer.toegankelijkheid"
|
||||
>Toegankelijkheid</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="meta" i18n="@@footer.demo">Demo / POC — geen echte gegevens.</div>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
})
|
||||
export class SiteFooterComponent {}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { SiteFooterComponent } from './site-footer.component';
|
||||
|
||||
const meta: Meta<SiteFooterComponent> = {
|
||||
title: 'Design System/Organisms/Site Footer',
|
||||
component: SiteFooterComponent,
|
||||
render: () => ({ template: `<app-site-footer />` }),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SiteFooterComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { InjectionToken } from '@angular/core';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
|
||||
export interface HeaderNavItem {
|
||||
readonly label: string;
|
||||
readonly to: string;
|
||||
/** Hidden when this feature flag is off (e.g. WP-47's Inschrijven gate). Omit for an
|
||||
always-visible item. */
|
||||
readonly flag?: string;
|
||||
}
|
||||
|
||||
/** One admin page: its label, a short description, its route, and the capability that
|
||||
gates it. Consumed by the site header's admin nav AND (per app) a dashboard's own
|
||||
Beheer section, both filtered by `AccessStore.can`. Capability-gated, never
|
||||
role-derived (PRD-0002 §6): the FE only mirrors server-resolved capabilities. */
|
||||
export interface AdminLink {
|
||||
readonly label: string;
|
||||
readonly description: string;
|
||||
readonly to: string;
|
||||
readonly cap: Capability;
|
||||
}
|
||||
|
||||
/** Each app supplies its own primary nav — the set of top-level routes differs per app
|
||||
(e.g. the SSP's "Herregistratie"/"Inschrijven" vs. behandelportal's own). */
|
||||
export const HEADER_NAV_ITEMS = new InjectionToken<readonly HeaderNavItem[]>('HEADER_NAV_ITEMS', {
|
||||
factory: () => [],
|
||||
});
|
||||
|
||||
/** Each app supplies its own admin links — which admin pages exist differs per app
|
||||
(e.g. only the SSP has a brief/huisstijl editor). */
|
||||
export const HEADER_ADMIN_LINKS = new InjectionToken<readonly AdminLink[]>('HEADER_ADMIN_LINKS', {
|
||||
factory: () => [],
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { NavigationEnd, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import { SESSION_PORT } from '@shared/application/session.port';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { BreadcrumbComponent } from '@shared/layout/breadcrumb/breadcrumb.component';
|
||||
import { trailFor } from '@shared/layout/breadcrumb/breadcrumb-trail';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
|
||||
|
||||
/** Organism: CIBG Huisstijl site header — logo block, robijn titlebar (breadcrumb +
|
||||
user menu), horizontal nav. ponytail: text wordmark, not the licensed Rijksoverheid
|
||||
beeldmerk; no search box (no search feature yet). */
|
||||
@Component({
|
||||
selector: 'app-site-header',
|
||||
imports: [RouterLink, RouterLinkActive, BreadcrumbComponent],
|
||||
styles: [
|
||||
`
|
||||
.logout {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
/* CIBG's header nav has no bg by default in this build — the grey bar is ours.
|
||||
(.titlebar keeps its own robijn fill — --ro-layout — untouched; the breadcrumb
|
||||
inside it has no background of its own, so the bar's colour shows through.) */
|
||||
nav {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<header>
|
||||
<div class="logo">
|
||||
<div class="logo__wrapper">
|
||||
<a routerLink="/dashboard" class="logo__link">
|
||||
<figure class="logo__figure">
|
||||
<figcaption class="logo__text">
|
||||
<span class="logo__sender" i18n="@@header.sender">BIG-register</span>
|
||||
<span class="logo__ministry" i18n="@@header.ministry"
|
||||
>Ministerie van Volksgezondheid, Welzijn en Sport</span
|
||||
>
|
||||
</figcaption>
|
||||
</figure>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="titlebar">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="title col-md-7">
|
||||
@if (trail().length) {
|
||||
<app-breadcrumb [items]="trail()" />
|
||||
}
|
||||
</div>
|
||||
<div class="user-menu col-md-5">
|
||||
@if (session(); as s) {
|
||||
<div>
|
||||
<span class="login-name">{{ s.naam }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="logout" (click)="logout()" i18n="@@header.uitloggen">
|
||||
Uitloggen
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav i18n-aria-label="@@header.navAria" aria-label="Hoofdnavigatie">
|
||||
<div class="container">
|
||||
<ul>
|
||||
@for (item of navItems(); track item.to) {
|
||||
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
|
||||
<a [routerLink]="item.to">{{ item.label }}</a>
|
||||
</li>
|
||||
}
|
||||
@for (item of adminItems(); track item.to) {
|
||||
<li routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">
|
||||
<a [routerLink]="item.to">{{ item.label }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
`,
|
||||
})
|
||||
export class SiteHeaderComponent {
|
||||
private access = inject(AccessStore);
|
||||
private flags = inject(FeatureFlagStore);
|
||||
private rawNavItems = inject(HEADER_NAV_ITEMS);
|
||||
private rawAdminLinks = inject(HEADER_ADMIN_LINKS);
|
||||
|
||||
/** Hides an item whose `flag` is off (e.g. the SSP's Inschrijven gate, WP-47) — which
|
||||
items exist, and which carry a flag, is entirely up to the app that provided them. */
|
||||
protected readonly navItems = computed(() =>
|
||||
this.rawNavItems.filter((i) => !i.flag || this.flags.enabled(i.flag)),
|
||||
);
|
||||
|
||||
private router = inject(Router);
|
||||
private sessionPort = inject(SESSION_PORT, { optional: true });
|
||||
/** Injecting AccessStore here also warms `/me` at app start (the header renders on
|
||||
every page), so the admin routes' guard usually finds caps already resolved. */
|
||||
protected adminItems = computed(() => this.rawAdminLinks.filter((i) => this.access.can(i.cap)));
|
||||
|
||||
readonly session = computed(() => this.sessionPort?.session() ?? null);
|
||||
private url = toSignal(
|
||||
this.router.events.pipe(
|
||||
filter((e) => e instanceof NavigationEnd),
|
||||
map(() => this.router.url),
|
||||
),
|
||||
{ initialValue: this.router.url },
|
||||
);
|
||||
protected trail = computed(() => trailFor(this.url()));
|
||||
|
||||
logout() {
|
||||
this.sessionPort?.logout();
|
||||
this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AccessStore } from '@shared/application/access.store';
|
||||
import { FeatureFlagStore } from '@shared/application/feature-flags.store';
|
||||
import { Capability } from '@shared/domain/capability';
|
||||
import { HEADER_ADMIN_LINKS, HEADER_NAV_ITEMS } from './nav-config';
|
||||
import { SiteHeaderComponent } from './site-header.component';
|
||||
|
||||
// The header injects AccessStore for the capability-gated admin links and FeatureFlagStore
|
||||
// (WP-47, for the Inschrijven nav gate); stub both so the story needs no HTTP/ApiClient.
|
||||
// `can` decides which admin links appear; `enabled` true keeps Inschrijven visible. Nav/admin
|
||||
// links are app-provided (HEADER_NAV_ITEMS/HEADER_ADMIN_LINKS) — this story supplies a
|
||||
// representative sample rather than importing a real app's config, keeping the story
|
||||
// decoupled from any one app.
|
||||
const withCaps = (caps: Capability[]) =>
|
||||
applicationConfig({
|
||||
providers: [
|
||||
provideRouter([]),
|
||||
{ provide: AccessStore, useValue: { can: (c: Capability) => caps.includes(c) } },
|
||||
{ provide: FeatureFlagStore, useValue: { enabled: () => true } },
|
||||
{
|
||||
provide: HEADER_NAV_ITEMS,
|
||||
useValue: [
|
||||
{ label: 'Overzicht', to: '/dashboard' },
|
||||
{ label: 'Mijn gegevens', to: '/registratie' },
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: HEADER_ADMIN_LINKS,
|
||||
useValue: [
|
||||
{ label: 'Huisstijl', description: '', to: '/brief/huisstijl', cap: 'orgtemplate:edit' },
|
||||
{ label: 'Stamdata', description: '', to: '/beheer/stamdata', cap: 'stamdata:edit' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const meta: Meta<SiteHeaderComponent> = {
|
||||
title: 'Design System/Organisms/Site Header',
|
||||
component: SiteHeaderComponent,
|
||||
decorators: [withCaps([])],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-site-header />`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<SiteHeaderComponent>;
|
||||
|
||||
/** Standard user — no admin links. */
|
||||
export const Default: Story = {};
|
||||
|
||||
/** Admin — the capability-gated Huisstijl + Stamdata links appear. */
|
||||
export const AsAdmin: Story = {
|
||||
decorators: [withCaps(['orgtemplate:edit', 'stamdata:edit'])],
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||
|
||||
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
|
||||
Shared so every wizard's `primaryLabel` reads the same way. */
|
||||
export const naarStapLabel = (stepNumber: number, stepLabel: string) =>
|
||||
$localize`:@@wizard.naarStap:Naar stap ${stepNumber}:nummer: - ${stepLabel}:label:`;
|
||||
|
||||
/** A flat validation error pointing at a field: `id` matches the field's anchor. */
|
||||
export interface WizardError {
|
||||
readonly id: string;
|
||||
readonly message: string;
|
||||
}
|
||||
|
||||
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
|
||||
/**
|
||||
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
||||
* It owns the consistent outline — CIBG stappenindicator (title merged in) + error
|
||||
* summary + the horizontal <form> + the CIBG procesnavigatie button row + the
|
||||
* submitting/submitted/failed states — and the a11y focus management.
|
||||
*
|
||||
* Presentational and unidirectional: all state stays in the wizard container
|
||||
* (the Elm-style store). Inputs flow down; the container reacts to the outputs
|
||||
* and dispatches messages. The step's own fields are projected as the default
|
||||
* slot; the success screen is projected via [wizardSuccess].
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-wizard-shell',
|
||||
imports: [FormsModule, ButtonComponent, AlertComponent, SpinnerComponent, StepperComponent],
|
||||
// CIBG-GAP EXTENSION: Foutmelding — the vendored build has no error-summary/
|
||||
// Veldvalidatie list pattern (verified absent from huisstijl.min.css); the
|
||||
// .es-title/.es-list rules below are the hand-rolled surface, see cibg-gaps.mdx.
|
||||
// They render inside a vendored `.feedback-error` alert (app-alert).
|
||||
styles: [
|
||||
`
|
||||
.es-title {
|
||||
margin: 0 0 var(--rhc-space-max-sm);
|
||||
}
|
||||
.es-list {
|
||||
margin: 0;
|
||||
padding-inline-start: var(--rhc-space-max-xl);
|
||||
}
|
||||
/* Default link color doesn't meet contrast on the error-alert's light-red surface. */
|
||||
.es-list a {
|
||||
color: var(--rhc-color-lintblauw-700);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@switch (status()) {
|
||||
@case ('editing') {
|
||||
<app-stepper
|
||||
class="app-section"
|
||||
[steps]="steps()"
|
||||
[current]="current()"
|
||||
[processName]="processName()"
|
||||
[stepTitle]="stepTitle()"
|
||||
(stepSelected)="goToStep.emit($event)"
|
||||
/>
|
||||
@if (errors().length) {
|
||||
<div
|
||||
#errorSummary
|
||||
tabindex="-1"
|
||||
role="alert"
|
||||
aria-labelledby="wizard-error-title"
|
||||
class="app-section"
|
||||
>
|
||||
<app-alert type="error">
|
||||
<h3 id="wizard-error-title" class="es-title" i18n="@@wizard.errorTitle">
|
||||
Er ging iets mis met uw invoer
|
||||
</h3>
|
||||
<ul class="es-list">
|
||||
@for (e of errors(); track e.id) {
|
||||
<li>
|
||||
<a [href]="'#' + e.id" (click)="goToField($event, e.id)">{{ e.message }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</app-alert>
|
||||
</div>
|
||||
}
|
||||
<form (ngSubmit)="primary.emit()" class="form-horizontal app-section">
|
||||
<div class="form-header">
|
||||
<div class="form-action">
|
||||
<span class="meta" i18n="@@form.verplichteVelden">* verplichte velden</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Wizard pages wrap their field groups in <fieldset>s; CIBG's
|
||||
".form-horizontal fieldset" gives each a grey #f1f5f9 surface with a 1.25em token-ok: hex named in prose, not a style value
|
||||
gap. The shell stays group-agnostic and does NOT add its own fieldset (an
|
||||
outer grey fieldset would hide the white gaps between the page groups). -->
|
||||
<ng-content />
|
||||
<hr />
|
||||
<div class="d-flex flex-column flex-sm-row-reverse">
|
||||
<div class="m-0">
|
||||
<app-button type="submit" variant="primary">{{ primaryLabel() }}</app-button>
|
||||
</div>
|
||||
@if (canGoBack()) {
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
class="me-auto"
|
||||
(click)="back.emit()"
|
||||
i18n="@@wizard.terugVorige"
|
||||
>Terug naar vorige stap</app-button
|
||||
>
|
||||
}
|
||||
</div>
|
||||
<div class="app-section">
|
||||
<app-button
|
||||
type="button"
|
||||
variant="subtle"
|
||||
(click)="cancel.emit()"
|
||||
i18n="@@wizard.annuleren"
|
||||
>Annuleren</app-button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('submitting') {
|
||||
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
||||
}
|
||||
@case ('submitted') {
|
||||
<ng-content select="[wizardSuccess]" />
|
||||
}
|
||||
@case ('failed') {
|
||||
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
||||
>Opnieuw proberen</app-button
|
||||
>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class WizardShellComponent {
|
||||
steps = input.required<string[]>();
|
||||
current = input.required<number>();
|
||||
stepTitle = input.required<string>();
|
||||
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
||||
processName = input('');
|
||||
status = input.required<WizardStatus>();
|
||||
primaryLabel = input.required<string>();
|
||||
canGoBack = input(false);
|
||||
errors = input<readonly WizardError[]>([]);
|
||||
errorMessage = input('');
|
||||
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
||||
|
||||
primary = output<void>();
|
||||
back = output<void>();
|
||||
cancel = output<void>();
|
||||
retry = output<void>();
|
||||
/** A visited step number was clicked in the stepper — back-navigation only. */
|
||||
goToStep = output<number>();
|
||||
|
||||
/** Error-summary link: focus the field instead of letting the browser navigate.
|
||||
A fragment href resolves against <base href="/">, not the current route, so
|
||||
a real navigation would reload to "/" and bounce to login. */
|
||||
protected goToField(ev: Event, id: string) {
|
||||
ev.preventDefault();
|
||||
document.getElementById(id)?.focus(); // focus() scrolls the input into view
|
||||
}
|
||||
|
||||
private stepper = viewChild(StepperComponent);
|
||||
private errorSummary = viewChild<ElementRef<HTMLElement>>('errorSummary');
|
||||
|
||||
constructor() {
|
||||
// A11y: move focus to the step title when the step changes (skip first run
|
||||
// so we don't grab focus on initial load). Tracks current(), which is value-
|
||||
// stable across keystrokes, so typing never steals focus.
|
||||
let firstStep = true;
|
||||
effect(() => {
|
||||
this.current();
|
||||
if (firstStep) {
|
||||
firstStep = false;
|
||||
return;
|
||||
}
|
||||
untracked(() => queueMicrotask(() => this.stepper()?.focusTitle()));
|
||||
});
|
||||
// A11y: when validation errors first appear (after a failed submit), move
|
||||
// focus to the error summary so it's announced. Only on the rising edge
|
||||
// (none → some): typing rebuilds the errors array each keystroke, and
|
||||
// re-focusing then would scroll the page up mid-edit. The summary keeps
|
||||
// role="alert", so content changes are still announced without the jump.
|
||||
let firstErr = true;
|
||||
let hadErrors = false;
|
||||
effect(() => {
|
||||
const has = this.errors().length > 0;
|
||||
if (firstErr) {
|
||||
firstErr = false;
|
||||
hadErrors = has;
|
||||
return;
|
||||
}
|
||||
if (has && !hadErrors)
|
||||
untracked(() => queueMicrotask(() => this.errorSummary()?.nativeElement.focus()));
|
||||
hadErrors = has;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { WizardShellComponent } from './wizard-shell.component';
|
||||
|
||||
const meta: Meta<WizardShellComponent> = {
|
||||
title: 'Design System/Templates/WizardShell',
|
||||
component: WizardShellComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
|
||||
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
|
||||
(goToStep)="goToStep($event)">
|
||||
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
|
||||
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
|
||||
</app-wizard-shell>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: {
|
||||
description: {
|
||||
component: 'CIBG-gap extension (error summary only) — see Foundations/CIBG Gap Register.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<WizardShellComponent>;
|
||||
|
||||
const steps = ['Adres', 'Beroep', 'Controle'];
|
||||
const base = {
|
||||
steps,
|
||||
current: 1,
|
||||
stepTitle: 'Beroep op basis van uw diploma',
|
||||
processName: 'Inschrijven in het BIG-register',
|
||||
primaryLabel: 'Volgende',
|
||||
canGoBack: true,
|
||||
errors: [],
|
||||
errorMessage: '',
|
||||
goToStep: () => {},
|
||||
};
|
||||
|
||||
export const Editing: Story = { args: { ...base, status: 'editing' } };
|
||||
export const EditingMetFouten: Story = {
|
||||
args: {
|
||||
...base,
|
||||
status: 'editing',
|
||||
errors: [
|
||||
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
|
||||
{ id: 'diploma', message: 'Kies een diploma.' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
|
||||
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
|
||||
export const Failed: Story = {
|
||||
args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' },
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
// ponytail: Angular's unit-test builder always resolves a `build` target for compiler
|
||||
// options, even for a project that's only ever tested, never served — there's no way to
|
||||
// opt out. This is that target's sole purpose; nothing imports it. Upgrade to a real
|
||||
// ng-packagr library target if this lib is ever actually built/published.
|
||||
export {};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type AlertType = 'info' | 'ok' | 'warning' | 'error';
|
||||
|
||||
// visually-hidden alternative for the status icon (CIBG a11y requirement).
|
||||
const ICON_LABELS: Record<AlertType, string> = {
|
||||
info: $localize`:@@alert.icon.info:Informatie`,
|
||||
ok: $localize`:@@alert.icon.ok:Gelukt`,
|
||||
warning: $localize`:@@alert.icon.warning:Waarschuwing`,
|
||||
error: $localize`:@@alert.icon.error:Foutmelding`,
|
||||
};
|
||||
|
||||
/** Atom: alert/message banner — the CIBG Huisstijl "melding"
|
||||
(designsystem.cibg.nl/componenten/meldingen). Thin wrapper over the vendored
|
||||
`.feedback feedback-*` classes: the design system owns surface + icon; we add
|
||||
only the icon's a11y label and a content wrapper (`.feedback` is a flex row).
|
||||
Errors are `role="alert"` (assertive — interrupts) since they need immediate
|
||||
attention; other variants stay `role="status"` (polite) so success/info banners
|
||||
don't interrupt what the user is doing. */
|
||||
@Component({
|
||||
selector: 'app-alert',
|
||||
styles: [
|
||||
`
|
||||
.feedback > div {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div
|
||||
class="feedback"
|
||||
[class.feedback-info]="type() === 'info'"
|
||||
[class.feedback-success]="type() === 'ok'"
|
||||
[class.feedback-warning]="type() === 'warning'"
|
||||
[class.feedback-error]="type() === 'error'"
|
||||
[attr.role]="type() === 'error' ? 'alert' : 'status'"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<span class="icon"
|
||||
><span class="visually-hidden">{{ iconLabels[type()] }}</span></span
|
||||
>
|
||||
<div><ng-content /></div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AlertComponent {
|
||||
type = input<AlertType>('info');
|
||||
protected readonly iconLabels = ICON_LABELS;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { AlertComponent } from './alert.component';
|
||||
|
||||
const meta: Meta<AlertComponent> = {
|
||||
title: 'Design System/Atoms/Alert',
|
||||
component: AlertComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-alert [type]="type">Uw wijziging is ontvangen.</app-alert>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<AlertComponent>;
|
||||
|
||||
// role assertions guard the polite/assertive split (WP-16): errors interrupt, others don't.
|
||||
export const Info: Story = {
|
||||
args: { type: 'info' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Ok: Story = {
|
||||
args: { type: 'ok' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { type: 'warning' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('status')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { type: 'error' },
|
||||
play: async ({ canvasElement }) => {
|
||||
await expect(within(canvasElement).getByRole('alert')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
// CIBG-GAP EXTENSION: Aanvragen (non-navigating row) — the vendored
|
||||
// `.dashboard-block.applications li a` chain only styles `<a>`; `.static-row`
|
||||
// mirrors it from tokens for the non-navigating case, see cibg-gaps.mdx.
|
||||
/** Molecule: one row in a CIBG Huisstijl "aanvragen" list
|
||||
(designsystem.cibg.nl/componenten/aanvragen) — a white card-link styled by the
|
||||
vendored `.dashboard-block.applications li a` chain (bg, chevron, link-blue `h3`),
|
||||
with an optional `.subtitle`/`.status`/`.cta`. Used on an `<li>` so the `<ul>`'s
|
||||
direct child is a native `<li>` (keeps the list axe-clean — a bare custom element
|
||||
between `<ul>` and its `<li>` trips axe's list rule regardless of `display:contents`).
|
||||
A non-navigating row renders a `<div>` (the vendored chain only styles `<a>`, so
|
||||
that surface is mirrored from tokens). A `[applicationActions]` slot projects a
|
||||
sibling action after the anchor — a button can't nest inside the anchor itself. */
|
||||
@Component({
|
||||
selector: 'li[app-application-link]',
|
||||
imports: [RouterLink, NgTemplateOutlet],
|
||||
styles: [
|
||||
`
|
||||
/* The vendored .applications li a surface only styles <a>; mirror it from tokens
|
||||
for a non-navigating (informational) row so the card looks consistent. */
|
||||
.static-row {
|
||||
display: flex;
|
||||
background: var(--rhc-color-wit);
|
||||
border-block-end: 0.065rem solid var(--rhc-color-border-subtle);
|
||||
padding: 0.75rem 2rem 0.75rem 1rem;
|
||||
}
|
||||
.content {
|
||||
flex: 1 1 auto;
|
||||
min-inline-size: 0;
|
||||
}
|
||||
.cta {
|
||||
margin-inline-start: auto;
|
||||
align-self: center;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@if (to()) {
|
||||
<a [routerLink]="to()"><ng-container [ngTemplateOutlet]="body" /></a>
|
||||
} @else if (clickable()) {
|
||||
<a href="#" (click)="onActivate($event)"><ng-container [ngTemplateOutlet]="body" /></a>
|
||||
} @else {
|
||||
<div class="static-row"><ng-container [ngTemplateOutlet]="body" /></div>
|
||||
}
|
||||
<ng-content select="[applicationActions]" />
|
||||
<ng-template #body>
|
||||
<div class="content">
|
||||
<!-- Raw <h3>, not <app-heading>: the vendored ".applications li a h3" chain styles
|
||||
the bare h3 (link-blue); an app-heading host wrapper would sit between and can
|
||||
break that selector. Documented in atomic-design.mdx (convergence decisions). -->
|
||||
<h3 class="h3">{{ heading() }}</h3>
|
||||
@if (subtitle()) {
|
||||
<div class="subtitle">{{ subtitle() }}</div>
|
||||
}
|
||||
@if (status()) {
|
||||
<div class="status">{{ status() }}</div>
|
||||
}
|
||||
</div>
|
||||
@if (cta()) {
|
||||
<div class="cta">{{ cta() }}</div>
|
||||
}
|
||||
</ng-template>
|
||||
`,
|
||||
})
|
||||
export class ApplicationLinkComponent {
|
||||
heading = input.required<string>();
|
||||
subtitle = input('');
|
||||
status = input('');
|
||||
cta = input('');
|
||||
/** Set for a plain routerLink navigation (e.g. the "Wat wilt u doen?" actions). */
|
||||
to = input('');
|
||||
/** Set when the row navigates imperatively (e.g. resume with query params) — the
|
||||
row still renders as a clickable `<a>`, but `activate` decides what happens. */
|
||||
clickable = input(false);
|
||||
activate = output<void>();
|
||||
|
||||
protected onActivate(ev: Event) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.activate.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ApplicationLinkComponent } from './application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationLinkComponent> = {
|
||||
title: 'Design System/Molecules/Application Link',
|
||||
component: ApplicationLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rows are <li>s in the "aanvragen" list — a real <ul> gives them their layout.
|
||||
template: `<div class="dashboard-block applications"><ul class="list-unstyled"><li app-application-link [heading]="heading" [subtitle]="subtitle" [status]="status" [cta]="cta" [to]="to" [clickable]="clickable"></li></ul></div>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'CIBG-gap extension (non-navigating row only, see NietInteractief) — see Foundations/CIBG Gap Register.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ApplicationLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: {
|
||||
heading: 'Inschrijven',
|
||||
subtitle: 'Schrijf u in in het BIG-register.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', status: 'Stap 2 van 3', cta: 'Verder gaan', clickable: true },
|
||||
};
|
||||
export const NietInteractief: Story = {
|
||||
args: { heading: 'Herregistratie', status: 'Referentie 2024-00123 · ingediend op 12 mei 2024' },
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
/** Molecule: wraps `<app-application-link>` rows in the CIBG Huisstijl "aanvragen"
|
||||
dashboard block (`.dashboard-block.applications`) — see
|
||||
designsystem.cibg.nl/componenten/aanvragen. Used for both the "Mijn aanvragen"
|
||||
list and the "Wat wilt u doen?" action list on the dashboard. */
|
||||
@Component({
|
||||
selector: 'app-application-list',
|
||||
template: `
|
||||
<div class="dashboard-block applications">
|
||||
<ul class="list-unstyled">
|
||||
<ng-content />
|
||||
</ul>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class ApplicationListComponent {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ApplicationListComponent } from './application-list.component';
|
||||
import { ApplicationLinkComponent } from '@shared/ui/application-link/application-link.component';
|
||||
|
||||
const meta: Meta<ApplicationListComponent> = {
|
||||
title: 'Design System/Molecules/Application List',
|
||||
component: ApplicationListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ApplicationLinkComponent] }),
|
||||
],
|
||||
render: () => ({
|
||||
template: `
|
||||
<app-application-list>
|
||||
<li app-application-link heading="Herregistratie" subtitle="Verlenging van uw BIG-registratie" status="In behandeling · Referentie 2024-00123 · ingediend op 12 mei 2024" to="/aanvraag/1"></li>
|
||||
<li app-application-link heading="Inschrijving" subtitle="Inschrijving in het BIG-register" status="Goedgekeurd · Referentie 2024-00088" to="/aanvraag/2"></li>
|
||||
<li app-application-link heading="Inschrijven" subtitle="Schrijf u in in het BIG-register." cta="Start inschrijving" to="/registreren"></li>
|
||||
</app-application-list>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ApplicationListComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
TemplateRef,
|
||||
computed,
|
||||
contentChild,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { RemoteData, fromResource, foldRemote } from '@shared/application/remote-data';
|
||||
|
||||
/* Slot markers. Put on <ng-template> children of <app-async>. Generic so the
|
||||
$implicit context is typed as the resource's T instead of unknown — see
|
||||
AsyncComponent's contentChild<AsyncLoadedDirective<T>> below, which threads the
|
||||
host's own T through the query result type. */
|
||||
@Directive({ selector: '[appAsyncLoaded]' })
|
||||
export class AsyncLoadedDirective<T = unknown> {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: T }>) {}
|
||||
|
||||
static ngTemplateContextGuard<T>(
|
||||
_dir: AsyncLoadedDirective<T>,
|
||||
_ctx: unknown,
|
||||
): _ctx is { $implicit: T } {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncLoading]' })
|
||||
export class AsyncLoadingDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncEmpty]' })
|
||||
export class AsyncEmptyDirective {
|
||||
constructor(public tpl: TemplateRef<unknown>) {}
|
||||
}
|
||||
@Directive({ selector: '[appAsyncError]' })
|
||||
export class AsyncErrorDirective {
|
||||
constructor(public tpl: TemplateRef<{ $implicit: Error | undefined; retry: () => void }>) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders exactly ONE of loading / empty / error / loaded for a signal-based
|
||||
* resource (e.g. httpResource). Built on a RemoteData tagged union (see
|
||||
* core/remote-data.ts), so the states are mutually exclusive by construction —
|
||||
* the UI can never show two at once ("impossible states"). Unprovided slots
|
||||
* fall back to sensible defaults.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-async',
|
||||
imports: [NgTemplateOutlet, SpinnerComponent, AlertComponent, ButtonComponent],
|
||||
template: `
|
||||
<div aria-live="polite" [attr.aria-busy]="rd().tag === 'Loading' ? 'true' : null">
|
||||
@switch (rd().tag) {
|
||||
@case ('Loading') {
|
||||
@if (loadingTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="loadingTpl()!.tpl" />
|
||||
} @else {
|
||||
<app-spinner />
|
||||
}
|
||||
}
|
||||
@case ('Failure') {
|
||||
@if (errorTpl()) {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="errorTpl()!.tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: error(), retry: retry }"
|
||||
/>
|
||||
} @else {
|
||||
<app-alert type="error">{{ errorText() }}</app-alert>
|
||||
<div style="margin-top:1rem">
|
||||
<app-button variant="secondary" (click)="retry()">{{ retryText() }}</app-button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@case ('Empty') {
|
||||
@if (emptyTpl()) {
|
||||
<ng-container [ngTemplateOutlet]="emptyTpl()!.tpl" />
|
||||
} @else {
|
||||
<p>{{ emptyText() }}</p>
|
||||
}
|
||||
}
|
||||
@case ('Success') {
|
||||
<ng-container
|
||||
[ngTemplateOutlet]="loadedTpl().tpl"
|
||||
[ngTemplateOutletContext]="{ $implicit: value() }"
|
||||
/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class AsyncComponent<T> {
|
||||
// Two ways to feed this component:
|
||||
// [resource] — a raw httpResource (the common case), or
|
||||
// [data] — an already-combined RemoteData (e.g. from a store via map2).
|
||||
resource = input<Resource<T>>();
|
||||
data = input<RemoteData<Error | undefined, T>>();
|
||||
isEmpty = input<(v: T) => boolean>(() => false);
|
||||
|
||||
// Shared/English component: copy lives behind language-agnostic inputs. Defaults are
|
||||
// localizable via $localize (source = default locale, currently nl); callers may override.
|
||||
errorText = input($localize`:@@async.error:Er ging iets mis bij het laden van de gegevens.`);
|
||||
retryText = input($localize`:@@async.retry:Opnieuw proberen`);
|
||||
emptyText = input($localize`:@@async.empty:Geen gegevens gevonden.`);
|
||||
|
||||
loadedTpl = contentChild.required<AsyncLoadedDirective<T>>(AsyncLoadedDirective);
|
||||
loadingTpl = contentChild(AsyncLoadingDirective);
|
||||
emptyTpl = contentChild(AsyncEmptyDirective);
|
||||
errorTpl = contentChild(AsyncErrorDirective);
|
||||
|
||||
// Single source of truth: the supplied RemoteData, or the resource projected into one.
|
||||
protected rd = computed<RemoteData<Error | undefined, T>>(() => {
|
||||
const data = this.data();
|
||||
if (data) return data;
|
||||
const r = this.resource();
|
||||
return r ? fromResource(r, this.isEmpty()) : { tag: 'Loading' };
|
||||
});
|
||||
|
||||
// value/error are pulled out via the exhaustive fold — only Success carries a
|
||||
// value, only Failure carries an error, so these can't lie.
|
||||
protected value = computed(() =>
|
||||
foldRemote(this.rd(), {
|
||||
loading: () => undefined,
|
||||
empty: () => undefined,
|
||||
failure: () => undefined,
|
||||
success: (v) => v,
|
||||
}),
|
||||
);
|
||||
protected error = computed(() =>
|
||||
foldRemote(this.rd(), {
|
||||
loading: () => undefined,
|
||||
empty: () => undefined,
|
||||
failure: (e) => e,
|
||||
success: () => undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
// [resource]-fed callers get reload() for free. [data]-fed callers (a store's
|
||||
// combined RemoteData — the component doesn't own that resource) must reload
|
||||
// it themselves; retryClicked is how they find out a retry was requested.
|
||||
retryClicked = output<void>();
|
||||
retry = () => {
|
||||
const r = this.resource();
|
||||
if (r && 'reload' in r && typeof (r as { reload?: unknown }).reload === 'function') {
|
||||
(r as { reload: () => void }).reload();
|
||||
}
|
||||
this.retryClicked.emit();
|
||||
};
|
||||
}
|
||||
|
||||
/** Convenience: import this array to get the wrapper + all slot directives. */
|
||||
export const ASYNC = [
|
||||
AsyncComponent,
|
||||
AsyncLoadedDirective,
|
||||
AsyncLoadingDirective,
|
||||
AsyncEmptyDirective,
|
||||
AsyncErrorDirective,
|
||||
] as const;
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import type { Resource } from '@angular/core';
|
||||
import { ASYNC } from './async.component';
|
||||
import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
|
||||
|
||||
/** Minimal fake of a signal Resource so the wrapper can be driven through every
|
||||
state in isolation (no HTTP). */
|
||||
function fakeResource<T>(status: string, value?: T, error?: Error): Resource<T> {
|
||||
return {
|
||||
value: () => value as T,
|
||||
status: () => status,
|
||||
error: () => error,
|
||||
hasValue: () => value !== undefined,
|
||||
reload: () => {},
|
||||
} as unknown as Resource<T>;
|
||||
}
|
||||
|
||||
const meta: Meta = {
|
||||
title: 'Design System/Molecules/Async States',
|
||||
decorators: [moduleMetadata({ imports: [...ASYNC, SkeletonComponent] })],
|
||||
render: (args) => ({
|
||||
// isEmpty is a function — Storybook strips function args, so set it here.
|
||||
props: { ...args, isEmpty: (v: string[]) => !v || v.length === 0 },
|
||||
template: `
|
||||
<app-async [resource]="resource" [isEmpty]="isEmpty">
|
||||
<ng-template appAsyncLoaded let-items>
|
||||
<ul class="rhc-unordered-list">
|
||||
@for (i of items; track i) { <li>{{ i }}</li> }
|
||||
</ul>
|
||||
</ng-template>
|
||||
<ng-template appAsyncLoading><app-skeleton [count]="3" height="1.5rem" [delay]="0" /></ng-template>
|
||||
<ng-template appAsyncEmpty><p class="rhc-paragraph">Geen items gevonden.</p></ng-template>
|
||||
</app-async>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj;
|
||||
|
||||
export const Loaded: Story = {
|
||||
args: { resource: fakeResource('resolved', ['Huisartsgeneeskunde', 'Spoedeisende hulp']) },
|
||||
};
|
||||
export const Loading: Story = { args: { resource: fakeResource('loading') } };
|
||||
export const Empty: Story = { args: { resource: fakeResource('resolved', [] as string[]) } };
|
||||
export const ErrorState: Story = {
|
||||
args: { resource: fakeResource('error', undefined, new Error('Demo')) },
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'subtle' | 'danger' | 'ghost';
|
||||
|
||||
/** Atom: button. Thin wrapper over the CIBG/Bootstrap button CSS. */
|
||||
@Component({
|
||||
selector: 'app-button',
|
||||
template: `
|
||||
<button
|
||||
[type]="type()"
|
||||
[disabled]="disabled()"
|
||||
class="btn"
|
||||
[class.btn-primary]="variant() === 'primary'"
|
||||
[class.btn-secondary]="variant() === 'secondary'"
|
||||
[class.btn-link]="variant() === 'subtle'"
|
||||
[class.btn-danger]="variant() === 'danger'"
|
||||
[class.btn-ghost]="variant() === 'ghost'"
|
||||
>
|
||||
<ng-content />
|
||||
</button>
|
||||
`,
|
||||
})
|
||||
export class ButtonComponent {
|
||||
variant = input<Variant>('primary');
|
||||
type = input<'button' | 'submit'>('button');
|
||||
disabled = input(false);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ButtonComponent } from './button.component';
|
||||
|
||||
const meta: Meta<ButtonComponent> = {
|
||||
title: 'Design System/Atoms/Button',
|
||||
component: ButtonComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-button [variant]="variant" [type]="type" [disabled]="disabled">Knop</app-button>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ButtonComponent>;
|
||||
|
||||
export const Primary: Story = { args: { variant: 'primary' } };
|
||||
export const Secondary: Story = { args: { variant: 'secondary' } };
|
||||
export const Subtle: Story = { args: { variant: 'subtle' } };
|
||||
export const Danger: Story = { args: { variant: 'danger' } };
|
||||
export const Ghost: Story = { args: { variant: 'ghost' } };
|
||||
export const Disabled: Story = { args: { variant: 'primary', disabled: true } };
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Component, computed, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
// Per-instance fallback ids, so the label's `for` always targets THIS checkbox. The
|
||||
// CIBG styled checkbox hides the native input and routes clicks through the label, so a
|
||||
// shared/undefined id silently makes every label toggle the first input — hence a default.
|
||||
let nextCheckboxId = 0;
|
||||
|
||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Thin
|
||||
wrapper over the CIBG Huisstijl `.form-check.styled` checkbox CSS; native
|
||||
input for full keyboard + screen-reader support. */
|
||||
@Component({
|
||||
selector: 'app-checkbox',
|
||||
template: `
|
||||
<div class="form-check styled">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="resolvedId()"
|
||||
[checked]="value"
|
||||
[disabled]="disabled"
|
||||
(change)="onToggle($event)"
|
||||
(blur)="onTouched()"
|
||||
/>
|
||||
<label class="form-check-label" [for]="resolvedId()">{{ label() }}</label>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true },
|
||||
],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
label = input('');
|
||||
|
||||
/** The caller's id, or a unique fallback — never undefined, so labels never collide. */
|
||||
private autoId = `app-checkbox-${nextCheckboxId++}`;
|
||||
protected resolvedId = computed(() => this.checkboxId() ?? this.autoId);
|
||||
|
||||
value = false;
|
||||
disabled = false;
|
||||
onChange: (v: boolean) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
onToggle(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).checked;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: boolean) {
|
||||
this.value = !!v;
|
||||
}
|
||||
registerOnChange(fn: (v: boolean) => void) {
|
||||
this.onChange = fn;
|
||||
}
|
||||
registerOnTouched(fn: () => void) {
|
||||
this.onTouched = fn;
|
||||
}
|
||||
setDisabledState(d: boolean) {
|
||||
this.disabled = d;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
|
||||
const meta: Meta<CheckboxComponent> = {
|
||||
title: 'Design System/Atoms/Checkbox',
|
||||
component: CheckboxComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<CheckboxComponent>;
|
||||
|
||||
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Component, input, output } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/** Molecule: one choice in a CIBG Huisstijl "keuzelijst" — `<li><div class="keuzelijst__link">`
|
||||
with a title and optional instructions (see choice-list.component.ts). Renders a
|
||||
plain (non-interactive) block when there's nothing to navigate to.
|
||||
|
||||
The title is a Bootstrap "stretched-link" (`.stretched-link`, vendored) rather than
|
||||
the whole box being an `<a>`: a `[choiceActions]` slot needs to project a sibling
|
||||
action (e.g. "Annuleren") *inside* the same card, and a `<button>` can't nest
|
||||
inside an `<a>` (invalid HTML, broken a11y). stretched-link keeps the entire card
|
||||
clickable via its `::after` overlay; the projected action sits above that overlay
|
||||
(see its own `position:relative;z-index:2` at the call site) so it stays clickable.
|
||||
|
||||
Unlike the "aanvragen" pattern's `.applications li a::after` (scoped to the `a`
|
||||
tag), CIBG's `.keuzelijst__link:after`/`:hover`/`:focus` rules key off the bare
|
||||
class — keuzelijst assumes every item IS a link — so a non-interactive row would
|
||||
otherwise inherit the chevron and hover accent too. The `--static` modifier below
|
||||
suppresses both for that case. `:focus-within` restores the focus accent that
|
||||
`:focus` would have given the (no longer directly focused) card. */
|
||||
@Component({
|
||||
selector: 'app-choice-link',
|
||||
imports: [RouterLink],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
.keuzelijst__link {
|
||||
position: relative;
|
||||
}
|
||||
.keuzelijst__link:focus-within {
|
||||
background-color: var(--rhc-color-cool-grey-100);
|
||||
box-shadow: inset 4px 0 0 0 var(--rhc-color-lintblauw-500);
|
||||
}
|
||||
.keuzelijst__link--static::after {
|
||||
content: none;
|
||||
}
|
||||
.keuzelijst__link--static:hover {
|
||||
background-color: var(--rhc-color-cool-grey-200);
|
||||
box-shadow: none;
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<li class="keuzelijst__list-item">
|
||||
<div class="keuzelijst__link" [class.keuzelijst__link--static]="!to() && !clickable()">
|
||||
<h3 class="keuzelijst__header">
|
||||
@if (to()) {
|
||||
<a class="stretched-link" [routerLink]="to()">{{ heading() }}</a>
|
||||
} @else if (clickable()) {
|
||||
<a class="stretched-link" href="#" (click)="onActivate($event)">{{ heading() }}</a>
|
||||
} @else {
|
||||
{{ heading() }}
|
||||
}
|
||||
</h3>
|
||||
@if (instructions()) {
|
||||
<p class="keuzelijst__instructions">{{ instructions() }}</p>
|
||||
}
|
||||
<ng-content select="[choiceActions]" />
|
||||
</div>
|
||||
</li>
|
||||
`,
|
||||
})
|
||||
export class ChoiceLinkComponent {
|
||||
heading = input.required<string>();
|
||||
instructions = input('');
|
||||
/** Set for a plain routerLink navigation. */
|
||||
to = input('');
|
||||
/** Set when the choice navigates imperatively (e.g. resume with query params) — the
|
||||
row still renders as a clickable card, but `activate` decides what happens. */
|
||||
clickable = input(false);
|
||||
activate = output<void>();
|
||||
|
||||
protected onActivate(ev: Event) {
|
||||
ev.preventDefault(); // fragment href resolves against <base href>, not the route
|
||||
this.activate.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ChoiceLinkComponent } from './choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceLinkComponent> = {
|
||||
title: 'Design System/Molecules/Choice Link',
|
||||
component: ChoiceLinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// Rows are <li>s — a real list gives them their normal layout in the story.
|
||||
template: `<ul class="keuzelijst__list"><app-choice-link [heading]="heading" [instructions]="instructions" [to]="to" [clickable]="clickable" /></ul>`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the <ul> and its <li> — axe's
|
||||
// list/listitem rule requires them adjacent regardless of `display:contents`.
|
||||
// WP-11 (CIBG markup fidelity) reworks this markup; see docs/project/backlog/WP-11-markup-fidelity.md.
|
||||
a11y: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ChoiceLinkComponent>;
|
||||
|
||||
export const Navigatie: Story = {
|
||||
args: {
|
||||
heading: 'Ik heb een Nederlands diploma',
|
||||
instructions: 'U kunt direct uw registratie aanvragen.',
|
||||
to: '/registreren',
|
||||
},
|
||||
};
|
||||
export const Actie: Story = {
|
||||
args: { heading: 'Inschrijving', instructions: 'Stap 2 van 3', clickable: true },
|
||||
};
|
||||
export const NietInteractief: Story = {
|
||||
args: {
|
||||
heading: 'Herregistratie',
|
||||
instructions: 'Referentie 2024-00123 · ingediend op 12 mei 2024',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
/** Molecule: the CIBG Huisstijl "keuzelijst" — a heading semantically linked
|
||||
(`aria-labelledby`) to a list of `<app-choice-link>` choices, used where a
|
||||
screen offers a set of options to pick between (see
|
||||
designsystem.cibg.nl/componenten/keuzelijst). Domain-free — the caller
|
||||
supplies the heading text and the choices. */
|
||||
@Component({
|
||||
selector: 'app-choice-list',
|
||||
template: `
|
||||
<h2 class="header header--medium" [id]="headingId">{{ heading() }}</h2>
|
||||
<ul class="keuzelijst__list" [attr.aria-labelledby]="headingId">
|
||||
<ng-content />
|
||||
</ul>
|
||||
`,
|
||||
})
|
||||
export class ChoiceListComponent {
|
||||
heading = input.required<string>();
|
||||
protected readonly headingId = `choice-list-${nextId++}`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig, moduleMetadata } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { ChoiceListComponent } from './choice-list.component';
|
||||
import { ChoiceLinkComponent } from '@shared/ui/choice-link/choice-link.component';
|
||||
|
||||
const meta: Meta<ChoiceListComponent> = {
|
||||
title: 'Design System/Molecules/Choice List',
|
||||
component: ChoiceListComponent,
|
||||
decorators: [
|
||||
applicationConfig({ providers: [provideRouter([])] }),
|
||||
moduleMetadata({ imports: [ChoiceLinkComponent] }),
|
||||
],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-choice-list [heading]="heading">
|
||||
<app-choice-link heading="Ik heb een Nederlands diploma" instructions="U kunt direct uw registratie aanvragen." to="/registreren" />
|
||||
<app-choice-link heading="Ik heb een buitenlands diploma" instructions="Uw diploma moet eerst officieel erkend worden." clickable="true" />
|
||||
</app-choice-list>`,
|
||||
}),
|
||||
parameters: {
|
||||
// Structural: app-choice-link's host sits between the <ul> and its <li> —
|
||||
// fixed by the WP-11 markup rework. See docs/project/backlog/WP-11-markup-fidelity.md.
|
||||
a11y: { disable: true },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ChoiceListComponent>;
|
||||
|
||||
export const Default: Story = { args: { heading: 'Maak een keuze' } };
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: the CIBG Huisstijl "bevestiging" (confirmation) — an animated green
|
||||
checkmark banner shown at the end of an aanvraagproces, ONLY when the user has
|
||||
nothing left to do (see designsystem.cibg.nl/componenten/bevestiging). Follow-up
|
||||
content (a reference number, a restart button) is projected below the banner. */
|
||||
@Component({
|
||||
selector: 'app-confirmation',
|
||||
template: `
|
||||
<div class="confirmation">
|
||||
<svg
|
||||
class="confirmation__checkmark"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 52 52"
|
||||
height="52"
|
||||
width="52"
|
||||
>
|
||||
<circle class="confirmation__checkmark-circle" cx="26" cy="26" r="18" fill="none" />
|
||||
<path class="confirmation__checkmark-check" fill="none" d="M14.1 27.2l7.1 7.2 16.7-16.8" />
|
||||
</svg>
|
||||
<div class="confirmation__title">
|
||||
<span class="visually-hidden">{{ successPrefix() }}</span
|
||||
>{{ title() }}
|
||||
</div>
|
||||
</div>
|
||||
<ng-content />
|
||||
`,
|
||||
})
|
||||
export class ConfirmationComponent {
|
||||
title = input.required<string>();
|
||||
successPrefix = input($localize`:@@confirmation.succes:Succes:`);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { ConfirmationComponent } from './confirmation.component';
|
||||
|
||||
const meta: Meta<ConfirmationComponent> = {
|
||||
title: 'Design System/Molecules/Confirmation',
|
||||
component: ConfirmationComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-confirmation [title]="title">
|
||||
<p class="app-section">Uw referentienummer is 2024-00123. Bewaar dit nummer voor uw administratie.</p>
|
||||
</app-confirmation>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<ConfirmationComponent>;
|
||||
|
||||
export const Default: Story = { args: { title: 'Uw aanvraag is verstuurd' } };
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
|
||||
/** Molecule: CIBG Huisstijl **Datablock** (designsystem.cibg.nl/componenten/datablock)
|
||||
— THE way to show user/application data. A grey `.data-block` surface holds a white
|
||||
`.block-wrapper` panel with a `<dl>` of projected `<app-data-row>`s. Use `stacked`
|
||||
(`.data-block--stacked`) when labels/values are long and should stack. This is the
|
||||
single data surface (a generic white `app-card` used to exist but was unused and
|
||||
removed — see WP-12); the datablock carries its own surface, so it is not nested in
|
||||
another one. When there is no visible `heading`, pass an `ariaLabel` so the definition
|
||||
list is announced. */
|
||||
@Component({
|
||||
selector: 'app-data-block',
|
||||
imports: [HeadingComponent],
|
||||
template: `
|
||||
@if (heading()) {
|
||||
<app-heading [level]="level()">{{ heading() }}</app-heading>
|
||||
}
|
||||
<div class="data-block" [class.data-block--stacked]="stacked()">
|
||||
<div class="block-wrapper">
|
||||
<dl class="mb-0" [attr.aria-label]="ariaLabel() || null">
|
||||
<ng-content />
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class DataBlockComponent {
|
||||
heading = input('');
|
||||
/** Heading level when `heading` is set (default h3). */
|
||||
level = input<1 | 2 | 3 | 4 | 5>(3);
|
||||
/** Stacks label above value (`.data-block--stacked`) for long content. */
|
||||
stacked = input(false);
|
||||
/** Accessible name for the `<dl>` when there is no visible heading. */
|
||||
ariaLabel = input('');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { DataBlockComponent } from './data-block.component';
|
||||
import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
|
||||
|
||||
const meta: Meta<DataBlockComponent> = {
|
||||
title: 'Design System/Molecules/Data Block',
|
||||
component: DataBlockComponent,
|
||||
decorators: [moduleMetadata({ imports: [DataRowComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<app-data-block [heading]="heading" [stacked]="stacked" [ariaLabel]="ariaLabel">
|
||||
<div app-data-row key="BIG-nummer" value="19012345601"></div>
|
||||
<div app-data-row key="Naam" value="J. de Vries"></div>
|
||||
<div app-data-row key="Beroep" value="Verpleegkundige"></div>
|
||||
<div app-data-row key="Registratiedatum" value="1 maart 2018"></div>
|
||||
</app-data-block>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DataBlockComponent>;
|
||||
|
||||
export const Default: Story = { args: { heading: 'Persoonsgegevens (BRP)' } };
|
||||
export const ZonderKop: Story = { args: { ariaLabel: 'Registratiegegevens' } };
|
||||
export const Stacked: Story = { args: { heading: 'Toelichting', stacked: true } };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
/** Molecule: one key/value row inside a CIBG Huisstijl **Datablock** (see
|
||||
`data-block.component.ts`) — the row primitive of the datablock/`controlestap`
|
||||
data summary. Used on a `<div>` so the `<dl>`'s direct child is a native element
|
||||
(the HTML5.1 `dl > div > dt + dd` grouping), which keeps the definition list
|
||||
axe-clean — a bare custom element between `<dl>` and its `<dt>/<dd>` trips axe's
|
||||
definition-list rule regardless of `display:contents`. The host is the Bootstrap
|
||||
`.row`; `dt.col-md-4`/`dd.col-md-8` give the label/value widths. Wrap several in a
|
||||
`<dl class="mb-0">` (a `<app-data-block>`). Project custom content (e.g. a badge)
|
||||
into the `<dd>` via `<ng-content>`. */
|
||||
@Component({
|
||||
selector: 'div[app-data-row]',
|
||||
host: { class: 'row' },
|
||||
// The CIBG datablock draws a separator between entries. It ships that border on
|
||||
// dt/dd with a `:last-of-type` reset, but our one-row-per-<div> grouping (for axe)
|
||||
// makes every dt/dd a last-of-type — so we carry the separator on the row instead.
|
||||
styles: [
|
||||
`
|
||||
:host:not(:last-of-type) {
|
||||
border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-cool-grey-200);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<dt class="col-md-4">{{ key() }}</dt>
|
||||
<dd class="col-md-8">
|
||||
<ng-content>{{ value() }}</ng-content>
|
||||
</dd>
|
||||
`,
|
||||
})
|
||||
export class DataRowComponent {
|
||||
key = input.required<string>();
|
||||
value = input<string | null>('');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { DataRowComponent } from './data-row.component';
|
||||
|
||||
const meta: Meta<DataRowComponent> = {
|
||||
title: 'Design System/Molecules/Data Row',
|
||||
component: DataRowComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// A row is a `.row` <div> grouping dt.col-md-4/dd.col-md-8 inside the datablock <dl>
|
||||
// (HTML5.1 dl > div > dt+dd — a native div child keeps the definition list axe-clean).
|
||||
template: `<dl class="mb-0"><div app-data-row [key]="key" [value]="value"></div></dl>`,
|
||||
}),
|
||||
args: { key: 'BIG-nummer', value: '19012345601' },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<DataRowComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const Empty: Story = { args: { key: 'Tweede naam', value: '' } };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, booleanAttribute, input } from '@angular/core';
|
||||
|
||||
/** Molecule: form field = label + projected control + optional error/description,
|
||||
in the CIBG Huisstijl horizontal `form-group row` layout (label `col-md-4`,
|
||||
control `col-md-8`). The required asterisk comes from
|
||||
`.form-group.required>.col-form-label::after` — no separate "(verplicht)" text.
|
||||
Reused by the login form, the change-request form, and every wizard step. */
|
||||
@Component({
|
||||
selector: 'app-form-field',
|
||||
template: `
|
||||
<div class="form-group row" [class.required]="required()">
|
||||
<label class="col-md-4 col-form-label" [id]="fieldId() + '-label'" [for]="fieldId()">{{
|
||||
label()
|
||||
}}</label>
|
||||
<div class="col-md-8 col-control">
|
||||
@if (description()) {
|
||||
<div class="form-text" [id]="fieldId() + '-desc'">{{ description() }}</div>
|
||||
}
|
||||
<ng-content />
|
||||
@if (error()) {
|
||||
<div [id]="fieldId() + '-error'" role="alert">
|
||||
<span class="errortext">{{ error() }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class FormFieldComponent {
|
||||
label = input.required<string>();
|
||||
fieldId = input.required<string>();
|
||||
description = input<string>();
|
||||
error = input<string>();
|
||||
required = input(false, { transform: booleanAttribute });
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { moduleMetadata } from '@storybook/angular';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { FormFieldComponent } from './form-field.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
|
||||
const meta: Meta<FormFieldComponent> = {
|
||||
title: 'Design System/Molecules/Form Field',
|
||||
component: FormFieldComponent,
|
||||
decorators: [moduleMetadata({ imports: [TextInputComponent] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
// form-horizontal + .row context, same as every real caller (wizard-shell, login-form, …).
|
||||
template: `
|
||||
<form class="form-horizontal">
|
||||
<app-form-field [label]="label" [fieldId]="fieldId" [description]="description" [error]="error" [required]="required">
|
||||
<app-text-input [inputId]="fieldId" [hasDescription]="!!description" [invalid]="!!error" placeholder="Vul in" />
|
||||
</app-form-field>
|
||||
</form>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<FormFieldComponent>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: { label: 'BSN', fieldId: 'bsn', description: '9 cijfers', required: true },
|
||||
// Composition contract: fieldId must equal the input's id — enforced here, not by DI
|
||||
// (see WP-16). Catches drift in the description→aria-describedby wiring.
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc');
|
||||
},
|
||||
};
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Straat en huisnummer',
|
||||
fieldId: 'street',
|
||||
error: 'Dit veld is verplicht.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'street-error');
|
||||
},
|
||||
};
|
||||
export const WithDescriptionAndError: Story = {
|
||||
args: {
|
||||
label: 'BSN',
|
||||
fieldId: 'bsn',
|
||||
description: '9 cijfers',
|
||||
error: 'Ongeldig BSN.',
|
||||
required: true,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const input = canvas.getByRole('textbox');
|
||||
await expect(input).toHaveAttribute('aria-describedby', 'bsn-desc bsn-error');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
|
||||
/** Atom: heading. Renders the right h1..h5; CIBG/Bootstrap styles native headings.
|
||||
Single <ng-content> captured in a template — multiple ng-content across
|
||||
@switch branches silently drops the projected content. */
|
||||
@Component({
|
||||
selector: 'app-heading',
|
||||
imports: [NgTemplateOutlet],
|
||||
template: `
|
||||
<ng-template #content><ng-content /></ng-template>
|
||||
@switch (level()) {
|
||||
@case (1) {
|
||||
<h1><ng-container [ngTemplateOutlet]="content" /></h1>
|
||||
}
|
||||
@case (2) {
|
||||
<h2><ng-container [ngTemplateOutlet]="content" /></h2>
|
||||
}
|
||||
@case (3) {
|
||||
<h3><ng-container [ngTemplateOutlet]="content" /></h3>
|
||||
}
|
||||
@case (4) {
|
||||
<h4><ng-container [ngTemplateOutlet]="content" /></h4>
|
||||
}
|
||||
@default {
|
||||
<h5><ng-container [ngTemplateOutlet]="content" /></h5>
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class HeadingComponent {
|
||||
level = input<1 | 2 | 3 | 4 | 5>(2);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { HeadingComponent } from './heading.component';
|
||||
|
||||
const meta: Meta<HeadingComponent> = {
|
||||
title: 'Design System/Atoms/Heading',
|
||||
component: HeadingComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-heading [level]="level">Mijn BIG-registratie</app-heading>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<HeadingComponent>;
|
||||
|
||||
export const Level1: Story = { args: { level: 1 } };
|
||||
export const Level2: Story = { args: { level: 2 } };
|
||||
export const Level3: Story = { args: { level: 3 } };
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
import { RouterLink } from '@angular/router';
|
||||
|
||||
/** Atom: link. Internal router link; CIBG/Bootstrap styles bare anchors. */
|
||||
@Component({
|
||||
selector: 'app-link',
|
||||
imports: [RouterLink],
|
||||
template: `<a [routerLink]="to()" class="link-primary"><ng-content /></a>`,
|
||||
})
|
||||
export class LinkComponent {
|
||||
to = input.required<string>();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { applicationConfig } from '@storybook/angular';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { LinkComponent } from './link.component';
|
||||
|
||||
const meta: Meta<LinkComponent> = {
|
||||
title: 'Design System/Atoms/Link',
|
||||
component: LinkComponent,
|
||||
decorators: [applicationConfig({ providers: [provideRouter([])] })],
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-link [to]="to">Naar het dashboard</app-link>`,
|
||||
}),
|
||||
args: { to: '/dashboard' },
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<LinkComponent>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
|
||||
/**
|
||||
* Atom: a possibly-masked sensitive value (BSN, BIG-nummer, …) with an optional, audited
|
||||
* reveal affordance (WP-40). The value arrives masked from the server (data-minimisation)
|
||||
* and is swapped for the full value on reveal; the reveal button shows only when the value
|
||||
* is still masked AND the caller says the principal may reveal it. Centralises the
|
||||
* masked-detection that consumers used to sniff inline. The atom only emits `reveal`; the
|
||||
* caller owns the step-up gesture + the audited fetch (see behandel-scherm).
|
||||
*
|
||||
* ponytail: masked-detection is the mask character (`*`) — a POC heuristic. A server-sent
|
||||
* `masked` boolean would remove the sniff; wire it here without touching consumers.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-masked-value',
|
||||
imports: [ButtonComponent],
|
||||
template: `
|
||||
<span class="value">{{ value() }}</span>
|
||||
@if (canReveal() && masked()) {
|
||||
<app-button variant="subtle" (click)="reveal.emit()">{{ revealLabel() }}</app-button>
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class MaskedValueComponent {
|
||||
value = input.required<string>();
|
||||
canReveal = input(false);
|
||||
revealLabel = input($localize`:@@maskedValue.reveal:Tonen`);
|
||||
reveal = output<void>();
|
||||
|
||||
protected masked = computed(() => this.value().includes('*'));
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { MaskedValueComponent } from './masked-value.component';
|
||||
|
||||
const meta: Meta<MaskedValueComponent> = {
|
||||
title: 'Design System/Atoms/Masked Value',
|
||||
component: MaskedValueComponent,
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<MaskedValueComponent>;
|
||||
|
||||
/** Masked + the principal may reveal → the reveal button shows. */
|
||||
export const RevealableMasked: Story = {
|
||||
args: { value: '******601', canReveal: true, revealLabel: 'Toon BIG-nummer' },
|
||||
};
|
||||
|
||||
/** Masked but no reveal right → just the masked value, no affordance. */
|
||||
export const MaskedNoReveal: Story = {
|
||||
args: { value: '******601', canReveal: false },
|
||||
};
|
||||
|
||||
/** Already revealed (no mask character) → no reveal button even with the right. */
|
||||
export const Revealed: Story = {
|
||||
args: { value: '990000000012', canReveal: true },
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
|
||||
// CIBG-GAP EXTENSION: n/a — no vendored inline-chip/tag class; hand-rolled
|
||||
// brace-wrapped chip, see cibg-gaps.mdx.
|
||||
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
|
||||
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
|
||||
for linter error/warning states. Domain-free and presentational — the caller
|
||||
passes label/state; a11y label announces the field name + its resolution status.
|
||||
(The editor renders its own inline chips inside contenteditable; this atom is for
|
||||
everywhere the letter is shown, not edited.) */
|
||||
@Component({
|
||||
selector: 'app-placeholder-chip',
|
||||
styles: [
|
||||
`
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2em;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
padding: 0 0.35em;
|
||||
line-height: 1.6;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Braces use unicode escapes; a literal { in a CSS content string breaks the style parser. */
|
||||
.chip::before {
|
||||
content: '\\7B';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip::after {
|
||||
content: '\\7D';
|
||||
opacity: 0.6;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip--auto {
|
||||
background: var(--rhc-color-cool-grey-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--manual {
|
||||
background: var(--rhc-color-geel-100);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--warning {
|
||||
background: var(--rhc-color-geel-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
.chip--error {
|
||||
background: var(--rhc-color-rood-100);
|
||||
border-color: var(--rhc-color-border-default);
|
||||
color: var(--rhc-color-foreground-default);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{
|
||||
label()
|
||||
}}</span>`,
|
||||
})
|
||||
export class PlaceholderChipComponent {
|
||||
label = input.required<string>();
|
||||
autoResolvable = input(false);
|
||||
state = input<'ok' | 'warning' | 'error'>('ok');
|
||||
|
||||
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
|
||||
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
|
||||
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
|
||||
warningText = input($localize`:@@placeholderChip.warning:let op`);
|
||||
errorText = input($localize`:@@placeholderChip.error:fout`);
|
||||
|
||||
protected variant = computed(() => {
|
||||
const s = this.state();
|
||||
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
|
||||
});
|
||||
|
||||
protected ariaLabel = computed(() => {
|
||||
const status = {
|
||||
auto: this.autoText(),
|
||||
manual: this.manualText(),
|
||||
warning: this.warningText(),
|
||||
error: this.errorText(),
|
||||
}[this.variant()];
|
||||
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||
|
||||
const meta: Meta<PlaceholderChipComponent> = {
|
||||
title: 'Design System/Atoms/Placeholder Chip',
|
||||
component: PlaceholderChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
|
||||
}),
|
||||
parameters: {
|
||||
cibgGap: true,
|
||||
docs: { description: { component: 'CIBG-gap extension — see Foundations/CIBG Gap Register.' } },
|
||||
},
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PlaceholderChipComponent>;
|
||||
|
||||
export const AutoResolvable: Story = {
|
||||
args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' },
|
||||
};
|
||||
export const Manual: Story = {
|
||||
args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' },
|
||||
};
|
||||
export const Warning: Story = {
|
||||
args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' },
|
||||
};
|
||||
export const Error: Story = {
|
||||
args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' },
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user