commons-shared-web-ui 0.0.60 → 0.0.62

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 (223) hide show
  1. package/.claude/settings.local.json +10 -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 +599 -0
  11. package/documentation/form-components.md +384 -0
  12. package/documentation/form-field-configuration.md +250 -0
  13. package/documentation/nav.md +427 -0
  14. package/documentation/pagination.md +181 -0
  15. package/documentation/side-nav-documentation.md +169 -0
  16. package/documentation/smart-form.md +2819 -0
  17. package/documentation/smart-table.md +1297 -0
  18. package/documentation/snackbar.md +118 -0
  19. package/documentation/style-externalization.md +88 -0
  20. package/documentation/summary-card.md +279 -0
  21. package/documentation/table-column-configuration.md +221 -0
  22. package/documentation/time-picker.md +274 -0
  23. package/ng-package.json +30 -0
  24. package/package.json +53 -34
  25. package/src/lib/modules/alert/alert.models.ts +6 -0
  26. package/src/lib/modules/alert/alert.module.ts +16 -0
  27. package/src/lib/modules/alert/components/alert/alert.component.html +27 -0
  28. package/src/lib/modules/alert/components/alert/alert.component.scss +92 -0
  29. package/src/lib/modules/alert/components/alert/alert.component.ts +81 -0
  30. package/src/lib/modules/button/button.models.ts +13 -0
  31. package/src/lib/modules/button/button.module.ts +16 -0
  32. package/src/lib/modules/button/components/button/button.component.html +22 -0
  33. package/src/lib/modules/button/components/button/button.component.scss +92 -0
  34. package/src/lib/modules/button/components/button/button.component.ts +67 -0
  35. package/src/lib/modules/button-dropdown/button-dropdown.models.ts +26 -0
  36. package/src/lib/modules/button-dropdown/button-dropdown.module.ts +22 -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/confirmation-modal/components/confirmation-modal/confirmation-modal.component.html +77 -0
  47. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.scss +395 -0
  48. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.ts +266 -0
  49. package/src/lib/modules/confirmation-modal/confirmation-modal.models.ts +71 -0
  50. package/src/lib/modules/confirmation-modal/confirmation-modal.module.ts +20 -0
  51. package/src/lib/modules/filter/components/filter/filter.component.html +131 -0
  52. package/src/lib/modules/filter/components/filter/filter.component.scss +245 -0
  53. package/src/lib/modules/filter/components/filter/filter.component.ts +216 -0
  54. package/src/lib/modules/filter/filter.models.ts +88 -0
  55. package/src/lib/modules/filter/filter.module.ts +24 -0
  56. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.html +112 -0
  57. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.scss +186 -0
  58. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.ts +163 -0
  59. package/src/lib/modules/filter-sidebar/filter-sidebar.models.ts +95 -0
  60. package/src/lib/modules/filter-sidebar/filter-sidebar.module.ts +24 -0
  61. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.html +73 -0
  62. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.scss +321 -0
  63. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.ts +361 -0
  64. package/src/lib/modules/filter-table-selector/filter-table-selector.models.ts +91 -0
  65. package/src/lib/modules/filter-table-selector/filter-table-selector.module.ts +22 -0
  66. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.html +63 -0
  67. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.scss +496 -0
  68. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.ts +445 -0
  69. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.html +75 -0
  70. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.scss +210 -0
  71. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.ts +55 -0
  72. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.html +25 -0
  73. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.scss +82 -0
  74. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.ts +95 -0
  75. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.html +20 -0
  76. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.scss +37 -0
  77. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.ts +94 -0
  78. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.html +46 -0
  79. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.scss +102 -0
  80. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.ts +50 -0
  81. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.html +35 -0
  82. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.scss +67 -0
  83. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.ts +34 -0
  84. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.html +68 -0
  85. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.scss +113 -0
  86. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.ts +74 -0
  87. package/src/lib/modules/form-builder/configs/field-type-schema.map.ts +533 -0
  88. package/src/lib/modules/form-builder/form-builder.module.ts +36 -0
  89. package/src/lib/modules/form-builder/index.ts +9 -0
  90. package/src/lib/modules/form-builder/models/builder.models.ts +7 -0
  91. package/src/lib/modules/form-builder/models/field-configurator.models.ts +38 -0
  92. package/src/lib/modules/form-builder/models/field-selection.models.ts +51 -0
  93. package/src/lib/modules/form-builder/services/field-configurator.service.ts +258 -0
  94. package/src/lib/modules/form-builder/services/field-selection.service.ts +299 -0
  95. package/src/lib/modules/form-builder/services/form-schema-tree.service.ts +670 -0
  96. package/src/lib/modules/form-builder/tokens/builder.tokens.ts +10 -0
  97. package/src/lib/modules/form-builder/utils/constants.ts +43 -0
  98. package/src/lib/modules/form-components/components/checkbox/checkbox.component.html +29 -0
  99. package/src/lib/modules/form-components/components/checkbox/checkbox.component.scss +111 -0
  100. package/src/lib/modules/form-components/components/checkbox/checkbox.component.ts +207 -0
  101. package/src/lib/modules/form-components/components/checkbox/checkbox.models.ts +35 -0
  102. package/src/lib/modules/form-components/components/datepicker/datepicker.component.html +42 -0
  103. package/src/lib/modules/form-components/components/datepicker/datepicker.component.scss +115 -0
  104. package/src/lib/modules/form-components/components/datepicker/datepicker.component.ts +267 -0
  105. package/src/lib/modules/form-components/components/datepicker/datepicker.models.ts +45 -0
  106. package/src/lib/modules/form-components/components/dropdown/dropdown.component.html +74 -0
  107. package/src/lib/modules/form-components/components/dropdown/dropdown.component.scss +252 -0
  108. package/src/lib/modules/form-components/components/dropdown/dropdown.component.ts +377 -0
  109. package/src/lib/modules/form-components/components/dropdown/dropdown.models.ts +53 -0
  110. package/src/lib/modules/form-components/components/input/input.component.html +51 -0
  111. package/src/lib/modules/form-components/components/input/input.component.scss +128 -0
  112. package/src/lib/modules/form-components/components/input/input.component.ts +250 -0
  113. package/src/lib/modules/form-components/components/input/input.models.ts +55 -0
  114. package/src/lib/modules/form-components/components/radio/radio.component.html +22 -0
  115. package/src/lib/modules/form-components/components/radio/radio.component.scss +107 -0
  116. package/src/lib/modules/form-components/components/radio/radio.component.ts +181 -0
  117. package/src/lib/modules/form-components/components/radio/radio.models.ts +39 -0
  118. package/src/lib/modules/form-components/components/search/search.component.html +15 -0
  119. package/src/lib/modules/form-components/components/search/search.component.scss +87 -0
  120. package/src/lib/modules/form-components/components/search/search.component.ts +213 -0
  121. package/src/lib/modules/form-components/components/search/search.models.ts +40 -0
  122. package/src/lib/modules/form-components/components/toggle/toggle.component.html +15 -0
  123. package/src/lib/modules/form-components/components/toggle/toggle.component.scss +81 -0
  124. package/src/lib/modules/form-components/components/toggle/toggle.component.ts +166 -0
  125. package/src/lib/modules/form-components/components/toggle/toggle.models.ts +27 -0
  126. package/src/lib/modules/form-components/directives/click-outside.directive.ts +22 -0
  127. package/src/lib/modules/form-components/form-components.module.ts +42 -0
  128. package/src/lib/modules/form-field-configuration/components/_ffc-controls.scss +89 -0
  129. package/src/lib/modules/form-field-configuration/components/config-field-node/config-field-node.component.html +68 -0
  130. package/src/lib/modules/form-field-configuration/components/config-field-node/config-field-node.component.scss +73 -0
  131. package/src/lib/modules/form-field-configuration/components/config-field-node/config-field-node.component.ts +34 -0
  132. package/src/lib/modules/form-field-configuration/components/config-section-node/config-section-node.component.html +78 -0
  133. package/src/lib/modules/form-field-configuration/components/config-section-node/config-section-node.component.scss +83 -0
  134. package/src/lib/modules/form-field-configuration/components/config-section-node/config-section-node.component.ts +94 -0
  135. package/src/lib/modules/form-field-configuration/components/form-field-configuration/form-field-configuration.component.html +38 -0
  136. package/src/lib/modules/form-field-configuration/components/form-field-configuration/form-field-configuration.component.scss +42 -0
  137. package/src/lib/modules/form-field-configuration/components/form-field-configuration/form-field-configuration.component.ts +92 -0
  138. package/src/lib/modules/form-field-configuration/form-field-configuration.module.ts +23 -0
  139. package/src/lib/modules/form-field-configuration/index.ts +7 -0
  140. package/src/lib/modules/form-field-configuration/models/field-configuration.models.ts +62 -0
  141. package/src/lib/modules/form-field-configuration/services/config-schema-tree.service.ts +159 -0
  142. package/src/lib/modules/form-field-configuration/services/field-configuration.service.ts +228 -0
  143. package/src/lib/modules/material/material.module.ts +94 -0
  144. package/src/lib/modules/nav/components/nav/nav.component.html +34 -0
  145. package/src/lib/modules/nav/components/nav/nav.component.scss +171 -0
  146. package/src/lib/modules/nav/components/nav/nav.component.ts +82 -0
  147. package/src/lib/modules/nav/nav.models.ts +31 -0
  148. package/src/lib/modules/nav/nav.module.ts +17 -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/side-nav/components/side-nav/side-nav.component.html +56 -0
  154. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +342 -0
  155. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.ts +135 -0
  156. package/src/lib/modules/side-nav/side-nav.models.ts +38 -0
  157. package/src/lib/modules/side-nav/side-nav.module.ts +16 -0
  158. package/src/lib/modules/smart-form/components/form-field/form-field.component.html +1379 -0
  159. package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +2255 -0
  160. package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +3174 -0
  161. package/src/lib/modules/smart-form/components/form-section/form-section.component.html +67 -0
  162. package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +209 -0
  163. package/src/lib/modules/smart-form/components/form-section/form-section.component.ts +142 -0
  164. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.html +253 -0
  165. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +689 -0
  166. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.ts +1134 -0
  167. package/src/lib/modules/smart-form/index.ts +10 -0
  168. package/src/lib/modules/smart-form/models/form-schema.model.ts +803 -0
  169. package/src/lib/modules/smart-form/models/hierarchy-config.model.ts +22 -0
  170. package/src/lib/modules/smart-form/services/expression.service.ts +75 -0
  171. package/src/lib/modules/smart-form/services/smart-form-controller.service.ts +67 -0
  172. package/src/lib/modules/smart-form/smart-form.examples.ts +1324 -0
  173. package/src/lib/modules/smart-form/smart-form.module.ts +40 -0
  174. package/src/lib/modules/smart-form/smart-form.theme.scss +1 -0
  175. package/src/lib/modules/smart-form/utils/translation.utils.ts +82 -0
  176. package/src/lib/modules/smart-form/utils/trusted-url.pipe.ts +25 -0
  177. package/src/lib/modules/smart-form/utils/validation.utils.ts +98 -0
  178. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.html +283 -0
  179. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.scss +685 -0
  180. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.ts +1219 -0
  181. package/src/lib/modules/smart-table/models/table-config.model.ts +247 -0
  182. package/src/lib/modules/smart-table/smart-table.module.ts +30 -0
  183. package/src/lib/modules/smart-table/utils/safe-html.pipe.ts +28 -0
  184. package/src/lib/modules/smart-table/utils/smart-table.utils.ts +18 -0
  185. package/src/lib/modules/snackbar/components/snackbar.component.html +41 -0
  186. package/src/lib/modules/snackbar/components/snackbar.component.scss +99 -0
  187. package/src/lib/modules/snackbar/components/snackbar.component.ts +18 -0
  188. package/src/lib/modules/snackbar/models/snackbar.models.ts +10 -0
  189. package/src/lib/modules/snackbar/services/snackbar.service.ts +40 -0
  190. package/src/lib/modules/snackbar/snackbar.module.ts +11 -0
  191. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.html +47 -0
  192. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.scss +199 -0
  193. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.ts +126 -0
  194. package/src/lib/modules/summary-card/summary-card.module.ts +18 -0
  195. package/src/lib/modules/table-column-configuration/components/_tcc-controls.scss +53 -0
  196. package/src/lib/modules/table-column-configuration/components/config-column-node/config-column-node.component.html +47 -0
  197. package/src/lib/modules/table-column-configuration/components/config-column-node/config-column-node.component.scss +50 -0
  198. package/src/lib/modules/table-column-configuration/components/config-column-node/config-column-node.component.ts +33 -0
  199. package/src/lib/modules/table-column-configuration/components/table-column-configuration/table-column-configuration.component.html +30 -0
  200. package/src/lib/modules/table-column-configuration/components/table-column-configuration/table-column-configuration.component.scss +36 -0
  201. package/src/lib/modules/table-column-configuration/components/table-column-configuration/table-column-configuration.component.ts +78 -0
  202. package/src/lib/modules/table-column-configuration/index.ts +5 -0
  203. package/src/lib/modules/table-column-configuration/models/table-column-configuration.models.ts +20 -0
  204. package/src/lib/modules/table-column-configuration/services/table-column-configuration.service.ts +77 -0
  205. package/src/lib/modules/table-column-configuration/table-column-configuration.module.ts +21 -0
  206. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.html +37 -0
  207. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.scss +102 -0
  208. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.ts +178 -0
  209. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.html +78 -0
  210. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.scss +226 -0
  211. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.ts +595 -0
  212. package/src/lib/modules/time-picker/models/time-picker.models.ts +49 -0
  213. package/src/lib/modules/time-picker/time-picker.module.ts +23 -0
  214. package/src/lib/shared-ui.module.ts +55 -0
  215. package/src/lib/utils/constants.ts +11 -0
  216. package/src/lib/utils/storage.utils.ts +37 -0
  217. package/src/lib/utils/string.utils.ts +23 -0
  218. package/src/lib/utils/translation.utils.ts +87 -0
  219. package/src/public-api.ts +127 -0
  220. package/tsconfig.lib.json +15 -0
  221. package/fesm2022/commons-shared-web-ui.mjs +0 -16097
  222. package/fesm2022/commons-shared-web-ui.mjs.map +0 -1
  223. package/index.d.ts +0 -5022
@@ -0,0 +1,3174 @@
1
+ import { Component, Input, OnInit, OnDestroy, AfterViewInit, ViewChild, ElementRef, HostListener } from '@angular/core';
2
+ import { ConnectedPosition } from '@angular/cdk/overlay';
3
+ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
4
+ import {
5
+ FormArray,
6
+ FormBuilder,
7
+ FormControl,
8
+ FormGroup,
9
+ ValidatorFn,
10
+ Validators
11
+ } from '@angular/forms';
12
+ import { SectionConfig, FieldConfig, FormLabels, UploadedFile, MediaItem, LocationItem, LocationFieldValue, AttachmentConfig, LibraryUploadConfig, AutocompleteConfig, AutocompleteDisplayField, DropdownSearchConfig, LinkListConfig } from '../../models/form-schema.model';
13
+ import { SmartFormController } from '../../services/smart-form-controller.service';
14
+ import { ExpressionService } from '../../services/expression.service';
15
+ import { ValidationUtils } from '../../utils/validation.utils';
16
+ import { StringUtils } from '../../../../utils/string.utils';
17
+ import { Subject, combineLatest, forkJoin, BehaviorSubject, merge, Subscription } from 'rxjs';
18
+ import { takeUntil, debounceTime, distinctUntilChanged, finalize } from 'rxjs/operators';
19
+ import { ConfirmationModalConfig } from '../../../confirmation-modal/confirmation-modal.models';
20
+ import { TimePickerLabels } from '../../../time-picker/models/time-picker.models';
21
+
22
+ @Component({
23
+ selector: 'lib-form-field',
24
+ templateUrl: './form-field.component.html',
25
+ styleUrls: ['./form-field.component.scss'],
26
+ standalone: false
27
+ })
28
+ export class FormFieldComponent implements OnInit, AfterViewInit, OnDestroy {
29
+ @Input() config!: FieldConfig;
30
+ @Input() controller!: SmartFormController;
31
+ /**
32
+ * The FormGroup that THIS field's control should be registered in.
33
+ * For repeater instances this is the instance's own isolated FormGroup.
34
+ * For flat (non-repeater) fields this is the root formGroup.
35
+ */
36
+ @Input() formGroup!: FormGroup;
37
+
38
+ /**
39
+ * Set to TRUE when this field is part of a repeatable group (config.sectionConfig.allowMulti = true).
40
+ * When true, the field does NOT sync with the global controller to prevent data collision
41
+ * between different instances of the same repeater row.
42
+ */
43
+ @Input() allowMulti: boolean = false;
44
+
45
+ /**
46
+ * The repeater's FormArray, set only when this field instance lives inside an
47
+ * allowMulti section (see FormSectionComponent). Lets a single-select DROPDOWN
48
+ * exclude values already chosen by sibling instances — e.g. picking "English" in
49
+ * Language Details #1 removes it from Language Details #2's options — without any
50
+ * schema-level config (see displayOptionList/displayLocalOptionList below).
51
+ */
52
+ @Input() siblingFormArray: FormArray | null = null;
53
+ /** This instance's own index within siblingFormArray — excluded from the "taken values" set. */
54
+ @Input() siblingIndex: number | null = null;
55
+
56
+ value: any;
57
+ private _isVisible: boolean = true;
58
+ get isVisible(): boolean {
59
+ return this._isVisible;
60
+ }
61
+ set isVisible(value: boolean) {
62
+ this._isVisible = value;
63
+ this.updateParentVisibility();
64
+ }
65
+
66
+ private updateParentVisibility(): void {
67
+ setTimeout(() => {
68
+ const hostEl = this.hostRef?.nativeElement;
69
+ const parentEl = hostEl?.parentElement;
70
+ if (parentEl && (parentEl.classList.contains('sf-col') || parentEl.classList.contains('row-field'))) {
71
+ parentEl.style.display = this._isVisible ? '' : 'none';
72
+ }
73
+ });
74
+ }
75
+ showPassword: boolean = false; // password show/hide toggle
76
+ dynamicMinDate: string | null = null;
77
+ dynamicMinTime: string | null = null;
78
+
79
+ /**
80
+ * Effective minimum date for the datepicker.
81
+ * Priority: dynamic (sibling field) → explicit minDate → today (when allowPast is false).
82
+ * When allowPast is not false, past dates stay allowed unless an explicit min is set.
83
+ */
84
+ get effectiveMinDate(): string | null {
85
+ const explicitMin = this.dynamicMinDate || this.config.dateConfig?.minDate || null;
86
+ if (explicitMin) return explicitMin;
87
+ if (this.config.dateConfig?.allowPast === false) {
88
+ const now = new Date();
89
+ const pad = (n: number) => String(n).padStart(2, '0');
90
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ /**
96
+ * Effective minimum time for the time input.
97
+ * Priority: dynamic (sibling field) → explicit minTime.
98
+ * Mirrors {@link effectiveMinDate} for TIME fields.
99
+ */
100
+ get effectiveMinTime(): string | null {
101
+ return this.dynamicMinTime || this.config.timeConfig?.minTime || null;
102
+ }
103
+
104
+ /** Effective maximum time for the time input (explicit maxTime only). */
105
+ get effectiveMaxTime(): string | null {
106
+ return this.config.timeConfig?.maxTime || null;
107
+ }
108
+
109
+ // ── DROPDOWN state ────────────────────────────────────────────────────────
110
+ isMultiDropdownOpen: boolean = false;
111
+ isSingleDropdownOpen: boolean = false;
112
+
113
+ // ── Dropdown search state ─────────────────────────────────────────────────
114
+ searchText: string = '';
115
+ filteredOptionList: any[] = [];
116
+ dropdownSearch$ = new Subject<string>();
117
+ optionsLoading: boolean = false;
118
+ private optionsSubscription: Subscription | null = null;
119
+ isDragOver: boolean = false; // file upload drag-over state
120
+ fileUploadError: string = ''; // per-field file validation error
121
+ multiSaveError: string = ''; // error for multisave validation
122
+ private destroy$ = new Subject<void>();
123
+
124
+ // ── MEDIA_UPLOAD support ────────────────────────────────────────────────
125
+ @ViewChild('mediaDeviceInput') mediaDeviceInput!: ElementRef<HTMLInputElement>;
126
+ /** Host div for the library picker — always in DOM, moved to body to escape parent transforms */
127
+ @ViewChild('libraryModal') libraryModalRef!: ElementRef<HTMLDivElement>;
128
+ private portalElement: HTMLElement | null = null;
129
+ showMediaMenu: boolean = false; // hover/click dropdown menu
130
+ showYoutubeInput: boolean = false; // inline YouTube URL input panel
131
+ youtubeUrlInput: string = ''; // model for the YT URL field
132
+ youtubeUrlError: string = ''; // validation message for YT url
133
+ mediaCarouselIndex: number = 0; // active slide index for carousel
134
+ showLibraryModal: boolean = false; // library picker modal
135
+ libraryImages: any[] = []; // raw list fetched from library API
136
+ librarySelectedIds: Set<any> = new Set(); // selected library items
137
+ libraryLoading: boolean = false;
138
+ libraryError: string = '';
139
+ mediaUploadError: string = ''; // transient error message for max limits
140
+
141
+ // ── LOCATION field support ────────────────────────────────────────────────
142
+ /** Active tab: any string, e.g. 'VENUE', 'ONLINE', 'TBA'. Defaults to 'VENUE'. */
143
+ locationActiveTab: string = 'VENUE';
144
+ /** Current text in the venue search box */
145
+ locationSearchText: string = '';
146
+ /** Google Places autocomplete suggestions */
147
+ locationSuggestions: any[] = [];
148
+ /** Show the suggestions dropdown */
149
+ locationShowSuggestions: boolean = false;
150
+ /** Cached Google AutocompleteService instance */
151
+ private _googleAcService: any = null;
152
+ /** Whether Google Maps is loaded */
153
+ locationMapLoaded: boolean = false;
154
+ /** Map instance */
155
+ private _googleMap: any = null;
156
+ /** Map markers */
157
+ private _mapMarkers: any[] = [];
158
+
159
+ // ── AUTOCOMPLETE support ────────────────────────────────────────────────
160
+ /** FormControl used ONLY for the autocomplete text-input display value */
161
+ autocompleteInputCtrl = new FormControl('');
162
+ /** Filtered option list shown in the mat-autocomplete panel */
163
+ filteredOptions: { label: string; code: any }[] = [];
164
+ /**
165
+ * Component-local option list for DROPDOWN/RADIO/CHECKBOX/CHIP fields.
166
+ * Using a local copy prevents shared-config mutation when the same field config
167
+ * object is reused across multiple allowMulti repeater instances.
168
+ */
169
+ localOptionList: any[] = [];
170
+ /** Dedicated cache of currently selected option objects */
171
+ selectedItems: any[] = [];
172
+ /** Cache of the latest dependency parameters for server-side autocomplete filtering */
173
+ private _latestDependencyValues: { [key: string]: any } = {};
174
+
175
+ // ── GROUP / allowMulti support ──────────────────────────────────────────
176
+
177
+ /** For GROUP fields with allowMulti = true */
178
+ groupFormArray!: FormArray;
179
+
180
+ /** For GROUP fields with allowMulti = false — single nested FormGroup */
181
+ groupFormGroup!: FormGroup;
182
+
183
+ /**
184
+ * Tracked list of repeater instances.
185
+ * Using a separate array (not FormArray.controls) + trackBy(id) ensures
186
+ * Angular creates FRESH child components for every new row, preventing
187
+ * cached values from bleeding into new instances.
188
+ *
189
+ * Enhanced with isEditing and isSaved flags for the 'multiSave' card UI.
190
+ * Each instance gets its own rowController so cascading dropdowns work
191
+ * independently within each repeater row.
192
+ */
193
+ instanceList: { id: number; fg: FormGroup; initialValue?: any; isEditing?: boolean; isSaved?: boolean; isExpanded?: boolean; rowController: SmartFormController }[] = [];
194
+ private _nextInstanceId = 0;
195
+
196
+ /** Tracks open accordion panels for standard (non-multiSave) GROUP repeaters. */
197
+ expandedGroupInstances = new Set<number>();
198
+
199
+ // ── LINK_LIST support ──────────────────────────────────────────────────
200
+ newLinkInputValue = '';
201
+ editingLinkIndex: number | null = null;
202
+ editingLinkValue = '';
203
+ linkValidationError = '';
204
+
205
+ /**
206
+ * Key used to register the GROUP control on the parent formGroup.
207
+ * Priority: sectionConfig.name > field.name > camelCase(label) > '__group__'
208
+ */
209
+ get groupKey(): string {
210
+ return (
211
+ this.config.sectionConfig?.name ||
212
+ this.config.name ||
213
+ StringUtils.toCamelCase(this.config.sectionConfig?.label) ||
214
+ '__group__'
215
+ );
216
+ }
217
+
218
+
219
+ constructor(
220
+ private fb: FormBuilder,
221
+ private expressionService: ExpressionService,
222
+ private http: HttpClient,
223
+ private hostRef: ElementRef<HTMLElement>
224
+ ) { }
225
+
226
+ /** Portal root, resolved from this component's own node rather than the global document. */
227
+ private get portalRoot(): HTMLElement | null {
228
+ return this.hostRef.nativeElement.ownerDocument?.body ?? null;
229
+ }
230
+
231
+ ngOnInit(): void {
232
+ this.isVisible = this.config.visible !== false && this.config.isEnabled !== false;
233
+
234
+ if (this.isGroup) {
235
+ this.initGroupField();
236
+ return;
237
+ }
238
+
239
+ // Seed localOptionList from any static options already in the config
240
+ // (e.g. optionList defined directly in the JSON like Yes/No dropdowns).
241
+ // filteredOptionList mirrors localOptionList and is narrowed by search.
242
+ // Dynamic options loaded via API will replace this array via loadDropdownOptions.
243
+ this.localOptionList = [...(this.config.optionConfig?.optionList || [])];
244
+ this.filteredOptionList = [...this.localOptionList];
245
+
246
+ this.registerControl();
247
+ this.setupRequiredExpression();
248
+ this.mergeSelectedOptionsIntoList();
249
+ this._setupSiblingExclusion();
250
+ this.setupVisibility();
251
+ this.setupMinDateField();
252
+ this.setupMinTimeField();
253
+ this.setupGeneratedField();
254
+ this.setupFormulaValidation(); // Generic formula-based validation
255
+ this.setupDependencies();
256
+ this.setupMatchValidation(); // cross-field match (e.g. confirmPassword)
257
+
258
+ if (!this.config.optionConfig?.dependencies) {
259
+ this.loadDropdownOptions();
260
+ }
261
+
262
+ // ── Dropdown search stream (shared by single-select and multi-select) ────
263
+ if (this.isDropdown && this.config.optionConfig?.searchConfig?.enabled) {
264
+ this.initDropdownSearch();
265
+ }
266
+
267
+ if (this.isAutocomplete) {
268
+ this.initAutocomplete();
269
+ }
270
+
271
+ if (this.isLocation) {
272
+ this.initLocationField();
273
+ }
274
+ }
275
+
276
+ // ── GROUP initialisation ──────────────────────────────────────────────────
277
+ get addMultiLabel(): string {
278
+ const rawLabel = this.config.sectionConfig?.multiSaveConfig?.addLabel;
279
+ if (rawLabel) {
280
+ return this.controller.labels?.[rawLabel] || rawLabel;
281
+ }
282
+ const sectionLabel = this.config.sectionConfig?.label;
283
+ const translatedSectionLabel = sectionLabel ? (this.controller.labels?.[sectionLabel] || sectionLabel) : '';
284
+ return '+ Add a ' + translatedSectionLabel;
285
+ }
286
+
287
+ /** Getter for Select placeholder label */
288
+ get selectPlaceholderLabel(): string {
289
+ return this.controller.labels?.['SELECT_PLACEHOLDER'] || 'Select';
290
+ }
291
+
292
+ /** Getter for No options available label */
293
+ get noOptionsAvailableLabel(): string {
294
+ return this.controller.labels?.['NO_OPTIONS_AVAILABLE'] || 'No options available';
295
+ }
296
+
297
+ /** Getter for expand less icon name */
298
+ get expandLessLabel(): string {
299
+ return this.controller.labels?.['EXPAND_LESS'] || 'expand_less';
300
+ }
301
+
302
+ /** Getter for expand more icon name */
303
+ get expandMoreLabel(): string {
304
+ return this.controller.labels?.['EXPAND_MORE'] || 'expand_more';
305
+ }
306
+
307
+ /** Getter for dropdown search placeholder label */
308
+ get searchPlaceholderLabel(): string {
309
+ return this.controller.labels?.['SEARCH_PLACEHOLDER'] || 'Search...';
310
+ }
311
+
312
+ /** Getter for Select All label */
313
+ get selectAllLabel(): string {
314
+ return this.controller.labels?.['SELECT_ALL'] || 'Select All';
315
+ }
316
+
317
+ /** Getter for No matching options label */
318
+ get noMatchingOptionsLabel(): string {
319
+ return this.controller.labels?.['NO_MATCHING_OPTIONS'] || 'No options match your search';
320
+ }
321
+
322
+ /** Returns the label of the currently selected option for single-select custom dropdowns */
323
+ get singleSelectedLabel(): string {
324
+ if (!this.config.name) return '';
325
+ const currentValue = this.formGroup?.get(this.config.name)?.value;
326
+ if (currentValue === null || currentValue === undefined) return '';
327
+ const matched = this.localOptionList.find(o => o.code === currentValue);
328
+ return matched?.label || '';
329
+ }
330
+
331
+ /** Whether the dropdown search feature is enabled for this field */
332
+ get isSearchableDropdown(): boolean {
333
+ return !!this.config.optionConfig?.searchConfig?.enabled;
334
+ }
335
+
336
+ private initGroupField(): void {
337
+ if (this.config.sectionConfig?.allowMulti) {
338
+ this.groupFormArray = this.fb.array([]);
339
+ this.formGroup.addControl(this.groupKey, this.groupFormArray);
340
+
341
+ const initialData = this.controller.getFieldValue(this.groupKey);
342
+ if (Array.isArray(initialData) && initialData.length > 0) {
343
+ initialData.forEach((item: any) => {
344
+ this.addGroupInstance(item);
345
+ });
346
+ } else {
347
+ // We always start with at least one instance.
348
+ // If multi-save is active, it starts in editing mode and is NOT
349
+ // added to the main form value until the user actually saves it.
350
+ this.addGroupInstance();
351
+ }
352
+ } else {
353
+ this.groupFormGroup = this.fb.group({});
354
+ this.formGroup.addControl(this.groupKey, this.groupFormGroup);
355
+ }
356
+ // Allow GROUP container nodes to participate in visibility expression evaluation.
357
+ // Without this, a GROUP's visibilityExpression (e.g. hiding an entire section header
358
+ // when none of its child fields are relevant) would never be evaluated because ngOnInit
359
+ // returns early after calling initGroupField(). This call is safe: setupVisibility()
360
+ // is a no-op when config.visibilityExpression is absent (existing groups unaffected).
361
+ this.setupVisibility();
362
+ }
363
+
364
+ /**
365
+ * Sets up cross-field validation based on the `onValidate` formula.
366
+ * Watches all variables mentioned in the formula and updates the field's
367
+ * validity whenever any of them change.
368
+ */
369
+ private setupFormulaValidation(): void {
370
+ if (!this.config.onValidate) return;
371
+
372
+ const expression = this.config.onValidate;
373
+ const variables = this.expressionService.extractVariables(expression);
374
+
375
+ // Subscribe to all fields mentioned in the formula
376
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
377
+
378
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
379
+ const context = this.controller.getAllData();
380
+ const isValid = this.expressionService.evaluateCondition(expression, context);
381
+
382
+ const control = this.formGroup.get(this.config.name!);
383
+ if (control) {
384
+ if (!isValid) {
385
+ control.setErrors({ ...control.errors, formulaError: true });
386
+ } else {
387
+ // Clear only the formulaError
388
+ if (control.hasError('formulaError')) {
389
+ const errors = { ...control.errors };
390
+ delete errors['formulaError'];
391
+ control.setErrors(Object.keys(errors).length ? errors : null);
392
+ }
393
+ }
394
+ }
395
+ });
396
+ }
397
+
398
+ addGroupInstance(initialData?: any): void {
399
+ const fg = this.fb.group({});
400
+ const isMultiSave = !!this.config.sectionConfig?.multiSaveConfig?.active;
401
+
402
+ // If initialData exists, treat the instance as already submitted/saved to form
403
+ const isEditing = initialData ? false : isMultiSave;
404
+ const isSaved = initialData ? true : !isMultiSave;
405
+
406
+ // Per-row controller: inherits token/labels from global but has isolated field state.
407
+ // This allows cascading dropdowns (dependencies) to work within each repeater row
408
+ // without cross-row data collisions.
409
+ const rowController = new SmartFormController();
410
+ rowController.token = this.controller.token;
411
+ rowController.tokenHeader = this.controller.tokenHeader;
412
+ rowController.labels = this.controller.labels;
413
+ rowController.actionLabels = this.controller.actionLabels;
414
+
415
+ // Pre-populate row controller so child fields initialize with the correct values
416
+ if (initialData) {
417
+ Object.entries(initialData).forEach(([key, value]) => {
418
+ rowController.updateField(key, value as any);
419
+ });
420
+ }
421
+
422
+ const instance = {
423
+ id: this._nextInstanceId++,
424
+ fg,
425
+ isEditing,
426
+ isSaved,
427
+ isExpanded: false,
428
+ initialValue: initialData ? { ...initialData } : undefined,
429
+ rowController
430
+ };
431
+
432
+ if (isSaved) {
433
+ this.groupFormArray.push(fg);
434
+ }
435
+
436
+ // Collapse all existing panels; expand only the new one
437
+ if (!isMultiSave) {
438
+ this.expandedGroupInstances.clear();
439
+ this.expandedGroupInstances.add(this.instanceList.length);
440
+ }
441
+
442
+ this.instanceList = [...this.instanceList, instance];
443
+
444
+ if (initialData) {
445
+ setTimeout(() => {
446
+ // Ensure that controls exist for all keys in initialData so we don't drop fields like ID!
447
+ Object.keys(initialData).forEach(key => {
448
+ if (!fg.contains(key)) {
449
+ fg.addControl(key, new FormControl(initialData[key]));
450
+ }
451
+ });
452
+ fg.patchValue(initialData);
453
+ });
454
+ }
455
+ }
456
+
457
+ saveGroupInstance(index: number): void {
458
+ const instance = this.instanceList[index];
459
+ const multiCfg = this.config.sectionConfig?.multiSaveConfig;
460
+
461
+ if (multiCfg?.active) {
462
+ // Reveal validation messages for every control in this entry, then block
463
+ // collapsing to the card view while any required/invalid field remains.
464
+ instance.fg.markAllAsTouched();
465
+ if (instance.fg.invalid) {
466
+ this.multiSaveError = this.controller.labels['ERR_MSG_EMPTY_MULTI'] || 'Please fill the required fields before saving.';
467
+ return;
468
+ }
469
+
470
+ this.multiSaveError = '';
471
+ instance.isSaved = true;
472
+ instance.isEditing = false;
473
+ instance.initialValue = { ...instance.fg.value };
474
+
475
+ // Push to the parent formArray so its submitted as part of the FormGroup value
476
+ if (!this.groupFormArray.controls.includes(instance.fg)) {
477
+ this.groupFormArray.push(instance.fg);
478
+ }
479
+ }
480
+ }
481
+
482
+ /**
483
+ * Re-opens any repeater instance (multiSave card or standard accordion panel)
484
+ * whose nested FormGroup is invalid, so errors hidden by a collapsed card/panel
485
+ * become visible. Called by the root form when a submit attempt is blocked.
486
+ * Returns true if at least one invalid instance was revealed.
487
+ */
488
+ revealInvalidInstances(): boolean {
489
+ if (!this.isGroup || !this.config.sectionConfig?.allowMulti) return false;
490
+
491
+ const isMultiSave = !!this.config.sectionConfig?.multiSaveConfig?.active;
492
+ let foundInvalid = false;
493
+
494
+ this.instanceList.forEach((instance, i) => {
495
+ instance.fg.markAllAsTouched();
496
+ if (instance.fg.invalid) {
497
+ foundInvalid = true;
498
+ if (isMultiSave) {
499
+ instance.isEditing = true;
500
+ } else {
501
+ this.expandedGroupInstances.add(i);
502
+ }
503
+ }
504
+ });
505
+
506
+ return foundInvalid;
507
+ }
508
+
509
+ cancelGroupInstance(index: number): void {
510
+ const instance = this.instanceList[index];
511
+ if (!instance.isSaved) {
512
+ // It was a new, unsaved addition - remove it entirely
513
+ this.removeGroupInstance(index, true);
514
+ } else {
515
+ // Revert to original values
516
+ if (instance.initialValue) {
517
+ instance.fg.patchValue(instance.initialValue, { emitEvent: false });
518
+ }
519
+ instance.isEditing = false;
520
+ }
521
+ }
522
+
523
+ editGroupInstance(index: number): void {
524
+ const instance = this.instanceList[index];
525
+ instance.isEditing = true;
526
+ instance.initialValue = { ...instance.fg.value };
527
+ }
528
+
529
+ toggleExpandGroupInstance(index: number): void {
530
+ const instance = this.instanceList[index];
531
+ instance.isExpanded = !instance.isExpanded;
532
+ }
533
+
534
+ removeGroupInstance(index: number, force: boolean = false): void {
535
+ // If not multiSave, we keep the one-row minimum rule.
536
+ // In multiSave, we can remove it even if it's the last one.
537
+ const isMultiSave = !!this.config.sectionConfig?.multiSaveConfig?.active;
538
+
539
+ if (force || isMultiSave || this.instanceList.length > 1) {
540
+ const instance = this.instanceList[index];
541
+ const arrayIndex = this.groupFormArray.controls.indexOf(instance.fg);
542
+ if (arrayIndex >= 0) {
543
+ this.groupFormArray.removeAt(arrayIndex);
544
+ }
545
+ instance.rowController.destroy();
546
+ this.instanceList = this.instanceList.filter((_, i) => i !== index);
547
+
548
+ // Rebuild accordion expanded set after index shift
549
+ if (!isMultiSave) {
550
+ const updated = new Set<number>();
551
+ this.expandedGroupInstances.forEach(i => {
552
+ if (i < index) updated.add(i);
553
+ else if (i > index) updated.add(i - 1);
554
+ });
555
+ this.expandedGroupInstances = updated;
556
+ }
557
+ }
558
+ }
559
+
560
+ toggleGroupAccordion(index: number): void {
561
+ if (this.expandedGroupInstances.has(index)) {
562
+ this.expandedGroupInstances.delete(index);
563
+ } else {
564
+ this.expandedGroupInstances.add(index);
565
+ }
566
+ }
567
+
568
+ isGroupExpanded(index: number): boolean {
569
+ return this.expandedGroupInstances.has(index);
570
+ }
571
+
572
+ trackByInstanceId(_: number, item: { id: number; fg: FormGroup }): number {
573
+ return item.id;
574
+ }
575
+
576
+ // ── Leaf control ─────────────────────────────────────────────────────────
577
+
578
+ ngAfterViewInit(): void {
579
+ const el = this.libraryModalRef?.nativeElement;
580
+ const root = this.portalRoot;
581
+ if (el && root) {
582
+ this.portalElement = el;
583
+ root.appendChild(el);
584
+ }
585
+ }
586
+
587
+ // ── Suffix Action Icons ──────────────────────────────────────────────────
588
+ /** Handles click on a suffix action icon and emits via the controller */
589
+ onSuffixActionClick(actionId: string): void {
590
+ if (this.config.name) {
591
+ this.controller.suffixActionClick$.next({ fieldName: this.config.name, actionId });
592
+ }
593
+ }
594
+
595
+ ngOnDestroy(): void {
596
+ // Clean up DOM portal — no-op when the node was never appended
597
+ this.portalElement?.remove();
598
+ this.portalElement = null;
599
+
600
+ // Always complete so any subscriptions (e.g. subfields valueChanges) are cleaned up
601
+ this.destroy$.next();
602
+ this.destroy$.complete();
603
+
604
+ if (this.isGroup) {
605
+ if (this.config.sectionConfig?.allowMulti) {
606
+ this.instanceList.forEach(inst => inst.rowController?.destroy());
607
+ }
608
+ if (this.formGroup?.contains(this.groupKey)) {
609
+ this.formGroup.removeControl(this.groupKey);
610
+ }
611
+ return;
612
+ }
613
+
614
+ const fieldName = this.config.name;
615
+ if (fieldName && this.formGroup?.contains(fieldName)) {
616
+ this.formGroup.removeControl(fieldName);
617
+ }
618
+ }
619
+
620
+ registerControl(): void {
621
+ if (!this.config.name || !this.formGroup) return;
622
+
623
+ const fieldName = this.config.name;
624
+ const validators = this.getValidators();
625
+
626
+ // When inside a repeater instance, ALWAYS start with defaultValue (never
627
+ // read from the shared controller — prevents cross-instance value copying).
628
+ const rawInitialValue = this.allowMulti
629
+ ? (this.config.defaultValue ?? null)
630
+ : (this.controller.getFieldValue(fieldName) ?? this.config.defaultValue ?? null);
631
+
632
+ let initialValue = rawInitialValue;
633
+ if (this.isDropdown || this.isAutocomplete) {
634
+ const extracted = this.extractInitialOption(rawInitialValue);
635
+ if (extracted) {
636
+ this.selectedItems = extracted.parsedOptions;
637
+ this.addParsedOptions(extracted.parsedOptions);
638
+ initialValue = extracted.code;
639
+ if (!this.allowMulti) {
640
+ this.controller.updateField(fieldName, initialValue);
641
+ // Also store the full option value object so MFEs can retrieve it from
642
+ // the controller without needing formFieldComponents (which doesn't
643
+ // pierce GROUP/section component boundaries).
644
+ if (extracted.parsedOptions.length === 1) {
645
+ this.controller.updateField(`_val_${fieldName}`, extracted.parsedOptions[0].value ?? null);
646
+ } else if (extracted.parsedOptions.length > 1) {
647
+ this.controller.updateField(`_val_${fieldName}`, extracted.parsedOptions.map(p => p.value));
648
+ }
649
+ }
650
+ } else {
651
+ const codes = Array.isArray(rawInitialValue) ? rawInitialValue : (rawInitialValue !== null && rawInitialValue !== undefined && rawInitialValue !== '' ? [rawInitialValue] : []);
652
+ this.updateSelectedItems(codes);
653
+ }
654
+ }
655
+
656
+
657
+ let control = this.formGroup.get(fieldName) as FormControl;
658
+ if (!control) {
659
+ control = new FormControl(
660
+ { value: initialValue, disabled: !!this.config.disabled || !this.isVisible },
661
+ validators
662
+ );
663
+ this.formGroup.addControl(fieldName, control);
664
+ }
665
+
666
+ this.value = control.value;
667
+
668
+ if (!this.allowMulti) {
669
+ // ── Flat field: keep in sync with shared controller ──────────────────
670
+ control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
671
+ if (this.isDropdown || this.isAutocomplete) {
672
+ const innerExtracted = this.extractInitialOption(val);
673
+ if (innerExtracted) {
674
+ this.selectedItems = innerExtracted.parsedOptions;
675
+ this.addParsedOptions(innerExtracted.parsedOptions);
676
+ control.setValue(innerExtracted.code, { emitEvent: false });
677
+ this.value = innerExtracted.code;
678
+ if (this.isAutocomplete) {
679
+ this._syncAutocompleteDisplayValue();
680
+ }
681
+ this.controller.updateField(fieldName, innerExtracted.code);
682
+ // Store the full option value object so MFEs can retrieve it by
683
+ // controller.getFieldValue('_val_<fieldName>') without needing
684
+ // to query formFieldComponents (which doesn't pierce GROUP boundaries).
685
+ if (innerExtracted.parsedOptions.length === 1) {
686
+ this.controller.updateField(`_val_${fieldName}`, innerExtracted.parsedOptions[0].value ?? null);
687
+ } else if (innerExtracted.parsedOptions.length > 1) {
688
+ this.controller.updateField(`_val_${fieldName}`, innerExtracted.parsedOptions.map(p => p.value));
689
+ }
690
+ return;
691
+ }
692
+ const codes = Array.isArray(val) ? val : (val !== null && val !== undefined && val !== '' ? [val] : []);
693
+ this.updateSelectedItems(codes);
694
+ }
695
+ this.value = val;
696
+ this.controller.updateField(fieldName, val);
697
+ });
698
+
699
+ this.controller.getFieldObservable(fieldName).pipe(takeUntil(this.destroy$)).subscribe(val => {
700
+ let actualValue = val;
701
+ if (this.isDropdown || this.isAutocomplete) {
702
+ const innerExtracted = this.extractInitialOption(val);
703
+ if (innerExtracted) {
704
+ this.selectedItems = innerExtracted.parsedOptions;
705
+ this.addParsedOptions(innerExtracted.parsedOptions);
706
+ actualValue = innerExtracted.code;
707
+ this.controller.updateField(fieldName, actualValue);
708
+ } else {
709
+ const codes = Array.isArray(val) ? val : (val !== null && val !== undefined && val !== '' ? [val] : []);
710
+ this.updateSelectedItems(codes);
711
+ }
712
+ }
713
+
714
+ if (actualValue !== control.value) {
715
+ control.setValue(actualValue, { emitEvent: false });
716
+ this.value = actualValue;
717
+ if (this.isAutocomplete) {
718
+ this._syncAutocompleteDisplayValue();
719
+ }
720
+ }
721
+ });
722
+ } else {
723
+ // ── Repeater field: local value tracking only ─────────────────────────
724
+ control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
725
+ if (this.isDropdown || this.isAutocomplete) {
726
+ const innerExtracted = this.extractInitialOption(val);
727
+ if (innerExtracted) {
728
+ this.selectedItems = innerExtracted.parsedOptions;
729
+ this.addParsedOptions(innerExtracted.parsedOptions);
730
+ control.setValue(innerExtracted.code, { emitEvent: false });
731
+ this.value = innerExtracted.code;
732
+ if (this.isAutocomplete) {
733
+ this._syncAutocompleteDisplayValue();
734
+ }
735
+ return;
736
+ }
737
+ const codes = Array.isArray(val) ? val : (val !== null && val !== undefined && val !== '' ? [val] : []);
738
+ this.updateSelectedItems(codes);
739
+ }
740
+ this.value = val;
741
+ });
742
+ }
743
+ }
744
+
745
+ getValidators(): ValidatorFn[] {
746
+ const validators: ValidatorFn[] = [];
747
+ if (this.config.required) validators.push(Validators.required);
748
+ if (this.config.subType === 'EMAIL') validators.push(Validators.email);
749
+ if (this.config.subType === 'PASSWORD') {
750
+ // Minimum 8 chars by default; honour explicit textConfig.length overrides
751
+ const minLen = this.config.textConfig?.length?.min ?? 8;
752
+ validators.push(Validators.minLength(minLen));
753
+ }
754
+ if (this.config.textConfig?.length) {
755
+ const { min, max } = this.config.textConfig.length;
756
+ if (min && this.config.subType !== 'PASSWORD') validators.push(Validators.minLength(min));
757
+ if (max) validators.push(Validators.maxLength(max));
758
+ }
759
+ if (this.config.textConfig?.pattern) validators.push(Validators.pattern(this.config.textConfig.pattern));
760
+ if (this.config.numberConfig) {
761
+ const { min, max } = this.config.numberConfig;
762
+ if (min !== undefined) validators.push(Validators.min(min));
763
+ if (max !== undefined) validators.push(Validators.max(max));
764
+ }
765
+ if (this.config.type === 'RICH_TEXT' && this.config.richTextConfig?.maxLength) {
766
+ const max = this.config.richTextConfig.maxLength;
767
+ validators.push((control: import('@angular/forms').AbstractControl) => {
768
+ if (!control.value) return null;
769
+ const plainText = String(control.value).replace(/<[^>]*>/g, '');
770
+ return plainText.length > max
771
+ ? { 'maxlength': { requiredLength: max, actualLength: plainText.length } }
772
+ : null;
773
+ });
774
+ }
775
+ return validators;
776
+ }
777
+
778
+ // ── Cross-field match validation (password === confirmPassword) ───────────
779
+
780
+ /**
781
+ * When `textConfig.matchField` is configured, subscribes to value changes on
782
+ * BOTH this control and the referenced control so the mismatch error updates
783
+ * instantly whichever field the user edits last.
784
+ *
785
+ * - Values differ → sets `{ passwordMismatch: true }` on THIS control.
786
+ * - Values match → clears `passwordMismatch` from THIS control.
787
+ */
788
+ setupMatchValidation(): void {
789
+ const matchFieldName = this.config.textConfig?.matchField;
790
+ if (!matchFieldName || !this.config.name || !this.formGroup) return;
791
+
792
+ const thisControl = this.formGroup.get(this.config.name);
793
+ const otherControl = this.formGroup.get(matchFieldName);
794
+ if (!thisControl || !otherControl) return;
795
+
796
+ const runCheck = () => {
797
+ const thisVal = thisControl.value;
798
+ const otherVal = otherControl.value;
799
+
800
+ if (thisVal && otherVal && thisVal !== otherVal) {
801
+ // Both have a value but they differ — flag the mismatch
802
+ thisControl.setErrors(
803
+ { ...thisControl.errors, passwordMismatch: true },
804
+ { emitEvent: false }
805
+ );
806
+ } else {
807
+ // Either one is empty OR they now match — clear the mismatch error
808
+ if (thisControl.hasError('passwordMismatch')) {
809
+ const errors = { ...thisControl.errors };
810
+ delete errors['passwordMismatch'];
811
+ thisControl.setErrors(
812
+ Object.keys(errors).length ? errors : null,
813
+ { emitEvent: false }
814
+ );
815
+ }
816
+ }
817
+ };
818
+
819
+ // Fire when THIS (confirmPassword) field changes
820
+ thisControl.valueChanges
821
+ .pipe(takeUntil(this.destroy$))
822
+ .subscribe(() => runCheck());
823
+
824
+ // Also fire when the OTHER (password) field changes so the error clears
825
+ // as soon as the user corrects the source value
826
+ otherControl.valueChanges
827
+ .pipe(takeUntil(this.destroy$))
828
+ .subscribe(() => runCheck());
829
+ }
830
+
831
+ setupVisibility(): void {
832
+ if (!this.config.visibilityExpression) return;
833
+
834
+ // Prefer formGroup-level lookup: works for both allowMulti rows and non-multi rows
835
+ // that still have a row-scoped formGroup (e.g. accordion repeater with allowMulti=false).
836
+ if (this.formGroup) {
837
+ const variables = this.expressionService.extractVariables(this.config.visibilityExpression);
838
+ const evaluate = () => {
839
+ // Merge global controller data so cross-group variables (e.g. sessionTypeId
840
+ // referenced from inside a nested GROUP) are in scope.
841
+ // Local formGroup values take precedence to preserve same-group behaviour.
842
+ const context = { ...this.controller.getAllData(), ...this.formGroup.value };
843
+ this.isVisible = this.expressionService.evaluateCondition(this.config.visibilityExpression!, context);
844
+ const control = this.formGroup.get(this.config.name!);
845
+ if (control) {
846
+ if (this.isVisible) {
847
+ control.enable({ emitEvent: false });
848
+ } else {
849
+ control.disable({ emitEvent: false });
850
+ if (control.value !== null && control.value !== undefined && control.value !== '') {
851
+ control.reset(null, { emitEvent: false });
852
+ }
853
+ }
854
+ }
855
+ };
856
+ evaluate();
857
+ // Subscribe to local group changes for same-group sibling expressions (existing behaviour).
858
+ this.formGroup.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => evaluate());
859
+ // Additionally subscribe to cross-group variables via the controller so that
860
+ // a field in a different FormGroup (e.g. sessionTypeId at root) re-evaluates
861
+ // this field's visibility even when the local formGroup does not emit.
862
+ const crossGroupVars = variables.filter(v => !this.formGroup.contains(v));
863
+ if (crossGroupVars.length > 0) {
864
+ const crossObservables = crossGroupVars.map(v => this.controller.getFieldObservable(v));
865
+ combineLatest(crossObservables).pipe(takeUntil(this.destroy$)).subscribe(() => evaluate());
866
+ }
867
+ } else {
868
+ const variables = this.expressionService.extractVariables(this.config.visibilityExpression);
869
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
870
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
871
+ const context = this.controller.getAllData();
872
+ this.isVisible = this.expressionService.evaluateCondition(this.config.visibilityExpression!, context);
873
+ const control = this.formGroup?.get(this.config.name!);
874
+ if (control) {
875
+ this.isVisible ? control.enable({ emitEvent: false }) : control.disable({ emitEvent: false });
876
+ }
877
+ });
878
+ }
879
+ }
880
+
881
+ /**
882
+ * When config.requiredExpression is set, dynamically re-evaluates the required
883
+ * validator whenever any referenced variable changes in the controller.
884
+ * Fully backwards-compatible: fields without requiredExpression are unaffected.
885
+ */
886
+ setupRequiredExpression(): void {
887
+ if (!this.config.requiredExpression || !this.config.name) return;
888
+
889
+ const control = this.formGroup?.get(this.config.name);
890
+ if (!control) return;
891
+
892
+ const expression = this.config.requiredExpression;
893
+ const variables = this.expressionService.extractVariables(expression);
894
+
895
+ const applyRequired = () => {
896
+ const context = { ...this.controller.getAllData(), ...this.formGroup.value };
897
+ const isRequired = this.expressionService.evaluateCondition(expression, context);
898
+ this.config.required = isRequired;
899
+ const base = this.getValidatorsWithoutRequired();
900
+ control.setValidators(isRequired ? [Validators.required, ...base] : base);
901
+ control.updateValueAndValidity({ emitEvent: false });
902
+ };
903
+
904
+ applyRequired(); // evaluate once on init
905
+
906
+ if (variables.length > 0) {
907
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
908
+ combineLatest(observables)
909
+ .pipe(takeUntil(this.destroy$))
910
+ .subscribe(() => applyRequired());
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Returns all validators from getValidators() except Validators.required.
916
+ * Used by setupRequiredExpression() to rebuild the validator list without
917
+ * duplicating the required validator.
918
+ */
919
+ private getValidatorsWithoutRequired(): ValidatorFn[] {
920
+ const savedRequired = this.config.required;
921
+ this.config.required = false;
922
+ const validators = this.getValidators();
923
+ this.config.required = savedRequired;
924
+ return validators;
925
+ }
926
+
927
+ /**
928
+ * trackBy function for field *ngFor loops in ROW containers.
929
+ * Keying by field name (or type as fallback for unnamed containers) prevents
930
+ * Angular from destroying and recreating FormFieldComponent instances on re-render,
931
+ * preserving localOptionList cache and avoiding duplicate API calls.
932
+ */
933
+ trackByFieldName(_: number, field: FieldConfig): string {
934
+ return field.name || field.type;
935
+ }
936
+
937
+ setupMinDateField(): void {
938
+ const minDateField = this.config.dateConfig?.minDateField;
939
+ if (!minDateField || this.config.type !== 'DATE') return;
940
+
941
+ // Use local date to avoid UTC-offset day shift (e.g. IST midnight = prev-day UTC)
942
+ const toIso = (val: any): string | null => {
943
+ if (!val) return null;
944
+ const d = val instanceof Date ? val : new Date(val);
945
+ if (isNaN(d.getTime())) return String(val);
946
+ const y = d.getFullYear();
947
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
948
+ const dy = String(d.getDate()).padStart(2, '0');
949
+ return `${y}-${mo}-${dy}`;
950
+ };
951
+
952
+ // Prefer formGroup-level lookup: works for both allowMulti rows and non-multi rows
953
+ // that still have a row-scoped formGroup (e.g. accordion repeater with allowMulti=false).
954
+ if (this.formGroup) {
955
+ const sourceCtrl = this.formGroup.get(minDateField);
956
+ if (sourceCtrl) {
957
+ this.dynamicMinDate = toIso(sourceCtrl.value);
958
+ sourceCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
959
+ this.dynamicMinDate = toIso(val);
960
+ // If end date is now before the new start date, clear it immediately
961
+ const thisCtrl = this.formGroup?.get(this.config.name!);
962
+ if (thisCtrl?.value && val) {
963
+ const endD = thisCtrl.value instanceof Date ? thisCtrl.value : new Date(thisCtrl.value);
964
+ const minD = val instanceof Date ? val : new Date(val as string);
965
+ if (!isNaN(endD.getTime()) && !isNaN(minD.getTime()) && endD < minD) {
966
+ thisCtrl.setValue(null, { emitEvent: true });
967
+ }
968
+ }
969
+ });
970
+ return;
971
+ }
972
+ }
973
+
974
+ // Fall back to controller-based lookup (non-repeater flat fields).
975
+ const updateMin = (context: any) => {
976
+ this.dynamicMinDate = toIso(context?.[minDateField]);
977
+ };
978
+ updateMin(this.controller.getAllData());
979
+ this.controller.getFieldObservable(minDateField)
980
+ .pipe(takeUntil(this.destroy$))
981
+ .subscribe(() => updateMin(this.controller.getAllData()));
982
+ }
983
+
984
+ /**
985
+ * Wires up a dynamic minimum for a TIME field from a sibling TIME field
986
+ * (config.timeConfig.minTimeField). When the source changes, this field's
987
+ * minimum updates and any now-invalid value (earlier than the new minimum)
988
+ * is cleared. Mirrors {@link setupMinDateField} for TIME fields.
989
+ */
990
+ setupMinTimeField(): void {
991
+ const minTimeField = this.config.timeConfig?.minTimeField;
992
+ if (!minTimeField || this.config.type !== 'TIME') return;
993
+
994
+ // Native <input type="time"> stores an "HH:mm" (or "HH:mm:ss") string,
995
+ // which compares correctly lexicographically. Normalise anything else to a string.
996
+ const toTime = (val: any): string | null => {
997
+ if (val === null || val === undefined || val === '') return null;
998
+ return String(val);
999
+ };
1000
+
1001
+ // Prefer formGroup-level lookup so it works for both allowMulti rows and
1002
+ // non-multi rows that still have a row-scoped formGroup (accordion repeaters).
1003
+ if (this.formGroup) {
1004
+ const sourceCtrl = this.formGroup.get(minTimeField);
1005
+ if (sourceCtrl) {
1006
+ this.dynamicMinTime = toTime(sourceCtrl.value);
1007
+ sourceCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
1008
+ this.dynamicMinTime = toTime(val);
1009
+ // If this (end) time is now before the new start time, clear it immediately.
1010
+ const thisCtrl = this.formGroup?.get(this.config.name!);
1011
+ const min = this.dynamicMinTime;
1012
+ if (thisCtrl?.value && min && String(thisCtrl.value) < min) {
1013
+ thisCtrl.setValue(null, { emitEvent: true });
1014
+ }
1015
+ });
1016
+ return;
1017
+ }
1018
+ }
1019
+
1020
+ // Fall back to controller-based lookup (non-repeater flat fields).
1021
+ const updateMin = (context: any) => {
1022
+ this.dynamicMinTime = toTime(context?.[minTimeField]);
1023
+ };
1024
+ updateMin(this.controller.getAllData());
1025
+ this.controller.getFieldObservable(minTimeField)
1026
+ .pipe(takeUntil(this.destroy$))
1027
+ .subscribe(() => updateMin(this.controller.getAllData()));
1028
+ }
1029
+
1030
+ setupGeneratedField(): void {
1031
+ if (this.config.type !== 'GENERATED' || !this.config.generatedConfig) return;
1032
+ const variables = this.config.generatedConfig.variables || [];
1033
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
1034
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
1035
+ const context = this.controller.getAllData();
1036
+ const result = this.evaluateFormula(context);
1037
+ if (result !== null && this.config.name) {
1038
+ this.controller.updateField(this.config.name, result);
1039
+ }
1040
+ });
1041
+ }
1042
+
1043
+ evaluateFormula(context: { [key: string]: any }): any {
1044
+ if (!this.config.generatedConfig) return null;
1045
+ const formula = this.config.generatedConfig.formula;
1046
+ const functionName = this.extractFunctionName(formula);
1047
+ if (functionName) {
1048
+ return this.expressionService.evaluateFormula(
1049
+ formula, functionName, context, this.config.generatedConfig.variables
1050
+ );
1051
+ }
1052
+ return null;
1053
+ }
1054
+
1055
+ extractFunctionName(formula: string): string | null {
1056
+ const match = formula.match(/(?:function|fun)\s+(\w+)\s*\(/);
1057
+ return match ? match[1] : null;
1058
+ }
1059
+
1060
+ setupDependencies(): void {
1061
+ if (!this.config.optionConfig?.dependencies) return;
1062
+ const dependencies = this.config.optionConfig.dependencies;
1063
+ const observables = Object.values(dependencies).map(fieldName =>
1064
+ this.controller.getFieldObservable(fieldName)
1065
+ );
1066
+ // Track previous values so we can detect user-driven parent changes vs. initial load.
1067
+ // BehaviorSubjects always emit the current value on subscribe — we must NOT clear the
1068
+ // child's value on that first emission (it would wipe pre-populated edit-mode data).
1069
+ let prevValues: any[] | null = null;
1070
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(values => {
1071
+ const dependencyValues: { [key: string]: any } = {};
1072
+ Object.keys(dependencies).forEach((paramKey, index) => {
1073
+ dependencyValues[paramKey] = values[index];
1074
+ });
1075
+ const allPresent = Object.values(dependencyValues).every(
1076
+ v => v !== null && v !== undefined && v !== ''
1077
+ );
1078
+
1079
+ const isFirstEmit = prevValues === null;
1080
+ const parentChanged = !isFirstEmit && values.some((v, i) => v !== prevValues![i]);
1081
+ prevValues = [...values];
1082
+
1083
+ if (allPresent) {
1084
+ if (parentChanged) {
1085
+ // Parent value changed by user interaction — reset child selection so the
1086
+ // previously chosen value (from a different parent) isn't shown as selected
1087
+ // against the newly loaded options.
1088
+ const control = this.formGroup?.get(this.config.name!);
1089
+ if (control && control.value !== null && control.value !== '') {
1090
+ control.setValue(null, { emitEvent: true });
1091
+ }
1092
+ }
1093
+ this._latestDependencyValues = dependencyValues;
1094
+ this.loadDropdownOptions(dependencyValues);
1095
+ } else if (this.config.optionConfig) {
1096
+ this._latestDependencyValues = {};
1097
+ this.localOptionList = [];
1098
+ this.filteredOptionList = [];
1099
+ if (parentChanged) {
1100
+ const control = this.formGroup?.get(this.config.name!);
1101
+ if (control && control.value !== null && control.value !== '') {
1102
+ control.setValue(null, { emitEvent: true });
1103
+ }
1104
+ }
1105
+ }
1106
+ });
1107
+ }
1108
+
1109
+ loadDropdownOptions(dynamicParams: { [key: string]: any } = {}): void {
1110
+ const optionConfig = this.config.optionConfig;
1111
+ const acConfig = this.config.autocompleteConfig;
1112
+
1113
+ const urls = optionConfig?.apiUrls ||
1114
+ (optionConfig?.apiUrl ? [optionConfig.apiUrl] :
1115
+ optionConfig?.optionUrl ? [optionConfig.optionUrl] : []);
1116
+
1117
+ if (!urls || urls.length === 0) return;
1118
+
1119
+ if (this.optionsSubscription) {
1120
+ this.optionsSubscription.unsubscribe();
1121
+ }
1122
+ this.optionsLoading = !!optionConfig?.showLoader;
1123
+
1124
+ // HTTP method/body/headers: read from autocompleteConfig first, fall back to optionConfig
1125
+ const method = acConfig?.method || 'GET';
1126
+ const staticBody = acConfig?.body ?? null;
1127
+ const staticQueryParams = acConfig?.queryParams || {};
1128
+ const customHeaders = acConfig?.headers;
1129
+
1130
+ const observables = urls.map(url => {
1131
+ let fullUrl = url;
1132
+
1133
+ // Merge static queryParams and dynamicParams
1134
+ const allQueryParams = { ...staticQueryParams, ...dynamicParams };
1135
+
1136
+ // Interpolate path variables (e.g., {program}) from allQueryParams
1137
+ Object.keys(allQueryParams).forEach(key => {
1138
+ const placeholder = `{${key}}`;
1139
+ if (fullUrl.includes(placeholder)) {
1140
+ fullUrl = fullUrl.replace(placeholder, String(allQueryParams[key]));
1141
+ // Remove it so it doesn't get appended as a query param
1142
+ delete allQueryParams[key];
1143
+ }
1144
+ });
1145
+
1146
+ let httpParams = new HttpParams();
1147
+ Object.entries(allQueryParams).forEach(([key, value]) => {
1148
+ if (value !== null && value !== undefined) {
1149
+ httpParams = httpParams.append(key, String(value));
1150
+ }
1151
+ });
1152
+
1153
+ const options: any = {
1154
+ headers: this.getHeaders(customHeaders),
1155
+ params: httpParams
1156
+ };
1157
+
1158
+ switch (method) {
1159
+ case 'POST': return this.http.post<any>(fullUrl, staticBody, options);
1160
+ case 'PUT': return this.http.put<any>(fullUrl, staticBody, options);
1161
+ case 'PATCH': return this.http.patch<any>(fullUrl, staticBody, options);
1162
+ default: return this.http.get<any>(fullUrl, options);
1163
+ }
1164
+ });
1165
+
1166
+ this.optionsSubscription = forkJoin(observables).pipe(
1167
+ takeUntil(this.destroy$),
1168
+ finalize(() => {
1169
+ this.optionsLoading = false;
1170
+ this.optionsSubscription = null;
1171
+ })
1172
+ ).subscribe({
1173
+ next: (responses: any[]) => {
1174
+ let mergedData: any[] = [];
1175
+ responses.forEach((response: any) => {
1176
+ let data = optionConfig?.dataPath
1177
+ ? this.getValueByPath(response, optionConfig.dataPath)
1178
+ : (Array.isArray(response) ? response : response.elements || response.data || response.items || response || []);
1179
+
1180
+ // Handle Dictionary Objects by converting them to Arrays
1181
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
1182
+ data = Object.values(data);
1183
+ }
1184
+
1185
+ if (Array.isArray(data)) mergedData = [...mergedData, ...data];
1186
+ });
1187
+
1188
+ if (optionConfig?.sortBy) {
1189
+ const sortKey = optionConfig.sortBy;
1190
+ const direction = optionConfig.sortDirection === 'DESC' ? -1 : 1;
1191
+ mergedData.sort((a, b) => {
1192
+ const valA = this.getValueByPath(a, sortKey);
1193
+ const valB = this.getValueByPath(b, sortKey);
1194
+ if (typeof valA === 'string' && typeof valB === 'string') return direction * valA.localeCompare(valB);
1195
+ if (typeof valA === 'number' && typeof valB === 'number') return direction * (valA - valB);
1196
+ return 0;
1197
+ });
1198
+ }
1199
+
1200
+ // labelTemplate: read from autocompleteConfig first
1201
+ const labelTemplate = acConfig?.labelTemplate;
1202
+
1203
+ const mappedOptions = mergedData.map((item: any) => {
1204
+ // Support labelTemplate: '{firstName} {lastName} ({login})'
1205
+ let label: string;
1206
+ if (labelTemplate) {
1207
+ label = labelTemplate.replace(/\{(\w+)\}/g, (_: string, key: string) => {
1208
+ const val = this.getValueByPath(item, key);
1209
+ return val !== undefined && val !== null ? String(val) : '';
1210
+ });
1211
+ } else if (optionConfig?.labelPath) {
1212
+ label = String(this.getValueByPath(item, optionConfig.labelPath) ?? '');
1213
+ } else {
1214
+ label = item.label || item.displayName || item.name || '';
1215
+ }
1216
+
1217
+ const code = optionConfig?.valuePath
1218
+ ? this.getValueByPath(item, optionConfig.valuePath)
1219
+ : (item.code ?? item.id ?? item.value);
1220
+
1221
+ // Resolve displayFields from autocompleteConfig
1222
+ // Supports string (single path, backward-compat) or AutocompleteDisplayField[]
1223
+ const displayFields = acConfig?.displayFields;
1224
+ let displayMeta: { path: string; type: string; label?: string; value: string }[] | undefined;
1225
+ if (displayFields) {
1226
+ const fields: AutocompleteDisplayField[] = typeof displayFields === 'string'
1227
+ ? [{ path: displayFields, type: 'text' }]
1228
+ : displayFields;
1229
+ displayMeta = fields.map(f => ({
1230
+ path: f.path,
1231
+ type: f.type || 'text',
1232
+ icon: f.icon,
1233
+ className: f.className,
1234
+ label: f.label,
1235
+ value: String(this.getValueByPath(item, f.path) ?? '')
1236
+ })).filter(f => f.value !== '');
1237
+ }
1238
+
1239
+ return { label, code, value: item, displayMeta };
1240
+ });
1241
+
1242
+ // --- PRESERVE SELECTED OPTION ---
1243
+ const newCodesSet = new Set(mappedOptions.map(o => o.code));
1244
+ const finalOptions = [...mappedOptions];
1245
+ this.selectedItems.forEach(opt => {
1246
+ if (!newCodesSet.has(opt.code)) {
1247
+ finalOptions.push(opt);
1248
+ }
1249
+ });
1250
+ this.localOptionList = finalOptions;
1251
+ // ---------------------------------
1252
+
1253
+ // Refresh autocomplete filter list after options arrive from API
1254
+ if (this.isAutocomplete) {
1255
+ this.filteredOptions = [...this.localOptionList];
1256
+ this._syncAutocompleteDisplayValue();
1257
+ }
1258
+
1259
+ // Refresh dropdown search filter list after options arrive from API
1260
+ if (this.isDropdown) {
1261
+ this.filteredOptionList = [...this.localOptionList];
1262
+ }
1263
+ },
1264
+ error: err => console.error('Failed to load dropdown options:', err)
1265
+ });
1266
+ }
1267
+
1268
+ private getValueByPath(obj: any, path: string): any {
1269
+ if (!path || path === '') return obj;
1270
+ return path.split('.').reduce((acc, part) => {
1271
+ const match = part.match(/(\w+)\[(\d+)\]/);
1272
+ if (match) return acc?.[match[1]]?.[parseInt(match[2])];
1273
+ return acc?.[part];
1274
+ }, obj);
1275
+ }
1276
+
1277
+ /**
1278
+ * Replaces `{placeholder}` tokens in a URL with values resolved from the
1279
+ * current form data (supports dot / array-index paths via getValueByPath).
1280
+ * Returns the interpolated URL plus whether EVERY token was resolved.
1281
+ *
1282
+ * Used by the delete flows: when a required id (e.g. `{entityId}`) is missing
1283
+ * — as in CREATE mode before the entity exists — `resolved` is false and the
1284
+ * caller skips the server delete and just removes the item from the form.
1285
+ */
1286
+ private resolveUrlPlaceholders(url: string): { url: string; resolved: boolean } {
1287
+ if (!url || !url.includes('{')) return { url, resolved: true };
1288
+ const data = this.controller.getAllData();
1289
+ let resolved = true;
1290
+ const out = url.replace(/\{([^}]+)\}/g, (_match, path: string) => {
1291
+ const val = this.getValueByPath(data, path.trim());
1292
+ if (val === null || val === undefined || val === '') {
1293
+ resolved = false;
1294
+ return `{${path}}`;
1295
+ }
1296
+ return String(val);
1297
+ });
1298
+ return { url: out, resolved };
1299
+ }
1300
+
1301
+ /** Builds HttpHeaders using the token stored in the SmartFormController (sourced from configJSON)
1302
+ * merged with any custom headers declared in optionConfig.headers.
1303
+ */
1304
+ private _fileLabel(key: keyof FormLabels, fallback: string, vars: Record<string, string> = {}): string {
1305
+ let tpl = (this.controller.actionLabels?.[key] as string) ?? fallback;
1306
+ Object.entries(vars).forEach(([k, v]) => { tpl = tpl.replace(`{${k}}`, v); });
1307
+ return tpl;
1308
+ }
1309
+
1310
+ private getHeaders(customHeaders?: { [key: string]: string }): HttpHeaders {
1311
+ let headers = new HttpHeaders();
1312
+ if (this.controller.token) {
1313
+ const headerName = this.controller.tokenHeader || 'Authorization';
1314
+ headers = headers.set(headerName, this.controller.token);
1315
+ }
1316
+ // Merge custom headers from optionConfig (allows MFE to pass e.g. X-SESSIONID)
1317
+ if (customHeaders) {
1318
+ Object.entries(customHeaders).forEach(([key, val]) => {
1319
+ headers = headers.set(key, val);
1320
+ });
1321
+ }
1322
+ return headers;
1323
+ }
1324
+
1325
+ updateValue(newValue: any): void {
1326
+ if (!this.config.name) return;
1327
+ const control = this.formGroup.get(this.config.name);
1328
+ if (control) {
1329
+ control.setValue(newValue);
1330
+ control.markAsDirty();
1331
+ control.markAsTouched();
1332
+ }
1333
+ }
1334
+
1335
+ onCheckboxListChange(code: string, checked: boolean): void {
1336
+ if (!this.config.name) return;
1337
+ const currentValue = (this.allowMulti
1338
+ ? (this.formGroup.get(this.config.name)?.value)
1339
+ : this.controller.getFieldValue(this.config.name)) || [];
1340
+ const newValue = checked
1341
+ ? [...currentValue, code]
1342
+ : currentValue.filter((c: string) => c !== code);
1343
+ this.updateValue(newValue);
1344
+ }
1345
+
1346
+ isChecked(code: string): boolean {
1347
+ const value = this.value || [];
1348
+ return Array.isArray(value) && value.includes(code);
1349
+ }
1350
+
1351
+ get errorMessage(): string {
1352
+ if (!this.config.name || !this.formGroup) return '';
1353
+ const control = this.formGroup.get(this.config.name);
1354
+ if (control && control.invalid && (control.touched || control.dirty)) {
1355
+ if (control.hasError('required')) return 'This field is required';
1356
+ if (control.hasError('email')) return 'Invalid email format';
1357
+ if (control.hasError('passwordMismatch')) return 'Passwords do not match';
1358
+ if (control.hasError('formulaError')) return this.config.errorMessage || 'Invalid value';
1359
+ if (control.hasError('minlength')) return `Minimum length is ${control.errors?.['minlength'].requiredLength} characters`;
1360
+ if (control.hasError('maxlength')) return `Maximum length is ${control.errors?.['maxlength'].requiredLength} characters`;
1361
+ if (control.hasError('min')) return `Minimum value is ${control.errors?.['min'].min}`;
1362
+ if (control.hasError('max')) return `Maximum value is ${control.errors?.['max'].max}`;
1363
+ if (control.hasError('pattern')) return this.config.textConfig?.patternMessage || 'Invalid format';
1364
+ }
1365
+ return '';
1366
+ }
1367
+
1368
+ get showCharCount(): boolean {
1369
+ if (this.isTextField && this.config.subType !== 'PHONE') {
1370
+ return !!(this.config.textConfig?.showCharCount && this.config.textConfig?.length?.max);
1371
+ }
1372
+ if (this.isRichText) {
1373
+ return !!(this.config.richTextConfig?.showCharCount && this.config.richTextConfig?.maxLength);
1374
+ }
1375
+ return false;
1376
+ }
1377
+
1378
+ get remainingCharacters(): number | null {
1379
+ if (!this.showCharCount || !this.formGroup || !this.config.name) return null;
1380
+ const currentVal = this.formGroup.get(this.config.name)?.value || '';
1381
+
1382
+ if (this.isTextField && this.config.textConfig?.length?.max) {
1383
+ return Math.max(0, this.config.textConfig.length.max - currentVal.length);
1384
+ }
1385
+ if (this.isRichText && this.config.richTextConfig?.maxLength) {
1386
+ const plainText = String(currentVal).replace(/<[^>]*>/g, '');
1387
+ return Math.max(0, this.config.richTextConfig.maxLength - plainText.length);
1388
+ }
1389
+ return null;
1390
+ }
1391
+
1392
+ // ── RICH_TEXT toolbar ──────────────────────────────────────────────────
1393
+
1394
+ private static readonly RICH_TEXT_TOKEN_MAP: { [key: string]: any } = {
1395
+ bold: 'bold',
1396
+ italic: 'italic',
1397
+ underline: 'underline',
1398
+ strike: 'strike',
1399
+ blockquote: 'blockquote',
1400
+ code: 'code-block',
1401
+ link: 'link',
1402
+ image: 'image',
1403
+ video: 'video',
1404
+ clean: 'clean',
1405
+ font: { font: [] },
1406
+ size: { size: ['small', false, 'large', 'huge'] },
1407
+ color: { color: [] },
1408
+ background: { background: [] },
1409
+ align: { align: [] },
1410
+ header1: { header: 1 },
1411
+ header2: { header: 2 },
1412
+ orderedList: { list: 'ordered' },
1413
+ bulletList: { list: 'bullet' },
1414
+ subscript: { script: 'sub' },
1415
+ superscript: { script: 'super' },
1416
+ indentMinus: { indent: '-1' },
1417
+ indentPlus: { indent: '+1' }
1418
+ };
1419
+
1420
+ private static readonly DEFAULT_RICH_TEXT_HEADER: string[] =
1421
+ ['bold', 'italic', 'underline', 'font', 'size', 'color', 'link', 'image', 'video'];
1422
+
1423
+ /**
1424
+ * Builds the ngx-quill `modules.toolbar` config from `richTextConfig.headerConfig`
1425
+ * (a flat list of tokens) falling back to a curated default set.
1426
+ * Consecutive simple (button) tokens are grouped together; dropdown-style
1427
+ * tokens (font/size/color/...) each get their own group, matching Quill's
1428
+ * expected toolbar shape.
1429
+ */
1430
+ get richTextModules(): any {
1431
+ const tokens = this.config.richTextConfig?.headerConfig?.length
1432
+ ? this.config.richTextConfig.headerConfig
1433
+ : FormFieldComponent.DEFAULT_RICH_TEXT_HEADER;
1434
+
1435
+ const groups: any[] = [];
1436
+ let currentGroup: any[] = [];
1437
+
1438
+ tokens.forEach(token => {
1439
+ const entry = FormFieldComponent.RICH_TEXT_TOKEN_MAP[token];
1440
+ if (entry === undefined) return;
1441
+ if (typeof entry === 'string') {
1442
+ currentGroup.push(entry);
1443
+ } else {
1444
+ if (currentGroup.length) {
1445
+ groups.push(currentGroup);
1446
+ currentGroup = [];
1447
+ }
1448
+ groups.push([entry]);
1449
+ }
1450
+ });
1451
+ if (currentGroup.length) groups.push(currentGroup);
1452
+
1453
+ return { toolbar: groups.length ? groups : false };
1454
+ }
1455
+
1456
+ // ── DROPDOWN helpers (single + multi) ─────────────────────────────────────
1457
+
1458
+ /**
1459
+ * Preferred positions for the custom dropdown panels, rendered in a CDK
1460
+ * connected overlay so they escape a scrollable modal body (cc-confirmation-modal)
1461
+ * instead of being clipped behind its footer. Opens below the trigger, flipping
1462
+ * above when there isn't room.
1463
+ */
1464
+ readonly dropdownOverlayPositions: ConnectedPosition[] = [
1465
+ { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top', offsetY: 4 },
1466
+ { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom', offsetY: -4 },
1467
+ ];
1468
+
1469
+ /**
1470
+ * Close the dropdown when the CDK overlay reports a click outside the panel.
1471
+ * Clicks on the trigger itself are ignored here so the trigger's own toggle
1472
+ * handler can close it (otherwise the two would fight and re-open it).
1473
+ */
1474
+ onDropdownOverlayOutsideClick(event: MouseEvent, triggerEl: HTMLElement): void {
1475
+ if (triggerEl && triggerEl.contains(event.target as Node)) {
1476
+ return;
1477
+ }
1478
+ this.isMultiDropdownOpen = false;
1479
+ this.isSingleDropdownOpen = false;
1480
+ this.resetDropdownSearch();
1481
+ }
1482
+
1483
+ toggleMultiDropdown(event: MouseEvent): void {
1484
+ event.stopPropagation();
1485
+ this.isMultiDropdownOpen = !this.isMultiDropdownOpen;
1486
+ if (this.isMultiDropdownOpen) {
1487
+ this.resetDropdownSearch();
1488
+ }
1489
+ }
1490
+
1491
+ toggleSingleDropdown(event: MouseEvent): void {
1492
+ event.stopPropagation();
1493
+ this.isSingleDropdownOpen = !this.isSingleDropdownOpen;
1494
+ if (this.isSingleDropdownOpen) {
1495
+ this.resetDropdownSearch();
1496
+ }
1497
+ }
1498
+
1499
+ selectSingleOption(option: any): void {
1500
+ this.updateValue(option.code);
1501
+ this.isSingleDropdownOpen = false;
1502
+ this.resetDropdownSearch(false);
1503
+ }
1504
+
1505
+ @HostListener('document:click')
1506
+ onDocumentClick(): void {
1507
+ if (this.isMultiDropdownOpen) {
1508
+ this.isMultiDropdownOpen = false;
1509
+ this.resetDropdownSearch(false);
1510
+ }
1511
+ if (this.isSingleDropdownOpen) {
1512
+ this.isSingleDropdownOpen = false;
1513
+ this.resetDropdownSearch(false);
1514
+ }
1515
+ }
1516
+
1517
+ @HostListener('document:keydown.escape')
1518
+ onEscapeKey(): void {
1519
+ this.isMultiDropdownOpen = false;
1520
+ this.isSingleDropdownOpen = false;
1521
+ this.resetDropdownSearch(false);
1522
+ }
1523
+
1524
+ get multiSelectedCount(): number {
1525
+ return Array.isArray(this.value) ? this.value.length : 0;
1526
+ }
1527
+
1528
+ // ── Sibling-instance duplicate prevention (repeater DROPDOWNs) ────────────
1529
+
1530
+ /** Cache of values currently chosen for this field name by OTHER repeater instances.
1531
+ * Only reassigned (a new Set) when siblingFormArray's value actually changes — see
1532
+ * _setupSiblingExclusion(). Previously this was a getter that rebuilt the Set (and a
1533
+ * second getter that re-filtered the option list) on every change-detection cycle,
1534
+ * handing *ngFor a brand-new array reference every digest — that trips
1535
+ * ExpressionChangedAfterItHasBeenCheckedError in dev builds and made Angular
1536
+ * re-diff/re-render the option list constantly, very visible in a repeater with
1537
+ * several dropdown instances. */
1538
+ private _siblingTaken: Set<any> = new Set();
1539
+
1540
+ /** True when this is a single-select DROPDOWN living inside an allowMulti repeater. */
1541
+ private get _excludesSiblingValues(): boolean {
1542
+ return this.isDropdown && this.config.subType !== 'MULTI'
1543
+ && !!this.siblingFormArray && this.siblingIndex !== null;
1544
+ }
1545
+
1546
+ /** Subscribes to the repeater's FormArray so _siblingTaken is refreshed only when a
1547
+ * sibling instance's value actually changes, not on every change-detection cycle. */
1548
+ private _setupSiblingExclusion(): void {
1549
+ if (!this._excludesSiblingValues) return;
1550
+ this._refreshSiblingTaken();
1551
+ this.siblingFormArray!.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(() => {
1552
+ this._refreshSiblingTaken();
1553
+ });
1554
+ }
1555
+
1556
+ private _refreshSiblingTaken(): void {
1557
+ const taken = new Set<any>();
1558
+ this.siblingFormArray!.controls.forEach((ctrl, i) => {
1559
+ if (i === this.siblingIndex) return;
1560
+ const v = (ctrl as FormGroup).get(this.config.name!)?.value;
1561
+ if (v !== null && v !== undefined && v !== '') taken.add(v);
1562
+ });
1563
+ this._siblingTaken = taken;
1564
+ }
1565
+
1566
+ /** Memoized per source list: only recomputes (and hands *ngFor a new array reference)
1567
+ * when the source list, the taken set, or this field's own value actually changed. */
1568
+ private _displayListMemo: { [key: string]: { src: any[]; taken: Set<any>; ownValue: any; result: any[] } } = {};
1569
+
1570
+ private _excludeTaken(list: any[], memoKey: string): any[] {
1571
+ if (!this._excludesSiblingValues || !list?.length) return list;
1572
+ const ownValue = this.value;
1573
+ const cached = this._displayListMemo[memoKey];
1574
+ if (cached && cached.src === list && cached.taken === this._siblingTaken && cached.ownValue === ownValue) {
1575
+ return cached.result;
1576
+ }
1577
+ const result = list.filter(opt => opt.code === ownValue || !this._siblingTaken.has(opt.code));
1578
+ this._displayListMemo[memoKey] = { src: list, taken: this._siblingTaken, ownValue, result };
1579
+ return result;
1580
+ }
1581
+
1582
+ /** Options for the custom single-select dropdown, with sibling-taken values hidden. */
1583
+ get displayOptionList(): any[] {
1584
+ return this._excludeTaken(this.filteredOptionList, 'option');
1585
+ }
1586
+
1587
+ /** Options for the native <select>/radio rendering, with sibling-taken values hidden. */
1588
+ get displayLocalOptionList(): any[] {
1589
+ return this._excludeTaken(this.localOptionList, 'local');
1590
+ }
1591
+
1592
+ // ── Dropdown search helpers ───────────────────────────────────────────────
1593
+
1594
+ /** Initialise the RxJS search stream for dropdown filtering. */
1595
+ private initDropdownSearch(): void {
1596
+ const searchConfig = this.config.optionConfig?.searchConfig;
1597
+ const debounceMs = searchConfig?.debounceTime ?? 300;
1598
+
1599
+ this.dropdownSearch$.pipe(
1600
+ debounceTime(debounceMs),
1601
+ distinctUntilChanged(),
1602
+ takeUntil(this.destroy$)
1603
+ ).subscribe(query => {
1604
+ this.executeDropdownSearch(query);
1605
+ });
1606
+ }
1607
+
1608
+ /** Execute client-side or server-side dropdown search. */
1609
+ private executeDropdownSearch(query: string): void {
1610
+ const searchConfig = this.config.optionConfig?.searchConfig;
1611
+ const mode = searchConfig?.mode || 'local';
1612
+
1613
+ if (mode === 'server') {
1614
+ const searchKey = searchConfig?.searchKey || 'search';
1615
+ const minLen = searchConfig?.minSearchLength ?? 3;
1616
+
1617
+ if (query.length >= minLen) {
1618
+ const params = { ...this._latestDependencyValues, [searchKey]: query };
1619
+ this.loadDropdownOptions(params);
1620
+ } else if (query.length === 0) {
1621
+ // Empty query — restore full list
1622
+ this.loadDropdownOptions(this._latestDependencyValues);
1623
+ }
1624
+ } else {
1625
+ // Local filtering
1626
+ if (!query || query.trim().length === 0) {
1627
+ this.filteredOptionList = [...this.localOptionList];
1628
+ } else {
1629
+ const q = query.toLowerCase().trim();
1630
+ this.filteredOptionList = this.localOptionList.filter(opt =>
1631
+ opt.label?.toLowerCase().includes(q) ||
1632
+ String(opt.code).toLowerCase().includes(q)
1633
+ );
1634
+ }
1635
+ }
1636
+ }
1637
+
1638
+ /** Resets the dropdown search text and filtered list. */
1639
+ resetDropdownSearch(forceLoad = true, bypassCache = false): void {
1640
+ this.searchText = '';
1641
+ const searchConfig = this.config.optionConfig?.searchConfig;
1642
+ if (forceLoad && searchConfig?.enabled && searchConfig?.mode === 'server') {
1643
+ if (bypassCache || this.localOptionList.length === 0) {
1644
+ this.loadDropdownOptions(this._latestDependencyValues);
1645
+ }
1646
+ } else {
1647
+ this.filteredOptionList = [...this.localOptionList];
1648
+ }
1649
+ }
1650
+
1651
+ /** Merges option(s) from selectedOptionsData into the local dropdown options list */
1652
+ mergeSelectedOptionsIntoList(): void {
1653
+ if (!this.config.name || !(this.isDropdown || this.isAutocomplete)) return;
1654
+
1655
+ const data = this.controller.selectedOptionsData?.[this.config.name];
1656
+ if (!data) return;
1657
+
1658
+ const parsed: any[] = [];
1659
+ if (Array.isArray(data)) {
1660
+ data.forEach(item => {
1661
+ const opt = this.parseSingleOptionObject(item);
1662
+ if (opt) parsed.push(opt);
1663
+ });
1664
+ } else {
1665
+ const opt = this.parseSingleOptionObject(data);
1666
+ if (opt) parsed.push(opt);
1667
+ }
1668
+
1669
+ if (parsed.length > 0) {
1670
+ this.selectedItems = parsed;
1671
+ this.addParsedOptions(parsed);
1672
+ }
1673
+ }
1674
+
1675
+ /**
1676
+ * Extracts option(s) and their corresponding raw codes from an initial value object or array of objects.
1677
+ * If the value is a primitive but initialLabelField is configured, it looks up the sibling field value.
1678
+ */
1679
+ private extractInitialOption(val: any): { code: any; parsedOptions: any[] } | null {
1680
+ if (val === null || val === undefined || val === '') return null;
1681
+
1682
+ // Check if it's an array of items (e.g. for MULTIPLE dropdown)
1683
+ if (Array.isArray(val)) {
1684
+ const parsedOptions: any[] = [];
1685
+ const codes: any[] = [];
1686
+ let hasObjects = false;
1687
+
1688
+ val.forEach(item => {
1689
+ const parsed = this.parseSingleOptionObject(item);
1690
+ if (parsed) {
1691
+ parsedOptions.push(parsed);
1692
+ codes.push(parsed.code);
1693
+ hasObjects = true;
1694
+ } else {
1695
+ codes.push(item);
1696
+ }
1697
+ });
1698
+
1699
+ if (hasObjects) {
1700
+ return { code: codes, parsedOptions };
1701
+ }
1702
+ return null;
1703
+ }
1704
+
1705
+ // Single item
1706
+ const parsed = this.parseSingleOptionObject(val);
1707
+ if (parsed) {
1708
+ return { code: parsed.code, parsedOptions: [parsed] };
1709
+ }
1710
+
1711
+ return null;
1712
+ }
1713
+
1714
+ private parseSingleOptionObject(item: any): { code: any; label: string; value?: any } | null {
1715
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
1716
+ // Avoid parsing File, Date, UploadedFile, or MediaItem
1717
+ if (item instanceof Date || 'file' in item || 'mediaType' in item) {
1718
+ return null;
1719
+ }
1720
+
1721
+ const optionConfig = this.config.optionConfig;
1722
+ const acConfig = this.config.autocompleteConfig;
1723
+
1724
+ // Extract code/value using valuePath or fallback
1725
+ let code: any;
1726
+ if (optionConfig?.valuePath) {
1727
+ code = this.getValueByPath(item, optionConfig.valuePath);
1728
+ } else {
1729
+ code = item.code !== undefined ? item.code : (item.id !== undefined ? item.id : item.value);
1730
+ }
1731
+
1732
+ // Extract label using labelTemplate, labelPath, or fallback
1733
+ let label: string;
1734
+ const labelTemplate = acConfig?.labelTemplate;
1735
+ if (labelTemplate) {
1736
+ label = labelTemplate.replace(/\{(\w+)\}/g, (_: string, key: string) => {
1737
+ const val = this.getValueByPath(item, key);
1738
+ return val !== undefined && val !== null ? String(val) : '';
1739
+ });
1740
+ } else if (optionConfig?.labelPath) {
1741
+ label = String(this.getValueByPath(item, optionConfig.labelPath) ?? '');
1742
+ } else {
1743
+ const fallbackLabel = item.label !== undefined ? item.label : (item.displayName !== undefined ? item.displayName : item.name);
1744
+ if (Array.isArray(fallbackLabel)) {
1745
+ const first = fallbackLabel[0];
1746
+ label = first ? String(first.text || first.label || first.name || JSON.stringify(first)) : '';
1747
+ } else {
1748
+ label = fallbackLabel !== undefined && fallbackLabel !== null ? String(fallbackLabel) : '';
1749
+ }
1750
+ }
1751
+
1752
+ if (code !== undefined && code !== null) {
1753
+ return { code, label, value: item };
1754
+ }
1755
+ }
1756
+ return null;
1757
+ }
1758
+
1759
+ private addParsedOptions(options: any[]): void {
1760
+ let updated = false;
1761
+ options.forEach(opt => {
1762
+ const exists = this.localOptionList.some(o => o.code === opt.code);
1763
+ if (!exists) {
1764
+ this.localOptionList.push({
1765
+ label: opt.label,
1766
+ code: opt.code,
1767
+ value: opt.value || opt
1768
+ });
1769
+ updated = true;
1770
+ }
1771
+ });
1772
+ if (updated) {
1773
+ this.filteredOptionList = [...this.localOptionList];
1774
+ if (this.isAutocomplete) {
1775
+ this.filteredOptions = [...this.localOptionList];
1776
+ this._syncAutocompleteDisplayValue();
1777
+ }
1778
+ }
1779
+ }
1780
+
1781
+ private updateSelectedItems(codes: any[]): void {
1782
+ const newSelectedItems: any[] = [];
1783
+ codes.forEach(code => {
1784
+ let match = this.selectedItems.find(o => o.code === code);
1785
+ if (!match) {
1786
+ match = this.localOptionList.find(o => o.code === code);
1787
+ }
1788
+ if (match) {
1789
+ newSelectedItems.push(match);
1790
+ } else {
1791
+ newSelectedItems.push({ code, label: String(code) });
1792
+ }
1793
+ });
1794
+ this.selectedItems = newSelectedItems;
1795
+
1796
+ // Push the resolved full objects to the controller for non-repeater fields
1797
+ if (!this.allowMulti && this.config.name) {
1798
+ if (this.config.subType !== 'MULTIPLE') {
1799
+ const fullObj = this.selectedItems[0]?.value ?? null;
1800
+ this.controller.updateField(`_val_${this.config.name}`, fullObj);
1801
+ } else {
1802
+ const fullObjs = this.selectedItems.map(item => item.value).filter(Boolean);
1803
+ this.controller.updateField(`_val_${this.config.name}`, fullObjs);
1804
+ }
1805
+ }
1806
+ }
1807
+
1808
+ // ── Select All helpers ────────────────────────────────────────────────────
1809
+
1810
+ /** Returns true if every option in the current filtered list is selected. */
1811
+ isAllSelected(): boolean {
1812
+ if (!this.filteredOptionList.length) return false;
1813
+ const currentValue: any[] = Array.isArray(this.value) ? this.value : [];
1814
+ return this.filteredOptionList.every(opt => currentValue.includes(opt.code));
1815
+ }
1816
+
1817
+ /** Returns true if some (but not all) filtered options are selected — for indeterminate state. */
1818
+ isSomeSelected(): boolean {
1819
+ if (!this.filteredOptionList.length) return false;
1820
+ const currentValue: any[] = Array.isArray(this.value) ? this.value : [];
1821
+ const selectedCount = this.filteredOptionList.filter(opt => currentValue.includes(opt.code)).length;
1822
+ return selectedCount > 0 && selectedCount < this.filteredOptionList.length;
1823
+ }
1824
+
1825
+ /** Toggles selection of all filtered options. */
1826
+ toggleSelectAll(checked: boolean): void {
1827
+ if (!this.config.name) return;
1828
+ const currentValue: any[] = Array.isArray(this.value) ? [...this.value] : [];
1829
+ const filteredCodes = this.filteredOptionList.map(opt => opt.code);
1830
+
1831
+ let newValue: any[];
1832
+ if (checked) {
1833
+ // Add all filtered codes that are not already selected
1834
+ const toAdd = filteredCodes.filter(code => !currentValue.includes(code));
1835
+ newValue = [...currentValue, ...toAdd];
1836
+ } else {
1837
+ // Remove only filtered codes from the selection
1838
+ newValue = currentValue.filter(code => !filteredCodes.includes(code));
1839
+ }
1840
+ this.updateValue(newValue);
1841
+ }
1842
+
1843
+ // ── Type guards ──────────────────────────────────────────────────────────
1844
+
1845
+ get isTextField(): boolean { return this.config.type === 'TEXT_INPUT'; }
1846
+ get isNumberField(): boolean { return this.config.type === 'NUMBER_INPUT'; }
1847
+ get isDateField(): boolean { return this.config.type === 'DATE'; }
1848
+ get isTimeField(): boolean { return this.config.type === 'TIME'; }
1849
+ get isWheelVariant(): boolean {
1850
+ return this.config.type === 'TIME' && this.config.timeConfig?.variant === 'wheel';
1851
+ }
1852
+
1853
+ get timePickerLabels(): TimePickerLabels {
1854
+ const labels = this.controller.labels || {};
1855
+ return {
1856
+ selectTimeTitle: labels['TIME_PICKER.SELECT_TIME'],
1857
+ hourLabel: labels['TIME_PICKER.HOUR'],
1858
+ minuteLabel: labels['TIME_PICKER.MINUTE'],
1859
+ periodLabel: labels['TIME_PICKER.PERIOD'],
1860
+ amLabel: labels['TIME_PICKER.AM'],
1861
+ pmLabel: labels['TIME_PICKER.PM'],
1862
+ confirmLabel: labels['COMMON.ACTIONS.CONFIRM'],
1863
+ cancelLabel: labels['COMMON.ACTIONS.CANCEL'],
1864
+ };
1865
+ }
1866
+ get isDropdown(): boolean { return this.config.type === 'DROPDOWN'; }
1867
+ get isAutocomplete(): boolean { return this.config.type === 'AUTOCOMPLETE'; }
1868
+ get isFileUpload(): boolean { return this.config.type === 'FILE_UPLOAD'; }
1869
+ get isMediaUpload(): boolean { return this.config.type === 'MEDIA_UPLOAD'; }
1870
+ get isRadio(): boolean { return this.config.type === 'RADIO'; }
1871
+ get isCheckbox(): boolean { return this.config.type === 'CHECKBOX'; }
1872
+ get isChip(): boolean { return this.config.type === 'CHIP'; }
1873
+ get isSwitch(): boolean { return this.config.type === 'SWITCH'; }
1874
+ get isRating(): boolean { return this.config.type === 'RATING'; }
1875
+ get isRichText(): boolean { return this.config.type === 'RICH_TEXT'; }
1876
+ get isGenerated(): boolean { return this.config.type === 'GENERATED'; }
1877
+ get isRow(): boolean { return this.config.type === 'ROW'; }
1878
+ get isGroup(): boolean { return this.config.type === 'GROUP'; }
1879
+ get isLocation(): boolean { return this.config.type === 'LOCATION'; }
1880
+ get isLinkList(): boolean { return this.config.type === 'LINK_LIST'; }
1881
+
1882
+ // ── AUTOCOMPLETE helpers ────────────────────────────────────────────────
1883
+
1884
+ /**
1885
+ * Initialise the separate display-control that drives the mat-autocomplete
1886
+ * text input. The real form control always stores the *code* value.
1887
+ */
1888
+ private initAutocomplete(): void {
1889
+ // Sync display input whenever the real control value changes externally
1890
+ this._syncAutocompleteDisplayValue();
1891
+
1892
+ // Read search settings from autocompleteConfig (preferred) — no fallback to optionConfig needed
1893
+ const acConfig = this.config.autocompleteConfig;
1894
+ const searchParam = acConfig?.searchParam || acConfig?.searchKey;
1895
+ const minLength = acConfig?.searchMinLength ?? 1;
1896
+ const debounceMs = acConfig?.searchDebounce ?? (searchParam ? 300 : 150);
1897
+
1898
+ // Filter the option list as the user types
1899
+ this.autocompleteInputCtrl.valueChanges
1900
+ .pipe(
1901
+ debounceTime(debounceMs),
1902
+ distinctUntilChanged(),
1903
+ takeUntil(this.destroy$)
1904
+ )
1905
+ .subscribe(search => {
1906
+ const query = typeof search === 'string' ? search : '';
1907
+
1908
+ if (searchParam) {
1909
+ if (query.length >= minLength) {
1910
+ // Trigger server-side search merged with active dependencies
1911
+ const params = { ...this._latestDependencyValues, [searchParam]: query };
1912
+ this.loadDropdownOptions(params);
1913
+ } else {
1914
+ // If query is below min length, revert to existing full list if empty query
1915
+ this.filteredOptions = query.length === 0 ? [...this.localOptionList] : [];
1916
+ }
1917
+ } else {
1918
+ // Local filter
1919
+ this.filteredOptions = this._filterOptions(query);
1920
+ }
1921
+ });
1922
+
1923
+ // Initialise filtered list with all available options
1924
+ this.filteredOptions = [...this.localOptionList];
1925
+ }
1926
+
1927
+ /** Filter options by the user's search text (matches label or code). */
1928
+ private _filterOptions(search: string): { label: string; code: any }[] {
1929
+ const q = search.toLowerCase().trim();
1930
+ if (!q) return [...this.localOptionList];
1931
+ return this.localOptionList.filter(opt =>
1932
+ opt.label?.toLowerCase().includes(q) ||
1933
+ String(opt.code).toLowerCase().includes(q)
1934
+ );
1935
+ }
1936
+
1937
+ /** Put the human-readable label into the display control based on the stored code. */
1938
+ private _syncAutocompleteDisplayValue(): void {
1939
+ if (!this.config.name) return;
1940
+ const code = this.formGroup?.get(this.config.name)?.value;
1941
+ if (code === null || code === undefined || code === '') return;
1942
+ const matched = this.localOptionList.find(o => o.code === code);
1943
+ if (matched) {
1944
+ this.autocompleteInputCtrl.setValue(matched.label, { emitEvent: false });
1945
+ }
1946
+ }
1947
+
1948
+ /** Called when user picks an option from the mat-autocomplete panel. */
1949
+ onAutocompleteSelected(option: { label: string; code: any }): void {
1950
+ this.autocompleteInputCtrl.setValue(option.label, { emitEvent: false });
1951
+ this.updateValue(option.code);
1952
+ }
1953
+
1954
+ /** Called when the input loses focus — clear display & value if text was manually deleted. */
1955
+ onAutocompleteClear(): void {
1956
+ const display = (this.autocompleteInputCtrl.value || '').trim();
1957
+ if (!display) {
1958
+ this.updateValue(null);
1959
+ } else {
1960
+ // Restore label from stored code (prevents partial text staying)
1961
+ this._syncAutocompleteDisplayValue();
1962
+ }
1963
+ }
1964
+
1965
+ /**
1966
+ * Returns the effective grid column span for a child inside a ROW.
1967
+ * If the child declares an explicit colSpan, use it.
1968
+ * Otherwise divide 12 equally among all children (floor, min 1).
1969
+ */
1970
+ getChildColSpan(child: FieldConfig): number {
1971
+ if (child.colSpan) return child.colSpan;
1972
+ const count = this.config.children?.length || 1;
1973
+ return Math.max(1, Math.floor(12 / count));
1974
+ }
1975
+
1976
+ getOptionColSpan(option: any): number {
1977
+ const span = option.colSpan || option.colspan;
1978
+ if (span) return span;
1979
+ const count = this.localOptionList.length || 1;
1980
+ return Math.max(1, Math.floor(12 / count));
1981
+ }
1982
+
1983
+ // ── Rating helpers ───────────────────────────────────────────────────────
1984
+
1985
+ onRatingChange(star: number, event?: MouseEvent): void {
1986
+ if (!this.config.name || this.config.disabled) return;
1987
+ let newValue = star;
1988
+ if (this.config.ratingConfig?.allowHalf && event) {
1989
+ const target = event.target as HTMLElement;
1990
+ const rect = target.getBoundingClientRect();
1991
+ if (event.clientX - rect.left < rect.width / 2) newValue = star - 0.5;
1992
+ }
1993
+ if (this.value === newValue) newValue = 0;
1994
+ this.updateValue(newValue);
1995
+ }
1996
+
1997
+ getStarArray(): number[] {
1998
+ const max = this.config.ratingConfig?.maxRating || 5;
1999
+ return Array.from({ length: max }, (_, i) => i + 1);
2000
+ }
2001
+
2002
+ isStarHalf(star: number): boolean { return (this.value || 0) === star - 0.5; }
2003
+ isStarFilled(star: number): boolean { return (this.value || 0) >= star; }
2004
+
2005
+ // ── File Upload helpers ──────────────────────────────────────────────────
2006
+
2007
+ onDragOver(event: DragEvent): void {
2008
+ event.preventDefault();
2009
+ event.stopPropagation();
2010
+ this.isDragOver = true;
2011
+ }
2012
+
2013
+ onDragLeave(event: DragEvent): void {
2014
+ event.preventDefault();
2015
+ event.stopPropagation();
2016
+ this.isDragOver = false;
2017
+ }
2018
+
2019
+ onFileDrop(event: DragEvent): void {
2020
+ event.preventDefault();
2021
+ event.stopPropagation();
2022
+ this.isDragOver = false;
2023
+ const files = event.dataTransfer?.files;
2024
+ if (files && files.length > 0) {
2025
+ this.processFiles(files);
2026
+ }
2027
+ }
2028
+
2029
+ onFileSelected(event: Event): void {
2030
+ const input = event.target as HTMLInputElement;
2031
+ if (input.files && input.files.length > 0) {
2032
+ this.processFiles(input.files);
2033
+ }
2034
+ // Reset input so the same file can be re-selected if removed
2035
+ input.value = '';
2036
+ }
2037
+
2038
+ private processFiles(files: FileList): void {
2039
+ // ── MEDIA_UPLOAD branch — uses attachmentConfig, stores MediaItem[] ─────
2040
+ if (this.isMediaUpload) {
2041
+ const mediaCfg = this.config.attachmentConfig;
2042
+ if (!mediaCfg) return;
2043
+ const maxItems = mediaCfg.maxFiles ?? 10;
2044
+
2045
+ let errorShown = false;
2046
+
2047
+ Array.from(files).forEach(file => {
2048
+ if (this.mediaItems.length >= maxItems) {
2049
+ if (!errorShown) {
2050
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
2051
+ errorShown = true;
2052
+ }
2053
+ return;
2054
+ }
2055
+
2056
+ const placeholder: MediaItem = { mediaType: 'image', url: '', isUploading: true };
2057
+ this._appendMediaItem(placeholder);
2058
+ this.controller.fileAdded$.next(file);
2059
+
2060
+ const formData = new FormData();
2061
+ formData.append('file', file);
2062
+
2063
+ if (!mediaCfg.uploadUrl) return;
2064
+ let finalUrl = mediaCfg.uploadUrl;
2065
+ if (finalUrl.includes('{fileName}')) {
2066
+ finalUrl = finalUrl.replace('{fileName}', encodeURIComponent(file.name));
2067
+ }
2068
+
2069
+ this.http.post<any>(finalUrl, formData, { headers: this.getHeaders() })
2070
+ .pipe(takeUntil(this.destroy$))
2071
+ .subscribe({
2072
+ next: (response: any) => {
2073
+ // API returns { completeURL, id, mimeType, fileName, ... }
2074
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
2075
+ const current: MediaItem[] = [...this.mediaItems];
2076
+ const idx = current.indexOf(placeholder);
2077
+ if (idx !== -1) {
2078
+ current[idx] = {
2079
+ mediaType: 'image',
2080
+ url: resolvedUrl,
2081
+ id: response?.id,
2082
+ mimeType: response?.mimeType,
2083
+ fileName: response?.fileName ?? response?.name,
2084
+ isUploading: false
2085
+ };
2086
+ this.updateValue(current);
2087
+ this.mediaCarouselIndex = current.length - 1;
2088
+ this.controller.fileUploadFinished$.next(file);
2089
+ }
2090
+ },
2091
+ error: () => {
2092
+ const cleaned = this.mediaItems.filter(m => m !== placeholder);
2093
+ this.updateValue(cleaned.length ? cleaned : null);
2094
+ this.controller.fileUploadFinished$.next(file);
2095
+ }
2096
+ });
2097
+ });
2098
+ return;
2099
+ }
2100
+
2101
+ // ── FILE_UPLOAD branch — uses attachmentConfig, stores UploadedFile[] ────
2102
+ this.fileUploadError = '';
2103
+ const cfg = this.config.attachmentConfig;
2104
+ const maxSizeBytes = (cfg?.maxSizeMB ?? 10) * 1024 * 1024;
2105
+ const currentFiles: UploadedFile[] = (this.value as UploadedFile[]) || [];
2106
+ const maxFiles = cfg?.maxFiles ?? (cfg?.multiple ? 10 : 1);
2107
+ const isMultiple = cfg?.multiple ?? false;
2108
+
2109
+ const incoming = Array.from(files);
2110
+
2111
+ for (const file of incoming) {
2112
+ // File type validation
2113
+ if (cfg?.accept) {
2114
+ const accepted = cfg.accept.split(',').map((s: string) => s.trim().toLowerCase());
2115
+ const fileExt = '.' + file.name.toLowerCase().split('.').pop();
2116
+ const fileMime = (file.type || '').toLowerCase();
2117
+ const isAllowed = accepted.some((pattern: string) => {
2118
+ if (pattern.startsWith('.')) return fileExt === pattern;
2119
+ if (pattern.endsWith('/*')) return fileMime.startsWith(pattern.slice(0, -1));
2120
+ return fileMime === pattern;
2121
+ });
2122
+ if (!isAllowed) {
2123
+ this.fileUploadError = this._fileLabel('fileTypeError', '"{fileName}" is not a supported file type.', { fileName: file.name });
2124
+ continue;
2125
+ }
2126
+ }
2127
+ // Size validation
2128
+ if (file.size > maxSizeBytes) {
2129
+ this.fileUploadError = this._fileLabel('fileSizeError', '"{fileName}" exceeds the maximum allowed size of {maxSizeMB} MB.', { fileName: file.name, maxSizeMB: String(cfg?.maxSizeMB ?? 10) });
2130
+ continue;
2131
+ }
2132
+ // Max-files validation
2133
+ if (isMultiple && currentFiles.length >= maxFiles) {
2134
+ this.fileUploadError = this._fileLabel('maxFilesError', 'Maximum {maxFiles} files allowed.', { maxFiles: String(maxFiles) });
2135
+ break;
2136
+ }
2137
+
2138
+ if (cfg?.uploadUrl) {
2139
+ // ── API upload path ───────────────────────────────────────────────
2140
+ const entry: UploadedFile = {
2141
+ name: file.name,
2142
+ size: file.size,
2143
+ type: file.type,
2144
+ file,
2145
+ isUploading: true
2146
+ };
2147
+
2148
+ // Add the placeholder entry immediately so the UI shows the progress row
2149
+ const withPlaceholder = isMultiple
2150
+ ? [...(this.value as UploadedFile[] || []), entry]
2151
+ : [entry];
2152
+ this.updateValue(withPlaceholder);
2153
+ this.controller.fileAdded$.next(file);
2154
+
2155
+ const formData = new FormData();
2156
+ formData.append('file', file);
2157
+
2158
+ let finalUrl = cfg.uploadUrl;
2159
+ if (finalUrl.includes('{fileName}')) {
2160
+ finalUrl = finalUrl.replace('{fileName}', encodeURIComponent(file.name));
2161
+ }
2162
+
2163
+ this.http.post<any>(finalUrl, formData, { headers: this.getHeaders() })
2164
+ .pipe(takeUntil(this.destroy$))
2165
+ .subscribe({
2166
+ next: (response: any) => {
2167
+ // The API returns a JSON object; use completeURL if present,
2168
+ // otherwise fall back to treating the raw response as a URL string.
2169
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
2170
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
2171
+ const idx = current.indexOf(entry);
2172
+ if (idx !== -1) {
2173
+ const updatedEntry: UploadedFile = { ...entry, dataUrl: resolvedUrl, id: response?.id, isUploading: false };
2174
+ const updatedList = [...current];
2175
+ updatedList[idx] = updatedEntry;
2176
+ this.updateValue(updatedList);
2177
+ this.controller.fileUploadFinished$.next(file);
2178
+ }
2179
+ },
2180
+ error: () => {
2181
+ // Remove the failed placeholder from the list
2182
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
2183
+ const cleaned = current.filter(f => f !== entry);
2184
+ this.updateValue(cleaned.length ? cleaned : null);
2185
+ this.fileUploadError = this._fileLabel('fileUploadFailed', 'Failed to upload "{fileName}". Please try again.', { fileName: file.name });
2186
+ this.controller.fileUploadFinished$.next(file);
2187
+ }
2188
+ });
2189
+ } else {
2190
+ // ── FileReader (base64) fallback path ────────────────────────────
2191
+ const reader = new FileReader();
2192
+ reader.onload = (e) => {
2193
+ const entry: UploadedFile = {
2194
+ name: file.name,
2195
+ size: file.size,
2196
+ type: file.type,
2197
+ dataUrl: e.target?.result as string,
2198
+ file
2199
+ };
2200
+ const updated = isMultiple
2201
+ ? [...(this.value as UploadedFile[] || []), entry]
2202
+ : [entry];
2203
+ this.updateValue(updated);
2204
+ };
2205
+ reader.readAsDataURL(file);
2206
+ }
2207
+ }
2208
+ }
2209
+
2210
+ removeUploadedFile(index: number): void {
2211
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
2212
+ const fileToRemove = current[index];
2213
+ if (!fileToRemove) return;
2214
+
2215
+ const removeLocally = () => {
2216
+ const updated = current.filter((_, i) => i !== index);
2217
+ this.updateValue(updated);
2218
+ };
2219
+
2220
+ const deleteUrl = this.config.attachmentConfig?.deleteUrl;
2221
+ const { url: resolvedDeleteUrl, resolved } = deleteUrl
2222
+ ? this.resolveUrlPlaceholders(deleteUrl)
2223
+ : { url: '', resolved: false };
2224
+
2225
+ if (fileToRemove.id && fileToRemove.id !== 0 && deleteUrl && resolved) {
2226
+ fileToRemove.isUploading = true; // Use this flag to disable the remove button
2227
+ this.updateValue([...current]); // trigger UI update
2228
+
2229
+ let params = new HttpParams();
2230
+ if (!resolvedDeleteUrl.includes('attachmentId=')) {
2231
+ params = params.set('attachmentId', fileToRemove.id.toString());
2232
+ }
2233
+ if (!resolvedDeleteUrl.includes('reason=')) {
2234
+ // As requested in the API link, the reason is passed
2235
+ params = params.set('reason', 'DELETED BY CREATOR');
2236
+ }
2237
+
2238
+ this.http.delete<any>(resolvedDeleteUrl, { headers: this.getHeaders(), params })
2239
+ .pipe(takeUntil(this.destroy$))
2240
+ .subscribe({
2241
+ next: () => {
2242
+ removeLocally();
2243
+ this.controller.fileRemoved$.next(fileToRemove);
2244
+ },
2245
+ error: (err) => {
2246
+ console.error('Failed to delete attachment', err);
2247
+ fileToRemove.isUploading = false;
2248
+ this.fileUploadError = this._fileLabel('fileDeleteFailed', 'Failed to delete "{fileName}". Please try again.', { fileName: fileToRemove.name });
2249
+ this.updateValue([...current]);
2250
+ }
2251
+ });
2252
+ } else {
2253
+ // No id, no deleteUrl, or an unresolved URL placeholder (e.g. {entityId}
2254
+ // in CREATE mode): just remove locally, no server delete call.
2255
+ removeLocally();
2256
+ this.controller.fileRemoved$.next(fileToRemove);
2257
+ }
2258
+ }
2259
+
2260
+ getFileIcon(mimeType: string): string {
2261
+ if (!mimeType) return 'attach_file'; // guard: undefined/null from non-FILE_UPLOAD values
2262
+ if (mimeType.includes('pdf')) return 'picture_as_pdf';
2263
+ if (mimeType.includes('image')) return 'image';
2264
+ if (mimeType.includes('word') || mimeType.includes('document')) return 'description';
2265
+ if (mimeType.includes('sheet') || mimeType.includes('excel') || mimeType.includes('csv')) return 'table_chart';
2266
+ if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'folder_zip';
2267
+ return 'attach_file';
2268
+ }
2269
+
2270
+ formatFileSize(bytes: number): string {
2271
+ if (bytes < 1024) return `${bytes} B`;
2272
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
2273
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
2274
+ }
2275
+
2276
+ // ── Action Labels ──────────────────────────────────────────────────────────
2277
+
2278
+ get addLabel(): string {
2279
+ const key = this.controller.actionLabels?.addLabel || 'Add';
2280
+ return this.controller.labels[key] || key;
2281
+ }
2282
+
2283
+ get removeLabel(): string {
2284
+ const key = this.controller.actionLabels?.removeLabel || 'Remove';
2285
+ return this.controller.labels[key] || key;
2286
+ }
2287
+
2288
+ // ── MEDIA_UPLOAD helpers ─────────────────────────────────────────────────
2289
+
2290
+ get mediaItems(): MediaItem[] {
2291
+ return (this.value as MediaItem[]) || [];
2292
+ }
2293
+
2294
+ /** Number of active items (used to clamp carousel index) */
2295
+ get mediaCount(): number { return this.mediaItems.length; }
2296
+
2297
+ /** The currently visible carousel item */
2298
+ get activeMediaItem(): MediaItem | null {
2299
+ if (!this.mediaItems.length) return null;
2300
+ return this.mediaItems[Math.min(this.mediaCarouselIndex, this.mediaItems.length - 1)];
2301
+ }
2302
+
2303
+ /** Thumbnail strip items */
2304
+ get mediaThumbnails(): MediaItem[] { return this.mediaItems; }
2305
+
2306
+ mediaCarouselPrev(): void {
2307
+ if (this.mediaCarouselIndex > 0) this.mediaCarouselIndex--;
2308
+ }
2309
+
2310
+ mediaCarouselNext(): void {
2311
+ if (this.mediaCarouselIndex < this.mediaItems.length - 1) this.mediaCarouselIndex++;
2312
+ }
2313
+
2314
+ mediaGoTo(index: number): void {
2315
+ this.mediaCarouselIndex = index;
2316
+ }
2317
+
2318
+ // ── YouTube ──────────────────────────────────────────────────────────────
2319
+
2320
+ onMediaMenuVideo(): void {
2321
+ this.showMediaMenu = false;
2322
+ this.showYoutubeInput = !this.showYoutubeInput;
2323
+ this.youtubeUrlInput = '';
2324
+ this.youtubeUrlError = '';
2325
+ }
2326
+
2327
+ addYoutubeMedia(): void {
2328
+ const cfg = this.config.attachmentConfig;
2329
+ const maxItems = cfg?.maxFiles ?? 10;
2330
+ if (this.mediaItems.length >= maxItems) {
2331
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
2332
+ return;
2333
+ }
2334
+
2335
+ const url = this.youtubeUrlInput.trim();
2336
+ const videoId = this._extractYoutubeId(url);
2337
+ if (!videoId) {
2338
+ this.youtubeUrlError = this.controller.labels['ERR_INVALID_YOUTUBE_URL'] || 'Please enter a valid YouTube URL.';
2339
+ return;
2340
+ }
2341
+ const item: MediaItem = {
2342
+ mediaType: 'youtube',
2343
+ url: `https://www.youtube.com/embed/${videoId}`,
2344
+ thumbnailUrl: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
2345
+ };
2346
+ this._appendMediaItem(item);
2347
+ this.showYoutubeInput = false;
2348
+ this.youtubeUrlInput = '';
2349
+ this.youtubeUrlError = '';
2350
+ }
2351
+
2352
+ private _extractYoutubeId(url: string): string | null {
2353
+ const patterns = [
2354
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})/,
2355
+ /youtube\.com\/shorts\/([\w-]{11})/
2356
+ ];
2357
+ for (const p of patterns) {
2358
+ const m = url.match(p);
2359
+ if (m) return m[1];
2360
+ }
2361
+ return null;
2362
+ }
2363
+
2364
+ // ── Device upload ────────────────────────────────────────────────────────
2365
+
2366
+ onMediaMenuDevice(): void {
2367
+ this.showMediaMenu = false;
2368
+ this.showYoutubeInput = false;
2369
+ // Use setTimeout so Angular finishes closing the dropdown before the
2370
+ // browser opens the native file picker (the input lives outside *ngIf).
2371
+ setTimeout(() => this.mediaDeviceInput?.nativeElement?.click());
2372
+ }
2373
+
2374
+ onMediaFileSelected(event: Event): void {
2375
+ const input = event.target as HTMLInputElement;
2376
+ if (input.files && input.files.length > 0) {
2377
+ this.processFiles(input.files);
2378
+ }
2379
+ input.value = '';
2380
+ }
2381
+
2382
+ // ── Library helpers ──────────────────────────────────────────────────────
2383
+
2384
+ /**
2385
+ * Resolves the library-image sync POST endpoint from `attachmentConfig.libraryConfig.uploadConfig`.
2386
+ * Returns `null` when not configured; the caller skips the API call.
2387
+ */
2388
+ private buildLibraryUploadRequest(
2389
+ cfg: AttachmentConfig | undefined,
2390
+ runtimeContext: Record<string, any>
2391
+ ): { baseUrl: string; params: HttpParams } | null {
2392
+ const uploadConfig = cfg?.libraryConfig?.uploadConfig;
2393
+ if (!uploadConfig) return null;
2394
+
2395
+ const { baseUrl, params: paramDefs = [] } = uploadConfig;
2396
+ let params = new HttpParams();
2397
+
2398
+ for (const def of paramDefs) {
2399
+ let value: string | undefined;
2400
+
2401
+ // sourcePath takes precedence over staticValue
2402
+ if (def.sourcePath) {
2403
+ const resolved = def.sourcePath
2404
+ .split('.')
2405
+ .reduce((obj: Record<string, any>, k: string) => obj?.[k], runtimeContext);
2406
+ if (resolved !== null && resolved !== undefined) {
2407
+ value = String(resolved);
2408
+ }
2409
+ }
2410
+
2411
+ if (!value && def.staticValue) {
2412
+ value = def.staticValue;
2413
+ }
2414
+
2415
+ if (def.required && !value) {
2416
+ console.warn(`[SmartForm] libraryConfig.uploadConfig: required param "${def.key}" could not be resolved.`);
2417
+ return null;
2418
+ }
2419
+
2420
+ if (value) {
2421
+ params = params.set(def.key, value);
2422
+ }
2423
+ }
2424
+
2425
+ return { baseUrl, params };
2426
+ }
2427
+
2428
+ // ── Library picker ───────────────────────────────────────────────────────
2429
+
2430
+ /** Config object for the cc-confirmation-modal used as the library picker. */
2431
+ get libraryModalConfig(): ConfirmationModalConfig {
2432
+ const labels = this.controller.labels;
2433
+ return {
2434
+ title: labels['LBL_ADD_IMAGES'] || 'Add Images',
2435
+ headerTheme: 'dark' as const,
2436
+ size: 'lg',
2437
+ closeOnBackdrop: true,
2438
+ closeOnEsc: true,
2439
+ showCloseButton: true,
2440
+ cancelButton: {
2441
+ show: true,
2442
+ label: labels['LBL_CANCEL'] || 'Cancel'
2443
+ },
2444
+ confirmButton: {
2445
+ label: labels['LBL_CONTINUE'] || 'Continue',
2446
+ type: 'primary' as const
2447
+ }
2448
+ };
2449
+ }
2450
+
2451
+ onMediaMenuLibrary(): void {
2452
+ this.showMediaMenu = false;
2453
+ this.showYoutubeInput = false;
2454
+ this.libraryImages = [];
2455
+ this.librarySelectedIds = new Set();
2456
+ this.libraryError = '';
2457
+ this.showLibraryModal = true;
2458
+ this._loadLibraryImages();
2459
+ }
2460
+
2461
+ private _loadLibraryImages(): void {
2462
+ const cfg = this.config.attachmentConfig;
2463
+ const libCfg = cfg?.libraryConfig;
2464
+ if (!libCfg?.apiUrl) { this.libraryError = 'Library not configured.'; return; }
2465
+ this.libraryLoading = true;
2466
+ this.http.get<any>(libCfg.apiUrl, { headers: this.getHeaders() })
2467
+ .pipe(takeUntil(this.destroy$))
2468
+ .subscribe({
2469
+ next: (res: any) => {
2470
+ let data: any;
2471
+
2472
+ if (libCfg.dataPath) {
2473
+ // Try explicit dot-notation path configured by the dev (e.g. 'data.items')
2474
+ data = this.getValueByPath(res, libCfg.dataPath);
2475
+ }
2476
+
2477
+ // If no array was found at the configured path, try auto-detecting
2478
+ // the array from common structures
2479
+ if (!Array.isArray(data)) {
2480
+ if (Array.isArray(res)) {
2481
+ // API returned a plain array
2482
+ data = res;
2483
+ } else if (res && typeof res === 'object') {
2484
+ // API returned an object keyed by entity-id e.g. { "1": [...], "2": [...] }
2485
+ // Flatten all value-arrays into one list
2486
+ data = (Object.values(res) as any[][])
2487
+ .filter(v => Array.isArray(v))
2488
+ .reduce((acc, arr) => acc.concat(arr), []);
2489
+ }
2490
+ }
2491
+
2492
+ this.libraryImages = Array.isArray(data) ? data : [];
2493
+ this.libraryLoading = false;
2494
+ },
2495
+ error: () => {
2496
+ this.libraryError = this.controller.labels['ERR_LIBRARY_LOAD_FAILED'] || 'Failed to load library images.';
2497
+ this.libraryLoading = false;
2498
+ }
2499
+ });
2500
+ }
2501
+
2502
+ getLibraryItemUrl(item: any): string {
2503
+ const libCfg = this.config.attachmentConfig?.libraryConfig;
2504
+ return libCfg?.urlPath ? this.getValueByPath(item, libCfg.urlPath) : (item?.url || item?.completeURL || '');
2505
+ }
2506
+
2507
+ getLibraryItemId(item: any): any {
2508
+ const libCfg = this.config.attachmentConfig?.libraryConfig;
2509
+ return libCfg?.idPath ? this.getValueByPath(item, libCfg.idPath) : (item?.id ?? item);
2510
+ }
2511
+
2512
+ isLibraryItemSelected(item: any): boolean {
2513
+ return this.librarySelectedIds.has(this.getLibraryItemId(item));
2514
+ }
2515
+
2516
+ toggleLibraryItem(item: any): void {
2517
+ const id = this.getLibraryItemId(item);
2518
+ if (this.librarySelectedIds.has(id)) {
2519
+ this.librarySelectedIds.delete(id);
2520
+ } else {
2521
+ this.librarySelectedIds.add(id);
2522
+ }
2523
+ }
2524
+
2525
+ closeLibraryModal(): void {
2526
+ this.showLibraryModal = false;
2527
+ }
2528
+
2529
+ confirmLibrarySelection(): void {
2530
+ const selectedItems = this.libraryImages.filter(item => this.isLibraryItemSelected(item));
2531
+
2532
+ if (!selectedItems.length) {
2533
+ this.showLibraryModal = false;
2534
+ return;
2535
+ }
2536
+
2537
+ const cfg = this.config.attachmentConfig;
2538
+ const maxItems = cfg?.maxFiles ?? 10;
2539
+ // NOTE: track added count live — do NOT snapshot currentItems here
2540
+ let errorShown = false;
2541
+
2542
+ // Close modal immediately so UI feels responsive
2543
+ this.showLibraryModal = false;
2544
+
2545
+ selectedItems.forEach(item => {
2546
+ // Use live count — this.mediaItems grows as _appendMediaItem is called
2547
+ if (this.mediaItems.length >= maxItems) {
2548
+ if (!errorShown) {
2549
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
2550
+ errorShown = true;
2551
+ }
2552
+ return;
2553
+ }
2554
+ const libraryUrl = this.getLibraryItemUrl(item);
2555
+
2556
+ if (!libraryUrl) return;
2557
+
2558
+ const placeholder: MediaItem = {
2559
+ mediaType: 'image',
2560
+ url: libraryUrl,
2561
+ mimeType: item.mimeType,
2562
+ fileName: item.fileName ?? item.name,
2563
+ isUploading: true
2564
+ };
2565
+
2566
+ this._appendMediaItem(placeholder);
2567
+
2568
+ // Build the POST request using the structured config or legacy URL fallback.
2569
+ // The runtime context provides dynamic param values (e.g. the selected image URL).
2570
+ const request = this.buildLibraryUploadRequest(cfg, { libraryItem: { url: libraryUrl } });
2571
+
2572
+ if (request) {
2573
+ // For legacy fallback the completeUrl param is not yet on params — add it now.
2574
+ // For the structured config it is declared via sourcePath='libraryItem.url' so
2575
+ // it gets resolved inside buildLibraryUploadRequest and is already on params.
2576
+ let { baseUrl, params } = request;
2577
+ if (!params.has('completeUrl')) {
2578
+ params = params.set('completeUrl', libraryUrl);
2579
+ }
2580
+
2581
+ // POST with null body — all data travels as query params
2582
+ this.http.post<any>(baseUrl, null, { headers: this.getHeaders(), params })
2583
+ .pipe(takeUntil(this.destroy$))
2584
+ .subscribe({
2585
+ next: (response: any) => {
2586
+ const current: MediaItem[] = [...this.mediaItems];
2587
+ const idx = current.indexOf(placeholder);
2588
+ if (idx !== -1) {
2589
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
2590
+ current[idx] = {
2591
+ mediaType: 'image',
2592
+ url: resolvedUrl,
2593
+ id: response?.id,
2594
+ mimeType: response?.mimeType ?? placeholder.mimeType,
2595
+ fileName: response?.fileName ?? response?.name ?? placeholder.fileName,
2596
+ isUploading: false
2597
+ };
2598
+ this.updateValue(current);
2599
+ }
2600
+ },
2601
+ error: (err) => {
2602
+ console.error('Failed to sync library image to attachments:', err);
2603
+ const current: MediaItem[] = [...this.mediaItems];
2604
+ const cleaned = current.filter(m => m !== placeholder);
2605
+ this.updateValue(cleaned.length ? cleaned : null);
2606
+ }
2607
+ });
2608
+ } else {
2609
+ // No sync URL configured — use the library URL directly (local-only fallback)
2610
+ const current: MediaItem[] = [...this.mediaItems];
2611
+ const idx = current.indexOf(placeholder);
2612
+ if (idx !== -1) {
2613
+ current[idx] = { ...placeholder, isUploading: false };
2614
+ this.updateValue(current);
2615
+ }
2616
+ }
2617
+ });
2618
+
2619
+ this.mediaCarouselIndex = this.mediaItems.length - 1;
2620
+ }
2621
+
2622
+ // ── Remove item ──────────────────────────────────────────────────────────
2623
+
2624
+ removeMediaItem(index: number): void {
2625
+ const items: MediaItem[] = [...this.mediaItems];
2626
+ const item = items[index];
2627
+ if (!item) return;
2628
+
2629
+ const removeLocally = () => {
2630
+ const updated = items.filter((_, i) => i !== index);
2631
+ this.updateValue(updated.length ? updated : null);
2632
+ // Clamp carousel index so it never points past the end
2633
+ this.mediaCarouselIndex = Math.max(0, this.mediaCarouselIndex - (index <= this.mediaCarouselIndex ? 1 : 0));
2634
+ };
2635
+
2636
+ const deleteUrl = this.config.attachmentConfig?.deleteUrl;
2637
+ const { url: resolvedDeleteUrl, resolved } = deleteUrl
2638
+ ? this.resolveUrlPlaceholders(deleteUrl)
2639
+ : { url: '', resolved: false };
2640
+
2641
+ if (item.id && item.id !== 0 && deleteUrl && resolved) {
2642
+ // ── API delete path (same logic as removeUploadedFile) ───────────────
2643
+ item.isUploading = true; // disable the remove button while in-flight
2644
+ this.updateValue([...items]); // trigger change detection
2645
+
2646
+ let params = new HttpParams();
2647
+ if (!resolvedDeleteUrl.includes('attachmentId=')) {
2648
+ params = params.set('attachmentId', item.id.toString());
2649
+ }
2650
+ if (!resolvedDeleteUrl.includes('reason=')) {
2651
+ params = params.set('reason', 'DELETED BY CREATOR');
2652
+ }
2653
+
2654
+ this.http.delete<any>(resolvedDeleteUrl, { headers: this.getHeaders(), params })
2655
+ .pipe(takeUntil(this.destroy$))
2656
+ .subscribe({
2657
+ next: () => {
2658
+ removeLocally();
2659
+ this.controller.fileRemoved$.next(item);
2660
+ },
2661
+ error: (err) => {
2662
+ console.error('Failed to delete media attachment', err);
2663
+ item.isUploading = false;
2664
+ this.updateValue([...items]); // restore UI state
2665
+ }
2666
+ });
2667
+ } else {
2668
+ // ── No id, no deleteUrl, or an unresolved URL placeholder (e.g.
2669
+ // {entityId} in CREATE mode before the entity exists): just remove
2670
+ // the item from the form — no server delete call. ─────────────────
2671
+ removeLocally();
2672
+ this.controller.fileRemoved$.next(item);
2673
+ }
2674
+ }
2675
+
2676
+
2677
+ private _appendMediaItem(item: MediaItem): void {
2678
+ const current: MediaItem[] = [...this.mediaItems];
2679
+ current.push(item);
2680
+ this.updateValue(current);
2681
+ this.mediaCarouselIndex = current.length - 1;
2682
+ }
2683
+
2684
+ private showMediaError(msg: string): void {
2685
+ this.mediaUploadError = msg;
2686
+ setTimeout(() => {
2687
+ if (this.mediaUploadError === msg) this.mediaUploadError = '';
2688
+ }, 4000);
2689
+ }
2690
+
2691
+ // ── LOCATION field methods ─────────────────────────────────────────────────
2692
+
2693
+ private initLocationField(): void {
2694
+ const cfg = this.config.locationConfig;
2695
+ this.locationActiveTab = cfg?.defaultTab ?? 'VENUE';
2696
+
2697
+ // Restore from existing form value using the robust getter
2698
+ if (this.locationValue.tab) {
2699
+ this.locationActiveTab = this.locationValue.tab;
2700
+ }
2701
+
2702
+ // Subscribe to external patchValue events (prefills)
2703
+ this.formGroup.get(this.config.name!)?.valueChanges
2704
+ .pipe(takeUntil(this.destroy$))
2705
+ .subscribe(() => {
2706
+ if (this.locationValue.tab) {
2707
+ this.locationActiveTab = this.locationValue.tab;
2708
+ }
2709
+ setTimeout(() => this._renderMap());
2710
+ });
2711
+
2712
+ // Load Google Maps script if not already loaded
2713
+ this._ensureGoogleMapsScript();
2714
+ }
2715
+
2716
+ private _ensureGoogleMapsScript(): void {
2717
+ const apiKey = this.config.locationConfig?.googleMapsApiKey;
2718
+ if (!apiKey) return;
2719
+
2720
+ if ((window as any).google?.maps?.places) {
2721
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
2722
+ this.locationMapLoaded = true;
2723
+ setTimeout(() => this._renderMap(), 100);
2724
+ return;
2725
+ }
2726
+
2727
+ // Only inject once
2728
+ if (document.querySelector('script[data-sf-gmaps]')) {
2729
+ // Wait for the script to load
2730
+ const wait = () => {
2731
+ if ((window as any).google?.maps?.places) {
2732
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
2733
+ this.locationMapLoaded = true;
2734
+ setTimeout(() => this._renderMap(), 100);
2735
+ } else {
2736
+ setTimeout(wait, 300);
2737
+ }
2738
+ };
2739
+ wait();
2740
+ return;
2741
+ }
2742
+
2743
+ const script = document.createElement('script');
2744
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
2745
+ script.async = true;
2746
+ script.defer = true;
2747
+ script.setAttribute('data-sf-gmaps', 'true');
2748
+ script.onload = () => {
2749
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
2750
+ this.locationMapLoaded = true;
2751
+ setTimeout(() => this._renderMap(), 100);
2752
+ };
2753
+ document.head.appendChild(script);
2754
+ }
2755
+
2756
+ onLocationTabChange(tab: string): void {
2757
+ if (this.locationActiveTab === 'VENUE' && tab !== 'VENUE') {
2758
+ this._googleMap = null;
2759
+ this._mapMarkers = [];
2760
+ }
2761
+ this.locationActiveTab = tab;
2762
+ this.locationSuggestions = [];
2763
+ this.locationShowSuggestions = false;
2764
+ this.locationSearchText = '';
2765
+ const existing: LocationFieldValue = this.formGroup.get(this.config.name!)?.value || { tab, venues: [], onlineUrl: '' };
2766
+ existing.tab = tab;
2767
+ this.updateValue(existing);
2768
+ if (tab === 'VENUE') {
2769
+ setTimeout(() => this._renderMap());
2770
+ }
2771
+ }
2772
+
2773
+ get locationValue(): LocationFieldValue {
2774
+ let v = this.formGroup.get(this.config.name!)?.value;
2775
+ if (typeof v === 'string') {
2776
+ try { v = JSON.parse(v); } catch { /* ignore */ }
2777
+ }
2778
+ // Automatically unwrap if prefill payload was mistakenly double-nested like `{ location: { tab... } }`
2779
+ if (v && !v.tab && v[this.config.name!]) {
2780
+ v = v[this.config.name!];
2781
+ }
2782
+ if (v && v.tab) return v;
2783
+ return { tab: this.locationActiveTab, venues: [], onlineUrl: '' };
2784
+ }
2785
+
2786
+ get locationVenues(): LocationItem[] {
2787
+ return this.locationValue.venues || [];
2788
+ }
2789
+
2790
+ get locationOnlineUrl(): string {
2791
+ return this.locationValue.onlineUrl || '';
2792
+ }
2793
+
2794
+ get locationMaxReached(): boolean {
2795
+ const isMulti = this.config.locationConfig?.allowMulti;
2796
+ const max = isMulti ? (this.config.locationConfig?.maxLocations ?? 5) : 1;
2797
+ return this.locationVenues.length >= max;
2798
+ }
2799
+
2800
+ handleLocationSearchInput(event: Event): void {
2801
+ const input = (event.target as HTMLInputElement).value;
2802
+ this.locationSearchText = input;
2803
+ if (!input || input.length < 2) {
2804
+ this.locationSuggestions = [];
2805
+ this.locationShowSuggestions = false;
2806
+ return;
2807
+ }
2808
+ if (!this._googleAcService) {
2809
+ return;
2810
+ }
2811
+ this._googleAcService.getPlacePredictions({ input }, (predictions: any[], status: string) => {
2812
+ if (status === 'OK' && predictions) {
2813
+ this.locationSuggestions = predictions;
2814
+ this.locationShowSuggestions = true;
2815
+ } else {
2816
+ this.locationSuggestions = [];
2817
+ this.locationShowSuggestions = false;
2818
+ }
2819
+ });
2820
+ }
2821
+
2822
+ onLocationSuggestionSelect(prediction: any): void {
2823
+ if (!prediction) return;
2824
+ const isMulti = this.config.locationConfig?.allowMulti;
2825
+ const max = isMulti ? (this.config.locationConfig?.maxLocations ?? 5) : 1;
2826
+ const current = this.locationVenues;
2827
+ if (current.length >= max) return;
2828
+
2829
+ const newItem: LocationItem = {
2830
+ description: prediction.description,
2831
+ placeId: prediction.place_id,
2832
+ address: prediction.description,
2833
+ type: 'GOOGLE',
2834
+ isActive: true
2835
+ };
2836
+
2837
+ // Geocode to get lat/lng and components
2838
+ if ((window as any).google?.maps?.Geocoder) {
2839
+ const geocoder = new (window as any).google.maps.Geocoder();
2840
+ geocoder.geocode({ placeId: prediction.place_id }, (results: any[], status: string) => {
2841
+ if (status === 'OK' && results[0]) {
2842
+ const res = results[0];
2843
+ newItem.latitude = res.geometry?.location?.lat();
2844
+ newItem.longitude = res.geometry?.location?.lng();
2845
+ newItem.address = res.formatted_address || prediction.description;
2846
+
2847
+ let name = prediction.description;
2848
+ if (prediction.structured_formatting && prediction.structured_formatting.main_text) {
2849
+ name = prediction.structured_formatting.main_text;
2850
+ }
2851
+ newItem.name = name;
2852
+
2853
+ res.address_components?.forEach((c: any) => {
2854
+ if (c.types.includes('locality')) newItem.cityLabel = c.long_name;
2855
+ if (c.types.includes('administrative_area_level_1')) newItem.stateLabel = c.long_name;
2856
+ if (c.types.includes('country')) newItem.countryLabel = c.long_name;
2857
+ });
2858
+ }
2859
+ this._addVenueAndUpdate(newItem);
2860
+ });
2861
+ } else {
2862
+ this._addVenueAndUpdate(newItem);
2863
+ }
2864
+
2865
+ this.locationSearchText = '';
2866
+ this.locationSuggestions = [];
2867
+ this.locationShowSuggestions = false;
2868
+ }
2869
+
2870
+ private _addVenueAndUpdate(item: LocationItem): void {
2871
+ const val = { ...this.locationValue };
2872
+ val.venues = [...(val.venues || []), item];
2873
+ this.updateValue(val);
2874
+ setTimeout(() => this._renderMap());
2875
+ }
2876
+
2877
+ removeLocationVenue(index: number): void {
2878
+ const val = { ...this.locationValue };
2879
+ val.venues = (val.venues || []).filter((_, i) => i !== index);
2880
+ this.updateValue(val);
2881
+ setTimeout(() => this._renderMap());
2882
+ }
2883
+
2884
+ onLocationUrlChange(url: string): void {
2885
+ const val = { ...this.locationValue, onlineUrl: url };
2886
+ this.updateValue(val);
2887
+ }
2888
+
2889
+ hideLocationSuggestions(): void {
2890
+ setTimeout(() => { this.locationShowSuggestions = false; }, 200);
2891
+ }
2892
+
2893
+ getLocationMapEmbedUrl(): string {
2894
+ const venues = this.locationVenues.filter(v => v.placeId || (typeof v.latitude === 'number' && typeof v.longitude === 'number'));
2895
+ const apiKey = this.config.locationConfig?.googleMapsApiKey || '';
2896
+
2897
+ // No locations currently selected: Show the default map view (India)
2898
+ if (!venues.length) {
2899
+ if (apiKey) {
2900
+ // Embed API center fallback if they have an API Key but no marker yet
2901
+ const lat = this.config.locationConfig?.defaultLat ?? 20.5937;
2902
+ const lng = this.config.locationConfig?.defaultLng ?? 78.9629;
2903
+ const zoom = this.config.locationConfig?.defaultZoom ?? 4;
2904
+ return `https://www.google.com/maps/embed/v1/view?key=${apiKey}&center=${lat},${lng}&zoom=${zoom}`;
2905
+ } else {
2906
+ // Static iframe fallback text query
2907
+ return `https://maps.google.com/maps?q=India&output=embed`;
2908
+ }
2909
+ }
2910
+
2911
+ if (venues.length === 1) {
2912
+ const v = venues[0];
2913
+ const q = v.placeId ? `place_id:${v.placeId}` : encodeURIComponent(v.address || v.description || '');
2914
+ return `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${q}`;
2915
+ }
2916
+ // Multiple locations — use directions/search embed
2917
+ const q = encodeURIComponent(venues.map(v => v.address || v.description).join('|'));
2918
+ return `https://www.google.com/maps/embed/v1/search?key=${apiKey}&q=${q}`;
2919
+ }
2920
+
2921
+ private _renderMap(): void {
2922
+ if (!(window as any).google?.maps) return;
2923
+ if (!this.config.locationConfig?.showMap && this.config.locationConfig?.showMap !== undefined) return;
2924
+
2925
+ const venues = this.locationVenues.filter(v => typeof v.latitude === 'number' && typeof v.longitude === 'number');
2926
+
2927
+ const mapEl = document.getElementById(`loc-map-${this.config.name}`);
2928
+ if (!mapEl) return;
2929
+
2930
+ if (!this._googleMap) {
2931
+ // Default to India if no venues exist
2932
+ const defaultLat = this.config.locationConfig?.defaultLat ?? 20.5937;
2933
+ const defaultLng = this.config.locationConfig?.defaultLng ?? 78.9629;
2934
+ const defaultZoom = this.config.locationConfig?.defaultZoom ?? 4;
2935
+
2936
+ this._googleMap = new (window as any).google.maps.Map(mapEl, {
2937
+ zoom: venues.length === 1 ? 12 : defaultZoom,
2938
+ center: venues.length > 0
2939
+ ? { lat: venues[0].latitude, lng: venues[0].longitude }
2940
+ : { lat: defaultLat, lng: defaultLng }
2941
+ });
2942
+ } else {
2943
+ if (venues.length === 0) {
2944
+ // Re-center on default
2945
+ const lat = this.config.locationConfig?.defaultLat ?? 20.5937;
2946
+ const lng = this.config.locationConfig?.defaultLng ?? 78.9629;
2947
+ this._googleMap.setCenter({ lat, lng });
2948
+ this._googleMap.setZoom(this.config.locationConfig?.defaultZoom ?? 4);
2949
+ } else if (venues.length === 1) {
2950
+ this._googleMap.setCenter({ lat: venues[0].latitude, lng: venues[0].longitude });
2951
+ this._googleMap.setZoom(12);
2952
+ }
2953
+ }
2954
+
2955
+ // Clear old markers
2956
+ this._mapMarkers.forEach(m => m.setMap(null));
2957
+ this._mapMarkers = [];
2958
+
2959
+ venues.forEach(v => {
2960
+ const marker = new (window as any).google.maps.Marker({
2961
+ position: { lat: v.latitude, lng: v.longitude },
2962
+ map: this._googleMap,
2963
+ title: v.name || v.address || v.description
2964
+ });
2965
+ this._mapMarkers.push(marker);
2966
+ });
2967
+
2968
+ if (venues.length > 1) {
2969
+ const bounds = new (window as any).google.maps.LatLngBounds();
2970
+ venues.forEach(v => bounds.extend({ lat: v.latitude!, lng: v.longitude! }));
2971
+ this._googleMap.fitBounds(bounds);
2972
+ }
2973
+ }
2974
+
2975
+ // ── LINK_LIST helpers ──────────────────────────────────────────────────
2976
+ get linkListConfig(): LinkListConfig | undefined {
2977
+ return this.config.linkListConfig;
2978
+ }
2979
+
2980
+ get linkListItems(): any[] {
2981
+ const format = this.linkListConfig?.valueFormat || 'string';
2982
+ const val = this.value;
2983
+ if (!val) return [];
2984
+
2985
+ if (format === 'string') {
2986
+ if (typeof val === 'string') {
2987
+ const sep = this.linkListConfig?.separator || ',';
2988
+ return val.split(sep).map(s => s.trim()).filter(Boolean);
2989
+ }
2990
+ return [];
2991
+ } else if (format === 'array') {
2992
+ return Array.isArray(val) ? val : [];
2993
+ } else if (format === 'object') {
2994
+ return Array.isArray(val) ? val : [];
2995
+ }
2996
+ return [];
2997
+ }
2998
+
2999
+ getLinkDisplayUrl(item: any): string {
3000
+ if (!item) return '';
3001
+ const format = this.linkListConfig?.valueFormat || 'string';
3002
+ if (format === 'object') {
3003
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
3004
+ return item[urlKey] || '';
3005
+ }
3006
+ return String(item);
3007
+ }
3008
+
3009
+ addLinkItem(event?: Event): void {
3010
+ if (event) {
3011
+ event.preventDefault();
3012
+ event.stopPropagation();
3013
+ }
3014
+ const val = this.newLinkInputValue?.trim();
3015
+ if (!val) return;
3016
+
3017
+ // Normalize URL
3018
+ const formattedUrl = /^https?:\/\//i.test(val) ? val : 'https://' + val;
3019
+
3020
+ // Validate URL pattern
3021
+ const patternStr = this.linkListConfig?.pattern || '^https?://([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}(/\\S*)?$';
3022
+ try {
3023
+ const regex = new RegExp(patternStr, 'i');
3024
+ if (!regex.test(formattedUrl)) {
3025
+ this.linkValidationError = this.linkListConfig?.patternMessage || this.controller.labels['COMMON.INVALID_URL'] || 'Invalid URL format';
3026
+ return;
3027
+ }
3028
+ } catch (e) {
3029
+ console.error('Invalid pattern regex in LinkListConfig', e);
3030
+ }
3031
+
3032
+ this.linkValidationError = '';
3033
+
3034
+ const format = this.linkListConfig?.valueFormat || 'string';
3035
+ const currentItems = [...this.linkListItems];
3036
+
3037
+ if (format === 'string') {
3038
+ currentItems.push(formattedUrl);
3039
+ const sep = this.linkListConfig?.separator || ',';
3040
+ this.updateValue(currentItems.join(sep));
3041
+ } else if (format === 'array') {
3042
+ currentItems.push(formattedUrl);
3043
+ this.updateValue(currentItems);
3044
+ } else if (format === 'object') {
3045
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
3046
+ currentItems.push({ [urlKey]: formattedUrl, id: 0 });
3047
+ this.updateValue(currentItems);
3048
+ }
3049
+
3050
+ this.newLinkInputValue = '';
3051
+ }
3052
+
3053
+ deleteLinkItem(index: number): void {
3054
+ const format = this.linkListConfig?.valueFormat || 'string';
3055
+ const currentItems = [...this.linkListItems];
3056
+ const itemToRemove = currentItems[index];
3057
+
3058
+ const deleteLinkLocal = () => {
3059
+ currentItems.splice(index, 1);
3060
+ if (format === 'string') {
3061
+ const sep = this.linkListConfig?.separator || ',';
3062
+ this.updateValue(currentItems.length > 0 ? currentItems.join(sep) : null);
3063
+ } else if (format === 'array') {
3064
+ this.updateValue(currentItems.length > 0 ? currentItems : null);
3065
+ } else if (format === 'object') {
3066
+ this.updateValue(currentItems.length > 0 ? currentItems : null);
3067
+ }
3068
+ };
3069
+
3070
+ const deleteApiUrl = this.linkListConfig?.deleteApiUrl;
3071
+ if (format === 'object' && itemToRemove && itemToRemove.id && deleteApiUrl) {
3072
+ let resolvedUrl = deleteApiUrl;
3073
+ const idField = this.linkListConfig?.idField || 'id';
3074
+ const itemId = itemToRemove[idField] || '';
3075
+
3076
+ if (resolvedUrl.includes(':id')) {
3077
+ resolvedUrl = resolvedUrl.replace(':id', String(itemId));
3078
+ } else if (resolvedUrl.includes('{id}')) {
3079
+ resolvedUrl = resolvedUrl.replace('{id}', String(itemId));
3080
+ }
3081
+
3082
+ this.http.delete<any>(resolvedUrl, { headers: this.getHeaders() }).subscribe({
3083
+ next: () => {
3084
+ deleteLinkLocal();
3085
+ },
3086
+ error: (err) => {
3087
+ console.error('Failed to delete link via API', err);
3088
+ }
3089
+ });
3090
+ } else {
3091
+ deleteLinkLocal();
3092
+ }
3093
+ }
3094
+
3095
+ startEditLink(index: number): void {
3096
+ this.editingLinkIndex = index;
3097
+ const item = this.linkListItems[index];
3098
+ this.editingLinkValue = this.getLinkDisplayUrl(item);
3099
+ }
3100
+
3101
+ cancelEditLink(): void {
3102
+ this.editingLinkIndex = null;
3103
+ this.editingLinkValue = '';
3104
+ }
3105
+
3106
+ saveEditLink(index: number): void {
3107
+ const val = this.editingLinkValue?.trim();
3108
+ if (!val) return;
3109
+
3110
+ const formattedUrl = /^https?:\/\//i.test(val) ? val : 'https://' + val;
3111
+
3112
+ // Validate URL pattern
3113
+ const patternStr = this.linkListConfig?.pattern || '^https?://([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}(/\\S*)?$';
3114
+ try {
3115
+ const regex = new RegExp(patternStr, 'i');
3116
+ if (!regex.test(formattedUrl)) {
3117
+ this.linkValidationError = this.linkListConfig?.patternMessage || this.controller.labels['COMMON.INVALID_URL'] || 'Invalid URL format';
3118
+ return;
3119
+ }
3120
+ } catch (e) {
3121
+ console.error('Invalid pattern regex in LinkListConfig', e);
3122
+ }
3123
+
3124
+ this.linkValidationError = '';
3125
+
3126
+ const format = this.linkListConfig?.valueFormat || 'string';
3127
+ const currentItems = [...this.linkListItems];
3128
+ const itemToEdit = currentItems[index];
3129
+
3130
+ const updateLinkLocal = () => {
3131
+ if (format === 'string') {
3132
+ currentItems[index] = formattedUrl;
3133
+ const sep = this.linkListConfig?.separator || ',';
3134
+ this.updateValue(currentItems.join(sep));
3135
+ } else if (format === 'array') {
3136
+ currentItems[index] = formattedUrl;
3137
+ this.updateValue(currentItems);
3138
+ } else if (format === 'object') {
3139
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
3140
+ currentItems[index] = { ...itemToEdit, [urlKey]: formattedUrl };
3141
+ this.updateValue(currentItems);
3142
+ }
3143
+ this.cancelEditLink();
3144
+ };
3145
+
3146
+ const editApiUrl = this.linkListConfig?.editApiUrl;
3147
+ if (format === 'object' && itemToEdit && itemToEdit.id && editApiUrl) {
3148
+ let resolvedUrl = editApiUrl;
3149
+ const idField = this.linkListConfig?.idField || 'id';
3150
+ const itemId = itemToEdit[idField] || '';
3151
+
3152
+ if (resolvedUrl.includes(':id')) {
3153
+ resolvedUrl = resolvedUrl.replace(':id', String(itemId));
3154
+ } else if (resolvedUrl.includes('{id}')) {
3155
+ resolvedUrl = resolvedUrl.replace('{id}', String(itemId));
3156
+ }
3157
+
3158
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
3159
+ const body = { ...itemToEdit, [urlKey]: formattedUrl };
3160
+
3161
+ this.http.put<any>(resolvedUrl, body, { headers: this.getHeaders() }).subscribe({
3162
+ next: () => {
3163
+ updateLinkLocal();
3164
+ },
3165
+ error: (err) => {
3166
+ console.error('Failed to edit link via API', err);
3167
+ }
3168
+ });
3169
+ } else {
3170
+ updateLinkLocal();
3171
+ }
3172
+ }
3173
+ }
3174
+