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>
148 lines
5.5 KiB
TypeScript
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.`;
|