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.
- package/commons-shared-web-ui-1.0.0.tgz +0 -0
- package/documentation/alert.md +123 -0
- package/documentation/button-dropdown.md +126 -0
- package/documentation/button.md +184 -0
- package/documentation/cards-usage-guidelines.md +131 -0
- package/documentation/configurable-form.md +605 -0
- package/documentation/confirmation-modal.md +250 -0
- package/documentation/filter-sidebar.md +178 -0
- package/documentation/filter-table-selector.md +228 -0
- package/documentation/form-builder.md +597 -0
- package/documentation/form-components.md +384 -0
- package/documentation/nav.md +427 -0
- package/documentation/pagination.md +181 -0
- package/documentation/side-nav-documentation.md +169 -0
- package/documentation/smart-form.md +2177 -0
- package/documentation/smart-table.md +1198 -0
- package/documentation/snackbar.md +118 -0
- package/documentation/style-externalization.md +88 -0
- package/documentation/summary-card.md +279 -0
- package/ng-package.json +28 -0
- package/package.json +54 -0
- package/src/lib/modules/alert/alert.models.ts +6 -0
- package/src/lib/modules/alert/alert.module.ts +16 -0
- package/src/lib/modules/alert/alert.theme.scss +85 -0
- package/src/lib/modules/alert/components/alert/alert.component.html +27 -0
- package/src/lib/modules/alert/components/alert/alert.component.scss +92 -0
- package/src/lib/modules/alert/components/alert/alert.component.ts +81 -0
- package/src/lib/modules/button/button.models.ts +13 -0
- package/src/lib/modules/button/button.module.ts +16 -0
- package/src/lib/modules/button/button.theme.scss +121 -0
- package/src/lib/modules/button/components/button/button.component.html +22 -0
- package/src/lib/modules/button/components/button/button.component.scss +88 -0
- package/src/lib/modules/button/components/button/button.component.ts +67 -0
- package/src/lib/modules/button-dropdown/button-dropdown.models.ts +26 -0
- package/src/lib/modules/button-dropdown/button-dropdown.module.ts +22 -0
- package/src/lib/modules/button-dropdown/button-dropdown.theme.scss +87 -0
- package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.html +41 -0
- package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.scss +135 -0
- package/src/lib/modules/button-dropdown/components/button-dropdown/button-dropdown.component.ts +160 -0
- package/src/lib/modules/configurable-form/component/configurable-form.component.html +294 -0
- package/src/lib/modules/configurable-form/component/configurable-form.component.scss +503 -0
- package/src/lib/modules/configurable-form/component/configurable-form.component.ts +628 -0
- package/src/lib/modules/configurable-form/configurable-form.examples.ts +154 -0
- package/src/lib/modules/configurable-form/configurable-form.model.ts +131 -0
- package/src/lib/modules/configurable-form/configurable-form.module.ts +19 -0
- package/src/lib/modules/configurable-form/configurable-form.theme.scss +78 -0
- package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.html +77 -0
- package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.scss +395 -0
- package/src/lib/modules/confirmation-modal/components/confirmation-modal/confirmation-modal.component.ts +266 -0
- package/src/lib/modules/confirmation-modal/confirmation-modal.models.ts +71 -0
- package/src/lib/modules/confirmation-modal/confirmation-modal.module.ts +20 -0
- package/src/lib/modules/confirmation-modal/confirmation-modal.theme.scss +87 -0
- package/src/lib/modules/filter/components/filter/filter.component.html +131 -0
- package/src/lib/modules/filter/components/filter/filter.component.scss +245 -0
- package/src/lib/modules/filter/components/filter/filter.component.ts +216 -0
- package/src/lib/modules/filter/filter.models.ts +88 -0
- package/src/lib/modules/filter/filter.module.ts +24 -0
- package/src/lib/modules/filter/filter.theme.scss +92 -0
- package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.html +112 -0
- package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.scss +186 -0
- package/src/lib/modules/filter-sidebar/components/filter-sidebar/filter-sidebar.component.ts +163 -0
- package/src/lib/modules/filter-sidebar/filter-sidebar.models.ts +95 -0
- package/src/lib/modules/filter-sidebar/filter-sidebar.module.ts +24 -0
- package/src/lib/modules/filter-sidebar/filter-sidebar.theme.scss +38 -0
- package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.html +73 -0
- package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.scss +321 -0
- package/src/lib/modules/filter-table-selector/components/filter-table-selector/filter-table-selector.component.ts +361 -0
- package/src/lib/modules/filter-table-selector/filter-table-selector.models.ts +91 -0
- package/src/lib/modules/filter-table-selector/filter-table-selector.module.ts +22 -0
- package/src/lib/modules/filter-table-selector/filter-table-selector.theme.scss +36 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.html +63 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.scss +496 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-config-panel/configurator-config-panel.component.ts +445 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.html +75 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.scss +210 -0
- package/src/lib/modules/form-builder/components/field-configurator/configurator-tree/configurator-tree.component.ts +55 -0
- package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.html +25 -0
- package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.scss +82 -0
- package/src/lib/modules/form-builder/components/field-configurator/field-configurator.component.ts +95 -0
- package/src/lib/modules/form-builder/components/field-selection/field-selection.component.html +20 -0
- package/src/lib/modules/form-builder/components/field-selection/field-selection.component.scss +37 -0
- package/src/lib/modules/form-builder/components/field-selection/field-selection.component.ts +94 -0
- package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.html +46 -0
- package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.scss +102 -0
- package/src/lib/modules/form-builder/components/field-selection/group-node/group-node.component.ts +50 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.html +35 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.scss +67 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-field-node/selection-field-node.component.ts +34 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.html +68 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.scss +113 -0
- package/src/lib/modules/form-builder/components/field-selection/selection-section-node/selection-section-node.component.ts +74 -0
- package/src/lib/modules/form-builder/configs/field-type-schema.map.ts +533 -0
- package/src/lib/modules/form-builder/form-builder.module.ts +36 -0
- package/src/lib/modules/form-builder/form-builder.theme.scss +212 -0
- package/src/lib/modules/form-builder/index.ts +9 -0
- package/src/lib/modules/form-builder/models/builder.models.ts +7 -0
- package/src/lib/modules/form-builder/models/field-configurator.models.ts +38 -0
- package/src/lib/modules/form-builder/models/field-selection.models.ts +51 -0
- package/src/lib/modules/form-builder/services/field-configurator.service.ts +258 -0
- package/src/lib/modules/form-builder/services/field-selection.service.ts +300 -0
- package/src/lib/modules/form-builder/services/form-schema-tree.service.ts +652 -0
- package/src/lib/modules/form-builder/tokens/builder.tokens.ts +10 -0
- package/src/lib/modules/form-builder/utils/constants.ts +43 -0
- package/src/lib/modules/form-components/components/checkbox/_theme.scss +63 -0
- package/src/lib/modules/form-components/components/checkbox/checkbox.component.html +29 -0
- package/src/lib/modules/form-components/components/checkbox/checkbox.component.scss +111 -0
- package/src/lib/modules/form-components/components/checkbox/checkbox.component.ts +207 -0
- package/src/lib/modules/form-components/components/checkbox/checkbox.models.ts +35 -0
- package/src/lib/modules/form-components/components/datepicker/_theme.scss +82 -0
- package/src/lib/modules/form-components/components/datepicker/datepicker.component.html +42 -0
- package/src/lib/modules/form-components/components/datepicker/datepicker.component.scss +115 -0
- package/src/lib/modules/form-components/components/datepicker/datepicker.component.ts +267 -0
- package/src/lib/modules/form-components/components/datepicker/datepicker.models.ts +45 -0
- package/src/lib/modules/form-components/components/dropdown/_theme.scss +91 -0
- package/src/lib/modules/form-components/components/dropdown/dropdown.component.html +74 -0
- package/src/lib/modules/form-components/components/dropdown/dropdown.component.scss +252 -0
- package/src/lib/modules/form-components/components/dropdown/dropdown.component.ts +377 -0
- package/src/lib/modules/form-components/components/dropdown/dropdown.models.ts +53 -0
- package/src/lib/modules/form-components/components/input/_theme.scss +77 -0
- package/src/lib/modules/form-components/components/input/input.component.html +51 -0
- package/src/lib/modules/form-components/components/input/input.component.scss +128 -0
- package/src/lib/modules/form-components/components/input/input.component.ts +250 -0
- package/src/lib/modules/form-components/components/input/input.models.ts +55 -0
- package/src/lib/modules/form-components/components/radio/_theme.scss +61 -0
- package/src/lib/modules/form-components/components/radio/radio.component.html +22 -0
- package/src/lib/modules/form-components/components/radio/radio.component.scss +107 -0
- package/src/lib/modules/form-components/components/radio/radio.component.ts +181 -0
- package/src/lib/modules/form-components/components/radio/radio.models.ts +39 -0
- package/src/lib/modules/form-components/components/search/_theme.scss +73 -0
- package/src/lib/modules/form-components/components/search/search.component.html +15 -0
- package/src/lib/modules/form-components/components/search/search.component.scss +87 -0
- package/src/lib/modules/form-components/components/search/search.component.ts +213 -0
- package/src/lib/modules/form-components/components/search/search.models.ts +40 -0
- package/src/lib/modules/form-components/components/toggle/_theme.scss +45 -0
- package/src/lib/modules/form-components/components/toggle/toggle.component.html +15 -0
- package/src/lib/modules/form-components/components/toggle/toggle.component.scss +81 -0
- package/src/lib/modules/form-components/components/toggle/toggle.component.ts +166 -0
- package/src/lib/modules/form-components/components/toggle/toggle.models.ts +27 -0
- package/src/lib/modules/form-components/directives/click-outside.directive.ts +22 -0
- package/src/lib/modules/form-components/form-components.module.ts +41 -0
- package/src/lib/modules/form-components/form-components.theme.scss +25 -0
- package/src/lib/modules/material/material.module.ts +94 -0
- package/src/lib/modules/nav/components/nav/nav.component.html +34 -0
- package/src/lib/modules/nav/components/nav/nav.component.scss +171 -0
- package/src/lib/modules/nav/components/nav/nav.component.ts +82 -0
- package/src/lib/modules/nav/nav.models.ts +31 -0
- package/src/lib/modules/nav/nav.module.ts +17 -0
- package/src/lib/modules/nav/nav.theme.scss +86 -0
- package/src/lib/modules/pagination/components/pagination/pagination.component.html +52 -0
- package/src/lib/modules/pagination/components/pagination/pagination.component.scss +155 -0
- package/src/lib/modules/pagination/components/pagination/pagination.component.ts +109 -0
- package/src/lib/modules/pagination/pagination.module.ts +17 -0
- package/src/lib/modules/pagination/pagination.theme.scss +66 -0
- package/src/lib/modules/side-nav/components/side-nav/side-nav.component.html +56 -0
- package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +342 -0
- package/src/lib/modules/side-nav/components/side-nav/side-nav.component.ts +135 -0
- package/src/lib/modules/side-nav/side-nav.models.ts +38 -0
- package/src/lib/modules/side-nav/side-nav.module.ts +16 -0
- package/src/lib/modules/side-nav/side-nav.theme.scss +111 -0
- package/src/lib/modules/smart-form/components/form-field/form-field.component.html +1109 -0
- package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +1860 -0
- package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +2232 -0
- package/src/lib/modules/smart-form/components/form-section/form-section.component.html +64 -0
- package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +209 -0
- package/src/lib/modules/smart-form/components/form-section/form-section.component.ts +119 -0
- package/src/lib/modules/smart-form/components/smart-form/smart-form.component.html +253 -0
- package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +689 -0
- package/src/lib/modules/smart-form/components/smart-form/smart-form.component.ts +1087 -0
- package/src/lib/modules/smart-form/index.ts +10 -0
- package/src/lib/modules/smart-form/models/form-schema.model.ts +700 -0
- package/src/lib/modules/smart-form/models/hierarchy-config.model.ts +21 -0
- package/src/lib/modules/smart-form/services/expression.service.ts +75 -0
- package/src/lib/modules/smart-form/services/smart-form-controller.service.ts +65 -0
- package/src/lib/modules/smart-form/smart-form.examples.ts +1324 -0
- package/src/lib/modules/smart-form/smart-form.module.ts +36 -0
- package/src/lib/modules/smart-form/smart-form.theme.scss +890 -0
- package/src/lib/modules/smart-form/utils/translation.utils.ts +82 -0
- package/src/lib/modules/smart-form/utils/trusted-url.pipe.ts +25 -0
- package/src/lib/modules/smart-form/utils/validation.utils.ts +98 -0
- package/src/lib/modules/smart-table/components/smart-table/smart-table.component.html +283 -0
- package/src/lib/modules/smart-table/components/smart-table/smart-table.component.scss +685 -0
- package/src/lib/modules/smart-table/components/smart-table/smart-table.component.ts +1118 -0
- package/src/lib/modules/smart-table/models/table-config.model.ts +202 -0
- package/src/lib/modules/smart-table/smart-table.module.ts +30 -0
- package/src/lib/modules/smart-table/smart-table.theme.scss +335 -0
- package/src/lib/modules/smart-table/utils/safe-html.pipe.ts +22 -0
- package/src/lib/modules/smart-table/utils/smart-table.utils.ts +18 -0
- package/src/lib/modules/snackbar/components/snackbar.component.html +41 -0
- package/src/lib/modules/snackbar/components/snackbar.component.scss +99 -0
- package/src/lib/modules/snackbar/components/snackbar.component.ts +18 -0
- package/src/lib/modules/snackbar/models/snackbar.models.ts +10 -0
- package/src/lib/modules/snackbar/services/snackbar.service.ts +40 -0
- package/src/lib/modules/snackbar/snackbar.module.ts +11 -0
- package/src/lib/modules/snackbar/snackbar.theme.scss +93 -0
- package/src/lib/modules/summary-card/components/summary-card/summary-card.component.html +47 -0
- package/src/lib/modules/summary-card/components/summary-card/summary-card.component.scss +199 -0
- package/src/lib/modules/summary-card/components/summary-card/summary-card.component.ts +126 -0
- package/src/lib/modules/summary-card/summary-card.module.ts +18 -0
- package/src/lib/modules/summary-card/summary-card.theme.scss +176 -0
- package/src/lib/shared-ui.module.ts +44 -0
- package/src/lib/styles/global.scss +152 -0
- package/src/lib/styles/utilities.scss +250 -0
- package/src/lib/utils/constants.ts +11 -0
- package/src/lib/utils/storage.utils.ts +37 -0
- package/src/lib/utils/string.utils.ts +23 -0
- package/src/lib/utils/translation.utils.ts +87 -0
- package/src/public-api.ts +104 -0
- 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}¢er=${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
|
+
|