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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user