@quadrel-enterprise-ui/framework 20.10.0 → 20.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.
@@ -11402,7 +11402,7 @@ class QdDatepickerComponent {
11402
11402
  _onChange = () => { };
11403
11403
  _onTouched = () => { };
11404
11404
  ngOnInit() {
11405
- this.language ??= this.translateService.currentLang;
11405
+ this.language = this.translateService?.currentLang ?? DEFAULT_LANGUAGE;
11406
11406
  this.subscribeToLanguageChange();
11407
11407
  this.subscribeToActionEmitterEvents();
11408
11408
  this.initControl();
@@ -27062,6 +27062,60 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
27062
27062
  type: Input
27063
27063
  }] } });
27064
27064
 
27065
+ class QdPageCommitActionExecutor {
27066
+ static execute(action, values, context) {
27067
+ const { formGroupManager, navigationInterceptor, destroyed$, onAfterSnapshot } = context;
27068
+ const captured = formGroupManager.captureFormValues();
27069
+ let result$;
27070
+ navigationInterceptor.executeWithBypass(() => {
27071
+ const handlerResult = action.handler(values);
27072
+ if (isObservable(handlerResult))
27073
+ result$ = handlerResult;
27074
+ });
27075
+ const applySuccess = () => {
27076
+ formGroupManager.setFormGroupsSnapshot(captured);
27077
+ navigationInterceptor.executeWithBypass(() => {
27078
+ try {
27079
+ onAfterSnapshot?.();
27080
+ }
27081
+ catch (err) {
27082
+ console.error('QD-UI | QdPage - internal onAfterSnapshot hook threw after form was marked as saved.', err);
27083
+ }
27084
+ try {
27085
+ action.onSuccess?.();
27086
+ }
27087
+ catch (err) {
27088
+ console.error('QD-UI | QdPage - onSuccess callback threw after form was marked as saved.', err);
27089
+ }
27090
+ });
27091
+ };
27092
+ if (result$) {
27093
+ result$.pipe(take(1), takeUntil(destroyed$)).subscribe({
27094
+ next: ok => {
27095
+ if (ok)
27096
+ applySuccess();
27097
+ },
27098
+ error: err => this.handleError(action, err)
27099
+ });
27100
+ }
27101
+ else {
27102
+ applySuccess();
27103
+ }
27104
+ }
27105
+ static handleError(action, err) {
27106
+ if (!action.onError) {
27107
+ console.error('QD-UI | QdPage - Commit action observable errored — form was not marked as saved. Provide an `onError` hook on the action, or handle errors in the handler.', err);
27108
+ return;
27109
+ }
27110
+ try {
27111
+ action.onError(err);
27112
+ }
27113
+ catch (callbackErr) {
27114
+ console.error('QD-UI | QdPage - onError callback threw.', callbackErr);
27115
+ }
27116
+ }
27117
+ }
27118
+
27065
27119
  /**
27066
27120
  * The QdFormGroupManagerService provides centralized registration, snapshotting, and value tracking
27067
27121
  * for multiple Angular `FormGroup` instances. It supports efficient state restoration, including dynamic
@@ -27176,6 +27230,39 @@ class QdFormGroupManagerService {
27176
27230
  takeFormGroupsSnapshot() {
27177
27231
  this._formGroups.forEach((fg, key) => this._formGroupsSnapshot.set(key, fg.getRawValue()));
27178
27232
  }
27233
+ /**
27234
+ * Captures a deep-cloned snapshot of all current form values without mutating internal state.
27235
+ *
27236
+ * Used by framework orchestration code that needs to remember the "at click time" state of a form
27237
+ * so the snapshot can later be set to exactly those values — even if the user edits the form
27238
+ * while an async commit action is pending.
27239
+ *
27240
+ * Throws if any form value is not structured-cloneable (functions, symbols, DOM nodes).
27241
+ */
27242
+ captureFormValues() {
27243
+ const captured = new Map();
27244
+ this._formGroups.forEach((fg, key) => {
27245
+ try {
27246
+ captured.set(key, structuredClone(fg.getRawValue()));
27247
+ }
27248
+ catch (err) {
27249
+ throw new Error(`QD-UI | QdFormGroupManager - captureFormValues() failed for group "${key}". ` +
27250
+ `Form values must be structured-cloneable. Non-cloneable values like functions, ` +
27251
+ `symbols, or DOM nodes are not supported. Original error: ${String(err)}`);
27252
+ }
27253
+ });
27254
+ return captured;
27255
+ }
27256
+ /**
27257
+ * Replaces the internal saved snapshot with the provided values.
27258
+ *
27259
+ * Intended to be paired with `captureFormValues()` so that the snapshot can be set to the
27260
+ * values present when a commit action was initiated — independent of whether the user edited
27261
+ * the form in the meantime.
27262
+ */
27263
+ setFormGroupsSnapshot(values) {
27264
+ values.forEach((snapshot, key) => this._formGroupsSnapshot.set(key, snapshot));
27265
+ }
27179
27266
  restoreFormGroupsFromSnapshot() {
27180
27267
  this._formGroups.forEach((fg, key) => {
27181
27268
  const snapshot = this._formGroupsSnapshot.get(key);
@@ -27185,20 +27272,6 @@ class QdFormGroupManagerService {
27185
27272
  });
27186
27273
  this.cancelPendingAsyncValidation();
27187
27274
  }
27188
- restoreFormGroup(fg, snapshot) {
27189
- Object.entries(fg.controls).forEach(([ctrlKey, ctrl]) => {
27190
- const newValue = snapshot[ctrlKey];
27191
- if (ctrl instanceof FormArray && Array.isArray(newValue)) {
27192
- this.resetFormArrayToValues(ctrl, newValue);
27193
- }
27194
- else if (ctrl instanceof FormGroup && newValue && typeof newValue === 'object') {
27195
- this.restoreFormGroup(ctrl, newValue);
27196
- }
27197
- else {
27198
- ctrl.reset(newValue);
27199
- }
27200
- });
27201
- }
27202
27275
  /**
27203
27276
  * Cancels any in-flight async validators on all registered form groups.
27204
27277
  *
@@ -27222,6 +27295,20 @@ class QdFormGroupManagerService {
27222
27295
  }
27223
27296
  });
27224
27297
  }
27298
+ restoreFormGroup(fg, snapshot) {
27299
+ Object.entries(fg.controls).forEach(([ctrlKey, ctrl]) => {
27300
+ const newValue = snapshot[ctrlKey];
27301
+ if (ctrl instanceof FormArray && Array.isArray(newValue)) {
27302
+ this.resetFormArrayToValues(ctrl, newValue);
27303
+ }
27304
+ else if (ctrl instanceof FormGroup && newValue && typeof newValue === 'object') {
27305
+ this.restoreFormGroup(ctrl, newValue);
27306
+ }
27307
+ else {
27308
+ ctrl.reset(newValue);
27309
+ }
27310
+ });
27311
+ }
27225
27312
  collectPendingControls(control) {
27226
27313
  const result = [];
27227
27314
  if (control instanceof FormGroup) {
@@ -27287,6 +27374,122 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
27287
27374
  type: Injectable
27288
27375
  }] });
27289
27376
 
27377
+ /**
27378
+ * Intercepts router navigation when unsaved form changes exist on a QdPage.
27379
+ *
27380
+ * Provided per `QdPageComponent`. Activated automatically when the page enters an editable state
27381
+ * (create pages or inspect pages in edit mode). Deactivated when the page returns to view mode.
27382
+ *
27383
+ * #### When navigation is intercepted
27384
+ *
27385
+ * - A `NavigationStart` event occurs (browser back, shell back button, or programmatic navigation).
27386
+ * - The registered form groups report unsaved changes via `QdFormGroupManagerService.$hasValuesChanged()`.
27387
+ *
27388
+ * The navigation is cancelled and a confirmation dialog is shown. The user can either discard
27389
+ * changes and proceed or stay on the page.
27390
+ *
27391
+ * #### When navigation is allowed
27392
+ *
27393
+ * - No unsaved changes exist — the form is either pristine or the framework has updated the
27394
+ * saved snapshot after a successful commit action (Submit, Save, SaveDraft).
27395
+ * - The page switches to view mode via `deactivate()`.
27396
+ *
27397
+ * The service has no concept of "pending actions" or bypass windows. Framework actions update the
27398
+ * saved snapshot directly via `QdFormGroupManagerService.setFormGroupsSnapshot(...)`; cancel-discard
27399
+ * flows reset the form to the saved snapshot via `restoreFormGroupsFromSnapshot()`. Either way, the
27400
+ * interceptor only observes the resulting form state — there is no time window where a bypass is active.
27401
+ */
27402
+ class QdPageNavigationInterceptorService {
27403
+ router = inject(Router);
27404
+ formGroupManager = inject(QdFormGroupManagerService);
27405
+ confirmationDialog = inject(QdConfirmationDialogOpenerService);
27406
+ _destroy$ = new Subject();
27407
+ _deactivate$ = new Subject();
27408
+ /**
27409
+ * Guards the re-dispatched navigation issued after the user confirms discard. While the dialog
27410
+ * is open and while the confirmed target is being re-dispatched, the router events must not
27411
+ * re-enter the interception pipeline — otherwise the dialog would open recursively.
27412
+ */
27413
+ _bypassInterception = false;
27414
+ /**
27415
+ * URL of the navigation target accepted by the user in the most recent discard-confirm dialog.
27416
+ * Set by `navigateToConfirmedTarget()` and consumed once by `shouldIntercept()` when the
27417
+ * corresponding `NavigationStart` arrives.
27418
+ */
27419
+ _confirmedTargetUrl;
27420
+ _confirmationMessage;
27421
+ ngOnDestroy() {
27422
+ this._confirmedTargetUrl = undefined;
27423
+ this._destroy$.next();
27424
+ this._destroy$.complete();
27425
+ this._deactivate$.complete();
27426
+ }
27427
+ /**
27428
+ * Starts intercepting navigation events. Replaces any previously active listener.
27429
+ */
27430
+ activate(confirmationMessage) {
27431
+ this._confirmationMessage = confirmationMessage;
27432
+ this._deactivate$.next();
27433
+ this.listenForUnsavedNavigationAttempts();
27434
+ }
27435
+ /**
27436
+ * Stops intercepting and clears the pending confirmed-target bypass.
27437
+ */
27438
+ deactivate() {
27439
+ this._deactivate$.next();
27440
+ this._confirmedTargetUrl = undefined;
27441
+ }
27442
+ /**
27443
+ * Runs the given action while temporarily suppressing navigation interception.
27444
+ *
27445
+ * Used by framework commit-action orchestration to wrap synchronous handler invocations and
27446
+ * post-commit `onSuccess` callbacks: any router navigation that occurs during `action()` is
27447
+ * passed through without raising the unsaved-changes dialog. Restoration happens in a `finally`
27448
+ * block, so the flag is released even if `action()` throws.
27449
+ */
27450
+ executeWithBypass(action) {
27451
+ this._bypassInterception = true;
27452
+ try {
27453
+ action();
27454
+ }
27455
+ finally {
27456
+ this._bypassInterception = false;
27457
+ }
27458
+ }
27459
+ listenForUnsavedNavigationAttempts() {
27460
+ this.router.events
27461
+ .pipe(filter$1((event) => event instanceof NavigationStart), filter$1(() => !this._bypassInterception), filter$1(event => this.shouldIntercept(event)), concatMap$1(event => this.checkForPendingChanges(event)), filter$1(({ hasChanges }) => hasChanges), exhaustMap(({ event }) => this.cancelNavigationAndConfirm(event)), filter$1(({ confirmed }) => confirmed), takeUntil$1(this._deactivate$), takeUntil$1(this._destroy$))
27462
+ .subscribe(({ targetUrl }) => this.navigateToConfirmedTarget(targetUrl));
27463
+ }
27464
+ shouldIntercept(event) {
27465
+ if (this._confirmedTargetUrl !== undefined && this._confirmedTargetUrl === event.url) {
27466
+ this._confirmedTargetUrl = undefined;
27467
+ return false;
27468
+ }
27469
+ return true;
27470
+ }
27471
+ checkForPendingChanges(event) {
27472
+ return this.formGroupManager.$hasValuesChanged().pipe(take$1(1), map$1(hasChanges => ({ event, hasChanges })));
27473
+ }
27474
+ cancelNavigationAndConfirm(event) {
27475
+ this._bypassInterception = true;
27476
+ void this.router.navigateByUrl(this.router.url, { skipLocationChange: true });
27477
+ return this.confirmationDialog
27478
+ .showDiscardConfirmDialog(this._confirmationMessage, 'page-navigation-confirmation')
27479
+ .pipe(map$1(confirmed => ({ confirmed, targetUrl: event.url })), finalize(() => (this._bypassInterception = false)));
27480
+ }
27481
+ navigateToConfirmedTarget(targetUrl) {
27482
+ this._bypassInterception = false;
27483
+ this._confirmedTargetUrl = targetUrl;
27484
+ void this.router.navigateByUrl(targetUrl);
27485
+ }
27486
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
27487
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService });
27488
+ }
27489
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService, decorators: [{
27490
+ type: Injectable
27491
+ }] });
27492
+
27290
27493
  class QdResolverTriggerService {
27291
27494
  route = inject(ActivatedRoute, { optional: true });
27292
27495
  shouldTriggerResolver(triggerOn) {
@@ -27336,6 +27539,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
27336
27539
  class QdPageObjectHeaderComponent {
27337
27540
  pageObjectResolver = inject(QD_PAGE_OBJECT_RESOLVER_TOKEN, { optional: true });
27338
27541
  formGroupManagerService = inject(QdFormGroupManagerService, { optional: true });
27542
+ navigationInterceptor = inject(QdPageNavigationInterceptorService);
27339
27543
  dialogComponent = inject(QdDialogComponent, { optional: true });
27340
27544
  dialog = inject(QdDialogService);
27341
27545
  confirmationDialogService = inject(QdConfirmationDialogOpenerService);
@@ -27500,13 +27704,18 @@ class QdPageObjectHeaderComponent {
27500
27704
  });
27501
27705
  }
27502
27706
  save() {
27503
- const handleSuccess = () => {
27504
- this.formGroupManagerService.cancelPendingAsyncValidation();
27505
- this.pageStoreService.toggleViewonly(true);
27506
- this.formGroupManagerService.takeFormGroupsSnapshot();
27507
- };
27508
- const result = this.saveButton?.handler?.(this.formGroupManagerService.getAllValues());
27509
- isObservable(result) ? result.pipe(take(1)).subscribe((ok) => ok && handleSuccess()) : handleSuccess();
27707
+ const saveAction = this.saveButton;
27708
+ if (!saveAction)
27709
+ return;
27710
+ QdPageCommitActionExecutor.execute(saveAction, this.formGroupManagerService.getAllValues(), {
27711
+ formGroupManager: this.formGroupManagerService,
27712
+ navigationInterceptor: this.navigationInterceptor,
27713
+ destroyed$: this._destroyed$,
27714
+ onAfterSnapshot: () => {
27715
+ this.formGroupManagerService.cancelPendingAsyncValidation();
27716
+ this.pageStoreService.toggleViewonly(true);
27717
+ }
27718
+ });
27510
27719
  }
27511
27720
  changeContext(context, selection, event) {
27512
27721
  event.stopPropagation();
@@ -27534,7 +27743,7 @@ class QdPageObjectHeaderComponent {
27534
27743
  .subscribe();
27535
27744
  }
27536
27745
  initContexts() {
27537
- this.contexts$ = this.contextService.contexts$.pipe(map(contexts => contexts.filter(context => context.selection || this.config.pageType === 'overview')), map(contexts => contexts?.map(({ selection, context }) => ({
27746
+ this.contexts$ = this.contextService.contexts$.pipe(map(contexts => contexts.filter(context => context.selection || this.config.pageType === 'overview')), map(contexts => contexts.map(({ selection, context }) => ({
27538
27747
  label: context.label.i18n,
27539
27748
  value: Array.isArray(selection?.value)
27540
27749
  ? selection?.value.filter(item => item !== null && item !== undefined)
@@ -27545,7 +27754,7 @@ class QdPageObjectHeaderComponent {
27545
27754
  selection: selection?.value ?? []
27546
27755
  }))));
27547
27756
  this.contexts$
27548
- .pipe(takeUntil(this._destroyed$), tap(contexts => (this._availableContexts = contexts?.length ?? 0)))
27757
+ .pipe(takeUntil(this._destroyed$), tap(contexts => (this._availableContexts = contexts.length)))
27549
27758
  .subscribe();
27550
27759
  }
27551
27760
  updateCustomActions() {
@@ -28564,126 +28773,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
28564
28773
  type: Output
28565
28774
  }] } });
28566
28775
 
28567
- /**
28568
- * Intercepts router navigation when unsaved form changes exist on a QdPage.
28569
- *
28570
- * Provided per `QdPageComponent`. Activated automatically when the page enters an editable state
28571
- * (create pages or inspect pages in edit mode). Deactivated when the page returns to view mode.
28572
- *
28573
- * #### When navigation is intercepted
28574
- *
28575
- * - The user has unsaved form changes (tracked via `QdFormGroupManagerService`).
28576
- * - A `NavigationStart` event occurs (browser back, shell back button, or programmatic navigation).
28577
- *
28578
- * The current navigation is cancelled, and a confirmation dialog is shown. The user can either
28579
- * discard changes and proceed or cancel and stay on the page.
28580
- *
28581
- * #### When navigation is allowed
28582
- *
28583
- * - No unsaved changes exist — navigation proceeds silently.
28584
- * - A framework action (Submit, SaveDraft) wraps its handler via `executeWithBypass()`.
28585
- * - A confirmed discard sets `allowNextNavigation()` before the cancel handler navigates.
28586
- * - The page switches to view mode via `deactivate()`.
28587
- *
28588
- * Custom actions defined by the application are not bypassed. If a custom action navigates away
28589
- * while unsaved changes exist, the confirmation dialog is shown.
28590
- */
28591
- class QdPageNavigationInterceptorService {
28592
- router = inject(Router);
28593
- formGroupManager = inject(QdFormGroupManagerService);
28594
- confirmationDialog = inject(QdConfirmationDialogOpenerService);
28595
- _destroy$ = new Subject();
28596
- _deactivate$ = new Subject();
28597
- _bypassInterception = false;
28598
- _allowedTargetUrls = new Set();
28599
- _confirmationMessage;
28600
- ngOnDestroy() {
28601
- this._allowedTargetUrls.clear();
28602
- this._destroy$.next();
28603
- this._destroy$.complete();
28604
- this._deactivate$.complete();
28605
- }
28606
- /**
28607
- * Starts intercepting navigation events. Replaces any previously active listener.
28608
- */
28609
- activate(confirmationMessage) {
28610
- this._confirmationMessage = confirmationMessage;
28611
- this._deactivate$.next();
28612
- this.listenForUnsavedNavigationAttempts();
28613
- }
28614
- /**
28615
- * Stops intercepting and clears all pending URL bypasses.
28616
- */
28617
- deactivate() {
28618
- this._deactivate$.next();
28619
- this._allowedTargetUrls.clear();
28620
- }
28621
- /**
28622
- * Whitelists the next navigation so it bypasses interception.
28623
- * Without a URL, any next navigation is bypassed (wildcard). With a URL, only that
28624
- * specific navigation is bypassed — non-matching navigations are still intercepted.
28625
- */
28626
- allowNextNavigation(targetUrl) {
28627
- this._allowedTargetUrls.add(targetUrl ?? '*');
28628
- }
28629
- /**
28630
- * Executes the callback with interception temporarily disabled.
28631
- * The callback must navigate synchronously — async navigation after the callback returns
28632
- * will not be bypassed. This works because Angular's router emits NavigationStart
28633
- * synchronously within the navigateByUrl() / navigate() call.
28634
- */
28635
- executeWithBypass(fn) {
28636
- this._bypassInterception = true;
28637
- try {
28638
- fn();
28639
- }
28640
- finally {
28641
- this._bypassInterception = false;
28642
- }
28643
- }
28644
- listenForUnsavedNavigationAttempts() {
28645
- this.router.events
28646
- .pipe(filter$1((event) => event instanceof NavigationStart), filter$1(() => !this._bypassInterception), filter$1(event => this.shouldIntercept(event)), concatMap$1(event => this.checkForPendingChanges(event)), filter$1(({ hasChanges }) => hasChanges), exhaustMap(({ event }) => this.cancelNavigationAndConfirm(event)), filter$1(({ confirmed }) => confirmed), takeUntil$1(this._deactivate$), takeUntil$1(this._destroy$))
28647
- .subscribe(({ targetUrl }) => this.navigateToConfirmedTarget(targetUrl));
28648
- }
28649
- shouldIntercept(event) {
28650
- if (this._allowedTargetUrls.has('*')) {
28651
- this._allowedTargetUrls.clear();
28652
- return false;
28653
- }
28654
- if (this._allowedTargetUrls.has(event.url)) {
28655
- this._allowedTargetUrls.delete(event.url);
28656
- return false;
28657
- }
28658
- return true;
28659
- }
28660
- checkForPendingChanges(event) {
28661
- return this.formGroupManager.$hasValuesChanged().pipe(take$1(1), map$1(hasChanges => ({ event, hasChanges })));
28662
- }
28663
- cancelNavigationAndConfirm(event) {
28664
- this._bypassInterception = true;
28665
- void this.router.navigateByUrl(this.router.url, { skipLocationChange: true });
28666
- return this.confirmationDialog
28667
- .showDiscardConfirmDialog(this._confirmationMessage, 'page-navigation-confirmation')
28668
- .pipe(map$1(confirmed => ({ confirmed, targetUrl: event.url })), finalize(() => (this._bypassInterception = false)));
28669
- }
28670
- navigateToConfirmedTarget(targetUrl) {
28671
- this._bypassInterception = false;
28672
- this._allowedTargetUrls.add(targetUrl);
28673
- void this.router.navigateByUrl(targetUrl);
28674
- }
28675
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
28676
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService });
28677
- }
28678
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImport: i0, type: QdPageNavigationInterceptorService, decorators: [{
28679
- type: Injectable
28680
- }] });
28681
-
28682
28776
  class QdPageSubmitActionService {
28683
28777
  footerService = inject(QdPageFooterService);
28684
28778
  formGroupManagerService = inject(QdFormGroupManagerService);
28779
+ navigationInterceptor = inject(QdPageNavigationInterceptorService);
28685
28780
  _labelI18n = 'i18n.qd.page.footer.submit';
28686
- _submitHandler;
28781
+ _submitAction;
28687
28782
  _isVisible = true;
28688
28783
  _destroyed$ = new Subject();
28689
28784
  _cancelTrackFormValidity$ = new Subject();
@@ -28697,9 +28792,9 @@ class QdPageSubmitActionService {
28697
28792
  this._isVisible = isVisible;
28698
28793
  this.configureSubmitAction(pageTypeConfig.submit);
28699
28794
  }
28700
- configureSubmitAction(submitConfig) {
28701
- this._labelI18n = submitConfig.label?.i18n || 'i18n.qd.page.footer.submit';
28702
- this._submitHandler = this.buildSubmitHandler(submitConfig.handler);
28795
+ configureSubmitAction(action) {
28796
+ this._labelI18n = action.label?.i18n || 'i18n.qd.page.footer.submit';
28797
+ this._submitAction = action;
28703
28798
  this.registerSubmitAction();
28704
28799
  this.trackFormValidity();
28705
28800
  }
@@ -28709,7 +28804,7 @@ class QdPageSubmitActionService {
28709
28804
  key: 'submit',
28710
28805
  action: {
28711
28806
  titleI18n: this._labelI18n,
28712
- handler: this._submitHandler,
28807
+ handler: (...args) => this.executeSubmit(...args),
28713
28808
  isDisabled: true,
28714
28809
  isVisible: this._isVisible,
28715
28810
  type: QdFooterActionType.Primary
@@ -28717,12 +28812,16 @@ class QdPageSubmitActionService {
28717
28812
  }
28718
28813
  ]);
28719
28814
  }
28720
- buildSubmitHandler(originalHandler) {
28721
- return (...args) => {
28722
- if (!this.formGroupManagerService.hasFormGroups())
28723
- return originalHandler(...args);
28724
- return originalHandler(this.formGroupManagerService.getAllValues());
28725
- };
28815
+ executeSubmit(...args) {
28816
+ const action = this._submitAction;
28817
+ if (!action)
28818
+ return;
28819
+ const values = this.formGroupManagerService.hasFormGroups() ? this.formGroupManagerService.getAllValues() : args;
28820
+ QdPageCommitActionExecutor.execute(action, values, {
28821
+ formGroupManager: this.formGroupManagerService,
28822
+ navigationInterceptor: this.navigationInterceptor,
28823
+ destroyed$: this._destroyed$
28824
+ });
28726
28825
  }
28727
28826
  trackFormValidity() {
28728
28827
  this._cancelTrackFormValidity$.next();
@@ -28737,7 +28836,7 @@ class QdPageSubmitActionService {
28737
28836
  key: 'submit',
28738
28837
  action: {
28739
28838
  titleI18n: this._labelI18n,
28740
- handler: this._submitHandler,
28839
+ handler: (...args) => this.executeSubmit(...args),
28741
28840
  isDisabled: !isValid,
28742
28841
  isVisible: this._isVisible,
28743
28842
  type: QdFooterActionType.Primary
@@ -28792,6 +28891,22 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28792
28891
  *
28793
28892
  * Please check the relevant interfaces for each page type: `QdPageConfigOverview`, `QdPageConfigCreate`, `QdPageConfigInspect`, and `QdPageConfigCustom`.
28794
28893
  *
28894
+ * #### **Commit Actions**
28895
+ *
28896
+ * Commit actions (`submit`, `save`, `saveDraft`) can either run synchronously (handler returns `void`) or wait on an async result (handler returns `Observable<boolean>`). The framework waits for the first emission, advances the saved-state baseline on `true`, and exposes race-free hooks for side effects so navigation after a successful commit does not trigger the unsaved-changes dialog.
28897
+ *
28898
+ * `onSuccess` runs when the handler emits `true` (after the baseline has advanced), `onError` runs when the handler observable errors — e.g. a failed HTTP request that is not caught inside the pipeline.
28899
+ *
28900
+ * ```ts
28901
+ * submit: {
28902
+ * handler: (formValues) => this.api.create(formValues).pipe(map(() => true)),
28903
+ * onSuccess: () => this.router.navigateByUrl('/'),
28904
+ * onError: (err) => this.notifications.add('', { type: 'critical', i18n: 'i18n.myApp.create.failed' })
28905
+ * }
28906
+ * ```
28907
+ *
28908
+ * The same mechanism applies to `save` and `saveDraft`. For the full contract (success vs. planned `false` vs. error, anti-patterns, `discardUnsavedChanges()`), see `QdPageCommitAction` and its action-specific extensions `QdPageSaveAction`, `QdPageSaveDraftAction`, `QdPageCreateSubmitAction`, and `QdPageInspectSubmitAction`.
28909
+ *
28795
28910
  * #### **Validation/Parameterization**
28796
28911
  *
28797
28912
  * Validation and parameterization are covered in a dedicated chapter in the Storybook. Please check the "Validation" section for more information.
@@ -28861,7 +28976,15 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28861
28976
  * handler: () => handleCancel()
28862
28977
  * },
28863
28978
  * save: {
28864
- * handler: (formValues) => handleSave(formValues)
28979
+ * handler: () => saveApi.save(form.value).pipe(
28980
+ * tap(result => notifications.success('Saved')),
28981
+ * map(() => true),
28982
+ * catchError(err => {
28983
+ * notifications.showError(err);
28984
+ * return of(false);
28985
+ * })
28986
+ * ),
28987
+ * onSuccess: () => router.navigate(['/overview'])
28865
28988
  * }
28866
28989
  * }
28867
28990
  * };
@@ -28979,7 +29102,8 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28979
29102
  * pageType: 'create',
28980
29103
  * pageTypeConfig: {
28981
29104
  * submit: {
28982
- * handler: (formValues) => handleSubmit(formValues)
29105
+ * handler: (formValues) => createApi.create(formValues).pipe(map(() => true)),
29106
+ * onSuccess: () => router.navigate(['/items'])
28983
29107
  * }
28984
29108
  * }
28985
29109
  * };
@@ -29072,7 +29196,14 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
29072
29196
  * handler: () => handleCancel()
29073
29197
  * },
29074
29198
  * save: {
29075
- * handler: (formValues) => handleSave(formValues)
29199
+ * handler: (formValues) => saveApi.save(formValues).pipe(
29200
+ * map(() => true),
29201
+ * catchError(err => {
29202
+ * notifications.showError(err);
29203
+ * return of(false);
29204
+ * })
29205
+ * ),
29206
+ * onSuccess: () => router.navigate(['/overview'])
29076
29207
  * }
29077
29208
  * }
29078
29209
  * };
@@ -29216,7 +29347,7 @@ class QdPageComponent {
29216
29347
  if (this.config.pageType === 'create' && this.config?.pageTypeConfig?.cancel !== undefined)
29217
29348
  this.handleCancelActionWithFormChanges();
29218
29349
  if (this.config.pageType === 'create' && this.config?.pageTypeConfig?.saveDraft !== undefined)
29219
- this.initSaveDraftFooterAction();
29350
+ this.initSaveDraftFooterAction(this.config.pageTypeConfig.saveDraft);
29220
29351
  if (this.config.pageType === 'inspect')
29221
29352
  this.pageStoreService.isViewonly$
29222
29353
  .pipe(takeUntil(this._destroyed$))
@@ -29230,6 +29361,34 @@ class QdPageComponent {
29230
29361
  this._destroyed$.next();
29231
29362
  this._destroyed$.complete();
29232
29363
  }
29364
+ /**
29365
+ * @description Resets all registered form groups to their last saved snapshot, marking the page
29366
+ * as no longer dirty.
29367
+ *
29368
+ * Intended for explicit consumer-driven discard scenarios where you want subsequent navigation
29369
+ * to bypass the unsaved-changes dialog — for example inside a commit action's `onError` hook
29370
+ * after an auth-expiry response, where the user must be redirected to the login page regardless
29371
+ * of unsaved edits.
29372
+ *
29373
+ * Prefer this over wiring custom bypass logic; it keeps the discard explicit and auditable in
29374
+ * application code.
29375
+ *
29376
+ * @example
29377
+ * ```ts
29378
+ * save: {
29379
+ * handler: (values) => api.save(values).pipe(map(() => true)),
29380
+ * onError: (err) => {
29381
+ * if (err.status === 401) {
29382
+ * this.pageComponent.discardUnsavedChanges();
29383
+ * this.router.navigateByUrl('/login');
29384
+ * }
29385
+ * }
29386
+ * }
29387
+ * ```
29388
+ */
29389
+ discardUnsavedChanges() {
29390
+ this.formGroupManagerService.restoreFormGroupsFromSnapshot();
29391
+ }
29233
29392
  checkConfigValidity() {
29234
29393
  if (!this.config)
29235
29394
  console.warn('QdUi | QdPageComponent - To configure the page you should provide a valid config.');
@@ -29242,11 +29401,14 @@ class QdPageComponent {
29242
29401
  const action = pageTypeConfig[actionKey];
29243
29402
  if (!action)
29244
29403
  continue;
29404
+ const handler = actionKey === 'cancel'
29405
+ ? () => action.handler()
29406
+ : this.generateCommitActionHandler(action);
29245
29407
  actions.push({
29246
29408
  actionKey,
29247
29409
  partialAction: {
29248
29410
  ...(action?.label?.i18n ? { titleI18n: action.label.i18n } : {}),
29249
- handler: this.generateFooterActionHandler(action?.handler)
29411
+ handler
29250
29412
  }
29251
29413
  });
29252
29414
  }
@@ -29263,25 +29425,20 @@ class QdPageComponent {
29263
29425
  partialAction: {
29264
29426
  handler: hasChanged
29265
29427
  ? () => this.setupCancelConfirmation()
29266
- : () => {
29267
- this.navigationInterceptor.executeWithBypass(() => {
29268
- this.config?.pageTypeConfig?.cancel?.handler();
29269
- });
29270
- }
29428
+ : () => this.config?.pageTypeConfig?.cancel?.handler()
29271
29429
  }
29272
29430
  }
29273
29431
  ]);
29274
29432
  });
29275
29433
  }
29276
- initSaveDraftFooterAction() {
29277
- const pageTypeConfig = this.config.pageTypeConfig;
29434
+ initSaveDraftFooterAction(saveDraft) {
29278
29435
  this.footerService.setActions([
29279
29436
  {
29280
29437
  key: 'saveDraft',
29281
29438
  action: {
29282
- titleI18n: pageTypeConfig.saveDraft?.label?.i18n ?? 'i18n.qd.page.footer.saveDraft',
29439
+ titleI18n: saveDraft.label?.i18n ?? 'i18n.qd.page.footer.saveDraft',
29283
29440
  type: QdFooterActionType.Secondary,
29284
- handler: this.generateFooterActionHandler(pageTypeConfig?.saveDraft?.handler),
29441
+ handler: this.generateCommitActionHandler(saveDraft),
29285
29442
  isVisible: true,
29286
29443
  isDisabled: false
29287
29444
  }
@@ -29308,12 +29465,14 @@ class QdPageComponent {
29308
29465
  if (this._isInitialized)
29309
29466
  this.operationModeChanged.emit(mode);
29310
29467
  }
29311
- generateFooterActionHandler(handler) {
29468
+ generateCommitActionHandler(action) {
29312
29469
  return (...args) => {
29313
- if (!handler)
29314
- return;
29315
29470
  const values = this.formGroupManagerService.hasFormGroups() ? this.formGroupManagerService.getAllValues() : args;
29316
- this.navigationInterceptor.executeWithBypass(() => handler(values));
29471
+ QdPageCommitActionExecutor.execute(action, values, {
29472
+ formGroupManager: this.formGroupManagerService,
29473
+ navigationInterceptor: this.navigationInterceptor,
29474
+ destroyed$: this._destroyed$
29475
+ });
29317
29476
  };
29318
29477
  }
29319
29478
  setupCancelConfirmation() {
@@ -29322,8 +29481,8 @@ class QdPageComponent {
29322
29481
  .showDiscardConfirmDialog(cancelConfig?.confirmationMessage, this.testId + '-cancel-confirmation')
29323
29482
  .pipe(filter$1(result => result), takeUntil(this._destroyed$))
29324
29483
  .subscribe(() => {
29484
+ this.formGroupManagerService.restoreFormGroupsFromSnapshot();
29325
29485
  cancelConfig?.handler?.();
29326
- this.navigationInterceptor.allowNextNavigation();
29327
29486
  });
29328
29487
  }
29329
29488
  initSubmitValidation() {
@@ -29331,7 +29490,7 @@ class QdPageComponent {
29331
29490
  this.formGroupManagerService
29332
29491
  .$areFormGroupsValid()
29333
29492
  .pipe(takeUntil(this._cancelSubmitValidation$), takeUntil(this._destroyed$), tap(isValid => {
29334
- const submitDisabledInfoText = this.config.pageType === 'inspect' ? this.config.pageTypeConfig?.submit?.disabledInfo : undefined;
29493
+ const submitDisabledInfoText = this.config.pageTypeConfig?.submit?.disabledInfo;
29335
29494
  this.footerService.updateActions([
29336
29495
  {
29337
29496
  actionKey: 'submit',