@daltonr/pathwrite-core 0.7.0 → 0.9.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 +42 -5
- package/dist/index.d.ts +168 -24
- package/dist/index.js +190 -63
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +341 -76
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export type PathData = Record<string, unknown>;
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The return type of a `
|
|
4
|
+
* The return type of a `fieldErrors` hook. Each key is a field ID; the value
|
|
5
5
|
* is an error string, or `undefined` / omitted to indicate no error for that field.
|
|
6
6
|
*
|
|
7
7
|
* Use `"_"` as a key for form-level errors that don't belong to a specific field:
|
|
@@ -18,12 +18,16 @@ export interface SerializedPathState {
|
|
|
18
18
|
data: PathData;
|
|
19
19
|
visitedStepIds: string[];
|
|
20
20
|
subPathMeta?: Record<string, unknown>;
|
|
21
|
+
stepEntryData?: PathData;
|
|
22
|
+
stepEnteredAt?: number;
|
|
21
23
|
pathStack: Array<{
|
|
22
24
|
pathId: string;
|
|
23
25
|
currentStepIndex: number;
|
|
24
26
|
data: PathData;
|
|
25
27
|
visitedStepIds: string[];
|
|
26
28
|
subPathMeta?: Record<string, unknown>;
|
|
29
|
+
stepEntryData?: PathData;
|
|
30
|
+
stepEnteredAt?: number;
|
|
27
31
|
}>;
|
|
28
32
|
_isNavigating: boolean;
|
|
29
33
|
}
|
|
@@ -31,9 +35,9 @@ export interface SerializedPathState {
|
|
|
31
35
|
/**
|
|
32
36
|
* The interface every path state store must implement.
|
|
33
37
|
*
|
|
34
|
-
* `HttpStore` from `@daltonr/pathwrite-store
|
|
38
|
+
* `HttpStore` from `@daltonr/pathwrite-store` is the reference
|
|
35
39
|
* implementation. Any backend — MongoDB, Redis, localStorage, etc. —
|
|
36
|
-
* implements this interface and works with `
|
|
40
|
+
* implements this interface and works with `persistence` and
|
|
37
41
|
* `restoreOrStart` without any other changes.
|
|
38
42
|
*/
|
|
39
43
|
export interface PathStore {
|
|
@@ -56,6 +60,46 @@ export interface PathStepContext<TData extends PathData = PathData> {
|
|
|
56
60
|
readonly isFirstEntry: boolean;
|
|
57
61
|
}
|
|
58
62
|
|
|
63
|
+
/**
|
|
64
|
+
* A conditional step selection placed in a path's `steps` array in place of a
|
|
65
|
+
* single `PathStep`. When the engine reaches a `StepChoice` it calls `select`
|
|
66
|
+
* to decide which of the bundled `steps` to activate. The chosen step is then
|
|
67
|
+
* treated exactly like any other step — its hooks, guards, and validation all
|
|
68
|
+
* apply normally.
|
|
69
|
+
*
|
|
70
|
+
* `StepChoice` has its own `id` (used for progress tracking and `goToStep`)
|
|
71
|
+
* while `formId` on the snapshot exposes which inner step was selected, so the
|
|
72
|
+
* UI can render the right component.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* {
|
|
77
|
+
* id: "contact-details",
|
|
78
|
+
* select: ({ data }) => data.accountType === "company" ? "company" : "individual",
|
|
79
|
+
* steps: [
|
|
80
|
+
* {
|
|
81
|
+
* id: "individual",
|
|
82
|
+
* fieldErrors: ({ data }) => ({ name: !data.name ? "Required." : undefined }),
|
|
83
|
+
* },
|
|
84
|
+
* {
|
|
85
|
+
* id: "company",
|
|
86
|
+
* fieldErrors: ({ data }) => ({ companyName: !data.companyName ? "Required." : undefined }),
|
|
87
|
+
* },
|
|
88
|
+
* ],
|
|
89
|
+
* }
|
|
90
|
+
* ```
|
|
91
|
+
*/
|
|
92
|
+
export interface StepChoice<TData extends PathData = PathData> {
|
|
93
|
+
id: string;
|
|
94
|
+
title?: string;
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
96
|
+
/** Called on step entry. Return the `id` of the step to activate. Throws if the returned id is not found in `steps`. */
|
|
97
|
+
select: (ctx: PathStepContext<TData>) => string;
|
|
98
|
+
steps: PathStep<TData>[];
|
|
99
|
+
/** When `true`, the engine skips this choice slot entirely (same semantics as `PathStep.shouldSkip`). */
|
|
100
|
+
shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
|
|
101
|
+
}
|
|
102
|
+
|
|
59
103
|
export interface PathStep<TData extends PathData = PathData> {
|
|
60
104
|
id: string;
|
|
61
105
|
title?: string;
|
|
@@ -69,7 +113,7 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
69
113
|
* by field name) so consumers do not need to duplicate validation logic in
|
|
70
114
|
* the template. Return `undefined` for a field to indicate no error.
|
|
71
115
|
*
|
|
72
|
-
* When `
|
|
116
|
+
* When `fieldErrors` is provided and `canMoveNext` is **not**, the engine
|
|
73
117
|
* automatically derives `canMoveNext` as `true` when all values are `undefined`
|
|
74
118
|
* (i.e. no messages), eliminating the need to express the same logic twice.
|
|
75
119
|
*
|
|
@@ -77,13 +121,29 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
77
121
|
*
|
|
78
122
|
* @example
|
|
79
123
|
* ```typescript
|
|
80
|
-
*
|
|
124
|
+
* fieldErrors: ({ data }) => ({
|
|
81
125
|
* name: !data.name?.trim() ? "Required." : undefined,
|
|
82
126
|
* email: !isValidEmail(data.email) ? "Invalid email address." : undefined,
|
|
83
127
|
* })
|
|
84
128
|
* ```
|
|
85
129
|
*/
|
|
86
|
-
|
|
130
|
+
fieldErrors?: (ctx: PathStepContext<TData>) => FieldErrors;
|
|
131
|
+
/**
|
|
132
|
+
* Returns a map of field ID → warning message for non-blocking advisories.
|
|
133
|
+
* Same shape as `fieldErrors`, but warnings never affect `canMoveNext` —
|
|
134
|
+
* they are purely informational. Shells render them in amber/yellow instead
|
|
135
|
+
* of red.
|
|
136
|
+
*
|
|
137
|
+
* Evaluated synchronously on every snapshot; async functions default to `{}`.
|
|
138
|
+
*
|
|
139
|
+
* @example
|
|
140
|
+
* ```typescript
|
|
141
|
+
* fieldWarnings: ({ data }) => ({
|
|
142
|
+
* email: looksLikeTypo(data.email) ? "Did you mean gmail.com?" : undefined,
|
|
143
|
+
* })
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
146
|
+
fieldWarnings?: (ctx: PathStepContext<TData>) => FieldErrors;
|
|
87
147
|
onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
88
148
|
onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
|
|
89
149
|
/**
|
|
@@ -117,7 +177,20 @@ export interface PathStep<TData extends PathData = PathData> {
|
|
|
117
177
|
export interface PathDefinition<TData extends PathData = PathData> {
|
|
118
178
|
id: string;
|
|
119
179
|
title?: string;
|
|
120
|
-
steps: PathStep<TData>[];
|
|
180
|
+
steps: (PathStep<TData> | StepChoice<TData>)[];
|
|
181
|
+
/**
|
|
182
|
+
* Optional callback invoked when this path completes (i.e. the user
|
|
183
|
+
* reaches the end of the last step). Receives the final path data.
|
|
184
|
+
* Only called for top-level paths — sub-path completion is handled by
|
|
185
|
+
* the parent step's `onSubPathComplete` hook.
|
|
186
|
+
*/
|
|
187
|
+
onComplete?: (data: TData) => void | Promise<void>;
|
|
188
|
+
/**
|
|
189
|
+
* Optional callback invoked when this path is cancelled. Receives the
|
|
190
|
+
* path data at the time of cancellation. Only called for top-level paths —
|
|
191
|
+
* sub-path cancellation is handled by the parent step's `onSubPathCancel` hook.
|
|
192
|
+
*/
|
|
193
|
+
onCancel?: (data: TData) => void | Promise<void>;
|
|
121
194
|
}
|
|
122
195
|
|
|
123
196
|
export type StepStatus = "completed" | "current" | "upcoming";
|
|
@@ -161,6 +234,12 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
161
234
|
stepId: string;
|
|
162
235
|
stepTitle?: string;
|
|
163
236
|
stepMeta?: Record<string, unknown>;
|
|
237
|
+
/**
|
|
238
|
+
* The `id` of the selected inner `PathStep` when the current position in
|
|
239
|
+
* the path is a `StepChoice`. `undefined` for ordinary steps.
|
|
240
|
+
* Use this to decide which form component to render.
|
|
241
|
+
*/
|
|
242
|
+
formId?: string;
|
|
164
243
|
stepIndex: number;
|
|
165
244
|
stepCount: number;
|
|
166
245
|
progress: number;
|
|
@@ -176,7 +255,7 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
176
255
|
rootProgress?: RootProgress;
|
|
177
256
|
/** True while an async guard or hook is executing. Use to disable navigation controls. */
|
|
178
257
|
isNavigating: boolean;
|
|
179
|
-
/** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `
|
|
258
|
+
/** 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. */
|
|
180
259
|
canMoveNext: boolean;
|
|
181
260
|
/** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
|
|
182
261
|
canMovePrevious: boolean;
|
|
@@ -189,22 +268,58 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
189
268
|
* render and only appear after the user has attempted to proceed:
|
|
190
269
|
*
|
|
191
270
|
* ```svelte
|
|
192
|
-
* {#if snapshot.hasAttemptedNext && snapshot.
|
|
193
|
-
* <span class="error">{snapshot.
|
|
271
|
+
* {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
|
|
272
|
+
* <span class="error">{snapshot.fieldErrors.email}</span>
|
|
194
273
|
* {/if}
|
|
195
274
|
* ```
|
|
196
275
|
*
|
|
197
|
-
* The shell itself uses this flag to gate its own automatic `
|
|
276
|
+
* The shell itself uses this flag to gate its own automatic `fieldErrors`
|
|
198
277
|
* summary rendering — errors are never shown before the first Next attempt.
|
|
199
278
|
*/
|
|
200
279
|
hasAttemptedNext: boolean;
|
|
280
|
+
/**
|
|
281
|
+
* True if any data has changed since entering this step. Automatically computed
|
|
282
|
+
* by comparing the current data to the snapshot taken on step entry. Resets to
|
|
283
|
+
* `false` when navigating to a new step or calling `resetStep()`.
|
|
284
|
+
*
|
|
285
|
+
* Useful for "unsaved changes" warnings, disabling Save buttons until changes
|
|
286
|
+
* are made, or styling forms to indicate modifications.
|
|
287
|
+
*
|
|
288
|
+
* ```typescript
|
|
289
|
+
* {#if snapshot.isDirty}
|
|
290
|
+
* <span class="warning">You have unsaved changes</span>
|
|
291
|
+
* {/if}
|
|
292
|
+
* ```
|
|
293
|
+
*/
|
|
294
|
+
isDirty: boolean;
|
|
295
|
+
/**
|
|
296
|
+
* Timestamp (from `Date.now()`) captured when the current step was entered.
|
|
297
|
+
* Useful for analytics, timeout warnings, or computing how long a user has
|
|
298
|
+
* been on a step.
|
|
299
|
+
*
|
|
300
|
+
* ```typescript
|
|
301
|
+
* const durationMs = Date.now() - snapshot.stepEnteredAt;
|
|
302
|
+
* const durationSec = Math.floor(durationMs / 1000);
|
|
303
|
+
* ```
|
|
304
|
+
*
|
|
305
|
+
* Resets to a new timestamp each time the step is entered (including when
|
|
306
|
+
* navigating back to a previously visited step).
|
|
307
|
+
*/
|
|
308
|
+
stepEnteredAt: number;
|
|
201
309
|
/**
|
|
202
310
|
* Field-keyed validation messages for the current step. Empty object when there are none.
|
|
203
|
-
* Use in step templates to render inline per-field errors: `snapshot.
|
|
311
|
+
* Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
|
|
204
312
|
* The shell also renders these automatically in a labeled summary box.
|
|
205
313
|
* Use `"_"` as a key for form-level (non-field-specific) errors.
|
|
206
314
|
*/
|
|
207
|
-
|
|
315
|
+
fieldErrors: Record<string, string>;
|
|
316
|
+
/**
|
|
317
|
+
* Field-keyed warning messages for the current step. Empty object when there are none.
|
|
318
|
+
* Same shape as `fieldErrors` but purely informational — warnings never block navigation.
|
|
319
|
+
* Shells render these in amber/yellow instead of red.
|
|
320
|
+
* Use `"_"` as a key for form-level (non-field-specific) warnings.
|
|
321
|
+
*/
|
|
322
|
+
fieldWarnings: Record<string, string>;
|
|
208
323
|
data: TData;
|
|
209
324
|
}
|
|
210
325
|
|
|
@@ -218,6 +333,7 @@ export type StateChangeCause =
|
|
|
218
333
|
| "goToStep"
|
|
219
334
|
| "goToStepChecked"
|
|
220
335
|
| "setData"
|
|
336
|
+
| "resetStep"
|
|
221
337
|
| "cancel"
|
|
222
338
|
| "restart";
|
|
223
339
|
|
|
@@ -316,6 +432,16 @@ interface ActivePath {
|
|
|
316
432
|
data: PathData;
|
|
317
433
|
visitedStepIds: Set<string>;
|
|
318
434
|
subPathMeta?: Record<string, unknown>;
|
|
435
|
+
/** Snapshot of data taken when the current step was entered. Used by resetStep(). */
|
|
436
|
+
stepEntryData: PathData;
|
|
437
|
+
/** Timestamp (Date.now()) captured when the current step was entered. */
|
|
438
|
+
stepEnteredAt: number;
|
|
439
|
+
/** The selected inner step when the current slot is a StepChoice. Cached on entry. */
|
|
440
|
+
resolvedChoiceStep?: PathStep;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function isStepChoice(item: PathStep | StepChoice): item is StepChoice {
|
|
444
|
+
return "select" in item && "steps" in item;
|
|
319
445
|
}
|
|
320
446
|
|
|
321
447
|
export class PathEngine {
|
|
@@ -325,6 +451,9 @@ export class PathEngine {
|
|
|
325
451
|
private _isNavigating = false;
|
|
326
452
|
/** True after the user has called next() on the current step at least once. Resets on step entry. */
|
|
327
453
|
private _hasAttemptedNext = false;
|
|
454
|
+
/** The path and initial data from the most recent top-level start() call. Used by restart(). */
|
|
455
|
+
private _rootPath: PathDefinition<any> | null = null;
|
|
456
|
+
private _rootInitialData: PathData = {};
|
|
328
457
|
|
|
329
458
|
constructor(options?: PathEngineOptions) {
|
|
330
459
|
if (options?.observers) {
|
|
@@ -374,7 +503,9 @@ export class PathEngine {
|
|
|
374
503
|
currentStepIndex: stackItem.currentStepIndex,
|
|
375
504
|
data: { ...stackItem.data },
|
|
376
505
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
377
|
-
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
|
|
506
|
+
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
507
|
+
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
508
|
+
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
378
509
|
});
|
|
379
510
|
}
|
|
380
511
|
|
|
@@ -393,11 +524,20 @@ export class PathEngine {
|
|
|
393
524
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
394
525
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
395
526
|
// from the parent when this path was started). On restore, it's undefined.
|
|
396
|
-
subPathMeta: undefined
|
|
527
|
+
subPathMeta: undefined,
|
|
528
|
+
stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
|
|
529
|
+
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
397
530
|
};
|
|
398
531
|
|
|
399
532
|
engine._isNavigating = state._isNavigating;
|
|
400
533
|
|
|
534
|
+
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
535
|
+
// always recomputed from current data on restore).
|
|
536
|
+
for (const stackItem of engine.pathStack) {
|
|
537
|
+
engine.cacheResolvedChoiceStep(stackItem);
|
|
538
|
+
}
|
|
539
|
+
engine.cacheResolvedChoiceStep(engine.activePath);
|
|
540
|
+
|
|
401
541
|
return engine;
|
|
402
542
|
}
|
|
403
543
|
|
|
@@ -412,27 +552,30 @@ export class PathEngine {
|
|
|
412
552
|
|
|
413
553
|
public start(path: PathDefinition<any>, initialData: PathData = {}): Promise<void> {
|
|
414
554
|
this.assertPathHasSteps(path);
|
|
555
|
+
this._rootPath = path;
|
|
556
|
+
this._rootInitialData = initialData;
|
|
415
557
|
return this._startAsync(path, initialData);
|
|
416
558
|
}
|
|
417
559
|
|
|
418
560
|
/**
|
|
419
561
|
* Tears down any active path (and the entire sub-path stack) without firing
|
|
420
|
-
* lifecycle hooks or emitting `cancelled`, then immediately
|
|
421
|
-
* path
|
|
562
|
+
* lifecycle hooks or emitting `cancelled`, then immediately restarts the same
|
|
563
|
+
* path with the same initial data that was passed to the original `start()` call.
|
|
422
564
|
*
|
|
423
565
|
* Safe to call at any time — whether a path is running, already completed,
|
|
424
566
|
* or has never been started. Use this to implement a "Start over" button or
|
|
425
567
|
* to retry a path after completion without remounting the host component.
|
|
426
568
|
*
|
|
427
|
-
* @
|
|
428
|
-
* @param initialData Data to seed the fresh path with. Defaults to `{}`.
|
|
569
|
+
* @throws If `restart()` is called before `start()` has ever been called.
|
|
429
570
|
*/
|
|
430
|
-
public restart(
|
|
431
|
-
this.
|
|
571
|
+
public restart(): Promise<void> {
|
|
572
|
+
if (!this._rootPath) {
|
|
573
|
+
throw new Error("Cannot restart: engine has not been started. Call start() first.");
|
|
574
|
+
}
|
|
432
575
|
this._isNavigating = false;
|
|
433
576
|
this.activePath = null;
|
|
434
577
|
this.pathStack.length = 0;
|
|
435
|
-
return this._startAsync(
|
|
578
|
+
return this._startAsync(this._rootPath, { ...this._rootInitialData });
|
|
436
579
|
}
|
|
437
580
|
|
|
438
581
|
/**
|
|
@@ -465,9 +608,9 @@ export class PathEngine {
|
|
|
465
608
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
466
609
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
467
610
|
* API consistency. */
|
|
468
|
-
public cancel(): Promise<void> {
|
|
611
|
+
public async cancel(): Promise<void> {
|
|
469
612
|
const active = this.requireActivePath();
|
|
470
|
-
if (this._isNavigating) return
|
|
613
|
+
if (this._isNavigating) return;
|
|
471
614
|
|
|
472
615
|
const cancelledPathId = active.definition.id;
|
|
473
616
|
const cancelledData = { ...active.data };
|
|
@@ -479,9 +622,12 @@ export class PathEngine {
|
|
|
479
622
|
return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
|
|
480
623
|
}
|
|
481
624
|
|
|
625
|
+
// Top-level path cancelled — call onCancel hook if defined
|
|
482
626
|
this.activePath = null;
|
|
483
627
|
this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
|
|
484
|
-
|
|
628
|
+
if (active.definition.onCancel) {
|
|
629
|
+
await active.definition.onCancel(cancelledData);
|
|
630
|
+
}
|
|
485
631
|
}
|
|
486
632
|
|
|
487
633
|
public setData(key: string, value: unknown): Promise<void> {
|
|
@@ -491,6 +637,20 @@ export class PathEngine {
|
|
|
491
637
|
return Promise.resolve();
|
|
492
638
|
}
|
|
493
639
|
|
|
640
|
+
/**
|
|
641
|
+
* Resets the current step's data to what it was when the step was entered.
|
|
642
|
+
* Useful for "Clear" or "Reset" buttons that undo changes within a step.
|
|
643
|
+
* Emits a `stateChanged` event with cause `"resetStep"`.
|
|
644
|
+
* Throws if no path is active.
|
|
645
|
+
*/
|
|
646
|
+
public resetStep(): Promise<void> {
|
|
647
|
+
const active = this.requireActivePath();
|
|
648
|
+
// Restore data from the snapshot taken when this step was entered
|
|
649
|
+
active.data = { ...active.stepEntryData };
|
|
650
|
+
this.emitStateChanged("resetStep");
|
|
651
|
+
return Promise.resolve();
|
|
652
|
+
}
|
|
653
|
+
|
|
494
654
|
/** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
|
|
495
655
|
public goToStep(stepId: string): Promise<void> {
|
|
496
656
|
const active = this.requireActivePath();
|
|
@@ -527,7 +687,8 @@ export class PathEngine {
|
|
|
527
687
|
}
|
|
528
688
|
|
|
529
689
|
const active = this.activePath;
|
|
530
|
-
const
|
|
690
|
+
const item = this.getCurrentItem(active);
|
|
691
|
+
const effectiveStep = this.getEffectiveStep(active);
|
|
531
692
|
const { steps } = active.definition;
|
|
532
693
|
const stepCount = steps.length;
|
|
533
694
|
|
|
@@ -555,9 +716,10 @@ export class PathEngine {
|
|
|
555
716
|
|
|
556
717
|
return {
|
|
557
718
|
pathId: active.definition.id,
|
|
558
|
-
stepId:
|
|
559
|
-
stepTitle:
|
|
560
|
-
stepMeta:
|
|
719
|
+
stepId: item.id,
|
|
720
|
+
stepTitle: effectiveStep.title ?? item.title,
|
|
721
|
+
stepMeta: effectiveStep.meta ?? item.meta,
|
|
722
|
+
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
561
723
|
stepIndex: active.currentStepIndex,
|
|
562
724
|
stepCount,
|
|
563
725
|
progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
|
|
@@ -577,16 +739,19 @@ export class PathEngine {
|
|
|
577
739
|
rootProgress,
|
|
578
740
|
isNavigating: this._isNavigating,
|
|
579
741
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
580
|
-
canMoveNext: this.evaluateCanMoveNextSync(
|
|
581
|
-
canMovePrevious: this.evaluateGuardSync(
|
|
582
|
-
|
|
742
|
+
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
743
|
+
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
744
|
+
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
745
|
+
fieldWarnings: this.evaluateFieldMessagesSync(effectiveStep.fieldWarnings, active),
|
|
746
|
+
isDirty: this.computeIsDirty(active),
|
|
747
|
+
stepEnteredAt: active.stepEnteredAt,
|
|
583
748
|
data: { ...active.data }
|
|
584
749
|
};
|
|
585
750
|
}
|
|
586
751
|
|
|
587
752
|
/**
|
|
588
753
|
* Exports the current engine state as a plain JSON-serializable object.
|
|
589
|
-
* Use with storage adapters (e.g. `@daltonr/pathwrite-store
|
|
754
|
+
* Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
|
|
590
755
|
* persist and restore wizard progress.
|
|
591
756
|
*
|
|
592
757
|
* Returns `null` if no path is active.
|
|
@@ -608,12 +773,16 @@ export class PathEngine {
|
|
|
608
773
|
currentStepIndex: active.currentStepIndex,
|
|
609
774
|
data: { ...active.data },
|
|
610
775
|
visitedStepIds: Array.from(active.visitedStepIds),
|
|
776
|
+
stepEntryData: { ...active.stepEntryData },
|
|
777
|
+
stepEnteredAt: active.stepEnteredAt,
|
|
611
778
|
pathStack: this.pathStack.map((p) => ({
|
|
612
779
|
pathId: p.definition.id,
|
|
613
780
|
currentStepIndex: p.currentStepIndex,
|
|
614
781
|
data: { ...p.data },
|
|
615
782
|
visitedStepIds: Array.from(p.visitedStepIds),
|
|
616
|
-
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
|
|
783
|
+
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
|
|
784
|
+
stepEntryData: { ...p.stepEntryData },
|
|
785
|
+
stepEnteredAt: p.stepEnteredAt
|
|
617
786
|
})),
|
|
618
787
|
_isNavigating: this._isNavigating
|
|
619
788
|
};
|
|
@@ -640,7 +809,9 @@ export class PathEngine {
|
|
|
640
809
|
currentStepIndex: 0,
|
|
641
810
|
data: { ...initialData },
|
|
642
811
|
visitedStepIds: new Set(),
|
|
643
|
-
subPathMeta: undefined
|
|
812
|
+
subPathMeta: undefined,
|
|
813
|
+
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
814
|
+
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
644
815
|
};
|
|
645
816
|
|
|
646
817
|
this._isNavigating = true;
|
|
@@ -676,7 +847,7 @@ export class PathEngine {
|
|
|
676
847
|
this.emitStateChanged("next");
|
|
677
848
|
|
|
678
849
|
try {
|
|
679
|
-
const step = this.
|
|
850
|
+
const step = this.getEffectiveStep(active);
|
|
680
851
|
|
|
681
852
|
if (await this.canMoveNext(active, step)) {
|
|
682
853
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -713,7 +884,7 @@ export class PathEngine {
|
|
|
713
884
|
this.emitStateChanged("previous");
|
|
714
885
|
|
|
715
886
|
try {
|
|
716
|
-
const step = this.
|
|
887
|
+
const step = this.getEffectiveStep(active);
|
|
717
888
|
|
|
718
889
|
if (await this.canMovePrevious(active, step)) {
|
|
719
890
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -745,7 +916,7 @@ export class PathEngine {
|
|
|
745
916
|
this.emitStateChanged("goToStep");
|
|
746
917
|
|
|
747
918
|
try {
|
|
748
|
-
const currentStep = this.
|
|
919
|
+
const currentStep = this.getEffectiveStep(active);
|
|
749
920
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
750
921
|
|
|
751
922
|
active.currentStepIndex = targetIndex;
|
|
@@ -767,7 +938,7 @@ export class PathEngine {
|
|
|
767
938
|
this.emitStateChanged("goToStepChecked");
|
|
768
939
|
|
|
769
940
|
try {
|
|
770
|
-
const currentStep = this.
|
|
941
|
+
const currentStep = this.getEffectiveStep(active);
|
|
771
942
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
772
943
|
const allowed = goingForward
|
|
773
944
|
? await this.canMoveNext(active, currentStep)
|
|
@@ -805,13 +976,14 @@ export class PathEngine {
|
|
|
805
976
|
const parent = this.activePath;
|
|
806
977
|
|
|
807
978
|
if (parent) {
|
|
808
|
-
const
|
|
979
|
+
const parentItem = this.getCurrentItem(parent);
|
|
980
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
809
981
|
if (parentStep.onSubPathCancel) {
|
|
810
982
|
const ctx: PathStepContext = {
|
|
811
983
|
pathId: parent.definition.id,
|
|
812
|
-
stepId:
|
|
984
|
+
stepId: parentItem.id,
|
|
813
985
|
data: { ...parent.data },
|
|
814
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
986
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
815
987
|
};
|
|
816
988
|
this.applyPatch(
|
|
817
989
|
await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
|
|
@@ -838,14 +1010,15 @@ export class PathEngine {
|
|
|
838
1010
|
// The meta is stored on the parent, not the sub-path
|
|
839
1011
|
const finishedMeta = parent.subPathMeta;
|
|
840
1012
|
this.activePath = parent;
|
|
841
|
-
const
|
|
1013
|
+
const parentItem = this.getCurrentItem(parent);
|
|
1014
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
842
1015
|
|
|
843
1016
|
if (parentStep.onSubPathComplete) {
|
|
844
1017
|
const ctx: PathStepContext = {
|
|
845
1018
|
pathId: parent.definition.id,
|
|
846
|
-
stepId:
|
|
1019
|
+
stepId: parentItem.id,
|
|
847
1020
|
data: { ...parent.data },
|
|
848
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
1021
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
849
1022
|
};
|
|
850
1023
|
this.applyPatch(
|
|
851
1024
|
await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
|
|
@@ -859,8 +1032,12 @@ export class PathEngine {
|
|
|
859
1032
|
snapshot: this.snapshot()!
|
|
860
1033
|
});
|
|
861
1034
|
} else {
|
|
1035
|
+
// Top-level path completed — call onComplete hook if defined
|
|
862
1036
|
this.activePath = null;
|
|
863
1037
|
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
1038
|
+
if (finished.definition.onComplete) {
|
|
1039
|
+
await finished.definition.onComplete(finishedData);
|
|
1040
|
+
}
|
|
864
1041
|
}
|
|
865
1042
|
}
|
|
866
1043
|
|
|
@@ -891,10 +1068,63 @@ export class PathEngine {
|
|
|
891
1068
|
this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
|
|
892
1069
|
}
|
|
893
1070
|
|
|
894
|
-
|
|
1071
|
+
/** Returns the raw item at the current index — either a PathStep or a StepChoice. */
|
|
1072
|
+
private getCurrentItem(active: ActivePath): PathStep | StepChoice {
|
|
895
1073
|
return active.definition.steps[active.currentStepIndex];
|
|
896
1074
|
}
|
|
897
1075
|
|
|
1076
|
+
/**
|
|
1077
|
+
* Calls `StepChoice.select` and caches the chosen inner step in
|
|
1078
|
+
* `active.resolvedChoiceStep`. Clears the cache when the current item is a
|
|
1079
|
+
* plain `PathStep`. Throws if `select` returns an id not present in `steps`.
|
|
1080
|
+
*/
|
|
1081
|
+
private cacheResolvedChoiceStep(active: ActivePath): void {
|
|
1082
|
+
const item = this.getCurrentItem(active);
|
|
1083
|
+
if (!isStepChoice(item)) {
|
|
1084
|
+
active.resolvedChoiceStep = undefined;
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
const ctx: PathStepContext = {
|
|
1088
|
+
pathId: active.definition.id,
|
|
1089
|
+
stepId: item.id,
|
|
1090
|
+
data: { ...active.data },
|
|
1091
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1092
|
+
};
|
|
1093
|
+
let selectedId: string;
|
|
1094
|
+
try {
|
|
1095
|
+
selectedId = item.select(ctx);
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
`[pathwrite] StepChoice "${item.id}".select() threw an error: ${err}`
|
|
1099
|
+
);
|
|
1100
|
+
}
|
|
1101
|
+
const found = item.steps.find((s) => s.id === selectedId);
|
|
1102
|
+
if (!found) {
|
|
1103
|
+
throw new Error(
|
|
1104
|
+
`[pathwrite] StepChoice "${item.id}".select() returned "${selectedId}" ` +
|
|
1105
|
+
`but no step with that id exists in its steps array.`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
active.resolvedChoiceStep = found;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* Returns the effective `PathStep` for the current position. When the
|
|
1113
|
+
* current item is a `StepChoice`, returns the cached resolved inner step.
|
|
1114
|
+
* When it is a plain `PathStep`, returns it directly.
|
|
1115
|
+
*/
|
|
1116
|
+
private getEffectiveStep(active: ActivePath): PathStep {
|
|
1117
|
+
if (active.resolvedChoiceStep) return active.resolvedChoiceStep;
|
|
1118
|
+
const item = this.getCurrentItem(active);
|
|
1119
|
+
if (isStepChoice(item)) {
|
|
1120
|
+
// resolvedChoiceStep should always be set after enterCurrentStep; this
|
|
1121
|
+
// branch is a defensive fallback (e.g. during fromState restore).
|
|
1122
|
+
this.cacheResolvedChoiceStep(active);
|
|
1123
|
+
return active.resolvedChoiceStep!;
|
|
1124
|
+
}
|
|
1125
|
+
return item;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
898
1128
|
private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
|
|
899
1129
|
if (patch && typeof patch === "object") {
|
|
900
1130
|
const active = this.activePath;
|
|
@@ -912,15 +1142,15 @@ export class PathEngine {
|
|
|
912
1142
|
active.currentStepIndex >= 0 &&
|
|
913
1143
|
active.currentStepIndex < active.definition.steps.length
|
|
914
1144
|
) {
|
|
915
|
-
const
|
|
916
|
-
if (!
|
|
1145
|
+
const item = active.definition.steps[active.currentStepIndex];
|
|
1146
|
+
if (!item.shouldSkip) break;
|
|
917
1147
|
const ctx: PathStepContext = {
|
|
918
1148
|
pathId: active.definition.id,
|
|
919
|
-
stepId:
|
|
1149
|
+
stepId: item.id,
|
|
920
1150
|
data: { ...active.data },
|
|
921
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1151
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
922
1152
|
};
|
|
923
|
-
const skip = await
|
|
1153
|
+
const skip = await item.shouldSkip(ctx);
|
|
924
1154
|
if (!skip) break;
|
|
925
1155
|
active.currentStepIndex += direction;
|
|
926
1156
|
}
|
|
@@ -931,19 +1161,31 @@ export class PathEngine {
|
|
|
931
1161
|
this._hasAttemptedNext = false;
|
|
932
1162
|
const active = this.activePath;
|
|
933
1163
|
if (!active) return;
|
|
934
|
-
|
|
935
|
-
|
|
1164
|
+
|
|
1165
|
+
// Save a snapshot of the data as it was when entering this step (for resetStep)
|
|
1166
|
+
active.stepEntryData = { ...active.data };
|
|
1167
|
+
|
|
1168
|
+
// Capture the timestamp when entering this step (for analytics, timeout warnings)
|
|
1169
|
+
active.stepEnteredAt = Date.now();
|
|
1170
|
+
|
|
1171
|
+
const item = this.getCurrentItem(active);
|
|
1172
|
+
|
|
1173
|
+
// Resolve the inner step when this slot is a StepChoice
|
|
1174
|
+
this.cacheResolvedChoiceStep(active);
|
|
1175
|
+
|
|
1176
|
+
const effectiveStep = this.getEffectiveStep(active);
|
|
1177
|
+
const isFirstEntry = !active.visitedStepIds.has(item.id);
|
|
936
1178
|
// Mark as visited before calling onEnter so re-entrant calls see the
|
|
937
1179
|
// correct isFirstEntry value.
|
|
938
|
-
active.visitedStepIds.add(
|
|
939
|
-
if (!
|
|
1180
|
+
active.visitedStepIds.add(item.id);
|
|
1181
|
+
if (!effectiveStep.onEnter) return;
|
|
940
1182
|
const ctx: PathStepContext = {
|
|
941
1183
|
pathId: active.definition.id,
|
|
942
|
-
stepId:
|
|
1184
|
+
stepId: item.id,
|
|
943
1185
|
data: { ...active.data },
|
|
944
1186
|
isFirstEntry
|
|
945
1187
|
};
|
|
946
|
-
return
|
|
1188
|
+
return effectiveStep.onEnter(ctx);
|
|
947
1189
|
}
|
|
948
1190
|
|
|
949
1191
|
private async leaveCurrentStep(
|
|
@@ -973,8 +1215,8 @@ export class PathEngine {
|
|
|
973
1215
|
};
|
|
974
1216
|
return step.canMoveNext(ctx);
|
|
975
1217
|
}
|
|
976
|
-
if (step.
|
|
977
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1218
|
+
if (step.fieldErrors) {
|
|
1219
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
978
1220
|
}
|
|
979
1221
|
return true;
|
|
980
1222
|
}
|
|
@@ -1012,12 +1254,12 @@ export class PathEngine {
|
|
|
1012
1254
|
active: ActivePath
|
|
1013
1255
|
): boolean {
|
|
1014
1256
|
if (!guard) return true;
|
|
1015
|
-
const
|
|
1257
|
+
const item = this.getCurrentItem(active);
|
|
1016
1258
|
const ctx: PathStepContext = {
|
|
1017
1259
|
pathId: active.definition.id,
|
|
1018
|
-
stepId:
|
|
1260
|
+
stepId: item.id,
|
|
1019
1261
|
data: { ...active.data },
|
|
1020
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1262
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1021
1263
|
};
|
|
1022
1264
|
try {
|
|
1023
1265
|
const result = guard(ctx);
|
|
@@ -1025,7 +1267,7 @@ export class PathEngine {
|
|
|
1025
1267
|
// Async guard detected - warn and return optimistic default
|
|
1026
1268
|
if (result && typeof result.then === "function") {
|
|
1027
1269
|
console.warn(
|
|
1028
|
-
`[pathwrite] Async guard detected on step "${
|
|
1270
|
+
`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
1029
1271
|
`Guards in snapshots must be synchronous. ` +
|
|
1030
1272
|
`Returning true (optimistic) as default. ` +
|
|
1031
1273
|
`The async guard will still be enforced during actual navigation.`
|
|
@@ -1034,7 +1276,7 @@ export class PathEngine {
|
|
|
1034
1276
|
return true;
|
|
1035
1277
|
} catch (err) {
|
|
1036
1278
|
console.warn(
|
|
1037
|
-
`[pathwrite] Guard on step "${
|
|
1279
|
+
`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
1038
1280
|
`Returning true (allow navigation) as a safe default. ` +
|
|
1039
1281
|
`Note: guards are evaluated before onEnter runs on first entry — ` +
|
|
1040
1282
|
`ensure guards handle missing/undefined data gracefully.`,
|
|
@@ -1047,24 +1289,24 @@ export class PathEngine {
|
|
|
1047
1289
|
/**
|
|
1048
1290
|
* Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
|
|
1049
1291
|
* When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
|
|
1050
|
-
* When absent but `
|
|
1292
|
+
* When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
|
|
1051
1293
|
* When neither is defined, returns `true`.
|
|
1052
1294
|
*/
|
|
1053
1295
|
private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
|
|
1054
1296
|
if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
|
|
1055
|
-
if (step.
|
|
1056
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1297
|
+
if (step.fieldErrors) {
|
|
1298
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
1057
1299
|
}
|
|
1058
1300
|
return true;
|
|
1059
1301
|
}
|
|
1060
1302
|
|
|
1061
1303
|
/**
|
|
1062
|
-
* Evaluates a
|
|
1304
|
+
* Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
|
|
1063
1305
|
* If the hook is absent, returns `{}`.
|
|
1064
1306
|
* If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
|
|
1065
1307
|
* `undefined` values are stripped from the result — only fields with a defined message are included.
|
|
1066
1308
|
*
|
|
1067
|
-
* **Note:** Like guards, `
|
|
1309
|
+
* **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
|
|
1068
1310
|
* entry. Write it defensively so it does not throw when fields are absent.
|
|
1069
1311
|
*/
|
|
1070
1312
|
private evaluateFieldMessagesSync(
|
|
@@ -1072,12 +1314,12 @@ export class PathEngine {
|
|
|
1072
1314
|
active: ActivePath
|
|
1073
1315
|
): Record<string, string> {
|
|
1074
1316
|
if (!fn) return {};
|
|
1075
|
-
const
|
|
1317
|
+
const item = this.getCurrentItem(active);
|
|
1076
1318
|
const ctx: PathStepContext = {
|
|
1077
1319
|
pathId: active.definition.id,
|
|
1078
|
-
stepId:
|
|
1320
|
+
stepId: item.id,
|
|
1079
1321
|
data: { ...active.data },
|
|
1080
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1322
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1081
1323
|
};
|
|
1082
1324
|
try {
|
|
1083
1325
|
const result = fn(ctx);
|
|
@@ -1092,22 +1334,45 @@ export class PathEngine {
|
|
|
1092
1334
|
}
|
|
1093
1335
|
if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
|
|
1094
1336
|
console.warn(
|
|
1095
|
-
`[pathwrite] Async
|
|
1096
|
-
`
|
|
1337
|
+
`[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
|
|
1338
|
+
`fieldErrors must be synchronous. Returning {} as default. ` +
|
|
1097
1339
|
`Use synchronous validation or move async checks to canMoveNext.`
|
|
1098
1340
|
);
|
|
1099
1341
|
}
|
|
1100
1342
|
return {};
|
|
1101
1343
|
} catch (err) {
|
|
1102
1344
|
console.warn(
|
|
1103
|
-
`[pathwrite]
|
|
1345
|
+
`[pathwrite] fieldErrors on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
1104
1346
|
`Returning {} as a safe default. ` +
|
|
1105
|
-
`Note:
|
|
1347
|
+
`Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
|
|
1106
1348
|
`ensure it handles missing/undefined data gracefully.`,
|
|
1107
1349
|
err
|
|
1108
1350
|
);
|
|
1109
1351
|
return {};
|
|
1110
1352
|
}
|
|
1111
1353
|
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Compares the current step data to the snapshot taken when the step was entered.
|
|
1357
|
+
* Returns `true` if any data value has changed.
|
|
1358
|
+
*
|
|
1359
|
+
* Performs a shallow comparison — only top-level keys are checked. Nested objects
|
|
1360
|
+
* are compared by reference, not by deep equality.
|
|
1361
|
+
*/
|
|
1362
|
+
private computeIsDirty(active: ActivePath): boolean {
|
|
1363
|
+
const current = active.data;
|
|
1364
|
+
const entry = active.stepEntryData;
|
|
1365
|
+
|
|
1366
|
+
// Get all unique keys from both objects
|
|
1367
|
+
const allKeys = new Set([...Object.keys(current), ...Object.keys(entry)]);
|
|
1368
|
+
|
|
1369
|
+
for (const key of allKeys) {
|
|
1370
|
+
if (current[key] !== entry[key]) {
|
|
1371
|
+
return true;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
return false;
|
|
1376
|
+
}
|
|
1112
1377
|
}
|
|
1113
1378
|
|