From 7e5b018dc7643388610c719e9642e2be49a66684 Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Fri, 12 Jan 2024 02:10:17 -0500 Subject: [PATCH] chore(tests): add SlickGrid unit tests for editor code (#1324) * chore(tests): add SlickGrid unit tests for editor code --- .../src/core/__tests__/slickGrid.spec.ts | 317 +++++++++++++++++- packages/common/src/core/slickGrid.ts | 42 ++- 2 files changed, 333 insertions(+), 26 deletions(-) diff --git a/packages/common/src/core/__tests__/slickGrid.spec.ts b/packages/common/src/core/__tests__/slickGrid.spec.ts index a19d25218..8208b4fdb 100644 --- a/packages/common/src/core/__tests__/slickGrid.spec.ts +++ b/packages/common/src/core/__tests__/slickGrid.spec.ts @@ -1,8 +1,8 @@ import { BasePubSubService } from '@slickgrid-universal/event-pub-sub'; -import { InputEditor, LongTextEditor } from '../../editors'; +import { CheckboxEditor, InputEditor, LongTextEditor } from '../../editors'; import { SlickCellSelectionModel, SlickRowSelectionModel } from '../../extensions'; -import { Column, Editor, FormatterResultWithHtml, FormatterResultWithText, GridOption } from '../../interfaces'; -import { SlickEventData } from '../slickCore'; +import { Column, Editor, FormatterResultWithHtml, FormatterResultWithText, GridOption, type EditCommand } from '../../interfaces'; +import { SlickEventData, SlickGlobalEditorLock } from '../slickCore'; import { SlickDataView } from '../slickDataview'; import { SlickGrid } from '../slickGrid'; import { createDomElement } from '@slickgrid-universal/utils'; @@ -73,6 +73,7 @@ describe('SlickGrid core file', () => { expect(grid.getCanvasNode()).toBeTruthy(); expect(grid.getActiveCanvasNode()).toBeTruthy(); expect(grid.getContainerNode()).toEqual(container); + expect(grid.getGridPosition()).toBeTruthy(); }); it('should be able to instantiate SlickGrid with an external PubSub Service', () => { @@ -1441,14 +1442,321 @@ describe('SlickGrid core file', () => { describe('Editors', () => { const columns = [{ id: 'firstName', field: 'firstName', name: 'First Name', editor: LongTextEditor }] as Column[]; + let items: Array<{ id: number; name: string; age: number; active?: boolean; }> = []; + + beforeEach(() => { + items = [ + { id: 0, name: 'Avery', age: 44 }, + { id: 1, name: 'Bob', age: 20 }, + { id: 2, name: 'Rachel', age: 46 }, + { id: 3, name: 'Jane', age: 24 }, + { id: 4, name: 'John', age: 20 }, + { id: 5, name: 'Arnold', age: 50 }, + { id: 6, name: 'Carole', age: 40 }, + { id: 7, name: 'Jason', age: 48 }, + { id: 8, name: 'Julie', age: 42 }, + { id: 9, name: 'Aaron', age: 23 }, + { id: 10, name: 'Ariane', age: 43 }, + ]; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); it('should expect editor when calling getEditController()', () => { - grid = new SlickGrid(container, [], columns, defaultOptions); + grid = new SlickGrid(container, items, columns, defaultOptions); const result = grid.getEditController(); expect(result).toBeTruthy(); }); + + it('should return undefined editor when getDataItem() did not find any associated cell item', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + jest.spyOn(grid, 'getDataItem').mockReturnValue(null); + grid.setActiveCell(0, 1); + grid.editActiveCell(InputEditor as any, true); + + const result = grid.getCellEditor(); + + expect(result).toBeFalsy(); + }); + + it('should return undefined editor when trying to add a new row item and "cannotTriggerInsert" is set', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', cannotTriggerInsert: true, editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true, autoEditNewRow: false }); + const onBeforeEditCellSpy = jest.spyOn(grid.onBeforeEditCell, 'notify'); + grid.setActiveCell(0, 1); + jest.spyOn(grid, 'getDataLength').mockReturnValue(0); // trick grid to think it's a new item + grid.editActiveCell(InputEditor as any, true); + + expect(onBeforeEditCellSpy).not.toHaveBeenCalled(); + }); + + it('should return undefined editor when onBeforeEditCell returns false', () => { + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', cannotTriggerInsert: true, editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true, autoEditNewRow: false }); + const sed = new SlickEventData(); + jest.spyOn(sed, 'getReturnValue').mockReturnValue(false); + const onBeforeEditCellSpy = jest.spyOn(grid.onBeforeEditCell, 'notify').mockReturnValue(sed); + grid.setActiveCell(0, 1); + grid.editActiveCell(InputEditor as any, true); + const result = grid.getCellEditor(); + + expect(result).toBeFalsy(); + }); + + it('should do nothing when trying to commit unchanged Age field Editor', () => { + (navigator as any).__defineGetter__('userAgent', () => 'msie'); // this will call clearTextSelection() & window.getSelection() + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + grid.setActiveCell(0, 1); + grid.editActiveCell(InputEditor as any, true); + const editor = grid.getCellEditor(); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'isValueChanged').mockReturnValue(false); // unchanged value + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(onCellChangeSpy).not.toHaveBeenCalled(); + expect(result).toBeTruthy(); + }); + + it('should commit Name field Editor via an Editor defined as ItemMetadata by column id & asyncEditorLoading enabled and expect it to call execute() command and triggering onCellChange() notify', () => { + (navigator as any).__defineGetter__('userAgent', () => 'msie'); // this will call clearTextSelection() & document.selection.empty() + Object.defineProperty(document, 'selection', { writable: true, value: { empty: () => { } } }); + const newValue = 33; + const columns = [{ id: 'name', field: 'name', name: 'Name', colspan: '*', }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + const dv = new SlickDataView(); + dv.setItems(items); + grid = new SlickGrid(container, dv, columns, { ...defaultOptions, enableCellNavigation: true, editable: true, asyncEditorLoading: true }); + jest.spyOn(dv, 'getItemMetadata').mockReturnValue({ columns: { age: { colspan: '*', editor: InputEditor } } } as any); + grid.setActiveCell(0, 1); + + jest.advanceTimersByTime(2); + const activeCellNode = container.querySelector('.slick-cell.editable.l1.r1'); + grid.editActiveCell(InputEditor as any, true); + + const editor = grid.getCellEditor(); + const updateRowSpy = jest.spyOn(grid, 'updateRow'); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValueOnce(newValue); + expect(activeCellNode).toBeTruthy(); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(updateRowSpy).toHaveBeenCalledWith(0); + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'execute', row: 0, cell: 1, item: { id: 0, name: 'Avery', age: newValue }, column: columns[1] }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + }); + + it('should commit Name field Editor via an Editor defined as ItemMetadata by column id & asyncEditorLoading enabled and expect it to call execute() command and triggering onCellChange() notify', () => { + (navigator as any).__defineGetter__('userAgent', () => 'Firefox'); + const newValue = 33; + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', colspan: '2', editor: InputEditor }] as Column[]; + const dv = new SlickDataView(); + dv.setItems(items); + grid = new SlickGrid(container, dv, columns, { ...defaultOptions, enableCellNavigation: true, editable: true, asyncEditorLoading: true }); + jest.spyOn(dv, 'getItemMetadata').mockReturnValue({ columns: { 1: { editor: InputEditor } } as any }); + grid.setActiveCell(0, 1); + + jest.advanceTimersByTime(2); + const activeCellNode = container.querySelector('.slick-cell.editable.l1.r1'); + grid.editActiveCell(InputEditor as any, true); + + const editor = grid.getCellEditor(); + const updateRowSpy = jest.spyOn(grid, 'updateRow'); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValueOnce(newValue); + expect(activeCellNode).toBeTruthy(); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(updateRowSpy).toHaveBeenCalledWith(0); + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'execute', row: 0, cell: 1, item: { id: 0, name: 'Avery', age: newValue }, column: columns[1] }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + }); + + it('should commit Age field Editor by calling execute() command and triggering onCellChange() notify', () => { + const newValue = 33; + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: LongTextEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + const onPositionSpy = jest.spyOn(grid.onActiveCellPositionChanged, 'notify'); + grid.setActiveCell(0, 1); + grid.editActiveCell(LongTextEditor as any, true); + const editor = grid.getCellEditor(); + const updateRowSpy = jest.spyOn(grid, 'updateRow'); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValue(newValue); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(onPositionSpy).toHaveBeenCalled(); + expect(editor).toBeTruthy(); + expect(updateRowSpy).toHaveBeenCalledWith(0); + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'execute', row: 0, cell: 1, item: { id: 0, name: 'Avery', age: newValue }, column: columns[1] }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + + // test hide editor when editor is already opened but we start scrolling + jest.spyOn(grid, 'getActiveCellPosition').mockReturnValue({ visible: false } as any); + const canvasBottom = container.querySelector('.slick-viewport-left'); + grid.setActiveCell(0, 1); + grid.editActiveCell(LongTextEditor as any, true); + const hideEditorSpy = jest.spyOn(grid.getCellEditor()!, 'hide'); + canvasBottom?.dispatchEvent(new Event('scroll')); + expect(hideEditorSpy).toHaveBeenCalled(); + }); + + it('should commit Active field Editor by calling execute() command with preClick and triggering onCellChange() notify', () => { + const newValue = false; + const columns = [ + { id: 'name', field: 'name', name: 'Name' }, + { id: 'age', field: 'age', name: 'Age', type: 'number', editor: CheckboxEditor }, + { id: 'active', field: 'active', name: 'Active', type: 'boolean' } + ] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + grid.setActiveCell(0, 1, true); + const editor = grid.getCellEditor(); + const updateRowSpy = jest.spyOn(grid, 'updateRow'); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValueOnce(newValue); + const preClickSpy = jest.spyOn(editor!, 'preClick'); + grid.editActiveCell(CheckboxEditor as any, true); + + const result = grid.getEditController()?.commitCurrentEdit(); + + // expect(preClickSpy).toHaveBeenCalled(); + expect(editor).toBeTruthy(); + expect(updateRowSpy).toHaveBeenCalledWith(0); + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'execute', row: 0, cell: 1, }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + }); + + it('should commit & rollback Age field Editor by calling execute() & undo() commands from a custom EditCommandHandler and triggering onCellChange() notify', () => { + const newValue = 33; + const editQueue: Array<{ item: any; column: Column; editCommand: EditCommand; }> = []; + const undoLastEdit = () => { + const lastEditCommand = editQueue.pop()?.editCommand; + if (lastEditCommand && SlickGlobalEditorLock.cancelCurrentEdit()) { + lastEditCommand.undo(); + grid.invalidate(); + } + }; + const editCommandHandler = (item, column, editCommand) => { + if (editCommand.prevSerializedValue !== editCommand.serializedValue) { + editQueue.push({ item, column, editCommand }); + grid.invalidate(); + editCommand.execute(); + } + }; + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true, editCommandHandler }); + grid.setActiveCell(0, 1); + grid.editActiveCell(InputEditor as any, true); + const editor = grid.getCellEditor(); + const updateRowSpy = jest.spyOn(grid, 'updateRow'); + const onCellChangeSpy = jest.spyOn(grid.onCellChange, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValueOnce(newValue); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(updateRowSpy).toHaveBeenCalledWith(0); + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'execute', row: 0, cell: 1, item: { id: 0, name: 'Avery', age: newValue }, column: columns[1] }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + + undoLastEdit(); + + expect(onCellChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ command: 'undo', row: 0, cell: 1, item: { id: 0, name: 'Avery', age: '44' }, column: columns[1] }), + expect.anything(), + grid + ); + }); + + it('should commit Age field Editor by applying new values and triggering onAddNewRow() notify', () => { + const newValue = 77; + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + grid.setActiveCell(1, 1); + grid.editActiveCell(InputEditor as any, true); + const editor = grid.getCellEditor(); + jest.spyOn(grid, 'getDataLength').mockReturnValueOnce(0); // trick grid to think it's a new item + const onAddNewRowSpy = jest.spyOn(grid.onAddNewRow, 'notify'); + jest.spyOn(editor!, 'serializeValue').mockReturnValue(newValue); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(onAddNewRowSpy).toHaveBeenCalledWith( + expect.objectContaining({ item: { age: newValue }, column: columns[1] }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeTruthy(); + }); + + it('should not commit Age field Editor returns invalid result, expect triggering onValidationError() notify', () => { + const invalidResult = { valid: false, msg: 'invalid value' }; + const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', type: 'number', editor: InputEditor }] as Column[]; + grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + grid.setActiveCell(0, 1); + grid.editActiveCell(InputEditor as any, true); + const editor = grid.getCellEditor(); + const onValidationErrorSpy = jest.spyOn(grid.onValidationError, 'notify'); + jest.spyOn(editor!, 'validate').mockReturnValue(invalidResult); + const activeCellNode = container.querySelector('.slick-cell.editable.l1.r1'); + + const result = grid.getEditController()?.commitCurrentEdit(); + + expect(editor).toBeTruthy(); + expect(onValidationErrorSpy).toHaveBeenCalledWith( + expect.objectContaining({ + editor, + cellNode: activeCellNode, + validationResults: invalidResult, + row: 0, + cell: 1, + column: columns[1] + }), + expect.anything(), + grid + ); + expect(grid.getEditController()).toBeTruthy(); + expect(result).toBeFalsy(); + }); }); describe('Sorting', () => { @@ -3415,6 +3723,7 @@ describe('SlickGrid core file', () => { it('should commit editor & set focus to next down cell when triggering Enter key with an active Editor', () => { const columns = [{ id: 'name', field: 'name', name: 'Name' }, { id: 'age', field: 'age', name: 'Age', editor: InputEditor }] as Column[]; grid = new SlickGrid(container, items, columns, { ...defaultOptions, enableCellNavigation: true, editable: true }); + jest.spyOn(grid.getEditorLock(), 'commitCurrentEdit').mockReturnValueOnce(true); grid.setActiveCell(0, 1); grid.editActiveCell(InputEditor as any, true); const onKeyDownSpy = jest.spyOn(grid.onKeyDown, 'notify'); diff --git a/packages/common/src/core/slickGrid.ts b/packages/common/src/core/slickGrid.ts index 613acaa1a..25e910526 100644 --- a/packages/common/src/core/slickGrid.ts +++ b/packages/common/src/core/slickGrid.ts @@ -121,7 +121,7 @@ interface RowCaching { export class SlickGrid = Column, O extends BaseGridOption = BaseGridOption> { // Public API - slickGridVersion = '5.5.0'; + slickGridVersion = '5.7.1'; /** optional grid state clientId */ cid = ''; @@ -5213,9 +5213,8 @@ export class SlickGrid = Column, O e } if (this._options.editable && opt_editMode && this.isCellPotentiallyEditable(this.activeRow, this.activeCell)) { - clearTimeout(this.h_editorLoader); - if (this._options.asyncEditorLoading) { + clearTimeout(this.h_editorLoader); this.h_editorLoader = setTimeout(() => { this.makeActiveCellEditable(undefined, preClickModeOn, e); }, this._options.asyncEditorLoadDelay); @@ -5238,7 +5237,7 @@ export class SlickGrid = Column, O e protected clearTextSelection() { if ((document as any).selection?.empty) { try { - // IE fails here if selected element is not in dom + // IE fails here if selected element is not in DOM (document as any).selection.empty(); // eslint-disable-next-line no-empty } catch (e) { } @@ -5365,7 +5364,7 @@ export class SlickGrid = Column, O e if (item && this.currentEditor) { this.currentEditor.loadValue(item); - if (preClickModeOn && this.currentEditor?.preClick) { + if (preClickModeOn && typeof this.currentEditor?.preClick === 'function') { this.currentEditor.preClick(); } } @@ -5450,24 +5449,22 @@ export class SlickGrid = Column, O e } protected handleActiveCellPositionChange() { - if (!this.activeCellNode) { - return; - } - - this.triggerEvent(this.onActiveCellPositionChanged, {}); + if (this.activeCellNode) { + this.triggerEvent(this.onActiveCellPositionChanged, {}); - if (this.currentEditor) { - const cellBox = this.getActiveCellPosition(); - if (this.currentEditor.show && this.currentEditor.hide) { - if (!cellBox.visible) { - this.currentEditor.hide(); - } else { - this.currentEditor.show(); + if (this.currentEditor) { + const cellBox = this.getActiveCellPosition(); + if (this.currentEditor.show && this.currentEditor.hide) { + if (!cellBox.visible) { + this.currentEditor.hide(); + } else { + this.currentEditor.show(); + } } - } - if (this.currentEditor.position) { - this.currentEditor.position(cellBox); + if (this.currentEditor.position) { + this.currentEditor.position(cellBox); + } } } } @@ -6154,6 +6151,7 @@ export class SlickGrid = Column, O e const prevSerializedValue = self.serializedEditorValue; if (self.activeRow < self.getDataLength()) { + // editing existing item found const editCommand = { row, cell, @@ -6179,8 +6177,8 @@ export class SlickGrid = Column, O e editCommand.execute(); self.makeActiveCellNormal(true); } - } else { + // editing new item to add to dataset const newItem = {}; self.currentEditor.applyValue(newItem, self.currentEditor.serializeValue()); self.makeActiveCellNormal(true); @@ -6190,7 +6188,7 @@ export class SlickGrid = Column, O e // check whether the lock has been re-acquired by event handlers return !self.getEditorLock()?.isActive(); } else { - // Re-add the CSS class to trigger transitions, if any. + // invalid editing: Re-add the CSS class to trigger transitions, if any. if (self.activeCellNode) { self.activeCellNode.classList.remove('invalid'); Utils.width(self.activeCellNode);// force layout