feat(beheer): WP-32 — undo/redo for the stamdata table editor
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user