style: format frontend, docs and skills with prettier; add .prettierignore
One-time prettier --write so the new format:check CI gate starts green. .prettierignore excludes generated (api-client.ts, documentation.json), vendored (public/cibg-huisstijl), and backend (dotnet format owns it). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,9 @@ const placeholders: PlaceholderDef[] = [
|
||||
{ key: 'reden', label: 'Reden', autoResolvable: false },
|
||||
];
|
||||
|
||||
const text = (t: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text: t }] }] });
|
||||
const text = (t: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text: t }] }],
|
||||
});
|
||||
|
||||
const libPassage = (id: string, sectionKey: string): LibraryPassage => ({
|
||||
passageId: id,
|
||||
@@ -35,7 +37,10 @@ function briefWith(status: BriefStatus, sections?: Brief['sections']): Brief {
|
||||
};
|
||||
}
|
||||
|
||||
const loaded = (status: BriefStatus = { tag: 'draft' }, sections?: Brief['sections']): BriefState => ({
|
||||
const loaded = (
|
||||
status: BriefStatus = { tag: 'draft' },
|
||||
sections?: Brief['sections'],
|
||||
): BriefState => ({
|
||||
tag: 'loaded',
|
||||
brief: briefWith(status, sections),
|
||||
availablePassages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
@@ -46,14 +51,27 @@ const sectionBlocks = (s: BriefState, key: string) =>
|
||||
|
||||
describe('brief.machine reduce', () => {
|
||||
it('BriefLoaded / BriefLoadFailed / Seed set state directly', () => {
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoaded', brief: briefWith({ tag: 'draft' }), availablePassages: [] }).tag).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({ tag: 'failed', reason: 'x' });
|
||||
expect(
|
||||
reduce(initialLoading(), {
|
||||
tag: 'BriefLoaded',
|
||||
brief: briefWith({ tag: 'draft' }),
|
||||
availablePassages: [],
|
||||
}).tag,
|
||||
).toBe('loaded');
|
||||
expect(reduce(initialLoading(), { tag: 'BriefLoadFailed', reason: 'x' })).toEqual({
|
||||
tag: 'failed',
|
||||
reason: 'x',
|
||||
});
|
||||
const seeded = loaded();
|
||||
expect(reduce(initialLoading(), { tag: 'Seed', state: seeded })).toBe(seeded);
|
||||
});
|
||||
|
||||
it('PassagesInserted creates one frozen block per passage, in order, with local ids', () => {
|
||||
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
const blocks = sectionBlocks(s, 'aanhef');
|
||||
expect(blocks.map((b) => b.blockId)).toEqual(['local-1', 'local-2']);
|
||||
expect(blocks.every((b) => b.type === 'passage' && b.edited === false)).toBe(true);
|
||||
@@ -62,7 +80,11 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
it('PassagesInserted deep-copies content — later library mutation does not leak in', () => {
|
||||
const passage = libPassage('p1', 'aanhef');
|
||||
const s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [passage] });
|
||||
const s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [passage],
|
||||
});
|
||||
// Mutate the source passage object after insertion.
|
||||
(passage.content.paragraphs[0].nodes as { type: 'text'; text: string }[])[0].text = 'HACKED';
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
@@ -77,7 +99,11 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('BlockContentEdited replaces content and marks a passage block edited', () => {
|
||||
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef')] });
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef')],
|
||||
});
|
||||
s = reduce(s, { tag: 'BlockContentEdited', blockId: 'local-1', content: text('aangepast') });
|
||||
const block = sectionBlocks(s, 'aanhef')[0];
|
||||
expect(block.type === 'passage' && block.edited).toBe(true);
|
||||
@@ -85,7 +111,11 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('BlockRemoved and BlockMovedWithinSection reorder within a section', () => {
|
||||
let s = reduce(loaded(), { tag: 'PassagesInserted', sectionKey: 'aanhef', passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')] });
|
||||
let s = reduce(loaded(), {
|
||||
tag: 'PassagesInserted',
|
||||
sectionKey: 'aanhef',
|
||||
passages: [libPassage('p1', 'aanhef'), libPassage('p2', 'aanhef')],
|
||||
});
|
||||
s = reduce(s, { tag: 'BlockMovedWithinSection', blockId: 'local-1', toIndex: 1 });
|
||||
expect(sectionBlocks(s, 'aanhef').map((b) => b.blockId)).toEqual(['local-2', 'local-1']);
|
||||
s = reduce(s, { tag: 'BlockRemoved', blockId: 'local-2' });
|
||||
@@ -94,17 +124,33 @@ describe('brief.machine reduce', () => {
|
||||
|
||||
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: '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: '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: '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);
|
||||
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);
|
||||
@@ -116,7 +162,12 @@ describe('brief.machine reduce', () => {
|
||||
});
|
||||
|
||||
it('editing a rejected letter reopens it to draft', () => {
|
||||
const s = loaded({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't', comments: 'graag aanpassen' });
|
||||
const s = loaded({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't',
|
||||
comments: 'graag aanpassen',
|
||||
});
|
||||
const next = reduce(s, { tag: 'FreeTextBlockAdded', sectionKey: 'slot' });
|
||||
expect(next.tag === 'loaded' && next.brief.status.tag).toBe('draft');
|
||||
expect(sectionBlocks(next, 'slot')).toHaveLength(1);
|
||||
@@ -128,7 +179,11 @@ describe('brief.machine reduce', () => {
|
||||
// fill the required section, then submit
|
||||
const filled = reduce(loaded(), { tag: 'FreeTextBlockAdded', sectionKey: 'aanhef' });
|
||||
const submitted = reduce(filled, { tag: 'Submitted', by: 'u1', at: 't' });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({ tag: 'submitted', submittedBy: 'u1', submittedAt: 't' });
|
||||
expect(submitted.tag === 'loaded' && submitted.brief.status).toEqual({
|
||||
tag: 'submitted',
|
||||
submittedBy: 'u1',
|
||||
submittedAt: 't',
|
||||
});
|
||||
});
|
||||
|
||||
it('approve/reject fire only from submitted; send only from approved', () => {
|
||||
@@ -136,10 +191,19 @@ describe('brief.machine reduce', () => {
|
||||
// approve from draft is a no-op
|
||||
expect(reduce(loaded(), { tag: 'Approved', by: 'u2', at: 't' })).toEqual(loaded());
|
||||
const approved = reduce(submitted, { tag: 'Approved', by: 'u2', at: 't2' });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({ tag: 'approved', approvedBy: 'u2', approvedAt: 't2' });
|
||||
expect(approved.tag === 'loaded' && approved.brief.status).toEqual({
|
||||
tag: 'approved',
|
||||
approvedBy: 'u2',
|
||||
approvedAt: 't2',
|
||||
});
|
||||
// reject carries comments
|
||||
const rejected = reduce(submitted, { tag: 'Rejected', by: 'u2', at: 't2', comments: 'nee' });
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({ tag: 'rejected', rejectedBy: 'u2', rejectedAt: 't2', comments: 'nee' });
|
||||
expect(rejected.tag === 'loaded' && rejected.brief.status).toEqual({
|
||||
tag: 'rejected',
|
||||
rejectedBy: 'u2',
|
||||
rejectedAt: 't2',
|
||||
comments: 'nee',
|
||||
});
|
||||
// send only from approved
|
||||
expect(reduce(submitted, { tag: 'Sent', at: 't' })).toBe(submitted);
|
||||
const sent = reduce(approved, { tag: 'Sent', at: 't3' });
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { assertNever } from '@shared/kernel/fp';
|
||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, allBlocks, canSubmit } from './brief';
|
||||
import {
|
||||
Brief,
|
||||
BriefStatus,
|
||||
LetterBlock,
|
||||
LetterSection,
|
||||
LibraryPassage,
|
||||
allBlocks,
|
||||
canSubmit,
|
||||
} from './brief';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
@@ -59,8 +67,15 @@ function nextLocalIndex(brief: Brief): number {
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
function mapSection(brief: Brief, sectionKey: string, f: (s: LetterSection) => LetterSection): Brief {
|
||||
return { ...brief, sections: brief.sections.map((s) => (s.sectionKey === sectionKey ? f(s) : s)) };
|
||||
function mapSection(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
f: (s: LetterSection) => LetterSection,
|
||||
): Brief {
|
||||
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. */
|
||||
@@ -86,7 +101,11 @@ function withEdit(s: BriefState, f: (b: Brief) => Brief): BriefState {
|
||||
return { ...s, brief };
|
||||
}
|
||||
|
||||
function insertPassages(brief: Brief, sectionKey: string, passages: readonly LibraryPassage[]): Brief {
|
||||
function insertPassages(
|
||||
brief: Brief,
|
||||
sectionKey: string,
|
||||
passages: readonly LibraryPassage[],
|
||||
): Brief {
|
||||
let idx = nextLocalIndex(brief);
|
||||
// The freeze happens HERE: each block gets a deep VALUE copy of the library content,
|
||||
// so later library edits can never mutate this letter (frozen snapshot).
|
||||
@@ -102,7 +121,11 @@ function insertPassages(brief: Brief, sectionKey: string, passages: readonly Lib
|
||||
}
|
||||
|
||||
function addFreeText(brief: Brief, sectionKey: string): Brief {
|
||||
const block: LetterBlock = { type: 'freeText', blockId: `local-${nextLocalIndex(brief)}`, content: emptyBlock() };
|
||||
const block: LetterBlock = {
|
||||
type: 'freeText',
|
||||
blockId: `local-${nextLocalIndex(brief)}`,
|
||||
content: emptyBlock(),
|
||||
};
|
||||
return mapSection(brief, sectionKey, (s) => ({ ...s, blocks: [...s.blocks, block] }));
|
||||
}
|
||||
|
||||
@@ -118,7 +141,11 @@ function editBlockContent(brief: Brief, blockId: string, content: RichTextBlock)
|
||||
);
|
||||
}
|
||||
|
||||
function moveWithinSection(blocks: readonly LetterBlock[], blockId: string, toIndex: number): LetterBlock[] {
|
||||
function moveWithinSection(
|
||||
blocks: readonly LetterBlock[],
|
||||
blockId: string,
|
||||
toIndex: number,
|
||||
): LetterBlock[] {
|
||||
const from = blocks.findIndex((b) => b.blockId === blockId);
|
||||
if (from === -1) return [...blocks];
|
||||
const clamped = Math.max(0, Math.min(toIndex, blocks.length - 1));
|
||||
@@ -140,12 +167,18 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
// 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) => (isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? insertPassages(b, m.sectionKey, m.passages) : b,
|
||||
);
|
||||
case 'FreeTextBlockAdded':
|
||||
return withEdit(s, (b) => (isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b));
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, m.sectionKey) ? addFreeText(b, m.sectionKey) : b,
|
||||
);
|
||||
case 'BlockContentEdited':
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId)) ? editBlockContent(b, m.blockId, m.content) : b,
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? editBlockContent(b, m.blockId, m.content)
|
||||
: b,
|
||||
);
|
||||
case 'BlockRemoved':
|
||||
return withEdit(s, (b) =>
|
||||
@@ -157,18 +190,34 @@ export function reduce(s: BriefState, m: BriefMsg): BriefState {
|
||||
return withEdit(s, (b) =>
|
||||
isSectionEditable(b, sectionKeyOfBlock(b, m.blockId))
|
||||
? mapBlocks(b, (blocks) =>
|
||||
blocks.some((x) => x.blockId === m.blockId) ? moveWithinSection(blocks, m.blockId, m.toIndex) : [...blocks],
|
||||
blocks.some((x) => x.blockId === m.blockId)
|
||||
? moveWithinSection(blocks, m.blockId, m.toIndex)
|
||||
: [...blocks],
|
||||
)
|
||||
: b,
|
||||
);
|
||||
|
||||
case 'Submitted':
|
||||
// Guard the transition AND the completeness invariant.
|
||||
return transition(s, 'draft', () => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }), canSubmit);
|
||||
return transition(
|
||||
s,
|
||||
'draft',
|
||||
() => ({ tag: 'submitted', submittedBy: m.by, submittedAt: m.at }),
|
||||
canSubmit,
|
||||
);
|
||||
case 'Approved':
|
||||
return transition(s, 'submitted', () => ({ tag: 'approved', approvedBy: m.by, approvedAt: m.at }));
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'approved',
|
||||
approvedBy: m.by,
|
||||
approvedAt: m.at,
|
||||
}));
|
||||
case 'Rejected':
|
||||
return transition(s, 'submitted', () => ({ tag: 'rejected', rejectedBy: m.by, rejectedAt: m.at, comments: m.comments }));
|
||||
return transition(s, 'submitted', () => ({
|
||||
tag: 'rejected',
|
||||
rejectedBy: m.by,
|
||||
rejectedAt: m.at,
|
||||
comments: m.comments,
|
||||
}));
|
||||
case 'Sent':
|
||||
return transition(s, 'approved', () => ({ tag: 'sent', sentAt: m.at }));
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { Brief, LetterBlock, allDiagnostics, canSubmit, hasBlockingErrors, unresolvedPlaceholders } from './brief';
|
||||
import {
|
||||
Brief,
|
||||
LetterBlock,
|
||||
allDiagnostics,
|
||||
canSubmit,
|
||||
hasBlockingErrors,
|
||||
unresolvedPlaceholders,
|
||||
} from './brief';
|
||||
import { PlaceholderDef } from './placeholders';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
@@ -19,21 +26,47 @@ const passage = (blockId: string, ...keys: string[]): LetterBlock => ({
|
||||
});
|
||||
|
||||
function brief(sections: Brief['sections']): Brief {
|
||||
return { briefId: 'b1', beroep: 'arts', templateId: 't1', placeholders, sections, status: { tag: 'draft' }, drafterId: 'u1' };
|
||||
return {
|
||||
briefId: 'b1',
|
||||
beroep: 'arts',
|
||||
templateId: 't1',
|
||||
placeholders,
|
||||
sections,
|
||||
status: { tag: 'draft' },
|
||||
drafterId: 'u1',
|
||||
};
|
||||
}
|
||||
|
||||
describe('brief selectors', () => {
|
||||
it('unresolvedPlaceholders returns deduped manual keys only (auto excluded)', () => {
|
||||
const b = brief([
|
||||
{ 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')] },
|
||||
{
|
||||
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, locked: false, 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 +75,28 @@ describe('brief selectors', () => {
|
||||
});
|
||||
|
||||
it('canSubmit is false when a required section is empty, true otherwise', () => {
|
||||
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);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,12 @@ export type BriefStatus =
|
||||
| { readonly tag: 'draft' }
|
||||
| { readonly tag: 'submitted'; readonly submittedBy: string; readonly submittedAt: string }
|
||||
| { readonly tag: 'approved'; readonly approvedBy: string; readonly approvedAt: string }
|
||||
| { readonly tag: 'rejected'; readonly rejectedBy: string; readonly rejectedAt: string; readonly comments: string }
|
||||
| {
|
||||
readonly tag: 'rejected';
|
||||
readonly rejectedBy: string;
|
||||
readonly rejectedAt: string;
|
||||
readonly comments: string;
|
||||
}
|
||||
| { readonly tag: 'sent'; readonly sentAt: string };
|
||||
|
||||
export interface Brief {
|
||||
@@ -81,7 +86,9 @@ export function allBlocks(brief: Brief): LetterBlock[] {
|
||||
/** Every diagnostic in the letter, in section→block→node order. This is what the
|
||||
diagnostics panel renders and what the send gate checks. */
|
||||
export function allDiagnostics(brief: Brief): Diagnostic[] {
|
||||
return allBlocks(brief).flatMap((b) => lintPlaceholders(b.content, brief.placeholders, b.blockId));
|
||||
return allBlocks(brief).flatMap((b) =>
|
||||
lintPlaceholders(b.content, brief.placeholders, b.blockId),
|
||||
);
|
||||
}
|
||||
|
||||
export function hasBlockingErrors(diagnostics: readonly Diagnostic[]): boolean {
|
||||
|
||||
@@ -9,8 +9,12 @@ const valid: PlaceholderDef[] = [
|
||||
{ key: 'niet_invulbaar', label: 'Niet invulbaar', autoResolvable: true, fillable: false },
|
||||
];
|
||||
|
||||
const withPlaceholder = (key: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'placeholder', key }] }] });
|
||||
const withText = (text: string): RichTextBlock => ({ paragraphs: [{ nodes: [{ type: 'text', text }] }] });
|
||||
const withPlaceholder = (key: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'placeholder', key }] }],
|
||||
});
|
||||
const withText = (text: string): RichTextBlock => ({
|
||||
paragraphs: [{ nodes: [{ type: 'text', text }] }],
|
||||
});
|
||||
|
||||
describe('lintPlaceholders', () => {
|
||||
it('clean content (auto-resolvable, fillable, current key) yields no diagnostics', () => {
|
||||
@@ -55,10 +59,17 @@ describe('lintPlaceholders', () => {
|
||||
const content: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'placeholder', key: 'onbekend' }] },
|
||||
{ nodes: [{ type: 'text', text: 'ok' }, { type: 'placeholder', key: 'reden' }] },
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'ok' },
|
||||
{ type: 'placeholder', key: 'reden' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const codes = lintPlaceholders(content, valid, 'b1').map((d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`);
|
||||
const codes = lintPlaceholders(content, valid, 'b1').map(
|
||||
(d) => `${d.code}@${d.location.paragraphIndex}.${d.location.nodeIndex}`,
|
||||
);
|
||||
expect(codes).toEqual(['unknown-placeholder@0.0', 'unresolved-at-send@1.1']);
|
||||
});
|
||||
|
||||
|
||||
@@ -77,7 +77,13 @@ function messageFor(code: DiagnosticCode, key?: string): string {
|
||||
}
|
||||
|
||||
function diag(code: DiagnosticCode, location: DiagnosticLocation, key?: string): Diagnostic {
|
||||
return { severity: severityOf(code), code, placeholderKey: key, location, message: messageFor(code, key) };
|
||||
return {
|
||||
severity: severityOf(code),
|
||||
code,
|
||||
placeholderKey: key,
|
||||
location,
|
||||
message: messageFor(code, key),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user