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;
|
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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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