Merge RB-20 — route cancel and delete through runSubmit, surface the error
CQ-002: ApplicationsStore.cancel and AdminCasesStore.delete reached the raw ApiClient and swallowed the failure in a bare catch, so a failed cancel made the row reappear with no message. Both now fold through runSubmit and expose lastError, which the two pages render. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> # Conflicts: # docs/project/refactor-backlog-setup/refactor-backlog/99-backlog.md # libs/shared/docs/behaviour-spec.mdx
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { AdminCasesStore } from './admin-cases.store';
|
||||
|
||||
@@ -43,7 +44,10 @@ describe('AdminCasesStore', () => {
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('rolls back the removal when the delete fails', async () => {
|
||||
// RB-20: a failed delete must not be silent — the row rolls back AND the store
|
||||
// surfaces the error the page renders. Before RB-20 this only rolled back
|
||||
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
||||
it('rolls back the removal and surfaces the error when the delete fails', async () => {
|
||||
const deleteAny = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const store = setup({ listAll: () => Promise.resolve([summary('a')]), deleteAny });
|
||||
await store.load();
|
||||
@@ -51,5 +55,24 @@ describe('AdminCasesStore', () => {
|
||||
await store.delete('a');
|
||||
const s = store.cases();
|
||||
expect(s.tag === 'Success' && s.value.map((c) => c.id)).toEqual(['a']); // reappears
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
});
|
||||
|
||||
it('clears a stale error on the next delete attempt', async () => {
|
||||
const deleteAny = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = setup({
|
||||
listAll: () => Promise.resolve([summary('a'), summary('b')]),
|
||||
deleteAny,
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.delete('a');
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
|
||||
await store.delete('b');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
@@ -12,8 +13,9 @@ type Err = Error | undefined;
|
||||
* Admin view of ALL cases across owners (WP-36; `cases:manage`) — the back-office
|
||||
* counterpart of the user-facing `ApplicationsStore`. Same shape: one root singleton
|
||||
* owns the list as a writable RemoteData signal, delete removes the row synchronously
|
||||
* (optimistic) and rolls back on error. Admin delete removes any case (any owner,
|
||||
* submitted or not — the server enforces the capability).
|
||||
* (optimistic), goes through `runSubmit`, and rolls back plus surfaces `lastError` on
|
||||
* failure (RB-20). Admin delete removes any case (any owner, submitted or not — the
|
||||
* server enforces the capability).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AdminCasesStore {
|
||||
@@ -22,6 +24,11 @@ export class AdminCasesStore {
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly cases = this.state.asReadonly();
|
||||
|
||||
/** Set on a failed delete (RB-20): the optimistic removal already rolled back by
|
||||
then, this is only the message for the alert the page renders above the list. */
|
||||
private error = signal<string | null>(null);
|
||||
readonly lastError = this.error.asReadonly();
|
||||
|
||||
/** Fetch + parse at the trust boundary, then publish as RemoteData. Keeps the
|
||||
last-good value on a resync (only shows Loading on the first load). */
|
||||
async load() {
|
||||
@@ -42,16 +49,18 @@ export class AdminCasesStore {
|
||||
void this.load();
|
||||
}
|
||||
|
||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error. */
|
||||
/** Delete a case: drop it now (synchronous), then confirm the DELETE; roll back on error
|
||||
AND surface it (RB-20) — a silent reappearance leaves the admin guessing why. */
|
||||
async delete(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.deleteAny(id);
|
||||
} catch {
|
||||
this.error.set(null);
|
||||
const r = await runSubmit(() => this.adapter.deleteAny(id), SUBMIT_FAILED);
|
||||
if (!r.ok) {
|
||||
this.state.set(before); // roll back: the row reappears
|
||||
this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||
import { ApplicationsStore } from './applications.store';
|
||||
|
||||
const summary = (id: string) => ({
|
||||
id,
|
||||
type: 'registratie',
|
||||
status: { tag: 'Concept', stepIndex: 0, stepCount: 3 },
|
||||
documentIds: [],
|
||||
createdAt: '2026-07-23T10:00:00Z',
|
||||
updatedAt: '2026-07-23T10:00:00Z',
|
||||
});
|
||||
|
||||
function setup(adapter: Partial<ApplicationsAdapter>): ApplicationsStore {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: ApplicationsAdapter, useValue: adapter }],
|
||||
});
|
||||
// The store's own constructor kicks off `load()` (dashboard revisit refresh) —
|
||||
// give every test a `list` so that initial call has something to resolve.
|
||||
return TestBed.inject(ApplicationsStore);
|
||||
}
|
||||
|
||||
describe('ApplicationsStore', () => {
|
||||
it('loads and parses the list', async () => {
|
||||
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]) });
|
||||
await store.load();
|
||||
const s = store.applications();
|
||||
expect(s.tag).toBe('Success');
|
||||
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('cancels optimistically and confirms via the DELETE endpoint', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const store = setup({
|
||||
list: () => Promise.resolve([summary('a'), summary('b')]),
|
||||
cancel,
|
||||
});
|
||||
await store.load();
|
||||
|
||||
await store.cancel('a');
|
||||
expect(cancel).toHaveBeenCalledWith('a');
|
||||
const s = store.applications();
|
||||
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['b']);
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
|
||||
// RB-20: a failed cancel must not be silent — the row rolls back AND the store
|
||||
// surfaces the error the page renders. Before RB-20 this only rolled back
|
||||
// (bare `catch { this.state.set(before) }`), so `lastError()` stayed null forever.
|
||||
it('rolls back the removal and surfaces the error when the cancel fails', async () => {
|
||||
const cancel = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
const store = setup({ list: () => Promise.resolve([summary('a')]), cancel });
|
||||
await store.load();
|
||||
|
||||
await store.cancel('a');
|
||||
const s = store.applications();
|
||||
expect(s.tag === 'Success' && s.value.map((a) => a.id)).toEqual(['a']); // reappears
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
});
|
||||
|
||||
it('clears a stale error on the next cancel attempt', async () => {
|
||||
const cancel = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error('boom'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = setup({ list: () => Promise.resolve([summary('a'), summary('b')]), cancel });
|
||||
await store.load();
|
||||
|
||||
await store.cancel('a');
|
||||
expect(store.lastError()).toBe(SUBMIT_FAILED);
|
||||
|
||||
await store.cancel('b');
|
||||
expect(store.lastError()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { RemoteData } from '@shared/application/remote-data';
|
||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||
import {
|
||||
ApplicationsAdapter,
|
||||
@@ -15,7 +16,8 @@ type Err = Error | undefined;
|
||||
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
|
||||
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
||||
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
||||
* computed server-side on read).
|
||||
* computed server-side on read). Cancel goes through `runSubmit` and rolls back plus
|
||||
* surfaces `lastError` on failure (RB-20).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApplicationsStore {
|
||||
@@ -24,6 +26,11 @@ export class ApplicationsStore {
|
||||
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||
readonly applications = this.state.asReadonly();
|
||||
|
||||
/** Set on a failed cancel (RB-20): the optimistic removal already rolled back by
|
||||
then, this is only the message for the alert the page renders above the list. */
|
||||
private error = signal<string | null>(null);
|
||||
readonly lastError = this.error.asReadonly();
|
||||
|
||||
constructor() {
|
||||
void this.load();
|
||||
}
|
||||
@@ -50,16 +57,19 @@ export class ApplicationsStore {
|
||||
}
|
||||
|
||||
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
||||
No resync — the delete succeeded, so the optimistic removal is authoritative. */
|
||||
No resync — the delete succeeded, so the optimistic removal is authoritative. On
|
||||
failure, roll back AND surface the error (RB-20) — a silent reappearance leaves the
|
||||
user guessing why the block came back. */
|
||||
async cancel(id: string) {
|
||||
const before = this.state();
|
||||
if (before.tag === 'Success') {
|
||||
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||
}
|
||||
try {
|
||||
await this.adapter.cancel(id);
|
||||
} catch {
|
||||
this.error.set(null);
|
||||
const r = await runSubmit(() => this.adapter.cancel(id), SUBMIT_FAILED);
|
||||
if (!r.ok) {
|
||||
this.state.set(before); // roll back: the block reappears
|
||||
this.error.set(r.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@ import { AdminCasesStore } from '@registratie/application/admin-cases.store';
|
||||
} @else if (!canManage()) {
|
||||
<app-alert type="error">{{ deniedText }}</app-alert>
|
||||
} @else {
|
||||
@if (store.lastError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
<app-async [data]="store.cases()">
|
||||
<ng-template appAsyncError>
|
||||
<app-alert type="error">{{ failedText }}</app-alert>
|
||||
|
||||
@@ -51,6 +51,9 @@ import { tasksFromProfile } from '@registratie/domain/tasks';
|
||||
intro="Welkom in uw persoonlijke omgeving van het BIG-register. Hier ziet u uw registratie en regelt u uw zaken."
|
||||
>
|
||||
<div class="app-stack">
|
||||
@if (cancelError(); as err) {
|
||||
<app-alert type="error">{{ err }}</app-alert>
|
||||
}
|
||||
@if (aanvragen().length) {
|
||||
<section>
|
||||
@for (a of concepten(); track a.id) {
|
||||
@@ -260,6 +263,8 @@ export class DashboardPage {
|
||||
protected cancelAanvraag(a: Aanvraag) {
|
||||
void this.apps.cancel(a.id);
|
||||
}
|
||||
/** RB-20: the message from a failed cancel, rendered above the list. */
|
||||
protected cancelError = computed(() => this.apps.lastError());
|
||||
|
||||
/** Server-computed eligibility (rendered, not recomputed). */
|
||||
private readonly eligible = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user