@daltonr/pathwrite-angular 0.9.0 → 0.10.1

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/src/index.ts CHANGED
@@ -98,6 +98,16 @@ export class PathFacade<TData extends PathData = PathData> implements OnDestroy
98
98
  return this._engine.restart();
99
99
  }
100
100
 
101
+ /** Re-runs the operation that set `snapshot().error`. Increments `retryCount` on repeated failure. No-op when there is no pending error. */
102
+ public retry(): Promise<void> {
103
+ return this._engine.retry();
104
+ }
105
+
106
+ /** Pauses the path with intent to return. Emits `suspended`. All state is preserved. */
107
+ public suspend(): Promise<void> {
108
+ return this._engine.suspend();
109
+ }
110
+
101
111
  public startSubPath(path: PathDefinition<any>, initialData: PathData = {}, meta?: Record<string, unknown>): Promise<void> {
102
112
  return this._engine.startSubPath(path, initialData, meta);
103
113
  }
@@ -141,15 +151,15 @@ export class PathFacade<TData extends PathData = PathData> implements OnDestroy
141
151
  }
142
152
 
143
153
  // ---------------------------------------------------------------------------
144
- // injectPath() - Signal-based path access
154
+ // usePathContext() - Signal-based path access
145
155
  // ---------------------------------------------------------------------------
146
156
 
147
157
  /**
148
- * Return type of `injectPath()`. Provides signal-based reactive access to the
158
+ * Return type of `usePathContext()`. Provides signal-based reactive access to the
149
159
  * path state and strongly-typed navigation actions. Mirrors React's `usePathContext()`
150
160
  * return type for consistency across adapters.
151
161
  */
152
- export interface InjectPathReturn<TData extends PathData = PathData> {
162
+ export interface UsePathContextReturn<TData extends PathData = PathData> {
153
163
  /** Current path snapshot as a signal. Returns `null` when no path is active. */
154
164
  snapshot: Signal<PathSnapshot<TData> | null>;
155
165
  /** Start (or restart) a path. */
@@ -175,16 +185,20 @@ export interface InjectPathReturn<TData extends PathData = PathData> {
175
185
  * Use for "Start over" / retry flows.
176
186
  */
177
187
  restart: () => Promise<void>;
188
+ /** Re-run the operation that set `snapshot().error`. */
189
+ retry: () => Promise<void>;
190
+ /** Pause with intent to return, preserving all state. Emits `suspended`. */
191
+ suspend: () => Promise<void>;
178
192
  }
179
193
 
180
194
  /**
181
- * Inject a PathFacade and return a signal-based API for use in Angular components.
195
+ * Access the nearest `PathFacade`'s path instance for use in Angular step components.
182
196
  * Requires `PathFacade` to be provided in the component's injector tree (either via
183
197
  * `providers: [PathFacade]` in the component or a parent component).
184
198
  *
185
199
  * **This is the recommended way to consume Pathwrite in Angular components** — it
186
- * provides the same ergonomic, framework-native API that React's `usePathContext()`
187
- * and Vue's `usePath()` offer. No template references or manual facade injection needed.
200
+ * provides the same ergonomic API as React's `usePathContext()` and Vue's `usePathContext()`.
201
+ * No template references or manual facade injection needed.
188
202
  *
189
203
  * The optional generic `TData` narrows `snapshot().data` and `setData()` to your
190
204
  * data shape. It is a **type-level assertion**, not a runtime guarantee.
@@ -203,7 +217,7 @@ export interface InjectPathReturn<TData extends PathData = PathData> {
203
217
  * `
204
218
  * })
205
219
  * export class ContactStepComponent {
206
- * protected readonly path = injectPath<ContactData>();
220
+ * protected readonly path = usePathContext<ContactData>();
207
221
  *
208
222
  * updateName(name: string) {
209
223
  * this.path.setData('name', name);
@@ -213,12 +227,12 @@ export interface InjectPathReturn<TData extends PathData = PathData> {
213
227
  *
214
228
  * @throws Error if PathFacade is not provided in the injector tree
215
229
  */
216
- export function injectPath<TData extends PathData = PathData>(): InjectPathReturn<TData> {
230
+ export function usePathContext<TData extends PathData = PathData>(): UsePathContextReturn<TData> {
217
231
  const facade = inject(PathFacade, { optional: true }) as PathFacade<TData> | null;
218
-
232
+
219
233
  if (!facade) {
220
234
  throw new Error(
221
- "injectPath() requires PathFacade to be provided. " +
235
+ "usePathContext() requires PathFacade to be provided. " +
222
236
  "Add 'providers: [PathFacade]' to your component or a parent component."
223
237
  );
224
238
  }
@@ -235,6 +249,8 @@ export function injectPath<TData extends PathData = PathData>(): InjectPathRetur
235
249
  goToStep: (stepId) => facade.goToStep(stepId),
236
250
  goToStepChecked: (stepId) => facade.goToStepChecked(stepId),
237
251
  restart: () => facade.restart(),
252
+ retry: () => facade.retry(),
253
+ suspend: () => facade.suspend(),
238
254
  };
239
255
  }
240
256
 
package/src/shell.ts CHANGED
@@ -26,7 +26,9 @@ import {
26
26
  PathEvent,
27
27
  PathSnapshot,
28
28
  ProgressLayout,
29
- RootProgress
29
+ RootProgress,
30
+ formatFieldKey,
31
+ errorPhaseMessage,
30
32
  } from "@daltonr/pathwrite-core";
31
33
  import { PathFacade } from "./index";
32
34
 
@@ -48,6 +50,10 @@ export interface PathShellActions {
48
50
  setData: (key: string, value: unknown) => Promise<void>;
49
51
  /** Restart the shell's current path with its current `initialData`. */
50
52
  restart: () => Promise<void>;
53
+ /** Re-run the operation that set `snapshot.error`. */
54
+ retry: () => Promise<void>;
55
+ /** Pause with intent to return, preserving all state. Emits `suspended`. */
56
+ suspend: () => Promise<void>;
51
57
  }
52
58
 
53
59
  // ---------------------------------------------------------------------------
@@ -216,10 +222,43 @@ export class PathShellFooterDirective {
216
222
  </li>
217
223
  </ul>
218
224
 
225
+ <!-- Blocking error — guard returned { allowed: false, reason } -->
226
+ <p class="pw-shell__blocking-error"
227
+ *ngIf="validationDisplay !== 'inline' && s.hasAttemptedNext && s.blockingError">
228
+ {{ s.blockingError }}
229
+ </p>
230
+
231
+ <!-- Error panel — replaces footer when an async operation has failed -->
232
+ <div class="pw-shell__error" *ngIf="s.status === 'error' && s.error; else footerOrCustom">
233
+ <div class="pw-shell__error-title">{{ s.error!.retryCount >= 2 ? 'Still having trouble.' : 'Something went wrong.' }}</div>
234
+ <div class="pw-shell__error-message">{{ errorPhaseMessage(s.error!.phase) }}{{ s.error!.message ? ' ' + s.error!.message : '' }}</div>
235
+ <div class="pw-shell__error-actions">
236
+ <button
237
+ *ngIf="s.error!.retryCount < 2"
238
+ type="button"
239
+ class="pw-shell__btn pw-shell__btn--retry"
240
+ (click)="facade.retry()"
241
+ >Try again</button>
242
+ <button
243
+ *ngIf="s.hasPersistence"
244
+ type="button"
245
+ [class]="'pw-shell__btn ' + (s.error!.retryCount >= 2 ? 'pw-shell__btn--retry' : 'pw-shell__btn--suspend')"
246
+ (click)="facade.suspend()"
247
+ >Save and come back later</button>
248
+ <button
249
+ *ngIf="s.error!.retryCount >= 2 && !s.hasPersistence"
250
+ type="button"
251
+ class="pw-shell__btn pw-shell__btn--retry"
252
+ (click)="facade.retry()"
253
+ >Try again</button>
254
+ </div>
255
+ </div>
219
256
  <!-- Footer — custom or default navigation buttons -->
220
- <ng-container *ngIf="customFooter; else defaultFooter">
221
- <ng-container *ngTemplateOutlet="customFooter.templateRef; context: { $implicit: s, actions: shellActions }"></ng-container>
222
- </ng-container>
257
+ <ng-template #footerOrCustom>
258
+ <ng-container *ngIf="customFooter; else defaultFooter">
259
+ <ng-container *ngTemplateOutlet="customFooter.templateRef; context: { $implicit: s, actions: shellActions }"></ng-container>
260
+ </ng-container>
261
+ </ng-template>
223
262
  <ng-template #defaultFooter>
224
263
  <div class="pw-shell__footer">
225
264
  <div class="pw-shell__footer-left">
@@ -228,7 +267,7 @@ export class PathShellFooterDirective {
228
267
  *ngIf="getResolvedFooterLayout(s) === 'form' && !hideCancel"
229
268
  type="button"
230
269
  class="pw-shell__btn pw-shell__btn--cancel"
231
- [disabled]="s.isNavigating"
270
+ [disabled]="s.status !== 'idle'"
232
271
  (click)="facade.cancel()"
233
272
  >{{ cancelLabel }}</button>
234
273
  <!-- Wizard mode: Back on the left -->
@@ -236,7 +275,7 @@ export class PathShellFooterDirective {
236
275
  *ngIf="getResolvedFooterLayout(s) === 'wizard' && !s.isFirstStep"
237
276
  type="button"
238
277
  class="pw-shell__btn pw-shell__btn--back"
239
- [disabled]="s.isNavigating || !s.canMovePrevious"
278
+ [disabled]="s.status !== 'idle' || !s.canMovePrevious"
240
279
  (click)="facade.previous()"
241
280
  >{{ backLabel }}</button>
242
281
  </div>
@@ -246,16 +285,17 @@ export class PathShellFooterDirective {
246
285
  *ngIf="getResolvedFooterLayout(s) === 'wizard' && !hideCancel"
247
286
  type="button"
248
287
  class="pw-shell__btn pw-shell__btn--cancel"
249
- [disabled]="s.isNavigating"
288
+ [disabled]="s.status !== 'idle'"
250
289
  (click)="facade.cancel()"
251
290
  >{{ cancelLabel }}</button>
252
291
  <!-- Both modes: Submit on the right -->
253
292
  <button
254
293
  type="button"
255
294
  class="pw-shell__btn pw-shell__btn--next"
256
- [disabled]="s.isNavigating"
295
+ [class.pw-shell__btn--loading]="s.status !== 'idle'"
296
+ [disabled]="s.status !== 'idle'"
257
297
  (click)="facade.next()"
258
- >{{ s.isLastStep ? completeLabel : nextLabel }}</button>
298
+ >{{ s.status !== 'idle' && loadingLabel ? loadingLabel : s.isLastStep ? completeLabel : nextLabel }}</button>
259
299
  </div>
260
300
  </div>
261
301
  </ng-template>
@@ -290,6 +330,8 @@ export class PathShellComponent implements OnInit, OnChanges, OnDestroy {
290
330
  @Input() nextLabel = "Next";
291
331
  /** Label for the Next button when on the last step. */
292
332
  @Input() completeLabel = "Complete";
333
+ /** Label shown on the Next/Complete button while an async operation is in progress. When undefined, the button keeps its label and shows a CSS spinner only. */
334
+ @Input() loadingLabel?: string;
293
335
  /** Label for the Cancel button. */
294
336
  @Input() cancelLabel = "Cancel";
295
337
  /** Hide the Cancel button entirely. */
@@ -342,6 +384,8 @@ export class PathShellComponent implements OnInit, OnChanges, OnDestroy {
342
384
  goToStepChecked: (id) => this.facade.goToStepChecked(id),
343
385
  setData: (key, value) => this.facade.setData(key, value as never),
344
386
  restart: () => this.facade.restart(),
387
+ retry: () => this.facade.retry(),
388
+ suspend: () => this.facade.suspend(),
345
389
  };
346
390
 
347
391
  private readonly destroy$ = new Subject<void>();
@@ -405,9 +449,6 @@ export class PathShellComponent implements OnInit, OnChanges, OnDestroy {
405
449
  : this.footerLayout;
406
450
  }
407
451
 
408
- /** Converts a camelCase or lowercase field key to a display label.
409
- * e.g. "firstName" "First Name", "email" → "Email" */
410
- protected formatFieldKey(key: string): string {
411
- return key.replace(/([A-Z])/g, " $1").replace(/^./, c => c.toUpperCase()).trim();
412
- }
452
+ protected errorPhaseMessage = errorPhaseMessage;
453
+ protected formatFieldKey = formatFieldKey;
413
454
  }