Fix Mijn aanvragen: instant cancel + one Concept per type (resume)
Two dashboard bugs from the just-built feature. 1. Cancel didn't reflect until a browser refresh. ApplicationsStore now OWNS the list in a writable RemoteData signal instead of projecting a resource() through fromResource; cancel removes the row synchronously (guaranteed disappear, no dependence on CD timing / HTTP cache / the reloading gap), then confirms the DELETE (rollback on failure, no resync). Adapter gains list(); applicationsResource() removed. Shared fromResource/remote-data.ts deliberately untouched. 2. Duplicate / inconsistent Concepts per type. createDraftSync.resume() now: a ?aanvraag link wins; else it resumes THIS type's existing Concept (loads its draft); else fresh. ensureId is gated behind resume so a fast typist can't create a duplicate before the lookup lands. restart()/reset() deletes the current Concept (submitted → 409, kept) so there's at most one active Concept per type. A non-Concept id can't reopen as an editable draft. Backend unchanged. Gates green: lint, vitest 128, build, check:tokens, backend dotnet 56. Wiring is not unit-covered — needs live verification (see plan). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
import { RemoteData, fromResource } from '@shared/application/remote-data';
|
import { RemoteData } from '@shared/application/remote-data';
|
||||||
import { Aanvraag } from '@registratie/domain/aanvraag';
|
import { Aanvraag } from '@registratie/domain/aanvraag';
|
||||||
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
@@ -7,45 +7,52 @@ type Err = Error | undefined;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The dashboard's view of the user's applications (aanvragen) — the backend is the
|
* The dashboard's view of the user's applications (aanvragen) — the backend is the
|
||||||
* system of record (PRD 0001). One root singleton owns the list resource and exposes
|
* system of record (PRD 0001). One root singleton OWNS the list as a writable
|
||||||
* it as a RemoteData signal, validated at the trust boundary (DTO → domain). Cancel
|
* RemoteData signal (CLAUDE.md §3: change state only through methods). Cancel removes
|
||||||
* is optimistic (hide immediately, reload to confirm, un-hide on failure); `reload`
|
* the row SYNCHRONOUSLY, so the block disappears deterministically — no dependence on
|
||||||
* re-fetches so a page revisit reflects auto-approval (Concept → In behandeling →
|
* change-detection timing, HTTP caching, or a resource `reload()`. `reload()` re-fetches
|
||||||
* Goedgekeurd is computed server-side on read).
|
* so a page revisit reflects auto-approval (Concept → In behandeling → Goedgekeurd is
|
||||||
|
* computed server-side on read).
|
||||||
*/
|
*/
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class ApplicationsStore {
|
export class ApplicationsStore {
|
||||||
private adapter = inject(ApplicationsAdapter);
|
private adapter = inject(ApplicationsAdapter);
|
||||||
private res = this.adapter.applicationsResource();
|
|
||||||
/** Ids optimistically hidden while their cancel is in flight (or done). */
|
|
||||||
private cancelling = signal<ReadonlySet<string>>(new Set());
|
|
||||||
|
|
||||||
readonly applications = computed<RemoteData<Err, Aanvraag[]>>(() => {
|
private state = signal<RemoteData<Err, Aanvraag[]>>({ tag: 'Loading' });
|
||||||
const rd = fromResource(this.res);
|
readonly applications = this.state.asReadonly();
|
||||||
if (rd.tag !== 'Success') return rd;
|
|
||||||
const parsed = parseApplications(rd.value ?? []);
|
constructor() {
|
||||||
if (!parsed.ok) return { tag: 'Failure', error: new Error(parsed.error) };
|
void this.load();
|
||||||
const hidden = this.cancelling();
|
}
|
||||||
return { tag: 'Success', value: parsed.value.filter((a) => !hidden.has(a.id)) };
|
|
||||||
});
|
/** 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() {
|
||||||
|
if (this.state().tag !== 'Success') this.state.set({ tag: 'Loading' });
|
||||||
|
try {
|
||||||
|
const parsed = parseApplications(await this.adapter.list());
|
||||||
|
this.state.set(parsed.ok ? { tag: 'Success', value: parsed.value } : { tag: 'Failure', error: new Error(parsed.error) });
|
||||||
|
} catch (e) {
|
||||||
|
this.state.set({ tag: 'Failure', error: e as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */
|
/** Re-fetch (e.g. on dashboard revisit) so auto-approval transitions show up. */
|
||||||
reload() {
|
reload() {
|
||||||
this.res.reload();
|
void this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Cancel a Concept: hide it optimistically, then confirm (reload) or roll back. */
|
/** Cancel a Concept: drop it now (synchronous, guaranteed), then confirm the DELETE.
|
||||||
|
No resync — the delete succeeded, so the optimistic removal is authoritative. */
|
||||||
async cancel(id: string) {
|
async cancel(id: string) {
|
||||||
this.cancelling.update((s) => new Set(s).add(id));
|
const before = this.state();
|
||||||
|
if (before.tag === 'Success') {
|
||||||
|
this.state.set({ tag: 'Success', value: before.value.filter((a) => a.id !== id) });
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await this.adapter.cancel(id);
|
await this.adapter.cancel(id);
|
||||||
this.res.reload(); // the reloaded list no longer contains it; the hide is now a no-op
|
|
||||||
} catch {
|
} catch {
|
||||||
this.cancelling.update((s) => {
|
this.state.set(before); // roll back: the block reappears
|
||||||
const next = new Set(s);
|
|
||||||
next.delete(id);
|
|
||||||
return next; // roll back: the block reappears
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Result } from '@shared/kernel/fp';
|
|||||||
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
||||||
import { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
|
import { SubmitApplicationRequest, SubmitApplicationResponse } from '@shared/infrastructure/api-client';
|
||||||
import { AanvraagType } from '@registratie/domain/aanvraag';
|
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
import { ApplicationsAdapter, parseApplications } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||||
export interface DraftSnapshot {
|
export interface DraftSnapshot {
|
||||||
@@ -31,9 +31,10 @@ const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels lag
|
|||||||
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
|
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
|
||||||
* `createStore`/`createUploadController`). Responsibilities:
|
* `createStore`/`createUploadController`). Responsibilities:
|
||||||
*
|
*
|
||||||
* - resume: if the URL carries `?aanvraag=<id>`, load that draft and seed the machine;
|
* - resume: a `?aanvraag=<id>` link wins; otherwise resume the ONE existing Concept of
|
||||||
* - create-on-first-progress: the Concept is created lazily the first time the wizard
|
* this type (at most one per type), seeding the machine from its saved draft;
|
||||||
* reports a non-null snapshot, and the id is stamped into the URL (so a reload resumes);
|
* - create-on-first-progress: when no Concept exists, one is created lazily the first
|
||||||
|
* time the wizard reports a non-null snapshot, and its id is stamped into the URL;
|
||||||
* - debounced draft sync on every subsequent change.
|
* - debounced draft sync on every subsequent change.
|
||||||
*
|
*
|
||||||
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
|
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
|
||||||
@@ -47,9 +48,13 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
let id: string | undefined;
|
let id: string | undefined;
|
||||||
let ensuring: Promise<string> | undefined; // in-flight create, so we never create twice
|
let ensuring: Promise<string> | undefined; // in-flight create, so we never create twice
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
// Resolves once resume() has decided whether a Concept of this type already exists;
|
||||||
|
// gates ensureId so a fast typist can't create a duplicate before that lookup lands.
|
||||||
|
let resumeGate: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
const ensureId = (): Promise<string> => {
|
const ensureId = async (): Promise<string> => {
|
||||||
if (id) return Promise.resolve(id);
|
await resumeGate;
|
||||||
|
if (id) return id;
|
||||||
ensuring ??= adapter.create(deps.type).then((newId) => {
|
ensuring ??= adapter.create(deps.type).then((newId) => {
|
||||||
id = newId;
|
id = newId;
|
||||||
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
|
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
|
||||||
@@ -78,23 +83,63 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
|
|
||||||
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
||||||
|
|
||||||
|
// Attach to a specific Concept id and seed the machine from its draft. A non-Concept
|
||||||
|
// (submitted/gone) id is treated as fresh so it can't reopen as an editable draft.
|
||||||
|
const load = (linked: string): Promise<void> => {
|
||||||
|
id = linked;
|
||||||
|
return adapter
|
||||||
|
.detail(linked)
|
||||||
|
.then((dto) => {
|
||||||
|
if (dto.status && dto.status.tag !== 'Concept') {
|
||||||
|
id = undefined;
|
||||||
|
deps.onResume(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deps.onResume(dto.draft ?? null);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
id = undefined;
|
||||||
|
deps.onResume(null); // unknown/deleted id → start fresh
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the user's existing Concept of this type (at most one), if any.
|
||||||
|
const findConcept = async (): Promise<string | undefined> => {
|
||||||
|
try {
|
||||||
|
const parsed = parseApplications(await adapter.list());
|
||||||
|
return parsed.ok ? parsed.value.find((a) => a.type === deps.type && a.status.tag === 'Concept')?.id : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
/** Resolve the initial state: resume a linked Concept, or start fresh. */
|
/** Resolve the initial state: a `?aanvraag` link wins; else resume this type's
|
||||||
resume() {
|
existing Concept; else start fresh (a Concept is created on first progress). */
|
||||||
if (!active()) {
|
async resume() {
|
||||||
|
let release!: () => void;
|
||||||
|
resumeGate = new Promise<void>((r) => (release = r));
|
||||||
|
try {
|
||||||
|
if (!active()) {
|
||||||
|
deps.onResume(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const linked = route!.snapshot.queryParamMap.get('aanvraag');
|
||||||
|
if (linked) {
|
||||||
|
await load(linked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = await findConcept();
|
||||||
|
if (existing) {
|
||||||
|
await load(existing);
|
||||||
|
// Stamp the id into the URL so a reload resumes the same Concept.
|
||||||
|
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: existing }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.onResume(null);
|
deps.onResume(null);
|
||||||
return;
|
} finally {
|
||||||
|
release();
|
||||||
}
|
}
|
||||||
const linked = route!.snapshot.queryParamMap.get('aanvraag');
|
|
||||||
if (!linked) {
|
|
||||||
deps.onResume(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
id = linked;
|
|
||||||
adapter
|
|
||||||
.detail(linked)
|
|
||||||
.then((dto) => deps.onResume(dto.draft ?? null))
|
|
||||||
.catch(() => deps.onResume(null)); // unknown/deleted id → start fresh
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
|
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
|
||||||
@@ -104,11 +149,16 @@ export function createDraftSync(deps: DraftSyncDeps) {
|
|||||||
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
|
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Detach from the current Concept (a new one is created on next progress) and
|
/** Restart: discard the current in-progress Concept (delete it) and detach, so a
|
||||||
drop the `?aanvraag` link. Used when the wizard restarts. */
|
fresh one is created on next progress. Keeps the one-per-type invariant. A
|
||||||
|
submitted id can't be deleted (409, caught) — that submission correctly remains,
|
||||||
|
and detaching still lets the user start a new Concept. */
|
||||||
reset() {
|
reset() {
|
||||||
id = undefined;
|
if (id) {
|
||||||
ensuring = undefined;
|
void adapter.cancel(id).catch(() => {}); // Concept → deleted; submitted → 409, kept
|
||||||
|
id = undefined;
|
||||||
|
ensuring = undefined;
|
||||||
|
}
|
||||||
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable, inject, resource } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Result, ok, err } from '@shared/kernel/fp';
|
import { Result, ok, err } from '@shared/kernel/fp';
|
||||||
import {
|
import {
|
||||||
ApiClient,
|
ApiClient,
|
||||||
@@ -23,8 +23,8 @@ export class ApplicationsAdapter {
|
|||||||
private client = inject(ApiClient);
|
private client = inject(ApiClient);
|
||||||
|
|
||||||
/** The dashboard's application list (raw DTOs; the store parses at the boundary). */
|
/** The dashboard's application list (raw DTOs; the store parses at the boundary). */
|
||||||
applicationsResource() {
|
list(): Promise<ApplicationSummaryDto[]> {
|
||||||
return resource({ loader: () => this.client.applicationsAll() });
|
return this.client.applicationsAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
detail(id: string): Promise<ApplicationDetailDto> {
|
detail(id: string): Promise<ApplicationDetailDto> {
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ export class RegistratieWizardComponent {
|
|||||||
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
/** Reset the wizard to a fresh start. Reload the BRP lookup so the address
|
||||||
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
|
re-prefills, keeping the form and the "vooraf ingevuld" note consistent. */
|
||||||
restart() {
|
restart() {
|
||||||
this.draftSync.reset(); // detach from the old Concept; next progress creates a new one
|
this.draftSync.reset(); // discard the current Concept; a fresh one starts on next progress
|
||||||
this.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
this.adresRes.reload();
|
this.adresRes.reload();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user