Extract reusable address-fields organism, adopt in both registratie call-sites

The editable address block (straat/postcode/woonplaats) was hand-built inline in
two places — the registratie wizard and the change-request form. Factor it into one
pure presentational organism (values in, errors in, per-field change out) grouped in
a fieldset/legend, and adopt it in both. Behaviour, validation and state flow are
unchanged: the wizard still dispatches SetField (flipping adresHerkomst on edit) and
the change-request form still parses the postcode on submit. Storybook entry added;
other field clusters left inline by design (single-use or genuinely divergent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 17:25:39 +02:00
parent 64385999eb
commit 770d454a32
4 changed files with 101 additions and 22 deletions

View File

@@ -0,0 +1,50 @@
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
export interface AdresValue {
straat: string;
postcode: string;
woonplaats: string;
}
export type AdresErrors = Partial<Record<keyof AdresValue, string>>;
/** Organism: the editable address block (straat / postcode / woonplaats), grouped
in a fieldset/legend. Pure & presentational — values in via `value`, errors in
via `errors`, every keystroke out via `fieldChange`. No store, no services, no
internal state; the container owns the Model and decides what a change means
(registratie-wizard dispatches SetField; change-request-form sets its props).
Composes the form-field molecule (×3) so labels/error wiring stay consistent. */
@Component({
selector: 'app-address-fields',
imports: [FormsModule, FormFieldComponent, TextInputComponent],
template: `
<fieldset class="utrecht-form-fieldset utrecht-form-fieldset--html-fieldset">
<legend class="utrecht-form-fieldset__legend utrecht-form-fieldset__legend--html-legend">{{ legend() }}</legend>
<app-form-field label="Straat en huisnummer" [fieldId]="idPrefix() + '-straat'" [error]="errors().straat">
<app-text-input [inputId]="idPrefix() + '-straat'" [invalid]="!!errors().straat"
[ngModel]="value().straat" (ngModelChange)="fieldChange.emit({ key: 'straat', value: $event })"
name="straat" [ngModelOptions]="{ standalone: true }" />
</app-form-field>
<app-form-field label="Postcode" [fieldId]="idPrefix() + '-postcode'" [error]="errors().postcode">
<app-text-input [inputId]="idPrefix() + '-postcode'" [invalid]="!!errors().postcode"
[ngModel]="value().postcode" (ngModelChange)="fieldChange.emit({ key: 'postcode', value: $event })"
name="postcode" placeholder="1234 AB" [ngModelOptions]="{ standalone: true }" />
</app-form-field>
<app-form-field label="Woonplaats" [fieldId]="idPrefix() + '-woonplaats'" [error]="errors().woonplaats">
<app-text-input [inputId]="idPrefix() + '-woonplaats'" [invalid]="!!errors().woonplaats"
[ngModel]="value().woonplaats" (ngModelChange)="fieldChange.emit({ key: 'woonplaats', value: $event })"
name="woonplaats" [ngModelOptions]="{ standalone: true }" />
</app-form-field>
</fieldset>
`,
})
export class AddressFieldsComponent {
value = input.required<AdresValue>();
errors = input<AdresErrors>({});
/** Prefix for field ids/labels — keeps them unique if two blocks ever co-exist. */
idPrefix = input('adres');
legend = input('Adres');
fieldChange = output<{ key: keyof AdresValue; value: string }>();
}

View File

@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/angular';
import { moduleMetadata } from '@storybook/angular';
import { AddressFieldsComponent } from './address-fields.component';
const meta: Meta<AddressFieldsComponent> = {
title: 'Organisms/Address Fields',
component: AddressFieldsComponent,
decorators: [moduleMetadata({ imports: [AddressFieldsComponent] })],
render: (args) => ({
props: { ...args, onChange: (e: unknown) => console.log('fieldChange', e) },
template: `
<app-address-fields [value]="value" [errors]="errors" (fieldChange)="onChange($event)" />`,
}),
};
export default meta;
type Story = StoryObj<AddressFieldsComponent>;
const empty = { straat: '', postcode: '', woonplaats: '' };
export const Default: Story = { args: { value: empty, errors: {} } };
export const Prefilled: Story = {
args: { value: { straat: 'Stationsplein 1', postcode: '3511 ED', woonplaats: 'Utrecht' }, errors: {} },
};
export const WithErrors: Story = {
args: {
value: { straat: '', postcode: '12', woonplaats: '' },
errors: { straat: 'Vul een straat en huisnummer in.', postcode: 'Voer een geldige postcode in, bijv. 1234 AB.' },
},
};

View File

@@ -1,9 +1,8 @@
import { Component, output, signal } from '@angular/core'; import { Component, output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FormFieldComponent } from '@shared/ui/form-field/form-field.component';
import { TextInputComponent } from '@shared/ui/text-input/text-input.component';
import { ButtonComponent } from '@shared/ui/button/button.component'; import { ButtonComponent } from '@shared/ui/button/button.component';
import { HeadingComponent } from '@shared/ui/heading/heading.component'; import { HeadingComponent } from '@shared/ui/heading/heading.component';
import { AddressFieldsComponent, AdresValue } from '@registratie/ui/address-fields/address-fields.component';
import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode'; import { Postcode, parsePostcode } from '@registratie/domain/value-objects/postcode';
/** A submitted change request carries a *parsed* postcode (branded Postcode), /** A submitted change request carries a *parsed* postcode (branded Postcode),
@@ -19,19 +18,15 @@ export interface ChangeRequest {
straight from the parser's Result — no parallel "is it valid" flag to drift. */ straight from the parser's Result — no parallel "is it valid" flag to drift. */
@Component({ @Component({
selector: 'app-change-request-form', selector: 'app-change-request-form',
imports: [FormsModule, FormFieldComponent, TextInputComponent, ButtonComponent, HeadingComponent], imports: [FormsModule, ButtonComponent, HeadingComponent, AddressFieldsComponent],
template: ` template: `
<app-heading [level]="2">Adreswijziging doorgeven</app-heading> <app-heading [level]="2">Adreswijziging doorgeven</app-heading>
<form (ngSubmit)="onSubmit()" style="max-width:28rem"> <form (ngSubmit)="onSubmit()" style="max-width:28rem">
<app-form-field label="Straat en huisnummer" fieldId="street" [error]="streetError()"> <app-address-fields
<app-text-input inputId="street" [(ngModel)]="street" name="street" [invalid]="!!streetError()" /> idPrefix="cr"
</app-form-field> [value]="{ straat: street, postcode: zip, woonplaats: city }"
<app-form-field label="Postcode" fieldId="zip" [error]="zipError()"> [errors]="{ straat: streetError(), postcode: zipError() }"
<app-text-input inputId="zip" [(ngModel)]="zip" name="zip" placeholder="1234 AB" [invalid]="!!zipError()" /> (fieldChange)="onAdres($event)" />
</app-form-field>
<app-form-field label="Woonplaats" fieldId="city">
<app-text-input inputId="city" [(ngModel)]="city" name="city" />
</app-form-field>
<div style="margin-top:1rem"> <div style="margin-top:1rem">
<app-button type="submit" variant="primary">Wijziging indienen</app-button> <app-button type="submit" variant="primary">Wijziging indienen</app-button>
</div> </div>
@@ -46,6 +41,12 @@ export class ChangeRequestFormComponent {
zipError = signal(''); zipError = signal('');
submitted = output<ChangeRequest>(); submitted = output<ChangeRequest>();
onAdres(e: { key: keyof AdresValue; value: string }) {
if (e.key === 'straat') this.street = e.value;
else if (e.key === 'postcode') this.zip = e.value;
else this.city = e.value;
}
onSubmit() { onSubmit() {
const street = this.street.trim(); const street = this.street.trim();
this.streetError.set(street ? '' : 'Vul straat en huisnummer in.'); this.streetError.set(street ? '' : 'Vul straat en huisnummer in.');

View File

@@ -10,6 +10,7 @@ import { SkeletonComponent } from '@shared/ui/skeleton/skeleton.component';
import { DataRowComponent } from '@shared/ui/data-row/data-row.component'; import { DataRowComponent } from '@shared/ui/data-row/data-row.component';
import { StepperComponent } from '@shared/ui/stepper/stepper.component'; import { StepperComponent } from '@shared/ui/stepper/stepper.component';
import { ASYNC } from '@shared/ui/async/async.component'; import { ASYNC } from '@shared/ui/async/async.component';
import { AddressFieldsComponent } from '@registratie/ui/address-fields/address-fields.component';
import { createStore } from '@shared/application/store'; import { createStore } from '@shared/application/store';
import { RemoteData, fromResource } from '@shared/application/remote-data'; import { RemoteData, fromResource } from '@shared/application/remote-data';
import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter'; import { BrpAdapter, parseBrpAddress } from '@registratie/infrastructure/brp.adapter';
@@ -43,7 +44,8 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
selector: 'app-registratie-wizard', selector: 'app-registratie-wizard',
imports: [ imports: [
FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent, FormsModule, FormFieldComponent, TextInputComponent, RadioGroupComponent, ButtonComponent,
AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent, ...ASYNC, AlertComponent, SpinnerComponent, SkeletonComponent, DataRowComponent, StepperComponent,
AddressFieldsComponent, ...ASYNC,
], ],
template: ` template: `
@switch (state().tag) { @switch (state().tag) {
@@ -67,15 +69,10 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
<app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert> <app-alert type="warning">We konden de BRP nu niet bereiken. Vul uw adres hieronder handmatig in.</app-alert>
} }
} }
<app-form-field label="Straat en huisnummer" fieldId="straat" [error]="err('straat')"> <app-address-fields
<app-text-input inputId="straat" [invalid]="!!err('straat')" [ngModel]="draft().straat ?? ''" (ngModelChange)="set('straat', $event)" name="straat" /> [value]="{ straat: draft().straat ?? '', postcode: draft().postcode ?? '', woonplaats: draft().woonplaats ?? '' }"
</app-form-field> [errors]="{ straat: err('straat'), postcode: err('postcode'), woonplaats: err('woonplaats') }"
<app-form-field label="Postcode" fieldId="postcode" [error]="err('postcode')"> (fieldChange)="set($event.key, $event.value)" />
<app-text-input inputId="postcode" [invalid]="!!err('postcode')" [ngModel]="draft().postcode ?? ''" (ngModelChange)="set('postcode', $event)" name="postcode" placeholder="1234 AB" />
</app-form-field>
<app-form-field label="Woonplaats" fieldId="woonplaats" [error]="err('woonplaats')">
<app-text-input inputId="woonplaats" [invalid]="!!err('woonplaats')" [ngModel]="draft().woonplaats ?? ''" (ngModelChange)="set('woonplaats', $event)" name="woonplaats" />
</app-form-field>
<app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')"> <app-form-field label="Hoe wilt u correspondentie ontvangen?" fieldId="correspondentie" [error]="err('correspondentie')">
<app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')" <app-radio-group name="correspondentie" [options]="kanalen" [invalid]="!!err('correspondentie')"
[ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" /> [ngModel]="draft().correspondentie ?? ''" (ngModelChange)="setKanaal($event)" />