Compare commits
10
Commits
84cbf3f7d8
...
c36d9e3ff0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c36d9e3ff0 | ||
|
|
fb7b531fdf | ||
|
|
c45d1bc0ff | ||
|
|
a8c7a573fc | ||
|
|
c599fee8e2 | ||
|
|
8e5f48c5d2 | ||
|
|
02d41536df | ||
|
|
43f62ddfee | ||
|
|
827c655c1b | ||
|
|
11664d2efa |
@@ -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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||||
import { Result } from '@shared/kernel/fp';
|
import { Result } from '@shared/kernel/fp';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
|
||||||
import { createHistory } from '@shared/application/history';
|
import { createHistory } from '@shared/application/history';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
import { SaveState, 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 +28,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 {
|
||||||
@@ -41,11 +40,16 @@ export class BriefStore implements PendingSave {
|
|||||||
|
|
||||||
readonly model = this.store.model;
|
readonly model = this.store.model;
|
||||||
|
|
||||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
|
||||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
these stay as plain `computed`s so the render seam (four `busy = input(...)`
|
||||||
|
components, two page templates) keeps a byte-identical boolean/string API. */
|
||||||
|
readonly busy = computed(() => {
|
||||||
|
const s = this.model();
|
||||||
|
return s.tag === 'Loaded' && s.action.tag === 'Busy';
|
||||||
|
});
|
||||||
readonly lastError = computed(() => {
|
readonly lastError = computed(() => {
|
||||||
const s = this.actionState();
|
const s = this.model();
|
||||||
return s.tag === 'Failed' ? s.error : null;
|
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||||
@@ -95,11 +99,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 +115,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 +186,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 } });
|
||||||
@@ -212,7 +216,9 @@ export class BriefStore implements PendingSave {
|
|||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.saveState.set({ tag: 'Saved' });
|
this.saveState.set({ tag: 'Saved' });
|
||||||
} else {
|
} else {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
// The autosave failure legitimately surfaces in two places: the small save
|
||||||
|
// indicator below (kept as-is) and the action error line (RD-12).
|
||||||
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
this.saveState.set({ tag: 'Error' });
|
this.saveState.set({ tag: 'Error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,19 +230,19 @@ export class BriefStore implements PendingSave {
|
|||||||
|
|
||||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||||
async resetDemo() {
|
async resetDemo() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
const r = await this.adapter.reset();
|
const r = await this.adapter.reset();
|
||||||
this.saveState.set({ tag: 'Idle' });
|
this.saveState.set({ tag: 'Idle' });
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.orgTemplate.set(r.value.orgTemplate);
|
this.orgTemplate.set(r.value.orgTemplate);
|
||||||
this.caseContext.set(r.value.caseContext);
|
this.caseContext.set(r.value.caseContext);
|
||||||
this.history.clear();
|
this.history.clear();
|
||||||
this.rejectionSnapshot.set(null);
|
this.rejectionSnapshot.set(null);
|
||||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||||
} else {
|
} else {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,13 +255,13 @@ export class BriefStore implements PendingSave {
|
|||||||
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
letter in a new tab via `BLOB_PRESENTER.open` — see its doc comment for why the
|
||||||
object URL is never revoked. */
|
object URL is never revoked. */
|
||||||
async previewLetter() {
|
async previewLetter() {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
const r = await this.previewAdapter.preview();
|
const r = await this.previewAdapter.preview();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.blobPresenter.open(r.value);
|
this.blobPresenter.open(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,7 +275,8 @@ export class BriefStore implements PendingSave {
|
|||||||
async revealBigNummer() {
|
async revealBigNummer() {
|
||||||
const r = await this.revealAdapter.reveal(true);
|
const r = await this.revealAdapter.reveal(true);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
// Never sets Busy — an existing asymmetry (RD-12), not fixed here.
|
||||||
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
this.caseContext.update((c) => (c ? { ...c, bigNummer: r.value } : c));
|
||||||
@@ -278,15 +285,15 @@ export class BriefStore implements PendingSave {
|
|||||||
// A transition: flush any pending save, call the server (authoritative), then mirror
|
// A transition: flush any pending save, call the server (authoritative), then mirror
|
||||||
// the returned status through the pure reducer's guarded transition.
|
// the returned status through the pure reducer's guarded transition.
|
||||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave();
|
await this.flushSave();
|
||||||
const r = await action();
|
const r = await action();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.applyServerStatus(r.value);
|
this.applyServerStatus(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
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 { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||||
import { machineRemoteData } from '@shared/application/machine-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';
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
SubOrgSummary,
|
SubOrgSummary,
|
||||||
} from '@brief/domain/org-template';
|
} from '@brief/domain/org-template';
|
||||||
import {
|
import {
|
||||||
|
OrgTemplateActionState,
|
||||||
OrgTemplateMsg,
|
OrgTemplateMsg,
|
||||||
OrgTemplateState,
|
OrgTemplateState,
|
||||||
initial,
|
initial,
|
||||||
@@ -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.`;
|
||||||
@@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||||
readonly selectedSubOrgId = signal<string | null>(null);
|
readonly selectedSubOrgId = signal<string | null>(null);
|
||||||
|
|
||||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
/** The one-shot action lifecycle and the publish impact-confirm gate now live on
|
||||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
the machine's `Loaded.action` as one four-variant union (RD-13); these stay as
|
||||||
|
plain `computed`s so the render seam (the editor organism's `input()`s, the
|
||||||
|
page template) keeps a byte-identical boolean/string API. */
|
||||||
|
private action = computed<OrgTemplateActionState>(() => this.loaded()?.action ?? { tag: 'Idle' });
|
||||||
|
readonly busy = computed(() => this.action().tag === 'Busy');
|
||||||
readonly lastError = computed(() => {
|
readonly lastError = computed(() => {
|
||||||
const s = this.actionState();
|
const a = this.action();
|
||||||
return s.tag === 'Failed' ? s.error : null;
|
return a.tag === 'Failed' ? a.error : null;
|
||||||
});
|
});
|
||||||
|
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST).
|
||||||
|
Before RD-13 this was an independent boolean, so it could be `true` at the same
|
||||||
|
time `busy` was `true` — representable and meaningless. It is now derived from
|
||||||
|
the same union `busy` reads, so the two are mutually exclusive by construction. */
|
||||||
|
readonly pendingPublish = computed(() => this.action().tag === 'ConfirmingPublish');
|
||||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||||
|
|
||||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||||
readonly pendingPublish = signal(false);
|
|
||||||
|
|
||||||
readonly remoteData = computed(() => machineRemoteData(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 +107,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({
|
||||||
@@ -165,60 +171,64 @@ export class OrgTemplateStore implements PendingSave {
|
|||||||
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||||
} else {
|
} else {
|
||||||
this.saveState.set({ tag: 'Error' });
|
this.saveState.set({ tag: 'Error' });
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- publish (impact-confirm) / rollback / proefbrief ---
|
// --- publish (impact-confirm) / rollback / proefbrief ---
|
||||||
|
|
||||||
|
// RD-13: `requestPublish`/`cancelPublish` are the only two commands here that do
|
||||||
|
// NOT guard on `loaded()` — as dispatches they no-op outside `Loaded` by
|
||||||
|
// construction (the reducer's own guard), so behaviour is unchanged.
|
||||||
requestPublish() {
|
requestPublish() {
|
||||||
this.pendingPublish.set(true);
|
this.store.dispatch({ tag: 'PublishRequested' });
|
||||||
}
|
}
|
||||||
cancelPublish() {
|
cancelPublish() {
|
||||||
this.pendingPublish.set(false);
|
this.store.dispatch({ tag: 'PublishCancelled' });
|
||||||
}
|
}
|
||||||
async confirmPublish() {
|
async confirmPublish() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.pendingPublish.set(false);
|
// ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
|
||||||
this.actionState.set({ tag: 'Busy' });
|
// Busy are never simultaneously true (RD-13).
|
||||||
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||||
const r = await this.adapter.publish(s.subOrgId);
|
const r = await this.adapter.publish(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
async rollback(version: number) {
|
async rollback(version: number) {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||||
}
|
}
|
||||||
|
|
||||||
async proefbrief() {
|
async proefbrief() {
|
||||||
const s = this.loaded();
|
const s = this.loaded();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
this.actionState.set({ tag: 'Busy' });
|
this.store.dispatch({ tag: 'ActionStarted' });
|
||||||
this.debouncedSave.cancel();
|
this.debouncedSave.cancel();
|
||||||
await this.flushSave(); // the proefbrief renders the server's draft
|
await this.flushSave(); // the proefbrief renders the server's draft
|
||||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.actionState.set({ tag: 'Idle' });
|
this.store.dispatch({ tag: 'ActionFinished' });
|
||||||
this.blobPresenter.open(r.value);
|
this.blobPresenter.open(r.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 @@ 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' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- RD-12: the action lifecycle lives on `Loaded.action`, driven by three msgs ---
|
||||||
|
|
||||||
|
it('ActionStarted moves a loaded brief to Busy', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Busy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFailed carries the error', () => {
|
||||||
|
const s = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Failed', error: 'niet gelukt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFinished returns to Idle', () => {
|
||||||
|
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
const s = reduce(busy, { tag: 'ActionFinished' });
|
||||||
|
expect(s.tag === 'Loaded' && s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('BriefLoaded resets a stale action error to Idle', () => {
|
||||||
|
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'niet gelukt' });
|
||||||
|
const reloaded = reduce(failed, {
|
||||||
|
tag: 'BriefLoaded',
|
||||||
|
brief: briefWith({ tag: 'draft' }),
|
||||||
|
availablePassages: lib,
|
||||||
|
decisions,
|
||||||
|
});
|
||||||
|
expect(reloaded.tag === 'Loaded' && reloaded.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an action message is a no-op when the brief is not loaded', () => {
|
||||||
|
expect(reduce(initialLoading(), { tag: 'ActionStarted' })).toEqual(initialLoading());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('a status transition replaces decisions with the fresh server value', () => {
|
it('a status transition replaces decisions with the fresh server value', () => {
|
||||||
@@ -280,10 +313,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' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
|
|||||||
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
* structurally impossible (a pasted `{{…}}` is caught by the linter as `malformed`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** The one-shot action lifecycle (submit/approve/reject/send/preview/reveal/reset),
|
||||||
|
owned by the reducer instead of an imperative store-level signal (RD-12). */
|
||||||
|
export type BriefActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
||||||
|
|
||||||
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;
|
||||||
|
action: BriefActionState;
|
||||||
}
|
}
|
||||||
| { 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 =
|
||||||
| {
|
| {
|
||||||
@@ -65,7 +70,10 @@ export type BriefMsg =
|
|||||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||||
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
| { tag: 'Sent'; at: string; decisions: BriefDecisions } // approved → sent
|
||||||
| { tag: 'Seed'; state: BriefState };
|
| { tag: 'Seed'; state: BriefState }
|
||||||
|
| { tag: 'ActionStarted' } // a one-shot action (submit/approve/preview/…) began
|
||||||
|
| { tag: 'ActionFinished' } // it completed successfully
|
||||||
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||||
|
|
||||||
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
/** Edits are allowed only in these statuses; editing a rejected letter reopens it. */
|
||||||
function isEditable(status: BriefStatus): boolean {
|
function isEditable(status: BriefStatus): boolean {
|
||||||
@@ -110,7 +118,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 +197,16 @@ 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,
|
||||||
|
// A fresh load clears a stale action error rather than letting it outlive
|
||||||
|
// the reload (RD-12, decision 4).
|
||||||
|
action: { tag: 'Idle' },
|
||||||
};
|
};
|
||||||
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 +214,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,
|
||||||
);
|
);
|
||||||
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
|||||||
case 'Sent':
|
case 'Sent':
|
||||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }), m.decisions);
|
||||||
|
|
||||||
|
// The action lifecycle (RD-12): a no-op unless a brief is loaded, since there is
|
||||||
|
// nothing to attach the action state to otherwise.
|
||||||
|
case 'ActionStarted':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||||
|
case 'ActionFinished':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionFailed':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return assertNever(m);
|
return assertNever(m);
|
||||||
}
|
}
|
||||||
@@ -275,6 +295,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,10 +154,50 @@ 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);
|
||||||
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
expect(switched.subOrgId).toBe('cibg-vakbekwaamheid');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- the action lifecycle + publish impact-confirm gate, folded into one union (RD-13) ---
|
||||||
|
|
||||||
|
it('PublishRequested moves a loaded template to ConfirmingPublish', () => {
|
||||||
|
const s = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'ConfirmingPublish' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PublishCancelled returns to Idle', () => {
|
||||||
|
const confirming = reduce(loaded(), { tag: 'PublishRequested' });
|
||||||
|
const s = expectTag(reduce(confirming, { tag: 'PublishCancelled' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist', () => {
|
||||||
|
const confirming = expectTag(reduce(loaded(), { tag: 'PublishRequested' }), 'Loaded');
|
||||||
|
expect(confirming.action.tag).toBe('ConfirmingPublish');
|
||||||
|
const s = expectTag(reduce(confirming, { tag: 'ActionStarted' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Busy' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ActionFailed carries the error', () => {
|
||||||
|
const busy = reduce(loaded(), { tag: 'ActionStarted' });
|
||||||
|
const s = expectTag(reduce(busy, { tag: 'ActionFailed', error: 'mislukt' }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Failed', error: 'mislukt' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DraftLoaded resets a stale action error to Idle', () => {
|
||||||
|
const failed = reduce(loaded(), { tag: 'ActionFailed', error: 'mislukt' });
|
||||||
|
const s = expectTag(reduce(failed, { tag: 'DraftLoaded', view: view() }), 'Loaded');
|
||||||
|
expect(s.action).toEqual({ tag: 'Idle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('an action message is a no-op when the template is not loaded', () => {
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'PublishRequested' })).toEqual({ tag: 'Loading' });
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionStarted' })).toEqual({ tag: 'Loading' });
|
||||||
|
expect(reduce({ tag: 'Loading' }, { tag: 'ActionFailed', error: 'x' })).toEqual({
|
||||||
|
tag: 'Loading',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ import { UploadMsg, UploadState, initialUpload, reduceUpload } from '@shared/dom
|
|||||||
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
* `dirty` tracks unsaved edits (the store debounce-saves them). The logo upload is
|
||||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||||
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
* `reduceUpload` — its `UploadComplete`/`UploadRemoved` also mutate `draft.logoDocumentId`.
|
||||||
|
*
|
||||||
|
* `action` (RD-13) owns the one-shot action lifecycle AND the publish impact-confirm
|
||||||
|
* gate as ONE four-variant union, replacing two independent store-level signals
|
||||||
|
* (`actionState` + `pendingPublish`). Before RD-13, `pendingPublish === true && busy
|
||||||
|
* === true` was representable and meaningless — the confirm dialog could show while a
|
||||||
|
* publish was already in flight. A single field with one tag at a time makes that
|
||||||
|
* combination unrepresentable.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** The org-identity text fields editable directly on the letter canvas. */
|
/** The org-identity text fields editable directly on the letter canvas. */
|
||||||
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
|
|||||||
| 'signatureRole'
|
| 'signatureRole'
|
||||||
| 'signatureClosing';
|
| 'signatureClosing';
|
||||||
|
|
||||||
|
/** The one-shot action lifecycle (publish/rollback/proefbrief), plus the publish
|
||||||
|
impact-confirm gate, owned by the reducer instead of two independent store-level
|
||||||
|
signals (RD-13). `ConfirmingPublish` is a variant of this SAME union, so
|
||||||
|
"confirming a publish while one is already in flight" is unrepresentable — no
|
||||||
|
state can ever carry both at once. */
|
||||||
|
export type OrgTemplateActionState =
|
||||||
|
| { tag: 'Idle' }
|
||||||
|
| { tag: 'ConfirmingPublish' }
|
||||||
|
| { tag: 'Busy' }
|
||||||
|
| { tag: 'Failed'; error: string };
|
||||||
|
|
||||||
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;
|
||||||
@@ -34,9 +52,10 @@ export type OrgTemplateState =
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||||
upload: UploadState;
|
upload: UploadState;
|
||||||
|
action: OrgTemplateActionState;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
export const initial: OrgTemplateState = { tag: 'Loading' };
|
||||||
|
|
||||||
export type OrgTemplateMsg =
|
export type OrgTemplateMsg =
|
||||||
| { tag: 'Loading' }
|
| { tag: 'Loading' }
|
||||||
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
|
|||||||
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
/** Carries the draft that was saved: clears `dirty` only if no edit landed during
|
||||||
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
the round-trip (reference-equal), so a concurrent edit keeps its pending save. */
|
||||||
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
| { tag: 'DraftSaved'; savedDraft: OrgTemplate }
|
||||||
| { tag: 'Upload'; msg: UploadMsg };
|
| { tag: 'Upload'; msg: UploadMsg }
|
||||||
|
| { tag: 'PublishRequested' } // opens the publish impact-confirm gate
|
||||||
|
| { tag: 'PublishCancelled' } // closes it without publishing
|
||||||
|
| { tag: 'ActionStarted' } // a one-shot action (publish/rollback/proefbrief) began
|
||||||
|
| { tag: 'ActionFinished' } // it completed successfully
|
||||||
|
| { tag: 'ActionFailed'; error: string }; // it failed, carrying the message to show
|
||||||
|
|
||||||
/** 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 +95,19 @@ 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,
|
||||||
|
// A fresh load clears a stale action error rather than letting it outlive
|
||||||
|
// the reload (RD-13, same as brief's RD-12).
|
||||||
|
action: { tag: 'Idle' },
|
||||||
};
|
};
|
||||||
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')
|
||||||
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
|||||||
}
|
}
|
||||||
return { ...s, upload };
|
return { ...s, upload };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The action lifecycle (RD-13): a no-op unless a template is loaded, since there
|
||||||
|
// is nothing to attach the action state to otherwise. `ConfirmingPublish` and
|
||||||
|
// `Busy` are variants of one field, so ActionStarted overwriting it to `Busy` is
|
||||||
|
// what makes the two mutually exclusive by construction — not by convention.
|
||||||
|
case 'PublishRequested':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'ConfirmingPublish' } } : s;
|
||||||
|
case 'PublishCancelled':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionStarted':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Busy' } } : s;
|
||||||
|
case 'ActionFinished':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Idle' } } : s;
|
||||||
|
case 'ActionFailed':
|
||||||
|
return s.tag === 'Loaded' ? { ...s, action: { tag: 'Failed', error: m.error } } : s;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return assertNever(m);
|
return assertNever(m);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+14
-13
@@ -6,7 +6,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
|
|||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
WizardStatus,
|
WizardPhase,
|
||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||||
@@ -50,11 +50,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
i18n-processName="@@herregWizard.processName"
|
i18n-processName="@@herregWizard.processName"
|
||||||
processName="Herregistratie aanvragen"
|
processName="Herregistratie aanvragen"
|
||||||
[status]="shellStatus()"
|
[phase]="phase()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="step() > 1"
|
[canGoBack]="step() > 1"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
|
||||||
(primary)="dispatch({ tag: 'Primary' })"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
@@ -214,7 +213,6 @@ export class HerregistratieWizardComponent {
|
|||||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||||
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
|
||||||
protected uploadCtl = createUploadController({
|
protected uploadCtl = createUploadController({
|
||||||
wizardId: 'herregistratie',
|
wizardId: 'herregistratie',
|
||||||
getUpload: () => this.upload(),
|
getUpload: () => this.upload(),
|
||||||
@@ -234,19 +232,22 @@ export class HerregistratieWizardComponent {
|
|||||||
protected goToStep(index: number) {
|
protected goToStep(index: number) {
|
||||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||||
}
|
}
|
||||||
protected errorMessage = computed(
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
);
|
protected phase = computed<WizardPhase>(() => {
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
const s = this.state();
|
||||||
switch (this.state().tag) {
|
switch (s.tag) {
|
||||||
case 'Editing':
|
case 'Editing':
|
||||||
return 'editing';
|
return { tag: 'Editing' };
|
||||||
case 'Submitting':
|
case 'Submitting':
|
||||||
return 'submitting';
|
return { tag: 'Submitting' };
|
||||||
case 'Submitted':
|
case 'Submitted':
|
||||||
return 'submitted';
|
return { tag: 'Submitted' };
|
||||||
case 'Failed':
|
case 'Failed':
|
||||||
return 'failed';
|
return {
|
||||||
|
tag: 'Failed',
|
||||||
|
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/** Current step's field errors, flattened for the shell's error summary. */
|
/** Current step's field errors, flattened for the shell's error summary. */
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp
|
|||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
WizardStatus,
|
WizardPhase,
|
||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { createStore } from '@shared/application/store';
|
import { createStore } from '@shared/application/store';
|
||||||
@@ -59,11 +59,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
i18n-processName="@@intake.processName"
|
i18n-processName="@@intake.processName"
|
||||||
processName="Herregistratie-intake"
|
processName="Herregistratie-intake"
|
||||||
[status]="shellStatus()"
|
[phase]="phase()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="cursor() > 0"
|
[canGoBack]="cursor() > 0"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
|
||||||
(primary)="dispatch({ tag: 'Primary' })"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
(back)="dispatch({ tag: 'Back' })"
|
(back)="dispatch({ tag: 'Back' })"
|
||||||
(cancel)="restart()"
|
(cancel)="restart()"
|
||||||
@@ -323,7 +322,6 @@ export class IntakeWizardComponent {
|
|||||||
);
|
);
|
||||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
readonly stepLabels = [
|
readonly stepLabels = [
|
||||||
@@ -342,19 +340,22 @@ export class IntakeWizardComponent {
|
|||||||
const next = this.cursor() + 1;
|
const next = this.cursor() + 1;
|
||||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||||
});
|
});
|
||||||
protected errorMessage = computed(
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
);
|
protected phase = computed<WizardPhase>(() => {
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
const s = this.state();
|
||||||
switch (this.state().tag) {
|
switch (s.tag) {
|
||||||
case 'Answering':
|
case 'Answering':
|
||||||
return 'editing';
|
return { tag: 'Editing' };
|
||||||
case 'Submitting':
|
case 'Submitting':
|
||||||
return 'submitting';
|
return { tag: 'Submitting' };
|
||||||
case 'Submitted':
|
case 'Submitted':
|
||||||
return 'submitted';
|
return { tag: 'Submitted' };
|
||||||
case 'Failed':
|
case 'Failed':
|
||||||
return 'failed';
|
return {
|
||||||
|
tag: 'Failed',
|
||||||
|
message: $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${s.error}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/** Current step's field errors, flattened for the shell's error summary. The
|
/** Current step's field errors, flattened for the shell's error summary. The
|
||||||
|
|||||||
+15
-15
@@ -14,7 +14,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp
|
|||||||
import {
|
import {
|
||||||
WizardShellComponent,
|
WizardShellComponent,
|
||||||
WizardError,
|
WizardError,
|
||||||
WizardStatus,
|
WizardPhase,
|
||||||
naarStapLabel,
|
naarStapLabel,
|
||||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||||
import { ASYNC } from '@shared/ui/async/async.component';
|
import { ASYNC } from '@shared/ui/async/async.component';
|
||||||
@@ -83,11 +83,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
|||||||
[stepTitle]="stepTitle()"
|
[stepTitle]="stepTitle()"
|
||||||
i18n-processName="@@regWizard.processName"
|
i18n-processName="@@regWizard.processName"
|
||||||
processName="Inschrijven in het BIG-register"
|
processName="Inschrijven in het BIG-register"
|
||||||
[status]="shellStatus()"
|
[phase]="phase()"
|
||||||
[primaryLabel]="primaryLabel()"
|
[primaryLabel]="primaryLabel()"
|
||||||
[canGoBack]="cursor() > 0"
|
[canGoBack]="cursor() > 0"
|
||||||
[errors]="errorList()"
|
[errors]="errorList()"
|
||||||
[errorMessage]="errorMessage()"
|
|
||||||
i18n-submittingLabel="@@regWizard.submitting"
|
i18n-submittingLabel="@@regWizard.submitting"
|
||||||
submittingLabel="Uw registratie wordt verwerkt…"
|
submittingLabel="Uw registratie wordt verwerkt…"
|
||||||
(primary)="dispatch({ tag: 'Primary' })"
|
(primary)="dispatch({ tag: 'Primary' })"
|
||||||
@@ -440,7 +439,6 @@ export class RegistratieWizardComponent {
|
|||||||
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
|
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
|
||||||
);
|
);
|
||||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
|
|
||||||
|
|
||||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||||
protected primaryLabel = computed(() => {
|
protected primaryLabel = computed(() => {
|
||||||
@@ -448,21 +446,23 @@ export class RegistratieWizardComponent {
|
|||||||
const next = this.cursor() + 1;
|
const next = this.cursor() + 1;
|
||||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||||
});
|
});
|
||||||
protected errorMessage = computed(
|
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||||
() =>
|
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||||
$localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` +
|
protected phase = computed<WizardPhase>(() => {
|
||||||
` ${this.failedError()}`,
|
const s = this.state();
|
||||||
);
|
switch (s.tag) {
|
||||||
protected shellStatus = computed<WizardStatus>(() => {
|
|
||||||
switch (this.state().tag) {
|
|
||||||
case 'Invullen':
|
case 'Invullen':
|
||||||
return 'editing';
|
return { tag: 'Editing' };
|
||||||
case 'Indienen':
|
case 'Indienen':
|
||||||
return 'submitting';
|
return { tag: 'Submitting' };
|
||||||
case 'Ingediend':
|
case 'Ingediend':
|
||||||
return 'submitted';
|
return { tag: 'Submitted' };
|
||||||
case 'Mislukt':
|
case 'Mislukt':
|
||||||
return 'failed';
|
return {
|
||||||
|
tag: 'Failed',
|
||||||
|
message:
|
||||||
|
$localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` + ` ${s.error}`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
/** Current step's errors (incl. per-question), flattened for the error summary. */
|
/** Current step's errors (incl. per-question), flattened for the error summary. */
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ ticket is picked. The phase sections below this table are the source for each ti
|
|||||||
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | 1b#2a,#6 | |
|
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | 1b#2a,#6 | |
|
||||||
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | 1b#2b | |
|
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | 1b#2b | |
|
||||||
| RD-15 | Delete `.claude/worktrees/` (22 checkouts, 4.7 GB) | 01 | 2.1 | |
|
| RD-15 | Delete `.claude/worktrees/` (22 checkouts, 4.7 GB) | 01 | 2.1 | |
|
||||||
| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | 2.2 | |
|
| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ DROPPED — would discard decisions | 01 | 2.2 | |
|
||||||
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | 2.3 | |
|
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | 2.3 | |
|
||||||
| RD-18 | Ticket sweep, frontend — 181 refs / 100 files | 01 | 2.4 | |
|
| RD-18 | Ticket sweep, frontend — 181 refs / 100 files | 01 | 2.4 | |
|
||||||
| RD-19 | Ticket sweep, backend — 370 refs / 86 files | 01 | 2.4 | |
|
| RD-19 | Ticket sweep, backend — 370 refs / 86 files | 01 | 2.4 | |
|
||||||
@@ -496,12 +496,38 @@ fix both bugs. If the budget shrinks, stop after A5; B3 and B2 are hygiene, not
|
|||||||
`637d500` merged the whole RB-01..RB-33 arc. **RD-15 must re-verify all 22 before removing
|
`637d500` merged the whole RB-01..RB-33 arc. **RD-15 must re-verify all 22 before removing
|
||||||
any** — check every branch tip is an ancestor of `main`, and stop if one is not.
|
any** — check every branch tip is an ancestor of `main`, and stop if one is not.
|
||||||
|
|
||||||
2. **Finish Step 2's name collapse** (committed as done, but did not land):
|
2. ~~**Finish Step 2's name collapse.**~~ **DROPPED while executing RD-16 — the instruction
|
||||||
`parseDashboardView` still returns `DashboardView`
|
was wrong, and following it would have introduced a bug.**
|
||||||
(`registratie/infrastructure/dashboard-view.adapter.ts:119`), and
|
|
||||||
`big-profile.store.ts` pays twice — line 35 computes `RemoteData<Err, DashboardView>`,
|
This plan claimed `DashboardViewDto → DashboardView → BigProfile` was "three names for one
|
||||||
line 45 re-maps to `RemoteData<Err, BigProfile>`. Make the parse return `BigProfile`
|
payload" and that `parseDashboardView` should return `BigProfile` directly. Reading the
|
||||||
directly and delete the intermediate. One payload, one name.
|
type disproves it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface DashboardView {
|
||||||
|
profile: BigProfile;
|
||||||
|
decisions: HerregistratieDecisions;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`DashboardView` is a **pair**, and `BigProfile` is `{ registration, person }` — one
|
||||||
|
_member_ of that pair, with nowhere to put `decisions`. Returning `BigProfile` directly
|
||||||
|
would silently discard the server-computed herregistratie eligibility, which is exactly
|
||||||
|
what ADR-0001 says the front end must render rather than recompute.
|
||||||
|
|
||||||
|
The store's two `map` calls are not a redundant hop either: they project one aggregate into
|
||||||
|
two independently-consumed signals, and six files consume them separately — for example
|
||||||
|
`mijn-registratie.section.ts` takes `profile` while `wat-moet-ik-regelen.section.ts` takes
|
||||||
|
`decisions`.
|
||||||
|
|
||||||
|
So the three names are a wire DTO, a screen-shaped aggregate, and a component of that
|
||||||
|
aggregate. Three different things, correctly named.
|
||||||
|
|
||||||
|
**The other half of Step 2 was already done correctly:** `HerregistratieDecisions` lives in
|
||||||
|
`registratie/domain/registration.ts:40`, not in `contracts/`, and only one hand-written
|
||||||
|
contracts file remains (`duo-diplomas.dto.ts`, a different endpoint). Commit `42e7a1e` did
|
||||||
|
the parts that were right and correctly left alone the part that would have been wrong.
|
||||||
|
|
||||||
3. **`successOf` / `successOr` sweep** — 10 inline unwraps remain in 8 files. They do not all
|
3. **`successOf` / `successOr` sweep** — 10 inline unwraps remain in 8 files. They do not all
|
||||||
want the same helper:
|
want the same helper:
|
||||||
- `undefined` fallback → existing `successOf`: `beoordeling.page.ts:78`
|
- `undefined` fallback → existing `successOf`: `beoordeling.page.ts:78`
|
||||||
@@ -852,8 +878,11 @@ End to end, after Phase 0 and Phase 3:
|
|||||||
|
|
||||||
Measured against the current tree, not assumed:
|
Measured against the current tree, not assumed:
|
||||||
|
|
||||||
- **Step 2 did not fully land** — the `DashboardViewDto → DashboardView → BigProfile` chain is
|
- ~~**Step 2 did not fully land.**~~ **This correction was itself wrong, and is withdrawn.**
|
||||||
intact (Phase 2.2).
|
The chain is intact because it _should_ be: `DashboardView` is a pair of `BigProfile` and
|
||||||
|
`HerregistratieDecisions`, not a third name for either. Collapsing it would discard the
|
||||||
|
server-computed decisions. The claim was made by reading the parse signature without reading
|
||||||
|
the type it returns. See Phase 2.2, now dropped.
|
||||||
- **7 files exceed 250 lines, not 8.** The plan counted by `wc -l`; the rule as specified uses
|
- **7 files exceed 250 lines, not 8.** The plan counted by `wc -l`; the rule as specified uses
|
||||||
`skipBlankLines` + `skipComments`. `concepts.page.ts` (472) was missing from its list, but
|
`skipBlankLines` + `skipComments`. `concepts.page.ts` (472) was missing from its list, but
|
||||||
`behandel-scherm` (232) and `stamdata-table-editor` (236) were on it and already pass.
|
`behandel-scherm` (232) and `stamdata-table-editor` (236) were on it and already pass.
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# RD-10 — Let the wizard shell carry the error, not drop it
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 1b#4
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`WizardStatus` is a payload-free string union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||||
|
```
|
||||||
|
|
||||||
|
Each wizard flattens its own state tag down to it with an identical 12-line switch, which
|
||||||
|
**throws the error away**. The error then has to travel separately, through a second
|
||||||
|
`errorMessage` input, and each wizard needs three computeds to take apart and reassemble what
|
||||||
|
one union could have carried intact:
|
||||||
|
|
||||||
|
| Wizard | `failedError` | `errorMessage` | `shellStatus` |
|
||||||
|
| ----------------------- | ------------- | -------------- | ------------- |
|
||||||
|
| `herregistratie-wizard` | 217 | 238 | 240-251 |
|
||||||
|
| `intake-wizard` | 326 | 346 | 348-359 |
|
||||||
|
| `registratie-wizard` | 443 | 453 | 456-467 |
|
||||||
|
|
||||||
|
Nine computeds and three switches exist because the type at the seam is too weak. One
|
||||||
|
payload-carrying union replaces all of it with three computeds — one per wizard.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts` — `WizardStatus` at 19,
|
||||||
|
`status` input at 148, `errorMessage` input at 152, the `@switch` at 56-133 (the
|
||||||
|
`@case ('failed')` at 131 is the only consumer of `errorMessage`)
|
||||||
|
- `libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts` — `base` at ~39 and the five
|
||||||
|
stories that set `status`
|
||||||
|
- The three `shellStatus`/`errorMessage`/`failedError` computeds listed above
|
||||||
|
- `libs/shared/src/kernel/fp.ts:27` — `whenTag`, which returns `Extract<…> | null`
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Replace `WizardStatus` and the `errorMessage` input with one payload-carrying union:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export type WizardPhase =
|
||||||
|
| { tag: 'Editing' }
|
||||||
|
| { tag: 'Submitting' }
|
||||||
|
| { tag: 'Submitted' }
|
||||||
|
| { tag: 'Failed'; message: string };
|
||||||
|
```
|
||||||
|
|
||||||
|
The shell takes `phase = input.required<WizardPhase>()`. The `errorMessage` input is
|
||||||
|
**deleted** — nothing else reads it.
|
||||||
|
|
||||||
|
2. **Keep the three mapping computeds. Do not try to remove them.** Each machine's tags are
|
||||||
|
its own and genuinely differ — `Editing`/`Answering`/`Invullen`, and registratie's Dutch
|
||||||
|
`Invullen`/`Indienen`/`Ingediend`/`Mislukt`. Those are not the shell's vocabulary and must
|
||||||
|
not become it. What changes is that each mapping now returns a `WizardPhase` carrying the
|
||||||
|
message, so **`failedError` and `errorMessage` fold into it** and each wizard goes from
|
||||||
|
three computeds to one.
|
||||||
|
|
||||||
|
3. **Compose the localized prefix inside the new computed**, exactly as `errorMessage` does
|
||||||
|
today, so both ids survive byte-identically:
|
||||||
|
- `@@wizard.indienenMislukt` — "Indienen mislukt:" (herregistratie and intake)
|
||||||
|
- `@@regWizard.indienenMislukt` — "Het indienen is niet gelukt:" (registratie)
|
||||||
|
|
||||||
|
The prefix differs per wizard, which is exactly why the mapping stays in the wizard. **Do
|
||||||
|
not** move either string into the shell, and do not reword them — same id with different
|
||||||
|
source text fails extraction.
|
||||||
|
|
||||||
|
4. **`@switch` cannot narrow a union in an Angular template.** So the `Failed` branch reads
|
||||||
|
the message through the existing helper: `whenTag(this.phase(), 'Failed')?.message ?? ''`.
|
||||||
|
Note `whenTag` returns `| null`, not `| undefined`. Do not add a new narrowing helper —
|
||||||
|
this is the idiom all five form components already use.
|
||||||
|
|
||||||
|
5. **Update `wizard-shell.stories.ts` in the same commit.** `base` carries
|
||||||
|
`errorMessage: ''` and five stories set `status:`; all become `phase:`. The failed story's
|
||||||
|
message ("Het indienen is niet gelukt: netwerkfout.") moves inside the phase object. Both
|
||||||
|
Storybook instances glob this file.
|
||||||
|
|
||||||
|
6. **Do not touch `errorList` or `WizardError`.** Those carry the current step's _field_
|
||||||
|
errors for the shell's error summary — a different concern from the submit failure, on a
|
||||||
|
different axis. They stay exactly as they are.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `libs/shared/src/layout/wizard-shell/wizard-shell.component.ts`
|
||||||
|
- `libs/shared/src/layout/wizard-shell/wizard-shell.stories.ts`
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/herregistratie-wizard/herregistratie-wizard.component.ts`
|
||||||
|
- `apps/ssp/src/app/herregistratie/ui/intake-wizard/intake-wizard.component.ts`
|
||||||
|
- `apps/ssp/src/app/registratie/ui/registratie-wizard/registratie-wizard.component.ts`
|
||||||
|
|
||||||
|
No machine changes. No xlf changes.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add `WizardPhase` to the shell, swap `status` + `errorMessage` for one `phase` input, and
|
||||||
|
read the failed message per decision 4.
|
||||||
|
2. Delete `WizardStatus`.
|
||||||
|
3. In each wizard, collapse `failedError` + `errorMessage` + `shellStatus` into one
|
||||||
|
`phase` computed returning a `WizardPhase`, keeping the localized prefix composition.
|
||||||
|
4. Update the shell's stories per decision 5.
|
||||||
|
5. Update this ticket's `Status:` to `done` and the README's RD-10 row to `done`.
|
||||||
|
6. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
The weak type is gone, and no wizard still needs three computeds to say one thing. These
|
||||||
|
commands were dry-run against the tree before this ticket was written, and the last two are
|
||||||
|
**path-scoped deliberately** — an unscoped version of either can never return nothing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -n "WizardStatus" -- apps libs # MUST return nothing
|
||||||
|
|
||||||
|
W=libs/shared/src/layout/wizard-shell
|
||||||
|
H=apps/ssp/src/app/herregistratie/ui
|
||||||
|
R=apps/ssp/src/app/registratie/ui/registratie-wizard
|
||||||
|
git grep -n "errorMessage" -- $W $H $R # MUST return nothing
|
||||||
|
git grep -n "failedError" -- $H $R # MUST return nothing
|
||||||
|
```
|
||||||
|
|
||||||
|
Why the scoping, so nobody "fixes" correct code to satisfy a bad check:
|
||||||
|
|
||||||
|
- **`errorMessage` legitimately exists elsewhere** — `brief/infrastructure/letter-preview.adapter.ts`,
|
||||||
|
its spec, `reveal-bignummer.adapter.ts`, and the generated `libs/shared/docs/behaviour-spec.mdx`.
|
||||||
|
All unrelated to this seam. Leave them.
|
||||||
|
- **`failedError` legitimately survives in the two single-step forms** —
|
||||||
|
`besluit-form.component.ts` and `change-request-form.component.ts`. RD-06 gave those their
|
||||||
|
own `Failed` branch and they keep their own computed. This ticket touches only the three
|
||||||
|
wizards.
|
||||||
|
|
||||||
|
Both `$localize` ids survive unchanged, so no new translation is needed. Scope to source —
|
||||||
|
an unscoped `-- apps` also matches the three locale files, which must **not** change:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git grep -l "wizard.indienenMislukt" -- $H # exactly 2: herregistratie + intake
|
||||||
|
git grep -l "regWizard.indienenMislukt" -- $R # exactly 1: registratie
|
||||||
|
|
||||||
|
# The locale files must be untouched by this ticket:
|
||||||
|
git diff --name-only HEAD | git grep -c "locale/messages" || true # expect no locale diff
|
||||||
|
```
|
||||||
|
|
||||||
|
For reference, the ids already exist in `apps/ssp/src/locale/messages.xlf`,
|
||||||
|
`apps/ssp/src/locale/messages.en.xlf` and `apps/behandelportal/src/locale/messages.en.xlf`.
|
||||||
|
Keeping the source text byte-identical is what lets all three stay as they are.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # exits 0
|
||||||
|
npm run ci --full # exits 0 — required, this changes stories
|
||||||
|
```
|
||||||
|
|
||||||
|
Then confirm the error still reaches the user: seed each wizard's failed state in Storybook
|
||||||
|
and check the alert shows the full message, prefix included. That is the behaviour this
|
||||||
|
ticket exists to protect, and the type change is what makes losing it impossible.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci --full`. `--full` is mandatory: this edits `wizard-shell.stories.ts`, and only
|
||||||
|
`build-storybook` plus the axe run exercise it. Both Storybook instances glob the shared
|
||||||
|
library, so both must build.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `errorList` / `WizardError` (decision 6).
|
||||||
|
- The three machines. This ticket changes only the UI seam.
|
||||||
|
- Splitting any wizard into steps. RD-22 and RD-23.
|
||||||
|
- `UploadStatus`'s `type:` discriminant. Optional RD-35.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`ng build --localize` fails on a changed `$localize` id or source text.** Keep both
|
||||||
|
template literals byte-identical and only move where they are composed (decision 3).
|
||||||
|
- **`whenTag` returns `null`, not `undefined`.** `?? ''` covers both, but a `=== undefined`
|
||||||
|
check would silently fail.
|
||||||
|
- **`input.required` has no default**, unlike the `errorMessage = input('')` it replaces.
|
||||||
|
Every call site must pass `phase`, including all five stories. A missed story fails at
|
||||||
|
runtime in Storybook, not at compile time — which is why `--full` is mandatory here.
|
||||||
|
- **Do not let the shell learn the machines' tags.** If `WizardPhase` grows an `Invullen` or
|
||||||
|
`Answering` member, the mapping has leaked into the shared layer and the change has made
|
||||||
|
things worse.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# RD-12 — Move the brief's action lifecycle into the machine
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 1b#2a
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`brief.store.ts` keeps the action lifecycle in a store-level signal, set imperatively from
|
||||||
|
about ten places entirely outside the reducer:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||||
|
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||||
|
readonly lastError = computed(() => { … });
|
||||||
|
```
|
||||||
|
|
||||||
|
So the machine cannot enforce which action transitions are legal, and `ActionState` has two
|
||||||
|
producers and **zero** consumers that keep the union — both stores immediately collapse it
|
||||||
|
back to a boolean plus a nullable string, the exact shape its own doc comment says it exists
|
||||||
|
to remove.
|
||||||
|
|
||||||
|
Move it into the machine's `Loaded` state and the reducer owns it, like every other state
|
||||||
|
change in this house.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `libs/shared/src/application/action-state.ts` — 9 lines, both types. **Only `ActionState`
|
||||||
|
is in scope**; `SaveState` is RD-14's.
|
||||||
|
- `apps/ssp/src/app/brief/application/brief.store.ts` — `actionState` at 44, `busy` at 45,
|
||||||
|
`lastError` at 46, and the setter sites in `flushSave` (207), `resetDemo` (226),
|
||||||
|
`previewLetter` (251), `revealBigNummer` (269) and `transition` (280)
|
||||||
|
- `apps/ssp/src/app/brief/domain/brief.machine.ts` — the `Loaded` variant (PascalCase since
|
||||||
|
RD-11) and `reduce`
|
||||||
|
- `apps/ssp/src/app/brief/ui/brief.page.ts:50-126` — the `<app-async>` wrapper. Decision 2
|
||||||
|
depends on it.
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **`action` becomes a field on `BriefState.Loaded`**, carrying the same three cases
|
||||||
|
(`Idle | Busy | Failed{error}`), driven by three new messages — `ActionStarted`,
|
||||||
|
`ActionFinished`, `ActionFailed` — handled in `reduce`. The imperative
|
||||||
|
`actionState.set(...)` calls become `dispatch(...)`.
|
||||||
|
|
||||||
|
2. **This is safe because every action trigger is template-gated, and that was verified, not
|
||||||
|
assumed.** `brief.page.ts:55` opens `<ng-template appAsyncLoaded>`, which renders only when
|
||||||
|
`remoteData()` is `Success` — i.e. when the machine is `Loaded`. All three entry points sit
|
||||||
|
inside it: the reset button (`:83`), `previewLetter` (`:101`, `:120`) and `revealBigNummer`
|
||||||
|
(`:102`). `transition` backs submit/approve/reject/send, reachable only from the same
|
||||||
|
surface, and `flushSave` runs from the debounced autosave, which only fires while editing a
|
||||||
|
loaded brief.
|
||||||
|
|
||||||
|
**If you add an action trigger outside that slot, this design breaks.** Do not add one.
|
||||||
|
|
||||||
|
3. **`busy` and `lastError` stay as `computed`s on the store.** They are the render seam, not
|
||||||
|
a second encoding: four components take `busy = input(...)` — `behandel-scherm`,
|
||||||
|
`letter-composer`, `org-template-editor`, `rejection-comments` — and two pages read
|
||||||
|
`store.busy()`/`store.lastError()` directly. A boolean is right at that boundary; the union
|
||||||
|
is right in the machine. **Do not push the union down into the components** — it would churn
|
||||||
|
four components and their stories for no gain.
|
||||||
|
|
||||||
|
4. **`BriefLoaded` resetting `action` to `Idle` is intended.** A fresh load clears a stale
|
||||||
|
action error, which is a small behaviour _improvement_: today a failed action's message can
|
||||||
|
outlive a reload. Let the reducer do it, and say so in a comment.
|
||||||
|
|
||||||
|
5. **`flushSave` sets both `saveState` and `actionState` today. Keep both.** The autosave
|
||||||
|
failure legitimately surfaces in two places — the small save indicator and the action error
|
||||||
|
line. Only the `actionState` half becomes a dispatch here; leave `saveState` exactly as it
|
||||||
|
is.
|
||||||
|
|
||||||
|
6. **Do not touch `org-template.store.ts`, `pendingPublish`, or `SaveState`.** RD-13 folds
|
||||||
|
org-template (including `pendingPublish`, the one genuine illegal-state pair), and RD-14
|
||||||
|
moves `SaveState` and deletes `action-state.ts`. `action-state.ts` therefore still exists
|
||||||
|
after this ticket, exporting only `SaveState` plus an `ActionState` that brief no longer
|
||||||
|
imports.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/brief/domain/brief.machine.ts` (+ `.spec.ts`)
|
||||||
|
- `apps/ssp/src/app/brief/application/brief.store.ts` (+ `.spec.ts`)
|
||||||
|
|
||||||
|
Not `action-state.ts` (RD-14 deletes it). Not `org-template.store.ts` (RD-13). No UI files.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add `action` to `BriefState.Loaded` and the three messages to `BriefMsg`; handle them in
|
||||||
|
`reduce`, including the `BriefLoaded` reset from decision 4.
|
||||||
|
2. Add reducer spec cases (see Acceptance).
|
||||||
|
3. Replace each `actionState.set(...)` in `brief.store.ts` with the matching `dispatch`.
|
||||||
|
4. Re-point `busy` and `lastError` at the machine's `Loaded.action`, keeping their public
|
||||||
|
signatures identical so no UI file changes.
|
||||||
|
5. Run `npm run gen:behaviour-spec` — new `it()` titles otherwise fail the drift check.
|
||||||
|
6. Update this ticket's `Status:` to `done` and the README's RD-12 row to `done`.
|
||||||
|
7. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Dry-run against the tree before handover, with the measured baselines: `brief.store.ts` has
|
||||||
|
**14** `actionState` occurrences and `brief.machine.ts` has **0** action messages; both must
|
||||||
|
invert. `saveState` is **5** and must stay 5. The `busy`/`lastError` declarations are **2** and
|
||||||
|
must stay 2.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
B=apps/ssp/src/app/brief
|
||||||
|
git grep -c "actionState" -- $B/application/brief.store.ts # MUST return nothing
|
||||||
|
git grep -n "ActionState" -- $B # MUST return nothing
|
||||||
|
git grep -c "ActionStarted\|ActionFinished\|ActionFailed" -- $B/domain/brief.machine.ts # >= 3
|
||||||
|
```
|
||||||
|
|
||||||
|
The render seam is unchanged, so no UI file was touched:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only HEAD | grep -c "brief/ui/" || true # MUST be 0
|
||||||
|
git grep -c "readonly busy\|readonly lastError" -- $B/application/brief.store.ts # still 2
|
||||||
|
```
|
||||||
|
|
||||||
|
`SaveState` and org-template are untouched (decision 6):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only HEAD | grep -cE "action-state|org-template" || true # MUST be 0
|
||||||
|
git grep -c "saveState" -- $B/application/brief.store.ts # unchanged: still 5
|
||||||
|
```
|
||||||
|
|
||||||
|
New reducer cases:
|
||||||
|
|
||||||
|
```
|
||||||
|
- ActionStarted moves a loaded brief to Busy
|
||||||
|
- ActionFailed carries the error
|
||||||
|
- ActionFinished returns to Idle
|
||||||
|
- BriefLoaded resets a stale action error to Idle
|
||||||
|
- an action message is a no-op when the brief is not loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required.
|
||||||
|
|
||||||
|
If you do run the full gate, pass `timeout: 600000` on the Bash call — it takes about 8
|
||||||
|
minutes and the harness backgrounds anything longer than 120s, which would end your turn with
|
||||||
|
the work uncommitted.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `org-template.store.ts` and `pendingPublish` — RD-13.
|
||||||
|
- `SaveState`, and deleting `action-state.ts` — RD-14.
|
||||||
|
- The four `busy = input(...)` components and their stories (decision 3).
|
||||||
|
- The `NO_SUBORGS`/`NO_TABLES`-should-be-`Empty` finding — optional RD-34.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Decision 2 is the load-bearing assumption.** It holds today because of one
|
||||||
|
`<ng-template appAsyncLoaded>`. Re-read `brief.page.ts:50-126` and confirm before you start;
|
||||||
|
if any trigger has moved outside that slot since this ticket was written, stop and say so
|
||||||
|
rather than adding a guard that changes behaviour.
|
||||||
|
- **`revealBigNummer` sets only `Failed`, never `Busy`.** Do not "fix" that asymmetry here —
|
||||||
|
it is existing behaviour, and changing it is a separate decision.
|
||||||
|
- **Keep `busy`/`lastError` signatures byte-identical.** They are read from two page templates;
|
||||||
|
a renamed or re-typed member turns a pure refactor into a UI change.
|
||||||
|
- **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same
|
||||||
|
commit.
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# RD-13 — Fold org-template's action lifecycle and `pendingPublish` into one union
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 1b#2a and 1b#6
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`org-template.store.ts` repeats the pattern RD-12 removed from brief — an `actionState`
|
||||||
|
signal set imperatively from 13 places — and adds the arc's **one genuine illegal-state
|
||||||
|
pair**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
private actionState = signal<ActionState>({ tag: 'Idle' }); // Idle | Busy | Failed
|
||||||
|
readonly pendingPublish = signal(false); // independent boolean
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing prevents `pendingPublish === true` _and_ `busy === true` at the same time. That state
|
||||||
|
is representable and meaningless: the UI would show the publish-impact confirmation while a
|
||||||
|
publish is already in flight. Two independent signals cannot express "these are mutually
|
||||||
|
exclusive"; one union can.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `docs/project/readable-codebase/RD-12-brief-action-in-machine.md` — the same migration,
|
||||||
|
already done and green for brief. Copy its shape.
|
||||||
|
- `apps/ssp/src/app/brief/application/org-template.store.ts` — `actionState` at 50, `busy` at
|
||||||
|
51, `lastError` at 52, `saveState` at 56, `pendingPublish` at 59, and the publish flow at
|
||||||
|
174-193
|
||||||
|
- `apps/ssp/src/app/brief/domain/org-template.machine.ts` — the `Loaded` variant at 27-35
|
||||||
|
(PascalCase since RD-11)
|
||||||
|
- `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts:245,282` and
|
||||||
|
`org-template.page.ts:59` — the render seam that must not change
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **One four-variant union on `OrgTemplateState.Loaded`:**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
action: { tag: 'Idle' } | { tag: 'ConfirmingPublish' } | { tag: 'Busy' } | { tag: 'Failed'; error: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
`ConfirmingPublish` is the fourth variant that absorbs `pendingPublish`. This is the whole
|
||||||
|
point of the ticket: after it, "confirming" and "busy" are mutually exclusive **by
|
||||||
|
construction**, not by convention.
|
||||||
|
|
||||||
|
2. **`requestPublish` and `cancelPublish` become dispatches.** They are the only two commands
|
||||||
|
in this store that do **not** guard on `loaded()` today — they just set the boolean. As
|
||||||
|
messages (`PublishRequested`, `PublishCancelled`) they no-op outside `Loaded`, which is the
|
||||||
|
correct behaviour and means you do not add a guard that changes anything.
|
||||||
|
|
||||||
|
3. **The other commands keep their existing `const s = this.loaded(); if (!s) return;`
|
||||||
|
guards** — `confirmPublish` (181), `rollback` (197), and the two at 158 and 211. Do not
|
||||||
|
remove them; they are stronger than brief's template gate and remain correct.
|
||||||
|
|
||||||
|
4. **`pendingPublish`, `busy` and `lastError` all stay as store members with byte-identical
|
||||||
|
public signatures.** `pendingPublish` becomes
|
||||||
|
`computed(() => this.action().tag === 'ConfirmingPublish')` rather than a `signal`. The
|
||||||
|
render seam must not move: `org-template-editor.component.ts:282` takes
|
||||||
|
`pendingPublish = input(false)`, `:245` renders on it, `org-template.page.ts:59` passes it,
|
||||||
|
and two story args set it. **No file under `brief/ui/` may change.**
|
||||||
|
|
||||||
|
5. **`flushSave` sets both `saveState` and `actionState`** (lines 161-169). Convert only the
|
||||||
|
`actionState` half. `saveState` must still number 5 occurrences.
|
||||||
|
|
||||||
|
6. **`action-state.ts` still exists after this ticket.** RD-14 moves `SaveState` into
|
||||||
|
`debounced-save.ts` and deletes the file. Do not delete it here, and do not touch
|
||||||
|
`SaveState`.
|
||||||
|
|
||||||
|
7. **Do not revisit `NO_SUBORGS`.** `org-template.store.ts:29,129` dispatches `LoadFailed` for
|
||||||
|
what is semantically `Empty`. That is a real finding and it is optional RD-34, not this
|
||||||
|
ticket.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `apps/ssp/src/app/brief/domain/org-template.machine.ts` (+ `.spec.ts`)
|
||||||
|
- `apps/ssp/src/app/brief/application/org-template.store.ts`
|
||||||
|
|
||||||
|
Not `action-state.ts` (RD-14). Not `brief.machine.ts` or `brief.store.ts` (RD-12, done). No UI
|
||||||
|
files.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Add the four-variant `action` field to `OrgTemplateState.Loaded` and the messages to
|
||||||
|
`OrgTemplateMsg`: `PublishRequested`, `PublishCancelled`, `ActionStarted`,
|
||||||
|
`ActionFinished`, `ActionFailed`.
|
||||||
|
2. Handle them in `reduce`, each a no-op outside `Loaded`. `DraftLoaded` resets `action` to
|
||||||
|
`Idle`, matching RD-12's deliberate reset.
|
||||||
|
3. Add reducer spec cases (see Acceptance), including the mutual-exclusion case.
|
||||||
|
4. Replace the 13 `actionState.set(...)` and 4 `pendingPublish.set(...)` sites with dispatches.
|
||||||
|
5. Re-point `busy`, `lastError` and `pendingPublish` at `Loaded.action`, keeping signatures
|
||||||
|
identical.
|
||||||
|
6. Run `npm run gen:behaviour-spec` — new spec titles otherwise fail the drift check.
|
||||||
|
7. Update this ticket's `Status:` to `done` and the README's RD-13 row to `done`.
|
||||||
|
8. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured baselines, dry-run before handover. Commands are scoped to **this ticket's two
|
||||||
|
files**, never to the `brief/` directory — `action-state.ts` and other files legitimately
|
||||||
|
still reference these names.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
S=apps/ssp/src/app/brief/application/org-template.store.ts
|
||||||
|
M=apps/ssp/src/app/brief/domain/org-template.machine.ts
|
||||||
|
|
||||||
|
git grep -c "actionState" -- $S # was 13 -> MUST return nothing
|
||||||
|
git grep -cw "ActionState" -- $S # MUST return nothing (word-anchored: a new
|
||||||
|
# OrgTemplateActionState would contain the old name)
|
||||||
|
git grep -c "pendingPublish" -- $S # was 4 (a signal) -> now exactly 1 (a computed)
|
||||||
|
git grep -c "saveState" -- $S # unchanged: still 5
|
||||||
|
git grep -c "readonly busy\|readonly lastError" -- $S # unchanged: still 2
|
||||||
|
git grep -c "ConfirmingPublish" -- $M # >= 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The render seam did not move:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --name-only 8e5f48c | grep -c "brief/ui/" || true # MUST be 0
|
||||||
|
```
|
||||||
|
|
||||||
|
New reducer cases, the third being the point of the ticket:
|
||||||
|
|
||||||
|
```
|
||||||
|
- PublishRequested moves a loaded template to ConfirmingPublish
|
||||||
|
- PublishCancelled returns to Idle
|
||||||
|
- ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist
|
||||||
|
- ActionFailed carries the error
|
||||||
|
- DraftLoaded resets a stale action error to Idle
|
||||||
|
- an action message is a no-op when the template is not loaded
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required.
|
||||||
|
|
||||||
|
If you run the full gate anyway, pass `timeout: 600000` on the Bash call — it takes about 8
|
||||||
|
minutes, and the harness backgrounds anything over 120s, which would end your turn with the
|
||||||
|
work uncommitted.
|
||||||
|
|
||||||
|
If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale
|
||||||
|
`bigregister.db` artifact documented in this README's Troubleshooting section. It is unrelated
|
||||||
|
to your change.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `SaveState` and deleting `action-state.ts` — RD-14.
|
||||||
|
- `NO_SUBORGS` becoming `Empty` — optional RD-34 (decision 7).
|
||||||
|
- Any file under `brief/ui/`, and the four `busy = input(...)` components.
|
||||||
|
- `brief.machine.ts` / `brief.store.ts` — RD-12 already did those.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **The mutual-exclusion case is the acceptance test that matters.** If your reducer lets
|
||||||
|
`ConfirmingPublish` and `Busy` coexist in any way, the ticket has not achieved its purpose
|
||||||
|
even if every grep passes.
|
||||||
|
- **`pendingPublish` changes from a `signal` to a `computed`.** Anything that _writes_ it must
|
||||||
|
become a dispatch. A leftover `.set()` call will not compile, which is the desired outcome.
|
||||||
|
- **Keep `busy`/`lastError`/`pendingPublish` signatures byte-identical.** All three are read
|
||||||
|
from a page template; renaming or re-typing one turns a pure refactor into a UI change and
|
||||||
|
breaks two stories.
|
||||||
|
- **`behaviour-spec.mdx` drift** from the new spec titles. Run `gen:behaviour-spec` in the same
|
||||||
|
commit.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# RD-14 — Move `SaveState` beside its producer, delete `action-state.ts`
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 1b#2b
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
RD-12 and RD-13 moved both `ActionState` consumers into their machines, so **`ActionState`
|
||||||
|
now has zero real users.** Word-anchored, it survives only in its own definition and in one
|
||||||
|
doc-comment mention.
|
||||||
|
|
||||||
|
`SaveState` is different and must survive: it has two genuine consumers that keep all four
|
||||||
|
cases (`brief.page.ts:150` and `org-template.page.ts:102` both `switch` on it, and
|
||||||
|
`brief.page.ts:77` reads `=== 'Error'`). The original plan called for deleting both types;
|
||||||
|
that was corrected once the consumers were read.
|
||||||
|
|
||||||
|
So the file's remaining job is to hold one type whose only producer lives elsewhere. Move
|
||||||
|
`SaveState` next to `createDebouncedSave`, which is what sets it, and the file has no reason
|
||||||
|
to exist.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `libs/shared/src/application/action-state.ts` — 9 lines, both types
|
||||||
|
- `libs/shared/src/application/debounced-save.ts` — `SaveState`'s new home; note the comment
|
||||||
|
at line 16, which names `ActionState`
|
||||||
|
- `apps/ssp/src/app/brief/application/brief.store.ts:4,57` and
|
||||||
|
`org-template.store.ts:3,66` — the two importers
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Delete `ActionState` outright.** Zero users after RD-12 and RD-13. Do not deprecate it,
|
||||||
|
do not keep a re-export.
|
||||||
|
|
||||||
|
2. **Move `SaveState` verbatim into `debounced-save.ts`**, keeping its doc comment. That file
|
||||||
|
already owns the debounced-autosave concern and `createDebouncedSave` is the only thing
|
||||||
|
that drives the state, so the type belongs beside it. Keep the four cases exactly as they
|
||||||
|
are — `Idle | Saving | Saved | Error`.
|
||||||
|
|
||||||
|
3. **Delete `libs/shared/src/application/action-state.ts`.** Nothing else lives in it.
|
||||||
|
|
||||||
|
4. **Update the two store imports** to `@shared/application/debounced-save`. Both stores
|
||||||
|
already import from that module for `createDebouncedSave`, so this should merge into an
|
||||||
|
existing import line rather than adding one.
|
||||||
|
|
||||||
|
5. **Reword `debounced-save.ts:16`**, which currently reads "it touches that store's
|
||||||
|
`SaveState`/`ActionState` + adapter". Drop the `ActionState` half — the type will not
|
||||||
|
exist.
|
||||||
|
|
||||||
|
6. **Change no UI file and no page.** `saveState`'s public signature on both stores stays
|
||||||
|
identical, so the three consumer sites need no edit.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `libs/shared/src/application/debounced-save.ts` — gains `SaveState`, comment reworded
|
||||||
|
- `libs/shared/src/application/action-state.ts` — **deleted**
|
||||||
|
- `apps/ssp/src/app/brief/application/brief.store.ts` — import only
|
||||||
|
- `apps/ssp/src/app/brief/application/org-template.store.ts` — import only
|
||||||
|
|
||||||
|
No spec files. No UI files. No machine files.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Move the `SaveState` declaration and its doc comment into `debounced-save.ts`.
|
||||||
|
2. Reword the `ActionState` mention at line 16 (decision 5).
|
||||||
|
3. Re-point both store imports (decision 4).
|
||||||
|
4. `git rm libs/shared/src/application/action-state.ts`.
|
||||||
|
5. Update this ticket's `Status:` to `done` and the README's RD-14 row to `done`.
|
||||||
|
6. Commit all of it together.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
Measured baselines, dry-run before handover.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# The file is gone, and nothing imports it.
|
||||||
|
ls libs/shared/src/application/action-state.ts # MUST be "No such file"
|
||||||
|
git grep -l "application/action-state" -- apps libs # was 2 files -> MUST return nothing
|
||||||
|
|
||||||
|
# ActionState is gone entirely, word-anchored (a name containing it would defeat a bare grep).
|
||||||
|
git grep -nw "ActionState" -- apps libs # MUST return nothing
|
||||||
|
|
||||||
|
# SaveState survives, in its new home, with all four cases. Anchor on the DECLARATION:
|
||||||
|
# a bare `-w SaveState` grep already returns 1 today, from the line-16 comment.
|
||||||
|
D=libs/shared/src/application/debounced-save.ts
|
||||||
|
git grep -c "export type SaveState" -- $D # was 0 -> MUST be 1
|
||||||
|
git grep -c "'Idle'\|'Saving'\|'Saved'\|'Error'" -- $D # MUST be >= 4
|
||||||
|
|
||||||
|
# The render seam did not move: the three consumer sites are untouched.
|
||||||
|
git diff --name-only c599fee | grep -c "brief/ui/" || true # MUST be 0
|
||||||
|
git grep -c "readonly saveState" -- \
|
||||||
|
apps/ssp/src/app/brief/application/brief.store.ts \
|
||||||
|
apps/ssp/src/app/brief/application/org-template.store.ts # still 1 each
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run ci # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci`. No story, no `.mdx`, no `libs/shared/src/ui/**`, so `--full` is not required.
|
||||||
|
|
||||||
|
`dep:check` matters here: `debounced-save.ts` is in `libs/shared/src/application`, the same
|
||||||
|
layer `action-state.ts` was in, so no boundary changes. If `dep:check` fails, the type landed
|
||||||
|
in the wrong layer.
|
||||||
|
|
||||||
|
If `dotnet test` fails with `SQLite Error 1: 'no such table: …'`, that is the stale
|
||||||
|
`bigregister.db` artifact in this README's Troubleshooting section, unrelated to your change.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Anything under `brief/ui/` (decision 6).
|
||||||
|
- The machines. RD-12 and RD-13 already moved the action lifecycles.
|
||||||
|
- `NO_SUBORGS` becoming `Empty` — optional RD-34.
|
||||||
|
- `UploadStatus`'s `type:` discriminant — optional RD-35.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Do not delete `SaveState` along with the file.** It has two four-way consumers. The
|
||||||
|
original plan said to delete both types; reading the consumers corrected that, and this
|
||||||
|
ticket is the corrected version.
|
||||||
|
- **Merge into the existing `debounced-save` import** in both stores rather than adding a
|
||||||
|
second import line from the same module — lint will not complain, but it reads badly.
|
||||||
|
- **This is the last ticket that touches `action-state.ts`.** After it, the phase's claim
|
||||||
|
holds: two encodings survive, `RemoteData` for fetched data and each machine's own state
|
||||||
|
union, plus `SaveState` as an explicitly-justified third for a different concern.
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# RD-15 — Remove the 22 abandoned agent worktrees
|
||||||
|
|
||||||
|
Status: done
|
||||||
|
Source: PLAN.md 2.1
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
`.claude/worktrees/` holds **22 abandoned agent checkouts totalling 4.7 GB**, left behind by
|
||||||
|
past agent runs. They are gitignored (`.gitignore:64`), so they never reach a commit — but
|
||||||
|
they are on disk, and every unqualified repository-wide `grep -r` or `find` walks all 22
|
||||||
|
copies of the source tree.
|
||||||
|
|
||||||
|
That is a real tax on every future search, by a person or an agent, and it is larger than it
|
||||||
|
looks. Measured:
|
||||||
|
|
||||||
|
| | files |
|
||||||
|
| -------------------------------- | ---------- |
|
||||||
|
| under `.claude/worktrees/` | **48,005** |
|
||||||
|
| tracked in the actual repository | **856** |
|
||||||
|
|
||||||
|
An unqualified `grep -r` or `find` therefore walks **56× more files than the repository
|
||||||
|
contains**. This ticket removes the cause; the `git grep` habit in the ticket-authoring rules
|
||||||
|
above handles the symptom.
|
||||||
|
|
||||||
|
## Read first
|
||||||
|
|
||||||
|
- `.gitignore:64` — confirms the directory is ignored
|
||||||
|
- `git worktree list` — 23 entries: the main working tree plus the 22 to remove
|
||||||
|
- The verification block below. **Run it before removing anything.**
|
||||||
|
|
||||||
|
## Decisions (pre-made, don't relitigate)
|
||||||
|
|
||||||
|
1. **Use `git worktree remove`, never `rm -rf`.** These are **live registered git
|
||||||
|
worktrees**, not orphaned directories — each has a real `worktree-agent-<hex>` branch. An
|
||||||
|
`rm -rf` leaves 22 broken registrations behind in `.git/worktrees/`, which is worse than
|
||||||
|
the disk usage. This correction was made while executing RD-01, where the original plan
|
||||||
|
assumed they were plain directories.
|
||||||
|
|
||||||
|
2. **Delete each `worktree-agent-*` branch too**, after removing its worktree. A worktree
|
||||||
|
removal does not delete the branch it had checked out, and 22 stale branches in
|
||||||
|
`git branch` are their own kind of noise.
|
||||||
|
|
||||||
|
3. **Finish with `git worktree prune`** to clear any leftover administrative entries.
|
||||||
|
|
||||||
|
4. **Re-verify before removing, even though it was verified when this ticket was written.**
|
||||||
|
This is the only destructive ticket in the arc. Both gates passed at authoring time — all
|
||||||
|
22 branch tips are ancestors of `main` (the RB-01..RB-33 arc was merged in `637d500`), and
|
||||||
|
all 22 working trees are clean. **If either gate fails for any worktree, stop and report
|
||||||
|
it; do not use `--force`.**
|
||||||
|
|
||||||
|
5. **This ticket changes no tracked file.** Its commit contains only this ticket file and the
|
||||||
|
README row. That is correct and expected — the work is entirely in gitignored paths and
|
||||||
|
local branch refs.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `docs/project/readable-codebase/RD-15-remove-abandoned-worktrees.md` (this file)
|
||||||
|
- `docs/project/readable-codebase/README.md` (the RD-15 row)
|
||||||
|
|
||||||
|
No source files. No configuration. `.gitignore` is already correct and must not change.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Run the verification block below. Do not proceed unless it reports `unmerged: 0` and
|
||||||
|
`dirty: 0`.
|
||||||
|
2. For each worktree: `git worktree remove .claude/worktrees/<name>`.
|
||||||
|
3. For each branch: `git branch -d worktree-agent-<hex>` (lowercase `-d`, which refuses to
|
||||||
|
delete anything unmerged — that is a second safety net, so do **not** use `-D`).
|
||||||
|
4. `git worktree prune`.
|
||||||
|
5. Confirm `.claude/worktrees/` is gone or empty.
|
||||||
|
6. Update this ticket's `Status:` to `done` and the README's RD-15 row to `done`.
|
||||||
|
7. Commit.
|
||||||
|
|
||||||
|
## The verification gate — run this first
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/eho/repos/atomic-design-poc
|
||||||
|
unmerged=0
|
||||||
|
for b in $(git branch --list 'worktree-agent-*' --format='%(refname:short)'); do
|
||||||
|
git merge-base --is-ancestor "$(git rev-parse "$b")" main 2>/dev/null \
|
||||||
|
|| { echo "UNMERGED: $b"; unmerged=$((unmerged+1)); }
|
||||||
|
done
|
||||||
|
dirty=0
|
||||||
|
for d in .claude/worktrees/agent-*; do
|
||||||
|
[ -d "$d" ] || continue
|
||||||
|
out=$(git -C "$d" status --porcelain 2>/dev/null | grep -v '^?? node_modules')
|
||||||
|
[ -z "$out" ] || { echo "DIRTY: $(basename "$d")"; dirty=$((dirty+1)); }
|
||||||
|
done
|
||||||
|
echo "unmerged: $unmerged dirty: $dirty"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected, and what was measured when this ticket was written: `unmerged: 0 dirty: 0`.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree list | wc -l # MUST be 1 (the main tree only)
|
||||||
|
git branch --list 'worktree-agent-*' | wc -l # MUST be 0
|
||||||
|
ls .claude/worktrees 2>/dev/null | wc -l # MUST be 0
|
||||||
|
du -sh .claude 2>/dev/null # was 4.7G under worktrees/
|
||||||
|
```
|
||||||
|
|
||||||
|
The repository is still intact — this is the check that matters after a destructive step:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git status --short # only the two doc files
|
||||||
|
git log --oneline -1 # HEAD unchanged from before your removals
|
||||||
|
npm run ci # exits 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Show the payoff, since it is the reason for the ticket:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
find .claude/worktrees -type f 2>/dev/null | wc -l # was 48005 -> MUST be 0
|
||||||
|
git ls-files | wc -l # unchanged: 856 tracked files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
`npm run ci`. No source file changes, so `--full` is not required — but run plain `ci` anyway,
|
||||||
|
because removing worktrees touches `.git` administrative state and the point is to prove the
|
||||||
|
repository is unharmed.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- `.gitignore` — already correct at line 64.
|
||||||
|
- Any worktree that fails a gate. Report it instead (decision 4).
|
||||||
|
- Preventing future accumulation. Worth doing, but it is a change to how agents are launched,
|
||||||
|
not a cleanup, and no ticket covers it yet. Note it as a follow-up.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **This is the arc's only destructive ticket.** The two gates in decision 4 are what make it
|
||||||
|
safe. Run them, and stop on any failure.
|
||||||
|
- **`git branch -d`, never `-D`.** Lowercase refuses unmerged branches, which duplicates the
|
||||||
|
first gate at the moment of deletion. If `-d` refuses a branch, that branch has commits not
|
||||||
|
in `main` — stop and report it.
|
||||||
|
- **`git worktree remove` refuses a dirty worktree** unless forced. Do not force. A refusal
|
||||||
|
means the second gate missed something.
|
||||||
|
- **Do not delete `node_modules` anywhere else** while cleaning up. The verification block
|
||||||
|
deliberately ignores untracked `node_modules` inside a worktree, because that is build
|
||||||
|
output, not work.
|
||||||
@@ -104,13 +104,13 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
|
|||||||
| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done |
|
| RD-07 | Add `Primary` to the 3 wizard machines + specs | 05 | | done |
|
||||||
| 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 | todo |
|
| 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 | | done |
|
||||||
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo |
|
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done |
|
||||||
| 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 | | done |
|
||||||
| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo |
|
| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | done |
|
||||||
| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | todo |
|
| RD-16 | ~~`parseDashboardView` returns `BigProfile`~~ — DROPPED, see PLAN.md 2.2 | 01 | | n/a |
|
||||||
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo |
|
| RD-17 | `successOf`/`successOr` sweep — 10 sites, 8 files | 01 | | todo |
|
||||||
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo |
|
| RD-18 | Ticket-reference sweep, frontend — 181 refs, 100 files | 01 | | todo |
|
||||||
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo |
|
| RD-19 | Ticket-reference sweep, backend — 370 refs, 86 files | 01 | | todo |
|
||||||
@@ -180,7 +180,7 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
estimate and nothing can check it. `npm run lint` has an exit code.
|
estimate and nothing can check it. `npm run lint` has an exit code.
|
||||||
4. **Run every acceptance command against the tree before you hand the ticket over.** A
|
4. **Run every acceptance command against the tree before you hand the ticket over.** A
|
||||||
command that cannot pass is worse than no command: the agent either wastes a cycle or,
|
command that cannot pass is worse than no command: the agent either wastes a cycle or,
|
||||||
worse, "fixes" correct code to satisfy it. Four real misses so far, all in tickets written
|
worse, "fixes" correct code to satisfy it. Seven real misses so far, all in tickets written
|
||||||
by the supervisor:
|
by the supervisor:
|
||||||
- RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`.
|
- RD-06 grepped only `runIfSubmitting`, missing that one wizard spells it `runIfIndienen`.
|
||||||
- RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated
|
- RD-08 grepped bare `onPrimary\|onRetry`, which can never return nothing — an unrelated
|
||||||
@@ -192,8 +192,13 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
files (they name the deleted method as the history of `done` work) and 22 gitignored
|
files (they name the deleted method as the history of `done` work) and 22 gitignored
|
||||||
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
|
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
|
||||||
history.
|
history.
|
||||||
|
- RD-11 asserted `git grep "machineRemoteData\|LoadLifecycle"` returns nothing, but the
|
||||||
|
replacement it mandates is named **`fromLoadLifecycle`** — which contains the old name as
|
||||||
|
a substring. The check can never pass. **When the new name contains the old one, anchor
|
||||||
|
on word boundaries**: `git grep -w machineRemoteData` and
|
||||||
|
`git grep -nE "(^|[^a-zA-Z])LoadLifecycle\b"`.
|
||||||
|
|
||||||
Three habits that prevent all four:
|
Four habits that prevent all five:
|
||||||
|
|
||||||
- **Use `git grep`, not `grep -r`.** It searches tracked files only, so untracked and
|
- **Use `git grep`, not `grep -r`.** It searches tracked files only, so untracked and
|
||||||
gitignored paths never pollute the result. Measured on this repo: `grep -r` finds 132
|
gitignored paths never pollute the result. Measured on this repo: `grep -r` finds 132
|
||||||
@@ -201,5 +206,32 @@ Three rules when you write a ticket file, because the agent reads its ticket and
|
|||||||
which are repo-wide sweeps.
|
which are repo-wide sweeps.
|
||||||
- **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately
|
- **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately
|
||||||
appear elsewhere.
|
appear elsewhere.
|
||||||
- **Keep the Files list consistent with the Acceptance commands.** If a command reaches a
|
- **`git grep -c` counts matching LINES, not occurrences.** RD-14 asserted
|
||||||
file the ticket says not to touch, one of the two is wrong.
|
`git grep -c "'Idle'\|'Saving'\|'Saved'\|'Error'"` would be `>= 4`, but all four tags
|
||||||
|
live on one line of a single-line type declaration, so the honest answer is `1`. The
|
||||||
|
agent correctly refused to reformat the type across four lines to satisfy the number.
|
||||||
|
When you want occurrences, use `grep -o … | wc -l`; when a line count is what you mean,
|
||||||
|
say so.
|
||||||
|
- **Scope every acceptance command to the ticket's Files list, never to a parent
|
||||||
|
directory.** This is the habit most often broken, including by the supervisor in RD-12:
|
||||||
|
the check `git grep "ActionState" -- apps/ssp/src/app/brief` cannot pass, because
|
||||||
|
`org-template.store.ts` lives in that directory and is deliberately out of scope until
|
||||||
|
RD-13. Name the files. If a command reaches a file the ticket says not to touch, one of
|
||||||
|
the two is wrong.
|
||||||
|
- **Prefer a number over a prohibition for anything that must not change.** "Do not rename
|
||||||
|
`BriefStatus`" invites reasoning around it; "this count must still be 54, and if it moves,
|
||||||
|
revert rather than adjust the number" does not. RD-11 renamed tags across 19 files with a
|
||||||
|
wire contract in the same file — on the same line in one place — and the count held.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**`dotnet test` fails with `SQLite Error 1: 'no such table: <X>'`.** Stale, gitignored
|
||||||
|
`bigregister.db` artifacts from an old build. Found during RD-11, where a 0-byte file dated
|
||||||
|
months earlier failed 6 backend tests on an otherwise clean tree. Delete all three and re-run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -f backend/bigregister.db backend/src/BigRegister.Api/bigregister.db \
|
||||||
|
backend/tests/BigRegister.Tests/bin/Debug/net10.0/bigregister.db
|
||||||
|
```
|
||||||
|
|
||||||
|
These are build artifacts, not fixtures — removing them is always safe.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ tested where._
|
|||||||
|
|
||||||
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
Every bullet below is a real test name from the suite — an `it()` title (frontend) or a test
|
||||||
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
method name (backend), read as a sentence. Nothing here is hand-written prose: this page
|
||||||
**is** the suite, reshaped for a business reader. 519 frontend behaviours across
|
**is** the suite, reshaped for a business reader. 530 frontend behaviours across
|
||||||
9 contexts; 261 backend behaviours across 42 test
|
9 contexts; 261 backend behaviours across 42 test
|
||||||
classes.
|
classes.
|
||||||
|
|
||||||
@@ -260,6 +260,11 @@ classes.
|
|||||||
- approve fires only from submitted
|
- approve fires only from submitted
|
||||||
- reject fires from submitted, carrying comments
|
- reject fires from submitted, carrying comments
|
||||||
- send fires only from approved
|
- send fires only from approved
|
||||||
|
- ActionStarted moves a loaded brief to Busy
|
||||||
|
- ActionFailed carries the error
|
||||||
|
- ActionFinished returns to Idle
|
||||||
|
- BriefLoaded resets a stale action error to Idle
|
||||||
|
- an action message is a no-op when the brief is not loaded
|
||||||
- a status transition replaces decisions with the fresh server value
|
- a status transition replaces decisions with the fresh server value
|
||||||
|
|
||||||
#### diffBlocks
|
#### diffBlocks
|
||||||
@@ -303,6 +308,12 @@ classes.
|
|||||||
- a completed logo upload sets logoDocumentId + dirty
|
- a completed logo upload sets logoDocumentId + dirty
|
||||||
- removing the logo clears logoDocumentId + dirty
|
- removing the logo clears logoDocumentId + dirty
|
||||||
- DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads
|
- DraftLoaded (sub-org switch) keeps the loaded logo category, drops uploads
|
||||||
|
- PublishRequested moves a loaded template to ConfirmingPublish
|
||||||
|
- PublishCancelled returns to Idle
|
||||||
|
- ActionStarted from ConfirmingPublish goes to Busy, so confirming and busy cannot coexist
|
||||||
|
- ActionFailed carries the error
|
||||||
|
- DraftLoaded resets a stale action error to Idle
|
||||||
|
- an action message is a no-op when the template is not loaded
|
||||||
|
|
||||||
#### parseOrgTemplateAdminView
|
#### parseOrgTemplateAdminView
|
||||||
|
|
||||||
@@ -819,6 +830,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 +853,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,9 +0,0 @@
|
|||||||
/** Transient state of a one-shot action (submit/approve/publish/reset/…): one tagged
|
|
||||||
union instead of a busy boolean + a nullable error sitting side by side. Shared by the
|
|
||||||
editor stores (WP-31). */
|
|
||||||
export type ActionState = { tag: 'Idle' } | { tag: 'Busy' } | { tag: 'Failed'; error: string };
|
|
||||||
|
|
||||||
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
|
|
||||||
concern from ActionState (a stale autosave error doesn't block submit/approve), but
|
|
||||||
tag-aligned with it for one consistent idiom. */
|
|
||||||
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/** Debounced-autosave indicator, shown in a small status line near a toolbar — a separate
|
||||||
|
concern from a store's one-shot action lifecycle (a stale autosave error doesn't block
|
||||||
|
submit/approve), but tag-aligned with it for one consistent idiom. */
|
||||||
|
export type SaveState = { tag: 'Idle' } | { tag: 'Saving' } | { tag: 'Saved' } | { tag: 'Error' };
|
||||||
|
|
||||||
export interface DebouncedSave {
|
export interface DebouncedSave {
|
||||||
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
|
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
|
||||||
schedule(): void;
|
schedule(): void;
|
||||||
@@ -13,7 +18,7 @@ export interface DebouncedSave {
|
|||||||
/**
|
/**
|
||||||
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
|
* The debounced-autosave timer shared by the editor stores (WP-31). It owns ONLY the timer
|
||||||
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
|
* bookkeeping; the actual write + save-state transitions live in the caller's `flush`
|
||||||
* (store-specific — it touches that store's SaveState/ActionState + adapter). The handle is
|
* (store-specific — it touches that store's SaveState + adapter). The handle is
|
||||||
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
|
* nulled the moment it fires, so `hasPendingSave()` means "a write is still owed". Integrates
|
||||||
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
|
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
|
||||||
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
|
* here so the CanDeactivate guard / beforeunload handler can flush a pending edit.
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import { Component, ElementRef, effect, input, output, untracked, viewChild } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
untracked,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||||
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
import { StepperComponent } from '@shared/ui/stepper/stepper.component';
|
||||||
|
import { whenTag } from '@shared/kernel/fp';
|
||||||
|
|
||||||
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
|
/** CIBG procesnavigatie primary-button copy for a non-final step: "Naar stap 2 - Werk".
|
||||||
Shared so every wizard's `primaryLabel` reads the same way. */
|
Shared so every wizard's `primaryLabel` reads the same way. */
|
||||||
@@ -16,7 +26,13 @@ export interface WizardError {
|
|||||||
readonly message: string;
|
readonly message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
/** The wizard shell's lifecycle union. The `Failed` variant carries the localized
|
||||||
|
message intact, so the shell needs no separate input to say what went wrong. */
|
||||||
|
export type WizardPhase =
|
||||||
|
| { tag: 'Editing' }
|
||||||
|
| { tag: 'Submitting' }
|
||||||
|
| { tag: 'Submitted' }
|
||||||
|
| { tag: 'Failed'; message: string };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
* Template: the canonical shell every wizard renders into, so they cannot drift.
|
||||||
@@ -52,8 +68,8 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
|||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
@switch (status()) {
|
@switch (phase().tag) {
|
||||||
@case ('editing') {
|
@case ('Editing') {
|
||||||
<app-stepper
|
<app-stepper
|
||||||
class="app-section"
|
class="app-section"
|
||||||
[steps]="steps()"
|
[steps]="steps()"
|
||||||
@@ -122,14 +138,14 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
@case ('submitting') {
|
@case ('Submitting') {
|
||||||
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
||||||
}
|
}
|
||||||
@case ('submitted') {
|
@case ('Submitted') {
|
||||||
<ng-content select="[wizardSuccess]" />
|
<ng-content select="[wizardSuccess]" />
|
||||||
}
|
}
|
||||||
@case ('failed') {
|
@case ('Failed') {
|
||||||
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
<app-alert type="error">{{ failedMessage() }}</app-alert>
|
||||||
<div class="app-section">
|
<div class="app-section">
|
||||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
||||||
>Opnieuw proberen</app-button
|
>Opnieuw proberen</app-button
|
||||||
@@ -145,13 +161,16 @@ export class WizardShellComponent {
|
|||||||
stepTitle = input.required<string>();
|
stepTitle = input.required<string>();
|
||||||
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
||||||
processName = input('');
|
processName = input('');
|
||||||
status = input.required<WizardStatus>();
|
phase = input.required<WizardPhase>();
|
||||||
primaryLabel = input.required<string>();
|
primaryLabel = input.required<string>();
|
||||||
canGoBack = input(false);
|
canGoBack = input(false);
|
||||||
errors = input<readonly WizardError[]>([]);
|
errors = input<readonly WizardError[]>([]);
|
||||||
errorMessage = input('');
|
|
||||||
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
submittingLabel = input($localize`:@@wizard.submitting:Aanvraag wordt verwerkt…`);
|
||||||
|
|
||||||
|
/** The `Failed` message, or '' otherwise. `@switch` can't narrow a union in a
|
||||||
|
template, so the narrowing happens here via the shared `whenTag` helper. */
|
||||||
|
protected failedMessage = computed(() => whenTag(this.phase(), 'Failed')?.message ?? '');
|
||||||
|
|
||||||
primary = output<void>();
|
primary = output<void>();
|
||||||
back = output<void>();
|
back = output<void>();
|
||||||
cancel = output<void>();
|
cancel = output<void>();
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ const meta: Meta<WizardShellComponent> = {
|
|||||||
props: args,
|
props: args,
|
||||||
template: `
|
template: `
|
||||||
<app-wizard-shell
|
<app-wizard-shell
|
||||||
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
|
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [phase]="phase"
|
||||||
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
|
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors"
|
||||||
(goToStep)="goToStep($event)">
|
(goToStep)="goToStep($event)">
|
||||||
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
|
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
|
||||||
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
|
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
|
||||||
@@ -36,23 +36,25 @@ const base = {
|
|||||||
primaryLabel: 'Volgende',
|
primaryLabel: 'Volgende',
|
||||||
canGoBack: true,
|
canGoBack: true,
|
||||||
errors: [],
|
errors: [],
|
||||||
errorMessage: '',
|
|
||||||
goToStep: () => {},
|
goToStep: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Editing: Story = { args: { ...base, status: 'editing' } };
|
export const Editing: Story = { args: { ...base, phase: { tag: 'Editing' } } };
|
||||||
export const EditingMetFouten: Story = {
|
export const EditingMetFouten: Story = {
|
||||||
args: {
|
args: {
|
||||||
...base,
|
...base,
|
||||||
status: 'editing',
|
phase: { tag: 'Editing' },
|
||||||
errors: [
|
errors: [
|
||||||
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
|
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
|
||||||
{ id: 'diploma', message: 'Kies een diploma.' },
|
{ id: 'diploma', message: 'Kies een diploma.' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
|
export const Submitting: Story = { args: { ...base, phase: { tag: 'Submitting' } } };
|
||||||
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
|
export const Submitted: Story = { args: { ...base, phase: { tag: 'Submitted' } } };
|
||||||
export const Failed: Story = {
|
export const Failed: Story = {
|
||||||
args: { ...base, status: 'failed', errorMessage: 'Het indienen is niet gelukt: netwerkfout.' },
|
args: {
|
||||||
|
...base,
|
||||||
|
phase: { tag: 'Failed', message: 'Het indienen is niet gelukt: netwerkfout.' },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user