204 WP-NN/RB-NN comments named a closed ticket instead of the code they sit next to. git blame already records history and stays correct when code moves; the comment does not. This sweep removes the reference and keeps the sentence, across 95 files in apps/ and libs/ plus the behaviour-spec generator's header text. Eleven references stay: five story files justify an a11y disable per the README's rule, and one line in a11y.mdx documents that convention. Two sentences needed a rewrite, not a deletion, so the reference's meaning survives its removal. behaviour-spec.mdx is regenerated, not hand-edited. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
217 lines
8.3 KiB
TypeScript
217 lines
8.3 KiB
TypeScript
import { DestroyRef, effect, inject } from '@angular/core';
|
|
import { ActivatedRoute, Router } from '@angular/router';
|
|
import { Result } from '@shared/kernel/fp';
|
|
import { runSubmit, SUBMIT_FAILED } from '@shared/application/submit';
|
|
import { registerPendingSave } from '@shared/application/pending-saves';
|
|
import type {
|
|
AanvraagIndienenRequest,
|
|
AanvraagIndienenResponse,
|
|
} from '@shared/infrastructure/api-client';
|
|
import { AanvraagType } from '@registratie/domain/aanvraag';
|
|
import { AanvragenAdapter } from '@registratie/infrastructure/aanvragen.adapter';
|
|
import { findConcept, loadConcept } from './find-concept';
|
|
|
|
/** 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. Called at most once, on init, and ONLY
|
|
with a real draft on a still-pristine machine — see `applyResume`. */
|
|
onResume: (draft: unknown) => 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: a `?aanvraag=<id>` link wins; otherwise resume the ONE existing Concept of
|
|
* this type (at most one per type), seeding the machine from its saved draft;
|
|
* - 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.
|
|
*
|
|
* Inert without a Router (stories) or when `enabled()` is false — no network, no resume.
|
|
*/
|
|
export function createDraftSync(deps: DraftSyncDeps) {
|
|
const adapter = inject(AanvragenAdapter);
|
|
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;
|
|
// 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 = async (): Promise<string> => {
|
|
await resumeGate;
|
|
if (id) return id;
|
|
ensuring ??= adapter
|
|
.create(deps.type)
|
|
// One Concept per type is server-enforced. Within a tab the resumeGate
|
|
// already prevents a second create, but a cross-tab/stale race can still hit the
|
|
// server's guard (409) — recover by adopting the existing Concept instead of
|
|
// erroring. Only recover when one actually exists; otherwise surface the failure.
|
|
.catch(async (e) => {
|
|
const existing = await findConcept(adapter, deps.type);
|
|
if (existing) return existing;
|
|
throw e;
|
|
})
|
|
.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;
|
|
};
|
|
|
|
// Apply a resumed draft only when it's safe to: a late lookup must never clobber
|
|
// progress the user already made while it was in flight, and "start fresh" needs no
|
|
// dispatch (the machine already starts fresh). snapshot() is non-null once the user
|
|
// has real progress.
|
|
const applyResume = (draft: unknown | null) => {
|
|
if (draft == null || deps.snapshot() != null) return;
|
|
deps.onResume(draft);
|
|
};
|
|
|
|
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);
|
|
// Null the handle when it fires so `hasPendingSave()` reflects "a write is still owed".
|
|
timer = setTimeout(() => {
|
|
timer = undefined;
|
|
void flush();
|
|
}, DEBOUNCE_MS);
|
|
});
|
|
|
|
inject(DestroyRef).onDestroy(() => timer && clearTimeout(timer));
|
|
|
|
// Flush a pending debounced draft write before an in-app route change / unload (see
|
|
// pending-saves.ts). onDestroy above only cancels the timer — this actually persists it.
|
|
const hasPendingSave = () => timer !== undefined;
|
|
const flushPending = async () => {
|
|
if (timer === undefined) return;
|
|
clearTimeout(timer);
|
|
timer = undefined;
|
|
await flush();
|
|
};
|
|
registerPendingSave({ hasPendingSave, flushPending });
|
|
|
|
// 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 loadConcept(adapter, linked).then((result) => {
|
|
if (result.tag === 'not-concept') {
|
|
id = undefined;
|
|
applyResume(null);
|
|
return;
|
|
}
|
|
applyResume(result.draft);
|
|
});
|
|
};
|
|
|
|
return {
|
|
/** True while a debounced draft write is still pending (PendingSave). */
|
|
hasPendingSave,
|
|
/** Flush the pending draft write now and await it; no-op when nothing is pending. */
|
|
flushPending,
|
|
|
|
/** Resolve the initial state: a `?aanvraag` link wins; else resume this type's
|
|
existing Concept; else start fresh (a Concept is created on first progress). */
|
|
async resume() {
|
|
let release!: () => void;
|
|
resumeGate = new Promise<void>((r) => (release = r));
|
|
try {
|
|
if (!active()) {
|
|
applyResume(null);
|
|
return;
|
|
}
|
|
const linked = route!.snapshot.queryParamMap.get('aanvraag');
|
|
if (linked) {
|
|
await load(linked);
|
|
return;
|
|
}
|
|
const existing = await findConcept(adapter, deps.type);
|
|
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;
|
|
}
|
|
applyResume(null);
|
|
} finally {
|
|
release();
|
|
}
|
|
},
|
|
|
|
/** Submit through the aanvraag lifecycle: ensure the Concept exists, then
|
|
`POST /applications/{id}/submit` (server sets autoApprovable + transitions).
|
|
Folded into a Result like the old submit-* commands. */
|
|
submit(body: AanvraagIndienenRequest): Promise<Result<string, AanvraagIndienenResponse>> {
|
|
return runSubmit(async () => adapter.submit(await ensureId(), body), SUBMIT_FAILED);
|
|
},
|
|
|
|
/** Restart: discard the current in-progress Concept (delete it) and detach, so a
|
|
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() {
|
|
if (id) {
|
|
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,
|
|
});
|
|
},
|
|
};
|
|
}
|