@daltonr/pathwrite-core 0.6.3 → 0.8.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/LICENSE +1 -1
- package/README.md +57 -5
- package/dist/index.d.ts +189 -16
- package/dist/index.js +198 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +381 -66
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
|
}
|
|
@@ -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";
|
|
@@ -129,11 +202,44 @@ export interface StepSummary {
|
|
|
129
202
|
status: StepStatus;
|
|
130
203
|
}
|
|
131
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Controls how the shell renders progress bars when a sub-path is active.
|
|
207
|
+
*
|
|
208
|
+
* | Value | Behaviour |
|
|
209
|
+
* |----------------|------------------------------------------------------------------------|
|
|
210
|
+
* | `"merged"` | Root and sub-path bars in one card (default) |
|
|
211
|
+
* | `"split"` | Root and sub-path bars as separate cards |
|
|
212
|
+
* | `"rootOnly"` | Only the root bar — sub-path bar hidden |
|
|
213
|
+
* | `"activeOnly"` | Only the active (sub-path) bar — root bar hidden (pre-v0.7 behaviour) |
|
|
214
|
+
*/
|
|
215
|
+
export type ProgressLayout = "merged" | "split" | "rootOnly" | "activeOnly";
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Summary of the root (top-level) path's progress. Present on `PathSnapshot`
|
|
219
|
+
* only when `nestingLevel > 0` — i.e. a sub-path is active.
|
|
220
|
+
*
|
|
221
|
+
* Shells use this to keep the top-level progress bar visible while navigating
|
|
222
|
+
* a sub-path, so users never lose sight of where they are in the main flow.
|
|
223
|
+
*/
|
|
224
|
+
export interface RootProgress {
|
|
225
|
+
pathId: string;
|
|
226
|
+
stepIndex: number;
|
|
227
|
+
stepCount: number;
|
|
228
|
+
progress: number;
|
|
229
|
+
steps: StepSummary[];
|
|
230
|
+
}
|
|
231
|
+
|
|
132
232
|
export interface PathSnapshot<TData extends PathData = PathData> {
|
|
133
233
|
pathId: string;
|
|
134
234
|
stepId: string;
|
|
135
235
|
stepTitle?: string;
|
|
136
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;
|
|
137
243
|
stepIndex: number;
|
|
138
244
|
stepCount: number;
|
|
139
245
|
progress: number;
|
|
@@ -141,9 +247,15 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
141
247
|
isFirstStep: boolean;
|
|
142
248
|
isLastStep: boolean;
|
|
143
249
|
nestingLevel: number;
|
|
250
|
+
/**
|
|
251
|
+
* Progress summary of the root (top-level) path. Only present when
|
|
252
|
+
* `nestingLevel > 0`. Shells use this to render a persistent top-level
|
|
253
|
+
* progress bar above the sub-path's own progress bar.
|
|
254
|
+
*/
|
|
255
|
+
rootProgress?: RootProgress;
|
|
144
256
|
/** True while an async guard or hook is executing. Use to disable navigation controls. */
|
|
145
257
|
isNavigating: boolean;
|
|
146
|
-
/** 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. */
|
|
147
259
|
canMoveNext: boolean;
|
|
148
260
|
/** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
|
|
149
261
|
canMovePrevious: boolean;
|
|
@@ -156,22 +268,58 @@ export interface PathSnapshot<TData extends PathData = PathData> {
|
|
|
156
268
|
* render and only appear after the user has attempted to proceed:
|
|
157
269
|
*
|
|
158
270
|
* ```svelte
|
|
159
|
-
* {#if snapshot.hasAttemptedNext && snapshot.
|
|
160
|
-
* <span class="error">{snapshot.
|
|
271
|
+
* {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
|
|
272
|
+
* <span class="error">{snapshot.fieldErrors.email}</span>
|
|
161
273
|
* {/if}
|
|
162
274
|
* ```
|
|
163
275
|
*
|
|
164
|
-
* The shell itself uses this flag to gate its own automatic `
|
|
276
|
+
* The shell itself uses this flag to gate its own automatic `fieldErrors`
|
|
165
277
|
* summary rendering — errors are never shown before the first Next attempt.
|
|
166
278
|
*/
|
|
167
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;
|
|
168
309
|
/**
|
|
169
310
|
* Field-keyed validation messages for the current step. Empty object when there are none.
|
|
170
|
-
* 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']`.
|
|
171
312
|
* The shell also renders these automatically in a labeled summary box.
|
|
172
313
|
* Use `"_"` as a key for form-level (non-field-specific) errors.
|
|
173
314
|
*/
|
|
174
|
-
|
|
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>;
|
|
175
323
|
data: TData;
|
|
176
324
|
}
|
|
177
325
|
|
|
@@ -185,6 +333,7 @@ export type StateChangeCause =
|
|
|
185
333
|
| "goToStep"
|
|
186
334
|
| "goToStepChecked"
|
|
187
335
|
| "setData"
|
|
336
|
+
| "resetStep"
|
|
188
337
|
| "cancel"
|
|
189
338
|
| "restart";
|
|
190
339
|
|
|
@@ -283,6 +432,16 @@ interface ActivePath {
|
|
|
283
432
|
data: PathData;
|
|
284
433
|
visitedStepIds: Set<string>;
|
|
285
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;
|
|
286
445
|
}
|
|
287
446
|
|
|
288
447
|
export class PathEngine {
|
|
@@ -341,7 +500,9 @@ export class PathEngine {
|
|
|
341
500
|
currentStepIndex: stackItem.currentStepIndex,
|
|
342
501
|
data: { ...stackItem.data },
|
|
343
502
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
344
|
-
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined
|
|
503
|
+
subPathMeta: stackItem.subPathMeta ? { ...stackItem.subPathMeta } : undefined,
|
|
504
|
+
stepEntryData: stackItem.stepEntryData ? { ...stackItem.stepEntryData } : { ...stackItem.data },
|
|
505
|
+
stepEnteredAt: stackItem.stepEnteredAt ?? Date.now()
|
|
345
506
|
});
|
|
346
507
|
}
|
|
347
508
|
|
|
@@ -360,11 +521,20 @@ export class PathEngine {
|
|
|
360
521
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
361
522
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
362
523
|
// from the parent when this path was started). On restore, it's undefined.
|
|
363
|
-
subPathMeta: undefined
|
|
524
|
+
subPathMeta: undefined,
|
|
525
|
+
stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
|
|
526
|
+
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
364
527
|
};
|
|
365
528
|
|
|
366
529
|
engine._isNavigating = state._isNavigating;
|
|
367
530
|
|
|
531
|
+
// Re-derive the selected inner step for any StepChoice slots (not serialized —
|
|
532
|
+
// always recomputed from current data on restore).
|
|
533
|
+
for (const stackItem of engine.pathStack) {
|
|
534
|
+
engine.cacheResolvedChoiceStep(stackItem);
|
|
535
|
+
}
|
|
536
|
+
engine.cacheResolvedChoiceStep(engine.activePath);
|
|
537
|
+
|
|
368
538
|
return engine;
|
|
369
539
|
}
|
|
370
540
|
|
|
@@ -432,9 +602,9 @@ export class PathEngine {
|
|
|
432
602
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
433
603
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
434
604
|
* API consistency. */
|
|
435
|
-
public cancel(): Promise<void> {
|
|
605
|
+
public async cancel(): Promise<void> {
|
|
436
606
|
const active = this.requireActivePath();
|
|
437
|
-
if (this._isNavigating) return
|
|
607
|
+
if (this._isNavigating) return;
|
|
438
608
|
|
|
439
609
|
const cancelledPathId = active.definition.id;
|
|
440
610
|
const cancelledData = { ...active.data };
|
|
@@ -446,9 +616,12 @@ export class PathEngine {
|
|
|
446
616
|
return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
|
|
447
617
|
}
|
|
448
618
|
|
|
619
|
+
// Top-level path cancelled — call onCancel hook if defined
|
|
449
620
|
this.activePath = null;
|
|
450
621
|
this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
|
|
451
|
-
|
|
622
|
+
if (active.definition.onCancel) {
|
|
623
|
+
await active.definition.onCancel(cancelledData);
|
|
624
|
+
}
|
|
452
625
|
}
|
|
453
626
|
|
|
454
627
|
public setData(key: string, value: unknown): Promise<void> {
|
|
@@ -458,6 +631,20 @@ export class PathEngine {
|
|
|
458
631
|
return Promise.resolve();
|
|
459
632
|
}
|
|
460
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Resets the current step's data to what it was when the step was entered.
|
|
636
|
+
* Useful for "Clear" or "Reset" buttons that undo changes within a step.
|
|
637
|
+
* Emits a `stateChanged` event with cause `"resetStep"`.
|
|
638
|
+
* Throws if no path is active.
|
|
639
|
+
*/
|
|
640
|
+
public resetStep(): Promise<void> {
|
|
641
|
+
const active = this.requireActivePath();
|
|
642
|
+
// Restore data from the snapshot taken when this step was entered
|
|
643
|
+
active.data = { ...active.stepEntryData };
|
|
644
|
+
this.emitStateChanged("resetStep");
|
|
645
|
+
return Promise.resolve();
|
|
646
|
+
}
|
|
647
|
+
|
|
461
648
|
/** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
|
|
462
649
|
public goToStep(stepId: string): Promise<void> {
|
|
463
650
|
const active = this.requireActivePath();
|
|
@@ -494,15 +681,39 @@ export class PathEngine {
|
|
|
494
681
|
}
|
|
495
682
|
|
|
496
683
|
const active = this.activePath;
|
|
497
|
-
const
|
|
684
|
+
const item = this.getCurrentItem(active);
|
|
685
|
+
const effectiveStep = this.getEffectiveStep(active);
|
|
498
686
|
const { steps } = active.definition;
|
|
499
687
|
const stepCount = steps.length;
|
|
500
688
|
|
|
689
|
+
// Build rootProgress from the bottom of the stack (the top-level path)
|
|
690
|
+
let rootProgress: RootProgress | undefined;
|
|
691
|
+
if (this.pathStack.length > 0) {
|
|
692
|
+
const root = this.pathStack[0];
|
|
693
|
+
const rootSteps = root.definition.steps;
|
|
694
|
+
const rootStepCount = rootSteps.length;
|
|
695
|
+
rootProgress = {
|
|
696
|
+
pathId: root.definition.id,
|
|
697
|
+
stepIndex: root.currentStepIndex,
|
|
698
|
+
stepCount: rootStepCount,
|
|
699
|
+
progress: rootStepCount <= 1 ? 1 : root.currentStepIndex / (rootStepCount - 1),
|
|
700
|
+
steps: rootSteps.map((s, i) => ({
|
|
701
|
+
id: s.id,
|
|
702
|
+
title: s.title,
|
|
703
|
+
meta: s.meta,
|
|
704
|
+
status: i < root.currentStepIndex ? "completed" as const
|
|
705
|
+
: i === root.currentStepIndex ? "current" as const
|
|
706
|
+
: "upcoming" as const
|
|
707
|
+
}))
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
501
711
|
return {
|
|
502
712
|
pathId: active.definition.id,
|
|
503
|
-
stepId:
|
|
504
|
-
stepTitle:
|
|
505
|
-
stepMeta:
|
|
713
|
+
stepId: item.id,
|
|
714
|
+
stepTitle: effectiveStep.title ?? item.title,
|
|
715
|
+
stepMeta: effectiveStep.meta ?? item.meta,
|
|
716
|
+
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
506
717
|
stepIndex: active.currentStepIndex,
|
|
507
718
|
stepCount,
|
|
508
719
|
progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
|
|
@@ -519,11 +730,15 @@ export class PathEngine {
|
|
|
519
730
|
active.currentStepIndex === stepCount - 1 &&
|
|
520
731
|
this.pathStack.length === 0,
|
|
521
732
|
nestingLevel: this.pathStack.length,
|
|
733
|
+
rootProgress,
|
|
522
734
|
isNavigating: this._isNavigating,
|
|
523
735
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
524
|
-
canMoveNext: this.evaluateCanMoveNextSync(
|
|
525
|
-
canMovePrevious: this.evaluateGuardSync(
|
|
526
|
-
|
|
736
|
+
canMoveNext: this.evaluateCanMoveNextSync(effectiveStep, active),
|
|
737
|
+
canMovePrevious: this.evaluateGuardSync(effectiveStep.canMovePrevious, active),
|
|
738
|
+
fieldErrors: this.evaluateFieldMessagesSync(effectiveStep.fieldErrors, active),
|
|
739
|
+
fieldWarnings: this.evaluateFieldMessagesSync(effectiveStep.fieldWarnings, active),
|
|
740
|
+
isDirty: this.computeIsDirty(active),
|
|
741
|
+
stepEnteredAt: active.stepEnteredAt,
|
|
527
742
|
data: { ...active.data }
|
|
528
743
|
};
|
|
529
744
|
}
|
|
@@ -552,12 +767,16 @@ export class PathEngine {
|
|
|
552
767
|
currentStepIndex: active.currentStepIndex,
|
|
553
768
|
data: { ...active.data },
|
|
554
769
|
visitedStepIds: Array.from(active.visitedStepIds),
|
|
770
|
+
stepEntryData: { ...active.stepEntryData },
|
|
771
|
+
stepEnteredAt: active.stepEnteredAt,
|
|
555
772
|
pathStack: this.pathStack.map((p) => ({
|
|
556
773
|
pathId: p.definition.id,
|
|
557
774
|
currentStepIndex: p.currentStepIndex,
|
|
558
775
|
data: { ...p.data },
|
|
559
776
|
visitedStepIds: Array.from(p.visitedStepIds),
|
|
560
|
-
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
|
|
777
|
+
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
|
|
778
|
+
stepEntryData: { ...p.stepEntryData },
|
|
779
|
+
stepEnteredAt: p.stepEnteredAt
|
|
561
780
|
})),
|
|
562
781
|
_isNavigating: this._isNavigating
|
|
563
782
|
};
|
|
@@ -584,7 +803,9 @@ export class PathEngine {
|
|
|
584
803
|
currentStepIndex: 0,
|
|
585
804
|
data: { ...initialData },
|
|
586
805
|
visitedStepIds: new Set(),
|
|
587
|
-
subPathMeta: undefined
|
|
806
|
+
subPathMeta: undefined,
|
|
807
|
+
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
808
|
+
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
588
809
|
};
|
|
589
810
|
|
|
590
811
|
this._isNavigating = true;
|
|
@@ -620,7 +841,7 @@ export class PathEngine {
|
|
|
620
841
|
this.emitStateChanged("next");
|
|
621
842
|
|
|
622
843
|
try {
|
|
623
|
-
const step = this.
|
|
844
|
+
const step = this.getEffectiveStep(active);
|
|
624
845
|
|
|
625
846
|
if (await this.canMoveNext(active, step)) {
|
|
626
847
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -657,7 +878,7 @@ export class PathEngine {
|
|
|
657
878
|
this.emitStateChanged("previous");
|
|
658
879
|
|
|
659
880
|
try {
|
|
660
|
-
const step = this.
|
|
881
|
+
const step = this.getEffectiveStep(active);
|
|
661
882
|
|
|
662
883
|
if (await this.canMovePrevious(active, step)) {
|
|
663
884
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -689,7 +910,7 @@ export class PathEngine {
|
|
|
689
910
|
this.emitStateChanged("goToStep");
|
|
690
911
|
|
|
691
912
|
try {
|
|
692
|
-
const currentStep = this.
|
|
913
|
+
const currentStep = this.getEffectiveStep(active);
|
|
693
914
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
694
915
|
|
|
695
916
|
active.currentStepIndex = targetIndex;
|
|
@@ -711,7 +932,7 @@ export class PathEngine {
|
|
|
711
932
|
this.emitStateChanged("goToStepChecked");
|
|
712
933
|
|
|
713
934
|
try {
|
|
714
|
-
const currentStep = this.
|
|
935
|
+
const currentStep = this.getEffectiveStep(active);
|
|
715
936
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
716
937
|
const allowed = goingForward
|
|
717
938
|
? await this.canMoveNext(active, currentStep)
|
|
@@ -749,13 +970,14 @@ export class PathEngine {
|
|
|
749
970
|
const parent = this.activePath;
|
|
750
971
|
|
|
751
972
|
if (parent) {
|
|
752
|
-
const
|
|
973
|
+
const parentItem = this.getCurrentItem(parent);
|
|
974
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
753
975
|
if (parentStep.onSubPathCancel) {
|
|
754
976
|
const ctx: PathStepContext = {
|
|
755
977
|
pathId: parent.definition.id,
|
|
756
|
-
stepId:
|
|
978
|
+
stepId: parentItem.id,
|
|
757
979
|
data: { ...parent.data },
|
|
758
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
980
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
759
981
|
};
|
|
760
982
|
this.applyPatch(
|
|
761
983
|
await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
|
|
@@ -782,14 +1004,15 @@ export class PathEngine {
|
|
|
782
1004
|
// The meta is stored on the parent, not the sub-path
|
|
783
1005
|
const finishedMeta = parent.subPathMeta;
|
|
784
1006
|
this.activePath = parent;
|
|
785
|
-
const
|
|
1007
|
+
const parentItem = this.getCurrentItem(parent);
|
|
1008
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
786
1009
|
|
|
787
1010
|
if (parentStep.onSubPathComplete) {
|
|
788
1011
|
const ctx: PathStepContext = {
|
|
789
1012
|
pathId: parent.definition.id,
|
|
790
|
-
stepId:
|
|
1013
|
+
stepId: parentItem.id,
|
|
791
1014
|
data: { ...parent.data },
|
|
792
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
1015
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
793
1016
|
};
|
|
794
1017
|
this.applyPatch(
|
|
795
1018
|
await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
|
|
@@ -803,8 +1026,12 @@ export class PathEngine {
|
|
|
803
1026
|
snapshot: this.snapshot()!
|
|
804
1027
|
});
|
|
805
1028
|
} else {
|
|
1029
|
+
// Top-level path completed — call onComplete hook if defined
|
|
806
1030
|
this.activePath = null;
|
|
807
1031
|
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
1032
|
+
if (finished.definition.onComplete) {
|
|
1033
|
+
await finished.definition.onComplete(finishedData);
|
|
1034
|
+
}
|
|
808
1035
|
}
|
|
809
1036
|
}
|
|
810
1037
|
|
|
@@ -835,10 +1062,63 @@ export class PathEngine {
|
|
|
835
1062
|
this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
|
|
836
1063
|
}
|
|
837
1064
|
|
|
838
|
-
|
|
1065
|
+
/** Returns the raw item at the current index — either a PathStep or a StepChoice. */
|
|
1066
|
+
private getCurrentItem(active: ActivePath): PathStep | StepChoice {
|
|
839
1067
|
return active.definition.steps[active.currentStepIndex];
|
|
840
1068
|
}
|
|
841
1069
|
|
|
1070
|
+
/**
|
|
1071
|
+
* Calls `StepChoice.select` and caches the chosen inner step in
|
|
1072
|
+
* `active.resolvedChoiceStep`. Clears the cache when the current item is a
|
|
1073
|
+
* plain `PathStep`. Throws if `select` returns an id not present in `steps`.
|
|
1074
|
+
*/
|
|
1075
|
+
private cacheResolvedChoiceStep(active: ActivePath): void {
|
|
1076
|
+
const item = this.getCurrentItem(active);
|
|
1077
|
+
if (!isStepChoice(item)) {
|
|
1078
|
+
active.resolvedChoiceStep = undefined;
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
const ctx: PathStepContext = {
|
|
1082
|
+
pathId: active.definition.id,
|
|
1083
|
+
stepId: item.id,
|
|
1084
|
+
data: { ...active.data },
|
|
1085
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1086
|
+
};
|
|
1087
|
+
let selectedId: string;
|
|
1088
|
+
try {
|
|
1089
|
+
selectedId = item.select(ctx);
|
|
1090
|
+
} catch (err) {
|
|
1091
|
+
throw new Error(
|
|
1092
|
+
`[pathwrite] StepChoice "${item.id}".select() threw an error: ${err}`
|
|
1093
|
+
);
|
|
1094
|
+
}
|
|
1095
|
+
const found = item.steps.find((s) => s.id === selectedId);
|
|
1096
|
+
if (!found) {
|
|
1097
|
+
throw new Error(
|
|
1098
|
+
`[pathwrite] StepChoice "${item.id}".select() returned "${selectedId}" ` +
|
|
1099
|
+
`but no step with that id exists in its steps array.`
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
active.resolvedChoiceStep = found;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Returns the effective `PathStep` for the current position. When the
|
|
1107
|
+
* current item is a `StepChoice`, returns the cached resolved inner step.
|
|
1108
|
+
* When it is a plain `PathStep`, returns it directly.
|
|
1109
|
+
*/
|
|
1110
|
+
private getEffectiveStep(active: ActivePath): PathStep {
|
|
1111
|
+
if (active.resolvedChoiceStep) return active.resolvedChoiceStep;
|
|
1112
|
+
const item = this.getCurrentItem(active);
|
|
1113
|
+
if (isStepChoice(item)) {
|
|
1114
|
+
// resolvedChoiceStep should always be set after enterCurrentStep; this
|
|
1115
|
+
// branch is a defensive fallback (e.g. during fromState restore).
|
|
1116
|
+
this.cacheResolvedChoiceStep(active);
|
|
1117
|
+
return active.resolvedChoiceStep!;
|
|
1118
|
+
}
|
|
1119
|
+
return item;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
842
1122
|
private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
|
|
843
1123
|
if (patch && typeof patch === "object") {
|
|
844
1124
|
const active = this.activePath;
|
|
@@ -856,15 +1136,15 @@ export class PathEngine {
|
|
|
856
1136
|
active.currentStepIndex >= 0 &&
|
|
857
1137
|
active.currentStepIndex < active.definition.steps.length
|
|
858
1138
|
) {
|
|
859
|
-
const
|
|
860
|
-
if (!
|
|
1139
|
+
const item = active.definition.steps[active.currentStepIndex];
|
|
1140
|
+
if (!item.shouldSkip) break;
|
|
861
1141
|
const ctx: PathStepContext = {
|
|
862
1142
|
pathId: active.definition.id,
|
|
863
|
-
stepId:
|
|
1143
|
+
stepId: item.id,
|
|
864
1144
|
data: { ...active.data },
|
|
865
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1145
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
866
1146
|
};
|
|
867
|
-
const skip = await
|
|
1147
|
+
const skip = await item.shouldSkip(ctx);
|
|
868
1148
|
if (!skip) break;
|
|
869
1149
|
active.currentStepIndex += direction;
|
|
870
1150
|
}
|
|
@@ -875,19 +1155,31 @@ export class PathEngine {
|
|
|
875
1155
|
this._hasAttemptedNext = false;
|
|
876
1156
|
const active = this.activePath;
|
|
877
1157
|
if (!active) return;
|
|
878
|
-
|
|
879
|
-
|
|
1158
|
+
|
|
1159
|
+
// Save a snapshot of the data as it was when entering this step (for resetStep)
|
|
1160
|
+
active.stepEntryData = { ...active.data };
|
|
1161
|
+
|
|
1162
|
+
// Capture the timestamp when entering this step (for analytics, timeout warnings)
|
|
1163
|
+
active.stepEnteredAt = Date.now();
|
|
1164
|
+
|
|
1165
|
+
const item = this.getCurrentItem(active);
|
|
1166
|
+
|
|
1167
|
+
// Resolve the inner step when this slot is a StepChoice
|
|
1168
|
+
this.cacheResolvedChoiceStep(active);
|
|
1169
|
+
|
|
1170
|
+
const effectiveStep = this.getEffectiveStep(active);
|
|
1171
|
+
const isFirstEntry = !active.visitedStepIds.has(item.id);
|
|
880
1172
|
// Mark as visited before calling onEnter so re-entrant calls see the
|
|
881
1173
|
// correct isFirstEntry value.
|
|
882
|
-
active.visitedStepIds.add(
|
|
883
|
-
if (!
|
|
1174
|
+
active.visitedStepIds.add(item.id);
|
|
1175
|
+
if (!effectiveStep.onEnter) return;
|
|
884
1176
|
const ctx: PathStepContext = {
|
|
885
1177
|
pathId: active.definition.id,
|
|
886
|
-
stepId:
|
|
1178
|
+
stepId: item.id,
|
|
887
1179
|
data: { ...active.data },
|
|
888
1180
|
isFirstEntry
|
|
889
1181
|
};
|
|
890
|
-
return
|
|
1182
|
+
return effectiveStep.onEnter(ctx);
|
|
891
1183
|
}
|
|
892
1184
|
|
|
893
1185
|
private async leaveCurrentStep(
|
|
@@ -917,8 +1209,8 @@ export class PathEngine {
|
|
|
917
1209
|
};
|
|
918
1210
|
return step.canMoveNext(ctx);
|
|
919
1211
|
}
|
|
920
|
-
if (step.
|
|
921
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1212
|
+
if (step.fieldErrors) {
|
|
1213
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
922
1214
|
}
|
|
923
1215
|
return true;
|
|
924
1216
|
}
|
|
@@ -956,12 +1248,12 @@ export class PathEngine {
|
|
|
956
1248
|
active: ActivePath
|
|
957
1249
|
): boolean {
|
|
958
1250
|
if (!guard) return true;
|
|
959
|
-
const
|
|
1251
|
+
const item = this.getCurrentItem(active);
|
|
960
1252
|
const ctx: PathStepContext = {
|
|
961
1253
|
pathId: active.definition.id,
|
|
962
|
-
stepId:
|
|
1254
|
+
stepId: item.id,
|
|
963
1255
|
data: { ...active.data },
|
|
964
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1256
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
965
1257
|
};
|
|
966
1258
|
try {
|
|
967
1259
|
const result = guard(ctx);
|
|
@@ -969,7 +1261,7 @@ export class PathEngine {
|
|
|
969
1261
|
// Async guard detected - warn and return optimistic default
|
|
970
1262
|
if (result && typeof result.then === "function") {
|
|
971
1263
|
console.warn(
|
|
972
|
-
`[pathwrite] Async guard detected on step "${
|
|
1264
|
+
`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
973
1265
|
`Guards in snapshots must be synchronous. ` +
|
|
974
1266
|
`Returning true (optimistic) as default. ` +
|
|
975
1267
|
`The async guard will still be enforced during actual navigation.`
|
|
@@ -978,7 +1270,7 @@ export class PathEngine {
|
|
|
978
1270
|
return true;
|
|
979
1271
|
} catch (err) {
|
|
980
1272
|
console.warn(
|
|
981
|
-
`[pathwrite] Guard on step "${
|
|
1273
|
+
`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
982
1274
|
`Returning true (allow navigation) as a safe default. ` +
|
|
983
1275
|
`Note: guards are evaluated before onEnter runs on first entry — ` +
|
|
984
1276
|
`ensure guards handle missing/undefined data gracefully.`,
|
|
@@ -991,24 +1283,24 @@ export class PathEngine {
|
|
|
991
1283
|
/**
|
|
992
1284
|
* Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
|
|
993
1285
|
* When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
|
|
994
|
-
* When absent but `
|
|
1286
|
+
* When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
|
|
995
1287
|
* When neither is defined, returns `true`.
|
|
996
1288
|
*/
|
|
997
1289
|
private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
|
|
998
1290
|
if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
|
|
999
|
-
if (step.
|
|
1000
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1291
|
+
if (step.fieldErrors) {
|
|
1292
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
1001
1293
|
}
|
|
1002
1294
|
return true;
|
|
1003
1295
|
}
|
|
1004
1296
|
|
|
1005
1297
|
/**
|
|
1006
|
-
* Evaluates a
|
|
1298
|
+
* Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
|
|
1007
1299
|
* If the hook is absent, returns `{}`.
|
|
1008
1300
|
* If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
|
|
1009
1301
|
* `undefined` values are stripped from the result — only fields with a defined message are included.
|
|
1010
1302
|
*
|
|
1011
|
-
* **Note:** Like guards, `
|
|
1303
|
+
* **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
|
|
1012
1304
|
* entry. Write it defensively so it does not throw when fields are absent.
|
|
1013
1305
|
*/
|
|
1014
1306
|
private evaluateFieldMessagesSync(
|
|
@@ -1016,12 +1308,12 @@ export class PathEngine {
|
|
|
1016
1308
|
active: ActivePath
|
|
1017
1309
|
): Record<string, string> {
|
|
1018
1310
|
if (!fn) return {};
|
|
1019
|
-
const
|
|
1311
|
+
const item = this.getCurrentItem(active);
|
|
1020
1312
|
const ctx: PathStepContext = {
|
|
1021
1313
|
pathId: active.definition.id,
|
|
1022
|
-
stepId:
|
|
1314
|
+
stepId: item.id,
|
|
1023
1315
|
data: { ...active.data },
|
|
1024
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1316
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1025
1317
|
};
|
|
1026
1318
|
try {
|
|
1027
1319
|
const result = fn(ctx);
|
|
@@ -1036,22 +1328,45 @@ export class PathEngine {
|
|
|
1036
1328
|
}
|
|
1037
1329
|
if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
|
|
1038
1330
|
console.warn(
|
|
1039
|
-
`[pathwrite] Async
|
|
1040
|
-
`
|
|
1331
|
+
`[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
|
|
1332
|
+
`fieldErrors must be synchronous. Returning {} as default. ` +
|
|
1041
1333
|
`Use synchronous validation or move async checks to canMoveNext.`
|
|
1042
1334
|
);
|
|
1043
1335
|
}
|
|
1044
1336
|
return {};
|
|
1045
1337
|
} catch (err) {
|
|
1046
1338
|
console.warn(
|
|
1047
|
-
`[pathwrite]
|
|
1339
|
+
`[pathwrite] fieldErrors on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
1048
1340
|
`Returning {} as a safe default. ` +
|
|
1049
|
-
`Note:
|
|
1341
|
+
`Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
|
|
1050
1342
|
`ensure it handles missing/undefined data gracefully.`,
|
|
1051
1343
|
err
|
|
1052
1344
|
);
|
|
1053
1345
|
return {};
|
|
1054
1346
|
}
|
|
1055
1347
|
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Compares the current step data to the snapshot taken when the step was entered.
|
|
1351
|
+
* Returns `true` if any data value has changed.
|
|
1352
|
+
*
|
|
1353
|
+
* Performs a shallow comparison — only top-level keys are checked. Nested objects
|
|
1354
|
+
* are compared by reference, not by deep equality.
|
|
1355
|
+
*/
|
|
1356
|
+
private computeIsDirty(active: ActivePath): boolean {
|
|
1357
|
+
const current = active.data;
|
|
1358
|
+
const entry = active.stepEntryData;
|
|
1359
|
+
|
|
1360
|
+
// Get all unique keys from both objects
|
|
1361
|
+
const allKeys = new Set([...Object.keys(current), ...Object.keys(entry)]);
|
|
1362
|
+
|
|
1363
|
+
for (const key of allKeys) {
|
|
1364
|
+
if (current[key] !== entry[key]) {
|
|
1365
|
+
return true;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1056
1371
|
}
|
|
1057
1372
|
|