@daltonr/pathwrite-core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.d.ts +168 -15
- package/dist/index.js +311 -99
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +420 -111
package/src/index.ts
CHANGED
|
@@ -11,6 +11,25 @@ export type PathData = Record<string, unknown>;
|
|
|
11
11
|
*/
|
|
12
12
|
export type FieldErrors = Record<string, string | undefined>;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The return type of a `canMoveNext` or `canMovePrevious` guard.
|
|
16
|
+
*
|
|
17
|
+
* - `true` — allow navigation
|
|
18
|
+
* - `{ allowed: false }` — block silently (no message)
|
|
19
|
+
* - `{ allowed: false, reason: "..." }` — block with a message; the shell surfaces
|
|
20
|
+
* this as `snapshot.blockingError` between the step content and the nav buttons
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* canMoveNext: async ({ data }) => {
|
|
25
|
+
* const result = await checkEligibility(data.applicantId);
|
|
26
|
+
* if (!result.eligible) return { allowed: false, reason: result.reason };
|
|
27
|
+
* return true;
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export type GuardResult = true | { allowed: false; reason?: string };
|
|
32
|
+
|
|
14
33
|
export interface SerializedPathState {
|
|
15
34
|
version: 1;
|
|
16
35
|
pathId: string;
|
|
@@ -29,15 +48,15 @@ export interface SerializedPathState {
|
|
|
29
48
|
stepEntryData?: PathData;
|
|
30
49
|
stepEnteredAt?: number;
|
|
31
50
|
}>;
|
|
32
|
-
|
|
51
|
+
_status: PathStatus;
|
|
33
52
|
}
|
|
34
53
|
|
|
35
54
|
/**
|
|
36
55
|
* The interface every path state store must implement.
|
|
37
56
|
*
|
|
38
|
-
* `HttpStore` from `@daltonr/pathwrite-store
|
|
57
|
+
* `HttpStore` from `@daltonr/pathwrite-store` is the reference
|
|
39
58
|
* implementation. Any backend — MongoDB, Redis, localStorage, etc. —
|
|
40
|
-
* implements this interface and works with `
|
|
59
|
+
* implements this interface and works with `persistence` and
|
|
41
60
|
* `restoreOrStart` without any other changes.
|
|
42
61
|
*/
|
|
43
62
|
export interface PathStore {
|
|
@@ -105,8 +124,8 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
105
124
|
title?: string;
|
|
106
125
|
meta?: Record<string, unknown>;
|
|
107
126
|
shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
|
|
108
|
-
canMoveNext?: (ctx: PathStepContext<TData>) =>
|
|
109
|
-
canMovePrevious?: (ctx: PathStepContext<TData>) =>
|
|
127
|
+
canMoveNext?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
|
|
128
|
+
canMovePrevious?: (ctx: PathStepContext<TData>) => GuardResult | Promise<GuardResult>;
|
|
110
129
|
/**
|
|
111
130
|
* Returns a map of field ID → error message explaining why the step is not
|
|
112
131
|
* yet valid. The shell displays these messages below the step content (labeled
|
|
@@ -195,6 +214,32 @@ export interface PathDefinition<TData extends PathData = PathData> {
|
|
|
195
214
|
|
|
196
215
|
export type StepStatus = "completed" | "current" | "upcoming";
|
|
197
216
|
|
|
217
|
+
/**
|
|
218
|
+
* The engine's current operational state. Exposed as `snapshot.status`.
|
|
219
|
+
*
|
|
220
|
+
* | Status | Meaning |
|
|
221
|
+
* |---------------|--------------------------------------------------------------|
|
|
222
|
+
* | `idle` | On a step, waiting for user input |
|
|
223
|
+
* | `entering` | `onEnter` hook is running |
|
|
224
|
+
* | `validating` | `canMoveNext` / `canMovePrevious` guard is running |
|
|
225
|
+
* | `leaving` | `onLeave` hook is running |
|
|
226
|
+
* | `completing` | `PathDefinition.onComplete` is running |
|
|
227
|
+
* | `error` | An async operation failed — see `snapshot.error` for details |
|
|
228
|
+
*/
|
|
229
|
+
export type PathStatus =
|
|
230
|
+
| "idle"
|
|
231
|
+
| "entering"
|
|
232
|
+
| "validating"
|
|
233
|
+
| "leaving"
|
|
234
|
+
| "completing"
|
|
235
|
+
| "error";
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The subset of `PathStatus` values that identify which phase was active
|
|
239
|
+
* when an error occurred. Used by `snapshot.error.phase`.
|
|
240
|
+
*/
|
|
241
|
+
export type ErrorPhase = Exclude<PathStatus, "idle" | "error">;
|
|
242
|
+
|
|
198
243
|
export interface StepSummary {
|
|
199
244
|
id: string;
|
|
200
245
|
title?: string;
|
|
@@ -253,8 +298,32 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
253
298
|
* progress bar above the sub-path's own progress bar.
|
|
254
299
|
*/
|
|
255
300
|
rootProgress?: RootProgress;
|
|
256
|
-
/**
|
|
257
|
-
|
|
301
|
+
/**
|
|
302
|
+
* The engine's current operational state. Use this instead of multiple boolean flags.
|
|
303
|
+
*
|
|
304
|
+
* Common patterns:
|
|
305
|
+
* - `status !== "idle"` — disable all navigation buttons (engine is busy or errored)
|
|
306
|
+
* - `status === "entering"` — show a skeleton/spinner inside the step area
|
|
307
|
+
* - `status === "validating"` — show "Checking…" on the Next button
|
|
308
|
+
* - `status === "error"` — show the retry / suspend error UI
|
|
309
|
+
*/
|
|
310
|
+
status: PathStatus;
|
|
311
|
+
/**
|
|
312
|
+
* Structured error set when an engine-invoked async operation throws. `null` when no error is active.
|
|
313
|
+
*
|
|
314
|
+
* `phase` identifies which operation failed so shells can adapt the message.
|
|
315
|
+
* `retryCount` counts how many times the user has explicitly called `retry()` — starts at 0 on
|
|
316
|
+
* first failure, increments on each retry. Use this to escalate from "Try again" to "Come back later".
|
|
317
|
+
*
|
|
318
|
+
* Cleared automatically when navigation succeeds or when `retry()` is called.
|
|
319
|
+
*/
|
|
320
|
+
error: { message: string; phase: ErrorPhase; retryCount: number } | null;
|
|
321
|
+
/**
|
|
322
|
+
* `true` when a `PathStore` is attached via `PathEngineOptions.hasPersistence`.
|
|
323
|
+
* Shells use this to decide whether to promise the user that progress is saved
|
|
324
|
+
* when showing the "come back later" escalation message.
|
|
325
|
+
*/
|
|
326
|
+
hasPersistence: boolean;
|
|
258
327
|
/** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldErrors` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
|
|
259
328
|
canMoveNext: boolean;
|
|
260
329
|
/** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
|
|
@@ -306,6 +375,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
306
375
|
* navigating back to a previously visited step).
|
|
307
376
|
*/
|
|
308
377
|
stepEnteredAt: number;
|
|
378
|
+
/**
|
|
379
|
+
* A guard-level blocking message set when `canMoveNext` returns
|
|
380
|
+
* `{ allowed: false, reason: "..." }`. `null` when there is no blocking message.
|
|
381
|
+
*
|
|
382
|
+
* Distinct from `fieldErrors` (field-attached) and `error` (async crash).
|
|
383
|
+
* Shells render this between the step content and the navigation buttons.
|
|
384
|
+
* Cleared automatically when the user successfully navigates to a new step.
|
|
385
|
+
*/
|
|
386
|
+
blockingError: string | null;
|
|
309
387
|
/**
|
|
310
388
|
* Field-keyed validation messages for the current step. Empty object when there are none.
|
|
311
389
|
* Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
|
|
@@ -335,12 +413,15 @@ export type StateChangeCause =
|
|
|
335
413
|
| "setData"
|
|
336
414
|
| "resetStep"
|
|
337
415
|
| "cancel"
|
|
338
|
-
| "restart"
|
|
416
|
+
| "restart"
|
|
417
|
+
| "retry"
|
|
418
|
+
| "suspend";
|
|
339
419
|
|
|
340
420
|
export type PathEvent =
|
|
341
421
|
| { type: "stateChanged"; cause: StateChangeCause; snapshot: PathSnapshot }
|
|
342
422
|
| { type: "completed"; pathId: string; data: PathData }
|
|
343
423
|
| { type: "cancelled"; pathId: string; data: PathData }
|
|
424
|
+
| { type: "suspended"; pathId: string; data: PathData }
|
|
344
425
|
| {
|
|
345
426
|
type: "resumed";
|
|
346
427
|
resumedPathId: string;
|
|
@@ -396,14 +477,15 @@ export type ObserverStrategy =
|
|
|
396
477
|
export function matchesStrategy(strategy: ObserverStrategy, event: PathEvent): boolean {
|
|
397
478
|
switch (strategy) {
|
|
398
479
|
case "onEveryChange":
|
|
399
|
-
// Only react once
|
|
400
|
-
//
|
|
401
|
-
return (event.type === "stateChanged" &&
|
|
480
|
+
// Only react once the engine has settled — stateChanged fires on every
|
|
481
|
+
// phase transition; only "idle" and "error" are settled states.
|
|
482
|
+
return (event.type === "stateChanged" &&
|
|
483
|
+
(event.snapshot.status === "idle" || event.snapshot.status === "error"))
|
|
402
484
|
|| event.type === "resumed";
|
|
403
485
|
case "onNext":
|
|
404
486
|
return event.type === "stateChanged"
|
|
405
487
|
&& event.cause === "next"
|
|
406
|
-
&&
|
|
488
|
+
&& (event.snapshot.status === "idle" || event.snapshot.status === "error");
|
|
407
489
|
case "onSubPathComplete":
|
|
408
490
|
return event.type === "resumed";
|
|
409
491
|
case "onComplete":
|
|
@@ -424,6 +506,12 @@ export interface PathEngineOptions {
|
|
|
424
506
|
* listeners use `engine.subscribe()`.
|
|
425
507
|
*/
|
|
426
508
|
observers?: PathObserver[];
|
|
509
|
+
/**
|
|
510
|
+
* Set to `true` when a `PathStore` is attached and will persist path state.
|
|
511
|
+
* Exposed as `snapshot.hasPersistence` so shells can honestly tell the user
|
|
512
|
+
* their progress is saved when showing the "come back later" escalation message.
|
|
513
|
+
*/
|
|
514
|
+
hasPersistence?: boolean;
|
|
427
515
|
}
|
|
428
516
|
|
|
429
517
|
interface ActivePath {
|
|
@@ -438,19 +526,61 @@ interface ActivePath {
|
|
|
438
526
|
stepEnteredAt: number;
|
|
439
527
|
/** The selected inner step when the current slot is a StepChoice. Cached on entry. */
|
|
440
528
|
resolvedChoiceStep?: PathStep;
|
|
529
|
+
/** Step IDs that have been confirmed skipped via shouldSkip during navigation. Used for accurate stepCount / progress in snapshots. */
|
|
530
|
+
resolvedSkips: Set<string>;
|
|
441
531
|
}
|
|
442
532
|
|
|
443
533
|
function isStepChoice(item: PathStep | StepChoice): item is StepChoice {
|
|
444
534
|
return "select" in item && "steps" in item;
|
|
445
535
|
}
|
|
446
536
|
|
|
537
|
+
/**
|
|
538
|
+
* Converts a camelCase or lowercase field key to a display label.
|
|
539
|
+
* `"firstName"` → `"First Name"`, `"email"` → `"Email"`.
|
|
540
|
+
* Used by shells to render labeled field-error summaries.
|
|
541
|
+
*/
|
|
542
|
+
export function formatFieldKey(key: string): string {
|
|
543
|
+
return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Returns a human-readable description of which operation failed, keyed by
|
|
548
|
+
* the `ErrorPhase` value on `snapshot.error.phase`. Used by shells to render
|
|
549
|
+
* the error panel message.
|
|
550
|
+
*/
|
|
551
|
+
export function errorPhaseMessage(phase: string): string {
|
|
552
|
+
switch (phase) {
|
|
553
|
+
case "entering": return "Failed to load this step.";
|
|
554
|
+
case "validating": return "The check could not be completed.";
|
|
555
|
+
case "leaving": return "Failed to save your progress.";
|
|
556
|
+
case "completing": return "Your submission could not be sent.";
|
|
557
|
+
default: return "An unexpected error occurred.";
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
447
561
|
export class PathEngine {
|
|
448
562
|
private activePath: ActivePath | null = null;
|
|
449
563
|
private readonly pathStack: ActivePath[] = [];
|
|
450
564
|
private readonly listeners = new Set<(event: PathEvent) => void>();
|
|
451
|
-
private
|
|
565
|
+
private _status: PathStatus = "idle";
|
|
452
566
|
/** True after the user has called next() on the current step at least once. Resets on step entry. */
|
|
453
567
|
private _hasAttemptedNext = false;
|
|
568
|
+
/** Blocking message from canMoveNext returning { allowed: false, reason }. Cleared on step entry. */
|
|
569
|
+
private _blockingError: string | null = null;
|
|
570
|
+
/** The path and initial data from the most recent top-level start() call. Used by restart(). */
|
|
571
|
+
private _rootPath: PathDefinition<any> | null = null;
|
|
572
|
+
private _rootInitialData: PathData = {};
|
|
573
|
+
/** Structured error from the most recent failed async operation. Null when no error is active. */
|
|
574
|
+
private _error: { message: string; phase: ErrorPhase; retryCount: number } | null = null;
|
|
575
|
+
/** Stored retry function. Null when no error is pending. */
|
|
576
|
+
private _pendingRetry: (() => Promise<void>) | null = null;
|
|
577
|
+
/**
|
|
578
|
+
* Counts how many times `retry()` has been called for the current error sequence.
|
|
579
|
+
* Reset to 0 by `next()` (fresh navigation). Incremented by `retry()`.
|
|
580
|
+
*/
|
|
581
|
+
private _retryCount = 0;
|
|
582
|
+
private _hasPersistence = false;
|
|
583
|
+
private _hasWarnedAsyncShouldSkip = false;
|
|
454
584
|
|
|
455
585
|
constructor(options?: PathEngineOptions) {
|
|
456
586
|
if (options?.observers) {
|
|
@@ -459,6 +589,9 @@ export class PathEngine {
|
|
|
459
589
|
this.listeners.add((event) => observer(event, this));
|
|
460
590
|
}
|
|
461
591
|
}
|
|
592
|
+
if (options?.hasPersistence) {
|
|
593
|
+
this._hasPersistence = true;
|
|
594
|
+
}
|
|
462
595
|
}
|
|
463
596
|
|
|
464
597
|
/**
|
|
@@ -500,6 +633,7 @@ export class PathEngine {
|
|
|
500
633
|
currentStepIndex: stackItem.currentStepIndex,
|
|
501
634
|
data: { ...stackItem.data },
|
|
502
635
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
636
|
+
resolvedSkips: new Set(),
|
|
503
637
|
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
504
638
|
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
505
639
|
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
@@ -519,6 +653,7 @@ export class PathEngine {
|
|
|
519
653
|
currentStepIndex: state.currentStepIndex,
|
|
520
654
|
data: { ...state.data },
|
|
521
655
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
656
|
+
resolvedSkips: new Set(),
|
|
522
657
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
523
658
|
// from the parent when this path was started). On restore, it's undefined.
|
|
524
659
|
subPathMeta: undefined,
|
|
@@ -526,7 +661,7 @@ export class PathEngine {
|
|
|
526
661
|
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
527
662
|
};
|
|
528
663
|
|
|
529
|
-
engine.
|
|
664
|
+
engine._status = state._status ?? "idle";
|
|
530
665
|
|
|
531
666
|
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
532
667
|
// always recomputed from current data on restore).
|
|
@@ -549,27 +684,31 @@ export class PathEngine {
|
|
|
549
684
|
|
|
550
685
|
public start(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
|
|
551
686
|
this.assertPathHasSteps(path);
|
|
687
|
+
this._rootPath = path;
|
|
688
|
+
this._rootInitialData = initialData;
|
|
552
689
|
return this._startAsync(path, initialData);
|
|
553
690
|
}
|
|
554
691
|
|
|
555
692
|
/**
|
|
556
693
|
* Tears down any active path (and the entire sub-path stack) without firing
|
|
557
|
-
* lifecycle hooks or emitting `cancelled`, then immediately
|
|
558
|
-
* path
|
|
694
|
+
* lifecycle hooks or emitting `cancelled`, then immediately restarts the same
|
|
695
|
+
* path with the same initial data that was passed to the original `start()` call.
|
|
559
696
|
*
|
|
560
697
|
* Safe to call at any time — whether a path is running, already completed,
|
|
561
698
|
* or has never been started. Use this to implement a "Start over" button or
|
|
562
699
|
* to retry a path after completion without remounting the host component.
|
|
563
700
|
*
|
|
564
|
-
* @
|
|
565
|
-
* @param initialData Data to seed the fresh path with. Defaults to `{}`.
|
|
701
|
+
* @throws If `restart()` is called before `start()` has ever been called.
|
|
566
702
|
*/
|
|
567
|
-
public restart(
|
|
568
|
-
this.
|
|
569
|
-
|
|
703
|
+
public restart(): Promise<void> {
|
|
704
|
+
if (!this._rootPath) {
|
|
705
|
+
throw new Error("Cannot restart: engine has not been started. Call start() first.");
|
|
706
|
+
}
|
|
707
|
+
this._status = "idle";
|
|
708
|
+
this._blockingError = null;
|
|
570
709
|
this.activePath = null;
|
|
571
710
|
this.pathStack.length = 0;
|
|
572
|
-
return this._startAsync(
|
|
711
|
+
return this._startAsync(this._rootPath, { ...this._rootInitialData });
|
|
573
712
|
}
|
|
574
713
|
|
|
575
714
|
/**
|
|
@@ -591,6 +730,14 @@ export class PathEngine {
|
|
|
591
730
|
|
|
592
731
|
public next(): Promise<void> {
|
|
593
732
|
const active = this.requireActivePath();
|
|
733
|
+
// Reset the retry sequence. If we're recovering from an error the user
|
|
734
|
+
// explicitly clicked Next again — clear the error and reset to idle so
|
|
735
|
+
// _nextAsync's entry guard passes. For any other non-idle status (busy)
|
|
736
|
+
// the guard in _nextAsync will drop this call.
|
|
737
|
+
this._retryCount = 0;
|
|
738
|
+
this._error = null;
|
|
739
|
+
this._pendingRetry = null;
|
|
740
|
+
if (this._status === "error") this._status = "idle";
|
|
594
741
|
return this._nextAsync(active);
|
|
595
742
|
}
|
|
596
743
|
|
|
@@ -599,12 +746,54 @@ export class PathEngine {
|
|
|
599
746
|
return this._previousAsync(active);
|
|
600
747
|
}
|
|
601
748
|
|
|
749
|
+
/**
|
|
750
|
+
* Re-runs the operation that caused the most recent `snapshot.error`.
|
|
751
|
+
* Increments `snapshot.error.retryCount` so shells can escalate from
|
|
752
|
+
* "Try again" to "Come back later" after repeated failures.
|
|
753
|
+
*
|
|
754
|
+
* No-op if there is no pending error or if navigation is in progress.
|
|
755
|
+
*/
|
|
756
|
+
public retry(): Promise<void> {
|
|
757
|
+
if (!this._pendingRetry || this._status !== "error") return Promise.resolve();
|
|
758
|
+
this._retryCount++;
|
|
759
|
+
const fn = this._pendingRetry;
|
|
760
|
+
this._pendingRetry = null;
|
|
761
|
+
this._error = null;
|
|
762
|
+
this._status = "idle"; // allow the retry fn's entry guard to pass
|
|
763
|
+
return fn();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Pauses the path with intent to return. Preserves all state and data.
|
|
768
|
+
*
|
|
769
|
+
* - Clears any active error state
|
|
770
|
+
* - Emits a `suspended` event that the application can listen for to dismiss
|
|
771
|
+
* the wizard UI (close a modal, navigate away, etc.)
|
|
772
|
+
* - The engine remains in its current state — call `start()` / `restoreOrStart()`
|
|
773
|
+
* to resume when the user returns
|
|
774
|
+
*
|
|
775
|
+
* Use in the "Come back later" escalation path when `snapshot.error.retryCount`
|
|
776
|
+
* has crossed `retryThreshold`. The `suspended` event signals the app to dismiss
|
|
777
|
+
* the UI; Pathwrite's persistence layer handles saving progress automatically via
|
|
778
|
+
* the configured store and observer strategy.
|
|
779
|
+
*/
|
|
780
|
+
public suspend(): Promise<void> {
|
|
781
|
+
const active = this.activePath;
|
|
782
|
+
const pathId = active?.definition.id ?? "";
|
|
783
|
+
const data = active ? { ...active.data } : {};
|
|
784
|
+
this._error = null;
|
|
785
|
+
this._pendingRetry = null;
|
|
786
|
+
this._status = "idle";
|
|
787
|
+
this.emit({ type: "suspended", pathId, data });
|
|
788
|
+
return Promise.resolve();
|
|
789
|
+
}
|
|
790
|
+
|
|
602
791
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
603
792
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
604
793
|
* API consistency. */
|
|
605
794
|
public async cancel(): Promise<void> {
|
|
606
795
|
const active = this.requireActivePath();
|
|
607
|
-
if (this.
|
|
796
|
+
if (this._status !== "idle") return;
|
|
608
797
|
|
|
609
798
|
const cancelledPathId = active.definition.id;
|
|
610
799
|
const cancelledData = { ...active.data };
|
|
@@ -684,25 +873,34 @@ export class PathEngine {
|
|
|
684
873
|
const item = this.getCurrentItem(active);
|
|
685
874
|
const effectiveStep = this.getEffectiveStep(active);
|
|
686
875
|
const { steps } = active.definition;
|
|
687
|
-
|
|
876
|
+
|
|
877
|
+
// Filter out steps confirmed as skipped during navigation. Steps not yet
|
|
878
|
+
// evaluated (e.g. on first render) are included optimistically.
|
|
879
|
+
const visibleSteps = steps.filter(s => !active.resolvedSkips.has(s.id));
|
|
880
|
+
const stepCount = visibleSteps.length;
|
|
881
|
+
const visibleIndex = visibleSteps.findIndex(s => s.id === item.id);
|
|
882
|
+
// Fall back to raw index if not found (should not happen in normal use)
|
|
883
|
+
const effectiveStepIndex = visibleIndex >= 0 ? visibleIndex : active.currentStepIndex;
|
|
688
884
|
|
|
689
885
|
// Build rootProgress from the bottom of the stack (the top-level path)
|
|
690
886
|
let rootProgress: RootProgress | undefined;
|
|
691
887
|
if (this.pathStack.length > 0) {
|
|
692
888
|
const root = this.pathStack[0];
|
|
693
|
-
const
|
|
694
|
-
const rootStepCount =
|
|
889
|
+
const rootVisibleSteps = root.definition.steps.filter(s => !root.resolvedSkips.has(s.id));
|
|
890
|
+
const rootStepCount = rootVisibleSteps.length;
|
|
891
|
+
const rootVisibleIndex = rootVisibleSteps.findIndex(s => s.id === root.definition.steps[root.currentStepIndex]?.id);
|
|
892
|
+
const rootEffectiveIndex = rootVisibleIndex >= 0 ? rootVisibleIndex : root.currentStepIndex;
|
|
695
893
|
rootProgress = {
|
|
696
894
|
pathId: root.definition.id,
|
|
697
|
-
stepIndex:
|
|
895
|
+
stepIndex: rootEffectiveIndex,
|
|
698
896
|
stepCount: rootStepCount,
|
|
699
|
-
progress: rootStepCount <= 1 ? 1 :
|
|
700
|
-
steps:
|
|
897
|
+
progress: rootStepCount <= 1 ? 1 : rootEffectiveIndex / (rootStepCount - 1),
|
|
898
|
+
steps: rootVisibleSteps.map((s, i) => ({
|
|
701
899
|
id: s.id,
|
|
702
900
|
title: s.title,
|
|
703
901
|
meta: s.meta,
|
|
704
|
-
status: i <
|
|
705
|
-
: i ===
|
|
902
|
+
status: i < rootEffectiveIndex ? "completed" as const
|
|
903
|
+
: i === rootEffectiveIndex ? "current" as const
|
|
706
904
|
: "upcoming" as const
|
|
707
905
|
}))
|
|
708
906
|
};
|
|
@@ -714,25 +912,28 @@ export class PathEngine {
|
|
|
714
912
|
stepTitle: effectiveStep.title ?? item.title,
|
|
715
913
|
stepMeta: effectiveStep.meta ?? item.meta,
|
|
716
914
|
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
717
|
-
stepIndex:
|
|
915
|
+
stepIndex: effectiveStepIndex,
|
|
718
916
|
stepCount,
|
|
719
|
-
progress: stepCount <= 1 ? 1 :
|
|
720
|
-
steps:
|
|
917
|
+
progress: stepCount <= 1 ? 1 : effectiveStepIndex / (stepCount - 1),
|
|
918
|
+
steps: visibleSteps.map((s, i) => ({
|
|
721
919
|
id: s.id,
|
|
722
920
|
title: s.title,
|
|
723
921
|
meta: s.meta,
|
|
724
|
-
status: i <
|
|
725
|
-
: i ===
|
|
922
|
+
status: i < effectiveStepIndex ? "completed" as const
|
|
923
|
+
: i === effectiveStepIndex ? "current" as const
|
|
726
924
|
: "upcoming" as const
|
|
727
925
|
})),
|
|
728
|
-
isFirstStep:
|
|
926
|
+
isFirstStep: effectiveStepIndex === 0,
|
|
729
927
|
isLastStep:
|
|
730
|
-
|
|
928
|
+
effectiveStepIndex === stepCount - 1 &&
|
|
731
929
|
this.pathStack.length === 0,
|
|
732
930
|
nestingLevel: this.pathStack.length,
|
|
733
931
|
rootProgress,
|
|
734
|
-
|
|
932
|
+
status: this._status,
|
|
933
|
+
error: this._error,
|
|
934
|
+
hasPersistence: this._hasPersistence,
|
|
735
935
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
936
|
+
blockingError: this._blockingError,
|
|
736
937
|
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
737
938
|
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
738
939
|
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
@@ -745,7 +946,7 @@ export class PathEngine {
|
|
|
745
946
|
|
|
746
947
|
/**
|
|
747
948
|
* Exports the current engine state as a plain JSON-serializable object.
|
|
748
|
-
* Use with storage adapters (e.g. `@daltonr/pathwrite-store
|
|
949
|
+
* Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
|
|
749
950
|
* persist and restore wizard progress.
|
|
750
951
|
*
|
|
751
952
|
* Returns `null` if no path is active.
|
|
@@ -778,7 +979,7 @@ export class PathEngine {
|
|
|
778
979
|
stepEntryData: { ...p.stepEntryData },
|
|
779
980
|
stepEnteredAt: p.stepEnteredAt
|
|
780
981
|
})),
|
|
781
|
-
|
|
982
|
+
_status: this._status
|
|
782
983
|
};
|
|
783
984
|
}
|
|
784
985
|
|
|
@@ -787,7 +988,7 @@ export class PathEngine {
|
|
|
787
988
|
// ---------------------------------------------------------------------------
|
|
788
989
|
|
|
789
990
|
private async _startAsync(path: PathDefinition, initialData: PathData, subPathMeta?: Record<string, unknown>): Promise<void> {
|
|
790
|
-
if (this.
|
|
991
|
+
if (this._status !== "idle") return;
|
|
791
992
|
|
|
792
993
|
if (this.activePath !== null) {
|
|
793
994
|
// Store the meta on the parent before pushing to stack
|
|
@@ -803,90 +1004,104 @@ export class PathEngine {
|
|
|
803
1004
|
currentStepIndex: 0,
|
|
804
1005
|
data: { ...initialData },
|
|
805
1006
|
visitedStepIds: new Set(),
|
|
1007
|
+
resolvedSkips: new Set(),
|
|
806
1008
|
subPathMeta: undefined,
|
|
807
1009
|
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
808
1010
|
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
809
1011
|
};
|
|
810
1012
|
|
|
811
|
-
this._isNavigating = true;
|
|
812
|
-
|
|
813
1013
|
await this.skipSteps(1);
|
|
814
1014
|
|
|
815
1015
|
if (this.activePath.currentStepIndex >= path.steps.length) {
|
|
816
|
-
this.
|
|
817
|
-
await this.finishActivePath();
|
|
1016
|
+
await this._finishActivePathWithErrorHandling();
|
|
818
1017
|
return;
|
|
819
1018
|
}
|
|
820
1019
|
|
|
1020
|
+
this._status = "entering";
|
|
821
1021
|
this.emitStateChanged("start");
|
|
822
1022
|
|
|
823
|
-
|
|
824
|
-
this.applyPatch(await this.enterCurrentStep());
|
|
825
|
-
this._isNavigating = false;
|
|
826
|
-
this.emitStateChanged("start");
|
|
827
|
-
} catch (err) {
|
|
828
|
-
this._isNavigating = false;
|
|
829
|
-
this.emitStateChanged("start");
|
|
830
|
-
throw err;
|
|
831
|
-
}
|
|
1023
|
+
await this._enterCurrentStepWithErrorHandling("start");
|
|
832
1024
|
}
|
|
833
1025
|
|
|
834
1026
|
private async _nextAsync(active: ActivePath): Promise<void> {
|
|
835
|
-
if (this.
|
|
1027
|
+
if (this._status !== "idle") return;
|
|
836
1028
|
|
|
837
1029
|
// Record that the user has attempted to advance — used by shells and step
|
|
838
1030
|
// templates to gate error display ("punish late, reward early").
|
|
839
1031
|
this._hasAttemptedNext = true;
|
|
840
|
-
|
|
1032
|
+
|
|
1033
|
+
// Phase: validating — canMoveNext guard
|
|
1034
|
+
this._status = "validating";
|
|
841
1035
|
this.emitStateChanged("next");
|
|
842
1036
|
|
|
1037
|
+
let guardResult: { allowed: boolean; reason: string | null };
|
|
843
1038
|
try {
|
|
844
|
-
|
|
1039
|
+
guardResult = await this.canMoveNext(active, this.getEffectiveStep(active));
|
|
1040
|
+
} catch (err) {
|
|
1041
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "validating", retryCount: this._retryCount };
|
|
1042
|
+
this._pendingRetry = () => this._nextAsync(active);
|
|
1043
|
+
this._status = "error";
|
|
1044
|
+
this.emitStateChanged("next");
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
845
1047
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
1048
|
+
if (guardResult.allowed) {
|
|
1049
|
+
// Phase: leaving — onLeave hook
|
|
1050
|
+
this._status = "leaving";
|
|
1051
|
+
this.emitStateChanged("next");
|
|
1052
|
+
try {
|
|
1053
|
+
this.applyPatch(await this.leaveCurrentStep(active, this.getEffectiveStep(active)));
|
|
1054
|
+
} catch (err) {
|
|
1055
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "leaving", retryCount: this._retryCount };
|
|
1056
|
+
this._pendingRetry = () => this._nextAsync(active);
|
|
1057
|
+
this._status = "error";
|
|
1058
|
+
this.emitStateChanged("next");
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
850
1061
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
await this.finishActivePath();
|
|
854
|
-
return;
|
|
855
|
-
}
|
|
1062
|
+
active.currentStepIndex += 1;
|
|
1063
|
+
await this.skipSteps(1);
|
|
856
1064
|
|
|
857
|
-
|
|
1065
|
+
if (active.currentStepIndex >= active.definition.steps.length) {
|
|
1066
|
+
// Phase: completing — PathDefinition.onComplete
|
|
1067
|
+
await this._finishActivePathWithErrorHandling();
|
|
1068
|
+
return;
|
|
858
1069
|
}
|
|
859
1070
|
|
|
860
|
-
|
|
861
|
-
this.
|
|
862
|
-
|
|
863
|
-
this._isNavigating = false;
|
|
864
|
-
this.emitStateChanged("next");
|
|
865
|
-
throw err;
|
|
1071
|
+
// Phase: entering — onEnter hook on the new step
|
|
1072
|
+
await this._enterCurrentStepWithErrorHandling("next");
|
|
1073
|
+
return;
|
|
866
1074
|
}
|
|
1075
|
+
|
|
1076
|
+
this._blockingError = guardResult.reason;
|
|
1077
|
+
this._status = "idle";
|
|
1078
|
+
this.emitStateChanged("next");
|
|
867
1079
|
}
|
|
868
1080
|
|
|
869
1081
|
private async _previousAsync(active: ActivePath): Promise<void> {
|
|
870
|
-
if (this.
|
|
1082
|
+
if (this._status !== "idle") return;
|
|
871
1083
|
|
|
872
1084
|
// No-op when already on the first step of a top-level path.
|
|
873
1085
|
// Sub-paths still cancel/pop back to the parent when previous() is called
|
|
874
1086
|
// on their first step (the currentStepIndex < 0 branch below handles that).
|
|
875
1087
|
if (active.currentStepIndex === 0 && this.pathStack.length === 0) return;
|
|
876
1088
|
|
|
877
|
-
this.
|
|
1089
|
+
this._status = "leaving";
|
|
878
1090
|
this.emitStateChanged("previous");
|
|
879
1091
|
|
|
880
1092
|
try {
|
|
881
1093
|
const step = this.getEffectiveStep(active);
|
|
882
1094
|
|
|
883
|
-
|
|
1095
|
+
const prevGuard = await this.canMovePrevious(active, step);
|
|
1096
|
+
if (!prevGuard.allowed) this._blockingError = prevGuard.reason;
|
|
1097
|
+
|
|
1098
|
+
if (prevGuard.allowed) {
|
|
884
1099
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
885
1100
|
active.currentStepIndex -= 1;
|
|
886
1101
|
await this.skipSteps(-1);
|
|
887
1102
|
|
|
888
1103
|
if (active.currentStepIndex < 0) {
|
|
889
|
-
this.
|
|
1104
|
+
this._status = "idle";
|
|
890
1105
|
await this.cancel();
|
|
891
1106
|
return;
|
|
892
1107
|
}
|
|
@@ -894,19 +1109,19 @@ export class PathEngine {
|
|
|
894
1109
|
this.applyPatch(await this.enterCurrentStep());
|
|
895
1110
|
}
|
|
896
1111
|
|
|
897
|
-
this.
|
|
1112
|
+
this._status = "idle";
|
|
898
1113
|
this.emitStateChanged("previous");
|
|
899
1114
|
} catch (err) {
|
|
900
|
-
this.
|
|
1115
|
+
this._status = "idle";
|
|
901
1116
|
this.emitStateChanged("previous");
|
|
902
1117
|
throw err;
|
|
903
1118
|
}
|
|
904
1119
|
}
|
|
905
1120
|
|
|
906
1121
|
private async _goToStepAsync(active: ActivePath, targetIndex: number): Promise<void> {
|
|
907
|
-
if (this.
|
|
1122
|
+
if (this._status !== "idle") return;
|
|
908
1123
|
|
|
909
|
-
this.
|
|
1124
|
+
this._status = "leaving";
|
|
910
1125
|
this.emitStateChanged("goToStep");
|
|
911
1126
|
|
|
912
1127
|
try {
|
|
@@ -916,38 +1131,41 @@ export class PathEngine {
|
|
|
916
1131
|
active.currentStepIndex = targetIndex;
|
|
917
1132
|
|
|
918
1133
|
this.applyPatch(await this.enterCurrentStep());
|
|
919
|
-
this.
|
|
1134
|
+
this._status = "idle";
|
|
920
1135
|
this.emitStateChanged("goToStep");
|
|
921
1136
|
} catch (err) {
|
|
922
|
-
this.
|
|
1137
|
+
this._status = "idle";
|
|
923
1138
|
this.emitStateChanged("goToStep");
|
|
924
1139
|
throw err;
|
|
925
1140
|
}
|
|
926
1141
|
}
|
|
927
1142
|
|
|
928
1143
|
private async _goToStepCheckedAsync(active: ActivePath, targetIndex: number): Promise<void> {
|
|
929
|
-
if (this.
|
|
1144
|
+
if (this._status !== "idle") return;
|
|
930
1145
|
|
|
931
|
-
this.
|
|
1146
|
+
this._status = "validating";
|
|
932
1147
|
this.emitStateChanged("goToStepChecked");
|
|
933
1148
|
|
|
934
1149
|
try {
|
|
935
1150
|
const currentStep = this.getEffectiveStep(active);
|
|
936
1151
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
937
|
-
const
|
|
1152
|
+
const guardResult = goingForward
|
|
938
1153
|
? await this.canMoveNext(active, currentStep)
|
|
939
1154
|
: await this.canMovePrevious(active, currentStep);
|
|
940
1155
|
|
|
941
|
-
if (allowed)
|
|
1156
|
+
if (!guardResult.allowed) this._blockingError = guardResult.reason;
|
|
1157
|
+
|
|
1158
|
+
if (guardResult.allowed) {
|
|
1159
|
+
this._status = "leaving";
|
|
942
1160
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
943
1161
|
active.currentStepIndex = targetIndex;
|
|
944
1162
|
this.applyPatch(await this.enterCurrentStep());
|
|
945
1163
|
}
|
|
946
1164
|
|
|
947
|
-
this.
|
|
1165
|
+
this._status = "idle";
|
|
948
1166
|
this.emitStateChanged("goToStepChecked");
|
|
949
1167
|
} catch (err) {
|
|
950
|
-
this.
|
|
1168
|
+
this._status = "idle";
|
|
951
1169
|
this.emitStateChanged("goToStepChecked");
|
|
952
1170
|
throw err;
|
|
953
1171
|
}
|
|
@@ -963,7 +1181,7 @@ export class PathEngine {
|
|
|
963
1181
|
// sub-path (which may have currentStepIndex = -1).
|
|
964
1182
|
this.activePath = this.pathStack.pop() ?? null;
|
|
965
1183
|
|
|
966
|
-
this.
|
|
1184
|
+
this._status = "leaving";
|
|
967
1185
|
this.emitStateChanged("cancel");
|
|
968
1186
|
|
|
969
1187
|
try {
|
|
@@ -985,10 +1203,10 @@ export class PathEngine {
|
|
|
985
1203
|
}
|
|
986
1204
|
}
|
|
987
1205
|
|
|
988
|
-
this.
|
|
1206
|
+
this._status = "idle";
|
|
989
1207
|
this.emitStateChanged("cancel");
|
|
990
1208
|
} catch (err) {
|
|
991
|
-
this.
|
|
1209
|
+
this._status = "idle";
|
|
992
1210
|
this.emitStateChanged("cancel");
|
|
993
1211
|
throw err;
|
|
994
1212
|
}
|
|
@@ -1026,12 +1244,66 @@ export class PathEngine {
|
|
|
1026
1244
|
snapshot: this.snapshot()!
|
|
1027
1245
|
});
|
|
1028
1246
|
} else {
|
|
1029
|
-
// Top-level path completed — call onComplete
|
|
1030
|
-
|
|
1031
|
-
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
1247
|
+
// Top-level path completed — call onComplete before clearing activePath so
|
|
1248
|
+
// that if it throws the engine remains on the final step and can retry.
|
|
1032
1249
|
if (finished.definition.onComplete) {
|
|
1033
1250
|
await finished.definition.onComplete(finishedData);
|
|
1034
1251
|
}
|
|
1252
|
+
this.activePath = null;
|
|
1253
|
+
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/**
|
|
1258
|
+
* Wraps `finishActivePath` with error handling for the `completing` phase.
|
|
1259
|
+
* On failure: sets `_error`, stores a retry that re-calls `finishActivePath`,
|
|
1260
|
+
* resets status to `"error"`, and emits `stateChanged`.
|
|
1261
|
+
* On success: resets status to `"idle"` (finishActivePath sets activePath = null,
|
|
1262
|
+
* so no stateChanged is needed — the `completed` event is the terminal signal).
|
|
1263
|
+
*/
|
|
1264
|
+
private async _finishActivePathWithErrorHandling(): Promise<void> {
|
|
1265
|
+
const active = this.activePath;
|
|
1266
|
+
this._status = "completing";
|
|
1267
|
+
try {
|
|
1268
|
+
await this.finishActivePath();
|
|
1269
|
+
this._status = "idle";
|
|
1270
|
+
// No stateChanged here — finishActivePath emits "completed" or "resumed"
|
|
1271
|
+
} catch (err) {
|
|
1272
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "completing", retryCount: this._retryCount };
|
|
1273
|
+
// Retry: call finishActivePath again (activePath is still set because onComplete
|
|
1274
|
+
// throws before this.activePath = null in the restructured finishActivePath)
|
|
1275
|
+
this._pendingRetry = () => this._finishActivePathWithErrorHandling();
|
|
1276
|
+
this._status = "error";
|
|
1277
|
+
if (active) {
|
|
1278
|
+
// Restore activePath if it was cleared mid-throw (defensive)
|
|
1279
|
+
if (!this.activePath) this.activePath = active;
|
|
1280
|
+
// Back up to the last valid step so snapshot() can render it while error is shown
|
|
1281
|
+
if (this.activePath.currentStepIndex >= this.activePath.definition.steps.length) {
|
|
1282
|
+
this.activePath.currentStepIndex = this.activePath.definition.steps.length - 1;
|
|
1283
|
+
}
|
|
1284
|
+
this.emitStateChanged("next");
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/**
|
|
1290
|
+
* Wraps `enterCurrentStep` with error handling for the `entering` phase.
|
|
1291
|
+
* Called by both `_startAsync` and `_nextAsync` after advancing to a new step.
|
|
1292
|
+
* On failure: sets `_error`, stores a retry that re-calls this method,
|
|
1293
|
+
* resets status to `"error"`, and emits `stateChanged` with the given `cause`.
|
|
1294
|
+
*/
|
|
1295
|
+
private async _enterCurrentStepWithErrorHandling(cause: StateChangeCause): Promise<void> {
|
|
1296
|
+
this._status = "entering";
|
|
1297
|
+
try {
|
|
1298
|
+
this.applyPatch(await this.enterCurrentStep());
|
|
1299
|
+
this._status = "idle";
|
|
1300
|
+
this.emitStateChanged(cause);
|
|
1301
|
+
} catch (err) {
|
|
1302
|
+
this._error = { message: PathEngine.errorMessage(err), phase: "entering", retryCount: this._retryCount };
|
|
1303
|
+
// Retry: re-enter the current step (don't repeat guards/leave)
|
|
1304
|
+
this._pendingRetry = () => this._enterCurrentStepWithErrorHandling(cause);
|
|
1305
|
+
this._status = "error";
|
|
1306
|
+
this.emitStateChanged(cause);
|
|
1035
1307
|
}
|
|
1036
1308
|
}
|
|
1037
1309
|
|
|
@@ -1039,6 +1311,10 @@ export class PathEngine {
|
|
|
1039
1311
|
// Private helpers
|
|
1040
1312
|
// ---------------------------------------------------------------------------
|
|
1041
1313
|
|
|
1314
|
+
private static errorMessage(err: unknown): string {
|
|
1315
|
+
return err instanceof Error ? err.message : String(err);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1042
1318
|
private requireActivePath(): ActivePath {
|
|
1043
1319
|
if (this.activePath === null) {
|
|
1044
1320
|
throw new Error("No active path.");
|
|
@@ -1137,15 +1413,36 @@ export class PathEngine {
|
|
|
1137
1413
|
active.currentStepIndex < active.definition.steps.length
|
|
1138
1414
|
) {
|
|
1139
1415
|
const item = active.definition.steps[active.currentStepIndex];
|
|
1140
|
-
if (!item.shouldSkip)
|
|
1416
|
+
if (!item.shouldSkip) {
|
|
1417
|
+
// This step has no shouldSkip — it is definitely visible. Remove it from
|
|
1418
|
+
// the cache in case a previous navigation had marked it as skipped.
|
|
1419
|
+
active.resolvedSkips.delete(item.id);
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1141
1422
|
const ctx: PathStepContext = {
|
|
1142
1423
|
pathId: active.definition.id,
|
|
1143
1424
|
stepId: item.id,
|
|
1144
1425
|
data: { ...active.data },
|
|
1145
1426
|
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1146
1427
|
};
|
|
1147
|
-
const
|
|
1148
|
-
if (
|
|
1428
|
+
const rawResult = item.shouldSkip(ctx);
|
|
1429
|
+
if (rawResult && typeof (rawResult as Promise<boolean>).then === "function") {
|
|
1430
|
+
if (!this._hasWarnedAsyncShouldSkip) {
|
|
1431
|
+
this._hasWarnedAsyncShouldSkip = true;
|
|
1432
|
+
console.warn(
|
|
1433
|
+
`[Pathwrite] Step "${item.id}" has an async shouldSkip. ` +
|
|
1434
|
+
`snapshot().stepCount and progress may be approximate until after the first navigation.`
|
|
1435
|
+
);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
const skip = await rawResult;
|
|
1439
|
+
if (!skip) {
|
|
1440
|
+
// This step resolved as NOT skipped — remove from cache in case it was
|
|
1441
|
+
// previously skipped (data changed since last navigation).
|
|
1442
|
+
active.resolvedSkips.delete(item.id);
|
|
1443
|
+
break;
|
|
1444
|
+
}
|
|
1445
|
+
active.resolvedSkips.add(item.id);
|
|
1149
1446
|
active.currentStepIndex += direction;
|
|
1150
1447
|
}
|
|
1151
1448
|
}
|
|
@@ -1153,6 +1450,7 @@ export class PathEngine {
|
|
|
1153
1450
|
private async enterCurrentStep(): Promise<Partial<PathData> | void> {
|
|
1154
1451
|
// Each step starts fresh — errors are not shown until the user attempts to proceed.
|
|
1155
1452
|
this._hasAttemptedNext = false;
|
|
1453
|
+
this._blockingError = null;
|
|
1156
1454
|
const active = this.activePath;
|
|
1157
1455
|
if (!active) return;
|
|
1158
1456
|
|
|
@@ -1199,7 +1497,7 @@ export class PathEngine {
|
|
|
1199
1497
|
private async canMoveNext(
|
|
1200
1498
|
active: ActivePath,
|
|
1201
1499
|
step: PathStep
|
|
1202
|
-
): Promise<boolean> {
|
|
1500
|
+
): Promise<{ allowed: boolean; reason: string | null }> {
|
|
1203
1501
|
if (step.canMoveNext) {
|
|
1204
1502
|
const ctx: PathStepContext = {
|
|
1205
1503
|
pathId: active.definition.id,
|
|
@@ -1207,26 +1505,34 @@ export class PathEngine {
|
|
|
1207
1505
|
data: { ...active.data },
|
|
1208
1506
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
1209
1507
|
};
|
|
1210
|
-
|
|
1508
|
+
const result = await step.canMoveNext(ctx);
|
|
1509
|
+
return PathEngine.normaliseGuardResult(result);
|
|
1211
1510
|
}
|
|
1212
1511
|
if (step.fieldErrors) {
|
|
1213
|
-
|
|
1512
|
+
const allowed = Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
1513
|
+
return { allowed, reason: null };
|
|
1214
1514
|
}
|
|
1215
|
-
return true;
|
|
1515
|
+
return { allowed: true, reason: null };
|
|
1216
1516
|
}
|
|
1217
1517
|
|
|
1218
1518
|
private async canMovePrevious(
|
|
1219
1519
|
active: ActivePath,
|
|
1220
1520
|
step: PathStep
|
|
1221
|
-
): Promise<boolean> {
|
|
1222
|
-
if (!step.canMovePrevious) return true;
|
|
1521
|
+
): Promise<{ allowed: boolean; reason: string | null }> {
|
|
1522
|
+
if (!step.canMovePrevious) return { allowed: true, reason: null };
|
|
1223
1523
|
const ctx: PathStepContext = {
|
|
1224
1524
|
pathId: active.definition.id,
|
|
1225
1525
|
stepId: step.id,
|
|
1226
1526
|
data: { ...active.data },
|
|
1227
1527
|
isFirstEntry: !active.visitedStepIds.has(step.id)
|
|
1228
1528
|
};
|
|
1229
|
-
|
|
1529
|
+
const result = await step.canMovePrevious(ctx);
|
|
1530
|
+
return PathEngine.normaliseGuardResult(result);
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
private static normaliseGuardResult(result: GuardResult): { allowed: boolean; reason: string | null } {
|
|
1534
|
+
if (result === true) return { allowed: true, reason: null };
|
|
1535
|
+
return { allowed: false, reason: result.reason ?? null };
|
|
1230
1536
|
}
|
|
1231
1537
|
|
|
1232
1538
|
/**
|
|
@@ -1244,7 +1550,7 @@ export class PathEngine {
|
|
|
1244
1550
|
* safe default (`true`) is returned so the UI remains operable.
|
|
1245
1551
|
*/
|
|
1246
1552
|
private evaluateGuardSync(
|
|
1247
|
-
guard: ((ctx: PathStepContext) =>
|
|
1553
|
+
guard: ((ctx: PathStepContext) => GuardResult | Promise<GuardResult>) | undefined,
|
|
1248
1554
|
active: ActivePath
|
|
1249
1555
|
): boolean {
|
|
1250
1556
|
if (!guard) return true;
|
|
@@ -1257,17 +1563,20 @@ export class PathEngine {
|
|
|
1257
1563
|
};
|
|
1258
1564
|
try {
|
|
1259
1565
|
const result = guard(ctx);
|
|
1260
|
-
if (
|
|
1261
|
-
|
|
1262
|
-
|
|
1566
|
+
if (result === true) return true;
|
|
1567
|
+
if (result && typeof (result as Promise<unknown>).then === "function") {
|
|
1568
|
+
// Async guard detected - suppress the unhandled rejection, warn, return optimistic default
|
|
1569
|
+
(result as Promise<unknown>).catch(() => {});
|
|
1263
1570
|
console.warn(
|
|
1264
1571
|
`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
1265
1572
|
`Guards in snapshots must be synchronous. ` +
|
|
1266
1573
|
`Returning true (optimistic) as default. ` +
|
|
1267
1574
|
`The async guard will still be enforced during actual navigation.`
|
|
1268
1575
|
);
|
|
1576
|
+
return true;
|
|
1269
1577
|
}
|
|
1270
|
-
|
|
1578
|
+
// { allowed: false, reason? } object returned synchronously
|
|
1579
|
+
return false;
|
|
1271
1580
|
} catch (err) {
|
|
1272
1581
|
console.warn(
|
|
1273
1582
|
`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|