feat(brief): letter composition + two-person approval (teaching slice)
New `brief` context — a letter-composition feature with a drafter/approver approval workflow, built as a teaching vertical slice on the repo's existing FP + Elm + atomic-design patterns (see plan in ~/.claude/plans). Domain (pure): - Rich text as a serialisable value tree (placeholders are first-class nodes), moved to @shared/kernel/rich-text.ts so the shared editor can use it. - lintPlaceholders: a pure, total content -> Diagnostic[] linter, derived never stored. - brief.machine.ts: status sum-type with guarded transitions; frozen-snapshot = deep value copy; derived diagnostics/editability. Full specs. Backend (.NET stub): - BriefStore + seed, GET/PUT /brief and submit/approve/reject/send endpoints, role via X-Role header (mirrors X-Admin), transition + approver!=drafter guards, audit logging. Regenerated typed client via gen:api. +6 backend tests. Seam: - brief.adapter.ts maps flat wire unions <-> domain discriminated unions at the parse boundary (+ spec). UI (atomic): - shared atoms: checkbox, placeholder-chip; molecule: rich-text-editor (no-dep contenteditable, DOM<->RichTextBlock round-trip tested). - brief/ui: letter-block, passage-picker, diagnostics-panel, rejection-comments, letter-section, letter-composer, letter-preview, brief.page + /brief route. - Dev-only ?role=drafter|approver toggle + roleInterceptor; dashboard nav link. Enforcement: @brief/* alias + eslint layer boundary (brief depends only on shared). Also included (same session): - Value-object specs (postcode/uren/big-nummer) — closes the "domain must have a spec" gap. - src/docs/ Storybook MDX foundation pages (atomic design, tokens, FP-in-UI). - .storybook/tsconfig.json: add @angular/localize to types (Storybook was fully broken — $localize unresolved — dev + build). Verified: 168 FE tests, 68 backend tests, lint/build/check:tokens green, Storybook boots, end-to-end HTTP smoke (self-approve 403, approver 200, full flow). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -929,6 +929,284 @@ export class ApiClient {
|
||||
}
|
||||
return Promise.resolve<SubmitApplicationResponse>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
briefGET(): Promise<BriefViewDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processBriefGET(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processBriefGET(response: Response): Promise<BriefViewDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefViewDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefViewDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
briefPUT(body: SaveBriefRequest): Promise<BriefDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
const content_ = JSON.stringify(body);
|
||||
|
||||
let options_: RequestInit = {
|
||||
body: content_,
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processBriefPUT(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processBriefPUT(response: Response): Promise<BriefDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
briefSubmit(): Promise<BriefDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/submit";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processBriefSubmit(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processBriefSubmit(response: Response): Promise<BriefDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
approve(): Promise<BriefDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/approve";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processApprove(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processApprove(response: Response): Promise<BriefDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
reject(body: RejectBriefRequest): Promise<BriefDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/reject";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
const content_ = JSON.stringify(body);
|
||||
|
||||
let options_: RequestInit = {
|
||||
body: content_,
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processReject(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processReject(response: Response): Promise<BriefDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 403) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result403: any = null;
|
||||
result403 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Forbidden", status, _responseText, _headers, result403);
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return OK
|
||||
*/
|
||||
send(): Promise<BriefDto> {
|
||||
let url_ = this.baseUrl + "/api/v1/brief/send";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.http.fetch(url_, options_).then((_response: Response) => {
|
||||
return this.processSend(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processSend(response: Response): Promise<BriefDto> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as BriefDto;
|
||||
return result200;
|
||||
});
|
||||
} else if (status === 409) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result409: any = null;
|
||||
result409 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as ProblemDetails;
|
||||
return throwException("Conflict", status, _responseText, _headers, result409);
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<BriefDto>(null as any);
|
||||
}
|
||||
}
|
||||
|
||||
export interface AantekeningDto {
|
||||
@@ -973,6 +1251,33 @@ export interface ApplicationSummaryDto {
|
||||
submittedAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface BriefDto {
|
||||
briefId?: string | undefined;
|
||||
beroep?: string | undefined;
|
||||
templateId?: string | undefined;
|
||||
placeholders?: PlaceholderDefDto[] | undefined;
|
||||
sections?: LetterSectionDto[] | undefined;
|
||||
status?: BriefStatusDto;
|
||||
drafterId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface BriefStatusDto {
|
||||
tag?: string | undefined;
|
||||
submittedBy?: string | undefined;
|
||||
submittedAt?: string | undefined;
|
||||
approvedBy?: string | undefined;
|
||||
approvedAt?: string | undefined;
|
||||
rejectedBy?: string | undefined;
|
||||
rejectedAt?: string | undefined;
|
||||
comments?: string | undefined;
|
||||
sentAt?: string | undefined;
|
||||
}
|
||||
|
||||
export interface BriefViewDto {
|
||||
brief?: BriefDto;
|
||||
availablePassages?: LibraryPassageDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface BrpAddressDto {
|
||||
gevonden?: boolean;
|
||||
adres?: AdresDto;
|
||||
@@ -1050,17 +1355,55 @@ export interface IntakeRequest {
|
||||
uren?: number;
|
||||
}
|
||||
|
||||
export interface LetterBlockDto {
|
||||
type?: string | undefined;
|
||||
blockId?: string | undefined;
|
||||
content?: RichTextBlockDto;
|
||||
sourcePassageId?: string | undefined;
|
||||
sourceVersion?: number | undefined;
|
||||
edited?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface LetterSectionDto {
|
||||
sectionKey?: string | undefined;
|
||||
title?: string | undefined;
|
||||
required?: boolean;
|
||||
blocks?: LetterBlockDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface LibraryPassageDto {
|
||||
passageId?: string | undefined;
|
||||
scope?: string | undefined;
|
||||
sectionKey?: string | undefined;
|
||||
label?: string | undefined;
|
||||
content?: RichTextBlockDto;
|
||||
version?: number;
|
||||
beroep?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ManualDiplomaPolicyDto {
|
||||
beroepen?: string[] | undefined;
|
||||
policyQuestions?: PolicyQuestionDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface ParagraphDto {
|
||||
nodes?: RichTextNodeDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface PersonDto {
|
||||
naam?: string | undefined;
|
||||
geboortedatum?: string | undefined;
|
||||
adres?: AdresDto;
|
||||
}
|
||||
|
||||
export interface PlaceholderDefDto {
|
||||
key?: string | undefined;
|
||||
label?: string | undefined;
|
||||
autoResolvable?: boolean;
|
||||
fillable?: boolean | undefined;
|
||||
deprecated?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface PolicyQuestionDto {
|
||||
id?: string | undefined;
|
||||
vraag?: string | undefined;
|
||||
@@ -1103,6 +1446,25 @@ export interface RegistrationStatusDto {
|
||||
doorgehaaldOp?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RejectBriefRequest {
|
||||
comments?: string | undefined;
|
||||
}
|
||||
|
||||
export interface RichTextBlockDto {
|
||||
paragraphs?: ParagraphDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface RichTextNodeDto {
|
||||
type?: string | undefined;
|
||||
text?: string | undefined;
|
||||
marks?: string[] | undefined;
|
||||
key?: string | undefined;
|
||||
}
|
||||
|
||||
export interface SaveBriefRequest {
|
||||
sections?: LetterSectionDto[] | undefined;
|
||||
}
|
||||
|
||||
export interface SubmitApplicationRequest {
|
||||
diplomaHerkomst?: string | undefined;
|
||||
uren?: number | undefined;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { HttpInterceptorFn } from '@angular/common/http';
|
||||
import { currentRole } from './role';
|
||||
|
||||
/**
|
||||
* Dev-only: stamps brief requests with the current `?role=` as an `X-Role` header so
|
||||
* the backend can enforce the drafter/approver rules. Only brief endpoints carry it;
|
||||
* everything else is untouched.
|
||||
*/
|
||||
export const roleInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
if (!req.url.includes('/api/v1/brief')) return next(req);
|
||||
return next(req.clone({ setHeaders: { 'X-Role': currentRole() } }));
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Dev-only role stand-in. This POC has one faked self-service user and no real
|
||||
* identities, so the two-person letter workflow (drafter vs approver) is driven by
|
||||
* a `?role=` query param — exactly the pattern of the `?scenario=` toggle. The
|
||||
* backend receives it as an `X-Role` header (see role.interceptor) and enforces the
|
||||
* approver≠drafter rule; the FE derives `editable` from it.
|
||||
*/
|
||||
export type Role = 'drafter' | 'approver';
|
||||
|
||||
export function currentRole(): Role {
|
||||
return new URLSearchParams(window.location.search).get('role') === 'approver' ? 'approver' : 'drafter';
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock, deepCopyBlock, emptyBlock, isBlockEmpty, placeholderKeysIn } from './rich-text';
|
||||
|
||||
const block = (): RichTextBlock => ({
|
||||
paragraphs: [
|
||||
{ nodes: [{ type: 'text', text: 'Beste ' }, { type: 'placeholder', key: 'naam' }] },
|
||||
{ nodes: [{ type: 'placeholder', key: 'datum' }, { type: 'placeholder', key: 'naam' }] },
|
||||
],
|
||||
});
|
||||
|
||||
describe('rich-text', () => {
|
||||
it('emptyBlock is one empty paragraph and reads as empty', () => {
|
||||
expect(emptyBlock()).toEqual({ paragraphs: [{ nodes: [] }] });
|
||||
expect(isBlockEmpty(emptyBlock())).toBe(true);
|
||||
});
|
||||
|
||||
it('isBlockEmpty is false when any placeholder or non-blank text exists', () => {
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: ' ' }] }] })).toBe(true);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'placeholder', key: 'x' }] }] })).toBe(false);
|
||||
expect(isBlockEmpty({ paragraphs: [{ nodes: [{ type: 'text', text: 'hoi' }] }] })).toBe(false);
|
||||
});
|
||||
|
||||
it('placeholderKeysIn walks in document order, keeping duplicates', () => {
|
||||
expect(placeholderKeysIn(block())).toEqual(['naam', 'datum', 'naam']);
|
||||
});
|
||||
|
||||
it('deepCopyBlock is an independent value copy (frozen snapshot)', () => {
|
||||
const original = block();
|
||||
const copy = deepCopyBlock(original);
|
||||
expect(copy).toEqual(original);
|
||||
expect(copy).not.toBe(original);
|
||||
expect(copy.paragraphs[0]).not.toBe(original.paragraphs[0]);
|
||||
// Mutating the copy must not touch the original — proves no shared reference.
|
||||
(copy.paragraphs[0].nodes as { type: 'text'; text: string }[])[0] = { type: 'text', text: 'CHANGED' };
|
||||
expect(placeholderKeysIn(original)).toEqual(['naam', 'datum', 'naam']);
|
||||
expect((original.paragraphs[0].nodes[0] as { text: string }).text).toBe('Beste ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Rich text as a *serialisable value*, not opaque HTML.
|
||||
*
|
||||
* A block is a node tree. Because a placeholder is a first-class NODE (not a
|
||||
* `{{token}}` substring hidden inside a string), highlighting it, inserting it,
|
||||
* and linting it are all pure functions over data — no regex over markup. This
|
||||
* is the whole reason the letter feature stays in the "impossible states" style:
|
||||
* the value the app holds is always well-shaped, and the imperative editor is
|
||||
* quarantined behind one component that converts to/from this tree.
|
||||
*/
|
||||
|
||||
export type Mark = 'bold' | 'italic' | 'underline';
|
||||
|
||||
export type RichTextNode =
|
||||
| { readonly type: 'text'; readonly text: string; readonly marks?: readonly Mark[] }
|
||||
| { readonly type: 'placeholder'; readonly key: string } // resolved to a value at send
|
||||
| { readonly type: 'lineBreak' };
|
||||
|
||||
export interface Paragraph {
|
||||
readonly nodes: readonly RichTextNode[];
|
||||
}
|
||||
|
||||
export interface RichTextBlock {
|
||||
readonly paragraphs: readonly Paragraph[];
|
||||
}
|
||||
|
||||
/** An empty editable block is one empty paragraph — never zero paragraphs, so the
|
||||
editor always has a caret line. */
|
||||
export function emptyBlock(): RichTextBlock {
|
||||
return { paragraphs: [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
/** True when the block carries no visible content (used for "required section empty"). */
|
||||
export function isBlockEmpty(block: RichTextBlock): boolean {
|
||||
return block.paragraphs.every((p) =>
|
||||
p.nodes.every((n) => (n.type === 'text' ? n.text.trim() === '' : false)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The frozen-snapshot primitive: a deep VALUE copy of a block. Inserting a library
|
||||
passage into a letter copies its tree through here, so the letter never shares a
|
||||
reference with the library — later library edits can't mutate an existing letter. */
|
||||
export function deepCopyBlock(block: RichTextBlock): RichTextBlock {
|
||||
// ponytail: structuredClone is exactly a deep value copy of a JSON-shaped tree;
|
||||
// a hand-rolled walk would be more code for the same result.
|
||||
return structuredClone(block) as RichTextBlock;
|
||||
}
|
||||
|
||||
/** Every placeholder key used in a block, in document order (duplicates kept — the
|
||||
caller dedupes when it wants a set). */
|
||||
export function placeholderKeysIn(block: RichTextBlock): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const p of block.paragraphs) {
|
||||
for (const n of p.nodes) {
|
||||
if (n.type === 'placeholder') keys.push(n.key);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component, forwardRef, input } from '@angular/core';
|
||||
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
|
||||
|
||||
/** Atom: a labelled checkbox wired as a form control (ngModel/reactive). Native
|
||||
input for full keyboard + screen-reader support; the label is the click target. */
|
||||
@Component({
|
||||
selector: 'app-checkbox',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
label{display:inline-flex;align-items:center;gap:var(--rhc-space-max-md);cursor:pointer}
|
||||
input{inline-size:1.1rem;block-size:1.1rem}
|
||||
`],
|
||||
template: `
|
||||
<label>
|
||||
<input type="checkbox" [id]="checkboxId()" [checked]="value" [disabled]="disabled"
|
||||
(change)="onToggle($event)" (blur)="onTouched()" />
|
||||
<span>{{ label() }}</span>
|
||||
</label>
|
||||
`,
|
||||
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => CheckboxComponent), multi: true }],
|
||||
})
|
||||
export class CheckboxComponent implements ControlValueAccessor {
|
||||
checkboxId = input<string>();
|
||||
label = input('');
|
||||
|
||||
value = false;
|
||||
disabled = false;
|
||||
onChange: (v: boolean) => void = () => {};
|
||||
onTouched: () => void = () => {};
|
||||
|
||||
onToggle(e: Event) {
|
||||
this.value = (e.target as HTMLInputElement).checked;
|
||||
this.onChange(this.value);
|
||||
}
|
||||
writeValue(v: boolean) { this.value = !!v; }
|
||||
registerOnChange(fn: (v: boolean) => void) { this.onChange = fn; }
|
||||
registerOnTouched(fn: () => void) { this.onTouched = fn; }
|
||||
setDisabledState(d: boolean) { this.disabled = d; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { CheckboxComponent } from './checkbox.component';
|
||||
|
||||
const meta: Meta<CheckboxComponent> = {
|
||||
title: 'Atoms/Checkbox',
|
||||
component: CheckboxComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-checkbox [label]="label" [checkboxId]="checkboxId"></app-checkbox>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<CheckboxComponent>;
|
||||
|
||||
export const Default: Story = { args: { label: 'Standaard aanhef', checkboxId: 'cb-1' } };
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, computed, input } from '@angular/core';
|
||||
|
||||
/** Atom: a highlighted, non-editable placeholder chip for READ-ONLY rendering
|
||||
(preview, diagnostics). Distinct styling for auto-resolvable vs manual fields and
|
||||
for linter error/warning states. Domain-free and presentational — the caller
|
||||
passes label/state; a11y label announces the field name + its resolution status.
|
||||
(The editor renders its own inline chips inside contenteditable; this atom is for
|
||||
everywhere the letter is shown, not edited.) */
|
||||
@Component({
|
||||
selector: 'app-placeholder-chip',
|
||||
styles: [`
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;gap:0.2em;
|
||||
border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;line-height:1.6;border:1px solid transparent;white-space:nowrap;
|
||||
}
|
||||
.chip::before{content:'⌗';opacity:0.6;font-weight:700}
|
||||
.chip--auto{background:var(--rhc-color-cool-grey-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--manual{background:var(--rhc-color-geel-100);color:var(--rhc-color-foreground-default)}
|
||||
.chip--warning{background:var(--rhc-color-geel-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
.chip--error{background:var(--rhc-color-rood-100);border-color:var(--rhc-color-border-default);color:var(--rhc-color-foreground-default)}
|
||||
`],
|
||||
template: `<span class="chip" [class]="'chip--' + variant()" [attr.aria-label]="ariaLabel()">{{ label() }}</span>`,
|
||||
})
|
||||
export class PlaceholderChipComponent {
|
||||
label = input.required<string>();
|
||||
autoResolvable = input(false);
|
||||
state = input<'ok' | 'warning' | 'error'>('ok');
|
||||
|
||||
// Copy is localizable-by-default per the shared-UI convention (like <app-async>).
|
||||
autoText = input($localize`:@@placeholderChip.auto:wordt automatisch ingevuld`);
|
||||
manualText = input($localize`:@@placeholderChip.manual:handmatig in te vullen`);
|
||||
warningText = input($localize`:@@placeholderChip.warning:let op`);
|
||||
errorText = input($localize`:@@placeholderChip.error:fout`);
|
||||
|
||||
protected variant = computed(() => {
|
||||
const s = this.state();
|
||||
return s !== 'ok' ? s : this.autoResolvable() ? 'auto' : 'manual';
|
||||
});
|
||||
|
||||
protected ariaLabel = computed(() => {
|
||||
const status = { auto: this.autoText(), manual: this.manualText(), warning: this.warningText(), error: this.errorText() }[this.variant()];
|
||||
return $localize`:@@placeholderChip.aria:Veld ${this.label()}:label:, ${status}:status:`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { PlaceholderChipComponent } from './placeholder-chip.component';
|
||||
|
||||
const meta: Meta<PlaceholderChipComponent> = {
|
||||
title: 'Atoms/Placeholder Chip',
|
||||
component: PlaceholderChipComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-placeholder-chip [label]="label" [autoResolvable]="autoResolvable" [state]="state"></app-placeholder-chip>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<PlaceholderChipComponent>;
|
||||
|
||||
export const AutoResolvable: Story = { args: { label: 'Naam zorgverlener', autoResolvable: true, state: 'ok' } };
|
||||
export const Manual: Story = { args: { label: 'Reden besluit', autoResolvable: false, state: 'ok' } };
|
||||
export const Warning: Story = { args: { label: 'Oud kenmerk', autoResolvable: true, state: 'warning' } };
|
||||
export const Error: Story = { args: { label: 'Onbekend veld', autoResolvable: false, state: 'error' } };
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
import { readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
const labelFor = (key: string) => ({ naam: 'Naam', datum: 'Datum' })[key] ?? key;
|
||||
|
||||
function roundTrip(block: RichTextBlock): RichTextBlock {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, block, labelFor);
|
||||
return readBlock(root);
|
||||
}
|
||||
|
||||
describe('rich-text DOM boundary', () => {
|
||||
it('round-trips text, marks, placeholders, line breaks and multiple paragraphs', () => {
|
||||
const block: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Beste ' },
|
||||
{ type: 'placeholder', key: 'naam' },
|
||||
{ type: 'text', text: ' vet', marks: ['bold'] },
|
||||
{ type: 'lineBreak' },
|
||||
{ type: 'text', text: 'nieuwe regel' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }] },
|
||||
],
|
||||
};
|
||||
expect(roundTrip(block)).toEqual(block);
|
||||
});
|
||||
|
||||
it('round-trips an empty paragraph (filler <br> is not a line break)', () => {
|
||||
const empty: RichTextBlock = { paragraphs: [{ nodes: [] }] };
|
||||
expect(roundTrip(empty)).toEqual(empty);
|
||||
});
|
||||
|
||||
it('renders a placeholder as a non-editable chip carrying its key and label', () => {
|
||||
const root = document.createElement('div');
|
||||
renderInto(root, { paragraphs: [{ nodes: [{ type: 'placeholder', key: 'naam' }] }] }, labelFor);
|
||||
const chip = root.querySelector('.rte-chip') as HTMLElement;
|
||||
expect(chip.getAttribute('contenteditable')).toBe('false');
|
||||
expect(chip.dataset['phKey']).toBe('naam');
|
||||
expect(chip.textContent).toBe('Naam');
|
||||
});
|
||||
|
||||
it('reads combined marks in canonical order regardless of nesting', () => {
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = '<p><em><strong>x</strong></em></p>'; // italic wrapping bold
|
||||
expect(readBlock(root)).toEqual({ paragraphs: [{ nodes: [{ type: 'text', text: 'x', marks: ['bold', 'italic'] }] }] });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Mark, RichTextBlock, RichTextNode } from '@shared/kernel/rich-text';
|
||||
|
||||
/**
|
||||
* The quarantined boundary between the imperative `contenteditable` DOM and the
|
||||
* serialisable `RichTextBlock` value. Pure functions (given a DOM they render /
|
||||
* read) so they round-trip losslessly and can be unit-tested without Angular. The
|
||||
* rest of the app only ever sees `RichTextBlock` — this is the one place DOM leaks.
|
||||
*
|
||||
* ponytail: mark detection covers the tags/styles a browser's execCommand emits
|
||||
* (strong/b, em/i, u, and inline font-weight/style/decoration); exotic pasted markup
|
||||
* degrades to plain text rather than crashing.
|
||||
*/
|
||||
|
||||
const ORDER: readonly Mark[] = ['bold', 'italic', 'underline'];
|
||||
const MARK_TAG: Record<Mark, string> = { bold: 'strong', italic: 'em', underline: 'u' };
|
||||
|
||||
export function renderInto(root: HTMLElement, block: RichTextBlock, labelFor: (key: string) => string): void {
|
||||
const doc = root.ownerDocument;
|
||||
root.replaceChildren();
|
||||
for (const para of block.paragraphs) {
|
||||
const p = doc.createElement('p');
|
||||
p.className = 'rte-para';
|
||||
if (para.nodes.length === 0) {
|
||||
p.appendChild(doc.createElement('br')); // keep the empty line focusable
|
||||
} else {
|
||||
for (const node of para.nodes) p.appendChild(renderNode(node, labelFor, doc));
|
||||
}
|
||||
root.appendChild(p);
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one non-editable placeholder chip element (shared by initial render and
|
||||
live caret insertion). setAttribute reflects reliably in jsdom + browsers. */
|
||||
export function createChip(doc: Document, key: string, label: string): HTMLElement {
|
||||
const span = doc.createElement('span');
|
||||
span.dataset['phKey'] = key;
|
||||
span.setAttribute('contenteditable', 'false');
|
||||
span.className = 'rte-chip';
|
||||
span.textContent = label;
|
||||
return span;
|
||||
}
|
||||
|
||||
function renderNode(node: RichTextNode, labelFor: (key: string) => string, doc: Document): Node {
|
||||
if (node.type === 'lineBreak') return doc.createElement('br');
|
||||
if (node.type === 'placeholder') return createChip(doc, node.key, labelFor(node.key));
|
||||
let el: Node = doc.createTextNode(node.text);
|
||||
// Nest marks in a canonical order so read-back is deterministic.
|
||||
for (const m of ORDER.filter((x) => node.marks?.includes(x))) {
|
||||
const wrap = doc.createElement(MARK_TAG[m]);
|
||||
wrap.appendChild(el);
|
||||
el = wrap;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
export function readBlock(root: HTMLElement): RichTextBlock {
|
||||
const paragraphs: { nodes: RichTextNode[] }[] = [];
|
||||
const blockEls = Array.from(root.children).filter((c) => c.tagName === 'P' || c.tagName === 'DIV');
|
||||
const containers = blockEls.length ? blockEls : [root];
|
||||
for (const el of containers) {
|
||||
const kids = Array.from(el.childNodes);
|
||||
const nodes: RichTextNode[] = [];
|
||||
// A lone <br> is the empty-line filler, not a content line break.
|
||||
if (!(kids.length === 1 && kids[0].nodeName === 'BR')) {
|
||||
for (const child of kids) collect(child, [], nodes);
|
||||
}
|
||||
paragraphs.push({ nodes });
|
||||
}
|
||||
return { paragraphs: paragraphs.length ? paragraphs : [{ nodes: [] }] };
|
||||
}
|
||||
|
||||
function collect(node: Node, marks: Mark[], out: RichTextNode[]): void {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.textContent ?? '';
|
||||
if (text !== '') out.push(marks.length ? { type: 'text', text, marks: canonical(marks) } : { type: 'text', text });
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = node as HTMLElement;
|
||||
if (el.tagName === 'BR') {
|
||||
out.push({ type: 'lineBreak' });
|
||||
return;
|
||||
}
|
||||
const key = el.dataset?.['phKey'];
|
||||
if (key != null) {
|
||||
out.push({ type: 'placeholder', key });
|
||||
return;
|
||||
}
|
||||
const m = markOf(el);
|
||||
const next = m ? [...marks, m] : marks;
|
||||
for (const child of Array.from(el.childNodes)) collect(child, next, out);
|
||||
}
|
||||
|
||||
function markOf(el: HTMLElement): Mark | null {
|
||||
switch (el.tagName) {
|
||||
case 'STRONG':
|
||||
case 'B':
|
||||
return 'bold';
|
||||
case 'EM':
|
||||
case 'I':
|
||||
return 'italic';
|
||||
case 'U':
|
||||
return 'underline';
|
||||
}
|
||||
const s = el.style;
|
||||
if (s.fontWeight === 'bold' || Number(s.fontWeight) >= 600) return 'bold';
|
||||
if (s.fontStyle === 'italic') return 'italic';
|
||||
if (s.textDecoration.includes('underline')) return 'underline';
|
||||
return null;
|
||||
}
|
||||
|
||||
function canonical(marks: Mark[]): Mark[] {
|
||||
return ORDER.filter((m) => marks.includes(m));
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Component, ElementRef, computed, effect, input, output, viewChild } from '@angular/core';
|
||||
import { RichTextBlock, emptyBlock } from '@shared/kernel/rich-text';
|
||||
import { createChip, readBlock, renderInto } from './rich-text-dom';
|
||||
|
||||
/** A menu entry for the insert-placeholder control — a plain {key,label}, so the
|
||||
editor stays domain-free (it never sees the brief's PlaceholderDef). */
|
||||
export interface PlaceholderOption {
|
||||
readonly key: string;
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Molecule: a minimal no-dependency WYSIWYG editor over a `RichTextBlock`.
|
||||
*
|
||||
* It is the single quarantined boundary to the imperative `contenteditable` DOM:
|
||||
* `content` in, `contentChanged` (a `RichTextBlock`) out, holding NO letter state.
|
||||
* Placeholders render as non-editable chips and can only be inserted from the menu
|
||||
* (valid keys only) — never typed as raw braces. Swapping in a real editor library
|
||||
* later (TipTap) means replacing only this component; nothing else sees the DOM.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-rich-text-editor',
|
||||
styles: [`
|
||||
:host{display:block}
|
||||
.rte-toolbar{display:flex;flex-wrap:wrap;gap:var(--rhc-space-max-sm);align-items:center;margin-block-end:var(--rhc-space-max-sm)}
|
||||
.rte-toolbar button{min-inline-size:2.2rem}
|
||||
.rte-sep{inline-size:1px;align-self:stretch;background:var(--rhc-color-border-default)}
|
||||
.rte-editable{
|
||||
border:1px solid var(--rhc-color-border-default);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:var(--rhc-space-max-md);min-block-size:4rem;
|
||||
}
|
||||
.rte-editable[contenteditable='false']{background:var(--rhc-color-cool-grey-100)}
|
||||
.rte-editable :is(p){margin:0 0 var(--rhc-space-max-sm)}
|
||||
/* Placeholder chips inside the editor: one neutral highlight (the read-only
|
||||
preview distinguishes auto/manual/error via app-placeholder-chip). */
|
||||
.rte-editable .rte-chip{
|
||||
background:var(--rhc-color-cool-grey-100);border-radius:var(--rhc-border-radius-sm);
|
||||
padding:0 0.35em;white-space:nowrap;
|
||||
}
|
||||
.rte-editable .rte-chip::before{content:'⌗';opacity:0.6;font-weight:700;margin-inline-end:0.15em}
|
||||
`],
|
||||
template: `
|
||||
@if (editable()) {
|
||||
<div class="rte-toolbar" role="toolbar" [attr.aria-label]="toolbarLabel()">
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('bold')" [attr.aria-label]="boldLabel()"><b>B</b></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('italic')" [attr.aria-label]="italicLabel()"><i>I</i></button>
|
||||
<button type="button" class="utrecht-button utrecht-button--subtle" (mousedown)="$event.preventDefault()" (click)="format('underline')" [attr.aria-label]="underlineLabel()"><u>U</u></button>
|
||||
@if (placeholders().length) {
|
||||
<span class="rte-sep" aria-hidden="true"></span>
|
||||
<label>
|
||||
<span class="app-text-subtle">{{ insertLabel() }}</span>
|
||||
<select #ins (change)="insert(ins.value); ins.value = ''">
|
||||
<option value="" selected>{{ insertPrompt() }}</option>
|
||||
@for (p of placeholders(); track p.key) {
|
||||
<option [value]="p.key">{{ p.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div #editor class="rte-editable" [attr.contenteditable]="editable()" (input)="emit()"
|
||||
role="textbox" aria-multiline="true" [attr.aria-label]="fieldLabel()"></div>
|
||||
`,
|
||||
})
|
||||
export class RichTextEditorComponent {
|
||||
content = input<RichTextBlock>(emptyBlock());
|
||||
placeholders = input<readonly PlaceholderOption[]>([]);
|
||||
editable = input(true);
|
||||
contentChanged = output<RichTextBlock>();
|
||||
|
||||
// Localizable-by-default copy (shared-UI convention).
|
||||
fieldLabel = input($localize`:@@richTextEditor.field:Tekst`);
|
||||
toolbarLabel = input($localize`:@@richTextEditor.toolbar:Opmaak`);
|
||||
boldLabel = input($localize`:@@richTextEditor.bold:Vet`);
|
||||
italicLabel = input($localize`:@@richTextEditor.italic:Cursief`);
|
||||
underlineLabel = input($localize`:@@richTextEditor.underline:Onderstreept`);
|
||||
insertLabel = input($localize`:@@richTextEditor.insert:Veld invoegen:`);
|
||||
insertPrompt = input($localize`:@@richTextEditor.insertPrompt:Kies…`);
|
||||
|
||||
private editorEl = viewChild<ElementRef<HTMLElement>>('editor');
|
||||
private lastEmitted = '';
|
||||
|
||||
private labelFor = (key: string) => this.placeholders().find((p) => p.key === key)?.label ?? key;
|
||||
|
||||
constructor() {
|
||||
// Render when content arrives/changes from OUTSIDE. Skip our own emitted value
|
||||
// flowing back (structural compare) so the caret isn't reset while typing.
|
||||
effect(() => {
|
||||
const content = this.content();
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const serialized = JSON.stringify(content);
|
||||
if (serialized === this.lastEmitted) return;
|
||||
renderInto(el, content, this.labelFor);
|
||||
this.lastEmitted = serialized;
|
||||
});
|
||||
}
|
||||
|
||||
protected emit() {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
const block = readBlock(el);
|
||||
this.lastEmitted = JSON.stringify(block);
|
||||
this.contentChanged.emit(block);
|
||||
}
|
||||
|
||||
protected format(cmd: 'bold' | 'italic' | 'underline') {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
// ponytail: execCommand is deprecated but universally supported and zero-dependency;
|
||||
// if a browser drops it, this component is the one place to swap in a range-based impl.
|
||||
el.ownerDocument.execCommand(cmd);
|
||||
this.emit();
|
||||
}
|
||||
|
||||
protected insert(key: string) {
|
||||
const el = this.editorEl()?.nativeElement;
|
||||
if (!key || !el) return;
|
||||
el.focus();
|
||||
const chip = createChip(el.ownerDocument, key, this.labelFor(key));
|
||||
const sel = el.ownerDocument.getSelection();
|
||||
if (sel && sel.rangeCount && el.contains(sel.anchorNode)) {
|
||||
const range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(chip);
|
||||
range.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
} else {
|
||||
(el.lastElementChild ?? el).appendChild(chip);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Meta, StoryObj } from '@storybook/angular';
|
||||
import { RichTextEditorComponent } from './rich-text-editor.component';
|
||||
import { RichTextBlock } from '@shared/kernel/rich-text';
|
||||
|
||||
const sample: RichTextBlock = {
|
||||
paragraphs: [
|
||||
{
|
||||
nodes: [
|
||||
{ type: 'text', text: 'Geachte heer/mevrouw ' },
|
||||
{ type: 'placeholder', key: 'naam_zorgverlener' },
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{ nodes: [{ type: 'text', text: 'Op ' }, { type: 'placeholder', key: 'datum' }, { type: 'text', text: ' hebben wij besloten.' }] },
|
||||
],
|
||||
};
|
||||
|
||||
const placeholders = [
|
||||
{ key: 'naam_zorgverlener', label: 'Naam zorgverlener' },
|
||||
{ key: 'datum', label: 'Datum' },
|
||||
{ key: 'big_nummer', label: 'BIG-nummer' },
|
||||
];
|
||||
|
||||
const meta: Meta<RichTextEditorComponent> = {
|
||||
title: 'Molecules/Rich Text Editor',
|
||||
component: RichTextEditorComponent,
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `<app-rich-text-editor [content]="content" [placeholders]="placeholders" [editable]="editable"></app-rich-text-editor>`,
|
||||
}),
|
||||
};
|
||||
export default meta;
|
||||
type Story = StoryObj<RichTextEditorComponent>;
|
||||
|
||||
export const Editing: Story = { args: { content: sample, placeholders, editable: true } };
|
||||
export const Empty: Story = { args: { content: { paragraphs: [{ nodes: [] }] }, placeholders, editable: true } };
|
||||
export const ReadOnly: Story = { args: { content: sample, placeholders, editable: false } };
|
||||
Reference in New Issue
Block a user