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