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();
}
}