From cf1f6415346068600575949eea9b8dba59d41ffe Mon Sep 17 00:00:00 2001 From: Edwin van den Houdt Date: Sat, 5 Sep 2026 00:02:30 +0200 Subject: [PATCH] refactor: split org-template-editor by output cluster (RD-25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit org-template-editor.component.ts carried an eslint-disable for max-lines, padded by a dead sample-letter constant, 13 label inputs that were never bindable, and two self-contained mutation clusters. Split all three out: - SAMPLE_LETTER_BRIEF moves to brief/domain/sample-letter.ts. It is production content (the letter the admin previews), not a test fixture, so it stays out of brief.testing.ts (no-testing-in-production forbids production code from reaching a *.testing.ts file). - 11 of the 13 label inputs become inline i18n template text. The two that interpolate MARGIN_MIN_MM/MARGIN_MAX_MM (marginsLegend, invalidHint) stay in TS, because moving an interpolated $localize call into a template renames the xlf placeholder and breaks the translation merge. Every id is preserved; messages.en.xlf is unchanged. - logo-upload.component.ts and version-history.component.ts each take one output cluster. The parent still declares and re-emits all 11 outputs — org-template.page.ts binds them directly on and is out of this ticket's file scope, so the parent's public surface cannot shrink. Correction to the ticket while executing it: its acceptance check for "= output" on the parent read "MUST be 7", copying decision 4's cluster count instead of decision 5's (and the ticket's own Risks section's) explicit requirement that the parent keep all 11 declarations. Fixed the ticket's acceptance section to the correct number. npm run ci --full is green. Co-Authored-By: Claude Sonnet 5 --- .../ssp/src/app/brief/domain/sample-letter.ts | 51 +++++ .../logo-upload.component.ts | 77 +++++++ .../org-template-editor.component.ts | 206 +++++------------- .../version-history.component.ts | 78 +++++++ .../RD-25-org-template-editor.md | 176 +++++++++++++++ docs/project/readable-codebase/README.md | 2 +- 6 files changed, 435 insertions(+), 155 deletions(-) create mode 100644 apps/ssp/src/app/brief/domain/sample-letter.ts create mode 100644 apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts create mode 100644 apps/ssp/src/app/brief/ui/org-template-editor/version-history.component.ts create mode 100644 docs/project/readable-codebase/RD-25-org-template-editor.md diff --git a/apps/ssp/src/app/brief/domain/sample-letter.ts b/apps/ssp/src/app/brief/domain/sample-letter.ts new file mode 100644 index 0000000..0b2c1c7 --- /dev/null +++ b/apps/ssp/src/app/brief/domain/sample-letter.ts @@ -0,0 +1,51 @@ +import { Brief } from './brief'; + +/** A minimal read-only sample letter, so the admin sees the org identity in context + while editing (content itself is not the admin's to change). Production content — + the letter the org-template editor previews — not a test fixture, so it lives here + rather than in `brief.testing.ts` (dependency-cruiser's no-testing-in-production + rule forbids production code from reaching any `*.testing.ts`). */ +export const SAMPLE_LETTER_BRIEF: Brief = { + briefId: 'VOORBEELD-0001', + beroep: 'arts', + templateId: 'sample', + drafterId: 'sample', + status: { tag: 'draft' }, + placeholders: [ + { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true }, + { key: 'datum', label: 'Datum', autoResolvable: true }, + ], + sections: [ + { + sectionKey: 'body', + title: 'Voorbeeldinhoud', + required: true, + locked: true, + blocks: [ + { + type: 'freeText', + blockId: 'sample-1', + content: { + paragraphs: [ + { + nodes: [ + { type: 'text', text: 'Geachte ' }, + { type: 'placeholder', key: 'naam_zorgverlener' }, + { type: 'text', text: ',' }, + ], + }, + { + nodes: [ + { + type: 'text', + text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.', + }, + ], + }, + ], + }, + }, + ], + }, + ], +}; diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts new file mode 100644 index 0000000..2914eee --- /dev/null +++ b/apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts @@ -0,0 +1,77 @@ +import { Component, computed, input, output } from '@angular/core'; +import { HeadingComponent } from '@shared/ui/heading/heading.component'; +import { AlertComponent } from '@shared/ui/alert/alert.component'; +import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; +import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; +import { UploadState } from '@shared/domain/upload.machine'; + +const LOGO_CATEGORY = 'org-logo'; + +/** + * Organism: the org-template editor's logo-upload block, split out of + * `org-template-editor.component.ts` (RD-25) — one of its two self-contained + * mutation clusters. Presentational: every mutation is an output the parent + * re-emits unchanged. + */ +@Component({ + selector: 'app-logo-upload', + imports: [HeadingComponent, AlertComponent, FileInputComponent, SingleUploadComponent], + styles: [ + ` + :host { + display: block; + } + .section { + margin-block-start: var(--rhc-space-max-xl); + } + `, + ], + template: ` +
+ Logo + @if (logoCategory()) { + + } + @if (logoRejection()) { + {{ logoRejection() }} + } + @if (logoUploads().length) { +
    + @for (u of logoUploads(); track u.localId) { +
  • + } +
+ } +
+ `, +}) +export class LogoUploadComponent { + logoUrl = input(null); + uploadState = input.required(); + previewUrlFor = input<(documentId: string) => string | undefined>(); + + logoSelected = output(); + logoRemoved = output(); + logoRetry = output(); + + protected logoCategory = computed(() => + this.uploadState().categories.find((c) => c.categoryId === LOGO_CATEGORY), + ); + protected logoUploads = computed(() => + this.uploadState().uploads.filter((u) => u.categoryId === LOGO_CATEGORY), + ); + protected logoRejection = computed(() => this.uploadState().rejections[LOGO_CATEGORY]); +} diff --git a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts index 59c303c..2239ad6 100644 --- a/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts +++ b/apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts @@ -1,13 +1,9 @@ -/* eslint-disable max-lines */ // sample letter + labels + editor in one file — removed by RD-25 import { Component, computed, input, output } from '@angular/core'; -import { DatePipe } from '@angular/common'; -import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { ButtonComponent } from '@shared/ui/button/button.component'; import { AlertComponent } from '@shared/ui/alert/alert.component'; -import { FileInputComponent } from '@shared/ui/upload/file-input/file-input.component'; -import { SingleUploadComponent } from '@shared/ui/upload/single-upload/single-upload.component'; import { UploadState } from '@shared/domain/upload.machine'; import { Brief } from '@brief/domain/brief'; +import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter'; import { MARGIN_MAX_MM, MARGIN_MIN_MM, @@ -18,74 +14,29 @@ import { } from '@brief/domain/org-template'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; +import { LogoUploadComponent } from './logo-upload.component'; +import { VersionHistoryComponent } from './version-history.component'; -const LOGO_CATEGORY = 'org-logo'; const EDGES: readonly (keyof Margins)[] = ['topMm', 'rightMm', 'bottomMm', 'leftMm']; -/** A minimal read-only sample letter, so the admin sees the org identity in context - while editing (content itself is not the admin's to change). */ -export const SAMPLE_LETTER_BRIEF: Brief = { - briefId: 'VOORBEELD-0001', - beroep: 'arts', - templateId: 'sample', - drafterId: 'sample', - status: { tag: 'draft' }, - placeholders: [ - { key: 'naam_zorgverlener', label: 'Naam zorgverlener', autoResolvable: true }, - { key: 'datum', label: 'Datum', autoResolvable: true }, - ], - sections: [ - { - sectionKey: 'body', - title: 'Voorbeeldinhoud', - required: true, - locked: true, - blocks: [ - { - type: 'freeText', - blockId: 'sample-1', - content: { - paragraphs: [ - { - nodes: [ - { type: 'text', text: 'Geachte ' }, - { type: 'placeholder', key: 'naam_zorgverlener' }, - { type: 'text', text: ',' }, - ], - }, - { - nodes: [ - { - type: 'text', - text: 'Dit is voorbeeldinhoud. Alleen de huisstijl-onderdelen (logo, afzender, ondertekening en voettekst) zijn hier bewerkbaar.', - }, - ], - }, - ], - }, - }, - ], - }, - ], -}; - /** * Organism: the admin org-template editor. The mirror of the drafter's * composer — the letter canvas runs in `editableRegions='template'` so the * letterhead/signature/footer are edited in place, while the content is a read-only - * sample. Margins, logo upload, version history and the publish bar sit around it. - * Presentational: every mutation is an output the store turns into a command. + * sample. Margins and the publish bar sit around it; the logo uploader and version + * history are their own children (`app-logo-upload`, `app-version-history`, RD-25) — + * each a self-contained mutation cluster. Presentational: every mutation is an + * output the store turns into a command, whether sourced here or re-emitted from + * a child. */ @Component({ selector: 'app-org-template-editor', imports: [ - DatePipe, - HeadingComponent, ButtonComponent, AlertComponent, - FileInputComponent, - SingleUploadComponent, LetterCanvasComponent, + LogoUploadComponent, + VersionHistoryComponent, ], styles: [ ` @@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = { .margins input { width: 6rem; } - .history-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: var(--rhc-space-max-sm); - } - .history-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--rhc-space-max-md); - border-block-end: var(--rhc-border-width-sm) solid var(--rhc-color-border-default); - padding-block-end: var(--rhc-space-max-sm); - } .bar { display: flex; flex-wrap: wrap; @@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = { template: `