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:
@@ -73,7 +73,15 @@
|
||||
|
||||
<section>
|
||||
<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>
|
||||
} @else {
|
||||
<p>Laden...</p>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ActivatedRoute, convertToParamMap } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ActivatedRoute, Router, convertToParamMap, provideRouter } from '@angular/router';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CaseDetailService } from '../case-detail.service';
|
||||
import { CaseDetail } from '../case-detail.types';
|
||||
@@ -9,7 +10,7 @@ import { CaseDetailPage } from './case-detail';
|
||||
|
||||
const legacyDetail: CaseDetail = {
|
||||
origin: 'Legacy',
|
||||
legacyAanvraagId: 1001,
|
||||
legacyAanvraagId: 1002,
|
||||
registrationApplicationId: null,
|
||||
surname: 'de Vries',
|
||||
initials: 'A.',
|
||||
@@ -26,54 +27,162 @@ const legacyDetail: CaseDetail = {
|
||||
processStatus: null,
|
||||
lastModifiedAt: null,
|
||||
actions: {
|
||||
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1001/details' },
|
||||
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1001/beoordeling' },
|
||||
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1001/take-ownership' },
|
||||
editApplicantDetails: { mode: 'writeThrough', href: '/api/worklist/legacy/1002/details' },
|
||||
recordAssessment: { mode: 'redirect', href: '/legacy/aanvraag/1002/beoordeling' },
|
||||
takeOwnership: { mode: 'transition', href: '/api/worklist/legacy/1002/take-ownership' },
|
||||
},
|
||||
seams: { aanvrager: 'legacy-backend', procestijdlijn: null },
|
||||
};
|
||||
|
||||
function configure(routeConfigPath: string, paramMap: Record<string, string>, getLegacyDetail: unknown, getOwnedDetail: unknown) {
|
||||
return TestBed.configureTestingModule({
|
||||
imports: [CaseDetailPage],
|
||||
providers: [
|
||||
{ provide: CaseDetailService, useValue: { getLegacyDetail, getOwnedDetail } },
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
paramMap: of(convertToParamMap(paramMap)),
|
||||
snapshot: { routeConfig: { path: routeConfigPath } },
|
||||
const ownedDetail: CaseDetail = {
|
||||
...legacyDetail,
|
||||
origin: 'Owned',
|
||||
legacyAanvraagId: null,
|
||||
registrationApplicationId: '00000000-0000-0000-0000-000000000002',
|
||||
actions: {
|
||||
editApplicantDetails: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/details' },
|
||||
recordAssessment: { mode: 'owned', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/assessment' },
|
||||
releaseOwnership: { mode: 'transition', href: '/api/worklist/owned/00000000-0000-0000-0000-000000000002/ownership' },
|
||||
},
|
||||
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', () => {
|
||||
let fixture: ComponentFixture<CaseDetailPage>;
|
||||
|
||||
it('given a legacy/:id route, fetches via getLegacyDetail and renders the case', async () => {
|
||||
const getLegacyDetail = vi.fn().mockReturnValue(of(legacyDetail));
|
||||
const getOwnedDetail = vi.fn();
|
||||
await configure('legacy/:id', { id: '1001' }, getLegacyDetail, getOwnedDetail);
|
||||
const { service, ready } = configure('legacy/:id', { id: '1002' });
|
||||
await ready;
|
||||
|
||||
fixture = TestBed.createComponent(CaseDetailPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getLegacyDetail).toHaveBeenCalledWith('1001');
|
||||
expect(getOwnedDetail).not.toHaveBeenCalled();
|
||||
expect(service.getLegacyDetail).toHaveBeenCalledWith('1002');
|
||||
expect(service.getOwnedDetail).not.toHaveBeenCalled();
|
||||
expect(fixture.nativeElement.textContent).toContain('de Vries');
|
||||
});
|
||||
|
||||
it('given an owned/:id route, fetches via getOwnedDetail', async () => {
|
||||
const getLegacyDetail = vi.fn();
|
||||
const getOwnedDetail = vi.fn().mockReturnValue(of({ ...legacyDetail, origin: 'Owned', legacyAanvraagId: null, registrationApplicationId: 'abc' }));
|
||||
await configure('owned/:id', { id: 'abc' }, getLegacyDetail, getOwnedDetail);
|
||||
const { service, ready } = configure('owned/:id', { id: '00000000-0000-0000-0000-000000000002' });
|
||||
await ready;
|
||||
|
||||
fixture = TestBed.createComponent(CaseDetailPage);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getOwnedDetail).toHaveBeenCalledWith('abc');
|
||||
expect(getLegacyDetail).not.toHaveBeenCalled();
|
||||
expect(service.getOwnedDetail).toHaveBeenCalledWith('00000000-0000-0000-0000-000000000002');
|
||||
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 { 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 { 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';
|
||||
|
||||
@Component({
|
||||
@@ -15,9 +17,11 @@ import { EditApplicantDetails } from '../edit-applicant-details/edit-applicant-d
|
||||
})
|
||||
export class CaseDetailPage {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly router = inject(Router);
|
||||
private readonly caseDetailService = inject(CaseDetailService);
|
||||
|
||||
readonly detail = signal<CaseDetail | null>(null);
|
||||
readonly banner = signal<string | null>(null);
|
||||
readonly toApplicantDetailsRequest = toApplicantDetailsRequest;
|
||||
|
||||
constructor() {
|
||||
@@ -38,4 +42,35 @@ export class CaseDetailPage {
|
||||
: this.caseDetailService.getOwnedDetail(current.registrationApplicationId!);
|
||||
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.';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user