Files
atomic-design-poc/libs/beheer/src/application/stamdata.store.ts
T
ehoandClaude Opus 5 ce952941bb refactor(shared): add BLOB_PRESENTER, unlock the blob-to-browser success paths (RB-28)
Three application-layer commands ended in raw DOM calls (URL.createObjectURL,
window.open, document.createElement('a').click(), URL.revokeObjectURL) as
their last statement. jsdom cannot assert a call that is also the end of the
function, so each command's success path stayed unassertable, and
StamdataStore.download()'s two-clause guard stayed permanently dark on its
true branch (TE-006).

Add BLOB_PRESENTER (libs/shared/src/application/blob-presenter.ts), an
InjectionToken mirroring SESSION_PORT's shape: an interface with open()/
download(), a real implementation preserving the existing open()-never-
revokes vs download()-always-revokes asymmetry, provided in root. Route
StamdataStore.download(), BriefStore.previewLetter(), and
OrgTemplateStore.proefbrief() through it.

Add specs with a recording fake presenter: StamdataStore.download()'s guard
(both clauses) and its success path, asserting toJson(...)'s exact output
reaches the file; BriefStore.previewLetter()'s existing success test now
goes through the seam instead of spying on window/URL directly; a new
org-template.store.spec.ts (none existed before) covers proefbrief()'s
success and failure paths.

Verified red without the fix by editing the download() filename to the
wrong extension, watching the success-path spec fail, then restoring it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 08:38:14 +02:00

148 lines
5.5 KiB
TypeScript

import { Injectable, computed, inject, signal } from '@angular/core';
import { createStore } from '@shared/application/store';
import { machineRemoteData } from '@shared/application/machine-remote-data';
import { createHistory } from '@shared/application/history';
import {
ChangeCounts,
StamRow,
StamTable,
changeCounts,
isValid,
rowErrors,
toJson,
} from '@beheer/domain/stamdata';
import {
StamdataEditorMsg,
StamdataEditorState,
initial,
reduce,
} from '@beheer/domain/stamdata-editor.machine';
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
import { BLOB_PRESENTER } from '@shared/application/blob-presenter';
type LoadedState = Extract<StamdataEditorState, { tag: 'loaded' }>;
/**
* Root singleton for the stamdata maintenance editor (ADR-0004). The Elm machine owns the
* draft rows; commands here load the catalog + a selected table and produce the download.
* There is deliberately NO save command — the reducer stays pure and the edit leaves as a
* downloaded JSON file that the admin drops into the repo (the CI build is the authority).
*/
@Injectable({ providedIn: 'root' })
export class StamdataStore {
private adapter = inject(StamdataAdapter);
private blobPresenter = inject(BLOB_PRESENTER);
private store = createStore<StamdataEditorState, StamdataEditorMsg>(initial, reduce);
readonly model = this.store.model;
readonly tables = signal<readonly StamTable[]>([]);
readonly selectedTableId = signal<string | null>(null);
/** Preview: show only rows valid on this date ('' = show all, editable). A local filter,
so toggling it never round-trips or drops unsaved edits (see domain `activeOn`). */
readonly previewDate = signal<string>('');
readonly remoteData = computed(() => machineRemoteData(this.model()));
private loaded = computed<LoadedState | null>(() => {
const s = this.model();
return s.tag === 'loaded' ? s : null;
});
readonly table = computed<StamTable | null>(() => this.loaded()?.table ?? null);
readonly rows = computed<readonly StamRow[]>(() => this.loaded()?.rows ?? []);
readonly errors = computed<readonly string[]>(() => {
const s = this.loaded();
return s ? rowErrors(s.table, s.rows) : [];
});
readonly counts = computed<ChangeCounts>(() => {
const s = this.loaded();
return s ? changeCounts(s.table, s.original, s.rows) : { added: 0, removed: 0, edited: 0 };
});
readonly dirty = computed(() => {
const c = this.counts();
return c.added + c.removed + c.edited > 0;
});
/** Download is blocked while previewing (the filtered view is not the full file) or while
any row has a format error (the CI gate would reject it anyway — fail fast here). */
readonly canDownload = computed(() => {
const s = this.loaded();
return this.previewDate() === '' && this.dirty() && s !== null && isValid(s.table, s.rows);
});
async load() {
this.store.dispatch({ tag: 'Loading' });
const list = await this.adapter.list();
if (!list.ok) {
this.store.dispatch({ tag: 'LoadFailed', reason: list.error });
return;
}
this.tables.set(list.value);
const first = list.value[0];
if (!first) {
this.store.dispatch({ tag: 'LoadFailed', reason: NO_TABLES });
return;
}
await this.selectTable(first.id);
}
async selectTable(tableId: string) {
this.selectedTableId.set(tableId);
this.previewDate.set('');
this.history.clear(); // undo history is per-table, not across tables
this.store.dispatch({ tag: 'Loading' });
const r = await this.adapter.load(tableId);
if (r.ok) this.store.dispatch({ tag: 'Loaded', table: r.value.table, rows: r.value.rows });
else this.store.dispatch({ tag: 'LoadFailed', reason: r.error });
}
setPreviewDate(date: string) {
this.previewDate.set(date);
}
/** Undo/redo over the edited rows (WP-32): the document snapshot is `rows`; restore via
the existing `Seed` msg. Only real edits are recorded (a no-op reduce leaves no step). */
private history = createHistory<readonly StamRow[]>(50);
readonly canUndo = this.history.canUndo;
readonly canRedo = this.history.canRedo;
private recordThenDispatch(msg: StamdataEditorMsg) {
const before = this.rows();
this.store.dispatch(msg);
if (this.loaded() && this.rows() !== before) this.history.record(before);
}
editCell(row: number, column: string, value: string) {
this.recordThenDispatch({ tag: 'CellEdited', row, column, value });
}
addRow() {
this.recordThenDispatch({ tag: 'RowAdded' });
}
removeRow(row: number) {
this.recordThenDispatch({ tag: 'RowRemoved', row });
}
undo() {
this.restore((rows) => this.history.undo(rows));
}
redo() {
this.restore((rows) => this.history.redo(rows));
}
private restore(step: (current: readonly StamRow[]) => readonly StamRow[] | undefined) {
const s = this.loaded();
if (!s) return;
const target = step(s.rows);
if (target === undefined) return;
// copy readonly history snapshot into the machine's mutable rows shape
this.store.dispatch({ tag: 'Seed', state: { ...s, rows: [...target] } });
}
/** Emit the edited data-file for the admin to drop into the repo (see domain `toJson`). */
download() {
const s = this.loaded();
if (!s || !this.canDownload()) return;
const blob = new Blob([toJson(s.table, s.rows)], { type: 'application/json' });
this.blobPresenter.download(blob, `${s.table.id}.json`);
}
}
const NO_TABLES = $localize`:@@beheer.noTables:Er is geen stamdata om te beheren.`;