@slickgrid-universal/composite-editor-component 4.0.3 → 4.2.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.
@@ -0,0 +1,1024 @@
1
+ import { BindingEventService } from '@slickgrid-universal/binding';
2
+ import { deepCopy, deepMerge, emptyObject, setDeepValue } from '@slickgrid-universal/utils';
3
+ import type {
4
+ Column,
5
+ CompositeEditorLabel,
6
+ CompositeEditorModalType,
7
+ CompositeEditorOpenDetailOption,
8
+ CompositeEditorOption,
9
+ ContainerService,
10
+ DOMEvent,
11
+ Editor,
12
+ EditorValidationResult,
13
+ ExternalResource,
14
+ GridOption,
15
+ GridService,
16
+ Locale,
17
+ OnCompositeEditorChangeEventArgs,
18
+ OnErrorOption,
19
+ PlainFunc,
20
+ SlickDataView,
21
+ SlickGrid,
22
+ TranslaterService,
23
+ } from '@slickgrid-universal/common';
24
+ import {
25
+ Constants,
26
+ createDomElement,
27
+ getDescendantProperty,
28
+ numericSortComparer,
29
+ SlickEventHandler,
30
+ SortDirectionNumber,
31
+ } from '@slickgrid-universal/common';
32
+
33
+ import { SlickCompositeEditor } from './compositeEditor.factory';
34
+
35
+ const DEFAULT_ON_ERROR = (error: OnErrorOption) => console.log(error.message);
36
+
37
+ type ApplyChangesCallbackFn = (
38
+ formValues: { [columnId: string]: any; } | null,
39
+ selection: { gridRowIndexes: number[]; dataContextIds: Array<number | string>; },
40
+ applyToDataview?: boolean,
41
+ ) => any[] | void | undefined;
42
+
43
+ type DataSelection = {
44
+ gridRowIndexes: number[];
45
+ dataContextIds: Array<number | string>;
46
+ };
47
+
48
+ export class SlickCompositeEditorComponent implements ExternalResource {
49
+ protected _bindEventService: BindingEventService;
50
+ protected _columnDefinitions: Column[] = [];
51
+ protected _compositeOptions!: CompositeEditorOption;
52
+ protected _eventHandler: SlickEventHandler;
53
+ protected _itemDataContext: any;
54
+ protected _modalElm!: HTMLDivElement;
55
+ protected _originalDataContext: any;
56
+ protected _options!: CompositeEditorOpenDetailOption;
57
+ protected _lastActiveRowNumber = -1;
58
+ protected _locales!: Locale;
59
+ protected _formValues: { [columnId: string]: any; } | null = null;
60
+ protected _editors!: { [columnId: string]: Editor; };
61
+ protected _editorContainers!: Array<HTMLElement | null>;
62
+ protected _modalBodyTopValidationElm!: HTMLDivElement;
63
+ protected _modalSaveButtonElm!: HTMLButtonElement;
64
+ protected grid!: SlickGrid;
65
+ protected gridService: GridService | null = null;
66
+ protected translaterService?: TranslaterService | null;
67
+
68
+ get eventHandler(): SlickEventHandler {
69
+ return this._eventHandler;
70
+ }
71
+
72
+ get dataView(): SlickDataView {
73
+ return this.grid?.getData<SlickDataView>();
74
+ }
75
+
76
+ get dataViewLength(): number {
77
+ return this.dataView.getLength();
78
+ }
79
+
80
+ get formValues(): any {
81
+ return this._formValues;
82
+ }
83
+
84
+ get editors(): { [columnId: string]: Editor; } {
85
+ return this._editors;
86
+ }
87
+ set editors(editors: { [columnId: string]: Editor; }) {
88
+ this._editors = editors;
89
+ }
90
+
91
+ get gridOptions(): GridOption {
92
+ return this.grid?.getOptions();
93
+ }
94
+
95
+ constructor() {
96
+ this._eventHandler = new SlickEventHandler();
97
+ this._bindEventService = new BindingEventService();
98
+ }
99
+
100
+ /**
101
+ * initialize the Composite Editor by passing the SlickGrid object and the container service
102
+ *
103
+ * Note: we aren't using DI in the constructor simply to be as framework agnostic as possible,
104
+ * we are simply using this init() function with a very basic container service to do the job
105
+ */
106
+ init(grid: SlickGrid, containerService: ContainerService) {
107
+ this.grid = grid;
108
+ this.gridService = containerService.get<GridService>('GridService');
109
+ this.translaterService = containerService.get<TranslaterService>('TranslaterService');
110
+
111
+ if (!this.gridService) {
112
+ throw new Error('[Slickgrid-Universal] it seems that the GridService is not being loaded properly, make sure the Container Service is properly implemented.');
113
+ }
114
+
115
+ if (this.gridOptions.enableTranslate && (!this.translaterService || !this.translaterService.translate)) {
116
+ throw new Error('[Slickgrid-Universal] requires a Translate Service to be installed and configured when the grid option "enableTranslate" is enabled.');
117
+ }
118
+
119
+ // get locales provided by user in forRoot or else use default English locales via the Constants
120
+ this._locales = this.gridOptions?.locales ?? Constants.locales;
121
+ }
122
+
123
+ /** Dispose of the Component & unsubscribe all events */
124
+ dispose() {
125
+ this._eventHandler.unsubscribeAll();
126
+ this._bindEventService.unbindAll();
127
+ this._formValues = null;
128
+ this.disposeComponent();
129
+ }
130
+
131
+ /** Dispose of the Component without unsubscribing any events */
132
+ disposeComponent() {
133
+ // protected _editorContainers!: Array<HTMLElement | null>;
134
+ this._modalBodyTopValidationElm?.remove();
135
+ this._modalSaveButtonElm?.remove();
136
+
137
+ if (typeof this._modalElm?.remove === 'function') {
138
+ this._modalElm.remove();
139
+
140
+ // remove the body backdrop click listener, every other listeners will be dropped automatically since we destroy the component
141
+ document.body.classList.remove('slick-modal-open');
142
+ }
143
+ this._editorContainers = [];
144
+ }
145
+
146
+ /**
147
+ * Dynamically change value of an input from the Composite Editor form.
148
+ *
149
+ * NOTE: user might get an error thrown when trying to apply a value on a Composite Editor that was not found in the form,
150
+ * but in some cases the user might still want the value to be applied to the formValues so that it will be sent to the save in final item data context
151
+ * and when that happens, you can just skip that error so it won't throw.
152
+ * @param {String | Column} columnIdOrDef - column id or column definition
153
+ * @param {*} newValue - the new value
154
+ * @param {Boolean} skipMissingEditorError - defaults to False, skipping the error when the Composite Editor was not found will allow to still apply the value into the formValues object
155
+ * @param {Boolean} triggerOnCompositeEditorChange - defaults to True, will this change trigger a onCompositeEditorChange event?
156
+ */
157
+ changeFormInputValue(columnIdOrDef: string | Column, newValue: any, skipMissingEditorError = false, triggerOnCompositeEditorChange = true) {
158
+ const columnDef = this.getColumnByObjectOrId(columnIdOrDef);
159
+ const columnId = typeof columnIdOrDef === 'string' ? columnIdOrDef : columnDef?.id ?? '';
160
+ const editor = this._editors?.[columnId];
161
+ let outputValue = newValue;
162
+
163
+ if (!editor && !skipMissingEditorError) {
164
+ throw new Error(`Composite Editor with column id "${columnId}" not found.`);
165
+ }
166
+
167
+ if (typeof editor?.setValue === 'function' && Array.isArray(this._editorContainers)) {
168
+ editor.setValue(newValue, true, triggerOnCompositeEditorChange);
169
+ const editorContainerElm = (this._editorContainers as HTMLElement[]).find(editorElm => editorElm!.dataset!.editorid === columnId);
170
+ const excludeDisabledFieldFormValues = this.gridOptions?.compositeEditorOptions?.excludeDisabledFieldFormValues ?? false;
171
+
172
+ if (!editor.disabled || (editor.disabled && !excludeDisabledFieldFormValues)) {
173
+ editorContainerElm?.classList?.add('modified');
174
+ } else {
175
+ outputValue = '';
176
+ editorContainerElm?.classList?.remove('modified');
177
+ }
178
+
179
+ // when the field is disabled, we will only allow a blank value anything else will be disregarded
180
+ if (editor.disabled && (outputValue !== '' || outputValue !== null || outputValue !== undefined || outputValue !== 0)) {
181
+ outputValue = '';
182
+ }
183
+ }
184
+
185
+ // is the field a complex object, like "address.streetNumber"
186
+ // we'll set assign the value as a complex object following the `field` dot notation
187
+ const fieldName = columnDef?.field ?? '';
188
+ if (columnDef && fieldName?.includes('.')) {
189
+ // when it's a complex object, user could override the object path (where the editable object is located)
190
+ // else we use the path provided in the Field Column Definition
191
+ const objectPath = columnDef.internalColumnEditor?.complexObjectPath ?? fieldName ?? '';
192
+ setDeepValue(this._formValues ?? {}, objectPath, newValue);
193
+ } else {
194
+ this._formValues = { ...this._formValues, [columnId]: outputValue };
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Dynamically update the `formValues` object directly without triggering the onCompositeEditorChange event.
200
+ * The fact that this doesn't trigger an event, might not always be good though, in these cases you are probably better with using the changeFormInputValue() method
201
+ * @param {String | Column} columnIdOrDef - column id or column definition
202
+ * @param {*} newValue - the new value
203
+ */
204
+ changeFormValue(columnIdOrDef: string | Column, newValue: any) {
205
+ const columnDef = this.getColumnByObjectOrId(columnIdOrDef);
206
+ const columnId = typeof columnIdOrDef === 'string' ? columnIdOrDef : columnDef?.id ?? '';
207
+
208
+ // is the field a complex object, like "address.streetNumber"
209
+ // we'll set assign the value as a complex object following the `field` dot notation
210
+ const fieldName = columnDef?.field ?? columnIdOrDef as string;
211
+ if (fieldName?.includes('.')) {
212
+ // when it's a complex object, user could override the object path (where the editable object is located)
213
+ // else we use the path provided in the Field Column Definition
214
+ const objectPath = columnDef?.internalColumnEditor?.complexObjectPath ?? fieldName ?? '';
215
+ setDeepValue(this._formValues, objectPath, newValue);
216
+ } else {
217
+ this._formValues = { ...this._formValues, [columnId]: newValue };
218
+ }
219
+
220
+ this._formValues = deepMerge({}, this._itemDataContext, this._formValues);
221
+ }
222
+
223
+ /**
224
+ * Dynamically change an Editor option of the Composite Editor form
225
+ * For example, a use case could be to dynamically change the "minDate" of another Date Editor in the Composite Editor form.
226
+ * @param {String} columnId - column id
227
+ * @param {*} newValue - the new value
228
+ */
229
+ changeFormEditorOption(columnId: string, optionName: string, newOptionValue: any) {
230
+ const editor = this._editors?.[columnId];
231
+
232
+ // change an Editor option (not all Editors have that method, so make sure it exists before trying to call it)
233
+ if (editor?.changeEditorOption) {
234
+ editor.changeEditorOption(optionName, newOptionValue);
235
+ } else {
236
+ throw new Error(`Editor with column id "${columnId}" not found OR the Editor does not support "changeEditorOption" (current only available with AutoComplete, Date, MultipleSelect & SingleSelect Editors).`);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Disable (or enable) an input of the Composite Editor form
242
+ * @param {String} columnId - column definition id
243
+ * @param isDisabled - defaults to True, are we disabling the associated form input
244
+ */
245
+ disableFormInput(columnId: string, isDisabled = true) {
246
+ const editor = this._editors?.[columnId];
247
+ if (editor?.disable && Array.isArray(this._editorContainers)) {
248
+ editor.disable(isDisabled);
249
+ }
250
+ }
251
+
252
+ /** Entry point to initialize and open the Composite Editor modal window */
253
+ openDetails(options: CompositeEditorOpenDetailOption): SlickCompositeEditorComponent | null {
254
+ const onError = options.onError ?? DEFAULT_ON_ERROR;
255
+ const defaultOptions = {
256
+ backdrop: 'static',
257
+ showCloseButtonOutside: true,
258
+ shouldClearRowSelectionAfterMassAction: true,
259
+ viewColumnLayout: 'auto',
260
+ modalType: 'edit',
261
+ } as CompositeEditorOpenDetailOption;
262
+
263
+ try {
264
+ if (!this.grid || (this.grid.getEditorLock().isActive() && !this.grid.getEditorLock().commitCurrentEdit())) {
265
+ return null;
266
+ }
267
+
268
+ this._formValues = null; // make sure there's no leftover from previous change
269
+ this._options = { ...defaultOptions, ...this.gridOptions.compositeEditorOptions, ...options, labels: { ...this.gridOptions.compositeEditorOptions?.labels, ...options?.labels } }; // merge default options with user options
270
+ this._options.backdrop = options.backdrop !== undefined ? options.backdrop : 'static';
271
+ const viewColumnLayout = this._options.viewColumnLayout || 1;
272
+ const activeCell = this.grid.getActiveCell();
273
+ const activeColIndex = activeCell?.cell ?? 0;
274
+ const activeRow = activeCell?.row ?? 0;
275
+ const gridUid = this.grid.getUID() || '';
276
+ let headerTitle = options.headerTitle || '';
277
+
278
+ // execute callback before creating the modal window (that is in short the first event in the lifecycle)
279
+ if (typeof this._options.onBeforeOpen === 'function') {
280
+ this._options.onBeforeOpen();
281
+ }
282
+
283
+ if (this.hasRowSelectionEnabled() && this._options.modalType === 'auto-mass' && this.grid.getSelectedRows) {
284
+ const selectedRowsIndexes = this.grid.getSelectedRows() || [];
285
+ if (selectedRowsIndexes.length > 0) {
286
+ this._options.modalType = 'mass-selection';
287
+ if (options?.headerTitleMassSelection) {
288
+ headerTitle = options?.headerTitleMassSelection;
289
+ }
290
+ } else {
291
+ this._options.modalType = 'mass-update';
292
+ if (options?.headerTitleMassUpdate) {
293
+ headerTitle = options?.headerTitleMassUpdate;
294
+ }
295
+ }
296
+ }
297
+ const modalType: CompositeEditorModalType = this._options.modalType || 'edit';
298
+
299
+ if (!this.gridOptions.editable) {
300
+ onError({ type: 'error', code: 'EDITABLE_GRID_REQUIRED', message: 'Your grid must be editable in order to use the Composite Editor Modal.' });
301
+ return null;
302
+ } else if (!this.gridOptions.enableCellNavigation) {
303
+ onError({ type: 'error', code: 'ENABLE_CELL_NAVIGATION_REQUIRED', message: 'Composite Editor requires the flag "enableCellNavigation" to be set to True in your Grid Options.' });
304
+ return null;
305
+ } else if (!this.gridOptions.enableAddRow && (modalType === 'clone' || modalType === 'create')) {
306
+ onError({ type: 'error', code: 'ENABLE_ADD_ROW_REQUIRED', message: 'Composite Editor requires the flag "enableAddRow" to be set to True in your Grid Options when cloning/creating a new item.' });
307
+ return null;
308
+ } else if (!activeCell && (modalType === 'clone' || modalType === 'edit')) {
309
+ onError({ type: 'warning', code: 'NO_RECORD_FOUND', message: 'No records selected for edit or clone operation.' });
310
+ return null;
311
+ } else {
312
+ const isWithMassChange = (modalType === 'mass-update' || modalType === 'mass-selection');
313
+ const dataContext = !isWithMassChange ? this.grid.getDataItem(activeRow) : {};
314
+ this._originalDataContext = deepCopy(dataContext);
315
+ this._columnDefinitions = this.grid.getColumns();
316
+ const selectedRowsIndexes = this.hasRowSelectionEnabled() ? this.grid.getSelectedRows() : [];
317
+ const fullDatasetLength = this.dataView?.getItemCount() ?? 0;
318
+ this._lastActiveRowNumber = activeRow;
319
+ const dataContextIds = this.dataView.getAllSelectedIds();
320
+
321
+ // focus on a first cell with an Editor (unless current cell already has an Editor then do nothing)
322
+ // also when it's a "Create" modal, we'll scroll to the end of the grid
323
+ const rowIndex = modalType === 'create' ? this.dataViewLength : activeRow;
324
+ const hasFoundEditor = this.focusOnFirstColumnCellWithEditor(this._columnDefinitions, dataContext, activeColIndex, rowIndex, isWithMassChange);
325
+ if (!hasFoundEditor) {
326
+ return null;
327
+ }
328
+
329
+ if (modalType === 'edit' && !dataContext) {
330
+ onError({ type: 'warning', code: 'ROW_NOT_EDITABLE', message: 'Current row is not editable.' });
331
+ return null;
332
+ } else if (modalType === 'mass-selection') {
333
+ if (selectedRowsIndexes.length < 1) {
334
+ onError({ type: 'warning', code: 'ROW_SELECTION_REQUIRED', message: 'You must select some rows before trying to apply new value(s).' });
335
+ return null;
336
+ }
337
+ }
338
+
339
+ let modalColumns: Column[] = [];
340
+ if (isWithMassChange) {
341
+ // when using Mass Update, we only care about the columns that have the "massUpdate: true", we disregard anything else
342
+ modalColumns = this._columnDefinitions.filter(col => col.editor && col.internalColumnEditor?.massUpdate === true);
343
+ } else {
344
+ modalColumns = this._columnDefinitions.filter(col => col.editor);
345
+ }
346
+
347
+ // user could optionally show the form inputs in a specific order instead of using default column definitions order
348
+ if (modalColumns.some(col => col.internalColumnEditor?.compositeEditorFormOrder !== undefined)) {
349
+ modalColumns.sort((col1: Column, col2: Column) => {
350
+ const val1 = col1?.internalColumnEditor?.compositeEditorFormOrder ?? Infinity;
351
+ const val2 = col2?.internalColumnEditor?.compositeEditorFormOrder ?? Infinity;
352
+ return numericSortComparer(val1, val2, SortDirectionNumber.asc);
353
+ });
354
+ }
355
+
356
+ // open the editor modal and we can also provide a header title with optional parsing pulled from the dataContext, via template {{ }}
357
+ // for example {{title}} => display the item title, or even complex object works {{product.name}} => display item product name
358
+ const parsedHeaderTitle = headerTitle.replace(/\{\{(.*?)\}\}/g, (_match, group) => getDescendantProperty(dataContext, group));
359
+ const layoutColCount = viewColumnLayout === 'auto' ? this.autoCalculateLayoutColumnCount(modalColumns.length) : viewColumnLayout;
360
+
361
+ this._modalElm = createDomElement('div', { className: `slick-editor-modal ${gridUid}` });
362
+ const modalContentElm = createDomElement('div', { className: 'slick-editor-modal-content' });
363
+
364
+ if ((!isNaN(viewColumnLayout as number) && +viewColumnLayout > 1) || (viewColumnLayout === 'auto' && layoutColCount > 1)) {
365
+ const splitClassName = layoutColCount === 2 ? 'split-view' : 'triple-split-view';
366
+ modalContentElm.classList.add(splitClassName);
367
+ }
368
+
369
+ const modalHeaderTitleElm = createDomElement('div', { className: 'slick-editor-modal-title' });
370
+ this.grid.applyHtmlCode(modalHeaderTitleElm, parsedHeaderTitle);
371
+
372
+ const modalCloseButtonElm = createDomElement('button', { type: 'button', ariaLabel: 'Close', textContent: '×', className: 'close', dataset: { action: 'close' } });
373
+ if (this._options.showCloseButtonOutside) {
374
+ modalHeaderTitleElm?.classList?.add('outside');
375
+ modalCloseButtonElm?.classList?.add('outside');
376
+ }
377
+
378
+ const modalHeaderElm = createDomElement('div', { ariaLabel: 'Close', className: 'slick-editor-modal-header' });
379
+ modalHeaderElm.appendChild(modalHeaderTitleElm);
380
+ modalHeaderElm.appendChild(modalCloseButtonElm);
381
+
382
+ const modalBodyElm = createDomElement('div', { className: 'slick-editor-modal-body' });
383
+ this._modalBodyTopValidationElm = createDomElement('div', { className: 'validation-summary', style: { display: 'none' } }, modalBodyElm);
384
+ const modalFooterElm = createDomElement('div', { className: 'slick-editor-modal-footer' });
385
+ const modalCancelButtonElm = createDomElement('button', {
386
+ type: 'button',
387
+ ariaLabel: this.getLabelText('cancelButton', 'TEXT_CANCEL', 'Cancel'),
388
+ className: 'btn btn-cancel btn-default btn-sm',
389
+ textContent: this.getLabelText('cancelButton', 'TEXT_CANCEL', 'Cancel'),
390
+ dataset: { action: 'cancel' },
391
+ });
392
+
393
+ let leftFooterText = '';
394
+ let saveButtonText = '';
395
+ switch (modalType) {
396
+ case 'clone':
397
+ saveButtonText = this.getLabelText('cloneButton', 'TEXT_CLONE', 'Clone');
398
+ break;
399
+ case 'mass-update':
400
+ const footerUnparsedText = this.getLabelText('massUpdateStatus', 'TEXT_ALL_X_RECORDS_SELECTED', 'All {{x}} records selected');
401
+ leftFooterText = this.parseText(footerUnparsedText, { x: fullDatasetLength });
402
+ saveButtonText = this.getLabelText('massUpdateButton', 'TEXT_APPLY_MASS_UPDATE', 'Mass Update');
403
+ break;
404
+ case 'mass-selection':
405
+ const selectionUnparsedText = this.getLabelText('massSelectionStatus', 'TEXT_X_OF_Y_MASS_SELECTED', '{{x}} of {{y}} selected');
406
+ leftFooterText = this.parseText(selectionUnparsedText, { x: dataContextIds.length, y: fullDatasetLength });
407
+ saveButtonText = this.getLabelText('massSelectionButton', 'TEXT_APPLY_TO_SELECTION', 'Update Selection');
408
+ break;
409
+ default:
410
+ saveButtonText = this.getLabelText('saveButton', 'TEXT_SAVE', 'Save');
411
+ }
412
+
413
+ const selectionCounterElm = createDomElement('div', { className: 'footer-status-text', textContent: leftFooterText });
414
+ this._modalSaveButtonElm = createDomElement('button', {
415
+ type: 'button', className: 'btn btn-save btn-primary btn-sm',
416
+ ariaLabel: saveButtonText,
417
+ textContent: saveButtonText,
418
+ dataset: {
419
+ action: (modalType === 'create' || modalType === 'edit') ? 'save' : modalType,
420
+ ariaLabel: saveButtonText
421
+ }
422
+ });
423
+
424
+ const footerContainerElm = createDomElement('div', { className: 'footer-buttons' });
425
+
426
+ if (modalType === 'mass-update' || modalType === 'mass-selection') {
427
+ modalFooterElm.appendChild(selectionCounterElm);
428
+ }
429
+ footerContainerElm.appendChild(modalCancelButtonElm);
430
+ footerContainerElm.appendChild(this._modalSaveButtonElm);
431
+ modalFooterElm.appendChild(footerContainerElm);
432
+
433
+ modalContentElm.appendChild(modalHeaderElm);
434
+ modalContentElm.appendChild(modalBodyElm);
435
+ modalContentElm.appendChild(modalFooterElm);
436
+ this._modalElm.appendChild(modalContentElm);
437
+
438
+ for (const columnDef of modalColumns) {
439
+ if (columnDef.editor) {
440
+ const itemContainer = createDomElement('div', { className: `item-details-container editor-${columnDef.id}` });
441
+
442
+ if (layoutColCount === 1) {
443
+ itemContainer.classList.add('slick-col-medium-12');
444
+ } else {
445
+ itemContainer.classList.add('slick-col-medium-6', `slick-col-xlarge-${12 / layoutColCount}`);
446
+ }
447
+
448
+ const templateItemLabelElm = createDomElement('div', { className: `item-details-label editor-${columnDef.id}` });
449
+ this.grid.applyHtmlCode(templateItemLabelElm, this.getColumnLabel(columnDef) || 'n/a');
450
+ const templateItemEditorElm = createDomElement('div', {
451
+ className: 'item-details-editor-container slick-cell',
452
+ dataset: { editorid: `${columnDef.id}` },
453
+ });
454
+ const templateItemValidationElm = createDomElement('div', { className: `item-details-validation editor-${columnDef.id}` });
455
+
456
+ // optionally add a reset button beside each editor
457
+ if (this._options?.showResetButtonOnEachEditor) {
458
+ const editorResetButtonElm = this.createEditorResetButtonElement(`${columnDef.id}`);
459
+ this._bindEventService.bind(editorResetButtonElm, 'click', this.handleResetInputValue.bind(this) as EventListener);
460
+ templateItemLabelElm.appendChild(editorResetButtonElm);
461
+ }
462
+
463
+ itemContainer.appendChild(templateItemLabelElm);
464
+ itemContainer.appendChild(templateItemEditorElm);
465
+ itemContainer.appendChild(templateItemValidationElm);
466
+ modalBodyElm.appendChild(itemContainer);
467
+ }
468
+ }
469
+
470
+ // optionally add a form reset button
471
+ if (this._options?.showFormResetButton) {
472
+ const resetButtonContainerElm = this.createFormResetButtonElement();
473
+ this._bindEventService.bind(resetButtonContainerElm, 'click', this.handleResetFormClicked.bind(this));
474
+ modalBodyElm.appendChild(resetButtonContainerElm);
475
+ }
476
+
477
+ document.body.appendChild(this._modalElm);
478
+ document.body.classList.add('slick-modal-open'); // add backdrop to body
479
+ this._bindEventService.bind(document.body, 'click', this.handleBodyClicked.bind(this));
480
+
481
+ this._editors = {};
482
+ this._editorContainers = modalColumns.map(col => modalBodyElm.querySelector<HTMLDivElement>(`[data-editorid=${col.id}]`)) || [];
483
+ this._compositeOptions = { destroy: this.disposeComponent.bind(this), modalType, validationMsgPrefix: '* ', formValues: {}, editors: this._editors };
484
+ const compositeEditor = new (SlickCompositeEditor as any)(modalColumns, this._editorContainers, this._compositeOptions) as typeof SlickCompositeEditor;
485
+ this.grid.editActiveCell(compositeEditor as any);
486
+
487
+ // --
488
+ // Add a few Event Handlers
489
+
490
+ // keyboard, blur & button event handlers
491
+ this._bindEventService.bind(modalCloseButtonElm, 'click', this.cancelEditing.bind(this) as EventListener);
492
+ this._bindEventService.bind(modalCancelButtonElm, 'click', this.cancelEditing.bind(this) as EventListener);
493
+ this._bindEventService.bind(this._modalSaveButtonElm, 'click', this.handleSaveClicked.bind(this) as EventListener);
494
+ this._bindEventService.bind(this._modalElm, 'keydown', this.handleKeyDown.bind(this) as EventListener);
495
+ this._bindEventService.bind(this._modalElm, 'focusout', this.validateCurrentEditor.bind(this) as EventListener);
496
+ this._bindEventService.bind(this._modalElm, 'blur', this.validateCurrentEditor.bind(this) as EventListener);
497
+
498
+ // when any of the input of the composite editor form changes, we'll add/remove a "modified" CSS className for styling purposes
499
+ this._eventHandler.subscribe(this.grid.onCompositeEditorChange, this.handleOnCompositeEditorChange.bind(this));
500
+
501
+ // when adding a new row to the grid, we need to invalidate that row and re-render the grid
502
+ this._eventHandler.subscribe(this.grid.onAddNewRow, (_e, args) => {
503
+ this._originalDataContext = this.insertNewItemInDataView(args.item); // this becomes the new data context
504
+ });
505
+ }
506
+ return this;
507
+
508
+ } catch (error: any) {
509
+ this.dispose();
510
+ const errorMsg = (typeof error === 'string') ? error : (error?.message ?? error?.body?.message ?? '');
511
+ const errorCode = (typeof error === 'string') ? error : error?.status ?? error?.body?.status ?? errorMsg;
512
+ onError({ type: 'error', code: errorCode, message: errorMsg });
513
+ return null;
514
+ }
515
+ }
516
+
517
+ /** Cancel the Editing which will also close the modal window */
518
+ async cancelEditing() {
519
+ let confirmed = true;
520
+ if (this.formValues && Object.keys(this.formValues).length > 0 && typeof this._options.onClose === 'function') {
521
+ confirmed = await this._options.onClose();
522
+ }
523
+
524
+ if (confirmed) {
525
+ this.grid.getEditController()?.cancelCurrentEdit();
526
+
527
+ // cancel current edit is not enough when editing/cloning,
528
+ // we also need to reset with the original item data context to undo/reset the entire row
529
+ if (this._options?.modalType === 'edit' || this._options?.modalType === 'clone') {
530
+ this.resetCurrentRowDataContext();
531
+ }
532
+
533
+ this.grid.setActiveRow(this._lastActiveRowNumber);
534
+ this.dispose();
535
+ }
536
+ }
537
+
538
+ /** Show a Validation Summary text (as a <div>) when a validation fails or simply hide it when there's no error */
539
+ showValidationSummaryText(isShowing: boolean, errorMsg = '') {
540
+ if (isShowing && errorMsg !== '') {
541
+ this._modalBodyTopValidationElm.textContent = errorMsg;
542
+ this._modalBodyTopValidationElm.style.display = 'block';
543
+ this._modalBodyTopValidationElm.scrollIntoView?.();
544
+ this._modalSaveButtonElm.disabled = false;
545
+ this._modalSaveButtonElm.classList.remove('saving');
546
+ } else {
547
+ this._modalBodyTopValidationElm.style.display = 'none';
548
+ this._modalBodyTopValidationElm.textContent = errorMsg;
549
+ }
550
+ }
551
+
552
+ // --
553
+ // protected methods
554
+ // ----------------
555
+
556
+ /** Apply Mass Update Changes (form values) to the entire dataset */
557
+ protected applySaveMassUpdateChanges(formValues: any, _selection: DataSelection, applyToDataview = true): any[] {
558
+ // not applying to dataView means that we're doing a preview of dataset and we should use a deep copy of it instead of applying changes directly to it
559
+ const data = applyToDataview ? this.dataView.getItems() : deepCopy(this.dataView.getItems());
560
+
561
+ // from the "lastCompositeEditor" object that we kept as reference, it contains all the changes inside the "formValues" property
562
+ // we can loop through these changes and apply them on the selected row indexes
563
+ Object.keys(formValues).forEach(itemProp => {
564
+ if (itemProp in formValues) {
565
+ data.forEach((dataContext: any) => {
566
+ if (itemProp in formValues && (this._options?.validateMassUpdateChange === undefined || this._options.validateMassUpdateChange(itemProp, dataContext, formValues) !== false)) {
567
+ dataContext[itemProp] = formValues[itemProp];
568
+ }
569
+ });
570
+ }
571
+ });
572
+
573
+ // change the entire dataset with our updated dataset
574
+ if (applyToDataview) {
575
+ this.dataView.setItems(data, this.gridOptions.datasetIdPropertyName);
576
+ this.grid.invalidate();
577
+ }
578
+ return data;
579
+ }
580
+
581
+ /** Apply Mass Changes to the Selected rows in the grid (form values) */
582
+ protected applySaveMassSelectionChanges(formValues: any, selection: DataSelection, applyToDataview = true): any[] {
583
+ const selectedItemIds = selection?.dataContextIds ?? [];
584
+ const selectedTmpItems = selectedItemIds.map(itemId => this.dataView.getItemById(itemId));
585
+
586
+ // not applying to dataView means that we're doing a preview of dataset and we should use a deep copy of it instead of applying changes directly to it
587
+ const selectedItems = applyToDataview ? selectedTmpItems : deepCopy(selectedTmpItems);
588
+
589
+ // from the "lastCompositeEditor" object that we kept as reference, it contains all the changes inside the "formValues" property
590
+ // we can loop through these changes and apply them on the selected row indexes
591
+ Object.keys(formValues).forEach(itemProp => {
592
+ if (itemProp in formValues) {
593
+ selectedItems.forEach((dataContext: any) => {
594
+ if (itemProp in formValues && (this._options?.validateMassUpdateChange === undefined || this._options.validateMassUpdateChange(itemProp, dataContext, formValues) !== false)) {
595
+ dataContext[itemProp] = formValues[itemProp];
596
+ }
597
+ });
598
+ }
599
+ });
600
+
601
+ // update all items in the grid with the grid service
602
+ if (applyToDataview) {
603
+ this.gridService?.updateItems(selectedItems);
604
+ }
605
+ return selectedItems;
606
+ }
607
+
608
+ /**
609
+ * Auto-Calculate how many columns to display in the view layout (1, 2, or 3).
610
+ * We'll display a 1 column layout for 8 or less Editors, 2 columns layout for less than 15 Editors or 3 columns when more than 15 Editors
611
+ * @param {number} editorCount - how many Editors do we have in total
612
+ * @returns {number} count - calculated column count (1, 2 or 3)
613
+ */
614
+ protected autoCalculateLayoutColumnCount(editorCount: number): number {
615
+ if (editorCount >= 15) {
616
+ return 3;
617
+ } else if (editorCount >= 8) {
618
+ return 2;
619
+ }
620
+ return 1;
621
+ }
622
+
623
+ /**
624
+ * Create a reset button for each editor and attach a button click handler
625
+ * @param {String} columnId - column id
626
+ * @returns {Object} - html button
627
+ */
628
+ protected createEditorResetButtonElement(columnId: string): HTMLButtonElement {
629
+ const resetButtonElm = createDomElement('button', {
630
+ type: 'button', name: columnId,
631
+ ariaLabel: 'Reset',
632
+ title: this._options?.labels?.resetFormButton ?? 'Reset Form Input',
633
+ className: 'btn btn-xs btn-editor-reset'
634
+ });
635
+
636
+ if (this._options?.resetEditorButtonCssClass) {
637
+ const resetBtnClasses = this._options?.resetEditorButtonCssClass.split(' ');
638
+ for (const cssClass of resetBtnClasses) {
639
+ resetButtonElm.classList.add(cssClass);
640
+ }
641
+ }
642
+
643
+ return resetButtonElm;
644
+ }
645
+
646
+ /**
647
+ * Create a form reset button and attach a button click handler
648
+ * @param {String} columnId - column id
649
+ * @returns {Object} - html button
650
+ */
651
+ protected createFormResetButtonElement(): HTMLDivElement {
652
+ const resetButtonContainerElm = createDomElement('div', { className: 'reset-container' });
653
+ const resetButtonElm = createDomElement('button', { type: 'button', className: 'btn btn-sm reset-form' }, resetButtonContainerElm);
654
+ createDomElement('span', { className: this._options?.resetFormButtonIconCssClass ?? '' }, resetButtonElm);
655
+ resetButtonElm.appendChild(document.createTextNode(' Reset Form'));
656
+
657
+ return resetButtonContainerElm;
658
+ }
659
+
660
+ /**
661
+ * Execute the onError callback when defined
662
+ * or use the default onError callback which is to simply display the error in the console
663
+ */
664
+ protected executeOnError(error: OnErrorOption) {
665
+ const onError = this._options?.onError ?? DEFAULT_ON_ERROR;
666
+ onError(error);
667
+ }
668
+
669
+ /**
670
+ * A simple and generic method to execute the "OnSave" callback if it's defined by the user OR else simply execute built-in apply changes callback.
671
+ * This method deals with multiple callbacks as shown below
672
+ * @param {Function} applyChangesCallback - first callback to apply the changes into the grid (this could be a user custom callback)
673
+ * @param {Function} executePostCallback - second callback to execute right after the "onSave"
674
+ * @param {Function} beforeClosingCallback - third and last callback to execute after Saving but just before closing the modal window
675
+ * @param {Object} itemDataContext - item data context when modal type is (create/clone/edit)
676
+ */
677
+ protected async executeOnSave(applyChangesCallback: ApplyChangesCallbackFn, executePostCallback: PlainFunc, beforeClosingCallback?: PlainFunc, itemDataContext?: any) {
678
+ try {
679
+ this.showValidationSummaryText(false, '');
680
+ const validationResults = this.validateCompositeEditors();
681
+
682
+ if (validationResults.valid) {
683
+ this._modalSaveButtonElm.classList.add('saving');
684
+ this._modalSaveButtonElm.disabled = true;
685
+
686
+ if (typeof this._options?.onSave === 'function') {
687
+ const isMassChange = (this._options.modalType === 'mass-update' || this._options.modalType === 'mass-selection');
688
+
689
+ // apply the changes in the grid early when that option is enabled (that is before the await of `onSave`)
690
+ let updatedDataset;
691
+ if (isMassChange && this._options?.shouldPreviewMassChangeDataset) {
692
+ updatedDataset = applyChangesCallback(this.formValues, this.getCurrentRowSelections(), false) as any[];
693
+ }
694
+ // call the custon onSave callback when defined and note that the item data context will only be filled for create/clone/edit
695
+ const dataContextOrUpdatedDatasetPreview = isMassChange ? updatedDataset : itemDataContext;
696
+ const successful = await this._options?.onSave(this.formValues, this.getCurrentRowSelections(), dataContextOrUpdatedDatasetPreview);
697
+
698
+ if (successful) {
699
+ // apply the changes in the grid (if it's not yet applied)
700
+ applyChangesCallback(this.formValues, this.getCurrentRowSelections());
701
+
702
+ // once we're done doing the mass update, we can cancel the current editor since we don't want to add any new row
703
+ // that will also destroy/close the modal window
704
+ executePostCallback();
705
+ }
706
+ } else {
707
+ applyChangesCallback(this.formValues, this.getCurrentRowSelections());
708
+ executePostCallback();
709
+ }
710
+
711
+ // run any function before closing the modal
712
+ if (typeof beforeClosingCallback === 'function') {
713
+ beforeClosingCallback();
714
+ }
715
+
716
+ // close the modal only when successful
717
+ this.dispose();
718
+ }
719
+ } catch (error: any) {
720
+ const errorMsg = (typeof error === 'string') ? error : (error?.message ?? error?.body?.message ?? '');
721
+ this.showValidationSummaryText(true, errorMsg);
722
+ }
723
+ }
724
+
725
+ // For the Composite Editor to work, the current active cell must have an Editor (because it calls editActiveCell() and that only works with a cell with an Editor)
726
+ // so if current active cell doesn't have an Editor, we'll find the first column with an Editor and focus on it (from left to right starting at index 0)
727
+ protected focusOnFirstColumnCellWithEditor(columns: Column[], dataContext: any, columnIndex: number, rowIndex: number, isWithMassChange: boolean): boolean {
728
+ // make sure we're not trying to activate a cell outside of the grid, that can happen when using MassUpdate without `enableAddRow` flag enabled
729
+ const activeCellIndex = (isWithMassChange && !this.gridOptions.enableAddRow && (rowIndex >= this.dataViewLength)) ? this.dataViewLength - 1 : rowIndex;
730
+
731
+ let columnIndexWithEditor = columnIndex;
732
+ const cellEditor = columns[columnIndex].editor;
733
+ let activeEditorCellNode = this.grid.getCellNode(activeCellIndex, columnIndex);
734
+
735
+ if (!cellEditor || !activeEditorCellNode || !this.getActiveCellEditor(activeCellIndex, columnIndex)) {
736
+ columnIndexWithEditor = this.findNextAvailableEditorColumnIndex(columns, dataContext, rowIndex, isWithMassChange);
737
+ if (columnIndexWithEditor === -1) {
738
+ this.executeOnError({ type: 'error', code: 'NO_EDITOR_FOUND', message: 'We could not find any Editor in your Column Definition' });
739
+ return false;
740
+ } else {
741
+ this.grid.setActiveCell(activeCellIndex, columnIndexWithEditor, false);
742
+
743
+ if (isWithMassChange) {
744
+ // when it's a mass change, we'll activate the last row without scrolling to it
745
+ // that is possible via the 3rd argument "suppressScrollIntoView" set to "true"
746
+ this.grid.setActiveRow(this.dataViewLength, columnIndexWithEditor, true);
747
+ }
748
+ }
749
+ }
750
+
751
+ // check again if the cell node is now being created, if it is then we're good
752
+ activeEditorCellNode = this.grid.getCellNode(activeCellIndex, columnIndexWithEditor);
753
+
754
+ return !!activeEditorCellNode;
755
+ }
756
+
757
+ protected findNextAvailableEditorColumnIndex(columns: Column[], dataContext: any, rowIndex: number, isWithMassUpdate: boolean): number {
758
+ let columnIndexWithEditor = -1;
759
+
760
+ for (let colIndex = 0; colIndex < columns.length; colIndex++) {
761
+ const col = columns[colIndex];
762
+ if (col.editor && (!isWithMassUpdate || (isWithMassUpdate && col.internalColumnEditor?.massUpdate))) {
763
+ // we can check that the cell is really editable by checking the onBeforeEditCell event not returning false (returning undefined, null also mean it is editable)
764
+ const isCellEditable = this.grid.onBeforeEditCell.notify({ row: rowIndex, cell: colIndex, item: dataContext, column: col, grid: this.grid, target: 'composite', compositeEditorOptions: this._compositeOptions }).getReturnValue();
765
+ this.grid.setActiveCell(rowIndex, colIndex, false);
766
+ if (isCellEditable !== false) {
767
+ columnIndexWithEditor = colIndex;
768
+ break;
769
+ }
770
+ }
771
+ }
772
+ return columnIndexWithEditor;
773
+ }
774
+
775
+ /**
776
+ * Get a column definition by providing a column id OR a column definition.
777
+ * If the input is a string, we'll assume it's a columnId and we'll simply search for the column in the column definitions list
778
+ */
779
+ protected getColumnByObjectOrId(columnIdOrDef: string | Column): Column | undefined {
780
+ let column: Column | undefined;
781
+
782
+ if (typeof columnIdOrDef === 'object') {
783
+ column = columnIdOrDef;
784
+ } else if (typeof columnIdOrDef === 'string') {
785
+ column = this._columnDefinitions.find(col => col.id === columnIdOrDef as string);
786
+ }
787
+ return column;
788
+ }
789
+
790
+ protected getActiveCellEditor(row: number, cell: number): Editor | null {
791
+ this.grid.setActiveCell(row, cell, false);
792
+ return this.grid.getCellEditor();
793
+ }
794
+
795
+ /**
796
+ * Get the column label, the label might have an optional "columnGroup" (or "columnGroupKey" which need to be translated)
797
+ * @param {object} columnDef - column definition
798
+ * @returns {string} label - column label
799
+ */
800
+ protected getColumnLabel(columnDef: Column): string {
801
+ const columnGroupSeparator = this.gridOptions.columnGroupSeparator || ' - ';
802
+ let columnName = columnDef.nameCompositeEditor || columnDef.name || '';
803
+ let columnGroup = columnDef.columnGroup || '';
804
+
805
+ if (this.gridOptions.enableTranslate && this.translaterService) {
806
+ const translationKey = columnDef.nameCompositeEditorKey || columnDef.nameKey;
807
+ if (translationKey) {
808
+ columnName = this.translaterService.translate(translationKey);
809
+ }
810
+ if (columnDef.columnGroupKey && this.translaterService?.translate) {
811
+ columnGroup = this.translaterService.translate(columnDef.columnGroupKey);
812
+ }
813
+ }
814
+
815
+ const columnLabel = columnGroup ? `${columnGroup}${columnGroupSeparator}${columnName}` : columnName;
816
+ return columnLabel instanceof HTMLElement ? columnLabel.innerHTML : columnLabel || '';
817
+ }
818
+
819
+ /** Get the correct label text depending, if we use a Translater Service then translate the text when possible else use default text */
820
+ protected getLabelText(labelProperty: keyof CompositeEditorLabel, localeText: string, defaultText: string): string {
821
+ const textLabels = { ...this.gridOptions.compositeEditorOptions?.labels, ...this._options?.labels };
822
+
823
+ if (this.gridOptions?.enableTranslate && this.translaterService?.translate && textLabels.hasOwnProperty(`${labelProperty}Key` as keyof CompositeEditorLabel)) {
824
+ const translationKey = textLabels[`${labelProperty}Key` as keyof CompositeEditorLabel];
825
+ return this.translaterService.translate(translationKey || '');
826
+ }
827
+ return textLabels?.[labelProperty as keyof CompositeEditorLabel] ?? this._locales?.[localeText as keyof Locale] ?? defaultText;
828
+ }
829
+
830
+ /** Retrieve the current selection of row indexes & data context Ids */
831
+ protected getCurrentRowSelections(): { gridRowIndexes: number[]; dataContextIds: Array<string | number>; } {
832
+ const dataContextIds = this.dataView.getAllSelectedIds();
833
+ const gridRowIndexes = this.dataView.mapIdsToRows(dataContextIds);
834
+ return { gridRowIndexes, dataContextIds };
835
+ }
836
+
837
+
838
+
839
+ protected handleBodyClicked(event: Event) {
840
+ if ((event.target as HTMLElement)?.classList?.contains('slick-editor-modal')) {
841
+ if (this._options?.backdrop !== 'static') {
842
+ this.dispose();
843
+ }
844
+ }
845
+ }
846
+
847
+ protected handleKeyDown(event: KeyboardEvent) {
848
+ if (event.code === 'Escape') {
849
+ this.cancelEditing();
850
+ event.stopPropagation();
851
+ event.preventDefault();
852
+ } else if (event.code === 'Tab') {
853
+ this.validateCurrentEditor();
854
+ }
855
+ }
856
+
857
+ protected handleResetInputValue(event: DOMEvent<HTMLButtonElement>) {
858
+ const columnId = event.target.name;
859
+ const editor = this._editors?.[columnId];
860
+ if (editor?.reset) {
861
+ editor.reset();
862
+ }
863
+ delete this._formValues?.[columnId];
864
+ }
865
+
866
+ /** Callback which processes a Mass Update or Mass Selection Changes */
867
+ protected async handleMassSaving(modalType: 'mass-update' | 'mass-selection', executePostCallback: PlainFunc) {
868
+ if (!this.formValues || Object.keys(this.formValues).length === 0) {
869
+ this.executeOnError({ type: 'warning', code: 'NO_CHANGES_DETECTED', message: 'Sorry we could not detect any changes.' });
870
+ } else {
871
+ const applyCallbackFnName = (modalType === 'mass-update') ? 'applySaveMassUpdateChanges' : 'applySaveMassSelectionChanges';
872
+ this.executeOnSave(this[applyCallbackFnName].bind(this), executePostCallback.bind(this));
873
+ }
874
+ }
875
+
876
+ /** Anytime an input of the Composite Editor form changes, we'll add/remove a "modified" CSS className for styling purposes */
877
+ protected handleOnCompositeEditorChange(_e: Event, args: OnCompositeEditorChangeEventArgs) {
878
+ const columnId = args.column?.id ?? '';
879
+ this._formValues = { ...this._formValues, ...args.formValues };
880
+ const editor = this._editors?.[columnId] as Editor;
881
+ const isEditorValueTouched = editor?.isValueTouched?.() ?? editor?.isValueChanged?.() ?? false;
882
+ this._itemDataContext = editor?.dataContext ?? {}; // keep reference of the item data context
883
+
884
+ // add extra css styling to the composite editor input(s) that got modified
885
+ const editorElm = this._modalElm.querySelector(`[data-editorid=${columnId}]`);
886
+ if (editorElm?.classList) {
887
+ if (isEditorValueTouched) {
888
+ editorElm.classList.add('modified');
889
+ } else {
890
+ editorElm.classList.remove('modified');
891
+ }
892
+ }
893
+
894
+ // after any input changes we'll re-validate all fields
895
+ this.validateCompositeEditors();
896
+ }
897
+
898
+ /** Check wether the grid has the Row Selection enabled */
899
+ protected hasRowSelectionEnabled(): boolean {
900
+ const selectionModel = this.grid.getSelectionModel();
901
+ const isRowSelectionEnabled = this.gridOptions.enableRowSelection || this.gridOptions.enableCheckboxSelector;
902
+ return !!(isRowSelectionEnabled && selectionModel);
903
+ }
904
+
905
+ /** Reset Form button handler */
906
+ protected handleResetFormClicked() {
907
+ for (const columnId of Object.keys(this._editors)) {
908
+ const editor = this._editors[columnId];
909
+ if (editor?.reset) {
910
+ editor.reset();
911
+ }
912
+ }
913
+
914
+ this._formValues = emptyObject(this._formValues);
915
+ }
916
+
917
+ /** switch case handler to determine which code to execute depending on the modal type */
918
+ protected async handleSaveClicked() {
919
+ const modalType = this._options?.modalType;
920
+ switch (modalType) {
921
+ case 'mass-update':
922
+ this.handleMassSaving(modalType, () => {
923
+ this.grid.getEditController()?.cancelCurrentEdit();
924
+ this.grid.setActiveCell(0, 0, false);
925
+ if (this._options.shouldClearRowSelectionAfterMassAction) {
926
+ this.grid.setSelectedRows([]);
927
+ }
928
+ });
929
+ break;
930
+ case 'mass-selection':
931
+ this.handleMassSaving(modalType, () => {
932
+ this.grid.getEditController()?.cancelCurrentEdit();
933
+ this.grid.setActiveRow(this._lastActiveRowNumber);
934
+ if (this._options.shouldClearRowSelectionAfterMassAction) {
935
+ this.grid.setSelectedRows([]);
936
+ }
937
+ });
938
+ break;
939
+ case 'clone':
940
+ // the clone object will be a merge of the selected data context (original object) with the changed form values
941
+ const clonedItemDataContext = { ...this._originalDataContext, ...this.formValues };
942
+
943
+ // post save callback (before closing modal)
944
+ const postSaveCloneCallback = () => {
945
+ this.grid.getEditController()?.cancelCurrentEdit();
946
+ this.grid.setActiveCell(0, 0, false);
947
+ };
948
+
949
+ // call the onSave execution and provide the item data context so that it's available to the user
950
+ this.executeOnSave(
951
+ this.insertNewItemInDataView.bind(this, clonedItemDataContext),
952
+ postSaveCloneCallback,
953
+ this.resetCurrentRowDataContext.bind(this),
954
+ clonedItemDataContext
955
+ );
956
+ break;
957
+ case 'create':
958
+ case 'edit':
959
+ default:
960
+ // commit the changes into the grid
961
+ // if it's a "create" then it will triggered the "onAddNewRow" event which will in term push it to the grid
962
+ // while an "edit" will simply applies the changes directly on the same row
963
+ let isFormValid = this.grid.getEditController()?.commitCurrentEdit();
964
+
965
+ // if the user provided the "onSave" callback, let's execute it with the item data context
966
+ if (isFormValid && typeof this._options?.onSave === 'function') {
967
+ const itemDataContext = (modalType === 'create')
968
+ ? this._originalDataContext // the inserted item was copied to our ref by the "onAddNewRow" event
969
+ : this.grid.getDataItem(this._lastActiveRowNumber); // for clone, we can get item data context directly from DataView
970
+ isFormValid = await this._options?.onSave(this.formValues, this.getCurrentRowSelections(), itemDataContext);
971
+ }
972
+ if (isFormValid) {
973
+ this.dispose(); // when the form is valid, we can close the modal
974
+ }
975
+ break;
976
+ }
977
+ }
978
+
979
+ /** Insert an item into the DataView or throw an error when finding duplicate id in the dataset */
980
+ protected insertNewItemInDataView(item: any) {
981
+ const fullDatasetLength = this.dataView?.getItemCount() ?? 0;
982
+ const newId = this._options.insertNewId ?? fullDatasetLength + 1;
983
+ item[this.gridOptions.datasetIdPropertyName || 'id'] = newId;
984
+
985
+ if (!this.dataView.getItemById(newId)) {
986
+ this.gridService?.addItem(item, this._options.insertOptions);
987
+ } else {
988
+ this.executeOnError({ type: 'error', code: 'ITEM_ALREADY_EXIST', message: `The item object which you are trying to add already exist with the same Id:: ${newId}` });
989
+ }
990
+ return item;
991
+ }
992
+
993
+ protected parseText(inputText: string, mappedArgs: any): string {
994
+ return inputText.replace(/\{\{(.*?)\}\}/g, (match, group) => {
995
+ return mappedArgs[group] !== undefined ? mappedArgs[group] : match;
996
+ });
997
+ }
998
+
999
+ /** Put back the current row to its original item data context using the DataView without triggering a change */
1000
+ protected resetCurrentRowDataContext() {
1001
+ const idPropName = this.gridOptions.datasetIdPropertyName || 'id';
1002
+ const dataView = this.grid.getData<SlickDataView>();
1003
+ dataView.updateItem(this._originalDataContext[idPropName], this._originalDataContext);
1004
+ }
1005
+
1006
+ /** Validate all the Composite Editors that are defined in the form */
1007
+ protected validateCompositeEditors(targetElm?: HTMLElement): EditorValidationResult {
1008
+ let validationResults: EditorValidationResult = { valid: true, msg: '' };
1009
+ const currentEditor = this.grid.getCellEditor();
1010
+
1011
+ if (currentEditor) {
1012
+ validationResults = currentEditor.validate(targetElm);
1013
+ }
1014
+ return validationResults;
1015
+ }
1016
+
1017
+ /** Validate the current cell editor */
1018
+ protected validateCurrentEditor() {
1019
+ const currentEditor = this.grid.getCellEditor();
1020
+ if (currentEditor?.validate) {
1021
+ currentEditor.validate();
1022
+ }
1023
+ }
1024
+ }