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') {
|
@if (actions().recordAssessment.mode === 'redirect') {
|
||||||
<a data-testid="record-assessment-link" [href]="actions().recordAssessment.href">Beoordeling vastleggen (in legacy)</a>
|
<a data-testid="record-assessment-link" [href]="actions().recordAssessment.href">Beoordeling vastleggen (in legacy)</a>
|
||||||
} @else {
|
} @else {
|
||||||
<div data-testid="record-assessment-form">
|
<app-record-assessment-form
|
||||||
<!-- app-record-assessment-form is embedded here once it exists -->
|
data-testid="record-assessment-form"
|
||||||
</div>
|
[actionLink]="actions().recordAssessment"
|
||||||
|
(saved)="savedRequested.emit()"
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (actions().takeOwnership; as takeOwnership) {
|
@if (actions().takeOwnership; as takeOwnership) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { CaseDetailService } from '../case-detail.service';
|
||||||
import { CaseDetailActions } from '../case-detail.types';
|
import { CaseDetailActions } from '../case-detail.types';
|
||||||
import { CaseActions } from './case-actions';
|
import { CaseActions } from './case-actions';
|
||||||
|
|
||||||
@@ -14,7 +15,10 @@ describe('CaseActions', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(async () => {
|
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 = {
|
const legacyActions: CaseDetailActions = {
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, input, output } from '@angular/core';
|
import { Component, input, output } from '@angular/core';
|
||||||
|
|
||||||
import { ActionLink, CaseDetailActions } from '../case-detail.types';
|
import { ActionLink, CaseDetailActions } from '../case-detail.types';
|
||||||
|
import { RecordAssessmentForm } from '../record-assessment-form/record-assessment-form';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-case-actions',
|
selector: 'app-case-actions',
|
||||||
imports: [],
|
imports: [RecordAssessmentForm],
|
||||||
templateUrl: './case-actions.html',
|
templateUrl: './case-actions.html',
|
||||||
styleUrl: './case-actions.css',
|
styleUrl: './case-actions.css',
|
||||||
})
|
})
|
||||||
@@ -13,4 +14,5 @@ export class CaseActions {
|
|||||||
|
|
||||||
readonly takeOwnershipRequested = output<ActionLink>();
|
readonly takeOwnershipRequested = output<ActionLink>();
|
||||||
readonly releaseOwnershipRequested = output<ActionLink>();
|
readonly releaseOwnershipRequested = output<ActionLink>();
|
||||||
|
readonly savedRequested = output<void>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Acties</h3>
|
<h3>Acties</h3>
|
||||||
<app-case-actions [actions]="d.actions" />
|
<app-case-actions [actions]="d.actions" (savedRequested)="reload()" />
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else {
|
||||||
<p>Laden...</p>
|
<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