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:
@@ -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>
|
||||
|
||||
+46
@@ -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>
|
||||
}
|
||||
+85
@@ -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}`;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user