Mijn aanvragen (D): backend draft-sync + resume-by-link (registratie slice)
Replaces the registratie wizard's sessionStorage draft with a backend-owned Concept aanvraag (PRD 0001, phase D — the registratie vertical slice). - createDraftSync (registratie/application): reusable controller (field-initializer idiom, like createUploadController). Creates the Concept lazily on first progress, stamps `?aanvraag=<id>` into the URL, debounced-syncs the machine snapshot per change, and resumes from `?aanvraag` on load. Inert without a Router or when an explicit seed is present (Storybook/tests) — no network there. - hasProgress (machine, pure + spec): "worth persisting?" — excludes the automatic BRP address prefill so a bare page visit creates nothing. Accepted regression: a step-0-only address edit isn't persisted until the user advances/chooses. - Wizard: dropped STORAGE_KEY/restore + the sessionStorage effect; restart() detaches the Concept and drops the link. Deferred (noted): ApplicationsStore -> phase F (dashboard is its only consumer); intake-v3 + herregistratie persistence -> phase E (copy this pattern). Gates green: vitest 125, lint, build; backend unchanged (dotnet 56). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
105
src/app/registratie/application/draft-sync.ts
Normal file
105
src/app/registratie/application/draft-sync.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { DestroyRef, effect, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { AanvraagType } from '@registratie/domain/aanvraag';
|
||||||
|
import { ApplicationsAdapter } from '@registratie/infrastructure/applications.adapter';
|
||||||
|
|
||||||
|
/** What a wizard persists per step: the opaque machine snapshot + progress + docs. */
|
||||||
|
export interface DraftSnapshot {
|
||||||
|
draft: unknown;
|
||||||
|
stepIndex: number;
|
||||||
|
stepCount: number;
|
||||||
|
documentIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftSyncDeps {
|
||||||
|
type: AanvraagType;
|
||||||
|
/** The machine snapshot while it's worth persisting; null when not (pristine/done). */
|
||||||
|
snapshot: () => DraftSnapshot | null;
|
||||||
|
/** Seed the machine from a resumed draft (null → start fresh). Called once, on init. */
|
||||||
|
onResume: (draft: unknown | null) => void;
|
||||||
|
/** Draft-sync only runs in the real app — false in Storybook/tests (explicit seed). */
|
||||||
|
enabled: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 600; // ponytail: fixed debounce; tune if the sync feels laggy/chatty.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The effectful glue that replaces per-wizard sessionStorage with a backend-owned
|
||||||
|
* Concept (PRD 0001, phase D). Instantiated in a field initializer (like
|
||||||
|
* `createStore`/`createUploadController`). Responsibilities:
|
||||||
|
*
|
||||||
|
* - resume: if the URL carries `?aanvraag=<id>`, load that draft and seed the machine;
|
||||||
|
* - create-on-first-progress: the Concept is created lazily the first time the wizard
|
||||||
|
* reports a non-null snapshot, and the id is stamped into the URL (so a reload resumes);
|
||||||
|
* - debounced draft sync on every subsequent change.
|
||||||
|
*
|
||||||
|
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
|
||||||
|
*/
|
||||||
|
export function createDraftSync(deps: DraftSyncDeps) {
|
||||||
|
const adapter = inject(ApplicationsAdapter);
|
||||||
|
const router = inject(Router, { optional: true });
|
||||||
|
const route = inject(ActivatedRoute, { optional: true });
|
||||||
|
const active = () => deps.enabled() && !!router && !!route;
|
||||||
|
|
||||||
|
let id: string | undefined;
|
||||||
|
let ensuring: Promise<string> | undefined; // in-flight create, so we never create twice
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const ensureId = (): Promise<string> => {
|
||||||
|
if (id) return Promise.resolve(id);
|
||||||
|
ensuring ??= adapter.create(deps.type).then((newId) => {
|
||||||
|
id = newId;
|
||||||
|
// Stamp the id into the URL (no navigation) so a reload resumes this Concept.
|
||||||
|
void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: newId }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
|
return newId;
|
||||||
|
});
|
||||||
|
return ensuring;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flush = async () => {
|
||||||
|
const snap = deps.snapshot();
|
||||||
|
if (!snap) return;
|
||||||
|
const theId = await ensureId();
|
||||||
|
await adapter.syncDraft(theId, { draft: snap.draft, stepIndex: snap.stepIndex, stepCount: snap.stepCount, documentIds: snap.documentIds });
|
||||||
|
};
|
||||||
|
|
||||||
|
// One effect watches the snapshot; each change resets a debounce timer. The timer's
|
||||||
|
// callback only does network I/O (never dispatch), so it can't livelock the store.
|
||||||
|
effect(() => {
|
||||||
|
if (!active()) return;
|
||||||
|
const snap = deps.snapshot(); // tracked: fires on every machine change
|
||||||
|
if (!snap) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => void flush(), DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Resolve the initial state: resume a linked Concept, or start fresh. */
|
||||||
|
resume() {
|
||||||
|
if (!active()) {
|
||||||
|
deps.onResume(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Detach from the current Concept (a new one is created on next progress) and
|
||||||
|
drop the `?aanvraag` link. Used when the wizard restarts. */
|
||||||
|
reset() {
|
||||||
|
id = undefined;
|
||||||
|
ensuring = undefined;
|
||||||
|
if (active()) void router!.navigate([], { relativeTo: route!, queryParams: { aanvraag: null }, queryParamsHandling: 'merge', replaceUrl: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/app/registratie/domain/has-progress.spec.ts
Normal file
22
src/app/registratie/domain/has-progress.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { hasProgress, initial, RegistratieState } from './registratie-wizard.machine';
|
||||||
|
|
||||||
|
const invullen = (over: Partial<Extract<RegistratieState, { tag: 'Invullen' }>>) =>
|
||||||
|
({ ...(initial as Extract<RegistratieState, { tag: 'Invullen' }>), ...over });
|
||||||
|
|
||||||
|
describe('hasProgress', () => {
|
||||||
|
it('is false for a fresh wizard', () => {
|
||||||
|
expect(hasProgress(initial as Extract<RegistratieState, { tag: 'Invullen' }>)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an auto-prefilled BRP address at step 0', () => {
|
||||||
|
const s = invullen({ draft: { straat: 'Lange Voorhout 9', postcode: '2514 EA', woonplaats: 'Den Haag', adresHerkomst: 'brp', antwoorden: {} } });
|
||||||
|
expect(hasProgress(s)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is true once the user advances, picks correspondence/diploma, or is past step 0', () => {
|
||||||
|
expect(hasProgress(invullen({ cursor: 1 }))).toBe(true);
|
||||||
|
expect(hasProgress(invullen({ draft: { correspondentie: 'post', antwoorden: {} } }))).toBe(true);
|
||||||
|
expect(hasProgress(invullen({ draft: { diplomaId: 'd1', antwoorden: {} } }))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -91,6 +91,22 @@ export function currentStep(s: Extract<RegistratieState, { tag: 'Invullen' }>):
|
|||||||
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
return STEPS[Math.min(s.cursor, STEPS.length - 1)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Has the user meaningfully started, so it's worth persisting as a Concept? Excludes
|
||||||
|
the automatic BRP address prefill on step 0 — a bare page visit creates nothing.
|
||||||
|
ponytail: an address typed at step 0 without any of these signals is not yet
|
||||||
|
persisted (created once they advance/choose); accepted regression vs. sessionStorage. */
|
||||||
|
export function hasProgress(s: Extract<RegistratieState, { tag: 'Invullen' }>): boolean {
|
||||||
|
const d = s.draft;
|
||||||
|
return (
|
||||||
|
s.cursor > 0 ||
|
||||||
|
!!d.correspondentie ||
|
||||||
|
!!d.email ||
|
||||||
|
!!d.diplomaId ||
|
||||||
|
!!d.beroep ||
|
||||||
|
deliveryRefs(s.upload).some((r) => r.channel === 'digital' && !!r.documentId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
/** Validate every question currently visible in ONE step. Errors keyed per field. */
|
||||||
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
|
function validateStep(step: StepId, d: Draft, upload: UploadState): Result<Errors, void> {
|
||||||
const errors: Errors = {};
|
const errors: Errors = {};
|
||||||
|
|||||||
@@ -25,15 +25,16 @@ import {
|
|||||||
StepId,
|
StepId,
|
||||||
initial,
|
initial,
|
||||||
reduce,
|
reduce,
|
||||||
|
hasProgress,
|
||||||
STEPS,
|
STEPS,
|
||||||
} from '@registratie/domain/registratie-wizard.machine';
|
} from '@registratie/domain/registratie-wizard.machine';
|
||||||
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
import { submitRegistratie } from '@registratie/application/submit-registratie';
|
||||||
|
import { createDraftSync } from '@registratie/application/draft-sync';
|
||||||
import { ApiClient } from '@shared/infrastructure/api-client';
|
import { ApiClient } from '@shared/infrastructure/api-client';
|
||||||
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
import { DocumentUploadComponent } from '@shared/ui/upload/document-upload/document-upload.component';
|
||||||
import { createUploadController } from '@shared/upload/upload-controller';
|
import { createUploadController } from '@shared/upload/upload-controller';
|
||||||
import { UploadState, initialUpload } from '@shared/upload/upload.machine';
|
import { UploadState, initialUpload, deliveryRefs } from '@shared/upload/upload.machine';
|
||||||
|
|
||||||
const STORAGE_KEY = 'registratie-v2'; // ponytail: bump the suffix if the persisted shape changes; no migration.
|
|
||||||
const KANALEN = [
|
const KANALEN = [
|
||||||
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
{ value: 'email', label: $localize`:@@registratie.kanaalEmail:E-mail` },
|
||||||
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
{ value: 'post', label: $localize`:@@registratie.kanaalPost:Post` },
|
||||||
@@ -43,8 +44,9 @@ const HANDMATIG = '__handmatig__'; // sentinel option: "my diploma isn't listed"
|
|||||||
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
/** Organism: the BIG-registration wizard. All state lives in one signal driven by
|
||||||
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
the pure `reduce` (registratie-wizard.machine.ts). The BRP address prefills the
|
||||||
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
draft via an effect; the DUO diploma list renders through <app-async>; choosing
|
||||||
a diploma reveals its server-derived beroep. The draft is persisted to
|
a diploma reveals its server-derived beroep. The draft is persisted to the
|
||||||
sessionStorage so a reload keeps the user's progress (cleared on tab close). Built from existing
|
backend as a Concept aanvraag (createDraftSync) so a reload — or a "Verder gaan"
|
||||||
|
from the dashboard via `?aanvraag=<id>` — resumes progress. Built from existing
|
||||||
atoms/molecules. */
|
atoms/molecules. */
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-registratie-wizard',
|
selector: 'app-registratie-wizard',
|
||||||
@@ -207,6 +209,19 @@ export class RegistratieWizardComponent {
|
|||||||
getUpload: () => this.upload(),
|
getUpload: () => this.upload(),
|
||||||
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
dispatch: (msg) => this.dispatch({ tag: 'Upload', msg }),
|
||||||
});
|
});
|
||||||
|
// Backend draft-sync (replaces sessionStorage): create a Concept once the user has
|
||||||
|
// made progress, then debounced-sync the whole machine snapshot; resume by `?aanvraag`.
|
||||||
|
private draftSync = createDraftSync({
|
||||||
|
type: 'registratie',
|
||||||
|
snapshot: () => {
|
||||||
|
const s = this.state();
|
||||||
|
if (s.tag !== 'Invullen' || !hasProgress(s)) return null;
|
||||||
|
const documentIds = deliveryRefs(s.upload).filter((r) => r.channel === 'digital' && r.documentId).map((r) => r.documentId!);
|
||||||
|
return { draft: s, stepIndex: s.cursor, stepCount: STEPS.length, documentIds };
|
||||||
|
},
|
||||||
|
onResume: (draft) => this.dispatch({ tag: 'Seed', state: (draft as RegistratieState | null) ?? initial }),
|
||||||
|
enabled: () => this.seed() === initial,
|
||||||
|
});
|
||||||
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
protected step = computed<StepId>(() => STEPS[Math.min(this.cursor(), STEPS.length - 1)]);
|
||||||
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
protected stepTitle = computed(() => this.stepTitles[Math.min(this.cursor(), this.stepTitles.length - 1)]);
|
||||||
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
protected referentie = computed(() => whenTag(this.state(), 'Ingediend')?.referentie ?? '');
|
||||||
@@ -315,16 +330,10 @@ export class RegistratieWizardComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// An explicit seed (stories) wins; otherwise resume from sessionStorage.
|
// An explicit seed (stories/tests) wins; otherwise resume from the backend draft
|
||||||
|
// (`?aanvraag=<id>`), or start fresh. Persistence is the draftSync controller's job.
|
||||||
const seeded = this.seed();
|
const seeded = this.seed();
|
||||||
queueMicrotask(() => this.dispatch({ tag: 'Seed', state: seeded !== initial ? seeded : (this.restore() ?? initial) }));
|
queueMicrotask(() => (seeded !== initial ? this.dispatch({ tag: 'Seed', state: seeded }) : this.draftSync.resume()));
|
||||||
// Persist only while filling in; clear once the flow is done. G1: sessionStorage
|
|
||||||
// (not localStorage) — the draft holds address/email and must not outlive the tab.
|
|
||||||
effect(() => {
|
|
||||||
const s = this.state();
|
|
||||||
if (s.tag === 'Invullen') sessionStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
|
||||||
else sessionStorage.removeItem(STORAGE_KEY);
|
|
||||||
});
|
|
||||||
// Prefill the address from the BRP lookup as it arrives. Track only the resource
|
// Prefill the address from the BRP lookup as it arrives. Track only the resource
|
||||||
// value; untrack the dispatch (it reads the state signal, which would otherwise
|
// value; untrack the dispatch (it reads the state signal, which would otherwise
|
||||||
// make this effect loop on its own write). Don't clobber edits/restored data.
|
// make this effect loop on its own write). Don't clobber edits/restored data.
|
||||||
@@ -346,17 +355,6 @@ export class RegistratieWizardComponent {
|
|||||||
// failed submit) now lives in the shared WizardShellComponent.
|
// failed submit) now lives in the shared WizardShellComponent.
|
||||||
}
|
}
|
||||||
|
|
||||||
private restore(): RegistratieState | null {
|
|
||||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as RegistratieState;
|
|
||||||
return parsed?.tag === 'Invullen' ? parsed : null; // G2: only resume a known shape.
|
|
||||||
} catch {
|
|
||||||
return null; // ponytail: corrupt entry -> start fresh, no migration.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onPrimary() {
|
onPrimary() {
|
||||||
const s = this.state();
|
const s = this.state();
|
||||||
if (s.tag !== 'Invullen') return;
|
if (s.tag !== 'Invullen') return;
|
||||||
@@ -372,6 +370,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.dispatch({ tag: 'Seed', state: initial });
|
this.dispatch({ tag: 'Seed', state: initial });
|
||||||
this.adresRes.reload();
|
this.adresRes.reload();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user