@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 CHANGED
@@ -21,6 +21,30 @@ onEnter: (ctx) => {
21
21
  }
22
22
  ```
23
23
 
24
+ ### ✅ Track Unsaved Changes with `isDirty`
25
+ ```typescript
26
+ // In your UI component
27
+ const snapshot = engine.snapshot();
28
+ if (snapshot.isDirty) {
29
+ // Show "unsaved changes" warning, disable Save button until changes made, etc.
30
+ }
31
+
32
+ // Revert changes
33
+ <button onClick={() => engine.resetStep()}>Undo Changes</button>
34
+ ```
35
+
36
+ ### ✅ Compute Time on Step with `stepEnteredAt`
37
+ ```typescript
38
+ // Analytics or timeout warnings
39
+ const snapshot = engine.snapshot();
40
+ const durationMs = Date.now() - snapshot.stepEnteredAt;
41
+ const durationSec = Math.floor(durationMs / 1000);
42
+
43
+ if (durationSec > 300) { // 5 minutes
44
+ console.warn("User has been on this step for 5+ minutes");
45
+ }
46
+ ```
47
+
24
48
  ### ✅ Correlate Sub-Paths with `meta`
25
49
  ```typescript
26
50
  // Starting sub-path
@@ -53,18 +77,26 @@ const path: PathDefinition<CourseData> = {
53
77
  onLeave: (ctx) => ({ courseName: ctx.data.courseName.trim() })
54
78
  },
55
79
  { id: "review" }
56
- ]
80
+ ],
81
+ onComplete: (data) => {
82
+ // Called when the path completes
83
+ console.log("Course created:", data.courseName);
84
+ },
85
+ onCancel: (data) => {
86
+ // Called when the path is cancelled
87
+ console.log("Course creation cancelled");
88
+ }
57
89
  };
58
90
  ```
59
91
 
60
92
  | Type | Description |
61
93
  |------|-------------|
62
- | `PathDefinition<TData>` | A path's ID, title, and ordered list of step definitions. |
94
+ | `PathDefinition<TData>` | A path's ID, title, ordered list of step definitions, and optional `onComplete` / `onCancel` callbacks. |
63
95
  | `PathStep<TData>` | A single step: guards, lifecycle hooks. |
64
96
  | `PathStepContext<TData>` | Passed to every hook and guard. `data` is a **readonly snapshot copy** — return a patch to update state. |
65
97
  | `PathSnapshot<TData>` | Point-in-time read of the engine: step ID, index, count, flags, and a copy of data. |
66
98
  | `PathEvent` | Union of `stateChanged` (includes `cause`), `completed`, `cancelled`, and `resumed`. |
67
- | `StateChangeCause` | Identifies the method that triggered a `stateChanged` event: `"start"` \| `"next"` \| `"previous"` \| `"goToStep"` \| `"goToStepChecked"` \| `"setData"` \| `"cancel"` \| `"restart"`. |
99
+ | `StateChangeCause` | Identifies the method that triggered a `stateChanged` event: `"start"` \| `"next"` \| `"previous"` \| `"goToStep"` \| `"goToStepChecked"` \| `"setData"` \| `"resetStep"` \| `"cancel"` \| `"restart"`. |
68
100
  | `PathObserver` | `(event: PathEvent, engine: PathEngine) => void` — a function registered at construction time that receives every event for the engine's lifetime. |
69
101
  | `PathEngineOptions` | `{ observers?: PathObserver[] }` — options accepted by the `PathEngine` constructor and `PathEngine.fromState()`. |
70
102
  | `ObserverStrategy` | Union type for the five built-in trigger strategies: `"onEveryChange" \| "onNext" \| "onSubPathComplete" \| "onComplete" \| "manual"`. Import and use in your own observer factories. |
@@ -83,10 +115,15 @@ engine.next();
83
115
  engine.previous();
84
116
  engine.cancel();
85
117
  engine.setData(key, value); // update a single data value; emits stateChanged
86
- engine.goToStep(stepId); // jump to step by ID; bypasses guards and shouldSkip
118
+ engine.resetStep(); // revert current step data to what it was on entry; emits stateChanged
87
119
  engine.goToStepChecked(stepId); // jump to step by ID; checks canMoveNext / canMovePrevious first
88
120
  engine.snapshot(); // returns PathSnapshot | null
89
121
 
122
+ // Key PathSnapshot fields
123
+ snapshot.isDirty // true if any data changed since entering this step (resets on navigation or resetStep)
124
+ snapshot.hasAttemptedNext // true after user clicks Next at least once on this step
125
+ snapshot.stepEnteredAt // Date.now() timestamp when step was entered (for analytics, timeout warnings)
126
+
90
127
  // Serialization API (for persistence)
91
128
  const state = engine.exportState(); // returns SerializedPathState | null
92
129
  const restoredEngine = PathEngine.fromState(state, pathDefinitions, { observers: [...] });
@@ -483,7 +520,7 @@ engine.subscribe((event) => {
483
520
  });
484
521
  ```
485
522
 
486
- Every `stateChanged` event includes a `cause` field (`StateChangeCause`) identifying which public method triggered it. Use this to react to specific operations — for example, the `store-http` package uses `event.cause === "next"` to implement the `onNext` persistence strategy.
523
+ Every `stateChanged` event includes a `cause` field (`StateChangeCause`) identifying which public method triggered it. Use this to react to specific operations — for example, the `store` package uses `event.cause === "next"` to implement the `onNext` persistence strategy.
487
524
 
488
525
  ## State Persistence
489
526
 
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type PathData = Record<string, unknown>;
2
2
  /**
3
- * The return type of a `fieldMessages` hook. Each key is a field ID; the value
3
+ * The return type of a `fieldErrors` hook. Each key is a field ID; the value
4
4
  * is an error string, or `undefined` / omitted to indicate no error for that field.
5
5
  *
6
6
  * Use `"_"` as a key for form-level errors that don't belong to a specific field:
@@ -16,21 +16,25 @@ export interface SerializedPathState {
16
16
  data: PathData;
17
17
  visitedStepIds: string[];
18
18
  subPathMeta?: Record<string, unknown>;
19
+ stepEntryData?: PathData;
20
+ stepEnteredAt?: number;
19
21
  pathStack: Array<{
20
22
  pathId: string;
21
23
  currentStepIndex: number;
22
24
  data: PathData;
23
25
  visitedStepIds: string[];
24
26
  subPathMeta?: Record<string, unknown>;
27
+ stepEntryData?: PathData;
28
+ stepEnteredAt?: number;
25
29
  }>;
26
30
  _isNavigating: boolean;
27
31
  }
28
32
  /**
29
33
  * The interface every path state store must implement.
30
34
  *
31
- * `HttpStore` from `@daltonr/pathwrite-store-http` is the reference
35
+ * `HttpStore` from `@daltonr/pathwrite-store` is the reference
32
36
  * implementation. Any backend — MongoDB, Redis, localStorage, etc. —
33
- * implements this interface and works with `httpPersistence` and
37
+ * implements this interface and works with `persistence` and
34
38
  * `restoreOrStart` without any other changes.
35
39
  */
36
40
  export interface PathStore {
@@ -51,6 +55,45 @@ export interface PathStepContext<TData extends PathData = PathData> {
51
55
  */
52
56
  readonly isFirstEntry: boolean;
53
57
  }
58
+ /**
59
+ * A conditional step selection placed in a path's `steps` array in place of a
60
+ * single `PathStep`. When the engine reaches a `StepChoice` it calls `select`
61
+ * to decide which of the bundled `steps` to activate. The chosen step is then
62
+ * treated exactly like any other step — its hooks, guards, and validation all
63
+ * apply normally.
64
+ *
65
+ * `StepChoice` has its own `id` (used for progress tracking and `goToStep`)
66
+ * while `formId` on the snapshot exposes which inner step was selected, so the
67
+ * UI can render the right component.
68
+ *
69
+ * @example
70
+ * ```typescript
71
+ * {
72
+ * id: "contact-details",
73
+ * select: ({ data }) => data.accountType === "company" ? "company" : "individual",
74
+ * steps: [
75
+ * {
76
+ * id: "individual",
77
+ * fieldErrors: ({ data }) => ({ name: !data.name ? "Required." : undefined }),
78
+ * },
79
+ * {
80
+ * id: "company",
81
+ * fieldErrors: ({ data }) => ({ companyName: !data.companyName ? "Required." : undefined }),
82
+ * },
83
+ * ],
84
+ * }
85
+ * ```
86
+ */
87
+ export interface StepChoice<TData extends PathData = PathData> {
88
+ id: string;
89
+ title?: string;
90
+ meta?: Record<string, unknown>;
91
+ /** Called on step entry. Return the `id` of the step to activate. Throws if the returned id is not found in `steps`. */
92
+ select: (ctx: PathStepContext<TData>) => string;
93
+ steps: PathStep<TData>[];
94
+ /** When `true`, the engine skips this choice slot entirely (same semantics as `PathStep.shouldSkip`). */
95
+ shouldSkip?: (ctx: PathStepContext<TData>) => boolean | Promise<boolean>;
96
+ }
54
97
  export interface PathStep<TData extends PathData = PathData> {
55
98
  id: string;
56
99
  title?: string;
@@ -64,7 +107,7 @@ export interface PathStep<TData extends PathData = PathData> {
64
107
  * by field name) so consumers do not need to duplicate validation logic in
65
108
  * the template. Return `undefined` for a field to indicate no error.
66
109
  *
67
- * When `fieldMessages` is provided and `canMoveNext` is **not**, the engine
110
+ * When `fieldErrors` is provided and `canMoveNext` is **not**, the engine
68
111
  * automatically derives `canMoveNext` as `true` when all values are `undefined`
69
112
  * (i.e. no messages), eliminating the need to express the same logic twice.
70
113
  *
@@ -72,13 +115,29 @@ export interface PathStep<TData extends PathData = PathData> {
72
115
  *
73
116
  * @example
74
117
  * ```typescript
75
- * fieldMessages: ({ data }) => ({
118
+ * fieldErrors: ({ data }) => ({
76
119
  * name: !data.name?.trim() ? "Required." : undefined,
77
120
  * email: !isValidEmail(data.email) ? "Invalid email address." : undefined,
78
121
  * })
79
122
  * ```
80
123
  */
81
- fieldMessages?: (ctx: PathStepContext<TData>) => FieldErrors;
124
+ fieldErrors?: (ctx: PathStepContext<TData>) => FieldErrors;
125
+ /**
126
+ * Returns a map of field ID → warning message for non-blocking advisories.
127
+ * Same shape as `fieldErrors`, but warnings never affect `canMoveNext` —
128
+ * they are purely informational. Shells render them in amber/yellow instead
129
+ * of red.
130
+ *
131
+ * Evaluated synchronously on every snapshot; async functions default to `{}`.
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * fieldWarnings: ({ data }) => ({
136
+ * email: looksLikeTypo(data.email) ? "Did you mean gmail.com?" : undefined,
137
+ * })
138
+ * ```
139
+ */
140
+ fieldWarnings?: (ctx: PathStepContext<TData>) => FieldErrors;
82
141
  onEnter?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
83
142
  onLeave?: (ctx: PathStepContext<TData>) => Partial<TData> | void | Promise<Partial<TData> | void>;
84
143
  /**
@@ -101,7 +160,20 @@ export interface PathStep<TData extends PathData = PathData> {
101
160
  export interface PathDefinition<TData extends PathData = PathData> {
102
161
  id: string;
103
162
  title?: string;
104
- steps: PathStep<TData>[];
163
+ steps: (PathStep<TData> | StepChoice<TData>)[];
164
+ /**
165
+ * Optional callback invoked when this path completes (i.e. the user
166
+ * reaches the end of the last step). Receives the final path data.
167
+ * Only called for top-level paths — sub-path completion is handled by
168
+ * the parent step's `onSubPathComplete` hook.
169
+ */
170
+ onComplete?: (data: TData) => void | Promise<void>;
171
+ /**
172
+ * Optional callback invoked when this path is cancelled. Receives the
173
+ * path data at the time of cancellation. Only called for top-level paths —
174
+ * sub-path cancellation is handled by the parent step's `onSubPathCancel` hook.
175
+ */
176
+ onCancel?: (data: TData) => void | Promise<void>;
105
177
  }
106
178
  export type StepStatus = "completed" | "current" | "upcoming";
107
179
  export interface StepSummary {
@@ -140,6 +212,12 @@ export interface PathSnapshot<TData extends PathData = PathData> {
140
212
  stepId: string;
141
213
  stepTitle?: string;
142
214
  stepMeta?: Record<string, unknown>;
215
+ /**
216
+ * The `id` of the selected inner `PathStep` when the current position in
217
+ * the path is a `StepChoice`. `undefined` for ordinary steps.
218
+ * Use this to decide which form component to render.
219
+ */
220
+ formId?: string;
143
221
  stepIndex: number;
144
222
  stepCount: number;
145
223
  progress: number;
@@ -155,7 +233,7 @@ export interface PathSnapshot<TData extends PathData = PathData> {
155
233
  rootProgress?: RootProgress;
156
234
  /** True while an async guard or hook is executing. Use to disable navigation controls. */
157
235
  isNavigating: boolean;
158
- /** Whether the current step's `canMoveNext` guard allows advancing. Async guards default to `true`. Auto-derived as `true` when `fieldMessages` is defined and returns no messages, and `canMoveNext` is not explicitly defined. */
236
+ /** 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. */
159
237
  canMoveNext: boolean;
160
238
  /** Whether the current step's `canMovePrevious` guard allows going back. Async guards default to `true`. */
161
239
  canMovePrevious: boolean;
@@ -168,28 +246,64 @@ export interface PathSnapshot<TData extends PathData = PathData> {
168
246
  * render and only appear after the user has attempted to proceed:
169
247
  *
170
248
  * ```svelte
171
- * {#if snapshot.hasAttemptedNext && snapshot.fieldMessages.email}
172
- * <span class="error">{snapshot.fieldMessages.email}</span>
249
+ * {#if snapshot.hasAttemptedNext && snapshot.fieldErrors.email}
250
+ * <span class="error">{snapshot.fieldErrors.email}</span>
173
251
  * {/if}
174
252
  * ```
175
253
  *
176
- * The shell itself uses this flag to gate its own automatic `fieldMessages`
254
+ * The shell itself uses this flag to gate its own automatic `fieldErrors`
177
255
  * summary rendering — errors are never shown before the first Next attempt.
178
256
  */
179
257
  hasAttemptedNext: boolean;
258
+ /**
259
+ * True if any data has changed since entering this step. Automatically computed
260
+ * by comparing the current data to the snapshot taken on step entry. Resets to
261
+ * `false` when navigating to a new step or calling `resetStep()`.
262
+ *
263
+ * Useful for "unsaved changes" warnings, disabling Save buttons until changes
264
+ * are made, or styling forms to indicate modifications.
265
+ *
266
+ * ```typescript
267
+ * {#if snapshot.isDirty}
268
+ * <span class="warning">You have unsaved changes</span>
269
+ * {/if}
270
+ * ```
271
+ */
272
+ isDirty: boolean;
273
+ /**
274
+ * Timestamp (from `Date.now()`) captured when the current step was entered.
275
+ * Useful for analytics, timeout warnings, or computing how long a user has
276
+ * been on a step.
277
+ *
278
+ * ```typescript
279
+ * const durationMs = Date.now() - snapshot.stepEnteredAt;
280
+ * const durationSec = Math.floor(durationMs / 1000);
281
+ * ```
282
+ *
283
+ * Resets to a new timestamp each time the step is entered (including when
284
+ * navigating back to a previously visited step).
285
+ */
286
+ stepEnteredAt: number;
180
287
  /**
181
288
  * Field-keyed validation messages for the current step. Empty object when there are none.
182
- * Use in step templates to render inline per-field errors: `snapshot.fieldMessages['email']`.
289
+ * Use in step templates to render inline per-field errors: `snapshot.fieldErrors['email']`.
183
290
  * The shell also renders these automatically in a labeled summary box.
184
291
  * Use `"_"` as a key for form-level (non-field-specific) errors.
185
292
  */
186
- fieldMessages: Record<string, string>;
293
+ fieldErrors: Record<string, string>;
294
+ /**
295
+ * Field-keyed warning messages for the current step. Empty object when there are none.
296
+ * Same shape as `fieldErrors` but purely informational — warnings never block navigation.
297
+ * Shells render these in amber/yellow instead of red.
298
+ * Use `"_"` as a key for form-level (non-field-specific) warnings.
299
+ */
300
+ fieldWarnings: Record<string, string>;
187
301
  data: TData;
188
302
  }
189
303
  /**
190
304
  * Identifies the public method that triggered a `stateChanged` event.
191
305
  */
192
- export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "cancel" | "restart";
306
+ export type StateChangeCause = "start" | "next" | "previous" | "goToStep" | "goToStepChecked" | "setData" | "resetStep" | "cancel" | "restart";
193
307
  export type PathEvent = {
194
308
  type: "stateChanged";
195
309
  cause: StateChangeCause;
@@ -266,6 +380,9 @@ export declare class PathEngine {
266
380
  private _isNavigating;
267
381
  /** True after the user has called next() on the current step at least once. Resets on step entry. */
268
382
  private _hasAttemptedNext;
383
+ /** The path and initial data from the most recent top-level start() call. Used by restart(). */
384
+ private _rootPath;
385
+ private _rootInitialData;
269
386
  constructor(options?: PathEngineOptions);
270
387
  /**
271
388
  * Restores a PathEngine from previously exported state.
@@ -286,17 +403,16 @@ export declare class PathEngine {
286
403
  start(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
287
404
  /**
288
405
  * Tears down any active path (and the entire sub-path stack) without firing
289
- * lifecycle hooks or emitting `cancelled`, then immediately starts the given
290
- * path from scratch.
406
+ * lifecycle hooks or emitting `cancelled`, then immediately restarts the same
407
+ * path with the same initial data that was passed to the original `start()` call.
291
408
  *
292
409
  * Safe to call at any time — whether a path is running, already completed,
293
410
  * or has never been started. Use this to implement a "Start over" button or
294
411
  * to retry a path after completion without remounting the host component.
295
412
  *
296
- * @param path The path definition to (re)start.
297
- * @param initialData Data to seed the fresh path with. Defaults to `{}`.
413
+ * @throws If `restart()` is called before `start()` has ever been called.
298
414
  */
299
- restart(path: PathDefinition<any>, initialData?: PathData): Promise<void>;
415
+ restart(): Promise<void>;
300
416
  /**
301
417
  * Starts a sub-path on top of the currently active path. Throws if no path
302
418
  * is running.
@@ -317,6 +433,13 @@ export declare class PathEngine {
317
433
  * API consistency. */
318
434
  cancel(): Promise<void>;
319
435
  setData(key: string, value: unknown): Promise<void>;
436
+ /**
437
+ * Resets the current step's data to what it was when the step was entered.
438
+ * Useful for "Clear" or "Reset" buttons that undo changes within a step.
439
+ * Emits a `stateChanged` event with cause `"resetStep"`.
440
+ * Throws if no path is active.
441
+ */
442
+ resetStep(): Promise<void>;
320
443
  /** Jumps directly to the step with the given ID. Does not check guards or shouldSkip. */
321
444
  goToStep(stepId: string): Promise<void>;
322
445
  /**
@@ -333,7 +456,7 @@ export declare class PathEngine {
333
456
  snapshot(): PathSnapshot | null;
334
457
  /**
335
458
  * Exports the current engine state as a plain JSON-serializable object.
336
- * Use with storage adapters (e.g. `@daltonr/pathwrite-store-http`) to
459
+ * Use with storage adapters (e.g. `@daltonr/pathwrite-store`) to
337
460
  * persist and restore wizard progress.
338
461
  *
339
462
  * Returns `null` if no path is active.
@@ -354,7 +477,20 @@ export declare class PathEngine {
354
477
  private assertPathHasSteps;
355
478
  private emit;
356
479
  private emitStateChanged;
357
- private getCurrentStep;
480
+ /** Returns the raw item at the current index — either a PathStep or a StepChoice. */
481
+ private getCurrentItem;
482
+ /**
483
+ * Calls `StepChoice.select` and caches the chosen inner step in
484
+ * `active.resolvedChoiceStep`. Clears the cache when the current item is a
485
+ * plain `PathStep`. Throws if `select` returns an id not present in `steps`.
486
+ */
487
+ private cacheResolvedChoiceStep;
488
+ /**
489
+ * Returns the effective `PathStep` for the current position. When the
490
+ * current item is a `StepChoice`, returns the cached resolved inner step.
491
+ * When it is a plain `PathStep`, returns it directly.
492
+ */
493
+ private getEffectiveStep;
358
494
  private applyPatch;
359
495
  private skipSteps;
360
496
  private enterCurrentStep;
@@ -379,18 +515,26 @@ export declare class PathEngine {
379
515
  /**
380
516
  * Evaluates `canMoveNext` synchronously for inclusion in the snapshot.
381
517
  * When `canMoveNext` is defined, delegates to `evaluateGuardSync`.
382
- * When absent but `fieldMessages` is defined, auto-derives: `true` iff no messages.
518
+ * When absent but `fieldErrors` is defined, auto-derives: `true` iff no messages.
383
519
  * When neither is defined, returns `true`.
384
520
  */
385
521
  private evaluateCanMoveNextSync;
386
522
  /**
387
- * Evaluates a fieldMessages function synchronously for inclusion in the snapshot.
523
+ * Evaluates a fieldErrors function synchronously for inclusion in the snapshot.
388
524
  * If the hook is absent, returns `{}`.
389
525
  * If the hook returns a `Promise`, returns `{}` (async hooks are not supported in snapshots).
390
526
  * `undefined` values are stripped from the result — only fields with a defined message are included.
391
527
  *
392
- * **Note:** Like guards, `fieldMessages` is evaluated before `onEnter` runs on first
528
+ * **Note:** Like guards, `fieldErrors` is evaluated before `onEnter` runs on first
393
529
  * entry. Write it defensively so it does not throw when fields are absent.
394
530
  */
395
531
  private evaluateFieldMessagesSync;
532
+ /**
533
+ * Compares the current step data to the snapshot taken when the step was entered.
534
+ * Returns `true` if any data value has changed.
535
+ *
536
+ * Performs a shallow comparison — only top-level keys are checked. Nested objects
537
+ * are compared by reference, not by deep equality.
538
+ */
539
+ private computeIsDirty;
396
540
  }