refactor: fold machine-remote-data into remote-data.ts, PascalCase load lifecycle (RD-11)

`machine-remote-data.ts` defined a third encoding of an in-flight fetch:
`LoadLifecycle`. It had three call sites, all one identical line, and the type
was never imported by name. Move the mapping into `remote-data.ts` as
`fromLoadLifecycle`, beside its neighbour `fromResource` — a `RemoteData`
constructor, not a sixth encoding.

The lowercase `loading`/`failed`/`loaded` tags on `BriefState`,
`OrgTemplateState` and `StamdataEditorState` existed only because
`LoadLifecycle` required them. Now that the constraint is inline and
PascalCase, the three machines' load-lifecycle tags become `Loading`,
`Failed` and `Loaded` — matching their own PascalCase message tags in the
same file. `stamdata-editor.machine.spec.ts` no longer asserts a PascalCase
message producing a lowercase state.

`BriefStatus` (the letter's draft/submitted/approved/rejected/sent status,
parsed off the wire from `BriefViewDto`) is a separate tag family and is
untouched — its tag count stays 54 before and after this change.

Delete `machine-remote-data.ts` and merge its spec into `remote-data.spec.ts`.
Regenerate `behaviour-spec.mdx` (the `machineRemoteData` section heading
becomes `fromLoadLifecycle`) and confirm `gen:snippets` reports no drift, since
`remote-data.ts` carries a showcase region.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 18:07:00 +02:00
co-authored by Claude Sonnet 5
parent 11664d2efa
commit 827c655c1b
19 changed files with 303 additions and 139 deletions
@@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief };
function loadedBrief(store: BriefStore): Brief {
const s = store.model();
if (s.tag !== 'loaded') throw new Error('not loaded');
if (s.tag !== 'Loaded') throw new Error('not loaded');
return s.brief;
}
@@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once, and the store ends up loaded from its result.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model().tag).toBe('loaded');
expect(store.model().tag).toBe('Loaded');
});
it('a second 404 does not drive a second reset()', async () => {
@@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
// Then reset() ran exactly once — the once-only bound holds across calls, not
// just within one — and the second 404 surfaces as an ordinary load failure.
expect(reset).toHaveBeenCalledTimes(1);
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
});
});
@@ -4,7 +4,7 @@ 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 { fromLoadLifecycle } from '@shared/application/remote-data';
import {
Brief,
CaseContext,
@@ -29,7 +29,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
*/
@Injectable({ providedIn: 'root' })
export class BriefStore implements PendingSave {
@@ -95,11 +95,11 @@ 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(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private brief = computed<Brief | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s.brief : null;
return s.tag === 'Loaded' ? s.brief : null;
});
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
@@ -111,7 +111,7 @@ export class BriefStore implements PendingSave {
private decisions = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s.decisions : null;
return s.tag === 'Loaded' ? s.decisions : null;
});
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
@@ -182,7 +182,7 @@ export class BriefStore implements PendingSave {
}
private restore(step: (current: Brief) => Brief | undefined) {
const s = this.model();
if (s.tag !== 'loaded') return;
if (s.tag !== 'Loaded') return;
const target = step(s.brief);
if (target === undefined) return;
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
@@ -2,7 +2,7 @@ import { Injectable, computed, effect, inject, signal } from '@angular/core';
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 { fromLoadLifecycle } from '@shared/application/remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
import { UploadShellService } from '@shared/application/upload-shell.service';
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
@@ -22,7 +22,7 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
const LOGO_CATEGORY = 'org-logo';
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
@@ -58,11 +58,11 @@ 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(() => machineRemoteData(this.model()));
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
return s.tag === 'Loaded' ? s : null;
});
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
@@ -101,7 +101,7 @@ export class OrgTemplateStore implements PendingSave {
// the length guard makes it idempotent (no dispatch loop).
effect(() => {
const s = this.model();
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
const status = this.categoriesRes.status();
if (status === 'resolved' || status === 'local')
this.dispatchUpload({
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
});
const sectionBlocks = (s: BriefState, key: string) =>
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
const passageIds = (s: BriefState, key: string) =>
sectionBlocks(s, key)
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
availablePassages: [],
decisions,
}).tag,
).toBe('loaded');
).toBe('Loaded');
});
it('BriefLoadFailed moves loading to failed with the reason', () => {
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
tag: 'failed',
tag: 'Failed',
reason: 'x',
});
});
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
comments: 'graag aanpassen',
});
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
});
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
// fill the required section via the besluit, then submit
const filled = reduce(loaded(), besluit('positief'));
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
tag: 'submitted',
submittedBy: 'u1',
submittedAt: 't',
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
// approve from draft is a no-op
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
tag: 'approved',
approvedBy: 'u2',
approvedAt: 't2',
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
comments: 'nee',
decisions,
});
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
tag: 'rejected',
rejectedBy: 'u2',
rejectedAt: 't2',
@@ -262,7 +262,7 @@ describe('brief.machine reduce', () => {
// send from submitted is a no-op
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
});
it('a status transition replaces decisions with the fresh server value', () => {
@@ -280,10 +280,10 @@ describe('brief.machine reduce', () => {
at: 't2',
decisions: staleApprover,
});
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
});
});
function initialLoading(): BriefState {
return { tag: 'loading' };
return { tag: 'Loading' };
}
@@ -37,16 +37,16 @@ import { passagesForBesluit } from './besluit';
*/
export type BriefState =
| { tag: 'loading' }
| { tag: 'Loading' }
| {
tag: 'loaded';
tag: 'Loaded';
brief: Brief;
availablePassages: readonly LibraryPassage[];
decisions: BriefDecisions;
}
| { tag: 'failed'; reason: string };
| { tag: 'Failed'; reason: string };
export const initial: BriefState = { tag: 'loading' };
export const initial: BriefState = { tag: 'Loading' };
export type BriefMsg =
| {
@@ -110,7 +110,7 @@ function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBl
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
let brief = f(s.brief);
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
return { ...s, brief };
@@ -189,13 +189,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
switch (m.tag) {
case 'BriefLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
brief: m.brief,
availablePassages: m.availablePassages,
decisions: m.decisions,
};
case 'BriefLoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'Seed':
return m.state;
@@ -203,7 +203,7 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
case 'BesluitSelected':
return withEdit(s, (b) =>
s.tag === 'loaded' && isSectionEditable(b, 'kern')
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
: b,
);
@@ -275,6 +275,6 @@ function transition(
decisions: BriefDecisions,
guard: (b: Brief) => boolean = () => true,
): BriefState {
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
return { ...s, brief: { ...s.brief, status: next() }, decisions };
}
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
});
const loaded = (): OrgTemplateState =>
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
const logoCategory: DocumentCategory = {
categoryId: 'org-logo',
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
describe('org-template.machine', () => {
it('DraftLoaded moves to loaded with the draft, clean', () => {
const s = expectTag(loaded(), 'loaded');
const s = expectTag(loaded(), 'Loaded');
expect(s.draft.orgName).toBe('CIBG');
expect(s.subOrgId).toBe('cibg-registers');
expect(s.unsentBriefs).toBe(2);
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
});
it('LoadFailed carries the reason', () => {
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
});
it('FieldEdited edits the draft and marks dirty', () => {
const s = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
'loaded',
'Loaded',
);
expect(s.draft.orgName).toBe('CIBG Nieuw');
expect(s.dirty).toBe(true);
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
it('MarginEdited edits one edge and marks dirty', () => {
const s = expectTag(
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
'loaded',
'Loaded',
);
expect(s.draft.margins.topMm).toBe(40);
expect(s.draft.margins.leftMm).toBe(20);
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
it('DraftSaved clears dirty when the saved draft is the current one', () => {
const edited = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
expect(s.dirty).toBe(false);
expect(s.draft.orgName).toBe('X');
});
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
const editing = expectTag(
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
'loaded',
'Loaded',
);
const savedDraft = editing.draft;
// a further edit changes the draft reference before the save resolves
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
expect(s.dirty).toBe(true);
});
it('edits are no-ops in non-loaded states', () => {
expect(
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
).toEqual({
tag: 'loading',
tag: 'Loading',
});
});
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
}),
'loaded',
'Loaded',
);
expect(done.draft.logoDocumentId).toBe('doc-1');
expect(done.dirty).toBe(true);
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
tag: 'Upload',
msg: { type: 'UploadRemoved', localId: 'a' },
}),
'loaded',
'Loaded',
);
expect(removed.draft.logoDocumentId).toBeUndefined();
expect(removed.dirty).toBe(true);
@@ -154,7 +154,7 @@ describe('org-template.machine', () => {
tag: 'DraftLoaded',
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
}),
'loaded',
'Loaded',
);
expect(switched.upload.categories).toHaveLength(1);
expect(switched.upload.uploads).toHaveLength(0);
@@ -22,10 +22,10 @@ export type OrgTemplateTextField =
| 'signatureClosing';
export type OrgTemplateState =
| { tag: 'loading' }
| { tag: 'failed'; reason: string }
| { tag: 'Loading' }
| { tag: 'Failed'; reason: string }
| {
tag: 'loaded';
tag: 'Loaded';
subOrgId: string;
draft: OrgTemplate;
publishedVersion: number;
@@ -36,7 +36,7 @@ export type OrgTemplateState =
upload: UploadState;
};
export const initial: OrgTemplateState = { tag: 'loading' };
export const initial: OrgTemplateState = { tag: 'Loading' };
export type OrgTemplateMsg =
| { tag: 'Loading' }
@@ -51,18 +51,18 @@ export type OrgTemplateMsg =
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
}
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
switch (m.tag) {
case 'Loading':
return { tag: 'loading' };
return { tag: 'Loading' };
case 'LoadFailed':
return { tag: 'failed', reason: m.reason };
return { tag: 'Failed', reason: m.reason };
case 'DraftLoaded':
return {
tag: 'loaded',
tag: 'Loaded',
subOrgId: m.view.draft.subOrgId,
draft: m.view.draft,
publishedVersion: m.view.publishedVersion,
@@ -71,16 +71,16 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
dirty: false,
// Keep the loaded logo category across sub-org switches (it's the same
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
};
case 'FieldEdited':
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
case 'MarginEdited':
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
case 'DraftSaved':
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
case 'Upload': {
if (s.tag !== 'loaded') return s;
if (s.tag !== 'Loaded') return s;
const upload = reduceUpload(s.upload, m.msg);
// A completed/removed logo upload also updates the draft's logoDocumentId.
if (m.msg.type === 'UploadComplete')
+1 -1
View File
@@ -172,7 +172,7 @@ export class BriefPage {
Success value is unwrapped here instead of through `let-`. */
protected readonly loaded = computed(() => {
const s = this.model();
return s.tag === 'loaded' ? s : undefined;
return s.tag === 'Loaded' ? s : undefined;
});
protected reload() {