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:
@@ -61,6 +61,17 @@ export interface ApplicantDetailsRequest {
|
||||
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 {
|
||||
verifiedItems: string[];
|
||||
exceptionReason: string | null;
|
||||
|
||||
@@ -62,6 +62,15 @@
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Gegevens wijzigen</h3>
|
||||
<app-edit-applicant-details
|
||||
[actionLink]="d.actions.editApplicantDetails"
|
||||
[initial]="toApplicantDetailsRequest(d)"
|
||||
(saved)="reload()"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>Acties</h3>
|
||||
<app-case-actions [actions]="d.actions" />
|
||||
|
||||
@@ -4,11 +4,12 @@ import { ActivatedRoute } from '@angular/router';
|
||||
|
||||
import { CaseActions } from '../case-actions/case-actions';
|
||||
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({
|
||||
selector: 'app-case-detail',
|
||||
imports: [CaseActions, KeyValuePipe],
|
||||
imports: [CaseActions, EditApplicantDetails, KeyValuePipe],
|
||||
templateUrl: './case-detail.html',
|
||||
styleUrl: './case-detail.css',
|
||||
})
|
||||
@@ -17,6 +18,7 @@ export class CaseDetailPage {
|
||||
private readonly caseDetailService = inject(CaseDetailService);
|
||||
|
||||
readonly detail = signal<CaseDetail | null>(null);
|
||||
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
|
||||
|
||||
constructor() {
|
||||
this.route.paramMap.subscribe((params) => {
|
||||
@@ -26,4 +28,14 @@ export class CaseDetailPage {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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>
|
||||
+118
@@ -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.';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user