@quadrel-enterprise-ui/framework 20.10.0 → 20.10.1-beta.143.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);
@@ -27360,6 +27564,7 @@ class QdPageObjectHeaderComponent {
27360
27564
  _isLoadingSubject = new BehaviorSubject(false);
27361
27565
  _customActionsSubject = new BehaviorSubject({ actions: [] });
27362
27566
  _customActionsSub;
27567
+ _metadataSub;
27363
27568
  _destroyed$ = new Subject();
27364
27569
  _availableContexts = 0;
27365
27570
  pageObjectData$ = this._pageObjectDataSubject.asObservable();
@@ -27457,12 +27662,15 @@ class QdPageObjectHeaderComponent {
27457
27662
  if (this.pageObjectResolver)
27458
27663
  this.setupResolverTrigger();
27459
27664
  this.updateCustomActions();
27665
+ this.subscribeToMetadataStream();
27460
27666
  this.formGroupManagerService.takeFormGroupsSnapshot();
27461
27667
  this.initContexts();
27462
27668
  }
27463
27669
  ngOnChanges(changes) {
27464
- if (changes['config'] && !changes['config'].firstChange)
27670
+ if (changes['config'] && !changes['config'].firstChange) {
27465
27671
  this.updateCustomActions();
27672
+ this.subscribeToMetadataStream();
27673
+ }
27466
27674
  }
27467
27675
  ngOnDestroy() {
27468
27676
  this.pageStoreService.toggleViewonly(false);
@@ -27500,13 +27708,18 @@ class QdPageObjectHeaderComponent {
27500
27708
  });
27501
27709
  }
27502
27710
  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();
27711
+ const saveAction = this.saveButton;
27712
+ if (!saveAction)
27713
+ return;
27714
+ QdPageCommitActionExecutor.execute(saveAction, this.formGroupManagerService.getAllValues(), {
27715
+ formGroupManager: this.formGroupManagerService,
27716
+ navigationInterceptor: this.navigationInterceptor,
27717
+ destroyed$: this._destroyed$,
27718
+ onAfterSnapshot: () => {
27719
+ this.formGroupManagerService.cancelPendingAsyncValidation();
27720
+ this.pageStoreService.toggleViewonly(true);
27721
+ }
27722
+ });
27510
27723
  }
27511
27724
  changeContext(context, selection, event) {
27512
27725
  event.stopPropagation();
@@ -27534,7 +27747,7 @@ class QdPageObjectHeaderComponent {
27534
27747
  .subscribe();
27535
27748
  }
27536
27749
  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 }) => ({
27750
+ this.contexts$ = this.contextService.contexts$.pipe(map(contexts => contexts.filter(context => context.selection || this.config.pageType === 'overview')), map(contexts => contexts.map(({ selection, context }) => ({
27538
27751
  label: context.label.i18n,
27539
27752
  value: Array.isArray(selection?.value)
27540
27753
  ? selection?.value.filter(item => item !== null && item !== undefined)
@@ -27545,7 +27758,7 @@ class QdPageObjectHeaderComponent {
27545
27758
  selection: selection?.value ?? []
27546
27759
  }))));
27547
27760
  this.contexts$
27548
- .pipe(takeUntil(this._destroyed$), tap(contexts => (this._availableContexts = contexts?.length ?? 0)))
27761
+ .pipe(takeUntil(this._destroyed$), tap(contexts => (this._availableContexts = contexts.length)))
27549
27762
  .subscribe();
27550
27763
  }
27551
27764
  updateCustomActions() {
@@ -27562,6 +27775,13 @@ class QdPageObjectHeaderComponent {
27562
27775
  }
27563
27776
  this.subscribeToViewOnlyMode();
27564
27777
  }
27778
+ subscribeToMetadataStream() {
27779
+ this._metadataSub?.unsubscribe();
27780
+ const metadata$ = this.config.metadata$;
27781
+ if (!metadata$)
27782
+ return;
27783
+ this._metadataSub = metadata$.pipe(takeUntil(this._destroyed$)).subscribe(partial => this.updateMetadata(partial));
27784
+ }
27565
27785
  subscribeToViewOnlyMode() {
27566
27786
  this._customActionsSub?.unsubscribe();
27567
27787
  this._customActionsSub = this.pageStoreService.isViewonly$
@@ -28564,126 +28784,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.18", ngImpo
28564
28784
  type: Output
28565
28785
  }] } });
28566
28786
 
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
28787
  class QdPageSubmitActionService {
28683
28788
  footerService = inject(QdPageFooterService);
28684
28789
  formGroupManagerService = inject(QdFormGroupManagerService);
28790
+ navigationInterceptor = inject(QdPageNavigationInterceptorService);
28685
28791
  _labelI18n = 'i18n.qd.page.footer.submit';
28686
- _submitHandler;
28792
+ _submitAction;
28687
28793
  _isVisible = true;
28688
28794
  _destroyed$ = new Subject();
28689
28795
  _cancelTrackFormValidity$ = new Subject();
@@ -28697,9 +28803,9 @@ class QdPageSubmitActionService {
28697
28803
  this._isVisible = isVisible;
28698
28804
  this.configureSubmitAction(pageTypeConfig.submit);
28699
28805
  }
28700
- configureSubmitAction(submitConfig) {
28701
- this._labelI18n = submitConfig.label?.i18n || 'i18n.qd.page.footer.submit';
28702
- this._submitHandler = this.buildSubmitHandler(submitConfig.handler);
28806
+ configureSubmitAction(action) {
28807
+ this._labelI18n = action.label?.i18n || 'i18n.qd.page.footer.submit';
28808
+ this._submitAction = action;
28703
28809
  this.registerSubmitAction();
28704
28810
  this.trackFormValidity();
28705
28811
  }
@@ -28709,7 +28815,7 @@ class QdPageSubmitActionService {
28709
28815
  key: 'submit',
28710
28816
  action: {
28711
28817
  titleI18n: this._labelI18n,
28712
- handler: this._submitHandler,
28818
+ handler: (...args) => this.executeSubmit(...args),
28713
28819
  isDisabled: true,
28714
28820
  isVisible: this._isVisible,
28715
28821
  type: QdFooterActionType.Primary
@@ -28717,12 +28823,16 @@ class QdPageSubmitActionService {
28717
28823
  }
28718
28824
  ]);
28719
28825
  }
28720
- buildSubmitHandler(originalHandler) {
28721
- return (...args) => {
28722
- if (!this.formGroupManagerService.hasFormGroups())
28723
- return originalHandler(...args);
28724
- return originalHandler(this.formGroupManagerService.getAllValues());
28725
- };
28826
+ executeSubmit(...args) {
28827
+ const action = this._submitAction;
28828
+ if (!action)
28829
+ return;
28830
+ const values = this.formGroupManagerService.hasFormGroups() ? this.formGroupManagerService.getAllValues() : args;
28831
+ QdPageCommitActionExecutor.execute(action, values, {
28832
+ formGroupManager: this.formGroupManagerService,
28833
+ navigationInterceptor: this.navigationInterceptor,
28834
+ destroyed$: this._destroyed$
28835
+ });
28726
28836
  }
28727
28837
  trackFormValidity() {
28728
28838
  this._cancelTrackFormValidity$.next();
@@ -28737,7 +28847,7 @@ class QdPageSubmitActionService {
28737
28847
  key: 'submit',
28738
28848
  action: {
28739
28849
  titleI18n: this._labelI18n,
28740
- handler: this._submitHandler,
28850
+ handler: (...args) => this.executeSubmit(...args),
28741
28851
  isDisabled: !isValid,
28742
28852
  isVisible: this._isVisible,
28743
28853
  type: QdFooterActionType.Primary
@@ -28792,6 +28902,22 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28792
28902
  *
28793
28903
  * Please check the relevant interfaces for each page type: `QdPageConfigOverview`, `QdPageConfigCreate`, `QdPageConfigInspect`, and `QdPageConfigCustom`.
28794
28904
  *
28905
+ * #### **Commit Actions**
28906
+ *
28907
+ * 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.
28908
+ *
28909
+ * `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.
28910
+ *
28911
+ * ```ts
28912
+ * submit: {
28913
+ * handler: (formValues) => this.api.create(formValues).pipe(map(() => true)),
28914
+ * onSuccess: () => this.router.navigateByUrl('/'),
28915
+ * onError: (err) => this.notifications.add('', { type: 'critical', i18n: 'i18n.myApp.create.failed' })
28916
+ * }
28917
+ * ```
28918
+ *
28919
+ * 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`.
28920
+ *
28795
28921
  * #### **Validation/Parameterization**
28796
28922
  *
28797
28923
  * Validation and parameterization are covered in a dedicated chapter in the Storybook. Please check the "Validation" section for more information.
@@ -28861,7 +28987,15 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28861
28987
  * handler: () => handleCancel()
28862
28988
  * },
28863
28989
  * save: {
28864
- * handler: (formValues) => handleSave(formValues)
28990
+ * handler: () => saveApi.save(form.value).pipe(
28991
+ * tap(result => notifications.success('Saved')),
28992
+ * map(() => true),
28993
+ * catchError(err => {
28994
+ * notifications.showError(err);
28995
+ * return of(false);
28996
+ * })
28997
+ * ),
28998
+ * onSuccess: () => router.navigate(['/overview'])
28865
28999
  * }
28866
29000
  * }
28867
29001
  * };
@@ -28870,19 +29004,42 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28870
29004
  *
28871
29005
  * #### **Updating Facets**
28872
29006
  *
28873
- * Typically, the values of the facets on a create or inspect page are set to read-only. However, there may be cases where, for example, a status change is needed, such as through a dialog. For this purpose, we have provided an update method. In the resolver service, define an empty method called `updateMetadata`. Inject this service using its type, and then call the `updateMetadata` method with the required parameter.
29007
+ * Typically, the values of the facets on a create or inspect page are set to read-only. If a facet value needs to change at runtime for instance after a status update from a dialog push the partial update through an observable on the page config. The header subscribes to `QdPageConfig.metadata$` and shallow-merges every emission into the current object data.
28874
29008
  *
28875
29009
  * **Please note: These values should not be modified directly within a QdPage.**
28876
29010
  *
28877
29011
  * ```ts
28878
- * @Injectable()
28879
- * class MyObjectModelResolver implements QdPageObjectResolver<MyObjectModel> {
28880
- * config: QdPageObjectResolverConfig = {
28881
- * // your configuration options here
29012
+ * @Component({
29013
+ * // ...
29014
+ * providers: [
29015
+ * {
29016
+ * provide: QD_PAGE_OBJECT_RESOLVER_TOKEN,
29017
+ * useClass: MyObjectModelResolver
29018
+ * }
29019
+ * ]
29020
+ * })
29021
+ * class MyPageComponent {
29022
+ * private metadataUpdates$ = new Subject<Partial<MyObjectModel>>();
29023
+ *
29024
+ * pageConfig: QdPageConfig<MyObjectModel> = {
29025
+ * title: { i18n: 'i18n.page.title' },
29026
+ * pageType: 'inspect',
29027
+ * headerFacets: [ /* ... *\/ ],
29028
+ * metadata$: this.metadataUpdates$,
29029
+ * pageTypeConfig: { /* ... *\/ }
29030
+ * };
29031
+ *
29032
+ * updateStatus() {
29033
+ * this.metadataUpdates$.next({ state: 'Updated' });
28882
29034
  * }
29035
+ * }
29036
+ * ```
28883
29037
  *
28884
- * constructor(private http: HttpClient) {}
29038
+ * Legacy approach (`@deprecated`): the resolver-level `updateMetadata` method is still wired up for backward compatibility, but prefer `metadata$` on the config for new code.
28885
29039
  *
29040
+ * ```ts
29041
+ * @Injectable()
29042
+ * class MyObjectModelResolver implements QdPageObjectResolver<MyObjectModel> {
28886
29043
  * resolve(): Observable<MyObjectModel> {
28887
29044
  * // your implementation here
28888
29045
  * }
@@ -28892,15 +29049,6 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28892
29049
  * }
28893
29050
  * }
28894
29051
  *
28895
- * @Component({
28896
- * // ...
28897
- * providers: [
28898
- * {
28899
- * provide: QD_PAGE_OBJECT_RESOLVER_TOKEN,
28900
- * useClass: MyObjectModelResolver
28901
- * }
28902
- * ]
28903
- * })
28904
29052
  * class MyPageComponent {
28905
29053
  * constructor(@Inject(QD_PAGE_OBJECT_RESOLVER_TOKEN) private objectResolver: MyObjectModelResolver) {}
28906
29054
  *
@@ -28979,7 +29127,8 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
28979
29127
  * pageType: 'create',
28980
29128
  * pageTypeConfig: {
28981
29129
  * submit: {
28982
- * handler: (formValues) => handleSubmit(formValues)
29130
+ * handler: (formValues) => createApi.create(formValues).pipe(map(() => true)),
29131
+ * onSuccess: () => router.navigate(['/items'])
28983
29132
  * }
28984
29133
  * }
28985
29134
  * };
@@ -29072,7 +29221,14 @@ const SAFE_BOTTOM_OFFSET_PX = 64;
29072
29221
  * handler: () => handleCancel()
29073
29222
  * },
29074
29223
  * save: {
29075
- * handler: (formValues) => handleSave(formValues)
29224
+ * handler: (formValues) => saveApi.save(formValues).pipe(
29225
+ * map(() => true),
29226
+ * catchError(err => {
29227
+ * notifications.showError(err);
29228
+ * return of(false);
29229
+ * })
29230
+ * ),
29231
+ * onSuccess: () => router.navigate(['/overview'])
29076
29232
  * }
29077
29233
  * }
29078
29234
  * };
@@ -29216,7 +29372,7 @@ class QdPageComponent {
29216
29372
  if (this.config.pageType === 'create' && this.config?.pageTypeConfig?.cancel !== undefined)
29217
29373
  this.handleCancelActionWithFormChanges();
29218
29374
  if (this.config.pageType === 'create' && this.config?.pageTypeConfig?.saveDraft !== undefined)
29219
- this.initSaveDraftFooterAction();
29375
+ this.initSaveDraftFooterAction(this.config.pageTypeConfig.saveDraft);
29220
29376
  if (this.config.pageType === 'inspect')
29221
29377
  this.pageStoreService.isViewonly$
29222
29378
  .pipe(takeUntil(this._destroyed$))
@@ -29230,6 +29386,34 @@ class QdPageComponent {
29230
29386
  this._destroyed$.next();
29231
29387
  this._destroyed$.complete();
29232
29388
  }
29389
+ /**
29390
+ * @description Resets all registered form groups to their last saved snapshot, marking the page
29391
+ * as no longer dirty.
29392
+ *
29393
+ * Intended for explicit consumer-driven discard scenarios where you want subsequent navigation
29394
+ * to bypass the unsaved-changes dialog — for example inside a commit action's `onError` hook
29395
+ * after an auth-expiry response, where the user must be redirected to the login page regardless
29396
+ * of unsaved edits.
29397
+ *
29398
+ * Prefer this over wiring custom bypass logic; it keeps the discard explicit and auditable in
29399
+ * application code.
29400
+ *
29401
+ * @example
29402
+ * ```ts
29403
+ * save: {
29404
+ * handler: (values) => api.save(values).pipe(map(() => true)),
29405
+ * onError: (err) => {
29406
+ * if (err.status === 401) {
29407
+ * this.pageComponent.discardUnsavedChanges();
29408
+ * this.router.navigateByUrl('/login');
29409
+ * }
29410
+ * }
29411
+ * }
29412
+ * ```
29413
+ */
29414
+ discardUnsavedChanges() {
29415
+ this.formGroupManagerService.restoreFormGroupsFromSnapshot();
29416
+ }
29233
29417
  checkConfigValidity() {
29234
29418
  if (!this.config)
29235
29419
  console.warn('QdUi | QdPageComponent - To configure the page you should provide a valid config.');
@@ -29242,11 +29426,14 @@ class QdPageComponent {
29242
29426
  const action = pageTypeConfig[actionKey];
29243
29427
  if (!action)
29244
29428
  continue;
29429
+ const handler = actionKey === 'cancel'
29430
+ ? () => action.handler()
29431
+ : this.generateCommitActionHandler(action);
29245
29432
  actions.push({
29246
29433
  actionKey,
29247
29434
  partialAction: {
29248
29435
  ...(action?.label?.i18n ? { titleI18n: action.label.i18n } : {}),
29249
- handler: this.generateFooterActionHandler(action?.handler)
29436
+ handler
29250
29437
  }
29251
29438
  });
29252
29439
  }
@@ -29263,25 +29450,20 @@ class QdPageComponent {
29263
29450
  partialAction: {
29264
29451
  handler: hasChanged
29265
29452
  ? () => this.setupCancelConfirmation()
29266
- : () => {
29267
- this.navigationInterceptor.executeWithBypass(() => {
29268
- this.config?.pageTypeConfig?.cancel?.handler();
29269
- });
29270
- }
29453
+ : () => this.config?.pageTypeConfig?.cancel?.handler()
29271
29454
  }
29272
29455
  }
29273
29456
  ]);
29274
29457
  });
29275
29458
  }
29276
- initSaveDraftFooterAction() {
29277
- const pageTypeConfig = this.config.pageTypeConfig;
29459
+ initSaveDraftFooterAction(saveDraft) {
29278
29460
  this.footerService.setActions([
29279
29461
  {
29280
29462
  key: 'saveDraft',
29281
29463
  action: {
29282
- titleI18n: pageTypeConfig.saveDraft?.label?.i18n ?? 'i18n.qd.page.footer.saveDraft',
29464
+ titleI18n: saveDraft.label?.i18n ?? 'i18n.qd.page.footer.saveDraft',
29283
29465
  type: QdFooterActionType.Secondary,
29284
- handler: this.generateFooterActionHandler(pageTypeConfig?.saveDraft?.handler),
29466
+ handler: this.generateCommitActionHandler(saveDraft),
29285
29467
  isVisible: true,
29286
29468
  isDisabled: false
29287
29469
  }
@@ -29308,12 +29490,14 @@ class QdPageComponent {
29308
29490
  if (this._isInitialized)
29309
29491
  this.operationModeChanged.emit(mode);
29310
29492
  }
29311
- generateFooterActionHandler(handler) {
29493
+ generateCommitActionHandler(action) {
29312
29494
  return (...args) => {
29313
- if (!handler)
29314
- return;
29315
29495
  const values = this.formGroupManagerService.hasFormGroups() ? this.formGroupManagerService.getAllValues() : args;
29316
- this.navigationInterceptor.executeWithBypass(() => handler(values));
29496
+ QdPageCommitActionExecutor.execute(action, values, {
29497
+ formGroupManager: this.formGroupManagerService,
29498
+ navigationInterceptor: this.navigationInterceptor,
29499
+ destroyed$: this._destroyed$
29500
+ });
29317
29501
  };
29318
29502
  }
29319
29503
  setupCancelConfirmation() {
@@ -29322,8 +29506,8 @@ class QdPageComponent {
29322
29506
  .showDiscardConfirmDialog(cancelConfig?.confirmationMessage, this.testId + '-cancel-confirmation')
29323
29507
  .pipe(filter$1(result => result), takeUntil(this._destroyed$))
29324
29508
  .subscribe(() => {
29509
+ this.formGroupManagerService.restoreFormGroupsFromSnapshot();
29325
29510
  cancelConfig?.handler?.();
29326
- this.navigationInterceptor.allowNextNavigation();
29327
29511
  });
29328
29512
  }
29329
29513
  initSubmitValidation() {
@@ -29331,7 +29515,7 @@ class QdPageComponent {
29331
29515
  this.formGroupManagerService
29332
29516
  .$areFormGroupsValid()
29333
29517
  .pipe(takeUntil(this._cancelSubmitValidation$), takeUntil(this._destroyed$), tap(isValid => {
29334
- const submitDisabledInfoText = this.config.pageType === 'inspect' ? this.config.pageTypeConfig?.submit?.disabledInfo : undefined;
29518
+ const submitDisabledInfoText = this.config.pageTypeConfig?.submit?.disabledInfo;
29335
29519
  this.footerService.updateActions([
29336
29520
  {
29337
29521
  actionKey: 'submit',