feat(portal-frontend): implement take-ownership and release-ownership actions

Take-ownership keeps the placeholder's confirm() dialog before this
one-way per-case migration step, then navigates straight to the new
owned detail on success (201). Release-ownership navigates back to
the worklist on success (204), no confirm dialog. Both surface 422
InvariantViolationResponse / 409 MessageResponse verbatim in a banner
rather than a curated client-side message.
This commit is contained in:
eho
2026-07-31 09:10:37 +02:00
parent 3b04c9fae3
commit c2d0ba9a3d
3 changed files with 185 additions and 33 deletions
@@ -73,7 +73,15 @@
<section> <section>
<h3>Acties</h3> <h3>Acties</h3>
<app-case-actions [actions]="d.actions" (savedRequested)="reload()" /> @if (banner(); as message) {
<p class="banner" data-testid="ownership-banner">{{ message }}</p>
}
<app-case-actions
[actions]="d.actions"
(savedRequested)="reload()"
(takeOwnershipRequested)="onTakeOwnership($event)"
(releaseOwnershipRequested)="onReleaseOwnership($event)"
/>
</section> </section>
} @else { } @else {
<p>Laden...</p> <p>Laden...</p>
@@ -1,7 +1,8 @@
import { HttpErrorResponse } from '@angular/common/http';
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap } from '@angular/router'; import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
import { of } from 'rxjs'; import { of, throwError } from 'rxjs';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { CaseDetailService } from '../case-detail.service'; import { CaseDetailService } from '../case-detail.service';
import { CaseDetail } from '../case-detail.types'; import { CaseDetail } from '../case-detail.types';
@@ -9,7 +10,7 @@ import { CaseDetailPage } from './case-detail';
const legacyDetail: CaseDetail = { const legacyDetail: CaseDetail = {
origin: 'Legacy', origin: 'Legacy',
legacyAanvraagId: 1001, legacyAanvraagId: 1002,
registrationApplicationId: null, registrationApplicationId: null,
surname: 'de Vries', surname: 'de Vries',
initials: 'A.', initials: 'A.',
@@ -26,54 +27,162 @@ const legacyDetail: CaseDetail = {
processStatus: null, processStatus: null,
lastModifiedAt: null, lastModifiedAt: null,
actions: { actions: {
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' }, editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' },
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' }, recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1002/beoordeling' },
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' }, takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1002/take-ownership' },
}, },
seams: { aanvrager: 'legacy-backend', procestijdlijn: null }, seams: { aanvrager: 'legacy-backend', procestijdlijn: null },
}; };
function configure(routeConfigPath: string, paramMap: Record<string, string>, getLegacyDetail: unknown, getOwnedDetail: unknown) { const ownedDetail: CaseDetail = {
return TestBed.configureTestingModule({ ...legacyDetail,
imports: [CaseDetailPage], origin: 'Owned',
providers: [ legacyAanvraagId: null,
{ provide: CaseDetailService, useValue: { getLegacyDetail, getOwnedDetail } }, registrationApplicationId: '00000000-0000-0000-0000-000000000002',
{ actions: {
provide: ActivatedRoute, editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' },
useValue: { recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' },
paramMap: of(convertToParamMap(paramMap)), releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' },
snapshot: { routeConfig: { path: routeConfigPath } }, },
seams: { aanvrager: 'owned', procestijdlijn: 'case-framework-timeline' },
};
function configure(routeConfigPath: string, paramMap: Record<string, string>, serviceOverrides: Record<string, unknown> = {}) {
const service = {
getLegacyDetail: vi.fn().mockReturnValue(of(legacyDetail)),
getOwnedDetail: vi.fn().mockReturnValue(of(ownedDetail)),
updateDetails: vi.fn(),
recordAssessment: vi.fn(),
takeOwnership: vi.fn(),
releaseOwnership: vi.fn(),
...serviceOverrides,
};
return {
service,
ready: TestBed.configureTestingModule({
imports: [CaseDetailPage],
providers: [
provideRouter([]),
{ provide: CaseDetailService, useValue: service },
{
provide: ActivatedRoute,
useValue: {
paramMap: of(convertToParamMap(paramMap)),
snapshot: { routeConfig: { path: routeConfigPath } },
},
}, },
}, ],
], }).compileComponents(),
}).compileComponents(); };
} }
describe('CaseDetailPage', () => { describe('CaseDetailPage', () => {
let fixture: ComponentFixture<CaseDetailPage>; let fixture: ComponentFixture<CaseDetailPage>;
it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => { it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => {
const getLegacyDetail = vi.fn().mockReturnValue(of(legacyDetail)); const { service, ready } = configure('legacy/:id', { id: '1002' });
const getOwnedDetail = vi.fn(); await ready;
await configure('legacy/:id', { id: '1001' }, getLegacyDetail, getOwnedDetail);
fixture = TestBed.createComponent(CaseDetailPage); fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges(); fixture.detectChanges();
expect(getLegacyDetail).toHaveBeenCalledWith('1001'); expect(service.getLegacyDetail).toHaveBeenCalledWith('1002');
expect(getOwnedDetail).not.toHaveBeenCalled(); expect(service.getOwnedDetail).not.toHaveBeenCalled();
expect(fixture.nativeElement.textContent).toContain('de Vries'); expect(fixture.nativeElement.textContent).toContain('de Vries');
}); });
it('given an owned/:id route, fetches via getOwnedDetail', async () => { it('given an owned/:id route, fetches via getOwnedDetail', async () => {
const getLegacyDetail = vi.fn(); const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' });
const getOwnedDetail = vi.fn().mockReturnValue(of({ ...legacyDetail, origin: 'Owned', legacyAanvraagId: null, registrationApplicationId: 'abc' })); await ready;
await configure('owned/:id', { id: 'abc' }, getLegacyDetail, getOwnedDetail);
fixture = TestBed.createComponent(CaseDetailPage); fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges(); fixture.detectChanges();
expect(getOwnedDetail).toHaveBeenCalledWith('abc'); expect(service.getOwnedDetail).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000002');
expect(getLegacyDetail).not.toHaveBeenCalled(); expect(service.getLegacyDetail).not.toHaveBeenCalled();
});
describe('take ownership', () => {
afterEach(() => vi.restoreAllMocks());
it('does nothing when the user declines the confirm dialog', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
const { service, ready } = configure('legacy/:id', { id: '1002' });
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
expect(service.takeOwnership).not.toHaveBeenCalled();
});
it('on confirm, calls takeOwnership and navigates to the new owned detail', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const { service, ready } = configure('legacy/:id', { id: '1002' }, {
takeOwnership: vi.fn().mockReturnValue(of({ registrationApplicationId: '00000000-0000-0000-0000-000000000002' })),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
expect(service.takeOwnership).toHaveBeenCalledWith('/api/worklist/legacy/1002/take-ownership');
expect(navigate).toHaveBeenCalledWith(['owned', '00000000-0000-0000-0000-000000000002']);
});
it('on a 422 InvariantViolationResponse, shows a banner instead of navigating', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const { ready } = configure('legacy/:id', { id: '1002' }, {
takeOwnership: vi
.fn()
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 422, error: { invariant: 'Bsn.ElevenProof', message: 'BSN fails the eleven-proof.' } }))),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="take-ownership-button"]').click();
fixture.detectChanges();
const banner = fixture.nativeElement.querySelector('[data-testid="ownership-banner"]');
expect(banner?.textContent).toContain('Bsn.ElevenProof');
expect(banner?.textContent).toContain('BSN fails the eleven-proof.');
});
});
describe('release ownership', () => {
it('calls releaseOwnership and navigates to the worklist on success', async () => {
const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
releaseOwnership: vi.fn().mockReturnValue(of(undefined)),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
const navigate = vi.spyOn(TestBed.inject(Router), 'navigate');
fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
expect(service.releaseOwnership).toHaveBeenCalledWith('/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership');
expect(navigate).toHaveBeenCalledWith(['/']);
});
it('on a 409 MessageResponse, shows the server message as a banner', async () => {
const { ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' }, {
releaseOwnership: vi
.fn()
.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 409, error: { message: 'Domain writes exist since adoption.' } }))),
});
await ready;
fixture = TestBed.createComponent(CaseDetailPage);
fixture.detectChanges();
fixture.nativeElement.querySelector('[data-testid="release-ownership-button"]').click();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('[data-testid="ownership-banner"]')?.textContent).toContain('Domain writes exist since adoption.');
});
}); });
}); });
@@ -1,10 +1,12 @@
import { HttpErrorResponse } from '@angular/common/http';
import { KeyValuePipe } from '@angular/common'; import { KeyValuePipe } from '@angular/common';
import { Component, inject, signal } from '@angular/core'; import { Component, inject, signal } from '@angular/core';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { InvariantViolationResponse, MessageResponse } from '../../shared/api-error.types';
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, toApplicantDetailsRequest } from '../case-detail.types'; import { ActionLink, CaseDetail, toApplicantDetailsRequest } from '../case-detail.types';
import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details'; import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-details';
@Component({ @Component({
@@ -15,9 +17,11 @@ import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-d
}) })
export class CaseDetailPage { export class CaseDetailPage {
private readonly route = inject(ActivatedRoute); private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly caseDetailService = inject(CaseDetailService); private readonly caseDetailService = inject(CaseDetailService);
readonly detail = signal<CaseDetail | null>(null); readonly detail = signal<CaseDetail | null>(null);
readonly banner = signal<string | null>(null);
readonly toApplicantDetailsRequest = toApplicantDetailsRequest; readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
constructor() { constructor() {
@@ -38,4 +42,35 @@ export class CaseDetailPage {
: this.caseDetailService.getOwnedDetail(current.registrationApplicationId!); : this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
source$.subscribe((updated) => this.detail.set(updated)); source$.subscribe((updated) => this.detail.set(updated));
} }
// Matches the placeholder's own confirm() dialog before this one-way,
// per-case migration step (ADR-003).
onTakeOwnership(link: ActionLink): void {
if (!confirm('Dit dossier in eigen beheer nemen?')) return;
this.banner.set(null);
this.caseDetailService.takeOwnership(link.href).subscribe({
next: (result) => this.router.navigate(['owned', result.registrationApplicationId]),
error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
});
}
onReleaseOwnership(link: ActionLink): void {
this.banner.set(null);
this.caseDetailService.releaseOwnership(link.href).subscribe({
next: () => this.router.navigate(['/']),
error: (error: HttpErrorResponse) => this.banner.set(this.describeError(error)),
});
}
private describeError(error: HttpErrorResponse): string {
if (error.status === 422) {
const body = error.error as InvariantViolationResponse;
return `${body.invariant}: ${body.message}`;
}
if (error.status === 409) {
const body = error.error as MessageResponse;
return body.message;
}
return 'Onbekende fout.';
}
} }