refactor: fold org-template's action lifecycle + pendingPublish into one union (RD-13)

Before this change, org-template.store.ts held the action lifecycle in an
actionState signal and the publish impact-confirm gate in an independent
pendingPublish signal. The two were representable in combination, so
pendingPublish === true and busy === true could both hold at once. That
state was meaningless: the UI would show the publish-impact confirmation
while a publish was already in flight.

OrgTemplateState.Loaded now carries one action field, a four-variant union
(Idle | ConfirmingPublish | Busy | Failed). ActionStarted overwrites the
field straight to Busy from any prior tag, so ConfirmingPublish and Busy
can never coexist — not by convention, but because one field can only
hold one tag. requestPublish and cancelPublish become dispatches
(PublishRequested/PublishCancelled); as the reducer already no-ops
outside Loaded, this changes no behaviour. The other four commands
(confirmPublish, rollback, proefbrief, flushSave) keep their existing
loaded() guards. busy, lastError and pendingPublish stay on the store as
computed values reading the new union, with byte-identical public
signatures — no file under brief/ui/ changes.

Ran gen:behaviour-spec for the six new reducer cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-04 18:38:23 +02:00
co-authored by Claude Sonnet 5
parent 8e5f48c5d2
commit c599fee8e2
6 changed files with 290 additions and 24 deletions
@@ -1,6 +1,6 @@
import { Injectable, computed, effect, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { ActionState, SaveState } from '@shared/application/action-state';
import { SaveState } from '@shared/application/action-state';
import { createDebouncedSave } from '@shared/application/debounced-save';
import { fromLoadLifecycle } from '@shared/application/remote-data';
import { UploadAdapter, uploadContentUrl } from '@shared/infrastructure/upload.adapter';
@@ -13,6 +13,7 @@ import {
SubOrgSummary,
} from '@brief/domain/org-template';
import {
OrgTemplateActionState,
OrgTemplateMsg,
OrgTemplateState,
initial,
@@ -47,17 +48,23 @@ 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(() => fromLoadLifecycle(this.model()));
private loaded = computed<LoadedState | null>(() => {
@@ -165,60 +172,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);
}
@@ -160,4 +160,44 @@ describe('org-template.machine', () => {
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,6 +28,17 @@ 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 }
@@ -34,6 +52,7 @@ export type OrgTemplateState =
dirty: boolean;
/** Logo upload sub-state (single file, `org-logo` category). */
upload: UploadState;
action: OrgTemplateActionState;
};
export const initial: OrgTemplateState = { tag: 'Loading' };
@@ -47,7 +66,12 @@ 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 {
@@ -72,6 +96,9 @@ export function reduce(s: OrgTemplateState, m: OrgTemplateMsg): OrgTemplateState
// 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,
// 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 }));
@@ -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);
}