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:
@@ -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 }>();
|
||||||
|
}
|
||||||
@@ -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.' },
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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.');
|
||||||
|
|||||||
@@ -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)" />
|
||||||
|
|||||||
Reference in New Issue
Block a user