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 { 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 { ButtonComponent } from '@shared/ui/button/button.component';
import { AlertComponent } from '@shared/ui/alert/alert.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 { UploadState } from '@shared/domain/upload.machine';
import { Brief } from '@brief/domain/brief'; import { Brief } from '@brief/domain/brief';
import { SAMPLE_LETTER_BRIEF } from '@brief/domain/sample-letter';
import { import {
MARGIN_MAX_MM, MARGIN_MAX_MM,
MARGIN_MIN_MM, MARGIN_MIN_MM,
@@ -18,74 +14,29 @@ import {
} from '@brief/domain/org-template'; } from '@brief/domain/org-template';
import { OrgTemplateTextField } from '@brief/domain/org-template.machine'; import { OrgTemplateTextField } from '@brief/domain/org-template.machine';
import { LetterCanvasComponent } from '@brief/ui/letter-canvas/letter-canvas.component'; 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']; 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 * Organism: the admin org-template editor. The mirror of the drafter's
* composer — the letter canvas runs in `editableRegions='template'` so the * composer — the letter canvas runs in `editableRegions='template'` so the
* letterhead/signature/footer are edited in place, while the content is a read-only * 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. * sample. Margins and the publish bar sit around it; the logo uploader and version
* Presentational: every mutation is an output the store turns into a command. * 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({ @Component({
selector: 'app-org-template-editor', selector: 'app-org-template-editor',
imports: [ imports: [
DatePipe,
HeadingComponent,
ButtonComponent, ButtonComponent,
AlertComponent, AlertComponent,
FileInputComponent,
SingleUploadComponent,
LetterCanvasComponent, LetterCanvasComponent,
LogoUploadComponent,
VersionHistoryComponent,
], ],
styles: [ styles: [
` `
@@ -123,22 +74,6 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
.margins input { .margins input {
width: 6rem; 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 { .bar {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -154,7 +89,7 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
template: ` template: `
<div class="toolbar"> <div class="toolbar">
<label class="field"> <label class="field">
<span>{{ subOrgLabel() }}</span> <span i18n="@@orgTemplate.subOrg">Organisatieonderdeel</span>
<select class="form-select" (change)="onSelectSubOrg($event)"> <select class="form-select" (change)="onSelectSubOrg($event)">
@for (o of subOrgs(); track o.subOrgId) { @for (o of subOrgs(); track o.subOrgId) {
<option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()"> <option [value]="o.subOrgId" [selected]="o.subOrgId === selectedSubOrgId()">
@@ -191,80 +126,62 @@ export const SAMPLE_LETTER_BRIEF: Brief = {
} }
</fieldset> </fieldset>
<section class="section"> <app-logo-upload
<app-heading [level]="3">{{ logoHeading() }}</app-heading> [logoUrl]="logoUrl()"
@if (logoCategory()) { [uploadState]="uploadState()"
<app-file-input [previewUrlFor]="previewUrlFor()"
inputId="org-logo-input" (logoSelected)="logoSelected.emit($event)"
[accept]="logoCategory()!.acceptedTypes" (logoRemoved)="logoRemoved.emit($event)"
[maxSizeMb]="logoCategory()!.maxSizeMb" (logoRetry)="logoRetry.emit($event)"
[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>
<section class="section"> <app-version-history
<app-heading [level]="3">{{ historyHeading() }}</app-heading> [history]="history()"
@if (history().length === 0) { [publishedVersion]="publishedVersion()"
<p class="published">{{ noHistory() }}</p> [busy]="busy()"
} @else { (rollback)="rollback.emit($event)"
<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>
<div class="bar"> <div class="bar">
<span class="published">{{ publishedLabel() }} {{ publishedVersion() }}</span> <span class="published"
><span i18n="@@orgTemplate.published">Gepubliceerde versie:</span>
{{ publishedVersion() }}</span
>
@if (pendingPublish()) { @if (pendingPublish()) {
<app-alert type="warning">{{ impactText() }}</app-alert> <app-alert type="warning">{{ impactText() }}</app-alert>
<app-button variant="primary" [disabled]="busy()" (click)="confirmPublish.emit()"> <app-button
{{ confirmLabel() }} variant="primary"
</app-button> [disabled]="busy()"
<app-button variant="subtle" [disabled]="busy()" (click)="cancelPublish.emit()"> (click)="confirmPublish.emit()"
{{ cancelLabel() }} i18n="@@orgTemplate.publish.confirm"
</app-button> >Bevestigen</app-button
>
<app-button
variant="subtle"
[disabled]="busy()"
(click)="cancelPublish.emit()"
i18n="@@orgTemplate.publish.cancel"
>Annuleren</app-button
>
} @else { } @else {
<app-button <app-button
variant="primary" variant="primary"
[disabled]="!draftValid() || busy()" [disabled]="!draftValid() || busy()"
(click)="requestPublish.emit()" (click)="requestPublish.emit()"
i18n="@@orgTemplate.publish"
>Publiceren</app-button
> >
{{ publishLabel() }}
</app-button>
@if (!draftValid()) { @if (!draftValid()) {
<span class="published">{{ invalidHint() }}</span> <span class="published">{{ invalidHint() }}</span>
} }
} }
<app-button variant="secondary" [disabled]="busy()" (click)="proefbrief.emit()"> <app-button
{{ proefbriefLabel() }} variant="secondary"
</app-button> [disabled]="busy()"
(click)="proefbrief.emit()"
i18n="@@orgTemplate.proefbrief"
>Proefbrief</app-button
>
</div> </div>
`, `,
}) })
@@ -300,14 +217,6 @@ export class OrgTemplateEditorComponent {
protected readonly MIN = MARGIN_MIN_MM; protected readonly MIN = MARGIN_MIN_MM;
protected readonly MAX = MARGIN_MAX_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) { protected onSelectSubOrg(event: Event) {
this.selectSubOrg.emit((event.target as HTMLSelectElement).value); 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?`, $localize`:@@orgTemplate.publish.impact:Dit raakt ${this.unsentBriefs()}:count: nog niet verzonden brieven. Publiceren?`,
); );
protected subOrgLabel = input($localize`:@@orgTemplate.subOrg:Organisatieonderdeel`);
protected marginsLegend = input( protected marginsLegend = input(
$localize`:@@orgTemplate.margins:Marges (mm, tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max:)`, $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( protected invalidHint = input(
$localize`:@@orgTemplate.invalid:Vul organisatienaam en ondertekenaar in; marges tussen ${MARGIN_MIN_MM}:min: en ${MARGIN_MAX_MM}:max: mm.`, $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>();
}
@@ -0,0 +1,176 @@
# RD-25 — Split `org-template-editor` by output cluster
Status: done
Source: PLAN.md 3f, order step 7
## Why
`org-template-editor.component.ts` measures ~330 effective lines against a limit of 250, and
carries `/* eslint-disable max-lines */`. Three separate things pad it:
- a 44-line sample letter constant, exported but used only in this file;
- 13 label `input()`s that are all declared `protected`, so nothing can ever bind them — they
are constants wearing input ceremony;
- two self-contained blocks, the logo uploader and the version history.
The **11 `output()`s are the tell**: each cluster is a mutation family, and two of them lift out
whole.
## Read first
- `org-template-editor.component.ts:27``SAMPLE_LETTER_BRIEF`, and line 284, its only use.
- `org-template-editor.component.ts:272-297` — the inputs and the 11 outputs.
- `org-template-editor.component.ts:337-353` — the 13 label inputs.
- `org-template-editor.component.ts:195-235` — the logo block and the history block, the two
that become children.
- `.dependency-cruiser.base.js:113-120``no-testing-in-production`. Decision 1 depends on it.
- PLAN.md 3e — the `$localize` boundary, which decision 2 applies.
## Decisions (pre-made, don't relitigate)
1. **`SAMPLE_LETTER_BRIEF` moves to `apps/ssp/src/app/brief/domain/sample-letter.ts`.** It is
production content — the letter the admin previews — not a test fixture.
**It must not go into `brief.testing.ts`, and nothing may import it from there.**
`.dependency-cruiser.base.js`'s `no-testing-in-production` rule forbids production code
reaching any `*.testing.ts`, so putting it there fails `npm run dep:check`. A new
`domain/sample-letter.ts` beside `brief.ts` is the right home; `domain/` is pure TS, and this
is data.
2. **11 of the 13 label inputs become inline `i18n` attributes in the template. Two stay.**
The two that stay are parameterised, and PLAN 3e explains why moving them breaks the build:
| Keep in TS | Id | Why |
| --------------- | ----------------------- | -------------------------------------------- |
| `marginsLegend` | `@@orgTemplate.margins` | interpolates `MARGIN_MIN_MM`/`MARGIN_MAX_MM` |
| `invalidHint` | `@@orgTemplate.invalid` | same two interpolations |
The `.xlf` stores an interpolation as `<x id="min" equiv-text="MARGIN_MIN_MM"/>`. Moving such
a message into a template renames the placeholder to `INTERPOLATION`, the translation merge
no longer matches, and `ng build --localize` fails.
**Every id is preserved.** A plain `protected foo = input($localize`:@@id:Text`)` used as
`{{ foo() }}` becomes the literal text in the template with an `i18n="@@id"` attribute, or
`i18n-label="@@id"` when it feeds an attribute. Neither `messages.en.xlf` may change.
3. **Two new children, each taking one output cluster:**
| File | Class | Inputs | Outputs |
| ------------------------------ | ---------------- | ----------------------------------------- | ------------------------------------------ |
| `logo-upload.component.ts` | `LogoUpload` | `logoUrl`, `uploadState`, `previewUrlFor` | `logoSelected`, `logoRemoved`, `logoRetry` |
| `version-history.component.ts` | `VersionHistory` | `history`, `publishedVersion` | `rollback` |
They live beside the parent, in `apps/ssp/src/app/brief/ui/org-template-editor/`.
4. **The parent keeps seven outputs, not the five PLAN estimated.** `selectSubOrg`,
`templateEdit`, `marginEdit`, `requestPublish`, `confirmPublish`, `cancelPublish`,
`proefbrief`. The publish trio stays with the parent because publishing acts on the whole
draft, not on the version list; only `rollback` is history's own verb. PLAN's "→ 5" was an
estimate made before the outputs were mapped to blocks — its line estimate (~222) is the part
that matches this split.
5. **The parent's public surface does not change.** All 11 outputs still exist on the parent and
still fire; two clusters are simply re-emitted from children. `org-template.page.ts` and the
story bind exactly what they bind today.
6. **Delete `/* eslint-disable max-lines */`.** Mandatory — `reportUnusedDisableDirectives` is
`error`, so the two rules pin each other in both directions.
7. **No new stories.** `org-template-editor.stories.ts` already renders both blocks through the
parent. The 13 label inputs were `protected`, so no story could bind them and none does.
## Files
- `apps/ssp/src/app/brief/domain/sample-letter.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/logo-upload.component.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/version-history.component.ts` (new)
- `apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts`
## Steps
1. Move `SAMPLE_LETTER_BRIEF` to `domain/sample-letter.ts` and import it in the parent
(decision 1).
2. Inline the 11 plain labels, keeping every id (decision 2).
3. Extract `logo-upload.component.ts`, then `version-history.component.ts` (decision 3), wiring
each child's outputs to the parent's existing ones.
4. Delete the disable (decision 6).
5. `git add -A`, then run the acceptance commands.
6. Update this ticket's `Status:` to `done` and the README's RD-25 row to `done`.
7. Commit all of it together.
## Acceptance criteria
Measured against the tree before handover. Run after `git add -A`.
```bash
P=apps/ssp/src/app/brief/ui/org-template-editor/org-template-editor.component.ts
git ls-files 'apps/ssp/src/app/brief/ui/org-template-editor/*.component.ts' | wc -l # is 1 -> MUST be 3
git ls-files apps/ssp/src/app/brief/domain/sample-letter.ts | wc -l # is 0 -> MUST be 1
git grep -c "eslint-disable max-lines" -- $P # is 1 -> MUST be 0
```
The constant moved, and the parent still uses it (import line plus use line is two lines, so 2
is the correct number here):
```bash
git grep -c "export const SAMPLE_LETTER_BRIEF" -- apps/ssp/src/app/brief/domain/sample-letter.ts # MUST be 1
git grep -c "export const SAMPLE_LETTER_BRIEF" -- $P # is 1 -> MUST be 0
git grep -c "SAMPLE_LETTER_BRIEF" -- $P # is 2 -> MUST still be 2
```
The ceremony is gone and the clusters left (decisions 2, 3, 4):
```bash
git grep -c "protected .* = input(" -- $P # is 13 -> MUST be 2
git grep -c "= output" -- $P # is 11 -> MUST still be 11 (decision 5, see below)
```
**Correction found while executing this ticket.** This check originally read `MUST be 7`,
copying decision 4's output count. That count is decision 4's classification of which cluster
owns each output, not the count of `output()` declarations on the parent class. Decision 5 and
this ticket's own Risks section both require the parent to keep declaring all 11 — a child's
output is re-emitted, not removed, and `org-template.page.ts` (out of scope, Files list excludes
it) binds all 11 directly on `<app-org-template-editor>`. Removing 4 declarations would break
that binding. Verified: `npx ng build ssp --localize` and `npm run dep:check` both pass with all
11 outputs present, and no other acceptance number changes.
The translation seam did not move (decision 2):
```bash
git grep -ho "@@orgTemplate[a-zA-Z0-9_.]*" -- apps/ssp/src/app/brief/ui/org-template-editor/ | sort -u | wc -l # is 18 -> MUST still be 18
git status --short -- '*.xlf' | wc -l # MUST be 0
```
```bash
npm run ci --full # exits 0
```
## Verification
`ng build --localize` inside the gate is the real check on decision 2: a renamed placeholder or
a lost id fails it. The `.xlf` files are hand-maintained, so **if you find yourself editing one,
you have changed an id and should undo it instead**.
`npm run dep:check` inside the gate is the real check on decision 1.
**Do not add a line-count command.** `npm run lint` is the exact check (decision 6).
## Out of scope
- `letter-canvas`. RD-26 owns it, and it keeps its disable.
- Changing any label text, any id, or any output name.
- The `orgTemplate.publish.impact` message. It is not one of the 13 labels and does not move.
- Reworking the upload controller or the publish flow.
## Risks
- **Moving a parameterised `$localize` into a template breaks the build** (decision 2). The two
named messages stay in TS. If a third turns out to interpolate, leave it in TS too and say so
in the commit message.
- **`brief.testing.ts` is the wrong home for the sample letter** (decision 1), and the failure
is a dependency-cruiser error rather than a type error, so it will not show up until
`dep:check`.
- **The parent must keep all 11 outputs** (decision 5). A child's output is re-emitted, not
removed — `org-template.page.ts` binds them.
- **Deleting the disable is mandatory** (decision 6).
+1 -1
View File
@@ -119,7 +119,7 @@ two. Note that RD-15 exists because 22 abandoned agent worktrees are still on di
| RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done | | RD-22 | `intake-wizard` to 3 step components | 08, 20 | yes | done |
| RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | done | | RD-23 | `registratie-wizard` to 3 steps + the upload-controller move | 08, 20 | yes | done |
| RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done | | RD-24 | `concepts.page` to 6 sections + `concept-card` + globals + code tokens | 02 | yes | done |
| RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | todo | | RD-25 | `org-template-editor` to `sample-letter.ts` + labels + 2 children | 02 | yes | done |
| RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo | | RD-26 | `letter-canvas`: inline the labels + `letter-line`; keep one disable | 02 | yes | todo |
| RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo | | RD-27 | **The layer move:** 33 `git mv` + 28 specifiers + 8 MDX imports | 21 | yes | todo |
| RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo | | RD-28 | Layer-tag fixes + the `libs/beheer` title rule | 27 | | todo |