feat(portal-frontend): implement record-assessment form and closurePending display

Owned-mode form embedded in case-actions where the redirect link
isn't used. verifiedItems is comma-split client-side for convenience
only; the server re-validates everything regardless. closurePending
renders as a neutral success note (ADR-001), never as an error. 422
InvariantViolationResponse surfaces invariant+message verbatim in a
banner, matching the edit-details form's approach.
This commit is contained in:
eho
2026-07-31 09:08:22 +02:00
parent 9e031da724
commit 3b04c9fae3
8 changed files with 204 additions and 6 deletions
@@ -1,9 +1,11 @@
@if (actions().recordAssessment.mode === 'redirect') {
<a data-testid="record-assessment-link" [href]="actions().recordAssessment.href">Beoordeling vastleggen (in legacy)</a>
} @else {
<div data-testid="record-assessment-form">
<!-- app-record-assessment-form is embedded here once it exists -->
</div>
<app-record-assessment-form
data-testid="record-assessment-form"
[actionLink]="actions().recordAssessment"
(saved)="savedRequested.emit()"
/>
}
@if (actions().takeOwnership; as takeOwnership) {
@@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CaseDetailService } from '../case-detail.service';
import { CaseDetailActions } from '../case-detail.types';
import { CaseActions } from './case-actions';
@@ -14,7 +15,10 @@ describe('CaseActions', () => {
}
beforeEach(async () => {
await TestBed.configureTestingModule({ imports: [CaseActions] }).compileComponents();
await TestBed.configureTestingModule({
imports: [CaseActions],
providers: [{ provide: CaseDetailService, useValue: { recordAssessment: () => { throw new Error('not stubbed'); } } }],
}).compileComponents();
});
const legacyActions: CaseDetailActions = {
@@ -1,10 +1,11 @@
import { Component, input, output } from '@angular/core';
import { ActionLink, CaseDetailActions } from '../case-detail.types';
import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form';
@Component({
selector: 'app-case-actions',
imports: [],
imports: [RecordAssessmentForm],
templateUrl: './case-actions.html',
styleUrl: './case-actions.css',
})
@@ -13,4 +14,5 @@ export class CaseActions {
readonly takeOwnershipRequested = output<ActionLink>();
readonly releaseOwnershipRequested = output<ActionLink>();
readonly savedRequested = output<void>();
}
@@ -73,7 +73,7 @@
<section>
<h3>Acties</h3>
<app-case-actions [actions]="d.actions" />
<app-case-actions [actions]="d.actions" (savedRequested)="reload()" />
</section>
} @else {
<p>Laden...</p>
@@ -0,0 +1,46 @@
@if (recorded) {
<p data-testid="assessment-recorded" class="success">
Beoordeling vastgelegd.
@if (closurePending) {
<span data-testid="closure-pending-note">Administratieve afsluiting in behandeling.</span>
}
</p>
} @else {
<form (ngSubmit)="submit()">
@if (banner) {
<p class="banner" data-testid="assessment-banner">{{ banner }}</p>
}
<label>
Gecontroleerde stukken (kommagescheiden)
<input type="text" name="verifiedItems" [(ngModel)]="verifiedItemsText" />
</label>
<label>
Uitzonderingsreden (alleen relevant zonder gecontroleerde stukken)
<input type="text" name="exceptionReason" [(ngModel)]="exceptionReason" />
</label>
<label>
Uitkomst
<select name="outcome" [(ngModel)]="outcome">
<option value="Approved">Approved</option>
<option value="Rejected">Rejected</option>
</select>
</label>
@if (outcome === 'Rejected') {
<label>
Afwijzingscategorie
<input type="text" name="rejectionCategory" [(ngModel)]="rejectionCategory" />
</label>
}
<label>
Motivatie
<textarea name="motivation" [(ngModel)]="motivation"></textarea>
</label>
<button type="submit" data-testid="record-assessment-submit">Beoordeling vastleggen</button>
</form>
}
@@ -0,0 +1,85 @@
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 { RecordAssessmentForm } from './record-assessment-form';
describe('RecordAssessmentForm', () => {
let fixture: ComponentFixture<RecordAssessmentForm>;
let recordAssessment: ReturnType<typeof vi.fn>;
async function render(): Promise<HTMLElement> {
fixture = TestBed.createComponent(RecordAssessmentForm);
fixture.componentRef.setInput('actionLink', { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' });
fixture.detectChanges();
// NgModel/NgForm finish registering their controls on a microtask after
// the first detectChanges() - without this, the first simulated input
// event lands before the control is wired up and gets silently dropped.
await fixture.whenStable();
return fixture.nativeElement as HTMLElement;
}
async function fillAndSubmit(el: HTMLElement, values: { verifiedItems?: string; motivation?: string }) {
const setValue = async (name: string, value: string) => {
const input = el.querySelector<HTMLInputElement | HTMLTextAreaElement>(`[name="${name}"]`)!;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
fixture.detectChanges();
await fixture.whenStable();
};
if (values.verifiedItems !== undefined) await setValue('verifiedItems', values.verifiedItems);
if (values.motivation !== undefined) await setValue('motivation', values.motivation);
el.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true }));
fixture.detectChanges();
await fixture.whenStable();
}
beforeEach(async () => {
recordAssessment = vi.fn();
await TestBed.configureTestingModule({
imports: [RecordAssessmentForm],
providers: [{ provide: CaseDetailService, useValue: { recordAssessment } }],
}).compileComponents();
});
it('submits verifiedItems split on commas and trimmed', async () => {
recordAssessment.mockReturnValue(of({ closurePending: false }));
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document, land , datum', motivation: 'Alles gecontroleerd.' });
expect(recordAssessment).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment', {
verifiedItems: ['document', 'land', 'datum'],
exceptionReason: null,
outcome: 'Approved',
rejectionCategory: null,
motivation: 'Alles gecontroleerd.',
});
});
it('shows closurePending as a neutral note, not an error, on success', async () => {
recordAssessment.mockReturnValue(of({ closurePending: true }));
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'Alles gecontroleerd.' });
expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeTruthy();
expect(el.querySelector('[data-testid="closure-pending-note"]')?.textContent).toContain('Administratieve afsluiting in behandeling.');
expect(el.querySelector('[data-testid="assessment-banner"]')).toBeFalsy();
});
it('shows a 422 InvariantViolationResponse as a banner, not a curated message', async () => {
recordAssessment.mockReturnValue(
throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Assessment.MotivationTooShort', message: 'Motivation is too short.' } })),
);
const el = await render();
await fillAndSubmit(el, { verifiedItems: 'document', motivation: 'te kort' });
expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Assessment.MotivationTooShort');
expect(el.querySelector('[data-testid="assessment-banner"]')?.textContent).toContain('Motivation is too short.');
expect(el.querySelector('[data-testid="assessment-recorded"]')).toBeFalsy();
});
});
@@ -0,0 +1,59 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { InvariantViolationResponse } from '../../shared/api-error.types';
import { CaseDetailService } from '../case-detail.service';
import { ActionLink, RecordAssessmentRequest } from '../case-detail.types';
@Component({
selector: 'app-record-assessment-form',
imports: [FormsModule],
templateUrl: './record-assessment-form.html',
styleUrl: './record-assessment-form.css',
})
export class RecordAssessmentForm {
private readonly caseDetailService = inject(CaseDetailService);
@Input({ required: true }) actionLink!: ActionLink;
@Output() readonly saved = new EventEmitter<void>();
verifiedItemsText = '';
exceptionReason = '';
outcome: 'Approved' | 'Rejected' = 'Approved';
rejectionCategory = '';
motivation = '';
banner: string | null = null;
recorded = false;
closurePending = false;
submit(): void {
this.banner = null;
const verifiedItems = this.verifiedItemsText
.split(',')
.map((item) => item.trim())
.filter((item) => item.length > 0);
const body: RecordAssessmentRequest = {
verifiedItems,
exceptionReason: verifiedItems.length === 0 ? this.exceptionReason || null : null,
outcome: this.outcome,
rejectionCategory: this.outcome === 'Rejected' ? this.rejectionCategory || null : null,
motivation: this.motivation,
};
this.caseDetailService.recordAssessment(this.actionLink.href, body).subscribe({
next: (result) => {
this.recorded = true;
this.closurePending = result.closurePending;
this.saved.emit();
},
error: (error: HttpErrorResponse) => {
const invariant = error.error as InvariantViolationResponse;
this.banner = `${invariant.invariant}: ${invariant.message}`;
},
});
}
}