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:
@@ -21,6 +21,8 @@ export class BriefStore {
|
||||
readonly role: Role = currentRole();
|
||||
readonly busy = signal(false);
|
||||
readonly lastError = signal<string | null>(null);
|
||||
/** Surfaced autosave state for the indicator + aria-live region. */
|
||||
readonly saveState = signal<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
|
||||
private brief = computed<Brief | null>(() => {
|
||||
const s = this.model();
|
||||
@@ -63,8 +65,26 @@ export class BriefStore {
|
||||
private async flushSave() {
|
||||
const b = this.brief();
|
||||
if (!b) return;
|
||||
this.saveState.set('saving');
|
||||
const r = await this.adapter.save(b.sections);
|
||||
if (!r.ok) this.lastError.set(r.error);
|
||||
if (r.ok) {
|
||||
this.saveState.set('saved');
|
||||
} else {
|
||||
this.lastError.set(r.error);
|
||||
this.saveState.set('error');
|
||||
}
|
||||
}
|
||||
|
||||
/** Demo "start over": recreate the brief server-side and load the fresh view. */
|
||||
async resetDemo() {
|
||||
this.busy.set(true);
|
||||
this.lastError.set(null);
|
||||
clearTimeout(this.saveTimer);
|
||||
const r = await this.adapter.reset();
|
||||
this.busy.set(false);
|
||||
this.saveState.set('idle');
|
||||
if (r.ok) this.store.dispatch({ tag: 'BriefLoaded', brief: r.value.brief, availablePassages: r.value.availablePassages });
|
||||
else this.lastError.set(r.error);
|
||||
}
|
||||
|
||||
submit = () => this.transition(() => this.adapter.submit());
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,40 @@ describe('brief.adapter parse boundary', () => {
|
||||
expect(parseStatus({ tag: 'draft' }).ok).toBe(true);
|
||||
});
|
||||
|
||||
it('reads section.locked (default false) and paragraph.list', () => {
|
||||
const r = parseBrief({
|
||||
...view.brief,
|
||||
sections: [
|
||||
{
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [
|
||||
{
|
||||
type: 'freeText',
|
||||
blockId: 'b1',
|
||||
content: {
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'een' }], list: 'bullet' },
|
||||
{ nodes: [{ type: 'text', text: 'twee' }], list: 'number' },
|
||||
{ nodes: [{ type: 'text', text: 'plat' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{ sectionKey: 'kern', title: 'Kern', required: true, blocks: [] }, // no `locked` → false
|
||||
],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
if (!r.ok) return;
|
||||
const [aanhef, kern] = r.value.sections;
|
||||
expect(aanhef.locked).toBe(true);
|
||||
expect(kern.locked).toBe(false);
|
||||
expect(aanhef.blocks[0].content.paragraphs.map((p) => p.list)).toEqual(['bullet', 'number', undefined]);
|
||||
});
|
||||
|
||||
it('rejects a passage block missing provenance', () => {
|
||||
const r = parseBrief({
|
||||
...view.brief,
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from '@shared/infrastructure/api-client';
|
||||
import { Brief, BriefStatus, LetterBlock, LetterSection, LibraryPassage, PassageScope } from '@brief/domain/brief';
|
||||
import { PlaceholderDef } from '@brief/domain/placeholders';
|
||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
import { Mark, Paragraph, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The only place brief HTTP lives (ADR-0001 anti-corruption boundary). The wire
|
||||
@@ -66,6 +66,12 @@ export class BriefAdapter {
|
||||
const r = await runSubmit(() => this.client.send(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBrief(r.value) : r;
|
||||
}
|
||||
|
||||
/** Demo "start over" — recreate a fresh brief server-side and return the new view. */
|
||||
async reset(): Promise<Result<string, BriefView>> {
|
||||
const r = await runSubmit(() => this.client.briefReset(), BRIEF_ACTION_FAILED);
|
||||
return r.ok ? parseBriefView(r.value) : r;
|
||||
}
|
||||
}
|
||||
|
||||
// --- parse: wire (flat) → domain (discriminated unions), validating at the boundary ---
|
||||
@@ -90,7 +96,7 @@ export function parseNode(dto: RichTextNodeDto): Result<string, RichTextNode> {
|
||||
|
||||
export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<string, RichTextBlock> {
|
||||
if (!dto || !Array.isArray(dto.paragraphs)) return err('content: paragraphs not an array');
|
||||
const paragraphs = [];
|
||||
const paragraphs: Paragraph[] = [];
|
||||
for (const p of dto.paragraphs) {
|
||||
const nodes: RichTextNode[] = [];
|
||||
for (const n of p.nodes ?? []) {
|
||||
@@ -98,7 +104,8 @@ export function parseBlockContent(dto: RichTextBlockDto | undefined): Result<str
|
||||
if (!parsed.ok) return parsed;
|
||||
nodes.push(parsed.value);
|
||||
}
|
||||
paragraphs.push({ nodes });
|
||||
const list = p.list === 'bullet' || p.list === 'number' ? p.list : undefined;
|
||||
paragraphs.push(list ? { nodes, list } : { nodes });
|
||||
}
|
||||
return ok({ paragraphs });
|
||||
}
|
||||
@@ -135,7 +142,7 @@ function parseSection(dto: LetterSectionDto): Result<string, LetterSection> {
|
||||
if (!parsed.ok) return parsed;
|
||||
blocks.push(parsed.value);
|
||||
}
|
||||
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, blocks });
|
||||
return ok({ sectionKey: dto.sectionKey, title: dto.title, required: dto.required, locked: dto.locked ?? false, blocks });
|
||||
}
|
||||
|
||||
function parsePlaceholderDef(dto: PlaceholderDefDto): Result<string, PlaceholderDef> {
|
||||
@@ -245,7 +252,7 @@ function nodeToDto(n: RichTextNode): RichTextNodeDto {
|
||||
}
|
||||
|
||||
function contentToDto(content: RichTextBlock): RichTextBlockDto {
|
||||
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto) })) };
|
||||
return { paragraphs: content.paragraphs.map((p) => ({ nodes: p.nodes.map(nodeToDto), ...(p.list ? { list: p.list } : {}) })) };
|
||||
}
|
||||
|
||||
function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||
@@ -255,5 +262,5 @@ function blockToDto(b: LetterBlock): LetterBlockDto {
|
||||
}
|
||||
|
||||
function sectionToDto(s: LetterSection): LetterSectionDto {
|
||||
return { sectionKey: s.sectionKey, title: s.title, required: s.required, blocks: s.blocks.map(blockToDto) };
|
||||
return { sectionKey: s.sectionKey, title: s.title, required: s.required, locked: s.locked, blocks: s.blocks.map(blockToDto) };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, computed, inject } from '@angular/core';
|
||||
import { PageShellComponent } from '@shared/layout/page-shell/page-shell.component';
|
||||
import { SpinnerComponent } from '@shared/ui/spinner/spinner.component';
|
||||
import { AlertComponent } from '@shared/ui/alert/alert.component';
|
||||
@@ -12,6 +12,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
@Component({
|
||||
selector: 'app-brief-page',
|
||||
imports: [PageShellComponent, SpinnerComponent, AlertComponent, ButtonComponent, LetterComposerComponent],
|
||||
styles: [`
|
||||
.brief-toolbar{display:flex;justify-content:space-between;align-items:center;gap:var(--rhc-space-max-md);margin-block-end:var(--rhc-space-max-lg)}
|
||||
.save{color:var(--rhc-color-foreground-subtle);font-size:0.9em}
|
||||
`],
|
||||
template: `
|
||||
<app-page-shell
|
||||
[heading]="heading"
|
||||
@@ -26,6 +30,10 @@ import { LetterComposerComponent } from '@brief/ui/letter-composer/letter-compos
|
||||
<app-button variant="secondary" (click)="reload()">{{ retryText }}</app-button>
|
||||
}
|
||||
@case ('loaded') {
|
||||
<div class="brief-toolbar">
|
||||
<span class="save" role="status" aria-live="polite">{{ saveText() }}</span>
|
||||
<app-button variant="subtle" [disabled]="store.busy()" (click)="resetDemo()">{{ resetLabel }}</app-button>
|
||||
</div>
|
||||
<app-letter-composer
|
||||
[brief]="brief()!"
|
||||
[availablePassages]="availablePassages()"
|
||||
@@ -53,11 +61,30 @@ export class BriefPage {
|
||||
protected intro = $localize`:@@brief.page.intro:Stel de brief aan de zorgverlener samen uit standaardteksten en vrije tekst.`;
|
||||
protected failedText = $localize`:@@brief.page.failed:De brief kon niet worden geladen.`;
|
||||
protected retryText = $localize`:@@brief.page.retry:Opnieuw proberen`;
|
||||
protected resetLabel = $localize`:@@brief.page.reset:Opnieuw beginnen (demo)`;
|
||||
|
||||
private savingText = $localize`:@@brief.page.saving:Concept opslaan…`;
|
||||
private savedText = $localize`:@@brief.page.saved:Concept opgeslagen`;
|
||||
private saveErrorText = $localize`:@@brief.page.saveError:Opslaan mislukt`;
|
||||
|
||||
/** Debounced-save state, surfaced in a polite live region. */
|
||||
protected saveText = computed(() => {
|
||||
switch (this.store.saveState()) {
|
||||
case 'saving': return this.savingText;
|
||||
case 'saved': return this.savedText;
|
||||
case 'error': return this.saveErrorText;
|
||||
default: return '';
|
||||
}
|
||||
});
|
||||
|
||||
constructor() {
|
||||
void this.store.load();
|
||||
}
|
||||
|
||||
protected resetDemo() {
|
||||
void this.store.resetDemo();
|
||||
}
|
||||
|
||||
// Narrow the loaded state for the template.
|
||||
protected brief() {
|
||||
const s = this.model();
|
||||
|
||||
@@ -46,7 +46,7 @@ import { RejectionCommentsComponent } from '@brief/ui/rejection-comments/rejecti
|
||||
[section]="section"
|
||||
[availablePassages]="availablePassages()"
|
||||
[placeholders]="menu()"
|
||||
[editable]="true"
|
||||
[editable]="!section.locked"
|
||||
(edit)="edit.emit($event)" />
|
||||
}
|
||||
</div>
|
||||
@@ -123,7 +123,9 @@ export class LetterComposerComponent {
|
||||
// The insert menu offers only valid, fillable, non-deprecated fields — inserting an
|
||||
// unknown/retired key is structurally impossible.
|
||||
protected menu = computed<PlaceholderOption[]>(() =>
|
||||
this.brief().placeholders.filter((p) => p.fillable !== false && !p.deprecated).map((p) => ({ key: p.key, label: p.label })),
|
||||
this.brief()
|
||||
.placeholders.filter((p) => p.fillable !== false && !p.deprecated)
|
||||
.map((p) => ({ key: p.key, label: p.label, autoResolvable: p.autoResolvable })),
|
||||
);
|
||||
|
||||
protected statusLabel = computed(() => {
|
||||
|
||||
@@ -24,15 +24,17 @@ function brief(status: BriefStatus): Brief {
|
||||
sectionKey: 'aanhef',
|
||||
title: 'Aanhef',
|
||||
required: true,
|
||||
locked: true,
|
||||
blocks: [{ type: 'passage', blockId: 'local-1', sourcePassageId: 'p1', sourceVersion: 1, edited: false, content: passages[0].content }],
|
||||
},
|
||||
{
|
||||
sectionKey: 'kern',
|
||||
title: 'Kern van het besluit',
|
||||
required: true,
|
||||
locked: false,
|
||||
blocks: [{ type: 'freeText', blockId: 'local-2', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Wij hebben besloten om reden ' }, { type: 'placeholder', key: 'reden_besluit' }, { type: 'text', text: '.' }] }] } }],
|
||||
},
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, blocks: [] },
|
||||
{ sectionKey: 'slot', title: 'Slot', required: false, locked: true, blocks: [{ type: 'freeText', blockId: 'local-3', content: { paragraphs: [{ nodes: [{ type: 'text', text: 'Met vriendelijke groet,' }] }] } }] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,42 +1,83 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
import { Component, computed, input, signal } from '@angular/core';
|
||||
import { NgTemplateOutlet } from '@angular/common';
|
||||
import { HeadingComponent } from '@shared/ui/heading/heading.component';
|
||||
import { ButtonComponent } from '@shared/ui/button/button.component';
|
||||
import { PlaceholderChipComponent } from '@shared/ui/placeholder-chip/placeholder-chip.component';
|
||||
import { Brief } from '@brief/domain/brief';
|
||||
import { Paragraph } from '@shared/kernel/rich-text';
|
||||
import { Brief, LetterBlock } from '@brief/domain/brief';
|
||||
import { Diagnostic } from '@brief/domain/placeholders';
|
||||
|
||||
/** A run of consecutive lines to render together: a list (bullet/number) or a single plain line. */
|
||||
type PreviewSegment = { readonly list: 'bullet' | 'number' | null; readonly items: readonly Paragraph[] };
|
||||
|
||||
function groupParagraphs(paras: readonly Paragraph[]): PreviewSegment[] {
|
||||
const out: { list: 'bullet' | 'number' | null; items: Paragraph[] }[] = [];
|
||||
for (const para of paras) {
|
||||
const kind = para.list ?? null;
|
||||
const last = out[out.length - 1];
|
||||
if (kind && last && last.list === kind) last.items.push(para);
|
||||
else out.push({ list: kind, items: [para] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Illustrative values for the "Voorbeeld" toggle — what send resolves server-side.
|
||||
const SAMPLE_VALUES: Record<string, string> = {
|
||||
naam_zorgverlener: 'J. Jansen',
|
||||
big_nummer: '12345678901',
|
||||
};
|
||||
|
||||
/** Organism: read-only rendering of the letter as the approver/recipient sees it.
|
||||
Placeholders show as labelled chips (values are resolved server-side only at send);
|
||||
a chip flagged by the linter shows its error/warning state. */
|
||||
a chip flagged by the linter shows its error/warning state. The "Voorbeeld" toggle
|
||||
fills auto-resolvable placeholders with sample values to preview the sent result. */
|
||||
@Component({
|
||||
selector: 'app-letter-preview',
|
||||
imports: [HeadingComponent, PlaceholderChipComponent],
|
||||
imports: [NgTemplateOutlet, HeadingComponent, ButtonComponent, PlaceholderChipComponent],
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.toolbar{display:flex;justify-content:flex-end;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.letter{background:var(--rhc-color-wit);border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-md);padding:var(--rhc-space-max-2xl)}
|
||||
section{margin-block-end:var(--rhc-space-max-xl)}
|
||||
p{margin:0 0 var(--rhc-space-max-sm)}
|
||||
ul,ol{margin:0 0 var(--rhc-space-max-sm);padding-inline-start:1.4em}
|
||||
`],
|
||||
template: `
|
||||
<ng-template #line let-nodes>
|
||||
@for (node of nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') { <span>{{ node.text }}</span> }
|
||||
@case ('lineBreak') { <br> }
|
||||
@case ('placeholder') {
|
||||
@if (showSample() && autoFor(node.key)) {
|
||||
<span>{{ sampleFor(node.key) }}</span>
|
||||
} @else {
|
||||
<app-placeholder-chip [label]="labelFor(node.key)" [autoResolvable]="autoFor(node.key)" [state]="stateFor(node.key)" />
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
||||
<div class="toolbar">
|
||||
<app-button variant="subtle" (click)="showSample.set(!showSample())" [attr.aria-pressed]="showSample()">
|
||||
{{ showSample() ? hideSampleLabel() : showSampleLabel() }}
|
||||
</app-button>
|
||||
</div>
|
||||
|
||||
<div class="letter">
|
||||
@for (section of brief().sections; track section.sectionKey) {
|
||||
<section>
|
||||
<app-heading [level]="3">{{ section.title }}</app-heading>
|
||||
@for (block of section.blocks; track block.blockId) {
|
||||
@for (para of block.content.paragraphs; track $index) {
|
||||
<p>
|
||||
@for (node of para.nodes; track $index) {
|
||||
@switch (node.type) {
|
||||
@case ('text') { <span>{{ node.text }}</span> }
|
||||
@case ('lineBreak') { <br> }
|
||||
@case ('placeholder') {
|
||||
<app-placeholder-chip
|
||||
[label]="labelFor(node.key)"
|
||||
[autoResolvable]="autoFor(node.key)"
|
||||
[state]="stateFor(node.key)" />
|
||||
}
|
||||
}
|
||||
}
|
||||
</p>
|
||||
@for (seg of segmentsOf(block); track $index) {
|
||||
@if (seg.list === 'bullet') {
|
||||
<ul>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ul>
|
||||
} @else if (seg.list === 'number') {
|
||||
<ol>@for (para of seg.items; track $index) { <li><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: para.nodes }" /></li>}</ol>
|
||||
} @else {
|
||||
<p><ng-container [ngTemplateOutlet]="line" [ngTemplateOutletContext]="{ $implicit: seg.items[0].nodes }" /></p>
|
||||
}
|
||||
}
|
||||
}
|
||||
</section>
|
||||
@@ -48,6 +89,12 @@ export class LetterPreviewComponent {
|
||||
brief = input.required<Brief>();
|
||||
diagnostics = input<readonly Diagnostic[]>([]);
|
||||
|
||||
showSampleLabel = input($localize`:@@brief.preview.showSample:Voorbeeld met testwaarden`);
|
||||
hideSampleLabel = input($localize`:@@brief.preview.hideSample:Testwaarden verbergen`);
|
||||
|
||||
protected showSample = signal(false);
|
||||
private today = new Date().toLocaleDateString('nl-NL', { day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
private defs = computed(() => new Map(this.brief().placeholders.map((p) => [p.key, p])));
|
||||
private worst = computed(() => {
|
||||
const m = new Map<string, 'error' | 'warning'>();
|
||||
@@ -59,7 +106,9 @@ export class LetterPreviewComponent {
|
||||
return m;
|
||||
});
|
||||
|
||||
protected segmentsOf = (block: LetterBlock) => groupParagraphs(block.content.paragraphs);
|
||||
protected labelFor = (key: string) => this.defs().get(key)?.label ?? key;
|
||||
protected autoFor = (key: string) => this.defs().get(key)?.autoResolvable ?? false;
|
||||
protected stateFor = (key: string): 'ok' | 'warning' | 'error' => this.worst().get(key) ?? 'ok';
|
||||
protected sampleFor = (key: string) => SAMPLE_VALUES[key] ?? (key === 'datum' ? this.today : this.labelFor(key));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user