feat(beheer): WP-32 — undo/redo for the stamdata table editor
CI / frontend (push) Successful in 1m50s
CI / backend (push) Has been cancelled
CI / e2e (push) Has been cancelled
CI / semgrep (push) Has been cancelled
CI / api-client-drift (push) Has been cancelled
CI / storybook-a11y (push) Has been cancelled

Wire the WP-31 createHistory helper into StamdataStore: per-table undo/redo
over the edited rows, recording only real edits and restoring via the existing
Seed msg. Ctrl/Cmd+Z / +Shift+Z, deferring to native text-undo inside grid
cell inputs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
eho
2026-07-22 16:10:27 +02:00
co-authored by Claude Opus 4.8
parent ac3e9a9399
commit 4cf1147fc1
7 changed files with 1313 additions and 589 deletions
@@ -0,0 +1,65 @@
import { TestBed } from '@angular/core/testing';
import { describe, it, expect } from 'vitest';
import { Result, ok } from '@shared/kernel/fp';
import { StamRow, StamTable } from '@beheer/domain/stamdata';
import { StamdataAdapter } from '@beheer/infrastructure/stamdata.adapter';
import { StamdataStore } from './stamdata.store';
const table: StamTable = {
id: 'professions',
label: 'Opleiding → beroep',
temporal: false,
columns: [
{ name: 'program', type: 'text', isKey: true, options: [] },
{ name: 'beroep', type: 'text', isKey: false, options: [] },
],
};
const rows: StamRow[] = [{ program: 'geneeskunde', beroep: 'Arts' }];
function setup(): StamdataStore {
const adapter: Partial<StamdataAdapter> = {
list: (): Promise<Result<string, StamTable[]>> => Promise.resolve(ok([table])),
load: (): Promise<Result<string, { table: StamTable; rows: StamRow[] }>> =>
Promise.resolve(ok({ table, rows: rows.map((r) => ({ ...r })) })),
};
TestBed.configureTestingModule({ providers: [{ provide: StamdataAdapter, useValue: adapter }] });
return TestBed.inject(StamdataStore);
}
describe('StamdataStore undo/redo (WP-32)', () => {
it('records a cell edit, undoes and redoes it', async () => {
const store = setup();
await store.load();
expect(store.canUndo()).toBe(false);
store.editCell(0, 'beroep', 'Chirurg');
expect(store.rows()[0]['beroep']).toBe('Chirurg');
expect(store.canUndo()).toBe(true);
store.undo();
expect(store.rows()[0]['beroep']).toBe('Arts');
expect(store.canRedo()).toBe(true);
store.redo();
expect(store.rows()[0]['beroep']).toBe('Chirurg');
expect(store.canRedo()).toBe(false);
});
it('records addRow and undoes it', async () => {
const store = setup();
await store.load();
store.addRow();
expect(store.rows().length).toBe(2);
store.undo();
expect(store.rows().length).toBe(1);
});
it('clears history when switching table', async () => {
const store = setup();
await store.load();
store.addRow();
expect(store.canUndo()).toBe(true);
await store.selectTable('professions');
expect(store.canUndo()).toBe(false);
});
});
+31 -3
View File
@@ -1,6 +1,7 @@
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,
@@ -85,6 +86,7 @@ export class StamdataStore {
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 });
@@ -95,14 +97,40 @@ export class StamdataStore {
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.store.dispatch({ tag: 'CellEdited', row, column, value });
this.recordThenDispatch({ tag: 'CellEdited', row, column, value });
}
addRow() {
this.store.dispatch({ tag: 'RowAdded' });
this.recordThenDispatch({ tag: 'RowAdded' });
}
removeRow(row: number) {
this.store.dispatch({ tag: 'RowRemoved', row });
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`). */
@@ -163,6 +163,12 @@ interface DisplayRow {
@if (!previewing()) {
<div class="footer">
<app-button variant="subtle" [disabled]="!canUndo()" (click)="undo.emit()">{{
undoLabel
}}</app-button>
<app-button variant="subtle" [disabled]="!canRedo()" (click)="redo.emit()">{{
redoLabel
}}</app-button>
<app-button variant="secondary" (click)="rowAdded.emit()">{{ addRowLabel }}</app-button>
<span class="counts">{{ countsLabel() }}</span>
<app-button variant="primary" [disabled]="!canDownload()" (click)="download.emit()">{{
@@ -180,6 +186,8 @@ export class StamdataTableEditorComponent {
counts = input.required<ChangeCounts>();
previewDate = input('');
canDownload = input(false);
canUndo = input(false);
canRedo = input(false);
tables = input<readonly StamTable[]>([]);
selectedTableId = input<string | null>(null);
@@ -189,6 +197,8 @@ export class StamdataTableEditorComponent {
rowRemoved = output<number>();
previewDateChanged = output<string>();
download = output<void>();
undo = output<void>();
redo = output<void>();
protected previewing = computed(() => this.previewDate() !== '');
@@ -224,6 +234,8 @@ export class StamdataTableEditorComponent {
protected previewNote = $localize`:@@beheer.previewNote:Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.`;
protected actionsLabel = $localize`:@@beheer.actions:Acties`;
protected removeLabel = $localize`:@@beheer.remove:Verwijderen`;
protected undoLabel = $localize`:@@beheer.undo:Ongedaan maken`;
protected redoLabel = $localize`:@@beheer.redo:Opnieuw uitvoeren`;
protected addRowLabel = $localize`:@@beheer.addRow:Rij toevoegen`;
protected downloadLabel = $localize`:@@beheer.download:Download JSON`;
protected applyHint = $localize`:@@beheer.applyHint:Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.`;
+16
View File
@@ -14,6 +14,7 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
*/
@Component({
selector: 'app-stamdata-page',
host: { '(document:keydown)': 'onKeydown($event)' },
imports: [
PageShellComponent,
AlertComponent,
@@ -42,6 +43,8 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
[counts]="store.counts()"
[previewDate]="store.previewDate()"
[canDownload]="store.canDownload()"
[canUndo]="store.canUndo()"
[canRedo]="store.canRedo()"
[tables]="store.tables()"
[selectedTableId]="store.selectedTableId()"
(selectTable)="store.selectTable($event)"
@@ -50,6 +53,8 @@ import { StamdataTableEditorComponent } from '@beheer/ui/stamdata-table-editor/s
(rowRemoved)="store.removeRow($event)"
(previewDateChanged)="store.setPreviewDate($event)"
(download)="store.download()"
(undo)="store.undo()"
(redo)="store.redo()"
/>
}
</ng-template>
@@ -86,4 +91,15 @@ export class StamdataPage {
protected reload() {
void this.store.load();
}
/** Ctrl/Cmd+Z undo, Ctrl/Cmd+Shift+Z redo (WP-32). Ignored while focus is in a grid
cell input so the browser's native text-undo still works there (mirrors brief.page). */
protected onKeydown(e: KeyboardEvent) {
if (!this.canEdit() || !(e.ctrlKey || e.metaKey) || (e.key !== 'z' && e.key !== 'Z')) return;
const t = e.target as HTMLElement | null;
if (t && (t.isContentEditable || ['INPUT', 'SELECT', 'TEXTAREA'].includes(t.tagName))) return;
e.preventDefault();
if (e.shiftKey) this.store.redo();
else this.store.undo();
}
}
+17
View File
@@ -3598,6 +3598,23 @@
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<target datatype="html">Undo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<target datatype="html">Redo</target>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
</body>
</file>
</xliff>
+39 -25
View File
@@ -70,7 +70,7 @@
<source>Er is geen stamdata om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/application/stamdata.store.ts</context>
<context context-type="linenumber">132</context>
<context context-type="linenumber">150</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.validation.key" datatype="html">
@@ -105,126 +105,140 @@
<source>toegevoegd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">213</context>
<context context-type="linenumber">223</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.edited" datatype="html">
<source>gewijzigd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">214</context>
<context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.removed" datatype="html">
<source>verwijderd</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">215</context>
<context context-type="linenumber">225</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.table" datatype="html">
<source>Tabel</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">221</context>
<context context-type="linenumber">231</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.peildatum" datatype="html">
<source>Toon geldig op</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">222</context>
<context context-type="linenumber">232</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.showAll" datatype="html">
<source>Toon alles</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">223</context>
<context context-type="linenumber">233</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.previewNote" datatype="html">
<source>Voorbeeld: alleen de rijen die op deze datum geldig zijn. Bewerken staat uit.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">224</context>
<context context-type="linenumber">234</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.actions" datatype="html">
<source>Acties</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">225</context>
<context context-type="linenumber">235</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.remove" datatype="html">
<source>Verwijderen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">226</context>
<context context-type="linenumber">236</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.undo" datatype="html">
<source>Ongedaan maken</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">237</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.redo" datatype="html">
<source>Opnieuw uitvoeren</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">238</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.addRow" datatype="html">
<source>Rij toevoegen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">227</context>
<context context-type="linenumber">239</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.download" datatype="html">
<source>Download JSON</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">228</context>
<context context-type="linenumber">240</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.applyHint" datatype="html">
<source>Wijzigingen worden als JSON-bestand gedownload en via een pull request toegepast — de build (CI) controleert ze.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts</context>
<context context-type="linenumber">229</context>
<context context-type="linenumber">241</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.heading" datatype="html">
<source>Stamdata onderhouden</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">67</context>
<context context-type="linenumber">72</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.intro" datatype="html">
<source>Beheer de business-tabellen die de registratie stuurt. Wijzigingen worden als JSON gedownload en via een pull request toegepast; de build blijft de bewaker.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">68</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.denied" datatype="html">
<source>U hebt geen rechten om stamdata te onderhouden.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">69</context>
<context context-type="linenumber">74</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.failed" datatype="html">
<source>De stamdata kon niet worden geladen.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">70</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="beheer.page.retry" datatype="html">
<source>Opnieuw proberen</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/beheer/ui/stamdata.page.ts</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">76</context>
</context-group>
</trans-unit>
<trans-unit id="orgTemplate.noSubOrgs" datatype="html">
<source>Er zijn geen organisatiesjablonen om te beheren.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/brief/application/org-template.store.ts</context>
<context context-type="linenumber">28</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="brief.lint.malformed" datatype="html">
@@ -2620,42 +2634,42 @@
<source>Huisstijl</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">26</context>
<context context-type="linenumber">27</context>
</context-group>
</trans-unit>
<trans-unit id="header.nav.stamdata" datatype="html">
<source>Stamdata</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">27</context>
<context context-type="linenumber">32</context>
</context-group>
</trans-unit>
<trans-unit id="header.sender" datatype="html">
<source>BIG-register</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">62,63</context>
<context context-type="linenumber">70,71</context>
</context-group>
</trans-unit>
<trans-unit id="header.ministry" datatype="html">
<source>Ministerie van Volksgezondheid, Welzijn en Sport</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">64,66</context>
<context context-type="linenumber">72,74</context>
</context-group>
</trans-unit>
<trans-unit id="header.uitloggen" datatype="html">
<source> Uitloggen </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">86,87</context>
<context context-type="linenumber">94,95</context>
</context-group>
</trans-unit>
<trans-unit id="header.navAria" datatype="html">
<source>Hoofdnavigatie</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/shared/layout/site-header/site-header.component.ts</context>
<context context-type="linenumber">94,95</context>
<context context-type="linenumber">102,103</context>
</context-group>
</trans-unit>
<trans-unit id="wizard.naarStap" datatype="html">