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:
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user