@daltonr/pathwrite-core 0.7.0 → 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/README.md +41 -4
- package/dist/index.d.ts +158 -16
- package/dist/index.js +176 -55
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +325 -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";
|
|
@@ -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 {
|
|
@@ -374,7 +500,9 @@ export class PathEngine {
|
|
|
374
500
|
currentStepIndex: stackItem.currentStepIndex,
|
|
375
501
|
data: { ...stackItem.data },
|
|
376
502
|
visitedStepIds: new Set(stackItem.visitedStepIds),
|
|
377
|
-
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()
|
|
378
506
|
});
|
|
379
507
|
}
|
|
380
508
|
|
|
@@ -393,11 +521,20 @@ export class PathEngine {
|
|
|
393
521
|
visitedStepIds: new Set(state.visitedStepIds),
|
|
394
522
|
// Active path's subPathMeta is not serialized (it's transient metadata
|
|
395
523
|
// from the parent when this path was started). On restore, it's undefined.
|
|
396
|
-
subPathMeta: undefined
|
|
524
|
+
subPathMeta: undefined,
|
|
525
|
+
stepEntryData: state.stepEntryData ? { ...state.stepEntryData } : { ...state.data },
|
|
526
|
+
stepEnteredAt: state.stepEnteredAt ?? Date.now()
|
|
397
527
|
};
|
|
398
528
|
|
|
399
529
|
engine._isNavigating = state._isNavigating;
|
|
400
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
|
+
|
|
401
538
|
return engine;
|
|
402
539
|
}
|
|
403
540
|
|
|
@@ -465,9 +602,9 @@ export class PathEngine {
|
|
|
465
602
|
/** Cancel is synchronous for top-level paths (no hooks). Sub-path cancellation
|
|
466
603
|
* is async when an `onSubPathCancel` hook is present. Returns a Promise for
|
|
467
604
|
* API consistency. */
|
|
468
|
-
public cancel(): Promise<void> {
|
|
605
|
+
public async cancel(): Promise<void> {
|
|
469
606
|
const active = this.requireActivePath();
|
|
470
|
-
if (this._isNavigating) return
|
|
607
|
+
if (this._isNavigating) return;
|
|
471
608
|
|
|
472
609
|
const cancelledPathId = active.definition.id;
|
|
473
610
|
const cancelledData = { ...active.data };
|
|
@@ -479,9 +616,12 @@ export class PathEngine {
|
|
|
479
616
|
return this._cancelSubPathAsync(cancelledPathId, cancelledData, cancelledMeta);
|
|
480
617
|
}
|
|
481
618
|
|
|
619
|
+
// Top-level path cancelled — call onCancel hook if defined
|
|
482
620
|
this.activePath = null;
|
|
483
621
|
this.emit({ type: "cancelled", pathId: cancelledPathId, data: cancelledData });
|
|
484
|
-
|
|
622
|
+
if (active.definition.onCancel) {
|
|
623
|
+
await active.definition.onCancel(cancelledData);
|
|
624
|
+
}
|
|
485
625
|
}
|
|
486
626
|
|
|
487
627
|
public setData(key: string, value: unknown): Promise<void> {
|
|
@@ -491,6 +631,20 @@ export class PathEngine {
|
|
|
491
631
|
return Promise.resolve();
|
|
492
632
|
}
|
|
493
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
|
+
|
|
494
648
|
/** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
|
|
495
649
|
public goToStep(stepId: string): Promise<void> {
|
|
496
650
|
const active = this.requireActivePath();
|
|
@@ -527,7 +681,8 @@ export class PathEngine {
|
|
|
527
681
|
}
|
|
528
682
|
|
|
529
683
|
const active = this.activePath;
|
|
530
|
-
const
|
|
684
|
+
const item = this.getCurrentItem(active);
|
|
685
|
+
const effectiveStep = this.getEffectiveStep(active);
|
|
531
686
|
const { steps } = active.definition;
|
|
532
687
|
const stepCount = steps.length;
|
|
533
688
|
|
|
@@ -555,9 +710,10 @@ export class PathEngine {
|
|
|
555
710
|
|
|
556
711
|
return {
|
|
557
712
|
pathId: active.definition.id,
|
|
558
|
-
stepId:
|
|
559
|
-
stepTitle:
|
|
560
|
-
stepMeta:
|
|
713
|
+
stepId: item.id,
|
|
714
|
+
stepTitle: effectiveStep.title ?? item.title,
|
|
715
|
+
stepMeta: effectiveStep.meta ?? item.meta,
|
|
716
|
+
formId: isStepChoice(item) ? effectiveStep.id : undefined,
|
|
561
717
|
stepIndex: active.currentStepIndex,
|
|
562
718
|
stepCount,
|
|
563
719
|
progress: stepCount <= 1 ? 1 : active.currentStepIndex / (stepCount - 1),
|
|
@@ -577,9 +733,12 @@ export class PathEngine {
|
|
|
577
733
|
rootProgress,
|
|
578
734
|
isNavigating: this._isNavigating,
|
|
579
735
|
hasAttemptedNext: this._hasAttemptedNext,
|
|
580
|
-
canMoveNext: this.evaluateCanMoveNextSync(
|
|
581
|
-
canMovePrevious: this.evaluateGuardSync(
|
|
582
|
-
|
|
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,
|
|
583
742
|
data: { ...active.data }
|
|
584
743
|
};
|
|
585
744
|
}
|
|
@@ -608,12 +767,16 @@ export class PathEngine {
|
|
|
608
767
|
currentStepIndex: active.currentStepIndex,
|
|
609
768
|
data: { ...active.data },
|
|
610
769
|
visitedStepIds: Array.from(active.visitedStepIds),
|
|
770
|
+
stepEntryData: { ...active.stepEntryData },
|
|
771
|
+
stepEnteredAt: active.stepEnteredAt,
|
|
611
772
|
pathStack: this.pathStack.map((p) => ({
|
|
612
773
|
pathId: p.definition.id,
|
|
613
774
|
currentStepIndex: p.currentStepIndex,
|
|
614
775
|
data: { ...p.data },
|
|
615
776
|
visitedStepIds: Array.from(p.visitedStepIds),
|
|
616
|
-
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined
|
|
777
|
+
subPathMeta: p.subPathMeta ? { ...p.subPathMeta } : undefined,
|
|
778
|
+
stepEntryData: { ...p.stepEntryData },
|
|
779
|
+
stepEnteredAt: p.stepEnteredAt
|
|
617
780
|
})),
|
|
618
781
|
_isNavigating: this._isNavigating
|
|
619
782
|
};
|
|
@@ -640,7 +803,9 @@ export class PathEngine {
|
|
|
640
803
|
currentStepIndex: 0,
|
|
641
804
|
data: { ...initialData },
|
|
642
805
|
visitedStepIds: new Set(),
|
|
643
|
-
subPathMeta: undefined
|
|
806
|
+
subPathMeta: undefined,
|
|
807
|
+
stepEntryData: { ...initialData }, // Will be updated in enterCurrentStep
|
|
808
|
+
stepEnteredAt: 0 // Will be set in enterCurrentStep
|
|
644
809
|
};
|
|
645
810
|
|
|
646
811
|
this._isNavigating = true;
|
|
@@ -676,7 +841,7 @@ export class PathEngine {
|
|
|
676
841
|
this.emitStateChanged("next");
|
|
677
842
|
|
|
678
843
|
try {
|
|
679
|
-
const step = this.
|
|
844
|
+
const step = this.getEffectiveStep(active);
|
|
680
845
|
|
|
681
846
|
if (await this.canMoveNext(active, step)) {
|
|
682
847
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -713,7 +878,7 @@ export class PathEngine {
|
|
|
713
878
|
this.emitStateChanged("previous");
|
|
714
879
|
|
|
715
880
|
try {
|
|
716
|
-
const step = this.
|
|
881
|
+
const step = this.getEffectiveStep(active);
|
|
717
882
|
|
|
718
883
|
if (await this.canMovePrevious(active, step)) {
|
|
719
884
|
this.applyPatch(await this.leaveCurrentStep(active, step));
|
|
@@ -745,7 +910,7 @@ export class PathEngine {
|
|
|
745
910
|
this.emitStateChanged("goToStep");
|
|
746
911
|
|
|
747
912
|
try {
|
|
748
|
-
const currentStep = this.
|
|
913
|
+
const currentStep = this.getEffectiveStep(active);
|
|
749
914
|
this.applyPatch(await this.leaveCurrentStep(active, currentStep));
|
|
750
915
|
|
|
751
916
|
active.currentStepIndex = targetIndex;
|
|
@@ -767,7 +932,7 @@ export class PathEngine {
|
|
|
767
932
|
this.emitStateChanged("goToStepChecked");
|
|
768
933
|
|
|
769
934
|
try {
|
|
770
|
-
const currentStep = this.
|
|
935
|
+
const currentStep = this.getEffectiveStep(active);
|
|
771
936
|
const goingForward = targetIndex > active.currentStepIndex;
|
|
772
937
|
const allowed = goingForward
|
|
773
938
|
? await this.canMoveNext(active, currentStep)
|
|
@@ -805,13 +970,14 @@ export class PathEngine {
|
|
|
805
970
|
const parent = this.activePath;
|
|
806
971
|
|
|
807
972
|
if (parent) {
|
|
808
|
-
const
|
|
973
|
+
const parentItem = this.getCurrentItem(parent);
|
|
974
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
809
975
|
if (parentStep.onSubPathCancel) {
|
|
810
976
|
const ctx: PathStepContext = {
|
|
811
977
|
pathId: parent.definition.id,
|
|
812
|
-
stepId:
|
|
978
|
+
stepId: parentItem.id,
|
|
813
979
|
data: { ...parent.data },
|
|
814
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
980
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
815
981
|
};
|
|
816
982
|
this.applyPatch(
|
|
817
983
|
await parentStep.onSubPathCancel(cancelledPathId, cancelledData, ctx, cancelledMeta)
|
|
@@ -838,14 +1004,15 @@ export class PathEngine {
|
|
|
838
1004
|
// The meta is stored on the parent, not the sub-path
|
|
839
1005
|
const finishedMeta = parent.subPathMeta;
|
|
840
1006
|
this.activePath = parent;
|
|
841
|
-
const
|
|
1007
|
+
const parentItem = this.getCurrentItem(parent);
|
|
1008
|
+
const parentStep = this.getEffectiveStep(parent);
|
|
842
1009
|
|
|
843
1010
|
if (parentStep.onSubPathComplete) {
|
|
844
1011
|
const ctx: PathStepContext = {
|
|
845
1012
|
pathId: parent.definition.id,
|
|
846
|
-
stepId:
|
|
1013
|
+
stepId: parentItem.id,
|
|
847
1014
|
data: { ...parent.data },
|
|
848
|
-
isFirstEntry: !parent.visitedStepIds.has(
|
|
1015
|
+
isFirstEntry: !parent.visitedStepIds.has(parentItem.id)
|
|
849
1016
|
};
|
|
850
1017
|
this.applyPatch(
|
|
851
1018
|
await parentStep.onSubPathComplete(finishedPathId, finishedData, ctx, finishedMeta)
|
|
@@ -859,8 +1026,12 @@ export class PathEngine {
|
|
|
859
1026
|
snapshot: this.snapshot()!
|
|
860
1027
|
});
|
|
861
1028
|
} else {
|
|
1029
|
+
// Top-level path completed — call onComplete hook if defined
|
|
862
1030
|
this.activePath = null;
|
|
863
1031
|
this.emit({ type: "completed", pathId: finishedPathId, data: finishedData });
|
|
1032
|
+
if (finished.definition.onComplete) {
|
|
1033
|
+
await finished.definition.onComplete(finishedData);
|
|
1034
|
+
}
|
|
864
1035
|
}
|
|
865
1036
|
}
|
|
866
1037
|
|
|
@@ -891,10 +1062,63 @@ export class PathEngine {
|
|
|
891
1062
|
this.emit({ type: "stateChanged", cause, snapshot: this.snapshot()! });
|
|
892
1063
|
}
|
|
893
1064
|
|
|
894
|
-
|
|
1065
|
+
/** Returns the raw item at the current index — either a PathStep or a StepChoice. */
|
|
1066
|
+
private getCurrentItem(active: ActivePath): PathStep | StepChoice {
|
|
895
1067
|
return active.definition.steps[active.currentStepIndex];
|
|
896
1068
|
}
|
|
897
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
|
+
|
|
898
1122
|
private applyPatch(patch: Partial<PathData> | void | null | undefined): void {
|
|
899
1123
|
if (patch && typeof patch === "object") {
|
|
900
1124
|
const active = this.activePath;
|
|
@@ -912,15 +1136,15 @@ export class PathEngine {
|
|
|
912
1136
|
active.currentStepIndex >= 0 &&
|
|
913
1137
|
active.currentStepIndex < active.definition.steps.length
|
|
914
1138
|
) {
|
|
915
|
-
const
|
|
916
|
-
if (!
|
|
1139
|
+
const item = active.definition.steps[active.currentStepIndex];
|
|
1140
|
+
if (!item.shouldSkip) break;
|
|
917
1141
|
const ctx: PathStepContext = {
|
|
918
1142
|
pathId: active.definition.id,
|
|
919
|
-
stepId:
|
|
1143
|
+
stepId: item.id,
|
|
920
1144
|
data: { ...active.data },
|
|
921
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1145
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
922
1146
|
};
|
|
923
|
-
const skip = await
|
|
1147
|
+
const skip = await item.shouldSkip(ctx);
|
|
924
1148
|
if (!skip) break;
|
|
925
1149
|
active.currentStepIndex += direction;
|
|
926
1150
|
}
|
|
@@ -931,19 +1155,31 @@ export class PathEngine {
|
|
|
931
1155
|
this._hasAttemptedNext = false;
|
|
932
1156
|
const active = this.activePath;
|
|
933
1157
|
if (!active) return;
|
|
934
|
-
|
|
935
|
-
|
|
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);
|
|
936
1172
|
// Mark as visited before calling onEnter so re-entrant calls see the
|
|
937
1173
|
// correct isFirstEntry value.
|
|
938
|
-
active.visitedStepIds.add(
|
|
939
|
-
if (!
|
|
1174
|
+
active.visitedStepIds.add(item.id);
|
|
1175
|
+
if (!effectiveStep.onEnter) return;
|
|
940
1176
|
const ctx: PathStepContext = {
|
|
941
1177
|
pathId: active.definition.id,
|
|
942
|
-
stepId:
|
|
1178
|
+
stepId: item.id,
|
|
943
1179
|
data: { ...active.data },
|
|
944
1180
|
isFirstEntry
|
|
945
1181
|
};
|
|
946
|
-
return
|
|
1182
|
+
return effectiveStep.onEnter(ctx);
|
|
947
1183
|
}
|
|
948
1184
|
|
|
949
1185
|
private async leaveCurrentStep(
|
|
@@ -973,8 +1209,8 @@ export class PathEngine {
|
|
|
973
1209
|
};
|
|
974
1210
|
return step.canMoveNext(ctx);
|
|
975
1211
|
}
|
|
976
|
-
if (step.
|
|
977
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1212
|
+
if (step.fieldErrors) {
|
|
1213
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
978
1214
|
}
|
|
979
1215
|
return true;
|
|
980
1216
|
}
|
|
@@ -1012,12 +1248,12 @@ export class PathEngine {
|
|
|
1012
1248
|
active: ActivePath
|
|
1013
1249
|
): boolean {
|
|
1014
1250
|
if (!guard) return true;
|
|
1015
|
-
const
|
|
1251
|
+
const item = this.getCurrentItem(active);
|
|
1016
1252
|
const ctx: PathStepContext = {
|
|
1017
1253
|
pathId: active.definition.id,
|
|
1018
|
-
stepId:
|
|
1254
|
+
stepId: item.id,
|
|
1019
1255
|
data: { ...active.data },
|
|
1020
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1256
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1021
1257
|
};
|
|
1022
1258
|
try {
|
|
1023
1259
|
const result = guard(ctx);
|
|
@@ -1025,7 +1261,7 @@ export class PathEngine {
|
|
|
1025
1261
|
// Async guard detected - warn and return optimistic default
|
|
1026
1262
|
if (result && typeof result.then === "function") {
|
|
1027
1263
|
console.warn(
|
|
1028
|
-
`[pathwrite] Async guard detected on step "${
|
|
1264
|
+
`[pathwrite] Async guard detected on step "${item.id}". ` +
|
|
1029
1265
|
`Guards in snapshots must be synchronous. ` +
|
|
1030
1266
|
`Returning true (optimistic) as default. ` +
|
|
1031
1267
|
`The async guard will still be enforced during actual navigation.`
|
|
@@ -1034,7 +1270,7 @@ export class PathEngine {
|
|
|
1034
1270
|
return true;
|
|
1035
1271
|
} catch (err) {
|
|
1036
1272
|
console.warn(
|
|
1037
|
-
`[pathwrite] Guard on step "${
|
|
1273
|
+
`[pathwrite] Guard on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
1038
1274
|
`Returning true (allow navigation) as a safe default. ` +
|
|
1039
1275
|
`Note: guards are evaluated before onEnter runs on first entry — ` +
|
|
1040
1276
|
`ensure guards handle missing/undefined data gracefully.`,
|
|
@@ -1047,24 +1283,24 @@ export class PathEngine {
|
|
|
1047
1283
|
/**
|
|
1048
1284
|
* Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
|
|
1049
1285
|
* When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
|
|
1050
|
-
* When absent but `
|
|
1286
|
+
* When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
|
|
1051
1287
|
* When neither is defined, returns `true`.
|
|
1052
1288
|
*/
|
|
1053
1289
|
private evaluateCanMoveNextSync(step: PathStep, active: ActivePath): boolean {
|
|
1054
1290
|
if (step.canMoveNext) return this.evaluateGuardSync(step.canMoveNext, active);
|
|
1055
|
-
if (step.
|
|
1056
|
-
return Object.keys(this.evaluateFieldMessagesSync(step.
|
|
1291
|
+
if (step.fieldErrors) {
|
|
1292
|
+
return Object.keys(this.evaluateFieldMessagesSync(step.fieldErrors, active)).length === 0;
|
|
1057
1293
|
}
|
|
1058
1294
|
return true;
|
|
1059
1295
|
}
|
|
1060
1296
|
|
|
1061
1297
|
/**
|
|
1062
|
-
* Evaluates a
|
|
1298
|
+
* Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
|
|
1063
1299
|
* If the hook is absent, returns `{}`.
|
|
1064
1300
|
* If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
|
|
1065
1301
|
* `undefined` values are stripped from the result — only fields with a defined message are included.
|
|
1066
1302
|
*
|
|
1067
|
-
* **Note:** Like guards, `
|
|
1303
|
+
* **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
|
|
1068
1304
|
* entry. Write it defensively so it does not throw when fields are absent.
|
|
1069
1305
|
*/
|
|
1070
1306
|
private evaluateFieldMessagesSync(
|
|
@@ -1072,12 +1308,12 @@ export class PathEngine {
|
|
|
1072
1308
|
active: ActivePath
|
|
1073
1309
|
): Record<string, string> {
|
|
1074
1310
|
if (!fn) return {};
|
|
1075
|
-
const
|
|
1311
|
+
const item = this.getCurrentItem(active);
|
|
1076
1312
|
const ctx: PathStepContext = {
|
|
1077
1313
|
pathId: active.definition.id,
|
|
1078
|
-
stepId:
|
|
1314
|
+
stepId: item.id,
|
|
1079
1315
|
data: { ...active.data },
|
|
1080
|
-
isFirstEntry: !active.visitedStepIds.has(
|
|
1316
|
+
isFirstEntry: !active.visitedStepIds.has(item.id)
|
|
1081
1317
|
};
|
|
1082
1318
|
try {
|
|
1083
1319
|
const result = fn(ctx);
|
|
@@ -1092,22 +1328,45 @@ export class PathEngine {
|
|
|
1092
1328
|
}
|
|
1093
1329
|
if (result && typeof (result as unknown as { then?: unknown }).then === "function") {
|
|
1094
1330
|
console.warn(
|
|
1095
|
-
`[pathwrite] Async
|
|
1096
|
-
`
|
|
1331
|
+
`[pathwrite] Async fieldErrors detected on step "${item.id}". ` +
|
|
1332
|
+
`fieldErrors must be synchronous. Returning {} as default. ` +
|
|
1097
1333
|
`Use synchronous validation or move async checks to canMoveNext.`
|
|
1098
1334
|
);
|
|
1099
1335
|
}
|
|
1100
1336
|
return {};
|
|
1101
1337
|
} catch (err) {
|
|
1102
1338
|
console.warn(
|
|
1103
|
-
`[pathwrite]
|
|
1339
|
+
`[pathwrite] fieldErrors on step "${item.id}" threw an error during snapshot evaluation. ` +
|
|
1104
1340
|
`Returning {} as a safe default. ` +
|
|
1105
|
-
`Note:
|
|
1341
|
+
`Note: fieldErrors is evaluated before onEnter runs on first entry — ` +
|
|
1106
1342
|
`ensure it handles missing/undefined data gracefully.`,
|
|
1107
1343
|
err
|
|
1108
1344
|
);
|
|
1109
1345
|
return {};
|
|
1110
1346
|
}
|
|
1111
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
|
+
}
|
|
1112
1371
|
}
|
|
1113
1372
|
|