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 {
|
||||
const s = store.model();
|
||||
if (s.tag !== 'loaded') throw new Error('not loaded');
|
||||
if (s.tag !== 'Loaded') throw new Error('not loaded');
|
||||
return s.brief;
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
|
||||
|
||||
// Then reset() ran exactly once, and the store ends up loaded from its result.
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(store.model().tag).toBe('loaded');
|
||||
expect(store.model().tag).toBe('Loaded');
|
||||
});
|
||||
|
||||
it('a second 404 does not drive a second reset()', async () => {
|
||||
@@ -447,6 +447,6 @@ describe('BriefStore.load — 404 tolerance (RB-22)', () => {
|
||||
// Then reset() ran exactly once — the once-only bound holds across calls, not
|
||||
// just within one — and the second 404 surfaces as an ordinary load failure.
|
||||
expect(reset).toHaveBeenCalledTimes(1);
|
||||
expect(store.model()).toEqual({ tag: 'failed', reason: BRIEF_LOAD_FAILED });
|
||||
expect(store.model()).toEqual({ tag: 'Failed', reason: BRIEF_LOAD_FAILED });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createHistory } from '@shared/application/history';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||
import {
|
||||
Brief,
|
||||
CaseContext,
|
||||
@@ -29,7 +28,7 @@ import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
* outcome. Mirrors `BigProfileStore`. All of `canEdit`/`canApprove`/`canReject`/
|
||||
* `canSend`, `diagnostics`, `unresolved`, `canSubmit` are DERIVED here — never
|
||||
* stored. The permission flags come from the server's decision DTO (PRD-0002 phase
|
||||
* P1) via `BriefState.loaded.decisions` — this store never computes them itself.
|
||||
* P1) via `BriefState.Loaded.decisions` — this store never computes them itself.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class BriefStore implements PendingSave {
|
||||
@@ -41,11 +40,16 @@ export class BriefStore implements PendingSave {
|
||||
|
||||
readonly model = this.store.model;
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
/** The one-shot action lifecycle now lives on the machine's `Loaded.action` (RD-12);
|
||||
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(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
const s = this.model();
|
||||
return s.tag === 'Loaded' && s.action.tag === 'Failed' ? s.action.error : null;
|
||||
});
|
||||
|
||||
/** 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
|
||||
owning the letter's own domain lifecycle (draft/submitted/approved/…); this is
|
||||
purely a projection of its loading/failed tags onto the shared async seam. */
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||
|
||||
private brief = computed<Brief | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.brief : null;
|
||||
return s.tag === 'Loaded' ? s.brief : null;
|
||||
});
|
||||
|
||||
readonly canEdit = computed(() => this.decisions()?.canEdit ?? false);
|
||||
@@ -111,7 +115,7 @@ export class BriefStore implements PendingSave {
|
||||
|
||||
private decisions = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s.decisions : null;
|
||||
return s.tag === 'Loaded' ? s.decisions : null;
|
||||
});
|
||||
readonly diagnostics = computed(() => (this.brief() ? allDiagnostics(this.brief()!) : []));
|
||||
readonly unresolved = computed(() => (this.brief() ? unresolvedPlaceholders(this.brief()!) : []));
|
||||
@@ -182,7 +186,7 @@ export class BriefStore implements PendingSave {
|
||||
}
|
||||
private restore(step: (current: Brief) => Brief | undefined) {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded') return;
|
||||
if (s.tag !== 'Loaded') return;
|
||||
const target = step(s.brief);
|
||||
if (target === undefined) return;
|
||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||
@@ -212,7 +216,9 @@ export class BriefStore implements PendingSave {
|
||||
if (r.ok) {
|
||||
this.saveState.set({ tag: 'Saved' });
|
||||
} 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' });
|
||||
}
|
||||
}
|
||||
@@ -224,19 +230,19 @@ export class BriefStore implements PendingSave {
|
||||
|
||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||
async resetDemo() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.reset();
|
||||
this.saveState.set({ tag: 'Idle' });
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.caseContext.set(r.value.caseContext);
|
||||
this.history.clear();
|
||||
this.rejectionSnapshot.set(null);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} 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
|
||||
object URL is never revoked. */
|
||||
async previewLetter() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
const r = await this.previewAdapter.preview();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
@@ -269,7 +275,8 @@ export class BriefStore implements PendingSave {
|
||||
async revealBigNummer() {
|
||||
const r = await this.revealAdapter.reveal(true);
|
||||
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;
|
||||
}
|
||||
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
|
||||
// the returned status through the pure reducer's guarded transition.
|
||||
private async transition(action: () => Promise<Result<string, BriefView>>) {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave();
|
||||
const r = await action();
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.applyServerStatus(r.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable, computed, effect, inject, signal } from '@angular/core';
|
||||
import { createStore } from '@shared/application/store';
|
||||
import { ActionState, SaveState } from '@shared/application/action-state';
|
||||
import { createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { machineRemoteData } from '@shared/application/machine-remote-data';
|
||||
import { SaveState, createDebouncedSave } from '@shared/application/debounced-save';
|
||||
import { fromLoadLifecycle } from '@shared/application/remote-data';
|
||||
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
|
||||
import { UploadShellService } from '@shared/application/upload-shell.service';
|
||||
import { UploadMsg, initialUpload, rejectReason } from '@shared/domain/upload.machine';
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
SubOrgSummary,
|
||||
} from '@brief/domain/org-template';
|
||||
import {
|
||||
OrgTemplateActionState,
|
||||
OrgTemplateMsg,
|
||||
OrgTemplateState,
|
||||
initial,
|
||||
@@ -22,7 +22,7 @@ import { OrgTemplateAdapter } from '@brief/infrastructure/org-template.adapter';
|
||||
import { PendingSave, registerPendingSave } from '@shared/application/pending-saves';
|
||||
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
|
||||
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'loaded' }>;
|
||||
type LoadedState = Extract<OrgTemplateState, { tag: 'Loaded' }>;
|
||||
|
||||
const LOGO_CATEGORY = 'org-logo';
|
||||
const NO_SUBORGS = $localize`:@@orgTemplate.noSubOrgs:Er zijn geen organisatiesjablonen om te beheren.`;
|
||||
@@ -47,22 +47,28 @@ export class OrgTemplateStore implements PendingSave {
|
||||
readonly subOrgs = signal<readonly SubOrgSummary[]>([]);
|
||||
readonly selectedSubOrgId = signal<string | null>(null);
|
||||
|
||||
private actionState = signal<ActionState>({ tag: 'Idle' });
|
||||
readonly busy = computed(() => this.actionState().tag === 'Busy');
|
||||
/** The one-shot action lifecycle and the publish impact-confirm gate now live on
|
||||
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(() => {
|
||||
const s = this.actionState();
|
||||
return s.tag === 'Failed' ? s.error : null;
|
||||
const a = this.action();
|
||||
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' });
|
||||
|
||||
/** The publish impact-confirm gate (PRD §7h: show N affected letters before POST). */
|
||||
readonly pendingPublish = signal(false);
|
||||
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||
|
||||
private loaded = computed<LoadedState | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : null;
|
||||
return s.tag === 'Loaded' ? s : null;
|
||||
});
|
||||
readonly draft = computed<OrgTemplate | null>(() => this.loaded()?.draft ?? null);
|
||||
readonly uploadState = computed(() => this.loaded()?.upload ?? initialUpload);
|
||||
@@ -101,7 +107,7 @@ export class OrgTemplateStore implements PendingSave {
|
||||
// the length guard makes it idempotent (no dispatch loop).
|
||||
effect(() => {
|
||||
const s = this.model();
|
||||
if (s.tag !== 'loaded' || s.upload.categories.length > 0) return;
|
||||
if (s.tag !== 'Loaded' || s.upload.categories.length > 0) return;
|
||||
const status = this.categoriesRes.status();
|
||||
if (status === 'resolved' || status === 'local')
|
||||
this.dispatchUpload({
|
||||
@@ -165,60 +171,64 @@ export class OrgTemplateStore implements PendingSave {
|
||||
this.store.dispatch({ tag: 'DraftSaved', savedDraft: draft });
|
||||
} else {
|
||||
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 ---
|
||||
|
||||
// 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() {
|
||||
this.pendingPublish.set(true);
|
||||
this.store.dispatch({ tag: 'PublishRequested' });
|
||||
}
|
||||
cancelPublish() {
|
||||
this.pendingPublish.set(false);
|
||||
this.store.dispatch({ tag: 'PublishCancelled' });
|
||||
}
|
||||
async confirmPublish() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.pendingPublish.set(false);
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
// ActionStarted overwrites `action` straight to Busy, so ConfirmingPublish and
|
||||
// Busy are never simultaneously true (RD-13).
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // publish the saved draft — flush any pending edit first
|
||||
const r = await this.adapter.publish(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
await this.selectSubOrg(s.subOrgId); // reload: new version, history, unsentBriefs = 0
|
||||
}
|
||||
|
||||
async rollback(version: number) {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
const r = await this.adapter.rollback(s.subOrgId, version);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.store.dispatch({ tag: 'DraftLoaded', view: r.value }); // old version copied into draft
|
||||
}
|
||||
|
||||
async proefbrief() {
|
||||
const s = this.loaded();
|
||||
if (!s) return;
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
this.store.dispatch({ tag: 'ActionStarted' });
|
||||
this.debouncedSave.cancel();
|
||||
await this.flushSave(); // the proefbrief renders the server's draft
|
||||
const r = await this.adapter.proefbrief(s.subOrgId);
|
||||
if (!r.ok) {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
this.store.dispatch({ tag: 'ActionFailed', error: r.error });
|
||||
return;
|
||||
}
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.store.dispatch({ tag: 'ActionFinished' });
|
||||
this.blobPresenter.open(r.value);
|
||||
}
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sectio
|
||||
});
|
||||
|
||||
const sectionBlocks = (s: BriefState, key: string) =>
|
||||
s.tag === 'loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
s.tag === 'Loaded' ? s.brief.sections.find((x) => x.sectionKey === key)!.blocks : [];
|
||||
|
||||
const passageIds = (s: BriefState, key: string) =>
|
||||
sectionBlocks(s, key)
|
||||
@@ -92,12 +92,12 @@ describe('brief.machine reduce', () => {
|
||||
availablePassages: [],
|
||||
decisions,
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
).toBe('Loaded');
|
||||
});
|
||||
|
||||
it('BriefLoadFailed moves loading to failed with the reason', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
tag: 'failed',
|
||||
tag: 'Failed',
|
||||
reason: 'x',
|
||||
});
|
||||
});
|
||||
@@ -210,7 +210,7 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'graag aanpassen',
|
||||
});
|
||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(next.tag === 'Loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -220,7 +220,7 @@ describe('brief.machine reduce', () => {
|
||||
// fill the required section via the besluit, then submit
|
||||
const filled = reduce(loaded(), besluit('positief'));
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't', decisions });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
expect(submitted.tag === 'Loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
submittedAt: 't',
|
||||
@@ -232,7 +232,7 @@ describe('brief.machine reduce', () => {
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't', decisions })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2', decisions });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
expect(approved.tag === 'Loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
@@ -248,7 +248,7 @@ describe('brief.machine reduce', () => {
|
||||
comments: 'nee',
|
||||
decisions,
|
||||
});
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
expect(rejected.tag === 'Loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't2',
|
||||
@@ -262,7 +262,40 @@ describe('brief.machine reduce', () => {
|
||||
// send from submitted is a no-op
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't', decisions })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3', decisions });
|
||||
expect(sent.tag === 'loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
expect(sent.tag === 'Loaded' && sent.brief.status).toEqual({ tag: 'sent', sentAt: 't3' });
|
||||
});
|
||||
|
||||
// --- 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', () => {
|
||||
@@ -280,10 +313,10 @@ describe('brief.machine reduce', () => {
|
||||
at: 't2',
|
||||
decisions: staleApprover,
|
||||
});
|
||||
expect(approved.tag === 'loaded' && approved.decisions).toEqual(staleApprover);
|
||||
expect(approved.tag === 'Loaded' && approved.decisions).toEqual(staleApprover);
|
||||
});
|
||||
});
|
||||
|
||||
function initialLoading(): BriefState {
|
||||
return { tag: 'loading' };
|
||||
return { tag: 'Loading' };
|
||||
}
|
||||
|
||||
@@ -36,17 +36,22 @@ import { passagesForBesluit } from './besluit';
|
||||
* 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 =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'Loading' }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
tag: 'Loaded';
|
||||
brief: Brief;
|
||||
availablePassages: readonly LibraryPassage[];
|
||||
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 =
|
||||
| {
|
||||
@@ -65,7 +70,10 @@ export type BriefMsg =
|
||||
| { tag: 'Approved'; by: string; at: string; decisions: BriefDecisions } // submitted → approved
|
||||
| { tag: 'Rejected'; by: string; at: string; comments: string; decisions: BriefDecisions } // submitted → rejected
|
||||
| { 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. */
|
||||
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. */
|
||||
function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
if (s.tag !== 'loaded' || !isEditable(s.brief.status)) return s;
|
||||
if (s.tag !== 'Loaded' || !isEditable(s.brief.status)) return s;
|
||||
let brief = f(s.brief);
|
||||
if (brief.status.tag === 'rejected') brief = { ...brief, status: { tag: 'draft' } };
|
||||
return { ...s, brief };
|
||||
@@ -189,13 +197,16 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
switch (m.tag) {
|
||||
case 'BriefLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
tag: 'Loaded',
|
||||
brief: m.brief,
|
||||
availablePassages: m.availablePassages,
|
||||
decisions: m.decisions,
|
||||
// A fresh load clears a stale action error rather than letting it outlive
|
||||
// the reload (RD-12, decision 4).
|
||||
action: { tag: 'Idle' },
|
||||
};
|
||||
case 'BriefLoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
return { tag: 'Failed', reason: m.reason };
|
||||
case 'Seed':
|
||||
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.
|
||||
case 'BesluitSelected':
|
||||
return withEdit(s, (b) =>
|
||||
s.tag === 'loaded' && isSectionEditable(b, 'kern')
|
||||
s.tag === 'Loaded' && isSectionEditable(b, 'kern')
|
||||
? composeKern(b, s.availablePassages, m.besluit, m.reasons)
|
||||
: b,
|
||||
);
|
||||
@@ -260,6 +271,15 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
case 'Sent':
|
||||
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:
|
||||
return assertNever(m);
|
||||
}
|
||||
@@ -275,6 +295,6 @@ function transition(
|
||||
decisions: BriefDecisions,
|
||||
guard: (b: Brief) => boolean = () => true,
|
||||
): BriefState {
|
||||
if (s.tag !== 'loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
if (s.tag !== 'Loaded' || s.brief.status.tag !== from || !guard(s.brief)) return s;
|
||||
return { ...s, brief: { ...s.brief, status: next() }, decisions };
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const view = (over: Partial<OrgTemplateAdminView> = {}): OrgTemplateAdminView =>
|
||||
});
|
||||
|
||||
const loaded = (): OrgTemplateState =>
|
||||
reduce({ tag: 'loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
reduce({ tag: 'Loading' }, { tag: 'DraftLoaded', view: view() });
|
||||
|
||||
const logoCategory: DocumentCategory = {
|
||||
categoryId: 'org-logo',
|
||||
@@ -41,7 +41,7 @@ const logoCategory: DocumentCategory = {
|
||||
|
||||
describe('org-template.machine', () => {
|
||||
it('DraftLoaded moves to loaded with the draft, clean', () => {
|
||||
const s = expectTag(loaded(), 'loaded');
|
||||
const s = expectTag(loaded(), 'Loaded');
|
||||
expect(s.draft.orgName).toBe('CIBG');
|
||||
expect(s.subOrgId).toBe('cibg-registers');
|
||||
expect(s.unsentBriefs).toBe(2);
|
||||
@@ -49,14 +49,14 @@ describe('org-template.machine', () => {
|
||||
});
|
||||
|
||||
it('LoadFailed carries the reason', () => {
|
||||
const s = reduce({ tag: 'loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'failed', reason: 'boom' });
|
||||
const s = reduce({ tag: 'Loading' }, { tag: 'LoadFailed', reason: 'boom' });
|
||||
expect(s).toEqual({ tag: 'Failed', reason: 'boom' });
|
||||
});
|
||||
|
||||
it('FieldEdited edits the draft and marks dirty', () => {
|
||||
const s = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'CIBG Nieuw' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(s.draft.orgName).toBe('CIBG Nieuw');
|
||||
expect(s.dirty).toBe(true);
|
||||
@@ -65,7 +65,7 @@ describe('org-template.machine', () => {
|
||||
it('MarginEdited edits one edge and marks dirty', () => {
|
||||
const s = expectTag(
|
||||
reduce(loaded(), { tag: 'MarginEdited', edge: 'topMm', value: 40 }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(s.draft.margins.topMm).toBe(40);
|
||||
expect(s.draft.margins.leftMm).toBe(20);
|
||||
@@ -75,9 +75,9 @@ describe('org-template.machine', () => {
|
||||
it('DraftSaved clears dirty when the saved draft is the current one', () => {
|
||||
const edited = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'loaded');
|
||||
const s = expectTag(reduce(edited, { tag: 'DraftSaved', savedDraft: edited.draft }), 'Loaded');
|
||||
expect(s.dirty).toBe(false);
|
||||
expect(s.draft.orgName).toBe('X');
|
||||
});
|
||||
@@ -85,20 +85,20 @@ describe('org-template.machine', () => {
|
||||
it('DraftSaved keeps dirty when an edit landed during the save round-trip', () => {
|
||||
const editing = expectTag(
|
||||
reduce(loaded(), { tag: 'FieldEdited', field: 'orgName', value: 'X' }),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
const savedDraft = editing.draft;
|
||||
// a further edit changes the draft reference before the save resolves
|
||||
const raced = reduce(editing, { tag: 'FieldEdited', field: 'orgName', value: 'Y' });
|
||||
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'loaded');
|
||||
const s = expectTag(reduce(raced, { tag: 'DraftSaved', savedDraft }), 'Loaded');
|
||||
expect(s.dirty).toBe(true);
|
||||
});
|
||||
|
||||
it('edits are no-ops in non-loaded states', () => {
|
||||
expect(
|
||||
reduce({ tag: 'loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
reduce({ tag: 'Loading' }, { tag: 'FieldEdited', field: 'orgName', value: 'x' }),
|
||||
).toEqual({
|
||||
tag: 'loading',
|
||||
tag: 'Loading',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('org-template.machine', () => {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadComplete', localId: 'a', documentId: 'doc-1' },
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(done.draft.logoDocumentId).toBe('doc-1');
|
||||
expect(done.dirty).toBe(true);
|
||||
@@ -138,7 +138,7 @@ describe('org-template.machine', () => {
|
||||
tag: 'Upload',
|
||||
msg: { type: 'UploadRemoved', localId: 'a' },
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(removed.draft.logoDocumentId).toBeUndefined();
|
||||
expect(removed.dirty).toBe(true);
|
||||
@@ -154,10 +154,50 @@ describe('org-template.machine', () => {
|
||||
tag: 'DraftLoaded',
|
||||
view: view({ draft: { ...template, subOrgId: 'cibg-vakbekwaamheid' } }),
|
||||
}),
|
||||
'loaded',
|
||||
'Loaded',
|
||||
);
|
||||
expect(switched.upload.categories).toHaveLength(1);
|
||||
expect(switched.upload.uploads).toHaveLength(0);
|
||||
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
|
||||
* the composable upload sub-machine folded in, exactly like the wizards fold
|
||||
* `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. */
|
||||
@@ -21,11 +28,22 @@ export type OrgTemplateTextField =
|
||||
| 'signatureRole'
|
||||
| '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 =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Failed'; reason: string }
|
||||
| {
|
||||
tag: 'loaded';
|
||||
tag: 'Loaded';
|
||||
subOrgId: string;
|
||||
draft: OrgTemplate;
|
||||
publishedVersion: number;
|
||||
@@ -34,9 +52,10 @@ export type OrgTemplateState =
|
||||
dirty: boolean;
|
||||
/** Logo upload sub-state (single file, `org-logo` category). */
|
||||
upload: UploadState;
|
||||
action: OrgTemplateActionState;
|
||||
};
|
||||
|
||||
export const initial: OrgTemplateState = { tag: 'loading' };
|
||||
export const initial: OrgTemplateState = { tag: 'Loading' };
|
||||
|
||||
export type OrgTemplateMsg =
|
||||
| { tag: 'Loading' }
|
||||
@@ -47,22 +66,27 @@ export type OrgTemplateMsg =
|
||||
/** 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. */
|
||||
| { 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). */
|
||||
function editDraft(s: OrgTemplateState, f: (draft: OrgTemplate) => OrgTemplate): OrgTemplateState {
|
||||
return s.tag === 'loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
return s.tag === 'Loaded' ? { ...s, draft: f(s.draft), dirty: true } : s;
|
||||
}
|
||||
|
||||
export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
return { tag: 'Loading' };
|
||||
case 'LoadFailed':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
return { tag: 'Failed', reason: m.reason };
|
||||
case 'DraftLoaded':
|
||||
return {
|
||||
tag: 'loaded',
|
||||
tag: 'Loaded',
|
||||
subOrgId: m.view.draft.subOrgId,
|
||||
draft: m.view.draft,
|
||||
publishedVersion: m.view.publishedVersion,
|
||||
@@ -71,16 +95,19 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
||||
dirty: false,
|
||||
// Keep the loaded logo category across sub-org switches (it's the same
|
||||
// `org-logo` category, loaded once); drop only any in-flight/finished uploads.
|
||||
upload: s.tag === 'loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
upload: s.tag === 'Loaded' ? { ...s.upload, uploads: [], rejections: {} } : initialUpload,
|
||||
// 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':
|
||||
return editDraft(s, (d) => ({ ...d, [m.field]: m.value }));
|
||||
case 'MarginEdited':
|
||||
return editDraft(s, (d) => ({ ...d, margins: { ...d.margins, [m.edge]: m.value } }));
|
||||
case 'DraftSaved':
|
||||
return s.tag === 'loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
return s.tag === 'Loaded' && s.draft === m.savedDraft ? { ...s, dirty: false } : s;
|
||||
case 'Upload': {
|
||||
if (s.tag !== 'loaded') return s;
|
||||
if (s.tag !== 'Loaded') return s;
|
||||
const upload = reduceUpload(s.upload, m.msg);
|
||||
// A completed/removed logo upload also updates the draft's logoDocumentId.
|
||||
if (m.msg.type === 'UploadComplete')
|
||||
@@ -96,6 +123,22 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
|
||||
}
|
||||
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:
|
||||
return assertNever(m);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class BriefPage {
|
||||
Success value is unwrapped here instead of through `let-`. */
|
||||
protected readonly loaded = computed(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : undefined;
|
||||
return s.tag === 'Loaded' ? s : undefined;
|
||||
});
|
||||
|
||||
protected reload() {
|
||||
|
||||
+14
-13
@@ -6,7 +6,7 @@ import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
WizardPhase,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.component';
|
||||
@@ -50,11 +50,10 @@ import { UploadState, initialUpload, deliveryRefs } from '@shared/domain/upload.
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@herregWizard.processName"
|
||||
processName="Herregistratie aanvragen"
|
||||
[status]="shellStatus()"
|
||||
[phase]="phase()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="step() > 1"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="dispatch({ tag: 'Primary' })"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
@@ -214,7 +213,6 @@ export class HerregistratieWizardComponent {
|
||||
protected errJaren = computed(() => this.editing()?.errors.jaren ?? '');
|
||||
protected errPunten = computed(() => this.editing()?.errors.punten ?? '');
|
||||
protected errDocumenten = computed(() => this.editing()?.errors.documenten ?? '');
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
protected uploadCtl = createUploadController({
|
||||
wizardId: 'herregistratie',
|
||||
getUpload: () => this.upload(),
|
||||
@@ -234,19 +232,22 @@ export class HerregistratieWizardComponent {
|
||||
protected goToStep(index: number) {
|
||||
this.dispatch({ tag: 'GaNaarStap', step: (index + 1) as 1 | 2 | 3 });
|
||||
}
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||
protected phase = computed<WizardPhase>(() => {
|
||||
const s = this.state();
|
||||
switch (s.tag) {
|
||||
case 'Editing':
|
||||
return 'editing';
|
||||
return { tag: 'Editing' };
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
return { tag: 'Submitting' };
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
return { tag: 'Submitted' };
|
||||
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. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
WizardPhase,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { createStore } from '@shared/application/store';
|
||||
@@ -59,11 +59,10 @@ import { IntakePolicyStore } from '@herregistratie/application/intake-policy.sto
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@intake.processName"
|
||||
processName="Herregistratie-intake"
|
||||
[status]="shellStatus()"
|
||||
[phase]="phase()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
(primary)="dispatch({ tag: 'Primary' })"
|
||||
(back)="dispatch({ tag: 'Back' })"
|
||||
(cancel)="restart()"
|
||||
@@ -323,7 +322,6 @@ export class IntakeWizardComponent {
|
||||
);
|
||||
/** Whether the inline scholing question is shown (and required) in the 'werk' step. */
|
||||
protected scholingZichtbaar = computed(() => lageUren(this.answers(), this.scholingThreshold()));
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Failed')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
readonly stepLabels = [
|
||||
@@ -342,19 +340,22 @@ export class IntakeWizardComponent {
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(
|
||||
() => $localize`:@@wizard.indienenMislukt:Indienen mislukt:` + ` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||
protected phase = computed<WizardPhase>(() => {
|
||||
const s = this.state();
|
||||
switch (s.tag) {
|
||||
case 'Answering':
|
||||
return 'editing';
|
||||
return { tag: 'Editing' };
|
||||
case 'Submitting':
|
||||
return 'submitting';
|
||||
return { tag: 'Submitting' };
|
||||
case 'Submitted':
|
||||
return 'submitted';
|
||||
return { tag: 'Submitted' };
|
||||
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
|
||||
|
||||
+15
-15
@@ -14,7 +14,7 @@ import { ConfirmationComponent } from '@shared/ui/confirmation/confirmation.comp
|
||||
import {
|
||||
WizardShellComponent,
|
||||
WizardError,
|
||||
WizardStatus,
|
||||
WizardPhase,
|
||||
naarStapLabel,
|
||||
} from '@shared/layout/wizard-shell/wizard-shell.component';
|
||||
import { ASYNC } from '@shared/ui/async/async.component';
|
||||
@@ -83,11 +83,10 @@ const NL_TAALVAARDIGHEID_VRAAG = 'nl-taalvaardigheid';
|
||||
[stepTitle]="stepTitle()"
|
||||
i18n-processName="@@regWizard.processName"
|
||||
processName="Inschrijven in het BIG-register"
|
||||
[status]="shellStatus()"
|
||||
[phase]="phase()"
|
||||
[primaryLabel]="primaryLabel()"
|
||||
[canGoBack]="cursor() > 0"
|
||||
[errors]="errorList()"
|
||||
[errorMessage]="errorMessage()"
|
||||
i18n-submittingLabel="@@regWizard.submitting"
|
||||
submittingLabel="Uw registratie wordt verwerkt…"
|
||||
(primary)="dispatch({ tag: 'Primary' })"
|
||||
@@ -440,7 +439,6 @@ export class RegistratieWizardComponent {
|
||||
() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)],
|
||||
);
|
||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||
protected failedError = computed(() => whenTag(this.state(), 'Mislukt')?.error ?? '');
|
||||
|
||||
// --- Presentational wiring for the shared wizard shell ---------------------
|
||||
protected primaryLabel = computed(() => {
|
||||
@@ -448,21 +446,23 @@ export class RegistratieWizardComponent {
|
||||
const next = this.cursor() + 1;
|
||||
return naarStapLabel(next + 1, this.stepLabels[next]);
|
||||
});
|
||||
protected errorMessage = computed(
|
||||
() =>
|
||||
$localize`:@@regWizard.indienenMislukt:Het indienen is niet gelukt:` +
|
||||
` ${this.failedError()}`,
|
||||
);
|
||||
protected shellStatus = computed<WizardStatus>(() => {
|
||||
switch (this.state().tag) {
|
||||
/** Maps this machine's own tags onto the shell's `WizardPhase` vocabulary,
|
||||
composing the localized failure prefix so the `Failed` message arrives intact. */
|
||||
protected phase = computed<WizardPhase>(() => {
|
||||
const s = this.state();
|
||||
switch (s.tag) {
|
||||
case 'Invullen':
|
||||
return 'editing';
|
||||
return { tag: 'Editing' };
|
||||
case 'Indienen':
|
||||
return 'submitting';
|
||||
return { tag: 'Submitting' };
|
||||
case 'Ingediend':
|
||||
return 'submitted';
|
||||
return { tag: 'Submitted' };
|
||||
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. */
|
||||
|
||||
@@ -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-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-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-18 | Ticket sweep, frontend — 181 refs / 100 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
|
||||
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):
|
||||
`parseDashboardView` still returns `DashboardView`
|
||||
(`registratie/infrastructure/dashboard-view.adapter.ts:119`), and
|
||||
`big-profile.store.ts` pays twice — line 35 computes `RemoteData<Err, DashboardView>`,
|
||||
line 45 re-maps to `RemoteData<Err, BigProfile>`. Make the parse return `BigProfile`
|
||||
directly and delete the intermediate. One payload, one name.
|
||||
2. ~~**Finish Step 2's name collapse.**~~ **DROPPED while executing RD-16 — the instruction
|
||||
was wrong, and following it would have introduced a bug.**
|
||||
|
||||
This plan claimed `DashboardViewDto → DashboardView → BigProfile` was "three names for one
|
||||
payload" and that `parseDashboardView` should return `BigProfile` directly. Reading the
|
||||
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
|
||||
want the same helper:
|
||||
- `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:
|
||||
|
||||
- **Step 2 did not fully land** — the `DashboardViewDto → DashboardView → BigProfile` chain is
|
||||
intact (Phase 2.2).
|
||||
- ~~**Step 2 did not fully land.**~~ **This correction was itself wrong, and is withdrawn.**
|
||||
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
|
||||
`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.
|
||||
|
||||
@@ -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-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-10 | `WizardStatus` to a payload-carrying `WizardPhase` | 08 | yes | todo |
|
||||
| RD-11 | Fold the lifecycle projection into `remote-data.ts`; PascalCase 3 machines | 01 | | todo |
|
||||
| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | todo |
|
||||
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | todo |
|
||||
| RD-14 | Move `SaveState` to `debounced-save.ts`; delete `action-state.ts` | 13 | | todo |
|
||||
| RD-15 | Remove 22 abandoned agent worktrees (4.7 GB) | 01 | | todo |
|
||||
| RD-16 | `parseDashboardView` returns `BigProfile`; delete `DashboardView` | 01 | | 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 | | done |
|
||||
| RD-12 | `ActionState` becomes `action` on `BriefState.Loaded` | 11 | | done |
|
||||
| RD-13 | Same for org-template, folding `pendingPublish` in | 12 | | done |
|
||||
| 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 | | done |
|
||||
| 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-18 | Ticket-reference sweep, frontend — 181 refs, 100 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.
|
||||
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,
|
||||
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:
|
||||
- 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
|
||||
@@ -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
|
||||
abandoned worktrees. Satisfying it literally would have corrupted completed-ticket
|
||||
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
|
||||
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.
|
||||
- **Anchor on a declaration** (`^ onRetry\(\)`), not on a name that may legitimately
|
||||
appear elsewhere.
|
||||
- **Keep the Files list consistent with the Acceptance commands.** If a command reaches a
|
||||
file the ticket says not to touch, one of the two is wrong.
|
||||
- **`git grep -c` counts matching LINES, not occurrences.** RD-14 asserted
|
||||
`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 { 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 {
|
||||
ChangeCounts,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
|
||||
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
|
||||
@@ -42,11 +42,11 @@ export class StamdataStore {
|
||||
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
|
||||
readonly previewDate = signal<string>('');
|
||||
|
||||
readonly remoteData = computed(() => machineRemoteData(this.model()));
|
||||
readonly remoteData = computed(() => fromLoadLifecycle(this.model()));
|
||||
|
||||
private loaded = computed<LoadedState | null>(() => {
|
||||
const s = this.model();
|
||||
return s.tag === 'loaded' ? s : null;
|
||||
return s.tag === 'Loaded' ? s : null;
|
||||
});
|
||||
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
|
||||
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
|
||||
|
||||
@@ -24,40 +24,40 @@ const seedLoaded = (): StamdataEditorState =>
|
||||
describe('stamdata-editor reduce', () => {
|
||||
it('Loaded snapshots original independently of rows', () => {
|
||||
const s = seedLoaded();
|
||||
expect(s.tag).toBe('loaded');
|
||||
if (s.tag !== 'loaded') return;
|
||||
expect(s.tag).toBe('Loaded');
|
||||
if (s.tag !== 'Loaded') return;
|
||||
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.original[0]['beroep']).toBe('Arts'); // snapshot untouched → diff works
|
||||
});
|
||||
|
||||
it('RowAdded appends an empty row shaped by the schema', () => {
|
||||
const s = reduce(seedLoaded(), { tag: 'RowAdded' });
|
||||
if (s.tag !== 'loaded') return;
|
||||
if (s.tag !== 'Loaded') return;
|
||||
expect(s.rows).toHaveLength(2);
|
||||
expect(s.rows[1]).toEqual({ program: '', beroep: '', geldigVan: '', geldigTot: '' });
|
||||
});
|
||||
|
||||
it('RowRemoved drops the row at the index', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
it('edit messages are ignored unless loaded', () => {
|
||||
expect(reduce(initial, { tag: 'RowAdded' })).toBe(initial);
|
||||
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,
|
||||
).toBe('failed');
|
||||
).toBe('Failed');
|
||||
});
|
||||
|
||||
it('LoadFailed and Loading transition regardless of prior state', () => {
|
||||
expect(reduce(seedLoaded(), { tag: 'LoadFailed', reason: 'boom' })).toEqual({
|
||||
tag: 'failed',
|
||||
tag: 'Failed',
|
||||
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).
|
||||
*/
|
||||
export type StamdataEditorState =
|
||||
| { tag: 'loading' }
|
||||
| { tag: 'failed'; reason: string }
|
||||
| { tag: 'loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
|
||||
| { tag: 'Loading' }
|
||||
| { tag: 'Failed'; reason: string }
|
||||
| { tag: 'Loaded'; table: StamTable; rows: StamRow[]; original: readonly StamRow[] };
|
||||
|
||||
export type StamdataEditorMsg =
|
||||
| { tag: 'Loading' }
|
||||
@@ -24,29 +24,29 @@ export type StamdataEditorMsg =
|
||||
| { tag: 'RowRemoved'; row: number }
|
||||
| { 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 }));
|
||||
|
||||
export function reduce(s: StamdataEditorState, m: StamdataEditorMsg): StamdataEditorState {
|
||||
switch (m.tag) {
|
||||
case 'Loading':
|
||||
return { tag: 'loading' };
|
||||
return { tag: 'Loading' };
|
||||
case 'Loaded':
|
||||
// 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':
|
||||
return { tag: 'failed', reason: m.reason };
|
||||
return { tag: 'Failed', reason: m.reason };
|
||||
case 'CellEdited':
|
||||
if (s.tag !== 'loaded') return s;
|
||||
if (s.tag !== 'Loaded') return s;
|
||||
return {
|
||||
...s,
|
||||
rows: s.rows.map((r, i) => (i === m.row ? { ...r, [m.column]: m.value } : r)),
|
||||
};
|
||||
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':
|
||||
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':
|
||||
return m.state;
|
||||
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
|
||||
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
|
||||
classes.
|
||||
|
||||
@@ -260,6 +260,11 @@ classes.
|
||||
- approve fires only from submitted
|
||||
- reject fires from submitted, carrying comments
|
||||
- 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
|
||||
|
||||
#### diffBlocks
|
||||
@@ -303,6 +308,12 @@ classes.
|
||||
- a completed logo upload sets logoDocumentId + dirty
|
||||
- removing the logo clears logoDocumentId + dirty
|
||||
- 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
|
||||
|
||||
@@ -819,6 +830,12 @@ classes.
|
||||
- is empty-safe: undefined, null, and empty string all yield the empty string
|
||||
- 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
|
||||
|
||||
- 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
|
||||
- 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)
|
||||
|
||||
- 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
|
||||
protected readonly loaded = computed(() => {
|
||||
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
|
||||
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
|
||||
machine's own `loading`/`failed` tags purely mirror the fetch (nothing extra beyond "not
|
||||
loaded yet" / "the GET failed"), project them onto a `RemoteData` computed at the store
|
||||
layer for `<app-async>` to render, the way `BriefStore.remoteData` does — the machine
|
||||
machine's own `Loading`/`Failed`/`Loaded` tags purely mirror the fetch (nothing extra
|
||||
beyond "not loaded yet" / "the GET failed"), project them with `fromLoadLifecycle` at the
|
||||
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
|
||||
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 {
|
||||
/** (Re)arm the debounce timer; no-op when `canSave()` is false. */
|
||||
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
|
||||
* 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
|
||||
* with the `PendingSave` seam (pending-saves.ts): a store delegates hasPendingSave/flushPending
|
||||
* 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 { 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';
|
||||
|
||||
const loadingRd: RemoteData<string, number> = loading();
|
||||
@@ -33,3 +33,20 @@ describe('successOf', () => {
|
||||
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' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** Exhaustive fold: you must handle every case, checked at compile time. */
|
||||
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 { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.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".
|
||||
Shared so every wizard's `primaryLabel` reads the same way. */
|
||||
@@ -16,7 +26,13 @@ export interface WizardError {
|
||||
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.
|
||||
@@ -52,8 +68,8 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
@switch (status()) {
|
||||
@case ('editing') {
|
||||
@switch (phase().tag) {
|
||||
@case ('Editing') {
|
||||
<app-stepper
|
||||
class="app-section"
|
||||
[steps]="steps()"
|
||||
@@ -122,14 +138,14 @@ export type WizardStatus = 'editing' | 'submitting' | 'submitted' | 'failed';
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
@case ('submitting') {
|
||||
@case ('Submitting') {
|
||||
<app-spinner /> <span>{{ submittingLabel() }}</span>
|
||||
}
|
||||
@case ('submitted') {
|
||||
@case ('Submitted') {
|
||||
<ng-content select="[wizardSuccess]" />
|
||||
}
|
||||
@case ('failed') {
|
||||
<app-alert type="error">{{ errorMessage() }}</app-alert>
|
||||
@case ('Failed') {
|
||||
<app-alert type="error">{{ failedMessage() }}</app-alert>
|
||||
<div class="app-section">
|
||||
<app-button variant="secondary" (click)="retry.emit()" i18n="@@wizard.opnieuwProberen"
|
||||
>Opnieuw proberen</app-button
|
||||
@@ -145,13 +161,16 @@ export class WizardShellComponent {
|
||||
stepTitle = input.required<string>();
|
||||
/** Overall process name, shown above the step title (e.g. "Herregistratie aanvragen"). */
|
||||
processName = input('');
|
||||
status = input.required<WizardStatus>();
|
||||
phase = input.required<WizardPhase>();
|
||||
primaryLabel = input.required<string>();
|
||||
canGoBack = input(false);
|
||||
errors = input<readonly WizardError[]>([]);
|
||||
errorMessage = input('');
|
||||
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>();
|
||||
back = output<void>();
|
||||
cancel = output<void>();
|
||||
|
||||
@@ -8,8 +8,8 @@ const meta: Meta<WizardShellComponent> = {
|
||||
props: args,
|
||||
template: `
|
||||
<app-wizard-shell
|
||||
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [status]="status"
|
||||
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors" [errorMessage]="errorMessage"
|
||||
[steps]="steps" [current]="current" [stepTitle]="stepTitle" [processName]="processName" [phase]="phase"
|
||||
[primaryLabel]="primaryLabel" [canGoBack]="canGoBack" [errors]="errors"
|
||||
(goToStep)="goToStep($event)">
|
||||
<p class="rhc-paragraph">Voorbeeld-stapinhoud (de stapvelden worden hier geprojecteerd).</p>
|
||||
<div wizardSuccess><p class="rhc-paragraph">Uw aanvraag is ontvangen.</p></div>
|
||||
@@ -36,23 +36,25 @@ const base = {
|
||||
primaryLabel: 'Volgende',
|
||||
canGoBack: true,
|
||||
errors: [],
|
||||
errorMessage: '',
|
||||
goToStep: () => {},
|
||||
};
|
||||
|
||||
export const Editing: Story = { args: { ...base, status: 'editing' } };
|
||||
export const Editing: Story = { args: { ...base, phase: { tag: 'Editing' } } };
|
||||
export const EditingMetFouten: Story = {
|
||||
args: {
|
||||
...base,
|
||||
status: 'editing',
|
||||
phase: { tag: 'Editing' },
|
||||
errors: [
|
||||
{ id: 'uren', message: 'Vul het aantal gewerkte uren in.' },
|
||||
{ id: 'diploma', message: 'Kies een diploma.' },
|
||||
],
|
||||
},
|
||||
};
|
||||
export const Submitting: Story = { args: { ...base, status: 'submitting' } };
|
||||
export const Submitted: Story = { args: { ...base, status: 'submitted' } };
|
||||
export const Submitting: Story = { args: { ...base, phase: { tag: 'Submitting' } } };
|
||||
export const Submitted: Story = { args: { ...base, phase: { tag: 'Submitted' } } };
|
||||
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