feat(fp): WP-27 — brief UX layer (undo/redo, standaardbrief, passage search, diff badges)
CI / storybook-a11y (push) Failing after 4m43s
CI / frontend (push) Failing after 1m15s
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:
eho
2026-07-20 14:36:32 +02:00
co-authored by Claude Opus 4.8
parent 950fb5f0b2
commit 5e36d68f11
21 changed files with 3537 additions and 1907 deletions
+99 -1
View File
@@ -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.