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:
@@ -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<BriefState, { tag: 'loaded' }>;
|
||||
|
||||
/**
|
||||
* 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<SaveState>({ 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<readonly Brief[]>([]);
|
||||
private future = signal<readonly Brief[]>([]);
|
||||
readonly canUndo = computed(() => this.past().length > 0);
|
||||
readonly canRedo = computed(() => this.future().length > 0);
|
||||
private history = createHistory<Brief>(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 `<app-async>` — 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<RemoteData<Error | undefined, LoadedBriefState>>(() => {
|
||||
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<Brief | null>(() => {
|
||||
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<typeof setTimeout>;
|
||||
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<Result<string, BriefView>>) {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
clearTimeout(this.saveTimer);
|
||||
this.saveTimer = undefined;
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
if (!r.ok) {
|
||||
|
||||
Reference in New Issue
Block a user