feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s
CI / frontend (push) Failing after 1m15s
CI / storybook-a11y (push) Failing after 4m43s
CI / backend (push) Successful in 1m24s
CI / codeql (csharp) (push) Failing after 2m51s
CI / e2e (push) Failing after 3h4m8s
CI / codeql (javascript-typescript) (push) Failing after 1m30s
CI / api-client-drift (push) Successful in 1m53s
Brief letter-composition UX improvements: - undo/redo history in the brief store (snapshot stacks, Ctrl/Cmd+Z) + retry-save - "Standaardbrief invoegen" starter for empty sections; isDefault library passages (backend DTO/seed + adapter parse) - passage-picker client-side search (rich-text textOf helper) - rejection diff badges on the letter canvas + show/hide changes toggle (pure brief-diff domain fns + spec) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { Result } from '@shared/kernel/fp';
|
||||
import { Brief, BriefDecisions } from '@brief/domain/brief';
|
||||
import { Brief, BriefDecisions, LetterBlock } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
@@ -105,6 +105,104 @@ describe('BriefStore action state (Idle | Busy | Failed)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// --- WP-27: undo/redo history + rejection diff ---
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
|
||||
}
|
||||
const kern = (blocks: LetterBlock[]) => ({
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks,
|
||||
});
|
||||
const filledBrief: Brief = { ...brief, sections: [kern([block('local-1', 'x')])] };
|
||||
const filledView: BriefView = { ...view, brief: filledBrief };
|
||||
|
||||
function loadedBrief(store: BriefStore): Brief {
|
||||
const s = store.model();
|
||||
if (s.tag !== 'loaded') throw new Error('not loaded');
|
||||
return s.brief;
|
||||
}
|
||||
|
||||
async function loadedStore(over: Partial<BriefAdapter> = {}): Promise<BriefStore> {
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({ load: () => ok(filledView), save: () => ok(filledView), ...over });
|
||||
await store.load();
|
||||
return store;
|
||||
}
|
||||
|
||||
describe('BriefStore undo/redo history', () => {
|
||||
it('records an edit, undoes and redoes it; buttons mirror; a no-op edit is not recorded', async () => {
|
||||
const store = await loadedStore();
|
||||
expect(store.canUndo()).toBe(false);
|
||||
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'local-1' });
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
expect(store.canUndo()).toBe(true);
|
||||
|
||||
store.undo();
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(1);
|
||||
expect(store.canRedo()).toBe(true);
|
||||
|
||||
store.redo();
|
||||
expect(loadedBrief(store).sections[0].blocks.length).toBe(0);
|
||||
|
||||
// A no-op edit (unknown block) changes nothing → leaves no dead history step.
|
||||
store.undo(); // back to 1 block, redo available
|
||||
store.edit({ tag: 'BlockRemoved', blockId: 'does-not-exist' });
|
||||
expect(store.canRedo()).toBe(true); // future NOT cleared by a no-op
|
||||
});
|
||||
|
||||
it('a new edit clears the redo future', async () => {
|
||||
const store = await loadedStore();
|
||||
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
store.undo();
|
||||
expect(store.canRedo()).toBe(true);
|
||||
store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(store.canRedo()).toBe(false);
|
||||
});
|
||||
|
||||
it('caps history at 50 snapshots', async () => {
|
||||
const store = await loadedStore();
|
||||
for (let i = 0; i < 55; i++) store.edit({ tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
let undos = 0;
|
||||
while (store.canUndo()) {
|
||||
store.undo();
|
||||
undos++;
|
||||
}
|
||||
expect(undos).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore rejection diff', () => {
|
||||
it('captures the rejected letter and diffs a subsequent edit against it', async () => {
|
||||
const submitted: Brief = { ...filledBrief, status: { tag: 'submitted', submittedBy: 'u', submittedAt: 't' } };
|
||||
const rejected: Brief = {
|
||||
...filledBrief,
|
||||
status: { tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'nee' },
|
||||
};
|
||||
const ok = (v: BriefView): Promise<Result<string, BriefView>> => Promise.resolve({ ok: true, value: v });
|
||||
const store = setup({
|
||||
load: () => ok({ ...filledView, brief: submitted }),
|
||||
save: () => ok(filledView),
|
||||
reject: () => ok({ ...filledView, brief: rejected }),
|
||||
});
|
||||
await store.load();
|
||||
await store.reject('nee');
|
||||
expect(store.hasRejectionDiff()).toBe(false); // nothing changed yet
|
||||
|
||||
store.edit({
|
||||
tag: 'BlockContentEdited',
|
||||
blockId: 'local-1',
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'CHANGED' }] }] },
|
||||
});
|
||||
expect(store.blockDiffs().get('local-1')).toBe('changed');
|
||||
expect(store.removedSinceReject()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BriefStore.previewLetter', () => {
|
||||
// vi.spyOn reuses an existing spy (and its call history) if one is already on
|
||||
// the property — window.open/URL.createObjectURL must be restored between tests.
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
unresolvedPlaceholders,
|
||||
} from '@brief/domain/brief';
|
||||
import { BriefMsg, BriefState, initial, reduce } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind, changedBlocks, diffBlocks } from '@brief/domain/brief-diff';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { BriefAdapter, BriefView } from '@brief/infrastructure/brief.adapter';
|
||||
import { LetterPreviewAdapter } from '@brief/infrastructure/letter-preview.adapter';
|
||||
@@ -52,6 +53,35 @@ export class BriefStore {
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
readonly saveState = signal<SaveState>({ tag: 'Idle' });
|
||||
|
||||
/** Undo/redo is SHELL state, not machine state (WP-27): a stack of past/future
|
||||
`Brief` snapshots. Each is a deep-frozen immutable value, so sharing is safe.
|
||||
Only CONTENT edits are recorded (they flow through `edit()`); status transitions
|
||||
never enter history, or undo would replay workflow state. Capped so a long session
|
||||
can't grow unbounded. Restore re-dispatches the existing `Seed` Msg — zero machine
|
||||
changes. */
|
||||
private static readonly HISTORY_CAP = 50;
|
||||
private past = signal<readonly Brief[]>([]);
|
||||
private future = signal<readonly Brief[]>([]);
|
||||
readonly canUndo = computed(() => this.past().length > 0);
|
||||
readonly canRedo = computed(() => this.future().length > 0);
|
||||
|
||||
/** The letter as it stood when it was REJECTED, captured shell-side (WP-27). The
|
||||
approver diffs it against the resubmitted letter. POC limit: in-memory only, so a
|
||||
full page reload loses it — a real system would persist the rejected revision. */
|
||||
private rejectionSnapshot = signal<Brief | null>(null);
|
||||
/** Changed/added/removed blocks since rejection — a pure fold over two snapshots. */
|
||||
readonly blockDiffs = computed<ReadonlyMap<string, BlockDiffKind>>(() => {
|
||||
const before = this.rejectionSnapshot();
|
||||
const after = this.brief();
|
||||
return before && after ? changedBlocks(diffBlocks(before, after)) : new Map();
|
||||
});
|
||||
/** Count of blocks removed since rejection — badged as a summary, since a removed
|
||||
block no longer renders inline. */
|
||||
readonly removedSinceReject = computed(
|
||||
() => [...this.blockDiffs().values()].filter((k) => k === 'removed').length,
|
||||
);
|
||||
readonly hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
|
||||
/** The org template the letter renders with (WP-24). Server-owned appearance data,
|
||||
not letter state — held beside the machine, never inside it (`brief.machine.ts`
|
||||
stays untouched by design). Set from every server view that carries it. */
|
||||
@@ -104,18 +134,50 @@ export class BriefStore {
|
||||
const r = await this.adapter.load();
|
||||
if (r.ok) {
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.clearHistory();
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.store.dispatch({ tag: 'BriefLoadFailed', reason: r.error });
|
||||
}
|
||||
}
|
||||
|
||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. */
|
||||
/** An edit: apply it optimistically in the pure reducer, then debounce-save. Records
|
||||
an undo step only when the reducer actually changed the brief (a no-op edit — e.g.
|
||||
a locked section — returns the same value and leaves no dead history step). */
|
||||
edit(msg: BriefMsg) {
|
||||
const before = this.brief();
|
||||
this.store.dispatch(msg);
|
||||
const after = this.brief();
|
||||
if (before && after && after !== before) {
|
||||
this.past.update((p) => [...p, before].slice(-BriefStore.HISTORY_CAP));
|
||||
this.future.set([]);
|
||||
}
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/** Undo: restore the previous snapshot via the existing `Seed` Msg, push the current
|
||||
onto the redo stack, then autosave. Redo is the mirror image. */
|
||||
undo() {
|
||||
this.step(this.past, this.future);
|
||||
}
|
||||
redo() {
|
||||
this.step(this.future, this.past);
|
||||
}
|
||||
private step(from: typeof this.past, to: typeof this.future) {
|
||||
const s = this.model();
|
||||
const target = from().at(-1);
|
||||
if (s.tag !== 'loaded' || !target) return;
|
||||
from.update((x) => x.slice(0, -1));
|
||||
to.update((x) => [...x, s.brief].slice(-BriefStore.HISTORY_CAP));
|
||||
this.store.dispatch({ tag: 'Seed', state: { ...s, brief: target } });
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private clearHistory() {
|
||||
this.past.set([]);
|
||||
this.future.set([]);
|
||||
}
|
||||
|
||||
private saveTimer?: ReturnType<typeof setTimeout>;
|
||||
private scheduleSave() {
|
||||
if (!this.canEdit()) return;
|
||||
@@ -136,6 +198,11 @@ export class BriefStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Retry a failed autosave — reuses the existing flush path, no new state (WP-27). */
|
||||
retrySave() {
|
||||
void this.flushSave();
|
||||
}
|
||||
|
||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||
async resetDemo() {
|
||||
this.actionState.set({ tag: 'Busy' });
|
||||
@@ -145,6 +212,8 @@ export class BriefStore {
|
||||
if (r.ok) {
|
||||
this.actionState.set({ tag: 'Idle' });
|
||||
this.orgTemplate.set(r.value.orgTemplate);
|
||||
this.clearHistory();
|
||||
this.rejectionSnapshot.set(null);
|
||||
this.store.dispatch({ tag: 'BriefLoaded', ...r.value });
|
||||
} else {
|
||||
this.actionState.set({ tag: 'Failed', error: r.error });
|
||||
@@ -198,6 +267,9 @@ export class BriefStore {
|
||||
this.store.dispatch({ tag: 'Approved', by: s.approvedBy, at: s.approvedAt, decisions });
|
||||
break;
|
||||
case 'rejected':
|
||||
// Capture the letter as-rejected for the resubmission diff (WP-27). This is the
|
||||
// "before" snapshot the approver later compares against.
|
||||
this.rejectionSnapshot.set(brief);
|
||||
this.store.dispatch({
|
||||
tag: 'Rejected',
|
||||
by: s.rejectedBy,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, LetterBlock } from './brief';
|
||||
import { diffBlocks, changedBlocks } from './brief-diff';
|
||||
|
||||
function block(id: string, text: string): LetterBlock {
|
||||
return { type: 'freeText', blockId: id, content: { paragraphs: [{ nodes: [{ type: 'text', text }] }] } };
|
||||
}
|
||||
|
||||
function brief(blocks: LetterBlock[]): Brief {
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders: [],
|
||||
sections: [{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks }],
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('diffBlocks', () => {
|
||||
it('marks added, removed, changed and unchanged by blockId', () => {
|
||||
const before = brief([block('local-1', 'a'), block('local-2', 'b'), block('local-3', 'c')]);
|
||||
const after = brief([block('local-1', 'a'), block('local-2', 'B!'), block('local-4', 'd')]);
|
||||
const diffs = diffBlocks(before, after);
|
||||
const byId = new Map(diffs.map((d) => [d.blockId, d.kind]));
|
||||
expect(byId.get('local-1')).toBe('unchanged');
|
||||
expect(byId.get('local-2')).toBe('changed');
|
||||
expect(byId.get('local-3')).toBe('removed'); // gone from after
|
||||
expect(byId.get('local-4')).toBe('added'); // new in after
|
||||
});
|
||||
|
||||
it('changedBlocks drops unchanged and keeps added/removed/changed', () => {
|
||||
const before = brief([block('local-1', 'a'), block('local-2', 'b')]);
|
||||
const after = brief([block('local-1', 'a'), block('local-2', 'B'), block('local-3', 'c')]);
|
||||
const map = changedBlocks(diffBlocks(before, after));
|
||||
expect(map.has('local-1')).toBe(false);
|
||||
expect(map.get('local-2')).toBe('changed');
|
||||
expect(map.get('local-3')).toBe('added');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Brief, LetterBlock, allBlocks } from './brief';
|
||||
|
||||
/**
|
||||
* The rejection diff as a PURE function over two immutable `Brief` values — the whole
|
||||
* teaching payload of WP-27: because state is one value, "what changed since the letter
|
||||
* was rejected" is just a fold over two snapshots, no change-tracking bookkeeping.
|
||||
*
|
||||
* Blocks are matched by `blockId` (stable `local-N`/seed ids):
|
||||
* - in `after` but not `before` → `added`
|
||||
* - in `before` but not `after` → `removed`
|
||||
* - in both, different content → `changed`
|
||||
* - in both, same content → `unchanged`
|
||||
*/
|
||||
|
||||
export type BlockDiffKind = 'added' | 'removed' | 'changed' | 'unchanged';
|
||||
|
||||
export interface BlockDiff {
|
||||
readonly blockId: string;
|
||||
readonly kind: BlockDiffKind;
|
||||
}
|
||||
|
||||
/** Content equality by value. Blocks are JSON-shaped immutable trees, so a canonical
|
||||
stringify is an honest deep-equal here (no functions, no cycles). */
|
||||
function contentEqual(a: LetterBlock, b: LetterBlock): boolean {
|
||||
return JSON.stringify(a.content) === JSON.stringify(b.content);
|
||||
}
|
||||
|
||||
export function diffBlocks(before: Brief, after: Brief): BlockDiff[] {
|
||||
const beforeById = new Map(allBlocks(before).map((b) => [b.blockId, b]));
|
||||
const afterById = new Map(allBlocks(after).map((b) => [b.blockId, b]));
|
||||
const out: BlockDiff[] = [];
|
||||
for (const a of afterById.values()) {
|
||||
const b = beforeById.get(a.blockId);
|
||||
out.push({
|
||||
blockId: a.blockId,
|
||||
kind: !b ? 'added' : contentEqual(a, b) ? 'unchanged' : 'changed',
|
||||
});
|
||||
}
|
||||
for (const b of beforeById.values()) {
|
||||
if (!afterById.has(b.blockId)) out.push({ blockId: b.blockId, kind: 'removed' });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Lookup of only the blocks that changed since rejection (drops `unchanged`), for
|
||||
badging the canvas. Keyed by `blockId`; removed ids are present too (the caller
|
||||
surfaces them as a count — a removed block no longer renders inline). */
|
||||
export function changedBlocks(diffs: readonly BlockDiff[]): ReadonlyMap<string, BlockDiffKind> {
|
||||
return new Map(diffs.filter((d) => d.kind !== 'unchanged').map((d) => [d.blockId, d.kind]));
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface LibraryPassage {
|
||||
readonly label: string;
|
||||
readonly content: RichTextBlock;
|
||||
readonly version: number; // library version, for provenance only
|
||||
readonly isDefault?: boolean; // part of the "standaardbrief" (kern) starter set
|
||||
}
|
||||
|
||||
/** A block inside a letter section: a frozen passage snapshot, or free text. */
|
||||
|
||||
@@ -249,6 +249,7 @@ function parsePassage(dto: LibraryPassageDto): Result<string, LibraryPassage> {
|
||||
content: content.value,
|
||||
version: dto.version,
|
||||
...(dto.beroep != null ? { beroep: dto.beroep } : {}),
|
||||
...(dto.isDefault ? { isDefault: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [PageShellComponent, AlertComponent, ButtonComponent, ...ASYNC, LetterComposerComponent],
|
||||
host: { '(document:keydown)': 'onKey($event)' },
|
||||
styles: [
|
||||
`
|
||||
.brief-toolbar {
|
||||
@@ -21,6 +22,11 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
gap: var(--rhc-space-max-md);
|
||||
margin-block-end: var(--rhc-space-max-lg);
|
||||
}
|
||||
.toolbar-start {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-md);
|
||||
}
|
||||
.save {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-size: 0.9em;
|
||||
@@ -42,7 +48,30 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
@if (loaded(); as s) {
|
||||
@if (store.orgTemplate(); as orgTemplate) {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<div class="toolbar-start">
|
||||
@if (store.canEdit()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canUndo()"
|
||||
[attr.aria-label]="undoLabel"
|
||||
(click)="store.undo()"
|
||||
>{{ undoLabel }}</app-button
|
||||
>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="!store.canRedo()"
|
||||
[attr.aria-label]="redoLabel"
|
||||
(click)="store.redo()"
|
||||
>{{ redoLabel }}</app-button
|
||||
>
|
||||
}
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
@if (store.saveState().tag === 'Error') {
|
||||
<app-button variant="secondary" (click)="store.retrySave()">{{
|
||||
retrySaveLabel
|
||||
}}</app-button>
|
||||
}
|
||||
</div>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{
|
||||
resetLabel
|
||||
}}</app-button>
|
||||
@@ -53,6 +82,8 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
[logoUrl]="store.logoUrl()"
|
||||
[availablePassages]="s.availablePassages"
|
||||
[diagnostics]="store.diagnostics()"
|
||||
[blockDiffs]="store.blockDiffs()"
|
||||
[removedCount]="store.removedSinceReject()"
|
||||
[canEdit]="store.canEdit()"
|
||||
[canApprove]="store.canApprove()"
|
||||
[canReject]="store.canReject()"
|
||||
@@ -83,10 +114,13 @@ export class BriefPage {
|
||||
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
||||
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||
protected undoLabel = $localize`:@@brief.page.undo:Ongedaan maken`;
|
||||
protected redoLabel = $localize`:@@brief.page.redo:Opnieuw uitvoeren`;
|
||||
protected retrySaveLabel = $localize`:@@brief.page.retrySave:Opnieuw proberen`;
|
||||
|
||||
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Niet opgeslagen — opnieuw proberen`;
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
@@ -121,4 +155,16 @@ export class BriefPage {
|
||||
protected reload() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
/** Ctrl/Cmd+Z = undo, Ctrl/Cmd+Shift+Z = redo (WP-27). Ignored while focus is in the
|
||||
rich-text editor or a form control, so the browser's own text undo keeps working
|
||||
there — our shell-level undo is for structural edits (add/remove/reorder blocks). */
|
||||
protected onKey(e: KeyboardEvent) {
|
||||
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'z' || !this.store.canEdit()) return;
|
||||
const t = e.target as HTMLElement | null;
|
||||
if (t && (t.isContentEditable || t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
|
||||
e.preventDefault();
|
||||
if (e.shiftKey) this.store.redo();
|
||||
else this.store.undo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
effect,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
@@ -21,6 +22,7 @@ import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterSectionComponent } from '@brief/ui/letter-section/letter-section.component';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
@@ -66,9 +68,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
margin-block-end: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--rhc-space-max-sm);
|
||||
}
|
||||
.zoom-pct {
|
||||
min-width: 3.5ch;
|
||||
text-align: center;
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Rejection-diff badge (WP-27): a small pill above a changed/added block. */
|
||||
.diff-block.diff-changed {
|
||||
border-inline-start: 3px solid var(--rhc-color-oranje-500);
|
||||
padding-inline-start: var(--rhc-space-max-sm);
|
||||
}
|
||||
.diff-badge {
|
||||
display: inline-block;
|
||||
margin-block-end: 1mm;
|
||||
padding: 0 1.5mm;
|
||||
border-radius: var(--rhc-border-radius-sm);
|
||||
font-size: 7.5pt;
|
||||
/* dark text, not white: oranje-500/groen-500 fail 4.5:1 contrast with white (WP-27 axe). */
|
||||
color: var(--rhc-color-foreground-default);
|
||||
background: var(--rhc-color-oranje-500);
|
||||
}
|
||||
.diff-badge.added {
|
||||
background: var(--rhc-color-groen-500);
|
||||
}
|
||||
/* Portal-side chrome around the letter surface (not part of the contract file). */
|
||||
.surface {
|
||||
background: var(--rhc-color-grijs-100);
|
||||
@@ -139,20 +172,40 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
@if (editableRegions() === 'none') {
|
||||
@if (editableRegions() !== 'template') {
|
||||
<div class="toolbar">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
<div class="zoom" role="group" [attr.aria-label]="zoomGroupLabel()">
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() <= 0.5"
|
||||
[attr.aria-label]="zoomOutLabel()"
|
||||
(click)="zoomBy(-0.1)"
|
||||
>−</app-button
|
||||
>
|
||||
<span class="zoom-pct">{{ zoomPct() }}</span>
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[disabled]="zoomLevel() >= 1.5"
|
||||
[attr.aria-label]="zoomInLabel()"
|
||||
(click)="zoomBy(0.1)"
|
||||
>+</app-button
|
||||
>
|
||||
<app-button variant="subtle" (click)="zoomLevel.set(1)">{{ zoomResetLabel() }}</app-button>
|
||||
</div>
|
||||
@if (editableRegions() === 'none') {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
(click)="showSample.set(!showSample())"
|
||||
[attr.aria-pressed]="showSample()"
|
||||
>
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="surface">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoom()">
|
||||
<div class="letter" #page [style]="marginStyle()" [style.zoom]="zoomLevel()">
|
||||
<!-- div, not <header>/<footer>: the CIBG huisstijl styles those bare elements
|
||||
(robijn footer background) — the letter surface must stay letter.css-only. -->
|
||||
<div class="letter__letterhead" [class.from-template]="tintTemplate()">
|
||||
@@ -211,7 +264,14 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
<section>
|
||||
<h3>{{ section.title }}</h3>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@let diffKind = showDiff() ? blockDiffs().get(block.blockId) : undefined;
|
||||
<div class="diff-block" [class.diff-changed]="!!diffKind">
|
||||
@if (diffKind) {
|
||||
<span class="diff-badge" [class.added]="diffKind === 'added'">{{
|
||||
diffLabel(diffKind)
|
||||
}}</span>
|
||||
}
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>
|
||||
@for (para of seg.items; track $index) {
|
||||
@@ -242,7 +302,8 @@ const A4_HEIGHT_PX = (297 * 96) / 25.4;
|
||||
/>
|
||||
</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -315,7 +376,13 @@ export class LetterCanvasComponent {
|
||||
availablePassages = input<readonly LibraryPassage[]>([]);
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
/** Initial zoom; the in-canvas controls take over from here (WP-27). */
|
||||
zoom = input(1);
|
||||
/** Blocks changed/added/removed since the letter was rejected (WP-27); badged when
|
||||
`showDiff` is on. Removed blocks aren't in the map's rendered set — they no longer
|
||||
exist in the letter — the composer surfaces them as a count. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
showDiff = input(false);
|
||||
/** The org logo's content URL (letterhead), or null when none is set. */
|
||||
logoUrl = input<string | null>(null);
|
||||
edit = output<BriefMsg>();
|
||||
@@ -343,10 +410,26 @@ export class LetterCanvasComponent {
|
||||
signatureRoleLabel = input($localize`:@@brief.canvas.signatureRole:Functie ondertekenaar`);
|
||||
footerContactLabel = input($localize`:@@brief.canvas.footerContact:Contactgegevens (voettekst)`);
|
||||
footerLegalLabel = input($localize`:@@brief.canvas.footerLegal:Juridische voettekst`);
|
||||
zoomGroupLabel = input($localize`:@@brief.canvas.zoom:Zoomniveau`);
|
||||
zoomInLabel = input($localize`:@@brief.canvas.zoomIn:Inzoomen`);
|
||||
zoomOutLabel = input($localize`:@@brief.canvas.zoomOut:Uitzoomen`);
|
||||
zoomResetLabel = input($localize`:@@brief.canvas.zoomReset:100%`);
|
||||
addedLabel = input($localize`:@@brief.diff.added:nieuw`);
|
||||
changedLabel = input($localize`:@@brief.diff.changed:gewijzigd sinds afwijzing`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
protected letterDate = formatDatumNl(new Date());
|
||||
|
||||
/** Zoom seeded from the input; the +/−/reset controls drive it from there. */
|
||||
protected zoomLevel = linkedSignal(() => this.zoom());
|
||||
protected zoomPct = computed(() => `${Math.round(this.zoomLevel() * 100)}%`);
|
||||
protected zoomBy(delta: number) {
|
||||
// clamp 0.5–1.5; round to avoid float drift accumulating on repeated clicks.
|
||||
this.zoomLevel.update((z) => Math.round(Math.min(1.5, Math.max(0.5, z + delta)) * 10) / 10);
|
||||
}
|
||||
protected diffLabel = (kind: BlockDiffKind) =>
|
||||
kind === 'added' ? this.addedLabel() : this.changedLabel();
|
||||
|
||||
/** The letterhead/signature/footer are tinted "not yours" only while composing —
|
||||
in 'none' the whole surface is read-only, in 'template' they ARE the editable focus. */
|
||||
protected tintTemplate = computed(() => this.editableRegions() === 'content');
|
||||
|
||||
@@ -148,6 +148,19 @@ export const TemplateMode: Story = { args: { editableRegions: 'template' } };
|
||||
|
||||
export const Zoomed: Story = { args: { editableRegions: 'none', zoom: 0.6 } };
|
||||
|
||||
/** Approver's "Toon wijzigingen": blocks changed/added since rejection are badged (WP-27). */
|
||||
export const WithDiff: Story = {
|
||||
args: {
|
||||
editableRegions: 'none',
|
||||
diagnostics: [],
|
||||
showDiff: true,
|
||||
blockDiffs: new Map([
|
||||
['local-1', 'added'],
|
||||
['local-2', 'changed'],
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
/** Long letter: the approximate ±page-break marks appear per A4 interval. */
|
||||
export const PageBreak: Story = {
|
||||
args: { editableRegions: 'none', brief: longBrief, diagnostics: [] },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, computed, input, output } from '@angular/core';
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { StatusBadgeComponent } from '@shared/ui/status-badge/status-badge.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
@@ -8,6 +8,7 @@ import { Brief, LibraryPassage } from '@brief/domain/brief';
|
||||
import { OrgTemplate } from '@brief/domain/org-template';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
import { BriefMsg } from '@brief/domain/brief.machine';
|
||||
import { BlockDiffKind } from '@brief/domain/brief-diff';
|
||||
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component';
|
||||
import { DiagnosticsPanelComponent } from '@brief/ui/diagnostics-panel/diagnostics-panel.component';
|
||||
import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejection-comments.component';
|
||||
@@ -62,6 +63,14 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
<div class="head">
|
||||
<app-heading [level]="2">{{ title() }}</app-heading>
|
||||
<div class="head-end">
|
||||
@if (hasRejectionDiff()) {
|
||||
<app-button
|
||||
variant="subtle"
|
||||
[attr.aria-pressed]="showDiff()"
|
||||
(click)="showDiff.set(!showDiff())"
|
||||
>{{ showDiff() ? hideDiffLabel() : showDiffLabel() }}</app-button
|
||||
>
|
||||
}
|
||||
<app-button variant="subtle" [disabled]="busy()" (click)="preview.emit()">{{
|
||||
previewLabel()
|
||||
}}</app-button>
|
||||
@@ -73,6 +82,10 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
<app-rejection-comments mode="show" [comments]="rejectComments()" />
|
||||
}
|
||||
|
||||
@if (showDiff() && removedCount() > 0) {
|
||||
<app-alert type="info">{{ removedText() }}</app-alert>
|
||||
}
|
||||
|
||||
<app-letter-canvas
|
||||
[brief]="brief()"
|
||||
[orgTemplate]="orgTemplate()"
|
||||
@@ -81,6 +94,8 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[diagnostics]="diagnostics()"
|
||||
[blockDiffs]="blockDiffs()"
|
||||
[showDiff]="showDiff()"
|
||||
(edit)="edit.emit($event)"
|
||||
/>
|
||||
|
||||
@@ -149,6 +164,12 @@ export class LetterComposerComponent {
|
||||
canSend = input(false);
|
||||
canSubmit = input(false);
|
||||
busy = input(false);
|
||||
/** Rejection diff (WP-27): the changed/added/removed blocks and their count. The
|
||||
"Toon wijzigingen" toggle only appears when there's something to show. */
|
||||
blockDiffs = input<ReadonlyMap<string, BlockDiffKind>>(new Map());
|
||||
removedCount = input(0);
|
||||
protected hasRejectionDiff = computed(() => this.blockDiffs().size > 0);
|
||||
protected showDiff = signal(false);
|
||||
|
||||
edit = output<BriefMsg>();
|
||||
submit = output<void>();
|
||||
@@ -160,6 +181,12 @@ export class LetterComposerComponent {
|
||||
|
||||
title = input($localize`:@@brief.title:Brief aan de zorgverlener`);
|
||||
previewLabel = input($localize`:@@brief.preview.open:Voorbeeld`);
|
||||
showDiffLabel = input($localize`:@@brief.diff.show:Toon wijzigingen`);
|
||||
hideDiffLabel = input($localize`:@@brief.diff.hide:Verberg wijzigingen`);
|
||||
removedText = computed(
|
||||
() =>
|
||||
$localize`:@@brief.diff.removed:${this.removedCount()}:count: blok(ken) verwijderd sinds afwijzing.`,
|
||||
);
|
||||
submitLabel = input($localize`:@@brief.submit:Indienen ter beoordeling`);
|
||||
resubmitLabel = input($localize`:@@brief.resubmit:Opnieuw indienen`);
|
||||
submitHint = input(
|
||||
|
||||
@@ -63,6 +63,11 @@ import { PassagePickerComponent } from '@brief/ui/passage-picker/passage-picker.
|
||||
|
||||
@if (editable()) {
|
||||
<div class="actions">
|
||||
@if (showStandardLetter()) {
|
||||
<app-button variant="primary" (click)="insertStandardLetter()">{{
|
||||
standardLetterLabel()
|
||||
}}</app-button>
|
||||
}
|
||||
<app-button variant="secondary" (click)="pickerOpen.set(!pickerOpen())">{{
|
||||
addPassageLabel()
|
||||
}}</app-button>
|
||||
@@ -89,11 +94,27 @@ export class LetterSectionComponent {
|
||||
emptyLabel = input($localize`:@@brief.section.empty:Nog geen tekst in deze sectie.`);
|
||||
addPassageLabel = input($localize`:@@brief.section.addPassage:Standaardtekst toevoegen`);
|
||||
addFreeLabel = input($localize`:@@brief.section.addFree:Vrije tekst toevoegen`);
|
||||
standardLetterLabel = input($localize`:@@brief.section.standardLetter:Standaardbrief invoegen`);
|
||||
|
||||
protected pickerOpen = signal(false);
|
||||
protected sectionPassages = computed(() =>
|
||||
this.availablePassages().filter((p) => p.sectionKey === this.section().sectionKey),
|
||||
);
|
||||
/** The "standaardbrief" starter set for this section (server-flagged defaults). */
|
||||
protected defaultPassages = computed(() => this.sectionPassages().filter((p) => p.isDefault));
|
||||
/** One-click starter, offered only while the section is still empty and defaults exist. */
|
||||
protected showStandardLetter = computed(
|
||||
() => this.section().blocks.length === 0 && this.defaultPassages().length > 0,
|
||||
);
|
||||
|
||||
protected insertStandardLetter() {
|
||||
// One Msg → one undo step (see brief.store `edit`).
|
||||
this.edit.emit({
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: this.section().sectionKey,
|
||||
passages: this.defaultPassages(),
|
||||
});
|
||||
}
|
||||
|
||||
protected onContent(blockId: string, content: RichTextBlock) {
|
||||
this.edit.emit({ tag: 'BlockContentEdited', blockId, content });
|
||||
|
||||
@@ -36,6 +36,7 @@ const passages: LibraryPassage[] = [
|
||||
sectionKey: 'kern',
|
||||
label: 'Toelichting arts',
|
||||
version: 1,
|
||||
isDefault: true, // part of the standaardbrief starter set (WP-27)
|
||||
content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Als arts ...' }] }] },
|
||||
},
|
||||
];
|
||||
@@ -52,4 +53,5 @@ type Story = StoryObj<LetterSectionComponent>;
|
||||
|
||||
export const ReadOnly: Story = { args: { editable: false } };
|
||||
export const Editable: Story = { args: { editable: true } };
|
||||
/** Empty section: the one-click "Standaardbrief invoegen" starter appears (WP-27). */
|
||||
export const EditableEmpty: Story = { args: { section: emptySection, editable: true } };
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Component, input, output, signal } from '@angular/core';
|
||||
import { Component, computed, input, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { CheckboxComponent } from '@shared/ui/checkbox/checkbox.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
|
||||
import { textOf } from '@shared/kernel/rich-text';
|
||||
import { LibraryPassage } from '@brief/domain/brief';
|
||||
|
||||
/** Molecule: multi-select list of the section's library passages. One "Voeg toe"
|
||||
@@ -9,7 +11,7 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
single-insert path. Presentational: emits the chosen passages in list order. */
|
||||
@Component({
|
||||
selector: 'app-passage-picker',
|
||||
imports: [FormsModule, CheckboxComponent, ButtonComponent],
|
||||
imports: [FormsModule, CheckboxComponent, ButtonComponent, TextInputComponent],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
@@ -19,6 +21,9 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
border-radius: var(--rhc-border-radius-md);
|
||||
padding: var(--rhc-space-max-md);
|
||||
}
|
||||
.search {
|
||||
margin-block-end: var(--rhc-space-max-md);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
@@ -29,11 +34,24 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
.scope {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
}
|
||||
.empty {
|
||||
color: var(--rhc-color-foreground-subtle);
|
||||
font-style: italic;
|
||||
margin: 0 0 var(--rhc-space-max-md);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="search">
|
||||
<app-text-input
|
||||
[placeholder]="searchLabel()"
|
||||
[attr.aria-label]="searchLabel()"
|
||||
[ngModel]="query()"
|
||||
(ngModelChange)="query.set($event)"
|
||||
/>
|
||||
</div>
|
||||
<ul>
|
||||
@for (p of passages(); track p.passageId) {
|
||||
@for (p of filtered(); track p.passageId) {
|
||||
<li>
|
||||
<app-checkbox
|
||||
[checkboxId]="'passage-' + p.passageId"
|
||||
@@ -43,6 +61,8 @@ import { LibraryPassage } from '@brief/domain/brief';
|
||||
/>
|
||||
<span class="scope"> · {{ p.scope === 'beroep' ? beroepLabel() : globalLabel() }}</span>
|
||||
</li>
|
||||
} @empty {
|
||||
<li class="empty">{{ noMatchLabel() }}</li>
|
||||
}
|
||||
</ul>
|
||||
<app-button variant="secondary" [disabled]="count() === 0" (click)="add()"
|
||||
@@ -57,8 +77,20 @@ export class PassagePickerComponent {
|
||||
addLabel = input($localize`:@@brief.picker.add:Voeg toe`);
|
||||
globalLabel = input($localize`:@@brief.picker.global:algemeen`);
|
||||
beroepLabel = input($localize`:@@brief.picker.beroep:beroepsspecifiek`);
|
||||
searchLabel = input($localize`:@@brief.picker.search:Zoek in standaardteksten…`);
|
||||
noMatchLabel = input($localize`:@@brief.picker.noMatch:Geen standaardteksten gevonden.`);
|
||||
|
||||
protected checked = signal<Record<string, boolean>>({});
|
||||
protected query = signal('');
|
||||
/** Client-side filter on label + rendered content text — the library is small, so no
|
||||
server search (WP-27). Placeholder keys are searchable too (see `textOf`). */
|
||||
protected filtered = computed(() => {
|
||||
const q = this.query().trim().toLowerCase();
|
||||
if (!q) return this.passages();
|
||||
return this.passages().filter(
|
||||
(p) => p.label.toLowerCase().includes(q) || textOf(p.content).includes(q),
|
||||
);
|
||||
});
|
||||
protected count = () => Object.values(this.checked()).filter(Boolean).length;
|
||||
|
||||
protected set(id: string, on: boolean) {
|
||||
|
||||
@@ -1712,6 +1712,7 @@ export interface LibraryPassageDto {
|
||||
content?: RichTextBlockDto;
|
||||
version?: number;
|
||||
beroep?: string | undefined;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
|
||||
@@ -49,6 +49,15 @@ export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||
return structuredClone(block) as RichTextBlock;
|
||||
}
|
||||
|
||||
/** All visible text of a block as one lowercased string — for client-side search over
|
||||
passages. Placeholders contribute their key so "naam" matches a `naam_zorgverlener` chip. */
|
||||
export function textOf(block: RichTextBlock): string {
|
||||
return block.paragraphs
|
||||
.flatMap((p) => p.nodes.map((n) => (n.type === 'text' ? n.text : n.type === 'placeholder' ? n.key : '')))
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/** Every placeholder key used in a block, in document order (duplicates kept — the
|
||||
caller dedupes when it wants a set). */
|
||||
export function placeholderKeysIn(block: RichTextBlock): string[] {
|
||||
|
||||
Reference in New Issue
Block a user