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:
@@ -179,7 +179,7 @@ const filledView: BriefView = { ...view, brief: filledBrief };
|
|||||||
|
|
||||||
function loadedBrief(store: BriefStore): Brief {
|
function loadedBrief(store: BriefStore): Brief {
|
||||||
const s = store.model();
|
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;
|
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.
|
// Then reset() ran exactly once, and the store ends up loaded from its result.
|
||||||
expect(reset).toHaveBeenCalledTimes(1);
|
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 () => {
|
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
|
// 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.
|
// just within one — and the second 404 surfaces as an ordinary load failure.
|
||||||
expect(reset).toHaveBeenCalledTimes(1);
|
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 { ActionState, SaveState } from '@shared/application/action-state';
|
||||||
import { createHistory } from '@shared/application/history';
|
import { createHistory } from '@shared/application/history';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||||
import {
|
import {
|
||||||
Brief,
|
Brief,
|
||||||
CaseContext,
|
CaseContext,
|
||||||
@@ -29,7 +29,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
|||||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
* 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' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class BriefStore implements PendingSave {
|
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
|
/** 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(() => machineRemoteData(this.model()));
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
|
|
||||||
private brief = computed<Brief | null>(() => {
|
private brief = computed<Brief | null>(() => {
|
||||||
const s = this.model();
|
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);
|
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||||
@@ -111,7 +111,7 @@ export class BriefStore implements PendingSave {
|
|||||||
|
|
||||||
private decisions = computed(() => {
|
private decisions = computed(() => {
|
||||||
const s = this.model();
|
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 diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(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) {
|
private restore(step: (current: Brief) => Brief | undefined) {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
if (s.tag !== 'loaded') return;
|
if (s.tag !== 'Loaded') return;
|
||||||
const target = step(s.brief);
|
const target = step(s.brief);
|
||||||
if (target === undefined) return;
|
if (target === undefined) return;
|
||||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
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 { createStore } from '@shared/application/store';
|
||||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
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 { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
|
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 { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
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 LOGO_CATEGORY = 'org-logo';
|
||||||
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
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). */
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||||
readonly pendingPublish = signal(false);
|
readonly pendingPublish = signal(false);
|
||||||
|
|
||||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
|
|
||||||
private loaded = computed<LoadedState | null>(() => {
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
const s = this.model();
|
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 draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||||
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
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).
|
// the length guard makes it idempotent (no dispatch loop).
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const s = this.model();
|
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();
|
const status = this.categoriesRes.status();
|
||||||
if (status === 'resolved' || status === 'local')
|
if (status === 'resolved' || status === 'local')
|
||||||
this.dispatchUpload({
|
this.dispatchUpload({
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sectionBlocks = (s: BriefState, key: string) =>
|
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) =>
|
const passageIds = (s: BriefState, key: string) =>
|
||||||
sectionBlocks(s, key)
|
sectionBlocks(s, key)
|
||||||
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
|
|||||||
availablePassages: [],
|
availablePassages: [],
|
||||||
decisions,
|
decisions,
|
||||||
}).tag,
|
}).tag,
|
||||||
).toBe('loaded');
|
).toBe('Loaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
||||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||||
tag: 'failed',
|
tag: 'Failed',
|
||||||
reason: 'x',
|
reason: 'x',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
|
|||||||
comments: 'graag aanpassen',
|
comments: 'graag aanpassen',
|
||||||
});
|
});
|
||||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
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);
|
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
|
|||||||
// fill the required section via the besluit, then submit
|
// fill the required section via the besluit, then submit
|
||||||
const filled = reduce(loaded(), besluit('positief'));
|
const filled = reduce(loaded(), besluit('positief'));
|
||||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
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',
|
tag: 'submitted',
|
||||||
submittedBy: 'u1',
|
submittedBy: 'u1',
|
||||||
submittedAt: 't',
|
submittedAt: 't',
|
||||||
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
|
|||||||
// approve from draft is a no-op
|
// approve from draft is a no-op
|
||||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
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',
|
tag: 'approved',
|
||||||
approvedBy: 'u2',
|
approvedBy: 'u2',
|
||||||
approvedAt: 't2',
|
approvedAt: 't2',
|
||||||
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
|
|||||||
comments: 'nee',
|
comments: 'nee',
|
||||||
decisions,
|
decisions,
|
||||||
});
|
});
|
||||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
|
||||||
tag: 'rejected',
|
tag: 'rejected',
|
||||||
rejectedBy: 'u2',
|
rejectedBy: 'u2',
|
||||||
rejectedAt: 't2',
|
rejectedAt: 't2',
|
||||||
@@ -262,7 +262,7 @@ describe('brief.machine reduce', () => {
|
|||||||
// send from submitted is a no-op
|
// send from submitted is a no-op
|
||||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
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', () => {
|
it('a status transition replaces decisions with the fresh server value', () => {
|
||||||
@@ -280,10 +280,10 @@ describe('brief.machine reduce', () => {
|
|||||||
at: 't2',
|
at: 't2',
|
||||||
decisions: staleApprover,
|
decisions: staleApprover,
|
||||||
});
|
});
|
||||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function initialLoading(): BriefState {
|
function initialLoading(): BriefState {
|
||||||
return { tag: 'loading' };
|
return { tag: 'Loading' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ import { passagesForBesluit } from './besluit';
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type BriefState =
|
export type BriefState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'Loading' }
|
||||||
| {
|
| {
|
||||||
tag: 'loaded';
|
tag: 'Loaded';
|
||||||
brief: Brief;
|
brief: Brief;
|
||||||
availablePassages: readonly LibraryPassage[];
|
availablePassages: readonly LibraryPassage[];
|
||||||
decisions: BriefDecisions;
|
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 =
|
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. */
|
/** Apply an edit to the brief, guarded by status. A rejected letter reopens to draft. */
|
||||||
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
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);
|
let brief = f(s.brief);
|
||||||
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||||
return { ...s, brief };
|
return { ...s, brief };
|
||||||
@@ -189,13 +189,13 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'BriefLoaded':
|
case 'BriefLoaded':
|
||||||
return {
|
return {
|
||||||
tag: 'loaded',
|
tag: 'Loaded',
|
||||||
brief: m.brief,
|
brief: m.brief,
|
||||||
availablePassages: m.availablePassages,
|
availablePassages: m.availablePassages,
|
||||||
decisions: m.decisions,
|
decisions: m.decisions,
|
||||||
};
|
};
|
||||||
case 'BriefLoadFailed':
|
case 'BriefLoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'Failed', reason: m.reason };
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
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.
|
// drafter's free text. `availablePassages` lives on the loaded state, so this stays pure.
|
||||||
case 'BesluitSelected':
|
case 'BesluitSelected':
|
||||||
return withEdit(s, (b) =>
|
return withEdit(s, (b) =>
|
||||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
|
||||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||||
: b,
|
: b,
|
||||||
);
|
);
|
||||||
@@ -275,6 +275,6 @@ function transition(
|
|||||||
decisions: BriefDecisions,
|
decisions: BriefDecisions,
|
||||||
guard: (b: Brief) => boolean = () => true,
|
guard: (b: Brief) => boolean = () => true,
|
||||||
): BriefState {
|
): 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 };
|
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
const loaded = (): OrgTemplateState =>
|
const loaded = (): OrgTemplateState =>
|
||||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
|
||||||
|
|
||||||
const logoCategory: DocumentCategory = {
|
const logoCategory: DocumentCategory = {
|
||||||
categoryId: 'org-logo',
|
categoryId: 'org-logo',
|
||||||
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
|
|||||||
|
|
||||||
describe('org-template.machine', () => {
|
describe('org-template.machine', () => {
|
||||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
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.draft.orgName).toBe('CIBG');
|
||||||
expect(s.subOrgId).toBe('cibg-registers');
|
expect(s.subOrgId).toBe('cibg-registers');
|
||||||
expect(s.unsentBriefs).toBe(2);
|
expect(s.unsentBriefs).toBe(2);
|
||||||
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('LoadFailed carries the reason', () => {
|
it('LoadFailed carries the reason', () => {
|
||||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('FieldEdited edits the draft and marks dirty', () => {
|
it('FieldEdited edits the draft and marks dirty', () => {
|
||||||
const s = expectTag(
|
const s = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
||||||
expect(s.dirty).toBe(true);
|
expect(s.dirty).toBe(true);
|
||||||
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
|
|||||||
it('MarginEdited edits one edge and marks dirty', () => {
|
it('MarginEdited edits one edge and marks dirty', () => {
|
||||||
const s = expectTag(
|
const s = expectTag(
|
||||||
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(s.draft.margins.topMm).toBe(40);
|
expect(s.draft.margins.topMm).toBe(40);
|
||||||
expect(s.draft.margins.leftMm).toBe(20);
|
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', () => {
|
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||||
const edited = expectTag(
|
const edited = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
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.dirty).toBe(false);
|
||||||
expect(s.draft.orgName).toBe('X');
|
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', () => {
|
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||||
const editing = expectTag(
|
const editing = expectTag(
|
||||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
const savedDraft = editing.draft;
|
const savedDraft = editing.draft;
|
||||||
// a further edit changes the draft reference before the save resolves
|
// a further edit changes the draft reference before the save resolves
|
||||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
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);
|
expect(s.dirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits are no-ops in non-loaded states', () => {
|
it('edits are no-ops in non-loaded states', () => {
|
||||||
expect(
|
expect(
|
||||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
tag: 'loading',
|
tag: 'Loading',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'Upload',
|
tag: 'Upload',
|
||||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(done.draft.logoDocumentId).toBe('doc-1');
|
expect(done.draft.logoDocumentId).toBe('doc-1');
|
||||||
expect(done.dirty).toBe(true);
|
expect(done.dirty).toBe(true);
|
||||||
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'Upload',
|
tag: 'Upload',
|
||||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(removed.draft.logoDocumentId).toBeUndefined();
|
expect(removed.draft.logoDocumentId).toBeUndefined();
|
||||||
expect(removed.dirty).toBe(true);
|
expect(removed.dirty).toBe(true);
|
||||||
@@ -154,7 +154,7 @@ describe('org-template.machine', () => {
|
|||||||
tag: 'DraftLoaded',
|
tag: 'DraftLoaded',
|
||||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||||
}),
|
}),
|
||||||
'loaded',
|
'Loaded',
|
||||||
);
|
);
|
||||||
expect(switched.upload.categories).toHaveLength(1);
|
expect(switched.upload.categories).toHaveLength(1);
|
||||||
expect(switched.upload.uploads).toHaveLength(0);
|
expect(switched.upload.uploads).toHaveLength(0);
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ export type OrgTemplateTextField =
|
|||||||
| 'signatureClosing';
|
| 'signatureClosing';
|
||||||
|
|
||||||
export type OrgTemplateState =
|
export type OrgTemplateState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'Loading' }
|
||||||
| { tag: 'failed'; reason: string }
|
| { tag: 'Failed'; reason: string }
|
||||||
| {
|
| {
|
||||||
tag: 'loaded';
|
tag: 'Loaded';
|
||||||
subOrgId: string;
|
subOrgId: string;
|
||||||
draft: OrgTemplate;
|
draft: OrgTemplate;
|
||||||
publishedVersion: number;
|
publishedVersion: number;
|
||||||
@@ -36,7 +36,7 @@ export type OrgTemplateState =
|
|||||||
upload: UploadState;
|
upload: UploadState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
export const initial: OrgTemplateState = { tag: 'Loading' };
|
||||||
|
|
||||||
export type OrgTemplateMsg =
|
export type OrgTemplateMsg =
|
||||||
| { tag: 'Loading' }
|
| { tag: 'Loading' }
|
||||||
@@ -51,18 +51,18 @@ export type OrgTemplateMsg =
|
|||||||
|
|
||||||
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
/** Edit the loaded draft; a no-op in any non-loaded state (illegal by construction). */
|
||||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
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 {
|
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'Loading':
|
case 'Loading':
|
||||||
return { tag: 'loading' };
|
return { tag: 'Loading' };
|
||||||
case 'LoadFailed':
|
case 'LoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'Failed', reason: m.reason };
|
||||||
case 'DraftLoaded':
|
case 'DraftLoaded':
|
||||||
return {
|
return {
|
||||||
tag: 'loaded',
|
tag: 'Loaded',
|
||||||
subOrgId: m.view.draft.subOrgId,
|
subOrgId: m.view.draft.subOrgId,
|
||||||
draft: m.view.draft,
|
draft: m.view.draft,
|
||||||
publishedVersion: m.view.publishedVersion,
|
publishedVersion: m.view.publishedVersion,
|
||||||
@@ -71,16 +71,16 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
// Keep the loaded logo category across sub-org switches (it's the same
|
// 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.
|
// `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':
|
case 'FieldEdited':
|
||||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||||
case 'MarginEdited':
|
case 'MarginEdited':
|
||||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||||
case 'DraftSaved':
|
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': {
|
case 'Upload': {
|
||||||
if (s.tag !== 'loaded') return s;
|
if (s.tag !== 'Loaded') return s;
|
||||||
const upload = reduceUpload(s.upload, m.msg);
|
const upload = reduceUpload(s.upload, m.msg);
|
||||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||||
if (m.msg.type === 'UploadComplete')
|
if (m.msg.type === 'UploadComplete')
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export class BriefPage {
|
|||||||
Success value is unwrapped here instead of through `let-`. */
|
Success value is unwrapped here instead of through `let-`. */
|
||||||
protected readonly loaded = computed(() => {
|
protected readonly loaded = computed(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s : undefined;
|
return s.tag === 'Loaded' ? s : undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
protected reload() {
|
protected reload() {
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# RD-11 — Fold the lifecycle projection into `remote-data.ts`, and PascalCase the 3 machines
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 1b#3
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`machine-remote-data.ts` is 24 lines defining a **third** encoding of "in flight / ok /
|
||||||
|
failed": `LoadLifecycle = { tag: 'loading' } | { tag: 'failed'; reason } | { tag: 'loaded' }`.
|
||||||
|
It has three call sites, all the identical line, and `LoadLifecycle` is never imported by
|
||||||
|
name anywhere — it is a purely structural constraint.
|
||||||
|
|
||||||
|
That constraint is the **only** reason three machines carry lowercase state tags while their
|
||||||
|
message tags are PascalCase in the same file. `stamdata-editor.machine.spec.ts:61` shows the
|
||||||
|
confusion in one line today:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
|
||||||
|
```
|
||||||
|
|
||||||
|
A PascalCase message producing a lowercase state. Relocate the projection with PascalCase
|
||||||
|
keys and the dialect drift resolves itself — no separate renaming pass, and one named concept
|
||||||
|
disappears.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `libs/shared/src/application/machine-remote-data.ts` — all 24 lines
|
||||||
|
- `libs/shared/src/application/machine-remote-data.spec.ts` — 20 lines, to be merged
|
||||||
|
- `libs/shared/src/application/remote-data.ts` — note `fromResource`, the neighbour and
|
||||||
|
precedent for the new function
|
||||||
|
- `libs/shared/docs/remote-data.mdx:72` — teaches `s.tag === 'loaded'`, so it must change too
|
||||||
|
- `apps/ssp/src/app/brief/domain/brief.ts:68` — **`BriefStatus`. Read this before renaming
|
||||||
|
anything.** See decision 4.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Relocate, do not simply delete.** The mapping has to exist somewhere, because
|
||||||
|
`<app-async>` takes a `RemoteData`. Deleting the module re-inlines a 6-line switch in three
|
||||||
|
stores, recreating the duplication WP-31 removed. Move it into `remote-data.ts` as
|
||||||
|
`fromLoadLifecycle`, beside `fromResource`, where it reads as what it is: **a `RemoteData`
|
||||||
|
constructor, not a sixth encoding.** Keep the `Extract<S, { tag: 'Loaded' }>` Success
|
||||||
|
payload so all three call sites stay one line.
|
||||||
|
|
||||||
|
2. **Key it PascalCase**: `Loading | Failed{reason} | Loaded`. Merge
|
||||||
|
`machine-remote-data.spec.ts` into `remote-data.spec.ts` and delete both old files.
|
||||||
|
|
||||||
|
3. **Rename only the three load-lifecycle tags, and catch all four syntactic forms.** Measured
|
||||||
|
counts of the construction form alone (39 across 11 files) understate it. The forms are:
|
||||||
|
|
||||||
|
| Form | Example |
|
||||||
|
| ------------- | ---------------------------------------------- |
|
||||||
|
| construction | `tag: 'loading'` |
|
||||||
|
| comparison | `s.tag === 'loaded'`, `s.tag !== 'loaded'` |
|
||||||
|
| type-level | `Extract<OrgTemplateState, { tag: 'loaded' }>` |
|
||||||
|
| documentation | `remote-data.mdx:72` |
|
||||||
|
|
||||||
|
Files in scope: the three machines (`brief.machine.ts`, `org-template.machine.ts`,
|
||||||
|
`stamdata-editor.machine.ts`), their three specs, `brief.store.ts`, `brief.store.spec.ts`,
|
||||||
|
`org-template.store.ts`, `stamdata.store.ts`, `brief.page.ts`, and `remote-data.mdx`.
|
||||||
|
|
||||||
|
4. **`BriefStatus` IS NOT IN SCOPE. This is the one way to break this ticket.**
|
||||||
|
`brief.machine.ts` contains **two** independent lowercase tag families:
|
||||||
|
- `BriefState`'s load lifecycle — `loading`/`failed`/`loaded` — **rename these**
|
||||||
|
- `BriefStatus`'s letter status — `draft`/`submitted`/`approved`/`rejected`/`sent`, defined
|
||||||
|
in `brief.ts:68` — **leave these alone**
|
||||||
|
|
||||||
|
`brief.machine.ts:278` has both in one line:
|
||||||
|
`if (s.tag !== 'loaded' || s.brief.status.tag !== from …)`. The first is in scope, the
|
||||||
|
second is not. `BriefStatus` is parsed off the wire from `BriefViewDto`, so renaming its
|
||||||
|
tags breaks the parse boundary and the backend contract. **Never rename by "all lowercase
|
||||||
|
tags in this file".**
|
||||||
|
|
||||||
|
5. **Three more collision sites must not be touched.** They use the same words for unrelated
|
||||||
|
things, and anchoring on `tag: '` already excludes them — but verify rather than assume:
|
||||||
|
- `scenario.ts` / `scenario.interceptor.ts` — `'loading'` is a `?scenario=` **URL param
|
||||||
|
value**, not a state tag
|
||||||
|
- `upload.machine.ts` and the four upload UI components — `UploadStatus` discriminates on
|
||||||
|
**`type:`**, not `tag:`, with `'failed'`/`'complete'`/`'uploading'`
|
||||||
|
- `registratie-lookup.store.ts` — `'loading'` is an Angular `resource()` status
|
||||||
|
|
||||||
|
6. **Do not touch `ActionState`, `SaveState`, or `pendingPublish`.** RD-12, RD-13 and RD-14
|
||||||
|
own those, and they must follow this ticket or the same tags get renamed twice.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
Add to / edit: `libs/shared/src/application/remote-data.ts` (+ `.spec.ts`),
|
||||||
|
`libs/shared/docs/remote-data.mdx`.
|
||||||
|
Delete: `libs/shared/src/application/machine-remote-data.ts` (+ `.spec.ts`).
|
||||||
|
Rename tags in: `brief.machine.ts` (+ spec), `org-template.machine.ts` (+ spec),
|
||||||
|
`stamdata-editor.machine.ts` (+ spec), `brief.store.ts` (+ spec), `org-template.store.ts`,
|
||||||
|
`stamdata.store.ts`, `brief.page.ts`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add `fromLoadLifecycle` to `remote-data.ts` with PascalCase keys (decisions 1-2).
|
||||||
|
2. Rename the load-lifecycle tags across the files in decision 3, one file at a time, letting
|
||||||
|
the type-checker find the next site. **Do not blanket-sed.**
|
||||||
|
3. Point the three stores at `fromLoadLifecycle`; delete `machine-remote-data.ts` and merge
|
||||||
|
its spec cases into `remote-data.spec.ts`.
|
||||||
|
4. Update `remote-data.mdx:72`.
|
||||||
|
5. Run `npm run gen:behaviour-spec` — `behaviour-spec.mdx:839` has a `machineRemoteData`
|
||||||
|
section that must become the new name.
|
||||||
|
6. Update this ticket's `Status:` to `done` and the README's RD-11 row to `done`.
|
||||||
|
7. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
The third encoding is gone and nothing lowercase survives in the three machines:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -n "machineRemoteData\|LoadLifecycle" -- apps libs # MUST return nothing
|
||||||
|
ls libs/shared/src/application/machine-remote-data* # MUST be "No such file"
|
||||||
|
|
||||||
|
M="apps/ssp/src/app/brief/domain apps/ssp/src/app/brief/application \
|
||||||
|
apps/ssp/src/app/brief/ui libs/beheer/src/domain libs/beheer/src/application"
|
||||||
|
git grep -n "tag: 'loading'\|tag: 'failed'\|tag: 'loaded'" -- $M # MUST return nothing
|
||||||
|
git grep -n "tag === 'loaded'\|tag !== 'loaded'" -- $M # MUST return nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
`BriefStatus` is untouched — this is the check that matters most (decision 4):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Measured before this ticket was written: the total is exactly 54. It MUST still be 54.
|
||||||
|
git grep -c "tag: 'draft'\|tag: 'submitted'\|tag: 'approved'\|tag: 'rejected'\|tag: 'sent'" \
|
||||||
|
-- apps/ssp/src/app/brief | awk -F: '{s+=$NF} END {print s}' # MUST print 54
|
||||||
|
|
||||||
|
git diff --stat apps/ssp/src/app/brief/domain/brief.ts # MUST be empty — brief.ts unchanged
|
||||||
|
```
|
||||||
|
|
||||||
|
If that number moves, you have renamed a wire contract. Stop and revert rather than adjusting
|
||||||
|
the number.
|
||||||
|
|
||||||
|
The collision sites are untouched:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only | git grep -c "scenario\|upload" || true # expect no such files
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci`. Also run `npm run ci -- --full` **with an explicit long timeout**: this edits
|
||||||
|
`remote-data.mdx`, which Storybook globs, and a broken MDX import is invisible to plain `ci`.
|
||||||
|
|
||||||
|
Note for whoever runs it: an 8-minute command cannot complete in the default 120s foreground
|
||||||
|
window and the harness will move it to the background. Pass `timeout: 600000` on the Bash
|
||||||
|
call so it runs to completion in the foreground, then commit.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `ActionState` / `SaveState` / `pendingPublish` — RD-12, RD-13, RD-14 (decision 6).
|
||||||
|
- `BriefStatus` (decision 4). If a `BriefStatus` tag changes, the ticket has failed.
|
||||||
|
- `UploadStatus`'s `type:` discriminant — optional RD-35.
|
||||||
|
- The `NO_SUBORGS`/`NO_TABLES`-should-be-`Empty` finding — optional RD-34.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`BriefStatus` (decision 4) is the failure mode to fear.** Its tags are a wire contract.
|
||||||
|
Rename by union, never by file.
|
||||||
|
- **Do not blanket-sed `'loading'`/`'failed'`/`'loaded'`.** Five files legitimately use those
|
||||||
|
words for other purposes (decision 5). Renaming one file at a time and following the
|
||||||
|
type-checker is slower and correct.
|
||||||
|
- **`brief.store.ts` and `brief.page.ts` use only the comparison form**, so a
|
||||||
|
construction-only grep misses them. That is why decision 3 lists four forms.
|
||||||
|
- **`behaviour-spec.mdx` drift**: it has a `machineRemoteData` section heading at :839 which
|
||||||
|
changes with the function name. Run `gen:behaviour-spec` in the same commit.
|
||||||
|
- **`remote-data.ts` carries a `// #region showcase:fold` marker at :30.** If your edit moves
|
||||||
|
or splits that region, run `npm run gen:snippets` in the same commit too.
|
||||||
@@ -105,7 +105,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
|
|||||||
| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done |
|
| RD-08 | Migrate the 3 wizards to the effect map + `Primary` | 07 | yes | done |
|
||||||
| RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done |
|
| RD-09 | Teach the effect map: ARCHITECTURE §2d + fp-tea (2 docs, no generator) | 08 | | done |
|
||||||
| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done |
|
| RD-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | done |
|
||||||
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo |
|
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | done |
|
||||||
| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo |
|
| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo |
|
||||||
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo |
|
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo |
|
||||||
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo |
|
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||||
import { createHistory } from '@shared/application/history';
|
import { createHistory } from '@shared/application/history';
|
||||||
import {
|
import {
|
||||||
ChangeCounts,
|
ChangeCounts,
|
||||||
@@ -20,7 +20,7 @@ import {
|
|||||||
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
|
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
|
||||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||||
|
|
||||||
type LoadedState = Extract<StamdataEditorState, { tag: 'loaded' }>;
|
type LoadedState = Extract<StamdataEditorState, { tag: 'Loaded' }>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
|
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
|
||||||
@@ -42,11 +42,11 @@ 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(() => machineRemoteData(this.model()));
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
|
|
||||||
private loaded = computed<LoadedState | null>(() => {
|
private loaded = computed<LoadedState | null>(() => {
|
||||||
const s = this.model();
|
const s = this.model();
|
||||||
return s.tag === 'loaded' ? s : null;
|
return s.tag === 'Loaded' ? s : null;
|
||||||
});
|
});
|
||||||
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
|
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
|
||||||
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
|
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
|
||||||
|
|||||||
@@ -24,40 +24,40 @@ const seedLoaded = (): StamdataEditorState =>
|
|||||||
describe('stamdata-editor reduce', () => {
|
describe('stamdata-editor reduce', () => {
|
||||||
it('Loaded snapshots original independently of rows', () => {
|
it('Loaded snapshots original independently of rows', () => {
|
||||||
const s = seedLoaded();
|
const s = seedLoaded();
|
||||||
expect(s.tag).toBe('loaded');
|
expect(s.tag).toBe('Loaded');
|
||||||
if (s.tag !== 'loaded') return;
|
if (s.tag !== 'Loaded') return;
|
||||||
const edited = reduce(s, { tag: 'CellEdited', row: 0, column: 'beroep', value: 'Chirurg' });
|
const edited = reduce(s, { tag: 'CellEdited', row: 0, column: 'beroep', value: 'Chirurg' });
|
||||||
if (edited.tag !== 'loaded') return;
|
if (edited.tag !== 'Loaded') return;
|
||||||
expect(edited.rows[0]['beroep']).toBe('Chirurg');
|
expect(edited.rows[0]['beroep']).toBe('Chirurg');
|
||||||
expect(edited.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works
|
expect(edited.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RowAdded appends an empty row shaped by the schema', () => {
|
it('RowAdded appends an empty row shaped by the schema', () => {
|
||||||
const s = reduce(seedLoaded(), { tag: 'RowAdded' });
|
const s = reduce(seedLoaded(), { tag: 'RowAdded' });
|
||||||
if (s.tag !== 'loaded') return;
|
if (s.tag !== 'Loaded') return;
|
||||||
expect(s.rows).toHaveLength(2);
|
expect(s.rows).toHaveLength(2);
|
||||||
expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' });
|
expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('RowRemoved drops the row at the index', () => {
|
it('RowRemoved drops the row at the index', () => {
|
||||||
const s = reduce(reduce(seedLoaded(), { tag: 'RowAdded' }), { tag: 'RowRemoved', row: 0 });
|
const s = reduce(reduce(seedLoaded(), { tag: 'RowAdded' }), { tag: 'RowRemoved', row: 0 });
|
||||||
if (s.tag !== 'loaded') return;
|
if (s.tag !== 'Loaded') return;
|
||||||
expect(s.rows).toHaveLength(1);
|
expect(s.rows).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edit messages are ignored unless loaded', () => {
|
it('edit messages are ignored unless loaded', () => {
|
||||||
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
|
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
|
||||||
expect(
|
expect(
|
||||||
reduce({ tag: 'failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
|
reduce({ tag: 'Failed', reason: 'x' }, { tag: 'CellEdited', row: 0, column: 'a', value: 'b' })
|
||||||
.tag,
|
.tag,
|
||||||
).toBe('failed');
|
).toBe('Failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('LoadFailed and Loading transition regardless of prior state', () => {
|
it('LoadFailed and Loading transition regardless of prior state', () => {
|
||||||
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
|
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
|
||||||
tag: 'failed',
|
tag: 'Failed',
|
||||||
reason: 'boom',
|
reason: 'boom',
|
||||||
});
|
});
|
||||||
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'loading' });
|
expect(reduce(seedLoaded(), { tag: 'Loading' })).toEqual({ tag: 'Loading' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import { StamRow, StamTable, emptyRow } from '@beheer/domain/stamdata';
|
|||||||
* apply path is a reviewed PR, not a runtime write — ADR-0004).
|
* apply path is a reviewed PR, not a runtime write — ADR-0004).
|
||||||
*/
|
*/
|
||||||
export type StamdataEditorState =
|
export type StamdataEditorState =
|
||||||
| { tag: 'loading' }
|
| { tag: 'Loading' }
|
||||||
| { tag: 'failed'; reason: string }
|
| { tag: 'Failed'; reason: string }
|
||||||
| { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
|
| { tag: 'Loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
|
||||||
|
|
||||||
export type StamdataEditorMsg =
|
export type StamdataEditorMsg =
|
||||||
| { tag: 'Loading' }
|
| { tag: 'Loading' }
|
||||||
@@ -24,29 +24,29 @@ export type StamdataEditorMsg =
|
|||||||
| { tag: 'RowRemoved'; row: number }
|
| { tag: 'RowRemoved'; row: number }
|
||||||
| { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests)
|
| { tag: 'Seed'; state: StamdataEditorState }; // mount a specific state (stories/tests)
|
||||||
|
|
||||||
export const initial: StamdataEditorState = { tag: 'loading' };
|
export const initial: StamdataEditorState = { tag: 'Loading' };
|
||||||
|
|
||||||
const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r }));
|
const copy = (rows: readonly StamRow[]): StamRow[] => rows.map((r) => ({ ...r }));
|
||||||
|
|
||||||
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
|
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
|
||||||
switch (m.tag) {
|
switch (m.tag) {
|
||||||
case 'Loading':
|
case 'Loading':
|
||||||
return { tag: 'loading' };
|
return { tag: 'Loading' };
|
||||||
case 'Loaded':
|
case 'Loaded':
|
||||||
// original is an independent snapshot so later edits never mutate it (drives the diff).
|
// original is an independent snapshot so later edits never mutate it (drives the diff).
|
||||||
return { tag: 'loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
|
return { tag: 'Loaded', table: m.table, rows: copy(m.rows), original: copy(m.rows) };
|
||||||
case 'LoadFailed':
|
case 'LoadFailed':
|
||||||
return { tag: 'failed', reason: m.reason };
|
return { tag: 'Failed', reason: m.reason };
|
||||||
case 'CellEdited':
|
case 'CellEdited':
|
||||||
if (s.tag !== 'loaded') return s;
|
if (s.tag !== 'Loaded') return s;
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
|
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
|
||||||
};
|
};
|
||||||
case 'RowAdded':
|
case 'RowAdded':
|
||||||
return s.tag === 'loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
|
return s.tag === 'Loaded' ? { ...s, rows: [...s.rows, emptyRow(s.table)] } : s;
|
||||||
case 'RowRemoved':
|
case 'RowRemoved':
|
||||||
return s.tag === 'loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
|
return s.tag === 'Loaded' ? { ...s, rows: s.rows.filter((_, i) => i !== m.row) } : s;
|
||||||
case 'Seed':
|
case 'Seed':
|
||||||
return m.state;
|
return m.state;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -819,6 +819,12 @@ classes.
|
|||||||
- is empty-safe: undefined, null, and empty string all yield the empty string
|
- is empty-safe: undefined, null, and empty string all yield the empty string
|
||||||
- returns empty for an unparseable string rather than "Invalid Date"
|
- returns empty for an unparseable string rather than "Invalid Date"
|
||||||
|
|
||||||
|
#### fromLoadLifecycle
|
||||||
|
|
||||||
|
- maps Loading → Loading
|
||||||
|
- maps Failed → Failure carrying an Error with the reason
|
||||||
|
- maps Loaded → Success carrying the whole loaded state
|
||||||
|
|
||||||
#### httpClientFetch
|
#### httpClientFetch
|
||||||
|
|
||||||
- sends the pending idempotency key as a header for a write, not a fresh one per attempt
|
- sends the pending idempotency key as a header for a write, not a fresh one per attempt
|
||||||
@@ -836,12 +842,6 @@ classes.
|
|||||||
- keeps query + hash on both targets
|
- keeps query + hash on both targets
|
||||||
- the root maps nl → / and en → /en/
|
- the root maps nl → / and en → /en/
|
||||||
|
|
||||||
#### machineRemoteData
|
|
||||||
|
|
||||||
- maps loading → Loading
|
|
||||||
- maps failed → Failure carrying an Error with the reason
|
|
||||||
- maps loaded → Success carrying the whole loaded state
|
|
||||||
|
|
||||||
#### parseBsn (elfproef)
|
#### parseBsn (elfproef)
|
||||||
|
|
||||||
- accepts a valid BSN (passes the elfproef)
|
- accepts a valid BSN (passes the elfproef)
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ The idiom this repo uses instead — see `brief.page.ts`, `dashboard.page.ts`,
|
|||||||
// in the component class
|
// in the component class
|
||||||
protected readonly loaded = computed(() => {
|
protected readonly loaded = computed(() => {
|
||||||
const s = this.model(); // or store.someRemoteData()
|
const s = this.model(); // or store.someRemoteData()
|
||||||
return s.tag === 'loaded' ? s : undefined;
|
return s.tag === 'Loaded' ? s : undefined;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -94,8 +94,8 @@ timing/outcome of `/api/*` calls. Try it on `/brief` or `/dashboard`.
|
|||||||
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
|
A store's own state machine (its `*.machine.ts`) should own the **domain** lifecycle of
|
||||||
what it holds (draft → submitted → approved, in the brief's case) — not the network
|
what it holds (draft → submitted → approved, in the brief's case) — not the network
|
||||||
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
|
fetch's loading/failure, which is a generic concern `RemoteData` already models. Where a
|
||||||
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
|
machine's own `Loading`/`Failed`/`Loaded` tags purely mirror the fetch (nothing extra
|
||||||
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
|
beyond "not loaded yet" / "the GET failed"), project them with `fromLoadLifecycle` at the
|
||||||
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
|
store layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
|
||||||
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
|
keeps deciding what the _letter_ is doing, `RemoteData` keeps deciding what the _fetch_ is
|
||||||
doing.
|
doing.
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import { machineRemoteData } from './machine-remote-data';
|
|
||||||
import { loading, success } from '../testing/remote-data';
|
|
||||||
|
|
||||||
describe('machineRemoteData', () => {
|
|
||||||
it('maps loading → Loading', () => {
|
|
||||||
expect(machineRemoteData({ tag: 'loading' })).toEqual(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(success(loaded));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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' }> };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { RemoteData, map2, map, successOf } from './remote-data';
|
import { RemoteData, fromLoadLifecycle, map2, map, successOf } from './remote-data';
|
||||||
import { loading, failure, empty, success } from '../testing/remote-data';
|
import { loading, failure, empty, success } from '../testing/remote-data';
|
||||||
|
|
||||||
const loadingRd: RemoteData<string, number> = loading();
|
const loadingRd: RemoteData<string, number> = loading();
|
||||||
@@ -33,3 +33,20 @@ describe('successOf', () => {
|
|||||||
expect(successOf(empty())).toBeUndefined();
|
expect(successOf(empty())).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('fromLoadLifecycle', () => {
|
||||||
|
it('maps Loading → Loading', () => {
|
||||||
|
expect(fromLoadLifecycle({ tag: 'Loading' })).toEqual(loading());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Failed → Failure carrying an Error with the reason', () => {
|
||||||
|
const rd = fromLoadLifecycle({ 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 loadedState = { tag: 'Loaded', foo: 42 } as const;
|
||||||
|
expect(fromLoadLifecycle(loadedState)).toEqual(success(loadedState));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,6 +27,26 @@ export function fromResource<T>(
|
|||||||
return { tag: 'Loading' };
|
return { tag: 'Loading' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project an Elm-machine's load lifecycle 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). A `RemoteData` constructor, not a sixth
|
||||||
|
* encoding — wrap the call in a `computed`.
|
||||||
|
*/
|
||||||
|
export function fromLoadLifecycle<
|
||||||
|
S extends { tag: 'Loading' } | { tag: 'Failed'; reason: string } | { tag: 'Loaded' },
|
||||||
|
>(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' }> };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// #region showcase:fold
|
// #region showcase:fold
|
||||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||||
export function foldRemote<E, T, R>(
|
export function foldRemote<E, T, R>(
|
||||||
|
|||||||
Reference in New Issue
Block a user