feat(portal-frontend): implement edit-applicant-details form with write-through disclaimer

Shared form for writeThrough and owned modes. Shows the ADR-002
disclaimer only in writeThrough mode (no client-side validation
stricter than legacy provides). Maps 400 ErrorsResponse to per-field
inline errors, 422/409 to a banner with the server's own text -
never a client-side switch over invariant names. Embedded in
case-detail with reload() on save so the actions block refreshes.
This commit is contained in:
eho
2026-07-31 08:59:55 +02:00
parent cebbf9f57a
commit 9e031da724
7 changed files with 317 additions and 2 deletions
@@ -61,6 +61,17 @@ export interface ApplicantDetailsRequest {
preferredChannel: string; preferredChannel: string;
} }
export function toApplicantDetailsRequest(detail: CaseDetail): ApplicantDetailsRequest {
return {
surname: detail.surname,
initials: detail.initials,
address: detail.address,
email: detail.email,
phone: detail.phone,
preferredChannel: detail.preferredChannel,
};
}
export interface RecordAssessmentRequest { export interface RecordAssessmentRequest {
verifiedItems: string[]; verifiedItems: string[];
exceptionReason: string | null; exceptionReason: string | null;
@@ -62,6 +62,15 @@
</dl> </dl>
</section> </section>
<section>
<h3>Gegevens wijzigen</h3>
<app-edit-applicant-details
[actionLink]="d.actions.editApplicantDetails"
[initial]="toApplicantDetailsRequest(d)"
(saved)="reload()"
/>
</section>
<section> <section>
<h3>Acties</h3> <h3>Acties</h3>
<app-case-actions [actions]="d.actions" /> <app-case-actions [actions]="d.actions" />
@@ -4,11 +4,12 @@ import { ActivatedRoute } from '@angular/router';
import { CaseActions } from '../case-actions/case-actions'; import { CaseActions } from '../case-actions/case-actions';
import { CaseDetailService } from '../case-detail.service'; import { CaseDetailService } from '../case-detail.service';
import { CaseDetail } from '../case-detail.types'; import { CaseDetail, toApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details';
@Component({ @Component({
selector: 'app-case-detail', selector: 'app-case-detail',
imports: [CaseActions, KeyValuePipe], imports: [CaseActions, EditApplicantDetails, KeyValuePipe],
templateUrl: './case-detail.html', templateUrl: './case-detail.html',
styleUrl: './case-detail.css', styleUrl: './case-detail.css',
}) })
@@ -17,6 +18,7 @@ export class CaseDetailPage {
private readonly caseDetailService = inject(CaseDetailService); private readonly caseDetailService = inject(CaseDetailService);
readonly detail = signal<CaseDetail | null>(null); readonly detail = signal<CaseDetail | null>(null);
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
constructor() { constructor() {
this.route.paramMap.subscribe((params) => { this.route.paramMap.subscribe((params) => {
@@ -26,4 +28,14 @@ export class CaseDetailPage {
source$.subscribe((detail) => this.detail.set(detail)); source$.subscribe((detail) => this.detail.set(detail));
}); });
} }
reload(): void {
const current = this.detail();
if (!current) return;
const source$ =
current.origin === 'Legacy'
? this.caseDetailService.getLegacyDetail(String(current.legacyAanvraagId))
: this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
source$.subscribe((updated) => this.detail.set(updated));
}
} }
@@ -0,0 +1,73 @@
<form (ngSubmit)="submit()">
@if (isWriteThrough) {
<p class="disclaimer" data-testid="write-through-disclaimer">
Gevalideerd door het legacy systeem — Angular voegt hier geen eigen validatie toe.
</p>
}
@if (banner) {
<p class="banner" data-testid="edit-details-banner">{{ banner }}</p>
}
<label>
Achternaam
<input type="text" name="surname" required [(ngModel)]="surname" />
</label>
@if (fieldErrors['surname']; as error) {
<p class="field-error" data-testid="field-error-surname">{{ error }}</p>
}
<label>
Voorletters
<input type="text" name="initials" required [(ngModel)]="initials" />
</label>
<label>
Straat
<input type="text" name="street" [(ngModel)]="street" />
</label>
<label>
Huisnummer
<input type="text" name="number" [(ngModel)]="number" />
</label>
@if (fieldErrors['address.number']; as error) {
<p class="field-error" data-testid="field-error-address.number">{{ error }}</p>
}
<label>
Postcode
<input type="text" name="postalCode" [(ngModel)]="postalCode" />
</label>
@if (fieldErrors['address.postalCode']; as error) {
<p class="field-error" data-testid="field-error-address.postalCode">{{ error }}</p>
}
<label>
Plaats
<input type="text" name="city" [(ngModel)]="city" />
</label>
<label>
E-mail
<input type="email" name="email" [(ngModel)]="email" />
</label>
@if (fieldErrors['email']; as error) {
<p class="field-error" data-testid="field-error-email">{{ error }}</p>
}
<label>
Telefoon
<input type="text" name="phone" [(ngModel)]="phone" />
</label>
@if (fieldErrors['phone']; as error) {
<p class="field-error" data-testid="field-error-phone">{{ error }}</p>
}
<label>
Voorkeurskanaal
<input type="text" name="preferredChannel" required [(ngModel)]="preferredChannel" />
</label>
<button type="submit">Opslaan</button>
</form>
@@ -0,0 +1,118 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from './edit-applicant-details';
const initial: ApplicantDetailsRequest = {
surname: 'de Vries',
initials: 'A.',
address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
email: 'anna@example.nl',
phone: null,
preferredChannel: 'Post',
};
describe('EditApplicantDetails', () => {
let fixture: ComponentFixture<EditApplicantDetails>;
let updateDetails: ReturnType<typeof vi.fn>;
function render(actionLink: ActionLink): HTMLElement {
fixture = TestBed.createComponent(EditApplicantDetails);
fixture.componentRef.setInput('actionLink', actionLink);
fixture.componentRef.setInput('initial', initial);
fixture.detectChanges();
return fixture.nativeElement as HTMLElement;
}
function submitForm(el: HTMLElement): void {
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
fixture.detectChanges();
}
beforeEach(async () => {
updateDetails = vi.fn();
await TestBed.configureTestingModule({
imports: [EditApplicantDetails],
providers: [{ provide: CaseDetailService, useValue: { updateDetails } }],
}).compileComponents();
});
it('shows the write-through disclaimer only in writeThrough mode', () => {
const writeThroughEl = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
expect(writeThroughEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeTruthy();
const ownedEl = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
expect(ownedEl.querySelector('[data-testid="write-through-disclaimer"]')).toBeFalsy();
});
it('submits the form body to the given href and emits saved on success', () => {
updateDetails.mockReturnValue(of(undefined));
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
const savedSpy = vi.fn();
fixture.componentInstance.saved.subscribe(savedSpy);
submitForm(el);
expect(updateDetails).toHaveBeenCalledWith('/api/worklist/owned/abc/details', {
surname: 'de Vries',
initials: 'A.',
address: { street: 'Kerkweg', number: '12', postalCode: '3512JK', city: 'Utrecht' },
email: 'anna@example.nl',
phone: null,
preferredChannel: 'Post',
});
expect(savedSpy).toHaveBeenCalled();
});
it('maps a 400 ErrorsResponse to per-field inline errors', () => {
updateDetails.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 400,
error: {
errors: [
{ field: 'surname', message: 'Achternaam is verplicht.' },
{ field: 'address.number', message: 'Huisnummer is verplicht.' },
{ field: 'address.postalCode', message: 'Ongeldige postcode.' },
],
},
}),
),
);
const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' });
submitForm(el);
expect(el.querySelector('[data-testid="field-error-surname"]')?.textContent).toContain('Achternaam is verplicht.');
expect(el.querySelector('[data-testid="field-error-address.number"]')?.textContent).toContain('Huisnummer is verplicht.');
expect(el.querySelector('[data-testid="field-error-address.postalCode"]')?.textContent).toContain('Ongeldige postcode.');
});
it('shows a 422 InvariantViolationResponse as an invariant + message banner', () => {
updateDetails.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Address.AllPartsRequired', message: 'All address parts are required.' } })),
);
const el = render({ mode: 'owned', href: '/api/worklist/owned/abc/details' });
submitForm(el);
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('Address.AllPartsRequired');
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('All address parts are required.');
});
it('shows a 409 MessageResponse as a banner', () => {
updateDetails.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'This aanvraag has already been migrated.' } })),
);
const el = render({ mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' });
submitForm(el);
expect(el.querySelector('[data-testid="edit-details-banner"]')?.textContent).toContain('This aanvraag has already been migrated.');
});
});
@@ -0,0 +1,92 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, OnInit, Output, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { ErrorsResponse, InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, ApplicantDetailsRequest } from '../case-detail.types';
@Component({
selector: 'app-edit-applicant-details',
imports: [FormsModule],
templateUrl: './edit-applicant-details.html',
styleUrl: './edit-applicant-details.css',
})
export class EditApplicantDetails implements OnInit {
private readonly caseDetailService = inject(CaseDetailService);
@Input({ required: true }) actionLink!: ActionLink;
@Input({ required: true }) initial!: ApplicantDetailsRequest;
@Output() readonly saved = new EventEmitter<void>();
surname = '';
initials = '';
street = '';
number = '';
postalCode = '';
city = '';
email = '';
phone = '';
preferredChannel = '';
fieldErrors: Record<string, string> = {};
banner: string | null = null;
get isWriteThrough(): boolean {
return this.actionLink.mode === 'writeThrough';
}
ngOnInit(): void {
this.surname = this.initial.surname;
this.initials = this.initial.initials;
this.street = this.initial.address?.street ?? '';
this.number = this.initial.address?.number ?? '';
this.postalCode = this.initial.address?.postalCode ?? '';
this.city = this.initial.address?.city ?? '';
this.email = this.initial.email ?? '';
this.phone = this.initial.phone ?? '';
this.preferredChannel = this.initial.preferredChannel;
}
submit(): void {
this.fieldErrors = {};
this.banner = null;
const body: ApplicantDetailsRequest = {
surname: this.surname,
initials: this.initials,
address: this.street ? { street: this.street, number: this.number, postalCode: this.postalCode, city: this.city } : null,
email: this.email || null,
phone: this.phone || null,
preferredChannel: this.preferredChannel,
};
this.caseDetailService.updateDetails(this.actionLink.href, body).subscribe({
next: () => this.saved.emit(),
error: (error: HttpErrorResponse) => this.handleError(error),
});
}
private handleError(error: HttpErrorResponse): void {
switch (error.status) {
case 400: {
const body = error.error as ErrorsResponse;
this.fieldErrors = Object.fromEntries(body.errors.map((fieldError) => [fieldError.field, fieldError.message]));
break;
}
case 422: {
const body = error.error as InvariantViolationResponse;
this.banner = `${body.invariant}: ${body.message}`;
break;
}
case 409: {
const body = error.error as MessageResponse;
this.banner = body.message;
break;
}
default:
this.banner = 'Onbekende fout bij opslaan.';
}
}
}