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.`;
|
||||
Reference in New Issue
Block a user