platformcommons-web-lib 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/commons-shared-web-ui-1.0.0.tgz +0 -0
  2. package/documentation/alert.md +123 -0
  3. package/documentation/button-dropdown.md +126 -0
  4. package/documentation/button.md +184 -0
  5. package/documentation/cards-usage-guidelines.md +131 -0
  6. package/documentation/configurable-form.md +605 -0
  7. package/documentation/confirmation-modal.md +250 -0
  8. package/documentation/filter-sidebar.md +178 -0
  9. package/documentation/filter-table-selector.md +228 -0
  10. package/documentation/form-builder.md +597 -0
  11. package/documentation/form-components.md +384 -0
  12. package/documentation/nav.md +427 -0
  13. package/documentation/pagination.md +181 -0
  14. package/documentation/side-nav-documentation.md +169 -0
  15. package/documentation/smart-form.md +2177 -0
  16. package/documentation/smart-table.md +1198 -0
  17. package/documentation/snackbar.md +118 -0
  18. package/documentation/style-externalization.md +88 -0
  19. package/documentation/summary-card.md +279 -0
  20. package/ng-package.json +28 -0
  21. package/package.json +54 -0
  22. package/src/lib/modules/alert/alert.models.ts +6 -0
  23. package/src/lib/modules/alert/alert.module.ts +16 -0
  24. package/src/lib/modules/alert/alert.theme.scss +85 -0
  25. package/src/lib/modules/alert/components/alert/alert.component.html +27 -0
  26. package/src/lib/modules/alert/components/alert/alert.component.scss +92 -0
  27. package/src/lib/modules/alert/components/alert/alert.component.ts +81 -0
  28. package/src/lib/modules/button/button.models.ts +13 -0
  29. package/src/lib/modules/button/button.module.ts +16 -0
  30. package/src/lib/modules/button/button.theme.scss +121 -0
  31. package/src/lib/modules/button/components/button/button.component.html +22 -0
  32. package/src/lib/modules/button/components/button/button.component.scss +88 -0
  33. package/src/lib/modules/button/components/button/button.component.ts +67 -0
  34. package/src/lib/modules/button-dropdown/button-dropdown.models.ts +26 -0
  35. package/src/lib/modules/button-dropdown/button-dropdown.module.ts +22 -0
  36. package/src/lib/modules/button-dropdown/button-dropdown.theme.scss +87 -0
  37. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.html +41 -0
  38. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.scss +135 -0
  39. package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.ts +160 -0
  40. package/src/lib/modules/configurable-form/component/configurable-form.component.html +294 -0
  41. package/src/lib/modules/configurable-form/component/configurable-form.component.scss +503 -0
  42. package/src/lib/modules/configurable-form/component/configurable-form.component.ts +628 -0
  43. package/src/lib/modules/configurable-form/configurable-form.examples.ts +154 -0
  44. package/src/lib/modules/configurable-form/configurable-form.model.ts +131 -0
  45. package/src/lib/modules/configurable-form/configurable-form.module.ts +19 -0
  46. package/src/lib/modules/configurable-form/configurable-form.theme.scss +78 -0
  47. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.html +77 -0
  48. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.scss +395 -0
  49. package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.ts +266 -0
  50. package/src/lib/modules/confirmation-modal/confirmation-modal.models.ts +71 -0
  51. package/src/lib/modules/confirmation-modal/confirmation-modal.module.ts +20 -0
  52. package/src/lib/modules/confirmation-modal/confirmation-modal.theme.scss +87 -0
  53. package/src/lib/modules/filter/components/filter/filter.component.html +131 -0
  54. package/src/lib/modules/filter/components/filter/filter.component.scss +245 -0
  55. package/src/lib/modules/filter/components/filter/filter.component.ts +216 -0
  56. package/src/lib/modules/filter/filter.models.ts +88 -0
  57. package/src/lib/modules/filter/filter.module.ts +24 -0
  58. package/src/lib/modules/filter/filter.theme.scss +92 -0
  59. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.html +112 -0
  60. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.scss +186 -0
  61. package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.ts +163 -0
  62. package/src/lib/modules/filter-sidebar/filter-sidebar.models.ts +95 -0
  63. package/src/lib/modules/filter-sidebar/filter-sidebar.module.ts +24 -0
  64. package/src/lib/modules/filter-sidebar/filter-sidebar.theme.scss +38 -0
  65. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.html +73 -0
  66. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.scss +321 -0
  67. package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.ts +361 -0
  68. package/src/lib/modules/filter-table-selector/filter-table-selector.models.ts +91 -0
  69. package/src/lib/modules/filter-table-selector/filter-table-selector.module.ts +22 -0
  70. package/src/lib/modules/filter-table-selector/filter-table-selector.theme.scss +36 -0
  71. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.html +63 -0
  72. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.scss +496 -0
  73. package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.ts +445 -0
  74. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.html +75 -0
  75. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.scss +210 -0
  76. package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.ts +55 -0
  77. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.html +25 -0
  78. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.scss +82 -0
  79. package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.ts +95 -0
  80. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.html +20 -0
  81. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.scss +37 -0
  82. package/src/lib/modules/form-builder/components/field-selection/field-selection.component.ts +94 -0
  83. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.html +46 -0
  84. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.scss +102 -0
  85. package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.ts +50 -0
  86. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.html +35 -0
  87. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.scss +67 -0
  88. package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.ts +34 -0
  89. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.html +68 -0
  90. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.scss +113 -0
  91. package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.ts +74 -0
  92. package/src/lib/modules/form-builder/configs/field-type-schema.map.ts +533 -0
  93. package/src/lib/modules/form-builder/form-builder.module.ts +36 -0
  94. package/src/lib/modules/form-builder/form-builder.theme.scss +212 -0
  95. package/src/lib/modules/form-builder/index.ts +9 -0
  96. package/src/lib/modules/form-builder/models/builder.models.ts +7 -0
  97. package/src/lib/modules/form-builder/models/field-configurator.models.ts +38 -0
  98. package/src/lib/modules/form-builder/models/field-selection.models.ts +51 -0
  99. package/src/lib/modules/form-builder/services/field-configurator.service.ts +258 -0
  100. package/src/lib/modules/form-builder/services/field-selection.service.ts +300 -0
  101. package/src/lib/modules/form-builder/services/form-schema-tree.service.ts +652 -0
  102. package/src/lib/modules/form-builder/tokens/builder.tokens.ts +10 -0
  103. package/src/lib/modules/form-builder/utils/constants.ts +43 -0
  104. package/src/lib/modules/form-components/components/checkbox/_theme.scss +63 -0
  105. package/src/lib/modules/form-components/components/checkbox/checkbox.component.html +29 -0
  106. package/src/lib/modules/form-components/components/checkbox/checkbox.component.scss +111 -0
  107. package/src/lib/modules/form-components/components/checkbox/checkbox.component.ts +207 -0
  108. package/src/lib/modules/form-components/components/checkbox/checkbox.models.ts +35 -0
  109. package/src/lib/modules/form-components/components/datepicker/_theme.scss +82 -0
  110. package/src/lib/modules/form-components/components/datepicker/datepicker.component.html +42 -0
  111. package/src/lib/modules/form-components/components/datepicker/datepicker.component.scss +115 -0
  112. package/src/lib/modules/form-components/components/datepicker/datepicker.component.ts +267 -0
  113. package/src/lib/modules/form-components/components/datepicker/datepicker.models.ts +45 -0
  114. package/src/lib/modules/form-components/components/dropdown/_theme.scss +91 -0
  115. package/src/lib/modules/form-components/components/dropdown/dropdown.component.html +74 -0
  116. package/src/lib/modules/form-components/components/dropdown/dropdown.component.scss +252 -0
  117. package/src/lib/modules/form-components/components/dropdown/dropdown.component.ts +377 -0
  118. package/src/lib/modules/form-components/components/dropdown/dropdown.models.ts +53 -0
  119. package/src/lib/modules/form-components/components/input/_theme.scss +77 -0
  120. package/src/lib/modules/form-components/components/input/input.component.html +51 -0
  121. package/src/lib/modules/form-components/components/input/input.component.scss +128 -0
  122. package/src/lib/modules/form-components/components/input/input.component.ts +250 -0
  123. package/src/lib/modules/form-components/components/input/input.models.ts +55 -0
  124. package/src/lib/modules/form-components/components/radio/_theme.scss +61 -0
  125. package/src/lib/modules/form-components/components/radio/radio.component.html +22 -0
  126. package/src/lib/modules/form-components/components/radio/radio.component.scss +107 -0
  127. package/src/lib/modules/form-components/components/radio/radio.component.ts +181 -0
  128. package/src/lib/modules/form-components/components/radio/radio.models.ts +39 -0
  129. package/src/lib/modules/form-components/components/search/_theme.scss +73 -0
  130. package/src/lib/modules/form-components/components/search/search.component.html +15 -0
  131. package/src/lib/modules/form-components/components/search/search.component.scss +87 -0
  132. package/src/lib/modules/form-components/components/search/search.component.ts +213 -0
  133. package/src/lib/modules/form-components/components/search/search.models.ts +40 -0
  134. package/src/lib/modules/form-components/components/toggle/_theme.scss +45 -0
  135. package/src/lib/modules/form-components/components/toggle/toggle.component.html +15 -0
  136. package/src/lib/modules/form-components/components/toggle/toggle.component.scss +81 -0
  137. package/src/lib/modules/form-components/components/toggle/toggle.component.ts +166 -0
  138. package/src/lib/modules/form-components/components/toggle/toggle.models.ts +27 -0
  139. package/src/lib/modules/form-components/directives/click-outside.directive.ts +22 -0
  140. package/src/lib/modules/form-components/form-components.module.ts +41 -0
  141. package/src/lib/modules/form-components/form-components.theme.scss +25 -0
  142. package/src/lib/modules/material/material.module.ts +94 -0
  143. package/src/lib/modules/nav/components/nav/nav.component.html +34 -0
  144. package/src/lib/modules/nav/components/nav/nav.component.scss +171 -0
  145. package/src/lib/modules/nav/components/nav/nav.component.ts +82 -0
  146. package/src/lib/modules/nav/nav.models.ts +31 -0
  147. package/src/lib/modules/nav/nav.module.ts +17 -0
  148. package/src/lib/modules/nav/nav.theme.scss +86 -0
  149. package/src/lib/modules/pagination/components/pagination/pagination.component.html +52 -0
  150. package/src/lib/modules/pagination/components/pagination/pagination.component.scss +155 -0
  151. package/src/lib/modules/pagination/components/pagination/pagination.component.ts +109 -0
  152. package/src/lib/modules/pagination/pagination.module.ts +17 -0
  153. package/src/lib/modules/pagination/pagination.theme.scss +66 -0
  154. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.html +56 -0
  155. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +342 -0
  156. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.ts +135 -0
  157. package/src/lib/modules/side-nav/side-nav.models.ts +38 -0
  158. package/src/lib/modules/side-nav/side-nav.module.ts +16 -0
  159. package/src/lib/modules/side-nav/side-nav.theme.scss +111 -0
  160. package/src/lib/modules/smart-form/components/form-field/form-field.component.html +1109 -0
  161. package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +1860 -0
  162. package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +2232 -0
  163. package/src/lib/modules/smart-form/components/form-section/form-section.component.html +64 -0
  164. package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +209 -0
  165. package/src/lib/modules/smart-form/components/form-section/form-section.component.ts +119 -0
  166. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.html +253 -0
  167. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +689 -0
  168. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.ts +1087 -0
  169. package/src/lib/modules/smart-form/index.ts +10 -0
  170. package/src/lib/modules/smart-form/models/form-schema.model.ts +700 -0
  171. package/src/lib/modules/smart-form/models/hierarchy-config.model.ts +21 -0
  172. package/src/lib/modules/smart-form/services/expression.service.ts +75 -0
  173. package/src/lib/modules/smart-form/services/smart-form-controller.service.ts +65 -0
  174. package/src/lib/modules/smart-form/smart-form.examples.ts +1324 -0
  175. package/src/lib/modules/smart-form/smart-form.module.ts +36 -0
  176. package/src/lib/modules/smart-form/smart-form.theme.scss +890 -0
  177. package/src/lib/modules/smart-form/utils/translation.utils.ts +82 -0
  178. package/src/lib/modules/smart-form/utils/trusted-url.pipe.ts +25 -0
  179. package/src/lib/modules/smart-form/utils/validation.utils.ts +98 -0
  180. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.html +283 -0
  181. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.scss +685 -0
  182. package/src/lib/modules/smart-table/components/smart-table/smart-table.component.ts +1118 -0
  183. package/src/lib/modules/smart-table/models/table-config.model.ts +202 -0
  184. package/src/lib/modules/smart-table/smart-table.module.ts +30 -0
  185. package/src/lib/modules/smart-table/smart-table.theme.scss +335 -0
  186. package/src/lib/modules/smart-table/utils/safe-html.pipe.ts +22 -0
  187. package/src/lib/modules/smart-table/utils/smart-table.utils.ts +18 -0
  188. package/src/lib/modules/snackbar/components/snackbar.component.html +41 -0
  189. package/src/lib/modules/snackbar/components/snackbar.component.scss +99 -0
  190. package/src/lib/modules/snackbar/components/snackbar.component.ts +18 -0
  191. package/src/lib/modules/snackbar/models/snackbar.models.ts +10 -0
  192. package/src/lib/modules/snackbar/services/snackbar.service.ts +40 -0
  193. package/src/lib/modules/snackbar/snackbar.module.ts +11 -0
  194. package/src/lib/modules/snackbar/snackbar.theme.scss +93 -0
  195. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.html +47 -0
  196. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.scss +199 -0
  197. package/src/lib/modules/summary-card/components/summary-card/summary-card.component.ts +126 -0
  198. package/src/lib/modules/summary-card/summary-card.module.ts +18 -0
  199. package/src/lib/modules/summary-card/summary-card.theme.scss +176 -0
  200. package/src/lib/shared-ui.module.ts +44 -0
  201. package/src/lib/styles/global.scss +152 -0
  202. package/src/lib/styles/utilities.scss +250 -0
  203. package/src/lib/utils/constants.ts +11 -0
  204. package/src/lib/utils/storage.utils.ts +37 -0
  205. package/src/lib/utils/string.utils.ts +23 -0
  206. package/src/lib/utils/translation.utils.ts +87 -0
  207. package/src/public-api.ts +104 -0
  208. package/tsconfig.lib.json +15 -0
@@ -0,0 +1,2232 @@
1
+ import { Component, Input, OnInit, OnDestroy, AfterViewInit, ViewChild, ElementRef, HostListener } from '@angular/core';
2
+ import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
3
+ import {
4
+ FormArray,
5
+ FormBuilder,
6
+ FormControl,
7
+ FormGroup,
8
+ ValidatorFn,
9
+ Validators
10
+ } from '@angular/forms';
11
+ import { SectionConfig, FieldConfig, FormLabels, UploadedFile, MediaItem, LocationItem, LocationFieldValue, AttachmentConfig, LibraryUploadConfig, AutocompleteConfig, AutocompleteDisplayField } from '../../models/form-schema.model';
12
+ import { SmartFormController } from '../../services/smart-form-controller.service';
13
+ import { ExpressionService } from '../../services/expression.service';
14
+ import { ValidationUtils } from '../../utils/validation.utils';
15
+ import { StringUtils } from '../../../../utils/string.utils';
16
+ import { Subject, combineLatest, forkJoin, BehaviorSubject, merge } from 'rxjs';
17
+ import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
18
+ import { ConfirmationModalConfig } from '../../../confirmation-modal/confirmation-modal.models';
19
+
20
+ @Component({
21
+ selector: 'lib-form-field',
22
+ templateUrl: './form-field.component.html',
23
+ styleUrls: ['./form-field.component.scss'],
24
+ standalone: false
25
+ })
26
+ export class FormFieldComponent implements OnInit, OnDestroy {
27
+ @Input() config!: FieldConfig;
28
+ @Input() controller!: SmartFormController;
29
+ /**
30
+ * The FormGroup that THIS field's control should be registered in.
31
+ * For repeater instances this is the instance's own isolated FormGroup.
32
+ * For flat (non-repeater) fields this is the root formGroup.
33
+ */
34
+ @Input() formGroup!: FormGroup;
35
+
36
+ /**
37
+ * Set to TRUE when this field is part of a repeatable group (config.sectionConfig.allowMulti = true).
38
+ * When true, the field does NOT sync with the global controller to prevent data collision
39
+ * between different instances of the same repeater row.
40
+ */
41
+ @Input() allowMulti: boolean = false;
42
+
43
+ value: any;
44
+ isVisible: boolean = true;
45
+ showPassword: boolean = false; // password show/hide toggle
46
+ dynamicMinDate: string | null = null;
47
+ dynamicMinTime: string | null = null;
48
+
49
+ /**
50
+ * Effective minimum date for the datepicker.
51
+ * Priority: dynamic (sibling field) → explicit minDate → today (when allowPast is false).
52
+ * When allowPast is not false, past dates stay allowed unless an explicit min is set.
53
+ */
54
+ get effectiveMinDate(): string | null {
55
+ const explicitMin = this.dynamicMinDate || this.config.dateConfig?.minDate || null;
56
+ if (explicitMin) return explicitMin;
57
+ if (this.config.dateConfig?.allowPast === false) {
58
+ const now = new Date();
59
+ const pad = (n: number) => String(n).padStart(2, '0');
60
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
61
+ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Effective minimum time for the time input.
67
+ * Priority: dynamic (sibling field) → explicit minTime.
68
+ * Mirrors {@link effectiveMinDate} for TIME fields.
69
+ */
70
+ get effectiveMinTime(): string | null {
71
+ return this.dynamicMinTime || this.config.timeConfig?.minTime || null;
72
+ }
73
+
74
+ /** Effective maximum time for the time input (explicit maxTime only). */
75
+ get effectiveMaxTime(): string | null {
76
+ return this.config.timeConfig?.maxTime || null;
77
+ }
78
+
79
+ // ── MULTIPLE dropdown state ───────────────────────────────────────────────
80
+ isMultiDropdownOpen: boolean = false;
81
+ isDragOver: boolean = false; // file upload drag-over state
82
+ fileUploadError: string = ''; // per-field file validation error
83
+ multiSaveError: string = ''; // error for multisave validation
84
+ private destroy$ = new Subject<void>();
85
+
86
+ // ── MEDIA_UPLOAD support ────────────────────────────────────────────────
87
+ @ViewChild('mediaDeviceInput') mediaDeviceInput!: ElementRef<HTMLInputElement>;
88
+ /** Host div for the library picker — always in DOM, moved to body to escape parent transforms */
89
+ @ViewChild('libraryModal') libraryModalRef!: ElementRef<HTMLDivElement>;
90
+ showMediaMenu: boolean = false; // hover/click dropdown menu
91
+ showYoutubeInput: boolean = false; // inline YouTube URL input panel
92
+ youtubeUrlInput: string = ''; // model for the YT URL field
93
+ youtubeUrlError: string = ''; // validation message for YT url
94
+ mediaCarouselIndex: number = 0; // active slide index for carousel
95
+ showLibraryModal: boolean = false; // library picker modal
96
+ libraryImages: any[] = []; // raw list fetched from library API
97
+ librarySelectedIds: Set<any> = new Set(); // selected library items
98
+ libraryLoading: boolean = false;
99
+ libraryError: string = '';
100
+ mediaUploadError: string = ''; // transient error message for max limits
101
+
102
+ // ── LOCATION field support ────────────────────────────────────────────────
103
+ /** Active tab: any string, e.g. 'VENUE', 'ONLINE', 'TBA'. Defaults to 'VENUE'. */
104
+ locationActiveTab: string = 'VENUE';
105
+ /** Current text in the venue search box */
106
+ locationSearchText: string = '';
107
+ /** Google Places autocomplete suggestions */
108
+ locationSuggestions: any[] = [];
109
+ /** Show the suggestions dropdown */
110
+ locationShowSuggestions: boolean = false;
111
+ /** Cached Google AutocompleteService instance */
112
+ private _googleAcService: any = null;
113
+ /** Whether Google Maps is loaded */
114
+ locationMapLoaded: boolean = false;
115
+ /** Map instance */
116
+ private _googleMap: any = null;
117
+ /** Map markers */
118
+ private _mapMarkers: any[] = [];
119
+
120
+ // ── AUTOCOMPLETE support ────────────────────────────────────────────────
121
+ /** FormControl used ONLY for the autocomplete text-input display value */
122
+ autocompleteInputCtrl = new FormControl('');
123
+ /** Filtered option list shown in the mat-autocomplete panel */
124
+ filteredOptions: { label: string; code: any }[] = [];
125
+ /**
126
+ * Component-local option list for DROPDOWN/RADIO/CHECKBOX/CHIP fields.
127
+ * Using a local copy prevents shared-config mutation when the same field config
128
+ * object is reused across multiple allowMulti repeater instances.
129
+ */
130
+ localOptionList: any[] = [];
131
+ /** Cache of the latest dependency parameters for server-side autocomplete filtering */
132
+ private _latestDependencyValues: { [key: string]: any } = {};
133
+
134
+ // ── GROUP / allowMulti support ──────────────────────────────────────────
135
+
136
+ /** For GROUP fields with allowMulti = true */
137
+ groupFormArray!: FormArray;
138
+
139
+ /** For GROUP fields with allowMulti = false — single nested FormGroup */
140
+ groupFormGroup!: FormGroup;
141
+
142
+ /**
143
+ * Tracked list of repeater instances.
144
+ * Using a separate array (not FormArray.controls) + trackBy(id) ensures
145
+ * Angular creates FRESH child components for every new row, preventing
146
+ * cached values from bleeding into new instances.
147
+ *
148
+ * Enhanced with isEditing and isSaved flags for the 'multiSave' card UI.
149
+ * Each instance gets its own rowController so cascading dropdowns work
150
+ * independently within each repeater row.
151
+ */
152
+ instanceList: { id: number; fg: FormGroup; initialValue?: any; isEditing?: boolean; isSaved?: boolean; isExpanded?: boolean; rowController: SmartFormController }[] = [];
153
+ private _nextInstanceId = 0;
154
+
155
+ /** Tracks open accordion panels for standard (non-multiSave) GROUP repeaters. */
156
+ expandedGroupInstances = new Set<number>();
157
+
158
+ /**
159
+ * Key used to register the GROUP control on the parent formGroup.
160
+ * Priority: sectionConfig.name > field.name > camelCase(label) > '__group__'
161
+ */
162
+ get groupKey(): string {
163
+ return (
164
+ this.config.sectionConfig?.name ||
165
+ this.config.name ||
166
+ StringUtils.toCamelCase(this.config.sectionConfig?.label) ||
167
+ '__group__'
168
+ );
169
+ }
170
+
171
+
172
+ constructor(
173
+ private fb: FormBuilder,
174
+ private expressionService: ExpressionService,
175
+ private http: HttpClient
176
+ ) { }
177
+
178
+ ngOnInit(): void {
179
+ if (this.isGroup) {
180
+ this.initGroupField();
181
+ return;
182
+ }
183
+
184
+ // Seed localOptionList from any static options already in the config
185
+ // (e.g. optionList defined directly in the JSON like Yes/No dropdowns).
186
+ // Dynamic options loaded via API will replace this array via loadDropdownOptions.
187
+ this.localOptionList = [...(this.config.optionConfig?.optionList || [])];
188
+
189
+ this.registerControl();
190
+ this.setupVisibility();
191
+ this.setupMinDateField();
192
+ this.setupMinTimeField();
193
+ this.setupGeneratedField();
194
+ this.setupFormulaValidation(); // Generic formula-based validation
195
+ this.setupDependencies();
196
+ this.setupMatchValidation(); // cross-field match (e.g. confirmPassword)
197
+
198
+ if (!this.config.optionConfig?.dependencies) {
199
+ this.loadDropdownOptions();
200
+ }
201
+
202
+ if (this.isAutocomplete) {
203
+ this.initAutocomplete();
204
+ }
205
+
206
+ if (this.isLocation) {
207
+ this.initLocationField();
208
+ }
209
+ }
210
+
211
+ // ── GROUP initialisation ──────────────────────────────────────────────────
212
+ get addMultiLabel(): string {
213
+ const rawLabel = this.config.sectionConfig?.multiSaveConfig?.addLabel;
214
+ if (rawLabel) {
215
+ return this.controller.labels?.[rawLabel] || rawLabel;
216
+ }
217
+ const sectionLabel = this.config.sectionConfig?.label;
218
+ const translatedSectionLabel = sectionLabel ? (this.controller.labels?.[sectionLabel] || sectionLabel) : '';
219
+ return '+ Add a ' + translatedSectionLabel;
220
+ }
221
+
222
+ /** Getter for Select placeholder label */
223
+ get selectPlaceholderLabel(): string {
224
+ return this.controller.labels?.['SELECT_PLACEHOLDER'] || 'Select';
225
+ }
226
+
227
+ /** Getter for No options available label */
228
+ get noOptionsAvailableLabel(): string {
229
+ return this.controller.labels?.['NO_OPTIONS_AVAILABLE'] || 'No options available';
230
+ }
231
+
232
+ /** Getter for expand less icon name */
233
+ get expandLessLabel(): string {
234
+ return this.controller.labels?.['EXPAND_LESS'] || 'expand_less';
235
+ }
236
+
237
+ /** Getter for expand more icon name */
238
+ get expandMoreLabel(): string {
239
+ return this.controller.labels?.['EXPAND_MORE'] || 'expand_more';
240
+ }
241
+
242
+ private initGroupField(): void {
243
+ if (this.config.sectionConfig?.allowMulti) {
244
+ this.groupFormArray = this.fb.array([]);
245
+ this.formGroup.addControl(this.groupKey, this.groupFormArray);
246
+
247
+ const initialData = this.controller.getFieldValue(this.groupKey);
248
+ if (Array.isArray(initialData) && initialData.length > 0) {
249
+ initialData.forEach((item: any) => {
250
+ this.addGroupInstance(item);
251
+ });
252
+ } else {
253
+ // We always start with at least one instance.
254
+ // If multi-save is active, it starts in editing mode and is NOT
255
+ // added to the main form value until the user actually saves it.
256
+ this.addGroupInstance();
257
+ }
258
+ } else {
259
+ this.groupFormGroup = this.fb.group({});
260
+ this.formGroup.addControl(this.groupKey, this.groupFormGroup);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Sets up cross-field validation based on the `onValidate` formula.
266
+ * Watches all variables mentioned in the formula and updates the field's
267
+ * validity whenever any of them change.
268
+ */
269
+ private setupFormulaValidation(): void {
270
+ if (!this.config.onValidate) return;
271
+
272
+ const expression = this.config.onValidate;
273
+ const variables = this.expressionService.extractVariables(expression);
274
+
275
+ // Subscribe to all fields mentioned in the formula
276
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
277
+
278
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
279
+ const context = this.controller.getAllData();
280
+ const isValid = this.expressionService.evaluateCondition(expression, context);
281
+
282
+ const control = this.formGroup.get(this.config.name!);
283
+ if (control) {
284
+ if (!isValid) {
285
+ control.setErrors({ ...control.errors, formulaError: true });
286
+ } else {
287
+ // Clear only the formulaError
288
+ if (control.hasError('formulaError')) {
289
+ const errors = { ...control.errors };
290
+ delete errors['formulaError'];
291
+ control.setErrors(Object.keys(errors).length ? errors : null);
292
+ }
293
+ }
294
+ }
295
+ });
296
+ }
297
+
298
+ addGroupInstance(initialData?: any): void {
299
+ const fg = this.fb.group({});
300
+ const isMultiSave = !!this.config.sectionConfig?.multiSaveConfig?.active;
301
+
302
+ // If initialData exists, treat the instance as already submitted/saved to form
303
+ const isEditing = initialData ? false : isMultiSave;
304
+ const isSaved = initialData ? true : !isMultiSave;
305
+
306
+ // Per-row controller: inherits token/labels from global but has isolated field state.
307
+ // This allows cascading dropdowns (dependencies) to work within each repeater row
308
+ // without cross-row data collisions.
309
+ const rowController = new SmartFormController();
310
+ rowController.token = this.controller.token;
311
+ rowController.tokenHeader = this.controller.tokenHeader;
312
+ rowController.labels = this.controller.labels;
313
+ rowController.actionLabels = this.controller.actionLabels;
314
+
315
+ // Pre-populate row controller so child fields initialize with the correct values
316
+ if (initialData) {
317
+ Object.entries(initialData).forEach(([key, value]) => {
318
+ rowController.updateField(key, value as any);
319
+ });
320
+ }
321
+
322
+ const instance = {
323
+ id: this._nextInstanceId++,
324
+ fg,
325
+ isEditing,
326
+ isSaved,
327
+ isExpanded: false,
328
+ initialValue: initialData ? { ...initialData } : undefined,
329
+ rowController
330
+ };
331
+
332
+ if (isSaved) {
333
+ this.groupFormArray.push(fg);
334
+ }
335
+
336
+ // Collapse all existing panels; expand only the new one
337
+ if (!isMultiSave) {
338
+ this.expandedGroupInstances.clear();
339
+ this.expandedGroupInstances.add(this.instanceList.length);
340
+ }
341
+
342
+ this.instanceList = [...this.instanceList, instance];
343
+
344
+ if (initialData) {
345
+ setTimeout(() => {
346
+ // Ensure that controls exist for all keys in initialData so we don't drop fields like ID!
347
+ Object.keys(initialData).forEach(key => {
348
+ if (!fg.contains(key)) {
349
+ fg.addControl(key, new FormControl(initialData[key]));
350
+ }
351
+ });
352
+ fg.patchValue(initialData);
353
+ });
354
+ }
355
+ }
356
+
357
+ saveGroupInstance(index: number): void {
358
+ const instance = this.instanceList[index];
359
+ const multiCfg = this.config.sectionConfig?.multiSaveConfig;
360
+
361
+ if (multiCfg?.active) {
362
+ // Validate that at least one key field is filled
363
+ const summaryVal = instance.fg.get(multiCfg.summaryField || '')?.value;
364
+ const descVal = multiCfg.descriptionField ? instance.fg.get(multiCfg.descriptionField)?.value : null;
365
+
366
+ const hasSummary = summaryVal !== null && summaryVal !== undefined && String(summaryVal).trim() !== '';
367
+ const hasDesc = multiCfg.descriptionField
368
+ ? (descVal !== null && descVal !== undefined && String(descVal).trim() !== '')
369
+ : true; // if no desc field defined, we only care about summary or others
370
+
371
+ // User rule: If both fields are empty, need to throw error (come from JSON)
372
+ const bothRequired = !!multiCfg.summaryField && !!multiCfg.descriptionField;
373
+ if (bothRequired && !hasSummary && !hasDesc) {
374
+ this.multiSaveError = this.controller.labels['ERR_MSG_EMPTY_MULTI'] || 'Please fill the required fields before saving.';
375
+ return;
376
+ }
377
+
378
+ this.multiSaveError = '';
379
+ instance.isSaved = true;
380
+ instance.isEditing = false;
381
+ instance.initialValue = { ...instance.fg.value };
382
+
383
+ // Push to the parent formArray so its submitted as part of the FormGroup value
384
+ if (!this.groupFormArray.controls.includes(instance.fg)) {
385
+ this.groupFormArray.push(instance.fg);
386
+ }
387
+ }
388
+ }
389
+
390
+ cancelGroupInstance(index: number): void {
391
+ const instance = this.instanceList[index];
392
+ if (!instance.isSaved) {
393
+ // It was a new, unsaved addition - remove it entirely
394
+ this.removeGroupInstance(index, true);
395
+ } else {
396
+ // Revert to original values
397
+ if (instance.initialValue) {
398
+ instance.fg.patchValue(instance.initialValue, { emitEvent: false });
399
+ }
400
+ instance.isEditing = false;
401
+ }
402
+ }
403
+
404
+ editGroupInstance(index: number): void {
405
+ const instance = this.instanceList[index];
406
+ instance.isEditing = true;
407
+ instance.initialValue = { ...instance.fg.value };
408
+ }
409
+
410
+ toggleExpandGroupInstance(index: number): void {
411
+ const instance = this.instanceList[index];
412
+ instance.isExpanded = !instance.isExpanded;
413
+ }
414
+
415
+ removeGroupInstance(index: number, force: boolean = false): void {
416
+ // If not multiSave, we keep the one-row minimum rule.
417
+ // In multiSave, we can remove it even if it's the last one.
418
+ const isMultiSave = !!this.config.sectionConfig?.multiSaveConfig?.active;
419
+
420
+ if (force || isMultiSave || this.instanceList.length > 1) {
421
+ const instance = this.instanceList[index];
422
+ const arrayIndex = this.groupFormArray.controls.indexOf(instance.fg);
423
+ if (arrayIndex >= 0) {
424
+ this.groupFormArray.removeAt(arrayIndex);
425
+ }
426
+ instance.rowController.destroy();
427
+ this.instanceList = this.instanceList.filter((_, i) => i !== index);
428
+
429
+ // Rebuild accordion expanded set after index shift
430
+ if (!isMultiSave) {
431
+ const updated = new Set<number>();
432
+ this.expandedGroupInstances.forEach(i => {
433
+ if (i < index) updated.add(i);
434
+ else if (i > index) updated.add(i - 1);
435
+ });
436
+ this.expandedGroupInstances = updated;
437
+ }
438
+ }
439
+ }
440
+
441
+ toggleGroupAccordion(index: number): void {
442
+ if (this.expandedGroupInstances.has(index)) {
443
+ this.expandedGroupInstances.delete(index);
444
+ } else {
445
+ this.expandedGroupInstances.add(index);
446
+ }
447
+ }
448
+
449
+ isGroupExpanded(index: number): boolean {
450
+ return this.expandedGroupInstances.has(index);
451
+ }
452
+
453
+ trackByInstanceId(_: number, item: { id: number; fg: FormGroup }): number {
454
+ return item.id;
455
+ }
456
+
457
+ // ── Leaf control ─────────────────────────────────────────────────────────
458
+
459
+ ngAfterViewInit(): void {
460
+ if (this.libraryModalRef && this.libraryModalRef.nativeElement) {
461
+ document.body.appendChild(this.libraryModalRef.nativeElement);
462
+ }
463
+ }
464
+
465
+ // ── Suffix Action Icons ──────────────────────────────────────────────────
466
+ /** Handles click on a suffix action icon and emits via the controller */
467
+ onSuffixActionClick(actionId: string): void {
468
+ if (this.config.name) {
469
+ this.controller.suffixActionClick$.next({ fieldName: this.config.name, actionId });
470
+ }
471
+ }
472
+
473
+ ngOnDestroy(): void {
474
+ // Clean up DOM portal
475
+ if (this.libraryModalRef && this.libraryModalRef.nativeElement) {
476
+ if (document.body.contains(this.libraryModalRef.nativeElement)) {
477
+ document.body.removeChild(this.libraryModalRef.nativeElement);
478
+ }
479
+ }
480
+
481
+ // Always complete so any subscriptions (e.g. subfields valueChanges) are cleaned up
482
+ this.destroy$.next();
483
+ this.destroy$.complete();
484
+
485
+ if (this.isGroup) {
486
+ if (this.config.sectionConfig?.allowMulti) {
487
+ this.instanceList.forEach(inst => inst.rowController?.destroy());
488
+ }
489
+ if (this.formGroup?.contains(this.groupKey)) {
490
+ this.formGroup.removeControl(this.groupKey);
491
+ }
492
+ return;
493
+ }
494
+
495
+ const fieldName = this.config.name;
496
+ if (fieldName && this.formGroup?.contains(fieldName)) {
497
+ this.formGroup.removeControl(fieldName);
498
+ }
499
+ }
500
+
501
+ registerControl(): void {
502
+ if (!this.config.name || !this.formGroup) return;
503
+
504
+ const fieldName = this.config.name;
505
+ const validators = this.getValidators();
506
+
507
+ // When inside a repeater instance, ALWAYS start with defaultValue (never
508
+ // read from the shared controller — prevents cross-instance value copying).
509
+ const initialValue = this.allowMulti
510
+ ? (this.config.defaultValue ?? null)
511
+ : (this.controller.getFieldValue(fieldName) ?? this.config.defaultValue ?? null);
512
+
513
+ let control = this.formGroup.get(fieldName) as FormControl;
514
+ if (!control) {
515
+ control = new FormControl(
516
+ { value: initialValue, disabled: !!this.config.disabled },
517
+ validators
518
+ );
519
+ this.formGroup.addControl(fieldName, control);
520
+ }
521
+
522
+ this.value = control.value;
523
+
524
+ if (!this.allowMulti) {
525
+ // ── Flat field: keep in sync with shared controller ──────────────────
526
+ control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
527
+ this.value = val;
528
+ this.controller.updateField(fieldName, val);
529
+ });
530
+
531
+ this.controller.getFieldObservable(fieldName).pipe(takeUntil(this.destroy$)).subscribe(val => {
532
+ if (val !== control.value) {
533
+ control.setValue(val, { emitEvent: false });
534
+ this.value = val;
535
+ }
536
+ });
537
+ } else {
538
+ // ── Repeater field: local value tracking only ─────────────────────────
539
+ control.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
540
+ this.value = val;
541
+ });
542
+ }
543
+ }
544
+
545
+ getValidators(): ValidatorFn[] {
546
+ const validators: ValidatorFn[] = [];
547
+ if (this.config.required) validators.push(Validators.required);
548
+ if (this.config.subType === 'EMAIL') validators.push(Validators.email);
549
+ if (this.config.subType === 'PASSWORD') {
550
+ // Minimum 8 chars by default; honour explicit textConfig.length overrides
551
+ const minLen = this.config.textConfig?.length?.min ?? 8;
552
+ validators.push(Validators.minLength(minLen));
553
+ }
554
+ if (this.config.textConfig?.length) {
555
+ const { min, max } = this.config.textConfig.length;
556
+ if (min && this.config.subType !== 'PASSWORD') validators.push(Validators.minLength(min));
557
+ if (max) validators.push(Validators.maxLength(max));
558
+ }
559
+ if (this.config.textConfig?.pattern) validators.push(Validators.pattern(this.config.textConfig.pattern));
560
+ if (this.config.numberConfig) {
561
+ const { min, max } = this.config.numberConfig;
562
+ if (min !== undefined) validators.push(Validators.min(min));
563
+ if (max !== undefined) validators.push(Validators.max(max));
564
+ }
565
+ if (this.config.type === 'RICH_TEXT' && this.config.richTextConfig?.maxLength) {
566
+ const max = this.config.richTextConfig.maxLength;
567
+ validators.push((control: import('@angular/forms').AbstractControl) => {
568
+ if (!control.value) return null;
569
+ const plainText = String(control.value).replace(/<[^>]*>/g, '');
570
+ return plainText.length > max
571
+ ? { 'maxlength': { requiredLength: max, actualLength: plainText.length } }
572
+ : null;
573
+ });
574
+ }
575
+ return validators;
576
+ }
577
+
578
+ // ── Cross-field match validation (password === confirmPassword) ───────────
579
+
580
+ /**
581
+ * When `textConfig.matchField` is configured, subscribes to value changes on
582
+ * BOTH this control and the referenced control so the mismatch error updates
583
+ * instantly whichever field the user edits last.
584
+ *
585
+ * - Values differ → sets `{ passwordMismatch: true }` on THIS control.
586
+ * - Values match → clears `passwordMismatch` from THIS control.
587
+ */
588
+ setupMatchValidation(): void {
589
+ const matchFieldName = this.config.textConfig?.matchField;
590
+ if (!matchFieldName || !this.config.name || !this.formGroup) return;
591
+
592
+ const thisControl = this.formGroup.get(this.config.name);
593
+ const otherControl = this.formGroup.get(matchFieldName);
594
+ if (!thisControl || !otherControl) return;
595
+
596
+ const runCheck = () => {
597
+ const thisVal = thisControl.value;
598
+ const otherVal = otherControl.value;
599
+
600
+ if (thisVal && otherVal && thisVal !== otherVal) {
601
+ // Both have a value but they differ — flag the mismatch
602
+ thisControl.setErrors(
603
+ { ...thisControl.errors, passwordMismatch: true },
604
+ { emitEvent: false }
605
+ );
606
+ } else {
607
+ // Either one is empty OR they now match — clear the mismatch error
608
+ if (thisControl.hasError('passwordMismatch')) {
609
+ const errors = { ...thisControl.errors };
610
+ delete errors['passwordMismatch'];
611
+ thisControl.setErrors(
612
+ Object.keys(errors).length ? errors : null,
613
+ { emitEvent: false }
614
+ );
615
+ }
616
+ }
617
+ };
618
+
619
+ // Fire when THIS (confirmPassword) field changes
620
+ thisControl.valueChanges
621
+ .pipe(takeUntil(this.destroy$))
622
+ .subscribe(() => runCheck());
623
+
624
+ // Also fire when the OTHER (password) field changes so the error clears
625
+ // as soon as the user corrects the source value
626
+ otherControl.valueChanges
627
+ .pipe(takeUntil(this.destroy$))
628
+ .subscribe(() => runCheck());
629
+ }
630
+
631
+ setupVisibility(): void {
632
+ if (!this.config.visibilityExpression) return;
633
+
634
+ // Prefer formGroup-level lookup: works for both allowMulti rows and non-multi rows
635
+ // that still have a row-scoped formGroup (e.g. accordion repeater with allowMulti=false).
636
+ if (this.formGroup) {
637
+ const variables = this.expressionService.extractVariables(this.config.visibilityExpression);
638
+ const evaluate = () => {
639
+ const context = this.formGroup.value;
640
+ this.isVisible = this.expressionService.evaluateCondition(this.config.visibilityExpression!, context);
641
+ const control = this.formGroup.get(this.config.name!);
642
+ if (control) {
643
+ this.isVisible ? control.enable({ emitEvent: false }) : control.disable({ emitEvent: false });
644
+ }
645
+ };
646
+ evaluate();
647
+ const varControls = variables
648
+ .map(v => this.formGroup.get(v))
649
+ .filter((c): c is FormControl => !!c);
650
+ const stream$ = varControls.length > 0
651
+ ? merge(...varControls.map(c => c.valueChanges))
652
+ : this.formGroup.valueChanges;
653
+ stream$.pipe(takeUntil(this.destroy$)).subscribe(() => evaluate());
654
+ } else {
655
+ const variables = this.expressionService.extractVariables(this.config.visibilityExpression);
656
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
657
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
658
+ const context = this.controller.getAllData();
659
+ this.isVisible = this.expressionService.evaluateCondition(this.config.visibilityExpression!, context);
660
+ const control = this.formGroup?.get(this.config.name!);
661
+ if (control) {
662
+ this.isVisible ? control.enable({ emitEvent: false }) : control.disable({ emitEvent: false });
663
+ }
664
+ });
665
+ }
666
+ }
667
+
668
+ setupMinDateField(): void {
669
+ const minDateField = this.config.dateConfig?.minDateField;
670
+ if (!minDateField || this.config.type !== 'DATE') return;
671
+
672
+ // Use local date to avoid UTC-offset day shift (e.g. IST midnight = prev-day UTC)
673
+ const toIso = (val: any): string | null => {
674
+ if (!val) return null;
675
+ const d = val instanceof Date ? val : new Date(val);
676
+ if (isNaN(d.getTime())) return String(val);
677
+ const y = d.getFullYear();
678
+ const mo = String(d.getMonth() + 1).padStart(2, '0');
679
+ const dy = String(d.getDate()).padStart(2, '0');
680
+ return `${y}-${mo}-${dy}`;
681
+ };
682
+
683
+ // Prefer formGroup-level lookup: works for both allowMulti rows and non-multi rows
684
+ // that still have a row-scoped formGroup (e.g. accordion repeater with allowMulti=false).
685
+ if (this.formGroup) {
686
+ const sourceCtrl = this.formGroup.get(minDateField);
687
+ if (sourceCtrl) {
688
+ this.dynamicMinDate = toIso(sourceCtrl.value);
689
+ sourceCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
690
+ this.dynamicMinDate = toIso(val);
691
+ // If end date is now before the new start date, clear it immediately
692
+ const thisCtrl = this.formGroup?.get(this.config.name!);
693
+ if (thisCtrl?.value && val) {
694
+ const endD = thisCtrl.value instanceof Date ? thisCtrl.value : new Date(thisCtrl.value);
695
+ const minD = val instanceof Date ? val : new Date(val as string);
696
+ if (!isNaN(endD.getTime()) && !isNaN(minD.getTime()) && endD < minD) {
697
+ thisCtrl.setValue(null, { emitEvent: true });
698
+ }
699
+ }
700
+ });
701
+ return;
702
+ }
703
+ }
704
+
705
+ // Fall back to controller-based lookup (non-repeater flat fields).
706
+ const updateMin = (context: any) => {
707
+ this.dynamicMinDate = toIso(context?.[minDateField]);
708
+ };
709
+ updateMin(this.controller.getAllData());
710
+ this.controller.getFieldObservable(minDateField)
711
+ .pipe(takeUntil(this.destroy$))
712
+ .subscribe(() => updateMin(this.controller.getAllData()));
713
+ }
714
+
715
+ /**
716
+ * Wires up a dynamic minimum for a TIME field from a sibling TIME field
717
+ * (config.timeConfig.minTimeField). When the source changes, this field's
718
+ * minimum updates and any now-invalid value (earlier than the new minimum)
719
+ * is cleared. Mirrors {@link setupMinDateField} for TIME fields.
720
+ */
721
+ setupMinTimeField(): void {
722
+ const minTimeField = this.config.timeConfig?.minTimeField;
723
+ if (!minTimeField || this.config.type !== 'TIME') return;
724
+
725
+ // Native <input type="time"> stores an "HH:mm" (or "HH:mm:ss") string,
726
+ // which compares correctly lexicographically. Normalise anything else to a string.
727
+ const toTime = (val: any): string | null => {
728
+ if (val === null || val === undefined || val === '') return null;
729
+ return String(val);
730
+ };
731
+
732
+ // Prefer formGroup-level lookup so it works for both allowMulti rows and
733
+ // non-multi rows that still have a row-scoped formGroup (accordion repeaters).
734
+ if (this.formGroup) {
735
+ const sourceCtrl = this.formGroup.get(minTimeField);
736
+ if (sourceCtrl) {
737
+ this.dynamicMinTime = toTime(sourceCtrl.value);
738
+ sourceCtrl.valueChanges.pipe(takeUntil(this.destroy$)).subscribe(val => {
739
+ this.dynamicMinTime = toTime(val);
740
+ // If this (end) time is now before the new start time, clear it immediately.
741
+ const thisCtrl = this.formGroup?.get(this.config.name!);
742
+ const min = this.dynamicMinTime;
743
+ if (thisCtrl?.value && min && String(thisCtrl.value) < min) {
744
+ thisCtrl.setValue(null, { emitEvent: true });
745
+ }
746
+ });
747
+ return;
748
+ }
749
+ }
750
+
751
+ // Fall back to controller-based lookup (non-repeater flat fields).
752
+ const updateMin = (context: any) => {
753
+ this.dynamicMinTime = toTime(context?.[minTimeField]);
754
+ };
755
+ updateMin(this.controller.getAllData());
756
+ this.controller.getFieldObservable(minTimeField)
757
+ .pipe(takeUntil(this.destroy$))
758
+ .subscribe(() => updateMin(this.controller.getAllData()));
759
+ }
760
+
761
+ setupGeneratedField(): void {
762
+ if (this.config.type !== 'GENERATED' || !this.config.generatedConfig) return;
763
+ const variables = this.config.generatedConfig.variables || [];
764
+ const observables = variables.map(v => this.controller.getFieldObservable(v));
765
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(() => {
766
+ const context = this.controller.getAllData();
767
+ const result = this.evaluateFormula(context);
768
+ if (result !== null && this.config.name) {
769
+ this.controller.updateField(this.config.name, result);
770
+ }
771
+ });
772
+ }
773
+
774
+ evaluateFormula(context: { [key: string]: any }): any {
775
+ if (!this.config.generatedConfig) return null;
776
+ const formula = this.config.generatedConfig.formula;
777
+ const functionName = this.extractFunctionName(formula);
778
+ if (functionName) {
779
+ return this.expressionService.evaluateFormula(
780
+ formula, functionName, context, this.config.generatedConfig.variables
781
+ );
782
+ }
783
+ return null;
784
+ }
785
+
786
+ extractFunctionName(formula: string): string | null {
787
+ const match = formula.match(/(?:function|fun)\s+(\w+)\s*\(/);
788
+ return match ? match[1] : null;
789
+ }
790
+
791
+ setupDependencies(): void {
792
+ if (!this.config.optionConfig?.dependencies) return;
793
+ const dependencies = this.config.optionConfig.dependencies;
794
+ const observables = Object.values(dependencies).map(fieldName =>
795
+ this.controller.getFieldObservable(fieldName)
796
+ );
797
+ // Track previous values so we can detect user-driven parent changes vs. initial load.
798
+ // BehaviorSubjects always emit the current value on subscribe — we must NOT clear the
799
+ // child's value on that first emission (it would wipe pre-populated edit-mode data).
800
+ let prevValues: any[] | null = null;
801
+ combineLatest(observables).pipe(takeUntil(this.destroy$)).subscribe(values => {
802
+ const dependencyValues: { [key: string]: any } = {};
803
+ Object.keys(dependencies).forEach((paramKey, index) => {
804
+ dependencyValues[paramKey] = values[index];
805
+ });
806
+ const allPresent = Object.values(dependencyValues).every(
807
+ v => v !== null && v !== undefined && v !== ''
808
+ );
809
+
810
+ const isFirstEmit = prevValues === null;
811
+ const parentChanged = !isFirstEmit && values.some((v, i) => v !== prevValues![i]);
812
+ prevValues = [...values];
813
+
814
+ if (allPresent) {
815
+ if (parentChanged) {
816
+ // Parent value changed by user interaction — reset child selection so the
817
+ // previously chosen value (from a different parent) isn't shown as selected
818
+ // against the newly loaded options.
819
+ const control = this.formGroup?.get(this.config.name!);
820
+ if (control && control.value !== null && control.value !== '') {
821
+ control.setValue(null, { emitEvent: true });
822
+ }
823
+ }
824
+ this._latestDependencyValues = dependencyValues;
825
+ this.loadDropdownOptions(dependencyValues);
826
+ } else if (this.config.optionConfig) {
827
+ this._latestDependencyValues = {};
828
+ this.localOptionList = [];
829
+ if (parentChanged) {
830
+ const control = this.formGroup?.get(this.config.name!);
831
+ if (control && control.value !== null && control.value !== '') {
832
+ control.setValue(null, { emitEvent: true });
833
+ }
834
+ }
835
+ }
836
+ });
837
+ }
838
+
839
+ loadDropdownOptions(dynamicParams: { [key: string]: any } = {}): void {
840
+ const optionConfig = this.config.optionConfig;
841
+ const acConfig = this.config.autocompleteConfig;
842
+
843
+ const urls = optionConfig?.apiUrls ||
844
+ (optionConfig?.apiUrl ? [optionConfig.apiUrl] :
845
+ optionConfig?.optionUrl ? [optionConfig.optionUrl] : []);
846
+
847
+ if (!urls || urls.length === 0) return;
848
+
849
+ // HTTP method/body/headers: read from autocompleteConfig first, fall back to optionConfig
850
+ const method = acConfig?.method || 'GET';
851
+ const staticBody = acConfig?.body ?? null;
852
+ const staticQueryParams = acConfig?.queryParams || {};
853
+ const customHeaders = acConfig?.headers;
854
+
855
+ const observables = urls.map(url => {
856
+ let fullUrl = url;
857
+
858
+ // Merge static queryParams and dynamicParams
859
+ const allQueryParams = { ...staticQueryParams, ...dynamicParams };
860
+
861
+ // Interpolate path variables (e.g., {program}) from allQueryParams
862
+ Object.keys(allQueryParams).forEach(key => {
863
+ const placeholder = `{${key}}`;
864
+ if (fullUrl.includes(placeholder)) {
865
+ fullUrl = fullUrl.replace(placeholder, String(allQueryParams[key]));
866
+ // Remove it so it doesn't get appended as a query param
867
+ delete allQueryParams[key];
868
+ }
869
+ });
870
+
871
+ let httpParams = new HttpParams();
872
+ Object.entries(allQueryParams).forEach(([key, value]) => {
873
+ if (value !== null && value !== undefined) {
874
+ httpParams = httpParams.append(key, String(value));
875
+ }
876
+ });
877
+
878
+ const options: any = {
879
+ headers: this.getHeaders(customHeaders),
880
+ params: httpParams
881
+ };
882
+
883
+ switch (method) {
884
+ case 'POST': return this.http.post<any>(fullUrl, staticBody, options);
885
+ case 'PUT': return this.http.put<any>(fullUrl, staticBody, options);
886
+ case 'PATCH': return this.http.patch<any>(fullUrl, staticBody, options);
887
+ default: return this.http.get<any>(fullUrl, options);
888
+ }
889
+ });
890
+
891
+ forkJoin(observables).pipe(takeUntil(this.destroy$)).subscribe({
892
+ next: (responses: any[]) => {
893
+ let mergedData: any[] = [];
894
+ responses.forEach((response: any) => {
895
+ let data = optionConfig?.dataPath
896
+ ? this.getValueByPath(response, optionConfig.dataPath)
897
+ : (Array.isArray(response) ? response : response.elements || response.data || response.items || response || []);
898
+
899
+ // Handle Dictionary Objects by converting them to Arrays
900
+ if (data !== null && typeof data === 'object' && !Array.isArray(data)) {
901
+ data = Object.values(data);
902
+ }
903
+
904
+ if (Array.isArray(data)) mergedData = [...mergedData, ...data];
905
+ });
906
+
907
+ if (optionConfig?.sortBy) {
908
+ const sortKey = optionConfig.sortBy;
909
+ const direction = optionConfig.sortDirection === 'DESC' ? -1 : 1;
910
+ mergedData.sort((a, b) => {
911
+ const valA = this.getValueByPath(a, sortKey);
912
+ const valB = this.getValueByPath(b, sortKey);
913
+ if (typeof valA === 'string' && typeof valB === 'string') return direction * valA.localeCompare(valB);
914
+ if (typeof valA === 'number' && typeof valB === 'number') return direction * (valA - valB);
915
+ return 0;
916
+ });
917
+ }
918
+
919
+ // labelTemplate: read from autocompleteConfig first
920
+ const labelTemplate = acConfig?.labelTemplate;
921
+
922
+ this.localOptionList = mergedData.map((item: any) => {
923
+ // Support labelTemplate: '{firstName} {lastName} ({login})'
924
+ let label: string;
925
+ if (labelTemplate) {
926
+ label = labelTemplate.replace(/\{(\w+)\}/g, (_: string, key: string) => {
927
+ const val = this.getValueByPath(item, key);
928
+ return val !== undefined && val !== null ? String(val) : '';
929
+ });
930
+ } else if (optionConfig?.labelPath) {
931
+ label = String(this.getValueByPath(item, optionConfig.labelPath) ?? '');
932
+ } else {
933
+ label = item.label || item.displayName || item.name || '';
934
+ }
935
+
936
+ const code = optionConfig?.valuePath
937
+ ? this.getValueByPath(item, optionConfig.valuePath)
938
+ : (item.code ?? item.id ?? item.value);
939
+
940
+ // Resolve displayFields from autocompleteConfig
941
+ // Supports string (single path, backward-compat) or AutocompleteDisplayField[]
942
+ const displayFields = acConfig?.displayFields;
943
+ let displayMeta: { path: string; type: string; label?: string; value: string }[] | undefined;
944
+ if (displayFields) {
945
+ const fields: AutocompleteDisplayField[] = typeof displayFields === 'string'
946
+ ? [{ path: displayFields, type: 'text' }]
947
+ : displayFields;
948
+ displayMeta = fields.map(f => ({
949
+ path: f.path,
950
+ type: f.type || 'text',
951
+ icon: f.icon,
952
+ className: f.className,
953
+ label: f.label,
954
+ value: String(this.getValueByPath(item, f.path) ?? '')
955
+ })).filter(f => f.value !== '');
956
+ }
957
+
958
+ return { label, code, value: item, displayMeta };
959
+ });
960
+
961
+ // Refresh autocomplete filter list after options arrive from API
962
+ if (this.isAutocomplete) {
963
+ this.filteredOptions = [...this.localOptionList];
964
+ this._syncAutocompleteDisplayValue();
965
+ }
966
+ },
967
+ error: err => console.error('Failed to load dropdown options:', err)
968
+ });
969
+ }
970
+
971
+ private getValueByPath(obj: any, path: string): any {
972
+ if (!path || path === '') return obj;
973
+ return path.split('.').reduce((acc, part) => {
974
+ const match = part.match(/(\w+)\[(\d+)\]/);
975
+ if (match) return acc?.[match[1]]?.[parseInt(match[2])];
976
+ return acc?.[part];
977
+ }, obj);
978
+ }
979
+
980
+ /**
981
+ * Replaces `{placeholder}` tokens in a URL with values resolved from the
982
+ * current form data (supports dot / array-index paths via getValueByPath).
983
+ * Returns the interpolated URL plus whether EVERY token was resolved.
984
+ *
985
+ * Used by the delete flows: when a required id (e.g. `{entityId}`) is missing
986
+ * — as in CREATE mode before the entity exists — `resolved` is false and the
987
+ * caller skips the server delete and just removes the item from the form.
988
+ */
989
+ private resolveUrlPlaceholders(url: string): { url: string; resolved: boolean } {
990
+ if (!url || !url.includes('{')) return { url, resolved: true };
991
+ const data = this.controller.getAllData();
992
+ let resolved = true;
993
+ const out = url.replace(/\{([^}]+)\}/g, (_match, path: string) => {
994
+ const val = this.getValueByPath(data, path.trim());
995
+ if (val === null || val === undefined || val === '') {
996
+ resolved = false;
997
+ return `{${path}}`;
998
+ }
999
+ return String(val);
1000
+ });
1001
+ return { url: out, resolved };
1002
+ }
1003
+
1004
+ /** Builds HttpHeaders using the token stored in the SmartFormController (sourced from configJSON)
1005
+ * merged with any custom headers declared in optionConfig.headers.
1006
+ */
1007
+ private _fileLabel(key: keyof FormLabels, fallback: string, vars: Record<string, string> = {}): string {
1008
+ let tpl = (this.controller.actionLabels?.[key] as string) ?? fallback;
1009
+ Object.entries(vars).forEach(([k, v]) => { tpl = tpl.replace(`{${k}}`, v); });
1010
+ return tpl;
1011
+ }
1012
+
1013
+ private getHeaders(customHeaders?: { [key: string]: string }): HttpHeaders {
1014
+ let headers = new HttpHeaders();
1015
+ if (this.controller.token) {
1016
+ const headerName = this.controller.tokenHeader || 'Authorization';
1017
+ headers = headers.set(headerName, this.controller.token);
1018
+ }
1019
+ // Merge custom headers from optionConfig (allows MFE to pass e.g. X-SESSIONID)
1020
+ if (customHeaders) {
1021
+ Object.entries(customHeaders).forEach(([key, val]) => {
1022
+ headers = headers.set(key, val);
1023
+ });
1024
+ }
1025
+ return headers;
1026
+ }
1027
+
1028
+ updateValue(newValue: any): void {
1029
+ if (!this.config.name) return;
1030
+ const control = this.formGroup.get(this.config.name);
1031
+ if (control) {
1032
+ control.setValue(newValue);
1033
+ control.markAsDirty();
1034
+ control.markAsTouched();
1035
+ }
1036
+ }
1037
+
1038
+ onCheckboxListChange(code: string, checked: boolean): void {
1039
+ if (!this.config.name) return;
1040
+ const currentValue = (this.allowMulti
1041
+ ? (this.formGroup.get(this.config.name)?.value)
1042
+ : this.controller.getFieldValue(this.config.name)) || [];
1043
+ const newValue = checked
1044
+ ? [...currentValue, code]
1045
+ : currentValue.filter((c: string) => c !== code);
1046
+ this.updateValue(newValue);
1047
+ }
1048
+
1049
+ isChecked(code: string): boolean {
1050
+ const value = this.value || [];
1051
+ return Array.isArray(value) && value.includes(code);
1052
+ }
1053
+
1054
+ get errorMessage(): string {
1055
+ if (!this.config.name || !this.formGroup) return '';
1056
+ const control = this.formGroup.get(this.config.name);
1057
+ if (control && control.invalid && (control.touched || control.dirty)) {
1058
+ if (control.hasError('required')) return 'This field is required';
1059
+ if (control.hasError('email')) return 'Invalid email format';
1060
+ if (control.hasError('passwordMismatch')) return 'Passwords do not match';
1061
+ if (control.hasError('formulaError')) return this.config.errorMessage || 'Invalid value';
1062
+ if (control.hasError('minlength')) return `Minimum length is ${control.errors?.['minlength'].requiredLength} characters`;
1063
+ if (control.hasError('maxlength')) return `Maximum length is ${control.errors?.['maxlength'].requiredLength} characters`;
1064
+ if (control.hasError('min')) return `Minimum value is ${control.errors?.['min'].min}`;
1065
+ if (control.hasError('max')) return `Maximum value is ${control.errors?.['max'].max}`;
1066
+ if (control.hasError('pattern')) return this.config.textConfig?.patternMessage || 'Invalid format';
1067
+ }
1068
+ return '';
1069
+ }
1070
+
1071
+ get showCharCount(): boolean {
1072
+ if (this.isTextField && this.config.subType !== 'PHONE') {
1073
+ return !!(this.config.textConfig?.showCharCount && this.config.textConfig?.length?.max);
1074
+ }
1075
+ if (this.isRichText) {
1076
+ return !!(this.config.richTextConfig?.showCharCount && this.config.richTextConfig?.maxLength);
1077
+ }
1078
+ return false;
1079
+ }
1080
+
1081
+ get remainingCharacters(): number | null {
1082
+ if (!this.showCharCount || !this.formGroup || !this.config.name) return null;
1083
+ const currentVal = this.formGroup.get(this.config.name)?.value || '';
1084
+
1085
+ if (this.isTextField && this.config.textConfig?.length?.max) {
1086
+ return Math.max(0, this.config.textConfig.length.max - currentVal.length);
1087
+ }
1088
+ if (this.isRichText && this.config.richTextConfig?.maxLength) {
1089
+ const plainText = String(currentVal).replace(/<[^>]*>/g, '');
1090
+ return Math.max(0, this.config.richTextConfig.maxLength - plainText.length);
1091
+ }
1092
+ return null;
1093
+ }
1094
+
1095
+ // ── MULTIPLE dropdown helpers ─────────────────────────────────────────────
1096
+
1097
+ toggleMultiDropdown(event: MouseEvent): void {
1098
+ event.stopPropagation();
1099
+ this.isMultiDropdownOpen = !this.isMultiDropdownOpen;
1100
+ }
1101
+
1102
+ @HostListener('document:click')
1103
+ onDocumentClick(): void {
1104
+ if (this.isMultiDropdownOpen) {
1105
+ this.isMultiDropdownOpen = false;
1106
+ }
1107
+ }
1108
+
1109
+ @HostListener('document:keydown.escape')
1110
+ onEscapeKey(): void {
1111
+ this.isMultiDropdownOpen = false;
1112
+ }
1113
+
1114
+ get multiSelectedCount(): number {
1115
+ return Array.isArray(this.value) ? this.value.length : 0;
1116
+ }
1117
+
1118
+ // ── Type guards ──────────────────────────────────────────────────────────
1119
+
1120
+ get isTextField(): boolean { return this.config.type === 'TEXT_INPUT'; }
1121
+ get isNumberField(): boolean { return this.config.type === 'NUMBER_INPUT'; }
1122
+ get isDateField(): boolean { return this.config.type === 'DATE'; }
1123
+ get isTimeField(): boolean { return this.config.type === 'TIME'; }
1124
+ get isDropdown(): boolean { return this.config.type === 'DROPDOWN'; }
1125
+ get isAutocomplete(): boolean { return this.config.type === 'AUTOCOMPLETE'; }
1126
+ get isFileUpload(): boolean { return this.config.type === 'FILE_UPLOAD'; }
1127
+ get isMediaUpload(): boolean { return this.config.type === 'MEDIA_UPLOAD'; }
1128
+ get isRadio(): boolean { return this.config.type === 'RADIO'; }
1129
+ get isCheckbox(): boolean { return this.config.type === 'CHECKBOX'; }
1130
+ get isChip(): boolean { return this.config.type === 'CHIP'; }
1131
+ get isSwitch(): boolean { return this.config.type === 'SWITCH'; }
1132
+ get isRating(): boolean { return this.config.type === 'RATING'; }
1133
+ get isRichText(): boolean { return this.config.type === 'RICH_TEXT'; }
1134
+ get isGenerated(): boolean { return this.config.type === 'GENERATED'; }
1135
+ get isRow(): boolean { return this.config.type === 'ROW'; }
1136
+ get isGroup(): boolean { return this.config.type === 'GROUP'; }
1137
+ get isLocation(): boolean { return this.config.type === 'LOCATION'; }
1138
+
1139
+ // ── AUTOCOMPLETE helpers ────────────────────────────────────────────────
1140
+
1141
+ /**
1142
+ * Initialise the separate display-control that drives the mat-autocomplete
1143
+ * text input. The real form control always stores the *code* value.
1144
+ */
1145
+ private initAutocomplete(): void {
1146
+ // Sync display input whenever the real control value changes externally
1147
+ this._syncAutocompleteDisplayValue();
1148
+
1149
+ // Read search settings from autocompleteConfig (preferred) — no fallback to optionConfig needed
1150
+ const acConfig = this.config.autocompleteConfig;
1151
+ const searchParam = acConfig?.searchParam || acConfig?.searchKey;
1152
+ const minLength = acConfig?.searchMinLength ?? 1;
1153
+ const debounceMs = acConfig?.searchDebounce ?? (searchParam ? 300 : 150);
1154
+
1155
+ // Filter the option list as the user types
1156
+ this.autocompleteInputCtrl.valueChanges
1157
+ .pipe(
1158
+ debounceTime(debounceMs),
1159
+ distinctUntilChanged(),
1160
+ takeUntil(this.destroy$)
1161
+ )
1162
+ .subscribe(search => {
1163
+ const query = typeof search === 'string' ? search : '';
1164
+
1165
+ if (searchParam) {
1166
+ if (query.length >= minLength) {
1167
+ // Trigger server-side search merged with active dependencies
1168
+ const params = { ...this._latestDependencyValues, [searchParam]: query };
1169
+ this.loadDropdownOptions(params);
1170
+ } else {
1171
+ // If query is below min length, revert to existing full list if empty query
1172
+ this.filteredOptions = query.length === 0 ? [...this.localOptionList] : [];
1173
+ }
1174
+ } else {
1175
+ // Local filter
1176
+ this.filteredOptions = this._filterOptions(query);
1177
+ }
1178
+ });
1179
+
1180
+ // Initialise filtered list with all available options
1181
+ this.filteredOptions = [...this.localOptionList];
1182
+ }
1183
+
1184
+ /** Filter options by the user's search text (matches label or code). */
1185
+ private _filterOptions(search: string): { label: string; code: any }[] {
1186
+ const q = search.toLowerCase().trim();
1187
+ if (!q) return [...this.localOptionList];
1188
+ return this.localOptionList.filter(opt =>
1189
+ opt.label?.toLowerCase().includes(q) ||
1190
+ String(opt.code).toLowerCase().includes(q)
1191
+ );
1192
+ }
1193
+
1194
+ /** Put the human-readable label into the display control based on the stored code. */
1195
+ private _syncAutocompleteDisplayValue(): void {
1196
+ if (!this.config.name) return;
1197
+ const code = this.formGroup?.get(this.config.name)?.value;
1198
+ if (code === null || code === undefined || code === '') return;
1199
+ const matched = this.localOptionList.find(o => o.code === code);
1200
+ if (matched) {
1201
+ this.autocompleteInputCtrl.setValue(matched.label, { emitEvent: false });
1202
+ }
1203
+ }
1204
+
1205
+ /** Called when user picks an option from the mat-autocomplete panel. */
1206
+ onAutocompleteSelected(option: { label: string; code: any }): void {
1207
+ this.autocompleteInputCtrl.setValue(option.label, { emitEvent: false });
1208
+ this.updateValue(option.code);
1209
+ }
1210
+
1211
+ /** Called when the input loses focus — clear display & value if text was manually deleted. */
1212
+ onAutocompleteClear(): void {
1213
+ const display = (this.autocompleteInputCtrl.value || '').trim();
1214
+ if (!display) {
1215
+ this.updateValue(null);
1216
+ } else {
1217
+ // Restore label from stored code (prevents partial text staying)
1218
+ this._syncAutocompleteDisplayValue();
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Returns the effective grid column span for a child inside a ROW.
1224
+ * If the child declares an explicit colSpan, use it.
1225
+ * Otherwise divide 12 equally among all children (floor, min 1).
1226
+ */
1227
+ getChildColSpan(child: FieldConfig): number {
1228
+ if (child.colSpan) return child.colSpan;
1229
+ const count = this.config.children?.length || 1;
1230
+ return Math.max(1, Math.floor(12 / count));
1231
+ }
1232
+
1233
+ getOptionColSpan(option: any): number {
1234
+ const span = option.colSpan || option.colspan;
1235
+ if (span) return span;
1236
+ const count = this.localOptionList.length || 1;
1237
+ return Math.max(1, Math.floor(12 / count));
1238
+ }
1239
+
1240
+ // ── Rating helpers ───────────────────────────────────────────────────────
1241
+
1242
+ onRatingChange(star: number, event?: MouseEvent): void {
1243
+ if (!this.config.name || this.config.disabled) return;
1244
+ let newValue = star;
1245
+ if (this.config.ratingConfig?.allowHalf && event) {
1246
+ const target = event.target as HTMLElement;
1247
+ const rect = target.getBoundingClientRect();
1248
+ if (event.clientX - rect.left < rect.width / 2) newValue = star - 0.5;
1249
+ }
1250
+ if (this.value === newValue) newValue = 0;
1251
+ this.updateValue(newValue);
1252
+ }
1253
+
1254
+ getStarArray(): number[] {
1255
+ const max = this.config.ratingConfig?.maxRating || 5;
1256
+ return Array.from({ length: max }, (_, i) => i + 1);
1257
+ }
1258
+
1259
+ isStarHalf(star: number): boolean { return (this.value || 0) === star - 0.5; }
1260
+ isStarFilled(star: number): boolean { return (this.value || 0) >= star; }
1261
+
1262
+ // ── File Upload helpers ──────────────────────────────────────────────────
1263
+
1264
+ onDragOver(event: DragEvent): void {
1265
+ event.preventDefault();
1266
+ event.stopPropagation();
1267
+ this.isDragOver = true;
1268
+ }
1269
+
1270
+ onDragLeave(event: DragEvent): void {
1271
+ event.preventDefault();
1272
+ event.stopPropagation();
1273
+ this.isDragOver = false;
1274
+ }
1275
+
1276
+ onFileDrop(event: DragEvent): void {
1277
+ event.preventDefault();
1278
+ event.stopPropagation();
1279
+ this.isDragOver = false;
1280
+ const files = event.dataTransfer?.files;
1281
+ if (files && files.length > 0) {
1282
+ this.processFiles(files);
1283
+ }
1284
+ }
1285
+
1286
+ onFileSelected(event: Event): void {
1287
+ const input = event.target as HTMLInputElement;
1288
+ if (input.files && input.files.length > 0) {
1289
+ this.processFiles(input.files);
1290
+ }
1291
+ // Reset input so the same file can be re-selected if removed
1292
+ input.value = '';
1293
+ }
1294
+
1295
+ private processFiles(files: FileList): void {
1296
+ // ── MEDIA_UPLOAD branch — uses attachmentConfig, stores MediaItem[] ─────
1297
+ if (this.isMediaUpload) {
1298
+ const mediaCfg = this.config.attachmentConfig;
1299
+ if (!mediaCfg) return;
1300
+ const maxItems = mediaCfg.maxFiles ?? 10;
1301
+
1302
+ let errorShown = false;
1303
+
1304
+ Array.from(files).forEach(file => {
1305
+ if (this.mediaItems.length >= maxItems) {
1306
+ if (!errorShown) {
1307
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
1308
+ errorShown = true;
1309
+ }
1310
+ return;
1311
+ }
1312
+
1313
+ const placeholder: MediaItem = { mediaType: 'image', url: '', isUploading: true };
1314
+ this._appendMediaItem(placeholder);
1315
+ this.controller.fileAdded$.next(file);
1316
+
1317
+ const formData = new FormData();
1318
+ formData.append('file', file);
1319
+
1320
+ if (!mediaCfg.uploadUrl) return;
1321
+ let finalUrl = mediaCfg.uploadUrl;
1322
+ if (finalUrl.includes('{fileName}')) {
1323
+ finalUrl = finalUrl.replace('{fileName}', encodeURIComponent(file.name));
1324
+ }
1325
+
1326
+ this.http.post<any>(finalUrl, formData, { headers: this.getHeaders() })
1327
+ .pipe(takeUntil(this.destroy$))
1328
+ .subscribe({
1329
+ next: (response: any) => {
1330
+ // API returns { completeURL, id, mimeType, fileName, ... }
1331
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
1332
+ const current: MediaItem[] = [...this.mediaItems];
1333
+ const idx = current.indexOf(placeholder);
1334
+ if (idx !== -1) {
1335
+ current[idx] = {
1336
+ mediaType: 'image',
1337
+ url: resolvedUrl,
1338
+ id: response?.id,
1339
+ mimeType: response?.mimeType,
1340
+ fileName: response?.fileName ?? response?.name,
1341
+ isUploading: false
1342
+ };
1343
+ this.updateValue(current);
1344
+ this.mediaCarouselIndex = current.length - 1;
1345
+ this.controller.fileUploadFinished$.next(file);
1346
+ }
1347
+ },
1348
+ error: () => {
1349
+ const cleaned = this.mediaItems.filter(m => m !== placeholder);
1350
+ this.updateValue(cleaned.length ? cleaned : null);
1351
+ this.controller.fileUploadFinished$.next(file);
1352
+ }
1353
+ });
1354
+ });
1355
+ return;
1356
+ }
1357
+
1358
+ // ── FILE_UPLOAD branch — uses attachmentConfig, stores UploadedFile[] ────
1359
+ this.fileUploadError = '';
1360
+ const cfg = this.config.attachmentConfig;
1361
+ const maxSizeBytes = (cfg?.maxSizeMB ?? 10) * 1024 * 1024;
1362
+ const currentFiles: UploadedFile[] = (this.value as UploadedFile[]) || [];
1363
+ const maxFiles = cfg?.maxFiles ?? (cfg?.multiple ? 10 : 1);
1364
+ const isMultiple = cfg?.multiple ?? false;
1365
+
1366
+ const incoming = Array.from(files);
1367
+
1368
+ for (const file of incoming) {
1369
+ // File type validation
1370
+ if (cfg?.accept) {
1371
+ const accepted = cfg.accept.split(',').map((s: string) => s.trim().toLowerCase());
1372
+ const fileExt = '.' + file.name.toLowerCase().split('.').pop();
1373
+ const fileMime = (file.type || '').toLowerCase();
1374
+ const isAllowed = accepted.some((pattern: string) => {
1375
+ if (pattern.startsWith('.')) return fileExt === pattern;
1376
+ if (pattern.endsWith('/*')) return fileMime.startsWith(pattern.slice(0, -1));
1377
+ return fileMime === pattern;
1378
+ });
1379
+ if (!isAllowed) {
1380
+ this.fileUploadError = this._fileLabel('fileTypeError', '"{fileName}" is not a supported file type.', { fileName: file.name });
1381
+ continue;
1382
+ }
1383
+ }
1384
+ // Size validation
1385
+ if (file.size > maxSizeBytes) {
1386
+ this.fileUploadError = this._fileLabel('fileSizeError', '"{fileName}" exceeds the maximum allowed size of {maxSizeMB} MB.', { fileName: file.name, maxSizeMB: String(cfg?.maxSizeMB ?? 10) });
1387
+ continue;
1388
+ }
1389
+ // Max-files validation
1390
+ if (isMultiple && currentFiles.length >= maxFiles) {
1391
+ this.fileUploadError = this._fileLabel('maxFilesError', 'Maximum {maxFiles} files allowed.', { maxFiles: String(maxFiles) });
1392
+ break;
1393
+ }
1394
+
1395
+ if (cfg?.uploadUrl) {
1396
+ // ── API upload path ───────────────────────────────────────────────
1397
+ const entry: UploadedFile = {
1398
+ name: file.name,
1399
+ size: file.size,
1400
+ type: file.type,
1401
+ file,
1402
+ isUploading: true
1403
+ };
1404
+
1405
+ // Add the placeholder entry immediately so the UI shows the progress row
1406
+ const withPlaceholder = isMultiple
1407
+ ? [...(this.value as UploadedFile[] || []), entry]
1408
+ : [entry];
1409
+ this.updateValue(withPlaceholder);
1410
+ this.controller.fileAdded$.next(file);
1411
+
1412
+ const formData = new FormData();
1413
+ formData.append('file', file);
1414
+
1415
+ let finalUrl = cfg.uploadUrl;
1416
+ if (finalUrl.includes('{fileName}')) {
1417
+ finalUrl = finalUrl.replace('{fileName}', encodeURIComponent(file.name));
1418
+ }
1419
+
1420
+ this.http.post<any>(finalUrl, formData, { headers: this.getHeaders() })
1421
+ .pipe(takeUntil(this.destroy$))
1422
+ .subscribe({
1423
+ next: (response: any) => {
1424
+ // The API returns a JSON object; use completeURL if present,
1425
+ // otherwise fall back to treating the raw response as a URL string.
1426
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
1427
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
1428
+ const idx = current.indexOf(entry);
1429
+ if (idx !== -1) {
1430
+ const updatedEntry: UploadedFile = { ...entry, dataUrl: resolvedUrl, id: response?.id, isUploading: false };
1431
+ const updatedList = [...current];
1432
+ updatedList[idx] = updatedEntry;
1433
+ this.updateValue(updatedList);
1434
+ this.controller.fileUploadFinished$.next(file);
1435
+ }
1436
+ },
1437
+ error: () => {
1438
+ // Remove the failed placeholder from the list
1439
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
1440
+ const cleaned = current.filter(f => f !== entry);
1441
+ this.updateValue(cleaned.length ? cleaned : null);
1442
+ this.fileUploadError = this._fileLabel('fileUploadFailed', 'Failed to upload "{fileName}". Please try again.', { fileName: file.name });
1443
+ this.controller.fileUploadFinished$.next(file);
1444
+ }
1445
+ });
1446
+ } else {
1447
+ // ── FileReader (base64) fallback path ────────────────────────────
1448
+ const reader = new FileReader();
1449
+ reader.onload = (e) => {
1450
+ const entry: UploadedFile = {
1451
+ name: file.name,
1452
+ size: file.size,
1453
+ type: file.type,
1454
+ dataUrl: e.target?.result as string,
1455
+ file
1456
+ };
1457
+ const updated = isMultiple
1458
+ ? [...(this.value as UploadedFile[] || []), entry]
1459
+ : [entry];
1460
+ this.updateValue(updated);
1461
+ };
1462
+ reader.readAsDataURL(file);
1463
+ }
1464
+ }
1465
+ }
1466
+
1467
+ removeUploadedFile(index: number): void {
1468
+ const current: UploadedFile[] = (this.value as UploadedFile[]) || [];
1469
+ const fileToRemove = current[index];
1470
+ if (!fileToRemove) return;
1471
+
1472
+ const removeLocally = () => {
1473
+ const updated = current.filter((_, i) => i !== index);
1474
+ this.updateValue(updated);
1475
+ };
1476
+
1477
+ const deleteUrl = this.config.attachmentConfig?.deleteUrl;
1478
+ const { url: resolvedDeleteUrl, resolved } = deleteUrl
1479
+ ? this.resolveUrlPlaceholders(deleteUrl)
1480
+ : { url: '', resolved: false };
1481
+
1482
+ if (fileToRemove.id && fileToRemove.id !== 0 && deleteUrl && resolved) {
1483
+ fileToRemove.isUploading = true; // Use this flag to disable the remove button
1484
+ this.updateValue([...current]); // trigger UI update
1485
+
1486
+ let params = new HttpParams();
1487
+ if (!resolvedDeleteUrl.includes('attachmentId=')) {
1488
+ params = params.set('attachmentId', fileToRemove.id.toString());
1489
+ }
1490
+ if (!resolvedDeleteUrl.includes('reason=')) {
1491
+ // As requested in the API link, the reason is passed
1492
+ params = params.set('reason', 'DELETED BY CREATOR');
1493
+ }
1494
+
1495
+ this.http.delete<any>(resolvedDeleteUrl, { headers: this.getHeaders(), params })
1496
+ .pipe(takeUntil(this.destroy$))
1497
+ .subscribe({
1498
+ next: () => {
1499
+ removeLocally();
1500
+ this.controller.fileRemoved$.next(fileToRemove);
1501
+ },
1502
+ error: (err) => {
1503
+ console.error('Failed to delete attachment', err);
1504
+ fileToRemove.isUploading = false;
1505
+ this.fileUploadError = this._fileLabel('fileDeleteFailed', 'Failed to delete "{fileName}". Please try again.', { fileName: fileToRemove.name });
1506
+ this.updateValue([...current]);
1507
+ }
1508
+ });
1509
+ } else {
1510
+ // No id, no deleteUrl, or an unresolved URL placeholder (e.g. {entityId}
1511
+ // in CREATE mode): just remove locally, no server delete call.
1512
+ removeLocally();
1513
+ this.controller.fileRemoved$.next(fileToRemove);
1514
+ }
1515
+ }
1516
+
1517
+ getFileIcon(mimeType: string): string {
1518
+ if (!mimeType) return 'attach_file'; // guard: undefined/null from non-FILE_UPLOAD values
1519
+ if (mimeType.includes('pdf')) return 'picture_as_pdf';
1520
+ if (mimeType.includes('image')) return 'image';
1521
+ if (mimeType.includes('word') || mimeType.includes('document')) return 'description';
1522
+ if (mimeType.includes('sheet') || mimeType.includes('excel') || mimeType.includes('csv')) return 'table_chart';
1523
+ if (mimeType.includes('zip') || mimeType.includes('compressed')) return 'folder_zip';
1524
+ return 'attach_file';
1525
+ }
1526
+
1527
+ formatFileSize(bytes: number): string {
1528
+ if (bytes < 1024) return `${bytes} B`;
1529
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1530
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1531
+ }
1532
+
1533
+ // ── Action Labels ──────────────────────────────────────────────────────────
1534
+
1535
+ get addLabel(): string {
1536
+ const key = this.controller.actionLabels?.addLabel || 'Add';
1537
+ return this.controller.labels[key] || key;
1538
+ }
1539
+
1540
+ get removeLabel(): string {
1541
+ const key = this.controller.actionLabels?.removeLabel || 'Remove';
1542
+ return this.controller.labels[key] || key;
1543
+ }
1544
+
1545
+ // ── MEDIA_UPLOAD helpers ─────────────────────────────────────────────────
1546
+
1547
+ get mediaItems(): MediaItem[] {
1548
+ return (this.value as MediaItem[]) || [];
1549
+ }
1550
+
1551
+ /** Number of active items (used to clamp carousel index) */
1552
+ get mediaCount(): number { return this.mediaItems.length; }
1553
+
1554
+ /** The currently visible carousel item */
1555
+ get activeMediaItem(): MediaItem | null {
1556
+ if (!this.mediaItems.length) return null;
1557
+ return this.mediaItems[Math.min(this.mediaCarouselIndex, this.mediaItems.length - 1)];
1558
+ }
1559
+
1560
+ /** Thumbnail strip items */
1561
+ get mediaThumbnails(): MediaItem[] { return this.mediaItems; }
1562
+
1563
+ mediaCarouselPrev(): void {
1564
+ if (this.mediaCarouselIndex > 0) this.mediaCarouselIndex--;
1565
+ }
1566
+
1567
+ mediaCarouselNext(): void {
1568
+ if (this.mediaCarouselIndex < this.mediaItems.length - 1) this.mediaCarouselIndex++;
1569
+ }
1570
+
1571
+ mediaGoTo(index: number): void {
1572
+ this.mediaCarouselIndex = index;
1573
+ }
1574
+
1575
+ // ── YouTube ──────────────────────────────────────────────────────────────
1576
+
1577
+ onMediaMenuVideo(): void {
1578
+ this.showMediaMenu = false;
1579
+ this.showYoutubeInput = !this.showYoutubeInput;
1580
+ this.youtubeUrlInput = '';
1581
+ this.youtubeUrlError = '';
1582
+ }
1583
+
1584
+ addYoutubeMedia(): void {
1585
+ const cfg = this.config.attachmentConfig;
1586
+ const maxItems = cfg?.maxFiles ?? 10;
1587
+ if (this.mediaItems.length >= maxItems) {
1588
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
1589
+ return;
1590
+ }
1591
+
1592
+ const url = this.youtubeUrlInput.trim();
1593
+ const videoId = this._extractYoutubeId(url);
1594
+ if (!videoId) {
1595
+ this.youtubeUrlError = this.controller.labels['ERR_INVALID_YOUTUBE_URL'] || 'Please enter a valid YouTube URL.';
1596
+ return;
1597
+ }
1598
+ const item: MediaItem = {
1599
+ mediaType: 'youtube',
1600
+ url: `https://www.youtube.com/embed/${videoId}`,
1601
+ thumbnailUrl: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
1602
+ };
1603
+ this._appendMediaItem(item);
1604
+ this.showYoutubeInput = false;
1605
+ this.youtubeUrlInput = '';
1606
+ this.youtubeUrlError = '';
1607
+ }
1608
+
1609
+ private _extractYoutubeId(url: string): string | null {
1610
+ const patterns = [
1611
+ /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]{11})/,
1612
+ /youtube\.com\/shorts\/([\w-]{11})/
1613
+ ];
1614
+ for (const p of patterns) {
1615
+ const m = url.match(p);
1616
+ if (m) return m[1];
1617
+ }
1618
+ return null;
1619
+ }
1620
+
1621
+ // ── Device upload ────────────────────────────────────────────────────────
1622
+
1623
+ onMediaMenuDevice(): void {
1624
+ this.showMediaMenu = false;
1625
+ this.showYoutubeInput = false;
1626
+ // Use setTimeout so Angular finishes closing the dropdown before the
1627
+ // browser opens the native file picker (the input lives outside *ngIf).
1628
+ setTimeout(() => this.mediaDeviceInput?.nativeElement?.click());
1629
+ }
1630
+
1631
+ onMediaFileSelected(event: Event): void {
1632
+ const input = event.target as HTMLInputElement;
1633
+ if (input.files && input.files.length > 0) {
1634
+ this.processFiles(input.files);
1635
+ }
1636
+ input.value = '';
1637
+ }
1638
+
1639
+ // ── Library helpers ──────────────────────────────────────────────────────
1640
+
1641
+ /**
1642
+ * Resolves the library-image sync POST endpoint from `attachmentConfig.libraryConfig.uploadConfig`.
1643
+ * Returns `null` when not configured; the caller skips the API call.
1644
+ */
1645
+ private buildLibraryUploadRequest(
1646
+ cfg: AttachmentConfig | undefined,
1647
+ runtimeContext: Record<string, any>
1648
+ ): { baseUrl: string; params: HttpParams } | null {
1649
+ const uploadConfig = cfg?.libraryConfig?.uploadConfig;
1650
+ if (!uploadConfig) return null;
1651
+
1652
+ const { baseUrl, params: paramDefs = [] } = uploadConfig;
1653
+ let params = new HttpParams();
1654
+
1655
+ for (const def of paramDefs) {
1656
+ let value: string | undefined;
1657
+
1658
+ // sourcePath takes precedence over staticValue
1659
+ if (def.sourcePath) {
1660
+ const resolved = def.sourcePath
1661
+ .split('.')
1662
+ .reduce((obj: Record<string, any>, k: string) => obj?.[k], runtimeContext);
1663
+ if (resolved !== null && resolved !== undefined) {
1664
+ value = String(resolved);
1665
+ }
1666
+ }
1667
+
1668
+ if (!value && def.staticValue) {
1669
+ value = def.staticValue;
1670
+ }
1671
+
1672
+ if (def.required && !value) {
1673
+ console.warn(`[SmartForm] libraryConfig.uploadConfig: required param "${def.key}" could not be resolved.`);
1674
+ return null;
1675
+ }
1676
+
1677
+ if (value) {
1678
+ params = params.set(def.key, value);
1679
+ }
1680
+ }
1681
+
1682
+ return { baseUrl, params };
1683
+ }
1684
+
1685
+ // ── Library picker ───────────────────────────────────────────────────────
1686
+
1687
+ /** Config object for the cc-confirmation-modal used as the library picker. */
1688
+ get libraryModalConfig(): ConfirmationModalConfig {
1689
+ const labels = this.controller.labels;
1690
+ return {
1691
+ title: labels['LBL_ADD_IMAGES'] || 'Add Images',
1692
+ headerTheme: 'dark' as const,
1693
+ size: 'lg',
1694
+ closeOnBackdrop: true,
1695
+ closeOnEsc: true,
1696
+ showCloseButton: true,
1697
+ cancelButton: {
1698
+ show: true,
1699
+ label: labels['LBL_CANCEL'] || 'Cancel'
1700
+ },
1701
+ confirmButton: {
1702
+ label: labels['LBL_CONTINUE'] || 'Continue',
1703
+ type: 'primary' as const
1704
+ }
1705
+ };
1706
+ }
1707
+
1708
+ onMediaMenuLibrary(): void {
1709
+ this.showMediaMenu = false;
1710
+ this.showYoutubeInput = false;
1711
+ this.libraryImages = [];
1712
+ this.librarySelectedIds = new Set();
1713
+ this.libraryError = '';
1714
+ this.showLibraryModal = true;
1715
+ this._loadLibraryImages();
1716
+ }
1717
+
1718
+ private _loadLibraryImages(): void {
1719
+ const cfg = this.config.attachmentConfig;
1720
+ const libCfg = cfg?.libraryConfig;
1721
+ if (!libCfg?.apiUrl) { this.libraryError = 'Library not configured.'; return; }
1722
+ this.libraryLoading = true;
1723
+ this.http.get<any>(libCfg.apiUrl, { headers: this.getHeaders() })
1724
+ .pipe(takeUntil(this.destroy$))
1725
+ .subscribe({
1726
+ next: (res: any) => {
1727
+ let data: any;
1728
+
1729
+ if (libCfg.dataPath) {
1730
+ // Try explicit dot-notation path configured by the dev (e.g. 'data.items')
1731
+ data = this.getValueByPath(res, libCfg.dataPath);
1732
+ }
1733
+
1734
+ // If no array was found at the configured path, try auto-detecting
1735
+ // the array from common structures
1736
+ if (!Array.isArray(data)) {
1737
+ if (Array.isArray(res)) {
1738
+ // API returned a plain array
1739
+ data = res;
1740
+ } else if (res && typeof res === 'object') {
1741
+ // API returned an object keyed by entity-id e.g. { "1": [...], "2": [...] }
1742
+ // Flatten all value-arrays into one list
1743
+ data = (Object.values(res) as any[][])
1744
+ .filter(v => Array.isArray(v))
1745
+ .reduce((acc, arr) => acc.concat(arr), []);
1746
+ }
1747
+ }
1748
+
1749
+ this.libraryImages = Array.isArray(data) ? data : [];
1750
+ this.libraryLoading = false;
1751
+ },
1752
+ error: () => {
1753
+ this.libraryError = this.controller.labels['ERR_LIBRARY_LOAD_FAILED'] || 'Failed to load library images.';
1754
+ this.libraryLoading = false;
1755
+ }
1756
+ });
1757
+ }
1758
+
1759
+ getLibraryItemUrl(item: any): string {
1760
+ const libCfg = this.config.attachmentConfig?.libraryConfig;
1761
+ return libCfg?.urlPath ? this.getValueByPath(item, libCfg.urlPath) : (item?.url || item?.completeURL || '');
1762
+ }
1763
+
1764
+ getLibraryItemId(item: any): any {
1765
+ const libCfg = this.config.attachmentConfig?.libraryConfig;
1766
+ return libCfg?.idPath ? this.getValueByPath(item, libCfg.idPath) : (item?.id ?? item);
1767
+ }
1768
+
1769
+ isLibraryItemSelected(item: any): boolean {
1770
+ return this.librarySelectedIds.has(this.getLibraryItemId(item));
1771
+ }
1772
+
1773
+ toggleLibraryItem(item: any): void {
1774
+ const id = this.getLibraryItemId(item);
1775
+ if (this.librarySelectedIds.has(id)) {
1776
+ this.librarySelectedIds.delete(id);
1777
+ } else {
1778
+ this.librarySelectedIds.add(id);
1779
+ }
1780
+ }
1781
+
1782
+ closeLibraryModal(): void {
1783
+ this.showLibraryModal = false;
1784
+ }
1785
+
1786
+ confirmLibrarySelection(): void {
1787
+ const selectedItems = this.libraryImages.filter(item => this.isLibraryItemSelected(item));
1788
+
1789
+ if (!selectedItems.length) {
1790
+ this.showLibraryModal = false;
1791
+ return;
1792
+ }
1793
+
1794
+ const cfg = this.config.attachmentConfig;
1795
+ const maxItems = cfg?.maxFiles ?? 10;
1796
+ // NOTE: track added count live — do NOT snapshot currentItems here
1797
+ let errorShown = false;
1798
+
1799
+ // Close modal immediately so UI feels responsive
1800
+ this.showLibraryModal = false;
1801
+
1802
+ selectedItems.forEach(item => {
1803
+ // Use live count — this.mediaItems grows as _appendMediaItem is called
1804
+ if (this.mediaItems.length >= maxItems) {
1805
+ if (!errorShown) {
1806
+ this.showMediaError(this.controller.labels['ERR_MEDIA_MAX'] || `Maximum ${maxItems} media items allowed.`);
1807
+ errorShown = true;
1808
+ }
1809
+ return;
1810
+ }
1811
+ const libraryUrl = this.getLibraryItemUrl(item);
1812
+
1813
+ if (!libraryUrl) return;
1814
+
1815
+ const placeholder: MediaItem = {
1816
+ mediaType: 'image',
1817
+ url: libraryUrl,
1818
+ mimeType: item.mimeType,
1819
+ fileName: item.fileName ?? item.name,
1820
+ isUploading: true
1821
+ };
1822
+
1823
+ this._appendMediaItem(placeholder);
1824
+
1825
+ // Build the POST request using the structured config or legacy URL fallback.
1826
+ // The runtime context provides dynamic param values (e.g. the selected image URL).
1827
+ const request = this.buildLibraryUploadRequest(cfg, { libraryItem: { url: libraryUrl } });
1828
+
1829
+ if (request) {
1830
+ // For legacy fallback the completeUrl param is not yet on params — add it now.
1831
+ // For the structured config it is declared via sourcePath='libraryItem.url' so
1832
+ // it gets resolved inside buildLibraryUploadRequest and is already on params.
1833
+ let { baseUrl, params } = request;
1834
+ if (!params.has('completeUrl')) {
1835
+ params = params.set('completeUrl', libraryUrl);
1836
+ }
1837
+
1838
+ // POST with null body — all data travels as query params
1839
+ this.http.post<any>(baseUrl, null, { headers: this.getHeaders(), params })
1840
+ .pipe(takeUntil(this.destroy$))
1841
+ .subscribe({
1842
+ next: (response: any) => {
1843
+ const current: MediaItem[] = [...this.mediaItems];
1844
+ const idx = current.indexOf(placeholder);
1845
+ if (idx !== -1) {
1846
+ const resolvedUrl: string = (response?.completeURL ?? response)?.toString().trim();
1847
+ current[idx] = {
1848
+ mediaType: 'image',
1849
+ url: resolvedUrl,
1850
+ id: response?.id,
1851
+ mimeType: response?.mimeType ?? placeholder.mimeType,
1852
+ fileName: response?.fileName ?? response?.name ?? placeholder.fileName,
1853
+ isUploading: false
1854
+ };
1855
+ this.updateValue(current);
1856
+ }
1857
+ },
1858
+ error: (err) => {
1859
+ console.error('Failed to sync library image to attachments:', err);
1860
+ const current: MediaItem[] = [...this.mediaItems];
1861
+ const cleaned = current.filter(m => m !== placeholder);
1862
+ this.updateValue(cleaned.length ? cleaned : null);
1863
+ }
1864
+ });
1865
+ } else {
1866
+ // No sync URL configured — use the library URL directly (local-only fallback)
1867
+ const current: MediaItem[] = [...this.mediaItems];
1868
+ const idx = current.indexOf(placeholder);
1869
+ if (idx !== -1) {
1870
+ current[idx] = { ...placeholder, isUploading: false };
1871
+ this.updateValue(current);
1872
+ }
1873
+ }
1874
+ });
1875
+
1876
+ this.mediaCarouselIndex = this.mediaItems.length - 1;
1877
+ }
1878
+
1879
+ // ── Remove item ──────────────────────────────────────────────────────────
1880
+
1881
+ removeMediaItem(index: number): void {
1882
+ const items: MediaItem[] = [...this.mediaItems];
1883
+ const item = items[index];
1884
+ if (!item) return;
1885
+
1886
+ const removeLocally = () => {
1887
+ const updated = items.filter((_, i) => i !== index);
1888
+ this.updateValue(updated.length ? updated : null);
1889
+ // Clamp carousel index so it never points past the end
1890
+ this.mediaCarouselIndex = Math.max(0, this.mediaCarouselIndex - (index <= this.mediaCarouselIndex ? 1 : 0));
1891
+ };
1892
+
1893
+ const deleteUrl = this.config.attachmentConfig?.deleteUrl;
1894
+ const { url: resolvedDeleteUrl, resolved } = deleteUrl
1895
+ ? this.resolveUrlPlaceholders(deleteUrl)
1896
+ : { url: '', resolved: false };
1897
+
1898
+ if (item.id && item.id !== 0 && deleteUrl && resolved) {
1899
+ // ── API delete path (same logic as removeUploadedFile) ───────────────
1900
+ item.isUploading = true; // disable the remove button while in-flight
1901
+ this.updateValue([...items]); // trigger change detection
1902
+
1903
+ let params = new HttpParams();
1904
+ if (!resolvedDeleteUrl.includes('attachmentId=')) {
1905
+ params = params.set('attachmentId', item.id.toString());
1906
+ }
1907
+ if (!resolvedDeleteUrl.includes('reason=')) {
1908
+ params = params.set('reason', 'DELETED BY CREATOR');
1909
+ }
1910
+
1911
+ this.http.delete<any>(resolvedDeleteUrl, { headers: this.getHeaders(), params })
1912
+ .pipe(takeUntil(this.destroy$))
1913
+ .subscribe({
1914
+ next: () => {
1915
+ removeLocally();
1916
+ this.controller.fileRemoved$.next(item);
1917
+ },
1918
+ error: (err) => {
1919
+ console.error('Failed to delete media attachment', err);
1920
+ item.isUploading = false;
1921
+ this.updateValue([...items]); // restore UI state
1922
+ }
1923
+ });
1924
+ } else {
1925
+ // ── No id, no deleteUrl, or an unresolved URL placeholder (e.g.
1926
+ // {entityId} in CREATE mode before the entity exists): just remove
1927
+ // the item from the form — no server delete call. ─────────────────
1928
+ removeLocally();
1929
+ this.controller.fileRemoved$.next(item);
1930
+ }
1931
+ }
1932
+
1933
+
1934
+ private _appendMediaItem(item: MediaItem): void {
1935
+ const current: MediaItem[] = [...this.mediaItems];
1936
+ current.push(item);
1937
+ this.updateValue(current);
1938
+ this.mediaCarouselIndex = current.length - 1;
1939
+ }
1940
+
1941
+ private showMediaError(msg: string): void {
1942
+ this.mediaUploadError = msg;
1943
+ setTimeout(() => {
1944
+ if (this.mediaUploadError === msg) this.mediaUploadError = '';
1945
+ }, 4000);
1946
+ }
1947
+
1948
+ // ── LOCATION field methods ─────────────────────────────────────────────────
1949
+
1950
+ private initLocationField(): void {
1951
+ const cfg = this.config.locationConfig;
1952
+ this.locationActiveTab = cfg?.defaultTab ?? 'VENUE';
1953
+
1954
+ // Restore from existing form value using the robust getter
1955
+ if (this.locationValue.tab) {
1956
+ this.locationActiveTab = this.locationValue.tab;
1957
+ }
1958
+
1959
+ // Subscribe to external patchValue events (prefills)
1960
+ this.formGroup.get(this.config.name!)?.valueChanges
1961
+ .pipe(takeUntil(this.destroy$))
1962
+ .subscribe(() => {
1963
+ if (this.locationValue.tab) {
1964
+ this.locationActiveTab = this.locationValue.tab;
1965
+ }
1966
+ setTimeout(() => this._renderMap());
1967
+ });
1968
+
1969
+ // Load Google Maps script if not already loaded
1970
+ this._ensureGoogleMapsScript();
1971
+ }
1972
+
1973
+ private _ensureGoogleMapsScript(): void {
1974
+ const apiKey = this.config.locationConfig?.googleMapsApiKey;
1975
+ if (!apiKey) return;
1976
+
1977
+ if ((window as any).google?.maps?.places) {
1978
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
1979
+ this.locationMapLoaded = true;
1980
+ setTimeout(() => this._renderMap(), 100);
1981
+ return;
1982
+ }
1983
+
1984
+ // Only inject once
1985
+ if (document.querySelector('script[data-sf-gmaps]')) {
1986
+ // Wait for the script to load
1987
+ const wait = () => {
1988
+ if ((window as any).google?.maps?.places) {
1989
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
1990
+ this.locationMapLoaded = true;
1991
+ setTimeout(() => this._renderMap(), 100);
1992
+ } else {
1993
+ setTimeout(wait, 300);
1994
+ }
1995
+ };
1996
+ wait();
1997
+ return;
1998
+ }
1999
+
2000
+ const script = document.createElement('script');
2001
+ script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`;
2002
+ script.async = true;
2003
+ script.defer = true;
2004
+ script.setAttribute('data-sf-gmaps', 'true');
2005
+ script.onload = () => {
2006
+ this._googleAcService = new (window as any).google.maps.places.AutocompleteService();
2007
+ this.locationMapLoaded = true;
2008
+ setTimeout(() => this._renderMap(), 100);
2009
+ };
2010
+ document.head.appendChild(script);
2011
+ }
2012
+
2013
+ onLocationTabChange(tab: string): void {
2014
+ if (this.locationActiveTab === 'VENUE' && tab !== 'VENUE') {
2015
+ this._googleMap = null;
2016
+ this._mapMarkers = [];
2017
+ }
2018
+ this.locationActiveTab = tab;
2019
+ this.locationSuggestions = [];
2020
+ this.locationShowSuggestions = false;
2021
+ this.locationSearchText = '';
2022
+ const existing: LocationFieldValue = this.formGroup.get(this.config.name!)?.value || { tab, venues: [], onlineUrl: '' };
2023
+ existing.tab = tab;
2024
+ this.updateValue(existing);
2025
+ if (tab === 'VENUE') {
2026
+ setTimeout(() => this._renderMap());
2027
+ }
2028
+ }
2029
+
2030
+ get locationValue(): LocationFieldValue {
2031
+ let v = this.formGroup.get(this.config.name!)?.value;
2032
+ if (typeof v === 'string') {
2033
+ try { v = JSON.parse(v); } catch { /* ignore */ }
2034
+ }
2035
+ // Automatically unwrap if prefill payload was mistakenly double-nested like `{ location: { tab... } }`
2036
+ if (v && !v.tab && v[this.config.name!]) {
2037
+ v = v[this.config.name!];
2038
+ }
2039
+ if (v && v.tab) return v;
2040
+ return { tab: this.locationActiveTab, venues: [], onlineUrl: '' };
2041
+ }
2042
+
2043
+ get locationVenues(): LocationItem[] {
2044
+ return this.locationValue.venues || [];
2045
+ }
2046
+
2047
+ get locationOnlineUrl(): string {
2048
+ return this.locationValue.onlineUrl || '';
2049
+ }
2050
+
2051
+ get locationMaxReached(): boolean {
2052
+ const isMulti = this.config.locationConfig?.allowMulti;
2053
+ const max = isMulti ? (this.config.locationConfig?.maxLocations ?? 5) : 1;
2054
+ return this.locationVenues.length >= max;
2055
+ }
2056
+
2057
+ handleLocationSearchInput(event: Event): void {
2058
+ const input = (event.target as HTMLInputElement).value;
2059
+ this.locationSearchText = input;
2060
+ if (!input || input.length < 2) {
2061
+ this.locationSuggestions = [];
2062
+ this.locationShowSuggestions = false;
2063
+ return;
2064
+ }
2065
+ if (!this._googleAcService) {
2066
+ return;
2067
+ }
2068
+ this._googleAcService.getPlacePredictions({ input }, (predictions: any[], status: string) => {
2069
+ if (status === 'OK' && predictions) {
2070
+ this.locationSuggestions = predictions;
2071
+ this.locationShowSuggestions = true;
2072
+ } else {
2073
+ this.locationSuggestions = [];
2074
+ this.locationShowSuggestions = false;
2075
+ }
2076
+ });
2077
+ }
2078
+
2079
+ onLocationSuggestionSelect(prediction: any): void {
2080
+ if (!prediction) return;
2081
+ const isMulti = this.config.locationConfig?.allowMulti;
2082
+ const max = isMulti ? (this.config.locationConfig?.maxLocations ?? 5) : 1;
2083
+ const current = this.locationVenues;
2084
+ if (current.length >= max) return;
2085
+
2086
+ const newItem: LocationItem = {
2087
+ description: prediction.description,
2088
+ placeId: prediction.place_id,
2089
+ address: prediction.description,
2090
+ type: 'GOOGLE',
2091
+ isActive: true
2092
+ };
2093
+
2094
+ // Geocode to get lat/lng and components
2095
+ if ((window as any).google?.maps?.Geocoder) {
2096
+ const geocoder = new (window as any).google.maps.Geocoder();
2097
+ geocoder.geocode({ placeId: prediction.place_id }, (results: any[], status: string) => {
2098
+ if (status === 'OK' && results[0]) {
2099
+ const res = results[0];
2100
+ newItem.latitude = res.geometry?.location?.lat();
2101
+ newItem.longitude = res.geometry?.location?.lng();
2102
+ newItem.address = res.formatted_address || prediction.description;
2103
+
2104
+ let name = prediction.description;
2105
+ if (prediction.structured_formatting && prediction.structured_formatting.main_text) {
2106
+ name = prediction.structured_formatting.main_text;
2107
+ }
2108
+ newItem.name = name;
2109
+
2110
+ res.address_components?.forEach((c: any) => {
2111
+ if (c.types.includes('locality')) newItem.cityLabel = c.long_name;
2112
+ if (c.types.includes('administrative_area_level_1')) newItem.stateLabel = c.long_name;
2113
+ if (c.types.includes('country')) newItem.countryLabel = c.long_name;
2114
+ });
2115
+ }
2116
+ this._addVenueAndUpdate(newItem);
2117
+ });
2118
+ } else {
2119
+ this._addVenueAndUpdate(newItem);
2120
+ }
2121
+
2122
+ this.locationSearchText = '';
2123
+ this.locationSuggestions = [];
2124
+ this.locationShowSuggestions = false;
2125
+ }
2126
+
2127
+ private _addVenueAndUpdate(item: LocationItem): void {
2128
+ const val = { ...this.locationValue };
2129
+ val.venues = [...(val.venues || []), item];
2130
+ this.updateValue(val);
2131
+ setTimeout(() => this._renderMap());
2132
+ }
2133
+
2134
+ removeLocationVenue(index: number): void {
2135
+ const val = { ...this.locationValue };
2136
+ val.venues = (val.venues || []).filter((_, i) => i !== index);
2137
+ this.updateValue(val);
2138
+ setTimeout(() => this._renderMap());
2139
+ }
2140
+
2141
+ onLocationUrlChange(url: string): void {
2142
+ const val = { ...this.locationValue, onlineUrl: url };
2143
+ this.updateValue(val);
2144
+ }
2145
+
2146
+ hideLocationSuggestions(): void {
2147
+ setTimeout(() => { this.locationShowSuggestions = false; }, 200);
2148
+ }
2149
+
2150
+ getLocationMapEmbedUrl(): string {
2151
+ const venues = this.locationVenues.filter(v => v.placeId || (typeof v.latitude === 'number' && typeof v.longitude === 'number'));
2152
+ const apiKey = this.config.locationConfig?.googleMapsApiKey || '';
2153
+
2154
+ // No locations currently selected: Show the default map view (India)
2155
+ if (!venues.length) {
2156
+ if (apiKey) {
2157
+ // Embed API center fallback if they have an API Key but no marker yet
2158
+ const lat = this.config.locationConfig?.defaultLat ?? 20.5937;
2159
+ const lng = this.config.locationConfig?.defaultLng ?? 78.9629;
2160
+ const zoom = this.config.locationConfig?.defaultZoom ?? 4;
2161
+ return `https://www.google.com/maps/embed/v1/view?key=${apiKey}&center=${lat},${lng}&zoom=${zoom}`;
2162
+ } else {
2163
+ // Static iframe fallback text query
2164
+ return `https://maps.google.com/maps?q=India&output=embed`;
2165
+ }
2166
+ }
2167
+
2168
+ if (venues.length === 1) {
2169
+ const v = venues[0];
2170
+ const q = v.placeId ? `place_id:${v.placeId}` : encodeURIComponent(v.address || v.description || '');
2171
+ return `https://www.google.com/maps/embed/v1/place?key=${apiKey}&q=${q}`;
2172
+ }
2173
+ // Multiple locations — use directions/search embed
2174
+ const q = encodeURIComponent(venues.map(v => v.address || v.description).join('|'));
2175
+ return `https://www.google.com/maps/embed/v1/search?key=${apiKey}&q=${q}`;
2176
+ }
2177
+
2178
+ private _renderMap(): void {
2179
+ if (!(window as any).google?.maps) return;
2180
+ if (!this.config.locationConfig?.showMap && this.config.locationConfig?.showMap !== undefined) return;
2181
+
2182
+ const venues = this.locationVenues.filter(v => typeof v.latitude === 'number' && typeof v.longitude === 'number');
2183
+
2184
+ const mapEl = document.getElementById(`loc-map-${this.config.name}`);
2185
+ if (!mapEl) return;
2186
+
2187
+ if (!this._googleMap) {
2188
+ // Default to India if no venues exist
2189
+ const defaultLat = this.config.locationConfig?.defaultLat ?? 20.5937;
2190
+ const defaultLng = this.config.locationConfig?.defaultLng ?? 78.9629;
2191
+ const defaultZoom = this.config.locationConfig?.defaultZoom ?? 4;
2192
+
2193
+ this._googleMap = new (window as any).google.maps.Map(mapEl, {
2194
+ zoom: venues.length === 1 ? 12 : defaultZoom,
2195
+ center: venues.length > 0
2196
+ ? { lat: venues[0].latitude, lng: venues[0].longitude }
2197
+ : { lat: defaultLat, lng: defaultLng }
2198
+ });
2199
+ } else {
2200
+ if (venues.length === 0) {
2201
+ // Re-center on default
2202
+ const lat = this.config.locationConfig?.defaultLat ?? 20.5937;
2203
+ const lng = this.config.locationConfig?.defaultLng ?? 78.9629;
2204
+ this._googleMap.setCenter({ lat, lng });
2205
+ this._googleMap.setZoom(this.config.locationConfig?.defaultZoom ?? 4);
2206
+ } else if (venues.length === 1) {
2207
+ this._googleMap.setCenter({ lat: venues[0].latitude, lng: venues[0].longitude });
2208
+ this._googleMap.setZoom(12);
2209
+ }
2210
+ }
2211
+
2212
+ // Clear old markers
2213
+ this._mapMarkers.forEach(m => m.setMap(null));
2214
+ this._mapMarkers = [];
2215
+
2216
+ venues.forEach(v => {
2217
+ const marker = new (window as any).google.maps.Marker({
2218
+ position: { lat: v.latitude, lng: v.longitude },
2219
+ map: this._googleMap,
2220
+ title: v.name || v.address || v.description
2221
+ });
2222
+ this._mapMarkers.push(marker);
2223
+ });
2224
+
2225
+ if (venues.length > 1) {
2226
+ const bounds = new (window as any).google.maps.LatLngBounds();
2227
+ venues.forEach(v => bounds.extend({ lat: v.latitude!, lng: v.longitude! }));
2228
+ this._googleMap.fitBounds(bounds);
2229
+ }
2230
+ }
2231
+ }
2232
+