feat(brief): locked sections, list formatting, auto/manual placeholder chips
- brief.machine: reducer refuses edits to locked (predefined) sections as
defense-in-depth; LetterSection gains a `locked` flag
- rich-text: paragraphs gain optional `list` kind; editor gets bullet/numbered
list buttons, keyboard shortcuts, and backspace-deletes-adjacent-chip
- placeholder chips distinguish auto-resolvable (grey) vs manual (yellow), in
both the editor and the read-only preview
- fix: preview chip now renders matching {…} braces (was a one-sided ⌗ glyph),
aligned with the editor's chip styling
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,8 +27,8 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections: sections ?? [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: false, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: false, blocks: [] },
|
||||
],
|
||||
status,
|
||||
drafterId: 'u1',
|
||||
@@ -92,6 +92,24 @@ describe('brief.machine reduce', () => {
|
||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-1']);
|
||||
});
|
||||
|
||||
it('edits to a locked section are no-ops (insert, free-text, content, remove, move)', () => {
|
||||
const lockedSections: Brief['sections'] = [
|
||||
{ sectionKey: 'aanhef', title: 'Aanhef', required: true, locked: true, blocks: [{ type: 'freeText', blockId: 'local-1', content: text('vast') }] },
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, locked: false, blocks: [] },
|
||||
];
|
||||
const s = loaded({ tag: 'draft' }, lockedSections);
|
||||
// The brief value is left untouched (withEdit reallocates state, but the guard returns
|
||||
// the same brief), so assert on deep equality of the section contents.
|
||||
expect(reduce(s, { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('gehackt') })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockRemoved', blockId: 'local-1' })).toEqual(s);
|
||||
expect(reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 0 })).toEqual(s);
|
||||
// the unlocked section still accepts edits
|
||||
const edited = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'kern' });
|
||||
expect(sectionBlocks(edited, 'kern')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('edits are no-ops once submitted (status invariant)', () => {
|
||||
const s = loaded({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
expect(reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' })).toBe(s);
|
||||
|
||||
@@ -63,6 +63,17 @@ function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => L
|
||||
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
||||
}
|
||||
|
||||
/** The section a block currently lives in, or undefined if the block is gone. */
|
||||
function sectionKeyOfBlock(brief: Brief, blockId: string): string | undefined {
|
||||
return brief.sections.find((s) => s.blocks.some((b) => b.blockId === blockId))?.sectionKey;
|
||||
}
|
||||
|
||||
/** A section accepts edits only when it is not a locked (predefined) template section. */
|
||||
function isSectionEditable(brief: Brief, sectionKey: string | undefined): boolean {
|
||||
const section = brief.sections.find((s) => s.sectionKey === sectionKey);
|
||||
return !!section && !section.locked;
|
||||
}
|
||||
|
||||
function mapBlocks(brief: Brief, f: (blocks: readonly LetterBlock[]) => LetterBlock[]): Brief {
|
||||
return { ...brief, sections: brief.sections.map((s) => ({ ...s, blocks: f(s.blocks) })) };
|
||||
}
|
||||
@@ -126,19 +137,29 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
case 'Seed':
|
||||
return m.state;
|
||||
|
||||
// Section-level guard (defense-in-depth): locked sections never accept edits, even if a
|
||||
// Msg reaches the reducer. The UI already hides controls for locked sections.
|
||||
case 'PassagesInserted':
|
||||
return withEdit(s, (b) => insertPassages(b, m.sectionKey, m.passages));
|
||||
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) => addFreeText(b, m.sectionKey));
|
||||
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
|
||||
case 'BlockContentEdited':
|
||||
return withEdit(s, (b) => editBlockContent(b, m.blockId, m.content));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
|
||||
);
|
||||
case 'BlockRemoved':
|
||||
return withEdit(s, (b) => mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId)));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) => blocks.filter((x) => x.blockId !== m.blockId))
|
||||
: b,
|
||||
);
|
||||
case 'BlockMovedWithinSection':
|
||||
return withEdit(s, (b) =>
|
||||
mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||
),
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||
)
|
||||
: b,
|
||||
);
|
||||
|
||||
case 'Submitted':
|
||||
|
||||
@@ -25,15 +25,15 @@ function brief(sections: Brief['sections']): Brief {
|
||||
describe('brief selectors', () => {
|
||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||
{ sectionKey: 's2', title: 'S2', required: false, blocks: [passage('local-2', 'reden')] },
|
||||
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'naam', 'reden')] },
|
||||
{ sectionKey: 's2', title: 'S2', required: false, locked: false, blocks: [passage('local-2', 'reden')] },
|
||||
]);
|
||||
expect(unresolvedPlaceholders(b)).toEqual(['reden']); // 'naam' is auto; 'reden' deduped
|
||||
});
|
||||
|
||||
it('allDiagnostics flattens across sections and blocks', () => {
|
||||
const b = brief([
|
||||
{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||
{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1', 'reden', 'onbekend')] },
|
||||
]);
|
||||
const codes = allDiagnostics(b).map((d) => d.code);
|
||||
expect(codes).toContain('unresolved-at-send'); // reden
|
||||
@@ -42,8 +42,8 @@ describe('brief selectors', () => {
|
||||
});
|
||||
|
||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [] }]))).toBe(false);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, blocks: [] }]))).toBe(true);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, blocks: [passage('local-1')] }]))).toBe(true);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [] }]))).toBe(false);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: false, locked: false, blocks: [] }]))).toBe(true);
|
||||
expect(canSubmit(brief([{ sectionKey: 's1', title: 'S1', required: true, locked: false, blocks: [passage('local-1')] }]))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface LetterSection {
|
||||
readonly sectionKey: string;
|
||||
readonly title: string;
|
||||
readonly required: boolean;
|
||||
// Predefined template sections (aanhef, slot) arrive locked and prefilled — the drafter
|
||||
// composes only the unlocked section(s). The reducer refuses edits to locked sections.
|
||||
readonly locked: boolean;
|
||||
readonly blocks: readonly LetterBlock[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user