diff --git a/docs/project/backlog/WP-31-shared-store-helpers.md b/docs/project/backlog/WP-31-shared-store-helpers.md new file mode 100644 index 0000000..6140c81 --- /dev/null +++ b/docs/project/backlog/WP-31-shared-store-helpers.md @@ -0,0 +1,52 @@ +# WP-31 — Shared store helpers (audit: apply high-value) + +Status: done +Phase: 7 — refinements + +## Why + +A code audit found real duplication across the editor stores. This WP extracts the four +highest-value shared helpers and rewires the stores to them (behaviour unchanged), and +**reports** the lower-value / riskier DDD items as deferred backlog. Extracting `createHistory` +here also unblocks WP-32 (stamdata undo) so it needn't copy-paste the brief pattern. + +## Decisions (pre-made, don't relitigate) + +- Extract into `shared/application/` (importable by every context; must not import back). +- Apply the four concrete extractions + reuse; **do not** chase the deferred DDD items in this + phase (bound the diff). Behaviour must be identical — the existing store specs are the gate. + +## Files + +- New (each with a co-located spec): `shared/application/action-state.ts` (`ActionState`/ + `SaveState`), `history.ts` (`createHistory`), `debounced-save.ts` (`createDebouncedSave`), + `machine-remote-data.ts` (`machineRemoteData`). +- Rewired: `brief/application/brief.store.ts` (all four), `brief/application/org-template.store.ts` + (types + debounced-save + remote-data), `beheer/application/stamdata.store.ts` (remote-data). + +## Acceptance criteria + +- [x] `ActionState`/`SaveState` defined once; both brief stores import them. +- [x] `createHistory` backs brief undo/redo (identical semantics; specs pass). +- [x] `createDebouncedSave` backs both brief stores' autosave, integrating `PendingSave`. +- [x] `machineRemoteData` backs the RemoteData projection in all three stores. +- [x] `npm run ci` green; all pre-existing store specs still pass (no behaviour change). + +## Deferred (reported, not built) — audit findings for a later WP + +- **`contracts/` folder inconsistency:** only `beheer/` + `registratie/` have a `contracts/` + folder; `brief/`/`herregistratie/`/`auth/` declare wire DTOs inline in adapters. Decide whether + inline DTOs are a sanctioned exception or should be normalized. +- **`parse*` traverse combinator:** ~35 `parse*` boundary fns repeat an array-parse-and-collect + shape; a shared `traverse`/`parseAll` `Result` combinator would collapse the common idiom. +- **`Seed { state }` msg boilerplate:** the `Seed`/`return m.state` pair repeats in 6 machines — + cheap and per-machine typed; extract only if it earns its keep. + +## Out of scope + +- The deferred items above (this WP only applies the four extractions). + +## Risks + +- Behaviour drift in the central stores — mitigated: the extractions are 1:1 with the originals + and gated by the existing brief/org-template/stamdata specs (all green). diff --git a/src/app/beheer/application/stamdata.store.ts b/src/app/beheer/application/stamdata.store.ts index f28ac8e..a313548 100644 --- a/src/app/beheer/application/stamdata.store.ts +++ b/src/app/beheer/application/stamdata.store.ts @@ -1,6 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { RemoteData } from '@shared/application/remote-data'; import { createStore } from '@shared/application/store'; +import { machineRemoteData } from '@shared/application/machine-remote-data'; import { ChangeCounts, StamRow, @@ -39,17 +39,7 @@ export class StamdataStore { so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */ readonly previewDate = signal(''); - readonly remoteData = computed>(() => { - const s = this.model(); - switch (s.tag) { - case 'loading': - return { tag: 'Loading' }; - case 'failed': - return { tag: 'Failure', error: new Error(s.reason) }; - case 'loaded': - return { tag: 'Success', value: s }; - } - }); + readonly remoteData = computed(() => machineRemoteData(this.model())); private loaded = computed(() => { const s = this.model(); diff --git a/src/app/brief/application/brief.store.ts b/src/app/brief/application/brief.store.ts index cde4ae4..46e9210 100644 --- a/src/app/brief/application/brief.store.ts +++ b/src/app/brief/application/brief.store.ts @@ -1,7 +1,10 @@ import { Injectable, computed, inject, signal } from '@angular/core'; import { Result } from '@shared/kernel/fp'; -import { RemoteData } from '@shared/application/remote-data'; import { createStore } from '@shared/application/store'; +import { ActionState, SaveState } from '@shared/application/action-state'; +import { createHistory } from '@shared/application/history'; +import { createDebouncedSave } from '@shared/application/debounced-save'; +import { machineRemoteData } from '@shared/application/machine-remote-data'; import { Brief, CaseContext, @@ -19,17 +22,6 @@ import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.a import { uploadContentUrl } from '@shared/upload/upload.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; -/** Transient action state (submit/approve/reject/send/resetDemo) — one tagged union - instead of a busy boolean + a nullable error sitting side by side. */ -type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; - -/** Debounced-autosave indicator, shown in a small status line near the toolbar — - a separate concern from ActionState (a stale autosave error doesn't block - submit/approve/reject), but tag-aligned with it for one consistent idiom. */ -type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; - -type LoadedBriefState = Extract; - /** * Root singleton for the letter: the Elm store (Model + dispatch), the derived * read-model, and the commands (effects) that call the adapter and dispatch the @@ -57,17 +49,14 @@ export class BriefStore implements PendingSave { /** Surfaced autosave state for the indicator + aria-live region. */ readonly saveState = signal({ tag: 'Idle' }); - /** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future - `Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe. - Only CONTENT edits are recorded (they flow through `edit()`); status transitions - never enter history, or undo would replay workflow state. Capped so a long session - can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine + /** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of + `Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded + (they flow through `edit()`); status transitions never enter history, or undo would + replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine changes. */ - private static readonly HISTORY_CAP = 50; - private past = signal([]); - private future = signal([]); - readonly canUndo = computed(() => this.past().length > 0); - readonly canRedo = computed(() => this.future().length > 0); + private history = createHistory(50); + readonly canUndo = this.history.canUndo; + readonly canRedo = this.history.canRedo; /** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The approver diffs it against the resubmitted letter. POC limit: in-memory only, so a @@ -104,17 +93,7 @@ export class BriefStore implements PendingSave { /** The load lifecycle as `RemoteData`, for `` — the machine keeps owning the letter's own domain lifecycle (draft/submitted/approved/…); this is purely a projection of its loading/failed tags onto the shared async seam. */ - readonly remoteData = computed>(() => { - const s = this.model(); - switch (s.tag) { - case 'loading': - return { tag: 'Loading' }; - case 'failed': - return { tag: 'Failure', error: new Error(s.reason) }; - case 'loaded': - return { tag: 'Success', value: s }; - } - }); + readonly remoteData = computed(() => machineRemoteData(this.model())); private brief = computed(() => { const s = this.model(); @@ -145,7 +124,7 @@ export class BriefStore implements PendingSave { if (r.ok) { this.orgTemplate.set(r.value.orgTemplate); this.caseContext.set(r.value.caseContext); - this.clearHistory(); + this.history.clear(); this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); } else { this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error }); @@ -159,34 +138,26 @@ export class BriefStore implements PendingSave { const before = this.brief(); this.store.dispatch(msg); const after = this.brief(); - if (before && after && after !== before) { - this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP)); - this.future.set([]); - } - this.scheduleSave(); + // Record only a real change: a no-op edit (e.g. a locked section) returns the same + // value and leaves no dead history step. + if (before && after && after !== before) this.history.record(before); + this.debouncedSave.schedule(); } - /** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current - onto the redo stack, then autosave. Redo is the mirror image. */ + /** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */ undo() { - this.step(this.past, this.future); + this.restore((current) => this.history.undo(current)); } redo() { - this.step(this.future, this.past); + this.restore((current) => this.history.redo(current)); } - private step(from: typeof this.past, to: typeof this.future) { + private restore(step: (current: Brief) => Brief | undefined) { const s = this.model(); - const target = from().at(-1); - if (s.tag !== 'loaded' || !target) return; - from.update((x) => x.slice(0, -1)); - to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP)); + if (s.tag !== 'loaded') return; + const target = step(s.brief); + if (target === undefined) return; this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } }); - this.scheduleSave(); - } - - private clearHistory() { - this.past.set([]); - this.future.set([]); + this.debouncedSave.schedule(); } constructor() { @@ -195,27 +166,15 @@ export class BriefStore implements PendingSave { registerPendingSave(this); } - private saveTimer?: ReturnType; - private scheduleSave() { - if (!this.canEdit()) return; - clearTimeout(this.saveTimer); - // ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record. - // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". - this.saveTimer = setTimeout(() => { - this.saveTimer = undefined; - void this.flushSave(); - }, 600); - } - - /** True while a debounced edit hasn't been written yet (PendingSave). */ - hasPendingSave = () => this.saveTimer !== undefined; - /** Flush a pending debounced save now and await it; no-op when nothing is pending. */ - async flushPending() { - if (this.saveTimer === undefined) return; - clearTimeout(this.saveTimer); - this.saveTimer = undefined; - await this.flushSave(); - } + // 600ms debounced autosave (the server is the store of record). Timer mechanics live in + // the shared helper; `flushSave` below is the store-specific write + save-state (WP-31). + private debouncedSave = createDebouncedSave({ + canSave: () => this.canEdit(), + flush: () => this.flushSave(), + }); + /** PendingSave: delegate to the debounce helper so the guard/unload can flush. */ + hasPendingSave = () => this.debouncedSave.hasPendingSave(); + flushPending = () => this.debouncedSave.flushPending(); private async flushSave() { const b = this.brief(); if (!b) return; @@ -237,15 +196,14 @@ export class BriefStore implements PendingSave { /** Demo "start over": recreate the brief server-side and load the fresh view. */ async resetDemo() { this.actionState.set({ tag: 'Busy' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); const r = await this.adapter.reset(); this.saveState.set({ tag: 'Idle' }); if (r.ok) { this.actionState.set({ tag: 'Idle' }); this.orgTemplate.set(r.value.orgTemplate); this.caseContext.set(r.value.caseContext); - this.clearHistory(); + this.history.clear(); this.rejectionSnapshot.set(null); this.store.dispatch({ tag: 'BriefLoaded', ...r.value }); } else { @@ -289,8 +247,7 @@ export class BriefStore implements PendingSave { // the returned status through the pure reducer's guarded transition. private async transition(action: () => Promise>) { this.actionState.set({ tag: 'Busy' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); await this.flushSave(); const r = await action(); if (!r.ok) { diff --git a/src/app/brief/application/org-template.store.ts b/src/app/brief/application/org-template.store.ts index 6417159..6135944 100644 --- a/src/app/brief/application/org-template.store.ts +++ b/src/app/brief/application/org-template.store.ts @@ -1,6 +1,8 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core'; -import { RemoteData } from '@shared/application/remote-data'; import { createStore } from '@shared/application/store'; +import { ActionState, SaveState } from '@shared/application/action-state'; +import { createDebouncedSave } from '@shared/application/debounced-save'; +import { machineRemoteData } from '@shared/application/machine-remote-data'; import { UploadAdapter } from '@shared/upload/upload.adapter'; import { UploadShellService } from '@shared/upload/upload-shell.service'; import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine'; @@ -19,9 +21,6 @@ import { import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter'; import { PendingSave, registerPendingSave } from '@shared/application/pending-saves'; -/** Transient action state for publish/rollback/proefbrief — the BriefStore idiom. */ -type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }; -type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' }; type LoadedState = Extract; const LOGO_CATEGORY = 'org-logo'; @@ -57,17 +56,7 @@ export class OrgTemplateStore implements PendingSave { /** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */ readonly pendingPublish = signal(false); - readonly remoteData = computed>(() => { - const s = this.model(); - switch (s.tag) { - case 'loading': - return { tag: 'Loading' }; - case 'failed': - return { tag: 'Failure', error: new Error(s.reason) }; - case 'loaded': - return { tag: 'Success', value: s }; - } - }); + readonly remoteData = computed(() => machineRemoteData(this.model())); private loaded = computed(() => { const s = this.model(); @@ -138,8 +127,7 @@ export class OrgTemplateStore implements PendingSave { async selectSubOrg(subOrgId: string) { this.selectedSubOrgId.set(subOrgId); this.saveState.set({ tag: 'Idle' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); this.store.dispatch({ tag: 'Loading' }); const r = await this.adapter.load(subOrgId); if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); @@ -149,30 +137,18 @@ export class OrgTemplateStore implements PendingSave { /** An in-place canvas or margin edit: apply optimistically, then debounce-save. */ edit(msg: OrgTemplateMsg) { this.store.dispatch(msg); - this.scheduleSave(); + this.debouncedSave.schedule(); } - private saveTimer?: ReturnType; - private scheduleSave() { - if (this.loaded() === null) return; - clearTimeout(this.saveTimer); - // ponytail: 600ms debounce, same as BriefStore; the server is the store of record. - // Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed". - this.saveTimer = setTimeout(() => { - this.saveTimer = undefined; - void this.flushSave(); - }, 600); - } - - /** True while a debounced edit hasn't been written yet (PendingSave). */ - hasPendingSave = () => this.saveTimer !== undefined; - /** Flush a pending debounced save now and await it; no-op when nothing is pending. */ - async flushPending() { - if (this.saveTimer === undefined) return; - clearTimeout(this.saveTimer); - this.saveTimer = undefined; - await this.flushSave(); - } + // 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the + // shared helper; `flushSave` below is the store-specific write + save-state. + private debouncedSave = createDebouncedSave({ + canSave: () => this.loaded() !== null, + flush: () => this.flushSave(), + }); + /** PendingSave: delegate to the debounce helper so the guard/unload can flush. */ + hasPendingSave = () => this.debouncedSave.hasPendingSave(); + flushPending = () => this.debouncedSave.flushPending(); private async flushSave() { const s = this.loaded(); if (!s || !s.dirty) return; @@ -201,8 +177,7 @@ export class OrgTemplateStore implements PendingSave { if (!s) return; this.pendingPublish.set(false); this.actionState.set({ tag: 'Busy' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); await this.flushSave(); // publish the saved draft — flush any pending edit first const r = await this.adapter.publish(s.subOrgId); if (!r.ok) { @@ -217,8 +192,7 @@ export class OrgTemplateStore implements PendingSave { const s = this.loaded(); if (!s) return; this.actionState.set({ tag: 'Busy' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); const r = await this.adapter.rollback(s.subOrgId, version); if (!r.ok) { this.actionState.set({ tag: 'Failed', error: r.error }); @@ -232,8 +206,7 @@ export class OrgTemplateStore implements PendingSave { const s = this.loaded(); if (!s) return; this.actionState.set({ tag: 'Busy' }); - clearTimeout(this.saveTimer); - this.saveTimer = undefined; + this.debouncedSave.cancel(); await this.flushSave(); // the proefbrief renders the server's draft const r = await this.adapter.proefbrief(s.subOrgId); if (!r.ok) { @@ -294,6 +267,7 @@ export class OrgTemplateStore implements PendingSave { draft (in the reducer) and needs persisting. */ private onUploadMsg(msg: UploadMsg) { this.dispatchUpload(msg); - if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave(); + if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') + this.debouncedSave.schedule(); } } diff --git a/src/app/shared/application/action-state.ts b/src/app/shared/application/action-state.ts new file mode 100644 index 0000000..ca63529 --- /dev/null +++ b/src/app/shared/application/action-state.ts @@ -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' }; diff --git a/src/app/shared/application/debounced-save.spec.ts b/src/app/shared/application/debounced-save.spec.ts new file mode 100644 index 0000000..b63e017 --- /dev/null +++ b/src/app/shared/application/debounced-save.spec.ts @@ -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(); + }); +}); diff --git a/src/app/shared/application/debounced-save.ts b/src/app/shared/application/debounced-save.ts new file mode 100644 index 0000000..7b48206 --- /dev/null +++ b/src/app/shared/application/debounced-save.ts @@ -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; + /** 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; +}): DebouncedSave { + const delay = opts.delayMs ?? 600; + let timer: ReturnType | 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; + }, + }; +} diff --git a/src/app/shared/application/history.spec.ts b/src/app/shared/application/history.spec.ts new file mode 100644 index 0000000..cd9147f --- /dev/null +++ b/src/app/shared/application/history.spec.ts @@ -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(); + 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(); + // 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(); + 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(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(); + h.record(1); + h.undo(2); + h.clear(); + expect(h.canUndo()).toBe(false); + expect(h.canRedo()).toBe(false); + }); +}); diff --git a/src/app/shared/application/history.ts b/src/app/shared/application/history.ts new file mode 100644 index 0000000..979290c --- /dev/null +++ b/src/app/shared/application/history.ts @@ -0,0 +1,53 @@ +import { Signal, computed, signal } from '@angular/core'; + +export interface History { + readonly canUndo: Signal; + readonly canRedo: Signal; + /** 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(cap = 50): History { + const past = signal([]); + const future = signal([]); + 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([]); + }, + }; +} diff --git a/src/app/shared/application/machine-remote-data.spec.ts b/src/app/shared/application/machine-remote-data.spec.ts new file mode 100644 index 0000000..484d890 --- /dev/null +++ b/src/app/shared/application/machine-remote-data.spec.ts @@ -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 }); + }); +}); diff --git a/src/app/shared/application/machine-remote-data.ts b/src/app/shared/application/machine-remote-data.ts new file mode 100644 index 0000000..8b1c3cc --- /dev/null +++ b/src/app/shared/application/machine-remote-data.ts @@ -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 `` 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: S, +): RemoteData> { + 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 }; + } +}