@ardimedia/angular-portal-azure 0.3.25 → 0.3.27

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
@@ -1,63 +1,63 @@
1
- # AngularPortalAzure
2
-
3
- This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.0.
4
-
5
- ## Code scaffolding
6
-
7
- Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
8
-
9
- ```bash
10
- ng generate component component-name
11
- ```
12
-
13
- For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
14
-
15
- ```bash
16
- ng generate --help
17
- ```
18
-
19
- ## Building
20
-
21
- To build the library, run:
22
-
23
- ```bash
24
- ng build angular-portal-azure
25
- ```
26
-
27
- This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
28
-
29
- ### Publishing the Library
30
-
31
- Once the project is built, you can publish your library by following these steps:
32
-
33
- 1. Navigate to the `dist` directory:
34
- ```bash
35
- cd dist/angular-portal-azure
36
- ```
37
-
38
- 2. Run the `npm publish` command to publish your library to the npm registry:
39
- ```bash
40
- npm publish
41
- ```
42
-
43
- ## Running unit tests
44
-
45
- To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
46
-
47
- ```bash
48
- ng test
49
- ```
50
-
51
- ## Running end-to-end tests
52
-
53
- For end-to-end (e2e) testing, run:
54
-
55
- ```bash
56
- ng e2e
57
- ```
58
-
59
- Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
60
-
61
- ## Additional Resources
62
-
63
- For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
1
+ # AngularPortalAzure
2
+
3
+ This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.1.0.
4
+
5
+ ## Code scaffolding
6
+
7
+ Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
8
+
9
+ ```bash
10
+ ng generate component component-name
11
+ ```
12
+
13
+ For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
14
+
15
+ ```bash
16
+ ng generate --help
17
+ ```
18
+
19
+ ## Building
20
+
21
+ To build the library, run:
22
+
23
+ ```bash
24
+ ng build angular-portal-azure
25
+ ```
26
+
27
+ This command will compile your project, and the build artifacts will be placed in the `dist/` directory.
28
+
29
+ ### Publishing the Library
30
+
31
+ Once the project is built, you can publish your library by following these steps:
32
+
33
+ 1. Navigate to the `dist` directory:
34
+ ```bash
35
+ cd dist/angular-portal-azure
36
+ ```
37
+
38
+ 2. Run the `npm publish` command to publish your library to the npm registry:
39
+ ```bash
40
+ npm publish
41
+ ```
42
+
43
+ ## Running unit tests
44
+
45
+ To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
46
+
47
+ ```bash
48
+ ng test
49
+ ```
50
+
51
+ ## Running end-to-end tests
52
+
53
+ For end-to-end (e2e) testing, run:
54
+
55
+ ```bash
56
+ ng e2e
57
+ ```
58
+
59
+ Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
60
+
61
+ ## Additional Resources
62
+
63
+ For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
@@ -1,8 +1,9 @@
1
1
  import * as i0 from '@angular/core';
2
- import { signal, computed, Injectable, inject, DestroyRef, effect, makeEnvironmentProviders, APP_INITIALIZER, ENVIRONMENT_INITIALIZER, input, output, Component, ElementRef, Injector, afterNextRender } from '@angular/core';
2
+ import { signal, computed, Injectable, inject, InjectionToken, makeEnvironmentProviders, ENVIRONMENT_INITIALIZER, DestroyRef, effect, APP_INITIALIZER, input, output, Component, ElementRef, Injector, afterNextRender, contentChild } from '@angular/core';
3
3
  import { Router, NavigationEnd } from '@angular/router';
4
4
  import { filter } from 'rxjs/operators';
5
5
  import { DOCUMENT, NgComponentOutlet } from '@angular/common';
6
+ import { NgForm } from '@angular/forms';
6
7
 
7
8
  function clearStatusBar() {
8
9
  return { text: '', style: 'none' };
@@ -72,6 +73,7 @@ const LABELS_DE_CH = {
72
73
  settings: 'Einstellungen',
73
74
  language: 'Sprache',
74
75
  appearance: 'Darstellung',
76
+ unsavedChangesConfirm: 'Die Änderungen wurden noch nicht gespeichert. Trotzdem verlassen?',
75
77
  };
76
78
  /** German (Germany) — Swiss spelling rules apply (no ß) */
77
79
  const LABELS_DE_DE = { ...LABELS_DE_CH };
@@ -101,6 +103,7 @@ const LABELS_EN = {
101
103
  settings: 'Settings',
102
104
  language: 'Language',
103
105
  appearance: 'Appearance',
106
+ unsavedChangesConfirm: 'You have unsaved changes. Leave anyway?',
104
107
  };
105
108
  /** French */
106
109
  const LABELS_FR = {
@@ -128,6 +131,7 @@ const LABELS_FR = {
128
131
  settings: 'Paramètres',
129
132
  language: 'Langue',
130
133
  appearance: 'Apparence',
134
+ unsavedChangesConfirm: 'Les modifications n\'ont pas été enregistrées. Quitter quand même ?',
131
135
  };
132
136
  /** Spanish */
133
137
  const LABELS_ES = {
@@ -155,6 +159,7 @@ const LABELS_ES = {
155
159
  settings: 'Configuración',
156
160
  language: 'Idioma',
157
161
  appearance: 'Apariencia',
162
+ unsavedChangesConfirm: 'Hay cambios sin guardar. ¿Salir de todos modos?',
158
163
  };
159
164
  /** Italian */
160
165
  const LABELS_IT = {
@@ -182,6 +187,7 @@ const LABELS_IT = {
182
187
  settings: 'Impostazioni',
183
188
  language: 'Lingua',
184
189
  appearance: 'Aspetto',
190
+ unsavedChangesConfirm: 'Le modifiche non sono state salvate. Uscire comunque?',
185
191
  };
186
192
  // ── Language preset registry ────────────────────────────────────────
187
193
  /** Keep DEFAULT_LABELS as alias for backward compatibility */
@@ -689,6 +695,70 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
689
695
  class BladeService {
690
696
  portal = inject(PortalService);
691
697
  registry = inject(BladeRegistry);
698
+ /**
699
+ * Unsaved-changes guard registry: blade path -> predicate returning true when the blade has
700
+ * unsaved edits. Populated by apa-blade-detail (from the projected NgForm). Consulted before a
701
+ * blade is closed/replaced or the page is left, so the user can confirm discarding changes.
702
+ * Message can be localised via PortalLabels.unsavedChangesConfirm; the navigation flow is
703
+ * synchronous, so a synchronous window.confirm is used.
704
+ */
705
+ dirtyChecks = new Map();
706
+ /** Optional override for the confirmation message (defaults to the de-CH label). */
707
+ unsavedChangesMessage = DEFAULT_LABELS.unsavedChangesConfirm;
708
+ constructor() {
709
+ // Native browser guard when the whole page/tab is closed or reloaded with unsaved edits.
710
+ if (typeof window !== 'undefined') {
711
+ window.addEventListener('beforeunload', (e) => {
712
+ if (this.anyDirty()) {
713
+ e.preventDefault();
714
+ e.returnValue = '';
715
+ }
716
+ });
717
+ }
718
+ }
719
+ /** Register a blade's unsaved-changes predicate (called by apa-blade-detail). */
720
+ registerDirtyCheck(path, isDirty) {
721
+ this.dirtyChecks.set(path.toLowerCase(), isDirty);
722
+ }
723
+ /** Remove a blade's unsaved-changes predicate (called when the detail is destroyed). */
724
+ unregisterDirtyCheck(path) {
725
+ this.dirtyChecks.delete(path.toLowerCase());
726
+ }
727
+ isPathDirty(path) {
728
+ const check = this.dirtyChecks.get(path.toLowerCase());
729
+ try {
730
+ return check ? check() === true : false;
731
+ }
732
+ catch {
733
+ return false;
734
+ }
735
+ }
736
+ anyDirty() {
737
+ for (const path of this.dirtyChecks.keys()) {
738
+ if (this.isPathDirty(path))
739
+ return true;
740
+ }
741
+ return false;
742
+ }
743
+ /**
744
+ * Returns true if it's OK to proceed with removing the given blade paths: none of them is dirty,
745
+ * or the user confirmed discarding the changes. Shows one confirmation regardless of how many
746
+ * dirty blades are affected.
747
+ */
748
+ mayDiscard(removedPaths) {
749
+ const anyDirty = removedPaths.some((p) => this.isPathDirty(p));
750
+ if (!anyDirty)
751
+ return true;
752
+ return typeof window === 'undefined' ? true : window.confirm(this.unsavedChangesMessage);
753
+ }
754
+ /** Drop dirty-check entries for paths that are no longer open. */
755
+ pruneDirtyChecks() {
756
+ const open = new Set(this.portal.blades().map((b) => b.path));
757
+ for (const path of [...this.dirtyChecks.keys()]) {
758
+ if (!open.has(path))
759
+ this.dirtyChecks.delete(path);
760
+ }
761
+ }
692
762
  /**
693
763
  * Set the first blade (e.g., when opening a top-level item from a tile).
694
764
  * Clears all existing blades, hides panorama, and adds the new blade.
@@ -696,6 +766,11 @@ class BladeService {
696
766
  * Ported from AreaBlades.setFirstBlade() in v0.2.346.
697
767
  */
698
768
  setFirstBlade(path, title = '', width) {
769
+ const existing = this.portal.blades();
770
+ if (existing.length > 0 && !this.mayDiscard(existing.map((b) => b.path))) {
771
+ return existing[0];
772
+ }
773
+ this.dirtyChecks.clear();
699
774
  this.portal.blades.set([]);
700
775
  const entry = this.registry.getEntry(path);
701
776
  const blade = createBlade(path.toLowerCase(), title || entry?.title || path, width ?? entry?.width ?? 315);
@@ -715,7 +790,8 @@ class BladeService {
715
790
  // Cascade close first: remove blades after the sender
716
791
  // This ensures a blade at the same path gets recreated with new params
717
792
  if (senderPath) {
718
- this.clearChild(senderPath);
793
+ if (!this.clearChild(senderPath))
794
+ return undefined;
719
795
  }
720
796
  // Check if blade already exists (after cascade close)
721
797
  const existing = this.portal.blades().find((b) => b.path === normalizedPath);
@@ -739,7 +815,10 @@ class BladeService {
739
815
  * Ported from AreaBlades.clearAll() in v0.2.346.
740
816
  */
741
817
  clearAll() {
818
+ if (!this.mayDiscard(this.portal.blades().map((b) => b.path)))
819
+ return;
742
820
  this.portal.blades.set([]);
821
+ this.dirtyChecks.clear();
743
822
  }
744
823
  /**
745
824
  * Remove a specific blade and all blades to its right.
@@ -752,7 +831,10 @@ class BladeService {
752
831
  const blades = this.portal.blades();
753
832
  const index = blades.findIndex((b) => b.path === normalizedPath);
754
833
  if (index >= 0) {
834
+ if (!this.mayDiscard(blades.slice(index).map((b) => b.path)))
835
+ return false;
755
836
  this.portal.blades.set(blades.slice(0, index));
837
+ this.pruneDirtyChecks();
756
838
  }
757
839
  else {
758
840
  // Check notification area
@@ -761,6 +843,7 @@ class BladeService {
761
843
  this.portal.hideNotification();
762
844
  }
763
845
  }
846
+ return true;
764
847
  }
765
848
  /**
766
849
  * Remove all blades AFTER a given path (keeps the blade itself).
@@ -770,13 +853,17 @@ class BladeService {
770
853
  */
771
854
  clearChild(path) {
772
855
  if (!path)
773
- return;
856
+ return true;
774
857
  const normalizedPath = path.toLowerCase();
775
858
  const blades = this.portal.blades();
776
859
  const index = blades.findIndex((b) => b.path === normalizedPath);
777
860
  if (index >= 0) {
861
+ if (!this.mayDiscard(blades.slice(index + 1).map((b) => b.path)))
862
+ return false;
778
863
  this.portal.blades.set(blades.slice(0, index + 1));
864
+ this.pruneDirtyChecks();
779
865
  }
866
+ return true;
780
867
  }
781
868
  /**
782
869
  * Remove blades at and beyond a specific 1-based level.
@@ -786,7 +873,10 @@ class BladeService {
786
873
  const adjustedLevel = level <= 0 ? 1 : level;
787
874
  const blades = this.portal.blades();
788
875
  if (adjustedLevel <= blades.length) {
876
+ if (!this.mayDiscard(blades.slice(adjustedLevel - 1).map((b) => b.path)))
877
+ return;
789
878
  this.portal.blades.set(blades.slice(0, adjustedLevel - 1));
879
+ this.pruneDirtyChecks();
790
880
  }
791
881
  }
792
882
  /**
@@ -796,7 +886,10 @@ class BladeService {
796
886
  clearLastLevel() {
797
887
  const blades = this.portal.blades();
798
888
  if (blades.length > 0) {
889
+ if (!this.mayDiscard([blades[blades.length - 1].path]))
890
+ return;
799
891
  this.portal.blades.set(blades.slice(0, -1));
892
+ this.pruneDirtyChecks();
800
893
  }
801
894
  }
802
895
  /**
@@ -831,7 +924,43 @@ class BladeService {
831
924
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeService, decorators: [{
832
925
  type: Injectable,
833
926
  args: [{ providedIn: 'root' }]
834
- }] });
927
+ }], ctorParameters: () => [] });
928
+
929
+ /** @internal */
930
+ const BLADE_ROUTER_CONFIG = new InjectionToken('BLADE_ROUTER_CONFIG');
931
+ /**
932
+ * Enables opt-in URL synchronization for the blade stack.
933
+ *
934
+ * Add alongside `provideRouter()` and `providePortalAzure()`:
935
+ * ```typescript
936
+ * export const appConfig: ApplicationConfig = {
937
+ * providers: [
938
+ * provideRouter(routes),
939
+ * providePortalAzure({ title: 'My Portal', ... }),
940
+ * provideBladeRouter(),
941
+ * ],
942
+ * };
943
+ * ```
944
+ *
945
+ * Optionally pass a config to set a fixed route prefix:
946
+ * ```typescript
947
+ * provideBladeRouter({ prefix: 'app' }) // → /app/customers/list
948
+ * provideBladeRouter({ prefix: '' }) // → /customers/list (no prefix)
949
+ * ```
950
+ *
951
+ * Without this provider, blade navigation remains purely in-memory.
952
+ */
953
+ function provideBladeRouter(config) {
954
+ return makeEnvironmentProviders([
955
+ BladeRouterService,
956
+ { provide: BLADE_ROUTER_CONFIG, useValue: config ?? {} },
957
+ {
958
+ provide: ENVIRONMENT_INITIALIZER,
959
+ multi: true,
960
+ useFactory: () => () => inject(BladeRouterService),
961
+ },
962
+ ]);
963
+ }
835
964
 
836
965
  /**
837
966
  * Optional service that syncs the blade stack with the browser URL.
@@ -849,6 +978,7 @@ class BladeRouterService {
849
978
  portal = inject(PortalService);
850
979
  registry = inject(BladeRegistry);
851
980
  destroyRef = inject(DestroyRef);
981
+ config = inject(BLADE_ROUTER_CONFIG, { optional: true }) ?? {};
852
982
  _syncingFromUrl = false;
853
983
  _initialRestoreDone = false;
854
984
  constructor() {
@@ -859,9 +989,11 @@ class BladeRouterService {
859
989
  return;
860
990
  if (blades.length === 0 && !this._initialRestoreDone)
861
991
  return;
862
- const routePrefix = this.getRoutePrefix();
992
+ const prefix = this.getEffectivePrefix();
863
993
  const bladePath = this.encodeBladesToPath(blades);
864
- const targetUrl = bladePath ? `/${routePrefix}/${bladePath}` : `/${routePrefix}`;
994
+ const targetUrl = prefix
995
+ ? (bladePath ? `/${prefix}/${bladePath}` : `/${prefix}`)
996
+ : (bladePath ? `/${bladePath}` : `/`);
865
997
  const currentPath = this.router.url.split('?')[0].split(';')[0];
866
998
  // Only navigate if the path actually changed (avoid loops)
867
999
  if (this.normalizeUrl(currentPath) !== this.normalizeUrl(targetUrl)) {
@@ -986,12 +1118,19 @@ class BladeRouterService {
986
1118
  }
987
1119
  /** Restore blade stack from a path-based URL */
988
1120
  restoreFromPath(url) {
989
- const routePrefix = this.getRoutePrefix();
1121
+ const prefix = this.getEffectivePrefix();
990
1122
  const path = url.split('?')[0]; // strip query params
991
- const prefixPattern = '/' + routePrefix;
992
- if (!path.startsWith(prefixPattern))
993
- return;
994
- const pathAfterPrefix = path.substring(prefixPattern.length + 1); // +1 for trailing /
1123
+ let pathAfterPrefix;
1124
+ if (prefix) {
1125
+ const prefixPattern = '/' + prefix;
1126
+ if (!path.startsWith(prefixPattern))
1127
+ return;
1128
+ pathAfterPrefix = path.substring(prefixPattern.length + 1); // +1 for trailing /
1129
+ }
1130
+ else {
1131
+ // No prefix: everything after the leading / is blade path
1132
+ pathAfterPrefix = path.substring(1);
1133
+ }
995
1134
  const newBlades = this.decodeBladesFromPath(pathAfterPrefix);
996
1135
  const currentPaths = this.portal.blades().map((b) => b.path);
997
1136
  const newPaths = newBlades.map((b) => b.path);
@@ -1054,12 +1193,25 @@ class BladeRouterService {
1054
1193
  const entry = this.registry.getEntry(path);
1055
1194
  return createBlade(path, entry?.title ?? path, entry?.width ?? 315);
1056
1195
  });
1057
- const routePrefix = this.getRoutePrefix();
1196
+ const prefix = this.getEffectivePrefix();
1058
1197
  const bladePath = this.encodeBladesToPath(blades);
1059
- const newUrl = bladePath ? `/${routePrefix}/${bladePath}` : `/${routePrefix}`;
1198
+ const newUrl = prefix
1199
+ ? (bladePath ? `/${prefix}/${bladePath}` : `/${prefix}`)
1200
+ : (bladePath ? `/${bladePath}` : `/`);
1060
1201
  // Redirect to new format
1061
1202
  this.router.navigateByUrl(newUrl, { replaceUrl: true });
1062
1203
  }
1204
+ /**
1205
+ * Return the effective route prefix. If a prefix was configured via
1206
+ * `provideBladeRouter({ prefix })`, use it (including empty string).
1207
+ * Otherwise fall back to dynamically reading the first URL segment.
1208
+ */
1209
+ getEffectivePrefix() {
1210
+ if (this.config.prefix !== undefined) {
1211
+ return this.config.prefix;
1212
+ }
1213
+ return this.getRoutePrefix();
1214
+ }
1063
1215
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeRouterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1064
1216
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeRouterService });
1065
1217
  }
@@ -1098,36 +1250,6 @@ function providePortalAzure(config) {
1098
1250
  ]);
1099
1251
  }
1100
1252
 
1101
- /**
1102
- * Enables opt-in URL synchronization for the blade stack.
1103
- *
1104
- * Add alongside `provideRouter()` and `providePortalAzure()`:
1105
- * ```typescript
1106
- * export const appConfig: ApplicationConfig = {
1107
- * providers: [
1108
- * provideRouter(routes),
1109
- * providePortalAzure({ title: 'My Portal', ... }),
1110
- * provideBladeRouter(),
1111
- * ],
1112
- * };
1113
- * ```
1114
- *
1115
- * When enabled, blade paths sync to the URL as a query parameter:
1116
- * `?blades=customers,customers/list,customers/1`
1117
- *
1118
- * Without this provider, blade navigation remains purely in-memory.
1119
- */
1120
- function provideBladeRouter() {
1121
- return makeEnvironmentProviders([
1122
- BladeRouterService,
1123
- {
1124
- provide: ENVIRONMENT_INITIALIZER,
1125
- multi: true,
1126
- useFactory: () => () => inject(BladeRouterService),
1127
- },
1128
- ]);
1129
- }
1130
-
1131
1253
  /**
1132
1254
  * Individual dashboard tile.
1133
1255
  * Ported from the tile section in home.html (v0.2.346).
@@ -1857,28 +1979,42 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
1857
1979
  *
1858
1980
  * Usage:
1859
1981
  * ```html
1982
+ * <!-- Default: each component is wrapped in <apa-blade> -->
1860
1983
  * <apa-blade-host />
1984
+ *
1985
+ * <!-- No wrapper: components render directly (they manage their own blade chrome) -->
1986
+ * <apa-blade-host [wrapBlade]="false" />
1861
1987
  * ```
1862
1988
  */
1863
1989
  class BladeHostComponent {
1990
+ /** Whether to wrap each component in an `<apa-blade>` element. Default: true. */
1991
+ wrapBlade = input(true, ...(ngDevMode ? [{ debugName: "wrapBlade" }] : /* istanbul ignore next */ []));
1864
1992
  portal = inject(PortalService);
1865
1993
  registry = inject(BladeRegistry);
1866
1994
  getComponent(path) {
1867
1995
  return this.registry.get(path) ?? null;
1868
1996
  }
1869
1997
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeHostComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1870
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.3", type: BladeHostComponent, isStandalone: true, selector: "apa-blade-host", ngImport: i0, template: `
1998
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.3", type: BladeHostComponent, isStandalone: true, selector: "apa-blade-host", inputs: { wrapBlade: { classPropertyName: "wrapBlade", publicName: "wrapBlade", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: `
1871
1999
  <div id="apa-blade-area" class="fxs-journey-target fxs-journey">
1872
2000
  <div class="fxs-journey-layout fxs-stacklayout fxs-stacklayout-horizontal">
1873
2001
  @for (blade of portal.blades(); track blade.uid) {
1874
2002
  <div class="azureportalblade fxs-stacklayout-child">
1875
- <apa-blade [blade]="blade">
2003
+ @if (wrapBlade()) {
2004
+ <apa-blade [blade]="blade">
2005
+ @if (getComponent(blade.path); as component) {
2006
+ <ng-container *ngComponentOutlet="component" />
2007
+ } @else {
2008
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
2009
+ }
2010
+ </apa-blade>
2011
+ } @else {
1876
2012
  @if (getComponent(blade.path); as component) {
1877
2013
  <ng-container *ngComponentOutlet="component" />
1878
2014
  } @else {
1879
2015
  <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1880
2016
  }
1881
- </apa-blade>
2017
+ }
1882
2018
  </div>
1883
2019
  }
1884
2020
  </div>
@@ -1892,19 +2028,27 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
1892
2028
  <div class="fxs-journey-layout fxs-stacklayout fxs-stacklayout-horizontal">
1893
2029
  @for (blade of portal.blades(); track blade.uid) {
1894
2030
  <div class="azureportalblade fxs-stacklayout-child">
1895
- <apa-blade [blade]="blade">
2031
+ @if (wrapBlade()) {
2032
+ <apa-blade [blade]="blade">
2033
+ @if (getComponent(blade.path); as component) {
2034
+ <ng-container *ngComponentOutlet="component" />
2035
+ } @else {
2036
+ <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
2037
+ }
2038
+ </apa-blade>
2039
+ } @else {
1896
2040
  @if (getComponent(blade.path); as component) {
1897
2041
  <ng-container *ngComponentOutlet="component" />
1898
2042
  } @else {
1899
2043
  <p style="padding:25px; color:var(--apa-text-secondary);">{{ blade.path }}</p>
1900
2044
  }
1901
- </apa-blade>
2045
+ }
1902
2046
  </div>
1903
2047
  }
1904
2048
  </div>
1905
2049
  </div>
1906
2050
  `, styles: [":host{display:block;height:100%}\n"] }]
1907
- }] });
2051
+ }], propDecorators: { wrapBlade: [{ type: i0.Input, args: [{ isSignal: true, alias: "wrapBlade", required: false }] }] } });
1908
2052
 
1909
2053
  /**
1910
2054
  * Navigation blade content — renders a list of nav items.
@@ -2145,8 +2289,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2145
2289
  */
2146
2290
  class BladeDetailComponent {
2147
2291
  blade = input.required(...(ngDevMode ? [{ debugName: "blade" }] : /* istanbul ignore next */ []));
2292
+ bladeService = inject(BladeService);
2293
+ /** The template-driven form projected into this detail, if any (auto-discovered). */
2294
+ form = contentChild(NgForm, { ...(ngDevMode ? { debugName: "form" } : /* istanbul ignore next */ {}), descendants: true });
2295
+ ngAfterContentInit() {
2296
+ const blade = this.blade();
2297
+ const isDirty = () => this.form()?.dirty ?? false;
2298
+ // Expose on the lifecycle (for consumers) and register with the BladeService so navigation
2299
+ // away from a dirty blade prompts for confirmation. No per-detail wiring needed — every
2300
+ // detail that projects a <form> is guarded automatically.
2301
+ blade.lifecycle.isDirty = isDirty;
2302
+ this.bladeService.registerDirtyCheck(blade.path, isDirty);
2303
+ // After a successful save, reset the form to pristine so the guard does not fire on a
2304
+ // saved-but-not-yet-navigated blade. Wrap (don't replace) any existing onSavedItem hook.
2305
+ const originalOnSaved = blade.lifecycle.onSavedItem;
2306
+ blade.lifecycle.onSavedItem = () => {
2307
+ this.form()?.form.markAsPristine();
2308
+ originalOnSaved?.();
2309
+ };
2310
+ }
2311
+ ngOnDestroy() {
2312
+ this.bladeService.unregisterDirtyCheck(this.blade().path);
2313
+ }
2148
2314
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.3", ngImport: i0, type: BladeDetailComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2149
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.3", type: BladeDetailComponent, isStandalone: true, selector: "apa-blade-detail", inputs: { blade: { classPropertyName: "blade", publicName: "blade", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
2315
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.2.0", version: "21.2.3", type: BladeDetailComponent, isStandalone: true, selector: "apa-blade-detail", inputs: { blade: { classPropertyName: "blade", publicName: "blade", isSignal: true, isRequired: true, transformFunction: null } }, queries: [{ propertyName: "form", first: true, predicate: NgForm, descendants: true, isSignal: true }], ngImport: i0, template: `
2150
2316
  <div class="apa-blade-detail">
2151
2317
  <ng-content />
2152
2318
  </div>
@@ -2159,7 +2325,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2159
2325
  <ng-content />
2160
2326
  </div>
2161
2327
  `, styles: [":host{display:flex;flex-direction:column;flex:1;min-height:0}.apa-blade-detail{flex:1;display:flex;flex-direction:column;min-height:0}\n"] }]
2162
- }], propDecorators: { blade: [{ type: i0.Input, args: [{ isSignal: true, alias: "blade", required: true }] }] } });
2328
+ }], propDecorators: { blade: [{ type: i0.Input, args: [{ isSignal: true, alias: "blade", required: true }] }], form: [{ type: i0.ContentChild, args: [i0.forwardRef(() => NgForm), { ...{ descendants: true }, isSignal: true }] }] } });
2163
2329
  /**
2164
2330
  * Create standard detail blade commands (new, save, delete, cancel).
2165
2331
  * Convenience function for setting up typical detail/edit blade commands
@@ -2410,5 +2576,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.3", ngImpor
2410
2576
  * Generated bundle index. Do not edit.
2411
2577
  */
2412
2578
 
2413
- export { AvatarMenuComponent, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeRegistry, BladeRouterService, BladeService, CommandBarComponent, DEFAULT_LABELS, LABELS_DE_CH, LABELS_DE_DE, LABELS_EN, LABELS_ES, LABELS_FR, LABELS_IT, LANGUAGE_PRESETS, NotificationPanelComponent, PanoramaComponent, PortalLayoutComponent, PortalService, SidebarComponent, TILE_DIMENSIONS, TileComponent, TileSize, clearStatusBar, createAvatarMenu, createBlade, createCommand, createDataBlade, createDetailCommands, createNavItem, createNotificationPanel, createPanorama, createTile, executeDeleteItem, executeLoadItem, executeLoadItems, executeSaveItem, filterItems, getUserDisplayName, layoutTiles, nextBladeUid, provideBladeRouter, providePortalAzure, registerLanguagePreset, statusBarError, statusBarInfo, statusBarSuccess };
2579
+ export { AvatarMenuComponent, BLADE_ROUTER_CONFIG, BladeComponent, BladeDetailComponent, BladeGridComponent, BladeHostComponent, BladeNavComponent, BladeRegistry, BladeRouterService, BladeService, CommandBarComponent, DEFAULT_LABELS, LABELS_DE_CH, LABELS_DE_DE, LABELS_EN, LABELS_ES, LABELS_FR, LABELS_IT, LANGUAGE_PRESETS, NotificationPanelComponent, PanoramaComponent, PortalLayoutComponent, PortalService, SidebarComponent, TILE_DIMENSIONS, TileComponent, TileSize, clearStatusBar, createAvatarMenu, createBlade, createCommand, createDataBlade, createDetailCommands, createNavItem, createNotificationPanel, createPanorama, createTile, executeDeleteItem, executeLoadItem, executeLoadItems, executeSaveItem, filterItems, getUserDisplayName, layoutTiles, nextBladeUid, provideBladeRouter, providePortalAzure, registerLanguagePreset, statusBarError, statusBarInfo, statusBarSuccess };
2414
2580
  //# sourceMappingURL=ardimedia-angular-portal-azure.mjs.map