refactor: split org-template-editor by output cluster (RD-25)

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
  <app-org-template-editor> 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 <noreply@anthropic.com>
This commit is contained in:
eho
2026-09-05 00:02:30 +02:00
co-authored by Claude Sonnet 5
parent 5aed15bb98
commit cf1f641534
6 changed files with 435 additions and 155 deletions
@@ -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.',
},
],
},
],
},
},
],
},
],
};
@@ -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: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.logo">Logo</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
i18n-label="@@orgTemplate.logo"
label="Logo"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
`,
})
export class LogoUploadComponent {
logoUrl = input<string | null>(null);
uploadState = input.required<UploadState>();
previewUrlFor = input<(documentId: string) => string | undefined>();
logoSelected = output<File[]>();
logoRemoved = output<string>();
logoRetry = output<string>();
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]);
}
@@ -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: `
<div class="toolbar">
<label class="field">
<span>{{ subOrgLabel() }}</span>
<span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -191,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
}
</fieldset>
<section class="section">
<app-heading [level]="3">{{ logoHeading() }}</app-heading>
@if (logoCategory()) {
<app-file-input
inputId="org-logo-input"
[accept]="logoCategory()!.acceptedTypes"
[maxSizeMb]="logoCategory()!.maxSizeMb"
[label]="logoHeading()"
(filesSelected)="logoSelected.emit($event)"
/>
}
@if (logoRejection()) {
<app-alert type="error">{{ logoRejection() }}</app-alert>
}
@if (logoUploads().length) {
<ul class="file-list">
@for (u of logoUploads(); track u.localId) {
<li
app-single-upload
[upload]="u"
[previewUrlFor]="previewUrlFor()"
(remove)="logoRemoved.emit(u.localId)"
(retry)="logoRetry.emit(u.localId)"
></li>
}
</ul>
}
</section>
<app-logo-upload
[logoUrl]="logoUrl()"
[uploadState]="uploadState()"
[previewUrlFor]="previewUrlFor()"
(logoSelected)="logoSelected.emit($event)"
(logoRemoved)="logoRemoved.emit($event)"
(logoRetry)="logoRetry.emit($event)"
/>
<section class="section">
<app-heading [level]="3">{{ historyHeading() }}</app-heading>
@if (history().length === 0) {
<p class="published">{{ noHistory() }}</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
>{{ versionLabel() }} {{ v.version }} · {{ v.publishedAt | date: 'longDate' }}</span
>
<app-button variant="subtle" [disabled]="busy()" (click)="rollback.emit(v.version)">
{{ rollbackLabel() }}
</app-button>
</li>
}
</ul>
}
</section>
<app-version-history
[history]="history()"
[publishedVersion]="publishedVersion()"
[busy]="busy()"
(rollback)="rollback.emit($event)"
/>
<div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span>
<span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()">
{{ confirmLabel() }}
</app-button>
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()">
{{ cancelLabel() }}
</app-button>
<app-button
variant="primary"
[disabled]="busy()"
(click)="confirmPublish.emit()"
i18n="@@orgTemplate.publish.confirm"
>Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else {
<app-button
variant="primary"
[disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
>
{{ publishLabel() }}
</app-button>
@if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span>
}
}
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()">
{{ proefbriefLabel() }}
</app-button>
<app-button
variant="secondary"
[disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div>
`,
})
@@ -300,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_MM;
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]);
protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value);
}
@@ -334,20 +243,9 @@ export class OrgTemplateEditorComponent {
$localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
);
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`,
);
protected logoHeading = input($localize`:@@orgTemplate.logo:Logo`);
protected historyHeading = input($localize`:@@orgTemplate.history:Versiegeschiedenis`);
protected noHistory = input($localize`:@@orgTemplate.history.none:Nog niets gepubliceerd.`);
protected versionLabel = input($localize`:@@orgTemplate.version:Versie`);
protected rollbackLabel = input($localize`:@@orgTemplate.rollback:Terugzetten in concept`);
protected publishedLabel = input($localize`:@@orgTemplate.published:Gepubliceerde versie:`);
protected publishLabel = input($localize`:@@orgTemplate.publish:Publiceren`);
protected confirmLabel = input($localize`:@@orgTemplate.publish.confirm:Bevestigen`);
protected cancelLabel = input($localize`:@@orgTemplate.publish.cancel:Annuleren`);
protected proefbriefLabel = input($localize`:@@orgTemplate.proefbrief:Proefbrief`);
protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`,
);
@@ -0,0 +1,78 @@
import { Component, 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 { OrgTemplateVersion } from '@brief/domain/org-template';
/**
* Organism: the org-template editor's version-history block, split out of
* `org-template-editor.component.ts` (RD-25) — one of its two self-contained
* mutation clusters. Presentational: `rollback` is the parent's own output,
* re-emitted unchanged.
*/
@Component({
selector: 'app-version-history',
imports: [DatePipe, HeadingComponent, ButtonComponent],
styles: [
`
:host {
display: block;
}
.section {
margin-block-start: var(--rhc-space-max-xl);
}
.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);
}
.published {
color: var(--rhc-color-foreground-subtle);
}
`,
],
template: `
<section class="section">
<app-heading [level]="3" i18n="@@orgTemplate.history">Versiegeschiedenis</app-heading>
@if (history().length === 0) {
<p class="published" i18n="@@orgTemplate.history.none">Nog niets gepubliceerd.</p>
} @else {
<ul class="history-list">
@for (v of history(); track v.version) {
<li class="history-row">
<span
><span i18n="@@orgTemplate.version">Versie</span> {{ v.version }} ·
{{ v.publishedAt | date: 'longDate' }}</span
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="rollback.emit(v.version)"
i18n="@@orgTemplate.rollback"
>Terugzetten in concept</app-button
>
</li>
}
</ul>
}
</section>
`,
})
export class VersionHistoryComponent {
history = input<readonly OrgTemplateVersion[]>([]);
publishedVersion = input(0);
busy = input(false);
rollback = output<number>();
}