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:
+1133
-561
File diff suppressed because one or more lines are too long
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user