refactor(fp): WP-31 — shared store helpers (dedupe brief/org-template/stamdata)
Audit "apply high-value": extract four shared helpers into shared/application/ and rewire the editor stores (behaviour unchanged, existing specs are the gate): - action-state.ts: ActionState/SaveState (were duplicated in both brief stores). - history.ts: createHistory<T> (extracted from BriefStore's WP-27 undo/redo; WP-32 reuses). - debounced-save.ts: createDebouncedSave (the 600ms timer/PendingSave dance, was 2×+). - machine-remote-data.ts: machineRemoteData (the loading/failed/loaded→RemoteData switch, 3×). Each helper has a co-located spec. Deferred DDD findings (contracts/ inconsistency, a parse* traverse combinator, the 6× Seed boilerplate) are reported in the WP file, not built. npm run ci green; 323 tests (+13 helper specs); brief/org-template/stamdata specs unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>`), `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).
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { RemoteData } from '@shared/application/remote-data';
|
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
|
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||||
import {
|
import {
|
||||||
ChangeCounts,
|
ChangeCounts,
|
||||||
StamRow,
|
StamRow,
|
||||||
@@ -39,17 +39,7 @@ export class StamdataStore {
|
|||||||
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
|
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
|
||||||
readonly previewDate = signal<string>('');
|
readonly previewDate = signal<string>('');
|
||||||
|
|
||||||
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
|
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||||
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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private loaded = computed<LoadedState | null>(() => {
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { RemoteData } from '@shared/application/remote-data';
|
|
||||||
import { createStore } from '@shared/application/store';
|
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 {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
CaseContext,
|
CaseContext,
|
||||||
@@ -19,17 +22,6 @@ import { RevealBigNummerAdapter } from '@brief/infrastructure/reveal-bignummer.a
|
|||||||
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
import { uploadContentUrl } from '@shared/upload/upload.adapter';
|
||||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
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<BriefState, { tag: 'loaded' }>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root singleton for the letter: the Elm store (Model + dispatch), the derived
|
* 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
|
* 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. */
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
|
/** Undo/redo is SHELL state, not machine state (WP-27): a `createHistory` stack of
|
||||||
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
|
`Brief` snapshots (WP-31 extracted the mechanics). Only CONTENT edits are recorded
|
||||||
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
|
(they flow through `edit()`); status transitions never enter history, or undo would
|
||||||
never enter history, or undo would replay workflow state. Capped so a long session
|
replay workflow state. Restore re-dispatches the existing `Seed` Msg — zero machine
|
||||||
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
|
|
||||||
changes. */
|
changes. */
|
||||||
private static readonly HISTORY_CAP = 50;
|
private history = createHistory<Brief>(50);
|
||||||
private past = signal<readonly Brief[]>([]);
|
readonly canUndo = this.history.canUndo;
|
||||||
private future = signal<readonly Brief[]>([]);
|
readonly canRedo = this.history.canRedo;
|
||||||
readonly canUndo = computed(() => this.past().length > 0);
|
|
||||||
readonly canRedo = computed(() => this.future().length > 0);
|
|
||||||
|
|
||||||
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
||||||
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
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 `<app-async>` — the machine keeps
|
/** The load lifecycle as `RemoteData`, for `<app-async>` — the machine keeps
|
||||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
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. */
|
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||||
readonly remoteData = computed<RemoteData<Error | undefined, LoadedBriefState>>(() => {
|
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||||
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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private brief = computed<Brief | null>(() => {
|
private brief = computed<Brief | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
@@ -145,7 +124,7 @@ export class BriefStore implements PendingSave {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
this.caseContext.set(r.value.caseContext);
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.clearHistory();
|
this.history.clear();
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||||
@@ -159,34 +138,26 @@ export class BriefStore implements PendingSave {
|
|||||||
const before = this.brief();
|
const before = this.brief();
|
||||||
this.store.dispatch(msg);
|
this.store.dispatch(msg);
|
||||||
const after = this.brief();
|
const after = this.brief();
|
||||||
if (before && after && after !== before) {
|
// Record only a real change: a no-op edit (e.g. a locked section) returns the same
|
||||||
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
|
// value and leaves no dead history step.
|
||||||
this.future.set([]);
|
if (before && after && after !== before) this.history.record(before);
|
||||||
}
|
this.debouncedSave.schedule();
|
||||||
this.scheduleSave();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
|
/** Undo/redo: restore a snapshot via the existing `Seed` Msg, then autosave. */
|
||||||
onto the redo stack, then autosave. Redo is the mirror image. */
|
|
||||||
undo() {
|
undo() {
|
||||||
this.step(this.past, this.future);
|
this.restore((current) => this.history.undo(current));
|
||||||
}
|
}
|
||||||
redo() {
|
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 s = this.model();
|
||||||
const target = from().at(-1);
|
if (s.tag !== 'loaded') return;
|
||||||
if (s.tag !== 'loaded' || !target) return;
|
const target = step(s.brief);
|
||||||
from.update((x) => x.slice(0, -1));
|
if (target === undefined) return;
|
||||||
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
|
|
||||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||||
this.scheduleSave();
|
this.debouncedSave.schedule();
|
||||||
}
|
|
||||||
|
|
||||||
private clearHistory() {
|
|
||||||
this.past.set([]);
|
|
||||||
this.future.set([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -195,27 +166,15 @@ export class BriefStore implements PendingSave {
|
|||||||
registerPendingSave(this);
|
registerPendingSave(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
// 600ms debounced autosave (the server is the store of record). Timer mechanics live in
|
||||||
private scheduleSave() {
|
// the shared helper; `flushSave` below is the store-specific write + save-state (WP-31).
|
||||||
if (!this.canEdit()) return;
|
private debouncedSave = createDebouncedSave({
|
||||||
clearTimeout(this.saveTimer);
|
canSave: () => this.canEdit(),
|
||||||
// ponytail: 600ms debounce like the wizard draft-sync; the server is the store of record.
|
flush: () => this.flushSave(),
|
||||||
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
});
|
||||||
this.saveTimer = setTimeout(() => {
|
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
|
||||||
this.saveTimer = undefined;
|
hasPendingSave = () => this.debouncedSave.hasPendingSave();
|
||||||
void this.flushSave();
|
flushPending = () => this.debouncedSave.flushPending();
|
||||||
}, 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();
|
|
||||||
}
|
|
||||||
private async flushSave() {
|
private async flushSave() {
|
||||||
const b = this.brief();
|
const b = this.brief();
|
||||||
if (!b) return;
|
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. */
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||||
async resetDemo() {
|
async resetDemo() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
const r = await this.adapter.reset();
|
const r = await this.adapter.reset();
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.actionState.set({ tag: 'Idle' });
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
this.caseContext.set(r.value.caseContext);
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.clearHistory();
|
this.history.clear();
|
||||||
this.rejectionSnapshot.set(null);
|
this.rejectionSnapshot.set(null);
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
@@ -289,8 +247,7 @@ export class BriefStore implements PendingSave {
|
|||||||
// the returned status through the pure reducer's guarded transition.
|
// the returned status through the pure reducer's guarded transition.
|
||||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
await this.flushSave();
|
await this.flushSave();
|
||||||
const r = await action();
|
const r = await action();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||||
import { RemoteData } from '@shared/application/remote-data';
|
|
||||||
import { createStore } from '@shared/application/store';
|
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 { UploadAdapter } from '@shared/upload/upload.adapter';
|
||||||
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
import { UploadShellService } from '@shared/upload/upload-shell.service';
|
||||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
import { UploadMsg, initialUpload, rejectReason } from '@shared/upload/upload.machine';
|
||||||
@@ -19,9 +21,6 @@ import {
|
|||||||
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
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<OrgTemplateState, { tag: 'loaded' }>;
|
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||||
|
|
||||||
const LOGO_CATEGORY = 'org-logo';
|
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). */
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||||
readonly pendingPublish = signal(false);
|
readonly pendingPublish = signal(false);
|
||||||
|
|
||||||
readonly remoteData = computed<RemoteData<Error | undefined, LoadedState>>(() => {
|
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||||
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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
private loaded = computed<LoadedState | null>(() => {
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
@@ -138,8 +127,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
async selectSubOrg(subOrgId: string) {
|
async selectSubOrg(subOrgId: string) {
|
||||||
this.selectedSubOrgId.set(subOrgId);
|
this.selectedSubOrgId.set(subOrgId);
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
this.store.dispatch({ tag: 'Loading' });
|
this.store.dispatch({ tag: 'Loading' });
|
||||||
const r = await this.adapter.load(subOrgId);
|
const r = await this.adapter.load(subOrgId);
|
||||||
if (r.ok) this.store.dispatch({ tag: 'DraftLoaded', view: r.value });
|
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. */
|
/** An in-place canvas or margin edit: apply optimistically, then debounce-save. */
|
||||||
edit(msg: OrgTemplateMsg) {
|
edit(msg: OrgTemplateMsg) {
|
||||||
this.store.dispatch(msg);
|
this.store.dispatch(msg);
|
||||||
this.scheduleSave();
|
this.debouncedSave.schedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
// 600ms debounced autosave (same idiom as BriefStore, WP-31). Timer mechanics live in the
|
||||||
private scheduleSave() {
|
// shared helper; `flushSave` below is the store-specific write + save-state.
|
||||||
if (this.loaded() === null) return;
|
private debouncedSave = createDebouncedSave({
|
||||||
clearTimeout(this.saveTimer);
|
canSave: () => this.loaded() !== null,
|
||||||
// ponytail: 600ms debounce, same as BriefStore; the server is the store of record.
|
flush: () => this.flushSave(),
|
||||||
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
});
|
||||||
this.saveTimer = setTimeout(() => {
|
/** PendingSave: delegate to the debounce helper so the guard/unload can flush. */
|
||||||
this.saveTimer = undefined;
|
hasPendingSave = () => this.debouncedSave.hasPendingSave();
|
||||||
void this.flushSave();
|
flushPending = () => this.debouncedSave.flushPending();
|
||||||
}, 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();
|
|
||||||
}
|
|
||||||
private async flushSave() {
|
private async flushSave() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s || !s.dirty) return;
|
if (!s || !s.dirty) return;
|
||||||
@@ -201,8 +177,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.pendingPublish.set(false);
|
this.pendingPublish.set(false);
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
const r = await this.adapter.publish(s.subOrgId);
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -217,8 +192,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||||
@@ -232,8 +206,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.actionState.set({ tag: 'Busy' });
|
||||||
clearTimeout(this.saveTimer);
|
this.debouncedSave.cancel();
|
||||||
this.saveTimer = undefined;
|
|
||||||
await this.flushSave(); // the proefbrief renders the server's draft
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -294,6 +267,7 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
draft (in the reducer) and needs persisting. */
|
draft (in the reducer) and needs persisting. */
|
||||||
private onUploadMsg(msg: UploadMsg) {
|
private onUploadMsg(msg: UploadMsg) {
|
||||||
this.dispatchUpload(msg);
|
this.dispatchUpload(msg);
|
||||||
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved') this.scheduleSave();
|
if (msg.type === 'UploadComplete' || msg.type === 'UploadRemoved')
|
||||||
|
this.debouncedSave.schedule();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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' }> };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user