platformcommons-web-lib 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/commons-shared-web-ui-1.0.0.tgz +0 -0
  2. package/documentation/alert.md +123 -0
  3. package/documentation/button-dropdown.md +126 -0
  4. package/documentation/button.md +184 -0
  5. package/documentation/cards-usage-guidelines.md +131 -0
  6. package/documentation/configurable-form.md +605 -0
  7. package/documentation/confirmation-modal.md +250 -0
  8. package/documentation/filter-sidebar.md +178 -0
  9. package/documentation/filter-table-selector.md +228 -0
  10. package/documentation/form-builder.md +597 -0
  11. package/documentation/form-components.md +384 -0
  12. package/documentation/nav.md +427 -0
  13. package/documentation/pagination.md +181 -0
  14. package/documentation/side-nav-documentation.md +169 -0
  15. package/documentation/smart-form.md +2177 -0
  16. package/documentation/smart-table.md +1198 -0
  17. package/documentation/snackbar.md +118 -0
  18. package/documentation/style-externalization.md +88 -0
  19. package/documentation/summary-card.md +279 -0
  20. package/ng-package.json +28 -0
  21. package/package.json +54 -0
  22. package/src/lib/modules/alert/alert.models.ts +6 -0
  23. package/src/lib/modules/alert/alert.module.ts +16 -0
  24. package/src/lib/modules/alert/alert.theme.scss +85 -0
  25. package/src/lib/modules/alert/components/alert/alert.component.html +27 -0
  26. package/src/lib/modules/alert/components/alert/alert.component.scss +92 -0
  27. package/src/lib/modules/alert/components/alert/alert.component.ts +81 -0
  28. package/src/lib/modules/button/button.models.ts +13 -0
  29. package/src/lib/modules/button/button.module.ts +16 -0
  30. package/src/lib/modules/button/button.theme.scss +121 -0
  31. package/src/lib/modules/button/components/button/button.component.html +22 -0
  32. package/src/lib/modules/button/components/button/button.component.scss +88 -0
  33. package/src/lib/modules/button/components/button/button.component.ts +67 -0
  34. package/src/lib/modules/button-dropdown/button-dropdown.models.ts +26 -0
  35. package/src/lib/modules/button-dropdown/button-dropdown.module.ts +22 -0
  36. package/src/lib/modules/button-dropdown/button-dropdown.theme.scss +87 -0
  37. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.html +41 -0
  38. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.scss +135 -0
  39. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.ts +160 -0
  40. package/src/lib/modules/configurable-form/component/configurable-form.component.html +294 -0
  41. package/src/lib/modules/configurable-form/component/configurable-form.component.scss +503 -0
  42. package/src/lib/modules/configurable-form/component/configurable-form.component.ts +628 -0
  43. package/src/lib/modules/configurable-form/configurable-form.examples.ts +154 -0
  44. package/src/lib/modules/configurable-form/configurable-form.model.ts +131 -0
  45. package/src/lib/modules/configurable-form/configurable-form.module.ts +19 -0
  46. package/src/lib/modules/configurable-form/configurable-form.theme.scss +78 -0
  47. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.html +77 -0
  48. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.scss +395 -0
  49. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.ts +266 -0
  50. package/src/lib/modules/confirmation-modal/confirmation-modal.models.ts +71 -0
  51. package/src/lib/modules/confirmation-modal/confirmation-modal.module.ts +20 -0
  52. package/src/lib/modules/confirmation-modal/confirmation-modal.theme.scss +87 -0
  53. package/src/lib/modules/filter/components/filter/filter.component.html +131 -0
  54. package/src/lib/modules/filter/components/filter/filter.component.scss +245 -0
  55. package/src/lib/modules/filter/components/filter/filter.component.ts +216 -0
  56. package/src/lib/modules/filter/filter.models.ts +88 -0
  57. package/src/lib/modules/filter/filter.module.ts +24 -0
  58. package/src/lib/modules/filter/filter.theme.scss +92 -0
  59. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.html +112 -0
  60. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.scss +186 -0
  61. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.ts +163 -0
  62. package/src/lib/modules/filter-sidebar/filter-sidebar.models.ts +95 -0
  63. package/src/lib/modules/filter-sidebar/filter-sidebar.module.ts +24 -0
  64. package/src/lib/modules/filter-sidebar/filter-sidebar.theme.scss +38 -0
  65. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.html +73 -0
  66. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.scss +321 -0
  67. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.ts +361 -0
  68. package/src/lib/modules/filter-table-selector/filter-table-selector.models.ts +91 -0
  69. package/src/lib/modules/filter-table-selector/filter-table-selector.module.ts +22 -0
  70. package/src/lib/modules/filter-table-selector/filter-table-selector.theme.scss +36 -0
  71. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.html +63 -0
  72. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.scss +496 -0
  73. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.ts +445 -0
  74. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.html +75 -0
  75. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.scss +210 -0
  76. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.ts +55 -0
  77. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.html +25 -0
  78. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.scss +82 -0
  79. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.ts +95 -0
  80. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.html +20 -0
  81. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.scss +37 -0
  82. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.ts +94 -0
  83. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.html +46 -0
  84. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.scss +102 -0
  85. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.ts +50 -0
  86. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.html +35 -0
  87. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.scss +67 -0
  88. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.ts +34 -0
  89. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.html +68 -0
  90. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.scss +113 -0
  91. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.ts +74 -0
  92. package/src/lib/modules/form-builder/configs/field-type-schema.map.ts +533 -0
  93. package/src/lib/modules/form-builder/form-builder.module.ts +36 -0
  94. package/src/lib/modules/form-builder/form-builder.theme.scss +212 -0
  95. package/src/lib/modules/form-builder/index.ts +9 -0
  96. package/src/lib/modules/form-builder/models/builder.models.ts +7 -0
  97. package/src/lib/modules/form-builder/models/field-configurator.models.ts +38 -0
  98. package/src/lib/modules/form-builder/models/field-selection.models.ts +51 -0
  99. package/src/lib/modules/form-builder/services/field-configurator.service.ts +258 -0
  100. package/src/lib/modules/form-builder/services/field-selection.service.ts +300 -0
  101. package/src/lib/modules/form-builder/services/form-schema-tree.service.ts +652 -0
  102. package/src/lib/modules/form-builder/tokens/builder.tokens.ts +10 -0
  103. package/src/lib/modules/form-builder/utils/constants.ts +43 -0
  104. package/src/lib/modules/form-components/components/checkbox/_theme.scss +63 -0
  105. package/src/lib/modules/form-components/components/checkbox/checkbox.component.html +29 -0
  106. package/src/lib/modules/form-components/components/checkbox/checkbox.component.scss +111 -0
  107. package/src/lib/modules/form-components/components/checkbox/checkbox.component.ts +207 -0
  108. package/src/lib/modules/form-components/components/checkbox/checkbox.models.ts +35 -0
  109. package/src/lib/modules/form-components/components/datepicker/_theme.scss +82 -0
  110. package/src/lib/modules/form-components/components/datepicker/datepicker.component.html +42 -0
  111. package/src/lib/modules/form-components/components/datepicker/datepicker.component.scss +115 -0
  112. package/src/lib/modules/form-components/components/datepicker/datepicker.component.ts +267 -0
  113. package/src/lib/modules/form-components/components/datepicker/datepicker.models.ts +45 -0
  114. package/src/lib/modules/form-components/components/dropdown/_theme.scss +91 -0
  115. package/src/lib/modules/form-components/components/dropdown/dropdown.component.html +74 -0
  116. package/src/lib/modules/form-components/components/dropdown/dropdown.component.scss +252 -0
  117. package/src/lib/modules/form-components/components/dropdown/dropdown.component.ts +377 -0
  118. package/src/lib/modules/form-components/components/dropdown/dropdown.models.ts +53 -0
  119. package/src/lib/modules/form-components/components/input/_theme.scss +77 -0
  120. package/src/lib/modules/form-components/components/input/input.component.html +51 -0
  121. package/src/lib/modules/form-components/components/input/input.component.scss +128 -0
  122. package/src/lib/modules/form-components/components/input/input.component.ts +250 -0
  123. package/src/lib/modules/form-components/components/input/input.models.ts +55 -0
  124. package/src/lib/modules/form-components/components/radio/_theme.scss +61 -0
  125. package/src/lib/modules/form-components/components/radio/radio.component.html +22 -0
  126. package/src/lib/modules/form-components/components/radio/radio.component.scss +107 -0
  127. package/src/lib/modules/form-components/components/radio/radio.component.ts +181 -0
  128. package/src/lib/modules/form-components/components/radio/radio.models.ts +39 -0
  129. package/src/lib/modules/form-components/components/search/_theme.scss +73 -0
  130. package/src/lib/modules/form-components/components/search/search.component.html +15 -0
  131. package/src/lib/modules/form-components/components/search/search.component.scss +87 -0
  132. package/src/lib/modules/form-components/components/search/search.component.ts +213 -0
  133. package/src/lib/modules/form-components/components/search/search.models.ts +40 -0
  134. package/src/lib/modules/form-components/components/toggle/_theme.scss +45 -0
  135. package/src/lib/modules/form-components/components/toggle/toggle.component.html +15 -0
  136. package/src/lib/modules/form-components/components/toggle/toggle.component.scss +81 -0
  137. package/src/lib/modules/form-components/components/toggle/toggle.component.ts +166 -0
  138. package/src/lib/modules/form-components/components/toggle/toggle.models.ts +27 -0
  139. package/src/lib/modules/form-components/directives/click-outside.directive.ts +22 -0
  140. package/src/lib/modules/form-components/form-components.module.ts +41 -0
  141. package/src/lib/modules/form-components/form-components.theme.scss +25 -0
  142. package/src/lib/modules/material/material.module.ts +94 -0
  143. package/src/lib/modules/nav/components/nav/nav.component.html +34 -0
  144. package/src/lib/modules/nav/components/nav/nav.component.scss +171 -0
  145. package/src/lib/modules/nav/components/nav/nav.component.ts +82 -0
  146. package/src/lib/modules/nav/nav.models.ts +31 -0
  147. package/src/lib/modules/nav/nav.module.ts +17 -0
  148. package/src/lib/modules/nav/nav.theme.scss +86 -0
  149. package/src/lib/modules/pagination/components/pagination/pagination.component.html +52 -0
  150. package/src/lib/modules/pagination/components/pagination/pagination.component.scss +155 -0
  151. package/src/lib/modules/pagination/components/pagination/pagination.component.ts +109 -0
  152. package/src/lib/modules/pagination/pagination.module.ts +17 -0
  153. package/src/lib/modules/pagination/pagination.theme.scss +66 -0
  154. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.html +56 -0
  155. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +342 -0
  156. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.ts +135 -0
  157. package/src/lib/modules/side-nav/side-nav.models.ts +38 -0
  158. package/src/lib/modules/side-nav/side-nav.module.ts +16 -0
  159. package/src/lib/modules/side-nav/side-nav.theme.scss +111 -0
  160. package/src/lib/modules/smart-form/components/form-field/form-field.component.html +1109 -0
  161. package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +1860 -0
  162. package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +2232 -0
  163. package/src/lib/modules/smart-form/components/form-section/form-section.component.html +64 -0
  164. package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +209 -0
  165. package/src/lib/modules/smart-form/components/form-section/form-section.component.ts +119 -0
  166. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.html +253 -0
  167. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +689 -0
  168. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.ts +1087 -0
  169. package/src/lib/modules/smart-form/index.ts +10 -0
  170. package/src/lib/modules/smart-form/models/form-schema.model.ts +700 -0
  171. package/src/lib/modules/smart-form/models/hierarchy-config.model.ts +21 -0
  172. package/src/lib/modules/smart-form/services/expression.service.ts +75 -0
  173. package/src/lib/modules/smart-form/services/smart-form-controller.service.ts +65 -0
  174. package/src/lib/modules/smart-form/smart-form.examples.ts +1324 -0
  175. package/src/lib/modules/smart-form/smart-form.module.ts +36 -0
  176. package/src/lib/modules/smart-form/smart-form.theme.scss +890 -0
  177. package/src/lib/modules/smart-form/utils/translation.utils.ts +82 -0
  178. package/src/lib/modules/smart-form/utils/trusted-url.pipe.ts +25 -0
  179. package/src/lib/modules/smart-form/utils/validation.utils.ts +98 -0
  180. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.html +283 -0
  181. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.scss +685 -0
  182. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.ts +1118 -0
  183. package/src/lib/modules/smart-table/models/table-config.model.ts +202 -0
  184. package/src/lib/modules/smart-table/smart-table.module.ts +30 -0
  185. package/src/lib/modules/smart-table/smart-table.theme.scss +335 -0
  186. package/src/lib/modules/smart-table/utils/safe-html.pipe.ts +22 -0
  187. package/src/lib/modules/smart-table/utils/smart-table.utils.ts +18 -0
  188. package/src/lib/modules/snackbar/components/snackbar.component.html +41 -0
  189. package/src/lib/modules/snackbar/components/snackbar.component.scss +99 -0
  190. package/src/lib/modules/snackbar/components/snackbar.component.ts +18 -0
  191. package/src/lib/modules/snackbar/models/snackbar.models.ts +10 -0
  192. package/src/lib/modules/snackbar/services/snackbar.service.ts +40 -0
  193. package/src/lib/modules/snackbar/snackbar.module.ts +11 -0
  194. package/src/lib/modules/snackbar/snackbar.theme.scss +93 -0
  195. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.html +47 -0
  196. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.scss +199 -0
  197. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.ts +126 -0
  198. package/src/lib/modules/summary-card/summary-card.module.ts +18 -0
  199. package/src/lib/modules/summary-card/summary-card.theme.scss +176 -0
  200. package/src/lib/shared-ui.module.ts +44 -0
  201. package/src/lib/styles/global.scss +152 -0
  202. package/src/lib/styles/utilities.scss +250 -0
  203. package/src/lib/utils/constants.ts +11 -0
  204. package/src/lib/utils/storage.utils.ts +37 -0
  205. package/src/lib/utils/string.utils.ts +23 -0
  206. package/src/lib/utils/translation.utils.ts +87 -0
  207. package/src/public-api.ts +104 -0
  208. package/tsconfig.lib.json +15 -0
@@ -0,0 +1,1087 @@
1
+ import { ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, OnChanges, SimpleChanges, Output } from '@angular/core';
2
+ import { FormArray, FormBuilder, FormGroup } from '@angular/forms';
3
+ import { HttpClient, HttpHeaders } from '@angular/common/http';
4
+ import { Router } from '@angular/router';
5
+ import { ActionBarConfig, ActionButtonConfig, ActionConfig, FieldConfig, FormSchema } from '../../models/form-schema.model';
6
+ import { HierarchyCfg } from '../../models/hierarchy-config.model';
7
+ import { SmartFormController } from '../../services/smart-form-controller.service';
8
+ import { ExpressionService } from '../../services/expression.service';
9
+ import { SmartFormTranslationUtils } from '../../utils/translation.utils';
10
+ import { SnackbarService } from '../../../snackbar/services/snackbar.service';
11
+ import { Subject, takeUntil, forkJoin, Observable, of } from 'rxjs';
12
+ import { catchError, map } from 'rxjs/operators';
13
+
14
+ @Component({
15
+ selector: 'lib-smart-form',
16
+ templateUrl: './smart-form.component.html',
17
+ styleUrls: ['./smart-form.component.scss'],
18
+ providers: [SmartFormController],
19
+ standalone: false
20
+ })
21
+ export class SmartFormComponent implements OnInit, OnChanges, OnDestroy {
22
+ private destroy$ = new Subject<void>();
23
+ @Input() formJson!: string;
24
+ @Input() initialValues?: { [key: string]: any };
25
+ @Input() enableDraftAutoSave: boolean = false;
26
+ /** Flat i18n labels map passed by the consuming app.
27
+ * After JSON parse the schema is walked and every string value that
28
+ * matches a key in this map is replaced with the translated value.
29
+ * Mirrors the pattern used by ConfigurableFormComponent + translateConfig.
30
+ */
31
+ @Input() labels: any = {};
32
+ @Input() mode: 'CREATE' | 'EDIT' = 'CREATE';
33
+ /** When true, all form fields are disabled and the action bar is hidden (preview/read-only mode). */
34
+ @Input() readOnly: boolean = false;
35
+
36
+ @Output() submit = new EventEmitter<{ [key: string]: any }>();
37
+ @Output() draftSave = new EventEmitter<string>();
38
+ /**
39
+ * Emitted when a button with a custom `type` (not 'cancel', 'draft', or
40
+ * 'submit') is clicked. Payload contains the button `id` and the current
41
+ * form data snapshot.
42
+ */
43
+ @Output() actionClick = new EventEmitter<{ id: string; formData: { [key: string]: any } }>();
44
+ @Output() valueChange = new EventEmitter<{ [key: string]: any }>();
45
+ @Output() fileAdded = new EventEmitter<any>();
46
+ @Output() fileUploadFinished = new EventEmitter<any>();
47
+ @Output() fileRemoved = new EventEmitter<any>();
48
+ /** Emitted when a suffixActionIcon is clicked. Payload: { fieldName, actionId } */
49
+ @Output() suffixActionClick = new EventEmitter<{ fieldName: string; actionId: string }>();
50
+ /** Emitted whenever the active section step changes. Carries current state so the
51
+ * host can show/hide Previous/Next/Submit buttons in its own footer. */
52
+ @Output() stepChange = new EventEmitter<{
53
+ currentStep: number;
54
+ totalSteps: number;
55
+ isFirst: boolean;
56
+ isLast: boolean;
57
+ stepLabel: string;
58
+ }>();
59
+
60
+ formSchema!: FormSchema;
61
+ formGroup!: FormGroup;
62
+ fieldList: FieldConfig[] = [];
63
+ isStepper: boolean = false;
64
+ currentStep: number = 0;
65
+ isLoading: boolean = false;
66
+ isDraftLoading: boolean = false;
67
+
68
+ /** True when sectionStepper mode is active (SECTION form with top-level GROUPs as steps). */
69
+ isSectionStepper: boolean = false;
70
+ /** Index of the currently visible section step. */
71
+ currentSectionStep: number = 0;
72
+ /** Flat list of top-level GROUP FieldConfigs that become the stepper steps. */
73
+ sectionSteps: FieldConfig[] = [];
74
+ /** Validation state per section step — drives badge colour/icon. */
75
+ stepValidationStates: ('untouched' | 'valid' | 'warning')[] = [];
76
+ /** Flat field-name lists per step used for targeted validation. */
77
+ private stepFieldNames: string[][] = [];
78
+
79
+ /** Controls skeleton visibility. Stays false until schema is parsed AND
80
+ * any EDIT-mode data fetch completes, but always shows for at least
81
+ * SKELETON_MIN_MS so the animation is visible even on fast loads. */
82
+ isFormReady = false;
83
+ private readonly SKELETON_MIN_MS = 350;
84
+ private _skeletonStart = 0;
85
+
86
+ constructor(
87
+ private fb: FormBuilder,
88
+ public controller: SmartFormController,
89
+ private expressionService: ExpressionService,
90
+ private http: HttpClient,
91
+ private snackbarService: SnackbarService,
92
+ private router: Router,
93
+ private cdr: ChangeDetectorRef
94
+ ) { }
95
+
96
+ ngOnInit(): void {
97
+ this._skeletonStart = Date.now();
98
+ this._startForm();
99
+ this.controller.fileAdded$.pipe(takeUntil(this.destroy$)).subscribe(file => this.fileAdded.emit(file));
100
+ this.controller.fileUploadFinished$.pipe(takeUntil(this.destroy$)).subscribe(file => this.fileUploadFinished.emit(file));
101
+ this.controller.fileRemoved$.pipe(takeUntil(this.destroy$)).subscribe(file => this.fileRemoved.emit(file));
102
+ this.controller.suffixActionClick$.pipe(takeUntil(this.destroy$)).subscribe(data => this.suffixActionClick.emit(data));
103
+ }
104
+
105
+ loadEditData(): void {
106
+ const config = this.formSchema.editConfig!;
107
+ this.isLoading = true;
108
+ const headers = this.getHeaders();
109
+ this.http.get(config.loadApiUrl, { headers }).subscribe({
110
+ next: (response: any) => {
111
+ this.initialValues = response;
112
+ this.controller.initialize(this.initialValues || {});
113
+ this.isLoading = false;
114
+ this._markReady();
115
+ },
116
+ error: (err) => {
117
+ this.showAlert('error', config.errorMessage || 'Failed to load form data', config.snackbarConfig);
118
+ console.error('Load data error:', err);
119
+ this.isLoading = false;
120
+ this._markReady();
121
+ }
122
+ });
123
+ }
124
+
125
+ /** Flips isFormReady=true after the skeleton has been visible for at least SKELETON_MIN_MS. */
126
+ private _markReady(): void {
127
+ const elapsed = Date.now() - this._skeletonStart;
128
+ const remaining = Math.max(0, this.SKELETON_MIN_MS - elapsed);
129
+ setTimeout(() => {
130
+ this.isFormReady = true;
131
+ // markForCheck propagates dirty up through any OnPush ancestor components
132
+ // (e.g. configurator-config-panel) so the skeleton-to-form transition
133
+ // renders in the same zone tick rather than waiting for a future user event.
134
+ this.cdr.markForCheck();
135
+ }, remaining);
136
+ }
137
+
138
+ ngOnChanges(changes: SimpleChanges): void {
139
+ if (changes['formJson'] && !changes['formJson'].isFirstChange()) {
140
+ this._startForm();
141
+ }
142
+ if (changes['labels'] && !changes['labels'].isFirstChange() && this.formSchema) {
143
+ this._startForm();
144
+ }
145
+ }
146
+
147
+ ngOnDestroy(): void {
148
+ this.controller.destroy();
149
+ this.destroy$.next();
150
+ this.destroy$.complete();
151
+ }
152
+
153
+ /** Public backward-compatible entry point — delegates to _startForm. */
154
+ parseFormJson(): void {
155
+ this._startForm();
156
+ }
157
+
158
+ private _startForm(): void {
159
+ if (!this.formJson) return;
160
+ let schema: any;
161
+ try {
162
+ schema = JSON.parse(this.formJson);
163
+ } catch (e) {
164
+ console.error('SmartForm: invalid formJson', e);
165
+ return;
166
+ }
167
+ if (this._hasHierarchyDynamic(schema)) {
168
+ this._expandHierarchyNodes(schema)
169
+ .pipe(takeUntil(this.destroy$))
170
+ .subscribe({
171
+ next: expanded => this._applySchema(expanded),
172
+ error: () => this._applySchema(schema),
173
+ });
174
+ } else {
175
+ this._applySchema(schema);
176
+ }
177
+ }
178
+
179
+ private _applySchema(schema: any): void {
180
+ this.formSchema = schema as FormSchema;
181
+ this.controller.token = this.formSchema.token;
182
+ this.controller.tokenHeader = this.formSchema.tokenHeader;
183
+ if (this.labels && Object.keys(this.labels).length) {
184
+ SmartFormTranslationUtils.translateSchema(this.formSchema, this.labels);
185
+ }
186
+ this.controller.labels = this.labels;
187
+ this.controller.actionLabels = this.formSchema.labels;
188
+ this.isStepper = this.formSchema.formType === 'STEPPER';
189
+ this.fieldList = this.isStepper
190
+ ? (this.formSchema.stepperConfig?.children || []).filter(step => step.isEnabled !== false && step.sectionConfig?.isEnabled !== false)
191
+ : (this.formSchema.sectionConfig?.children || []).filter(field => field.isEnabled !== false);
192
+ const topLevelGroups = this.fieldList.filter(f => f.type === 'GROUP' && f.sectionConfig);
193
+ this.isSectionStepper = !this.isStepper &&
194
+ (this.formSchema.sectionStepper === true ||
195
+ (this.formSchema.sectionStepper !== false && topLevelGroups.length === this.fieldList.length && topLevelGroups.length > 1));
196
+ if (this.isSectionStepper) {
197
+ this.sectionSteps = topLevelGroups;
198
+ this.currentSectionStep = 0;
199
+ this.stepValidationStates = this.sectionSteps.map(() => 'untouched' as const);
200
+ this.stepFieldNames = this.sectionSteps.map(s => this._collectFieldNames(s.sectionConfig?.children || []));
201
+ setTimeout(() => this._emitStepChange());
202
+ }
203
+ // Pre-seed array values (allowMulti GROUP initial data) into the controller
204
+ // BEFORE initializeForm() runs. form-field.initGroupField() calls
205
+ // controller.getFieldValue(groupKey) during ngOnInit to determine how many
206
+ // rows to create — it must find the array already there or it falls back to
207
+ // a single empty row and loses all initial values.
208
+ if (this.initialValues) {
209
+ Object.entries(this.initialValues).forEach(([key, value]) => {
210
+ if (Array.isArray(value)) {
211
+ this.controller.updateField(key, value);
212
+ }
213
+ });
214
+ }
215
+ this.initializeForm();
216
+ if (this.mode === 'EDIT' && this.formSchema?.editConfig?.loadApiUrl) {
217
+ this.loadEditData();
218
+ } else if (this.initialValues) {
219
+ this.controller.initialize(this.initialValues);
220
+ this._markReady();
221
+ } else {
222
+ this._markReady();
223
+ }
224
+ }
225
+
226
+
227
+ initializeForm(): void {
228
+ this.formGroup = this.fb.group({});
229
+ this.collectFields(this.fieldList);
230
+
231
+ // In read-only mode, disable all controls so the form acts as a preview
232
+ if (this.readOnly) {
233
+ this.formGroup.disable();
234
+ }
235
+
236
+ // Emit value changes to parent (skipped in read-only mode)
237
+ if (!this.readOnly) {
238
+ this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
239
+ this.valueChange.emit(this.collectFormData());
240
+ });
241
+ }
242
+ }
243
+
244
+ collectFields(fields: FieldConfig[]): void {
245
+ fields.forEach(field => {
246
+ if (field.isEnabled === false || field.sectionConfig?.isEnabled === false) return;
247
+
248
+ // Flat leaf fields: seed controller with default values
249
+ if (field.name && field.type !== 'GROUP' && field.type !== 'ROW') {
250
+ // Check if initialValues already has a value for this field
251
+ const existingValue = this.initialValues?.[field.name];
252
+ const value = existingValue !== undefined
253
+ ? existingValue
254
+ : (field.defaultValue !== undefined ? field.defaultValue : null);
255
+ this.controller.updateField(field.name, value);
256
+ }
257
+ // Recurse into ROW children
258
+ if (field.type === 'ROW' && field.children?.length) {
259
+ this.collectFields(field.children);
260
+ }
261
+ // Recurse into GROUP children
262
+ if (field.type === 'GROUP' && field.sectionConfig?.children?.length) {
263
+ this.collectFields(field.sectionConfig.children);
264
+ }
265
+ });
266
+ }
267
+
268
+ // ───────────────────────────────────────────────────────────────────────────
269
+ // Submit
270
+ // ───────────────────────────────────────────────────────────────────────────
271
+
272
+ handleSubmit(): void {
273
+ if (this.isStepper && this.currentStep < this.fieldList.length - 1) {
274
+ if (this.validate()) this.nextStep();
275
+ return;
276
+ }
277
+ if (!this.validate()) return;
278
+
279
+ this.submitToApi(this.collectFormData(), 'submit');
280
+ }
281
+
282
+ // ───────────────────────────────────────────────────────────────────────────
283
+ // Cancel
284
+ // ───────────────────────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Universal action handler for any button click.
288
+ * One handler decides how to process based on action.kind.
289
+ */
290
+ handleButtonClick(btn: ActionButtonConfig): void {
291
+ const action = btn.action;
292
+ if (!action) return;
293
+
294
+ switch (action.kind) {
295
+ case 'submit':
296
+ this.handleSubmit();
297
+ break;
298
+ case 'draft':
299
+ this.submitToApi(this.collectFormData(), 'draft', btn);
300
+ break;
301
+ case 'navigate':
302
+ this.navigateTo(action.redirectUrl);
303
+ break;
304
+ case 'api':
305
+ this.fireActionApiCall(action);
306
+ break;
307
+ case 'emit':
308
+ this.actionClick.emit({ id: btn.id, formData: this.collectFormData() });
309
+ break;
310
+ case 'next':
311
+ if (this.validate()) this.nextStep();
312
+ break;
313
+ case 'prev':
314
+ this.previousStep();
315
+ break;
316
+ }
317
+ }
318
+
319
+ // ── Action-Redirects & API ──────────────────────────────────────────────────
320
+
321
+ private fireActionApiCall(action: ActionConfig): void {
322
+ if (!action.apiUrl) return;
323
+ const headers = this.getHeaders();
324
+ const method = action.method || 'POST';
325
+ const body = action.extraPayload || {};
326
+
327
+ this.isLoading = true;
328
+ this.http.request(method, action.apiUrl, { body, headers }).subscribe({
329
+ next: (response) => {
330
+ this.isLoading = false;
331
+ const msg = action.successMessage || 'Action successful';
332
+ this.showAlert('success', msg, action.snackbarConfig);
333
+ if (action.redirectUrl) this.navigateTo(action.redirectUrl);
334
+ },
335
+ error: (err) => {
336
+ this.isLoading = false;
337
+ const msg = action.errorMessage || 'Action failed';
338
+ this.showAlert('error', msg, action.snackbarConfig);
339
+ console.error('API Action error:', err);
340
+ }
341
+ });
342
+ }
343
+
344
+ /**
345
+ * Constructs nested payload by checking field properties on form controls.
346
+ */
347
+ collectFormData(): { [key: string]: any } {
348
+ const rawValue = this.extractGroupValue(this.formGroup);
349
+ let payload = this.buildNestedPayload(rawValue, this.fieldList);
350
+
351
+ // Merge extra fields based on the mode
352
+ const config = this.mode === 'EDIT' ? this.formSchema.editConfig : this.formSchema.submitConfig;
353
+ if (config?.extraPayload) {
354
+ payload = this.deepMerge(payload, config.extraPayload);
355
+ }
356
+
357
+ return payload;
358
+ }
359
+
360
+ /**
361
+ * Deep merges the source object (e.g. extraPayload) into the target object (e.g. form payload).
362
+ */
363
+ private deepMerge(target: any, source: any): any {
364
+ if (typeof target !== 'object' || target === null) {
365
+ return source;
366
+ }
367
+ if (typeof source !== 'object' || source === null) {
368
+ return source;
369
+ }
370
+
371
+ if (Array.isArray(target) && Array.isArray(source)) {
372
+ // For arrays, we merge objects at the same index
373
+ const maxLength = Math.max(target.length, source.length);
374
+ const mergedArray = [];
375
+ for (let i = 0; i < maxLength; i++) {
376
+ if (i < target.length && i < source.length) {
377
+ mergedArray.push(this.deepMerge(target[i], source[i]));
378
+ } else if (i < source.length) {
379
+ mergedArray.push(source[i]);
380
+ } else {
381
+ mergedArray.push(target[i]);
382
+ }
383
+ }
384
+ return mergedArray;
385
+ }
386
+
387
+ const merged = { ...target };
388
+ Object.keys(source).forEach(key => {
389
+ if (source[key] instanceof Object && key in target) {
390
+ merged[key] = this.deepMerge(target[key], source[key]);
391
+ } else {
392
+ merged[key] = source[key];
393
+ }
394
+ });
395
+ return merged;
396
+ }
397
+
398
+ private buildNestedPayload(rawValue: any, fields: FieldConfig[]): { [key: string]: any } {
399
+ if (rawValue === null || typeof rawValue !== 'object') return rawValue;
400
+
401
+ const payload: { [key: string]: any } = {};
402
+ const processedKeys = new Set<string>();
403
+
404
+ const processFields = (fieldList: FieldConfig[]) => {
405
+ fieldList.forEach(field => {
406
+ if (field.isEnabled === false || field.sectionConfig?.isEnabled === false) return;
407
+
408
+ if (field.type === 'ROW' && field.children) {
409
+ processFields(field.children);
410
+ } else if (field.type === 'GROUP' && field.sectionConfig?.children) {
411
+ // FormFieldComponent generates unnamed groups using their label inside the root FormGroup.
412
+ const generatedKey = field.sectionConfig.label
413
+ ? field.sectionConfig.label.replace(/(?:^\w|[A-Z]|\b\w)/g, (w, i) => i === 0 ? w.toLowerCase() : w.toUpperCase()).replace(/\s+/g, '')
414
+ : '';
415
+ const groupKey = field.sectionConfig.name || field.name || generatedKey || '__group__';
416
+
417
+ processedKeys.add(groupKey);
418
+
419
+ const groupRawValue = rawValue[groupKey];
420
+ if (groupRawValue !== undefined) {
421
+ // Identify if it's purely a visual section vs an explicit structural data block
422
+ const isStructural = field.sectionConfig.name || field.name || field.sectionConfig.allowMulti;
423
+
424
+ if (!isStructural) {
425
+ // Visual section: Flatten its contents directly onto the target payload layer
426
+ Object.assign(payload, this.buildNestedPayload(groupRawValue, field.sectionConfig.children));
427
+ } else {
428
+ // Structural block: process nested mappings / array instances
429
+ const nestedData = (field.sectionConfig.allowMulti && Array.isArray(groupRawValue))
430
+ ? groupRawValue.map(item => this.buildNestedPayload(item, field.sectionConfig!.children!))
431
+ : this.buildNestedPayload(groupRawValue, field.sectionConfig.children);
432
+
433
+ if (field.payloadPath) {
434
+ this.setNestedValue(payload, field.payloadPath, nestedData);
435
+ } else {
436
+ payload[groupKey] = nestedData;
437
+ }
438
+ }
439
+ }
440
+ } else if (field.name) {
441
+ processedKeys.add(field.name);
442
+ if (rawValue[field.name] !== undefined) {
443
+ const val = rawValue[field.name];
444
+ if (field.payloadPath) {
445
+ this.setNestedValue(payload, field.payloadPath, val);
446
+ } else {
447
+ payload[field.name] = val;
448
+ }
449
+ }
450
+ }
451
+ });
452
+ };
453
+
454
+ processFields(fields);
455
+
456
+ // Preserve any hidden tracking keys (e.g. id, uuid, isEdit) that were present in the initial data
457
+ Object.keys(rawValue).forEach(key => {
458
+ if (!processedKeys.has(key)) {
459
+ payload[key] = rawValue[key];
460
+ }
461
+ });
462
+
463
+ return payload;
464
+ }
465
+
466
+ private setNestedValue(obj: any, path: string, value: any): void {
467
+ // Regex matches the property name and optionally an array index e.g. "name[0]" -> regex yields ["name[0]", "name", "0"]
468
+ const arrayRegex = /^([a-zA-Z0-9_]+)\[(\d+)\]$/;
469
+
470
+ const parts = path.split('.');
471
+ let current = obj;
472
+
473
+ for (let i = 0; i < parts.length - 1; i++) {
474
+ const part = parts[i];
475
+ const match = part.match(arrayRegex);
476
+
477
+ if (match) {
478
+ const prop = match[1];
479
+ const index = parseInt(match[2], 10);
480
+ if (!current[prop]) current[prop] = [];
481
+ if (!current[prop][index]) current[prop][index] = {};
482
+ current = current[prop][index];
483
+ } else {
484
+ if (!current[part]) current[part] = {};
485
+ current = current[part];
486
+ }
487
+ }
488
+
489
+ const lastPart = parts[parts.length - 1];
490
+ const matchLast = lastPart.match(arrayRegex);
491
+ if (matchLast) {
492
+ const prop = matchLast[1];
493
+ const index = parseInt(matchLast[2], 10);
494
+ if (!current[prop]) current[prop] = [];
495
+ current[prop][index] = value;
496
+ } else {
497
+ current[lastPart] = value;
498
+ }
499
+ }
500
+
501
+ private extractGroupValue(group: FormGroup): { [key: string]: any } {
502
+ const result: { [key: string]: any } = {};
503
+ Object.keys(group.controls).forEach(key => {
504
+ const ctrl = group.controls[key];
505
+ if (ctrl instanceof FormArray) {
506
+ // Repeater → array of objects
507
+ result[key] = (ctrl.controls as FormGroup[]).map(fg => this.extractGroupValue(fg));
508
+ } else if (ctrl instanceof FormGroup) {
509
+ // Nested single group → nested object
510
+ result[key] = this.extractGroupValue(ctrl);
511
+ } else {
512
+ result[key] = ctrl?.value ?? null;
513
+ }
514
+ });
515
+ return result;
516
+ }
517
+
518
+ validate(): boolean {
519
+ if (this.formGroup.invalid) {
520
+ this.formGroup.markAllAsTouched();
521
+ this.scrollToFirstInvalidControl();
522
+ return false;
523
+ }
524
+ return true;
525
+ }
526
+
527
+ scrollToFirstInvalidControl(): void {
528
+ setTimeout(() => {
529
+ // .is-invalid is set by form-field on actual control elements (not on <form>).
530
+ // The fallback targets native inputs/selects directly so we never accidentally
531
+ // scroll to the <form> element, which also receives .ng-invalid.ng-touched.
532
+ const firstInvalidControl =
533
+ document.querySelector('.is-invalid') ||
534
+ document.querySelector(
535
+ 'input.ng-invalid.ng-touched, select.ng-invalid.ng-touched, textarea.ng-invalid.ng-touched'
536
+ );
537
+ if (firstInvalidControl) {
538
+ firstInvalidControl.scrollIntoView({ behavior: 'smooth', block: 'center' });
539
+ }
540
+ }, 100);
541
+ }
542
+
543
+ submitToApi(formData: any, actionType: 'submit' | 'draft' = 'submit', btn?: ActionButtonConfig): void {
544
+ const isEdit = this.mode === 'EDIT';
545
+ const configLine: any = isEdit ? this.formSchema.editConfig : this.formSchema.submitConfig;
546
+ const action = btn?.action;
547
+
548
+ // Build the final payload, merging draft extraPayload if applicable
549
+ let finalPayload = formData;
550
+ if (actionType === 'draft' && action?.extraPayload) {
551
+ finalPayload = this.deepMerge(finalPayload, action.extraPayload);
552
+ }
553
+
554
+ // Fallback for emitted outputs if no API URL is defined centrally
555
+ if (!configLine || (!configLine.apiUrl && !configLine.submitApiUrl)) {
556
+ if (actionType === 'draft') {
557
+ this.draftSave.emit(JSON.stringify(finalPayload));
558
+ if (action?.redirectUrl) this.navigateTo(action.redirectUrl);
559
+ } else {
560
+ this.submit.emit(finalPayload);
561
+ }
562
+ return;
563
+ }
564
+
565
+ const apiUrl = isEdit ? configLine.submitApiUrl : configLine.apiUrl;
566
+ const method = isEdit ? (configLine.submitMethod || 'PATCH') : (configLine.method || 'POST');
567
+ const headers = this.getHeaders();
568
+
569
+ // Messages and redirect URLs (Prefer button action overrides if present)
570
+ const successMsg = actionType === 'draft'
571
+ ? (action?.successMessage || 'Draft saved successfully')
572
+ : (action?.successMessage || configLine.successMessage || 'Form submitted successfully');
573
+
574
+ const errorMsg = actionType === 'draft'
575
+ ? (action?.errorMessage || 'Failed to save draft')
576
+ : (action?.errorMessage || configLine.errorMessage || 'Failed to submit form');
577
+
578
+ const redirectUrl = actionType === 'draft'
579
+ ? (action?.redirectUrl || configLine.redirectUrl)
580
+ : (action?.redirectUrl || configLine.redirectUrl);
581
+
582
+ if (actionType === 'draft') {
583
+ this.isDraftLoading = true;
584
+ } else {
585
+ this.isLoading = true;
586
+ }
587
+
588
+ this.http.request(method, apiUrl, { body: finalPayload, headers }).subscribe({
589
+ next: (response) => {
590
+ this.showAlert('success', successMsg, action?.snackbarConfig || configLine.snackbarConfig);
591
+
592
+ if (actionType === 'draft') {
593
+ this.draftSave.emit(JSON.stringify(response));
594
+ this.isDraftLoading = false;
595
+ } else {
596
+ this.submit.emit(response);
597
+ this.isLoading = false;
598
+ }
599
+
600
+ if (redirectUrl) {
601
+ setTimeout(() => this.navigateTo(redirectUrl), 1500);
602
+ }
603
+ },
604
+ error: (err) => {
605
+ this.showAlert('error', errorMsg, action?.snackbarConfig || configLine.snackbarConfig);
606
+ console.error(actionType === 'draft' ? 'Draft save error:' : 'Submit error:', err);
607
+ this.isLoading = false;
608
+ this.isDraftLoading = false;
609
+ }
610
+ });
611
+ }
612
+
613
+ showAlert(type: 'success' | 'error' | 'warning' | 'info', message: string, customConfig?: any): void {
614
+ this.snackbarService.show({
615
+ variant: type,
616
+ message: message,
617
+ showCloseButton: true,
618
+ ...customConfig
619
+ });
620
+ }
621
+
622
+ /** Builds HttpHeaders from the token stored in the controller (sourced from configJSON). */
623
+ getHeaders(): HttpHeaders {
624
+ let headers = new HttpHeaders();
625
+ if (this.controller.token) {
626
+ const headerName = this.controller.tokenHeader || 'Authorization';
627
+ headers = headers.set(headerName, this.controller.token);
628
+ }
629
+ return headers;
630
+ }
631
+
632
+ // ───────────────────────────────────────────────────────────────────────────
633
+ // Stepper
634
+ // ───────────────────────────────────────────────────────────────────────────
635
+
636
+ nextStep(): void {
637
+ if (this.currentStep < this.fieldList.length - 1) this.currentStep++;
638
+ }
639
+
640
+ previousStep(): void {
641
+ if (this.currentStep > 0) this.currentStep--;
642
+ }
643
+
644
+ get canGoNext(): boolean { return this.currentStep < this.fieldList.length - 1; }
645
+ get canGoPrevious(): boolean { return this.currentStep > 0; }
646
+ get currentStepConfig(): FieldConfig | undefined {
647
+ return this.isStepper ? this.fieldList[this.currentStep] : undefined;
648
+ }
649
+
650
+ // ── Section Stepper (public API called by host via ViewChild) ────────────────
651
+
652
+ /** Advance to the next section step. Called by the host footer "Next" button.
653
+ * Validates the current step first — marks it valid (green) or warning (orange). */
654
+ navigateToNext(): void {
655
+ if (!this.isSectionStepper) return;
656
+ if (this.currentSectionStep < this.sectionSteps.length - 1) {
657
+ this._validateStep(this.currentSectionStep);
658
+ this.currentSectionStep++;
659
+ this._emitStepChange();
660
+ }
661
+ }
662
+
663
+ /** Go back to the previous section step. Called by the host footer "Previous" button. */
664
+ navigateToPrevious(): void {
665
+ if (!this.isSectionStepper) return;
666
+ if (this.currentSectionStep > 0) {
667
+ this.currentSectionStep--;
668
+ this._emitStepChange();
669
+ }
670
+ }
671
+
672
+ /** Jump directly to a specific section step by index.
673
+ * Validates the step being left so the badge state updates correctly. */
674
+ goToSectionStep(index: number): void {
675
+ if (!this.isSectionStepper) return;
676
+ if (index < 0 || index >= this.sectionSteps.length || index === this.currentSectionStep) return;
677
+ this._validateStep(this.currentSectionStep);
678
+ this.currentSectionStep = index;
679
+ this._emitStepChange();
680
+ }
681
+
682
+ get isSectionStepFirst(): boolean { return this.currentSectionStep === 0; }
683
+ get isSectionStepLast(): boolean { return this.currentSectionStep === this.sectionSteps.length - 1; }
684
+
685
+ /** Returns the SectionConfig for a given step — passed to lib-form-section.
686
+ * The outer label is intentionally omitted because the stepper nav already
687
+ * displays it; showing it again inside the content would be redundant. */
688
+ getSectionStepConfig(step: FieldConfig): any {
689
+ const { label, ...rest } = step.sectionConfig! as any;
690
+ return rest;
691
+ }
692
+
693
+ private _emitStepChange(): void {
694
+ const step = this.sectionSteps[this.currentSectionStep];
695
+ this.stepChange.emit({
696
+ currentStep: this.currentSectionStep,
697
+ totalSteps: this.sectionSteps.length,
698
+ isFirst: this.isSectionStepFirst,
699
+ isLast: this.isSectionStepLast,
700
+ stepLabel: step?.sectionConfig?.label || `Step ${this.currentSectionStep + 1}`
701
+ });
702
+ }
703
+
704
+ /** Marks all controls in the given step as touched and records valid/warning state. */
705
+ private _validateStep(stepIndex: number): void {
706
+ const names = this.stepFieldNames[stepIndex] || [];
707
+ let hasInvalid = false;
708
+ names.forEach(name => {
709
+ const ctrl = this.formGroup.get(name);
710
+ if (!ctrl) return;
711
+ if (ctrl instanceof FormArray) {
712
+ ctrl.controls.forEach(c => c.markAllAsTouched());
713
+ if (ctrl.invalid) hasInvalid = true;
714
+ } else if (ctrl instanceof FormGroup) {
715
+ ctrl.markAllAsTouched();
716
+ if (ctrl.invalid) hasInvalid = true;
717
+ } else {
718
+ ctrl.markAsTouched();
719
+ if (ctrl.invalid) hasInvalid = true;
720
+ }
721
+ });
722
+ this.stepValidationStates[stepIndex] = hasInvalid ? 'warning' : 'valid';
723
+ }
724
+
725
+ /** Recursively collects all leaf field names from a set of FieldConfigs. */
726
+ private _collectFieldNames(fields: FieldConfig[]): string[] {
727
+ const names: string[] = [];
728
+ fields.forEach(f => {
729
+ if (f.isEnabled === false) return;
730
+ if (f.name && f.type !== 'GROUP' && f.type !== 'ROW') {
731
+ names.push(f.name);
732
+ }
733
+ if (f.type === 'ROW' && f.children?.length) {
734
+ names.push(...this._collectFieldNames(f.children));
735
+ }
736
+ if (f.type === 'GROUP' && f.sectionConfig?.children?.length) {
737
+ if (f.name || f.sectionConfig.name) {
738
+ names.push((f.name || f.sectionConfig.name)!);
739
+ }
740
+ names.push(...this._collectFieldNames(f.sectionConfig.children));
741
+ }
742
+ });
743
+ return names;
744
+ }
745
+
746
+ // ── Action Labels ──────────────────────────────────────────────────────────
747
+
748
+ get nextLabel(): string {
749
+ const key = this.formSchema?.labels?.nextLabel || 'Next';
750
+ return this.labels[key] || key;
751
+ }
752
+
753
+ get submitLabel(): string {
754
+ const btn = this.getButtonByActionKind('submit');
755
+ if (btn?.label) return this.labels[btn.label] || btn.label;
756
+ const key = this.formSchema?.labels?.submitLabel || 'Submit';
757
+ return this.labels[key] || key;
758
+ }
759
+
760
+ get previousLabel(): string {
761
+ const key = this.formSchema?.labels?.previousLabel || 'Previous';
762
+ return this.labels[key] || key;
763
+ }
764
+
765
+ // ── Action Bar helpers ─────────────────────────────────────────────────────
766
+
767
+ get actionBarConfig(): ActionBarConfig | undefined {
768
+ return this.formSchema?.actionBarConfig;
769
+ }
770
+
771
+ /**
772
+ * Returns buttons for a given alignment, sorted by `order` (stable).
773
+ */
774
+ getButtonsForAlignment(alignment: 'left' | 'right'): ActionButtonConfig[] {
775
+ return (this.actionBarConfig?.buttons || [])
776
+ .filter(b => !b.hidden && (b.alignment ?? 'right') === alignment)
777
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
778
+ }
779
+
780
+ getButtonLabel(btn: ActionButtonConfig): string {
781
+ if (btn.action?.kind === 'submit' && this.isStepper && this.canGoNext) {
782
+ return this.nextLabel;
783
+ }
784
+ const key = btn.label || btn.id;
785
+ return this.labels[key] || key;
786
+ }
787
+
788
+ isButtonDisabled(btn: ActionButtonConfig): boolean {
789
+ if (btn.disabled) return true;
790
+ const kind = btn.action?.kind;
791
+ if (kind === 'submit' || kind === 'draft' || kind === 'api') {
792
+ return this.isLoading || this.isDraftLoading;
793
+ }
794
+ return false;
795
+ }
796
+
797
+ private getButtonByActionKind(kind: string): ActionButtonConfig | undefined {
798
+ return (this.actionBarConfig?.buttons || []).find(b => b.action?.kind === kind);
799
+ }
800
+
801
+ // ── Navigation helper ──────────────────────────────────────────────────────
802
+
803
+ private navigateTo(url?: string): void {
804
+ if (!url) return;
805
+ if (url.startsWith('http://') || url.startsWith('https://')) {
806
+ window.location.href = url;
807
+ } else {
808
+ this.router.navigateByUrl(url);
809
+ }
810
+ }
811
+
812
+ // ── GEOGRAPHY_DYNAMIC / Hierarchy expansion ───────────────────────────────
813
+ // Supports any API-driven hierarchy template. All paths, URLs, field names
814
+ // and dependency params are driven entirely by the geographyConfig JSON node —
815
+ // nothing is hardcoded here. New deployments only need to update the JSON.
816
+
817
+ private _hasHierarchyDynamic(obj: any): boolean {
818
+ if (!obj || typeof obj !== 'object') return false;
819
+ if ((obj as any)['type'] === 'GEOGRAPHY_DYNAMIC') return true;
820
+ return Object.values(obj as object).some((v: any) => this._hasHierarchyDynamic(v));
821
+ }
822
+
823
+ private _expandHierarchyNodes(schema: any): Observable<any> {
824
+ return this._expandNodeRecursive(schema);
825
+ }
826
+
827
+ private _expandNodeRecursive(obj: any): Observable<any> {
828
+ if (!this._hasHierarchyDynamic(obj)) return of(obj);
829
+
830
+ if (Array.isArray(obj)) {
831
+ const tasks$: Observable<any[]>[] = (obj as any[]).map((item: any) => {
832
+ if (item && (item as any)['type'] === 'GEOGRAPHY_DYNAMIC') {
833
+ return this._expandSingleNode(item);
834
+ }
835
+ return this._expandNodeRecursive(item).pipe(map((v: any) => [v]));
836
+ });
837
+ return (tasks$.length > 0 ? forkJoin(tasks$) : of([] as any[][])).pipe(
838
+ map((arrays: any[][]) => ([] as any[]).concat(...arrays))
839
+ );
840
+ }
841
+
842
+ if (typeof obj === 'object' && obj !== null) {
843
+ const keysWithDynamic = Object.keys(obj as object).filter(k =>
844
+ this._hasHierarchyDynamic((obj as any)[k])
845
+ );
846
+ if (keysWithDynamic.length === 0) return of(obj);
847
+ const updates$ = keysWithDynamic.map(key =>
848
+ this._expandNodeRecursive((obj as any)[key]).pipe(map((v: any) => ({ key, value: v })))
849
+ );
850
+ return forkJoin(updates$).pipe(
851
+ map((updates: { key: string; value: any }[]) => {
852
+ const result: any = { ...(obj as object) };
853
+ updates.forEach(u => { result[u.key] = u.value; });
854
+ return result;
855
+ })
856
+ );
857
+ }
858
+
859
+ return of(obj);
860
+ }
861
+
862
+ private _expandSingleNode(node: any): Observable<any[]> {
863
+ const rawCfg = (node as any)['geographyConfig'] || (node as any)['hierarchyConfig'];
864
+ if (!rawCfg) return of([]);
865
+ const cfg = this._normalizeHierarchyCfg(rawCfg);
866
+ if (!cfg.templateApiUrl) return of([]);
867
+ return this._fetchHierarchyTemplate(cfg).pipe(
868
+ map((template: any) => template ? this._buildHierarchyFields(cfg, template) : []),
869
+ catchError(() => of([]))
870
+ );
871
+ }
872
+
873
+ private _normalizeHierarchyCfg(raw: any): HierarchyCfg {
874
+ // New nested format: templateApi / dataApi / structure / field sub-objects
875
+ if (raw['templateApi'] && typeof raw['templateApi'] === 'object') {
876
+ const ta = raw['templateApi'];
877
+ const da = raw['dataApi'] || {};
878
+ const st = raw['structure'] || {};
879
+ const fi = raw['field'] || {};
880
+ return {
881
+ templateApiUrl: ta['apiUrl'] || ta['url'] || '',
882
+ templateListPath: ta['listPath'] ?? null,
883
+ templateSelect: ta['select'] || 'default',
884
+ templateSelectField: ta['selectField'] || 'isDefault',
885
+ templateSelectCode: ta['selectCode'],
886
+ rootCodePath: st['rootCodePath'] || 'rootClassCode',
887
+ templateCodePath: st['templateCodePath'] || 'templateCode',
888
+ hierarchyListPath: st['hierarchyListPath'] || 'mdmclasshierarchytemplatestructList',
889
+ parentField: st['parentField'] || 'parentCode',
890
+ childField: st['childField'] || 'childCode',
891
+ dataApiUrl: da['apiUrl'] || da['url'] || '',
892
+ dataPath: da['dataPath'] || 'elements',
893
+ labelPath: da['labelPath'] || 'name[0].text',
894
+ valuePath: da['valuePath'] || 'code',
895
+ colSpan: fi['colSpan'] ?? 12,
896
+ payloadPrefix: fi['payloadPathPrefix'] || '',
897
+ dependencyParam: fi['dependency']?.['paramName'] || 'parentDataCode',
898
+ nodes: fi['nodes'] || {},
899
+ branchSubType: fi['branchSubType'] || 'SINGLE',
900
+ };
901
+ }
902
+ // Legacy flat format (backward compatibility with existing geographyConfig JSON)
903
+ const reqCodes: string[] = raw['requiredClassCodes'] || [];
904
+ const payloadMap: Record<string, string> = raw['classCodePayloadFieldMap'] || {};
905
+ const nodes: Record<string, any> = {};
906
+ [...new Set([...reqCodes, ...Object.keys(payloadMap)])].forEach(code => {
907
+ nodes[code] = { payloadField: payloadMap[code], required: reqCodes.includes(code) };
908
+ });
909
+ return {
910
+ templateApiUrl: raw['templateApiUrl'] || '',
911
+ templateListPath: null,
912
+ templateSelect: raw['useDefault'] !== false ? 'default' : 'first',
913
+ templateSelectField: 'isDefault',
914
+ rootCodePath: 'rootClassCode',
915
+ templateCodePath: 'templateCode',
916
+ hierarchyListPath: 'mdmclasshierarchytemplatestructList',
917
+ parentField: 'parentCode',
918
+ childField: 'childCode',
919
+ dataApiUrl: raw['dataApiUrl'] || '',
920
+ dataPath: raw['dataPath'] || 'elements',
921
+ labelPath: raw['labelPath'] || 'name[0].text',
922
+ valuePath: raw['valuePath'] || 'code',
923
+ colSpan: raw['colSpan'] ?? 12,
924
+ payloadPrefix: raw['payloadPathPrefix'] || '',
925
+ dependencyParam: 'parentDataCode',
926
+ nodes,
927
+ branchSubType: 'SINGLE',
928
+ };
929
+ }
930
+
931
+ private _fetchHierarchyTemplate(cfg: HierarchyCfg): Observable<any> {
932
+ const headers = this.getHeaders();
933
+ return this.http.get<any>(cfg.templateApiUrl, { headers }).pipe(
934
+ map((response: any) => {
935
+ let list: any[];
936
+ if (cfg.templateListPath) {
937
+ list = this._getPathValue(response, cfg.templateListPath) || [];
938
+ } else if (Array.isArray(response)) {
939
+ list = response;
940
+ } else {
941
+ list = [response];
942
+ }
943
+ if (!Array.isArray(list) || list.length === 0) return null;
944
+ if (cfg.templateSelect === 'first') return list[0];
945
+ if (cfg.templateSelect === 'byCode') {
946
+ return list.find((t: any) => this._getPathValue(t, cfg.templateCodePath) === cfg.templateSelectCode) || list[0];
947
+ }
948
+ return list.find((t: any) => !!t[cfg.templateSelectField]) || list[0];
949
+ }),
950
+ catchError(() => of(null))
951
+ );
952
+ }
953
+
954
+ private _buildHierarchyFields(cfg: HierarchyCfg, template: any): any[] {
955
+ const rootCode: string = this._getPathValue(template, cfg.rootCodePath) || '';
956
+ const templateCode: string = this._getPathValue(template, cfg.templateCodePath) || '';
957
+ const hierList: any[] = this._getPathValue(template, cfg.hierarchyListPath) || [];
958
+
959
+ const childrenMap = new Map<string, string[]>();
960
+ hierList.forEach((s: any) => {
961
+ const parent = s[cfg.parentField];
962
+ const child = s[cfg.childField];
963
+ if (parent != null && child != null) {
964
+ if (!childrenMap.has(parent)) childrenMap.set(parent, []);
965
+ childrenMap.get(parent)!.push(child);
966
+ }
967
+ });
968
+
969
+ const fields: any[] = [];
970
+
971
+ const buildField = (code: string, parentFieldName: string | null, visibilityExpression?: string): void => {
972
+ const nodeOvr: any = cfg.nodes[code] || {};
973
+
974
+ if (nodeOvr['hidden']) {
975
+ (childrenMap.get(code) || []).forEach(c => buildField(c, parentFieldName, visibilityExpression));
976
+ return;
977
+ }
978
+
979
+ const fieldName: string = nodeOvr['name'] || this._codeToFieldName(code);
980
+ const label: string = nodeOvr['label'] || this._codeToLabel(code);
981
+ const payloadField: string = nodeOvr['payloadField'] || fieldName;
982
+ const colSpan: number = nodeOvr['colSpan'] ?? cfg.colSpan;
983
+ const payloadPath: string = cfg.payloadPrefix ? `${cfg.payloadPrefix}.${payloadField}` : payloadField;
984
+
985
+ const apiUrl: string = (cfg.dataApiUrl || '')
986
+ .replace(/\{classCode\}/g, encodeURIComponent(code))
987
+ .replace(/\{templateCode\}/g, encodeURIComponent(templateCode));
988
+
989
+ const optionConfig: any = {
990
+ apiUrl,
991
+ dataPath: cfg.dataPath,
992
+ labelPath: cfg.labelPath,
993
+ valuePath: cfg.valuePath,
994
+ };
995
+ if (parentFieldName && !nodeOvr['noDependency']) {
996
+ optionConfig['dependencies'] = { [cfg.dependencyParam]: parentFieldName };
997
+ }
998
+
999
+ const fieldDef: any = {
1000
+ name: fieldName,
1001
+ label,
1002
+ payloadPath,
1003
+ type: 'DROPDOWN',
1004
+ subType: 'SINGLE',
1005
+ colSpan,
1006
+ isEnabled: true,
1007
+ optionConfig,
1008
+ };
1009
+ if (visibilityExpression) fieldDef['visibilityExpression'] = visibilityExpression;
1010
+ if (nodeOvr['required']) {
1011
+ fieldDef['required'] = true;
1012
+ fieldDef['errorMessage'] = nodeOvr['errorMessage'] || `Please select ${label.toLowerCase()}`;
1013
+ }
1014
+ if (nodeOvr['placeholder']) fieldDef['placeholder'] = nodeOvr['placeholder'];
1015
+
1016
+ fields.push(fieldDef);
1017
+
1018
+ const children = childrenMap.get(code) || [];
1019
+ if (children.length > 1) {
1020
+ // Branch point: dynamically build Area Type selector from children
1021
+ const branchOptions = children.map(c => {
1022
+ const childOvr: any = cfg.nodes[c] || {};
1023
+ const childFieldName = childOvr['name'] || this._codeToFieldName(c);
1024
+ const childLabel = childOvr['label'] || this._codeToLabel(c);
1025
+ return { label: childLabel, code: childFieldName };
1026
+ });
1027
+
1028
+ const areaTypeField: any = {
1029
+ name: 'areaType',
1030
+ label: 'Area Type',
1031
+ type: 'DROPDOWN',
1032
+ subType: cfg.branchSubType,
1033
+ colSpan,
1034
+ isEnabled: true,
1035
+ optionConfig: { optionList: branchOptions },
1036
+ };
1037
+ if (visibilityExpression) areaTypeField['visibilityExpression'] = visibilityExpression;
1038
+ fields.push(areaTypeField);
1039
+
1040
+ // Skip structural branch-type nodes (e.g. RURAL, URABN) — they carry no MDM data.
1041
+ // Walk their descendants with `fieldName` (the linear parent before the fork, e.g. State)
1042
+ // as the dependency, so the first real data level loads correctly.
1043
+ children.forEach(c => {
1044
+ const childOvr: any = cfg.nodes[c] || {};
1045
+ const childFieldName = childOvr['name'] || this._codeToFieldName(c);
1046
+ const branchVisExpr = `areaType === '${childFieldName}'`;
1047
+ const branchDescendants = childrenMap.get(c) || [];
1048
+ if (branchDescendants.length === 0) {
1049
+ // Unusual: branch-type node has no descendants — fall back to treating it as a data field
1050
+ buildField(c, fieldName, branchVisExpr);
1051
+ } else {
1052
+ // Skip the branch-type node; walk its children with the pre-fork parent as dependency
1053
+ branchDescendants.forEach(desc => buildField(desc, fieldName, branchVisExpr));
1054
+ }
1055
+ });
1056
+ } else {
1057
+ children.forEach(c => buildField(c, fieldName, visibilityExpression));
1058
+ }
1059
+ };
1060
+
1061
+ buildField(rootCode, null);
1062
+ return fields;
1063
+ }
1064
+
1065
+ private _getPathValue(obj: any, path: string): any {
1066
+ if (!path || obj == null) return obj;
1067
+ return path.split('.').reduce((curr: any, key: string) => {
1068
+ if (curr == null) return undefined;
1069
+ const m = key.match(/^(\w+)\[(\d+)\]$/);
1070
+ return m ? curr[m[1]]?.[Number(m[2])] : curr[key];
1071
+ }, obj);
1072
+ }
1073
+
1074
+ private _codeToFieldName(classCode: string): string {
1075
+ const suffix = (classCode.split('.').pop() || classCode).replace(/^REGION_/, '');
1076
+ const words = suffix.split('_');
1077
+ if (words.length === 0) return classCode.toLowerCase();
1078
+ return words[0].toLowerCase() +
1079
+ words.slice(1).map((w: string) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join('');
1080
+ }
1081
+
1082
+ private _codeToLabel(classCode: string): string {
1083
+ const suffix = (classCode.split('.').pop() || classCode).replace(/^REGION_/, '');
1084
+ return suffix.split('_').map((w: string) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ');
1085
+ }
1086
+
1087
+ }