Files
atomic-design-poc/src/app/beheer/ui/stamdata-table-editor/stamdata-table-editor.component.ts
T
ehoandClaude Opus 4.8 4cf1147fc1
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
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>
2026-07-22 16:10:27 +02:00

243 lines
8.1 KiB
TypeScript

import { Component, computed, input, output } from '@angular/core';
import { ButtonComponent } from '@shared/ui/button/button.component';
import { ChangeCounts, StamColumn, StamRow, StamTable, activeOn } from '@beheer/domain/stamdata';
interface DisplayRow {
row: StamRow;
index: number;
}
/**
* Organism: the GENERIC stamdata grid. It renders entirely from the reflected column
* schema — one input per column type (native `date`/`number`, `enum` select, text) — so a
* new stamdata table needs zero UI code here. The "geldig op" control filters to the rows
* valid on a date (read-only preview); edits and download work on the full set. Emits
* intent; the store owns state (CLAUDE.md §1).
*/
@Component({
selector: 'app-stamdata-table-editor',
imports: [ButtonComponent],
styles: [
`
:host {
display: block;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: end;
margin-block-end: 1rem;
}
.field label {
display: block;
font-size: 0.85em;
color: var(--rhc-color-foreground-subtle);
}
table {
inline-size: 100%;
}
.err {
color: var(--rhc-color-rood-500);
font-size: 0.85em;
}
.footer {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-block-start: 1rem;
}
.counts {
color: var(--rhc-color-foreground-subtle);
}
.hint {
margin-block-start: 0.5rem;
color: var(--rhc-color-foreground-subtle);
font-size: 0.9em;
}
`,
],
template: `
<div class="toolbar">
@if (tables().length > 1) {
<div class="field">
<label for="stamdata-table">{{ tableLabel }}</label>
<select
id="stamdata-table"
class="form-select"
[value]="selectedTableId()"
(change)="selectTable.emit(asValue($event))"
>
@for (t of tables(); track t.id) {
<option [value]="t.id">{{ t.label }}</option>
}
</select>
</div>
}
@if (table().temporal) {
<div class="field">
<label for="stamdata-peildatum">{{ peildatumLabel }}</label>
<input
id="stamdata-peildatum"
type="date"
class="form-control"
[value]="previewDate()"
(input)="previewDateChanged.emit(asValue($event))"
/>
</div>
@if (previewing()) {
<app-button variant="subtle" (click)="previewDateChanged.emit('')">{{
showAll
}}</app-button>
}
}
</div>
@if (previewing()) {
<p class="hint">{{ previewNote }}</p>
}
<table class="table">
<thead>
<tr>
@for (col of table().columns; track col.name) {
<th scope="col">{{ col.name }}</th>
}
<th scope="col">{{ previewing() ? '' : actionsLabel }}</th>
</tr>
</thead>
<tbody>
@for (item of display(); track item.index) {
<tr>
@for (col of table().columns; track col.name) {
<td>
@if (col.type === 'enum') {
<select
class="form-select"
[value]="item.row[col.name]"
[disabled]="previewing()"
[attr.aria-label]="cellLabel(col, item.index)"
(change)="
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
"
>
<option value=""></option>
@for (opt of col.options; track opt) {
<option [value]="opt">{{ opt }}</option>
}
</select>
} @else {
<input
class="form-control"
[type]="inputType(col)"
[value]="item.row[col.name]"
[class.is-invalid]="!!errors()[item.index]"
[disabled]="previewing()"
[attr.aria-label]="cellLabel(col, item.index)"
(input)="
cellEdited.emit({ row: item.index, column: col.name, value: asValue($event) })
"
/>
}
</td>
}
<td>
@if (!previewing()) {
<app-button
variant="subtle"
[attr.aria-label]="removeLabel"
(click)="rowRemoved.emit(item.index)"
>{{ removeLabel }}</app-button
>
}
@if (errors()[item.index]) {
<span class="err">{{ errors()[item.index] }}</span>
}
</td>
</tr>
}
</tbody>
</table>
@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()">{{
downloadLabel
}}</app-button>
</div>
<p class="hint">{{ applyHint }}</p>
}
`,
})
export class StamdataTableEditorComponent {
table = input.required<StamTable>();
rows = input.required<readonly StamRow[]>();
errors = input.required<readonly string[]>();
counts = input.required<ChangeCounts>();
previewDate = input('');
canDownload = input(false);
canUndo = input(false);
canRedo = input(false);
tables = input<readonly StamTable[]>([]);
selectedTableId = input<string | null>(null);
selectTable = output<string>();
cellEdited = output<{ row: number; column: string; value: string }>();
rowAdded = output<void>();
rowRemoved = output<number>();
previewDateChanged = output<string>();
download = output<void>();
undo = output<void>();
redo = output<void>();
protected previewing = computed(() => this.previewDate() !== '');
protected display = computed<DisplayRow[]>(() =>
this.rows()
.map((row, index) => ({ row, index }))
.filter(({ row }) => !this.previewing() || activeOn(this.table(), row, this.previewDate())),
);
protected inputType(col: StamColumn): string {
return col.type === 'date' ? 'date' : col.type === 'number' ? 'number' : 'text';
}
protected cellLabel(col: StamColumn, index: number): string {
return `${col.name} — rij ${index + 1}`;
}
protected asValue(e: Event): string {
return (e.target as HTMLInputElement | HTMLSelectElement).value;
}
private addedWord = $localize`:@@beheer.added:toegevoegd`;
private editedWord = $localize`:@@beheer.edited:gewijzigd`;
private removedWord = $localize`:@@beheer.removed:verwijderd`;
protected countsLabel = computed(() => {
const c = this.counts();
return `${c.added} ${this.addedWord} · ${c.edited} ${this.editedWord} · ${c.removed} ${this.removedWord}`;
});
protected tableLabel = $localize`:@@beheer.table:Tabel`;
protected peildatumLabel = $localize`:@@beheer.peildatum:Toon geldig op`;
protected showAll = $localize`:@@beheer.showAll:Toon alles`;
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.`;
}