@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.
- package/dist/cjs/slick-composite-editor.component.js +16 -12
- package/dist/cjs/slick-composite-editor.component.js.map +1 -1
- package/dist/esm/slick-composite-editor.component.js +16 -12
- package/dist/esm/slick-composite-editor.component.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types/slick-composite-editor.component.d.ts +2 -2
- package/dist/types/slick-composite-editor.component.d.ts.map +1 -1
- package/package.json +7 -6
- package/src/compositeEditor.factory.ts +261 -0
- package/src/index.ts +2 -0
- package/src/slick-composite-editor.component.ts +1024 -0
|
@@ -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
|
+
}
|