diff --git a/package.json b/package.json index fe9ecdc4e..299d8b83e 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,22 @@ "packages/*" ], "devDependencies": { - "@typescript-eslint/eslint-plugin": "^2.29.0", - "@typescript-eslint/parser": "^2.29.0", + "@types/jest": "^25.2.1", + "@types/node": "^13.13.4", + "@typescript-eslint/eslint-plugin": "^2.30.0", + "@typescript-eslint/parser": "^2.30.0", "eslint": "^6.8.0", "eslint-plugin-import": "^2.20.2", - "eslint-plugin-prefer-arrow": "^1.2.0", - "lerna": "^3.20.2" + "eslint-plugin-prefer-arrow": "^1.2.1", + "jest": "^25.5.4", + "jest-cli": "^25.5.4", + "jest-environment-jsdom": "^25.5.0", + "jest-extended": "^0.11.5", + "jest-junit": "^10.0.0", + "jsdom": "^16.2.2", + "jsdom-global": "^3.0.2", + "lerna": "^3.20.2", + "ts-jest": "^25.4.0" }, "engines": { "node": ">=12.13.1", diff --git a/packages/common/package.json b/packages/common/package.json index 2785c1e8d..7185a5ebd 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -69,27 +69,17 @@ }, "devDependencies": { "@types/dompurify": "^2.0.1", - "@types/jest": "^25.2.1", - "@types/jquery": "^3.3.35", + "@types/jquery": "^3.3.36", "@types/moment": "^2.13.0", - "@types/node": "^13.13.2", "autoprefixer": "^9.7.6", "copyfiles": "^2.2.0", "cross-env": "^7.0.2", - "jest": "^25.4.0", - "jest-cli": "^25.4.0", - "jest-environment-jsdom": "^25.4.0", - "jest-extended": "^0.11.5", - "jest-junit": "^10.0.0", - "jsdom": "^16.2.2", - "jsdom-global": "^3.0.2", "mini-css-extract-plugin": "^0.9.0", "node-sass": "4.14.0", "nodemon": "^2.0.3", "npm-run-all": "^4.1.5", - "postcss-cli": "^7.1.0", + "postcss-cli": "^7.1.1", "rimraf": "^3.0.2", - "ts-jest": "^25.4.0", "typescript": "^3.8.3" }, "engines": { diff --git a/packages/common/src/editors/__tests__/dualInputEditor.spec.ts b/packages/common/src/editors/__tests__/dualInputEditor.spec.ts new file mode 100644 index 000000000..67ce39320 --- /dev/null +++ b/packages/common/src/editors/__tests__/dualInputEditor.spec.ts @@ -0,0 +1,789 @@ +import { Editors } from '../index'; +import { DualInputEditor } from '../dualInputEditor'; +import { KeyCode } from '../../enums/index'; +import { Column, EditorArgs, EditorArguments, GridOption, ColumnEditorComboInput } from '../../interfaces/index'; + +declare const Slick: any; +const KEY_CHAR_0 = 48; +const KEY_CHAR_A = 97; +const containerId = 'demo-container'; + +// define a
container to simulate the grid container +const template = `
`; + +const dataViewStub = { + refresh: jest.fn(), +}; + +const gridOptionMock = { + autoCommitEdit: false, + editable: true, +} as GridOption; + +const getEditorLockMock = { + commitCurrentEdit: jest.fn(), +}; + +const gridStub = { + getOptions: () => gridOptionMock, + getColumns: jest.fn(), + getEditorLock: () => getEditorLockMock, + getHeaderRowColumn: jest.fn(), + onValidationError: new Slick.Event(), + render: jest.fn(), +}; + +describe('DualInputEditor', () => { + let divContainer: HTMLDivElement; + let editor: DualInputEditor; + let editorArguments: EditorArguments; + let mockColumn: Column; + let mockItemData: any; + + beforeEach(() => { + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + + mockColumn = { id: 'title', field: 'title', editable: true, editor: { model: Editors.text }, internalColumnEditor: {} } as Column; + + editorArguments = { + grid: gridStub, + column: mockColumn, + item: mockItemData, + event: null, + cancelChanges: jest.fn(), + commitChanges: jest.fn(), + container: divContainer, + columnMetaData: null, + dataView: dataViewStub, + gridPosition: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + position: { top: 0, left: 0, bottom: 10, right: 10, height: 100, width: 100, visible: true }, + }; + }); + + describe('with invalid Editor instance', () => { + it('should throw an error when trying to call init without any arguments', (done) => { + try { + editor = new DualInputEditor(null); + } catch (e) { + expect(e.toString()).toContain(`[Slickgrid-Universal] Something is wrong with this grid, an Editor must always have valid arguments.`); + done(); + } + }); + + it('should throw an error when initialize the editor without the requires params leftInput/rightInput', (done) => { + try { + // @ts-ignore + editor = new DualInputEditor({}); + } catch (e) { + expect(e.toString()).toContain(`[Slickgrid-Universal] Please make sure that your Combo Input Editor has params defined with "leftInput" and "rightInput"`); + done(); + } + }); + }); + + describe('with valid Editor instance', () => { + beforeEach(() => { + const editorParams = { leftInput: { field: 'from', type: 'float' }, rightInput: { field: 'to', type: 'float' } } as ColumnEditorComboInput; + mockItemData = { id: 1, from: 1, to: 22, isActive: true }; + mockColumn = { + id: 'range', field: 'range', editable: true, internalColumnEditor: { params: editorParams }, + editor: { model: Editors.dualInput, params: editorParams }, + } as Column; + + editorArguments.column = mockColumn; + editorArguments.item = mockItemData; + }); + + afterEach(() => { + editor.destroy(); + }); + + it('should initialize the editor', () => { + editor = new DualInputEditor(editorArguments); + const editorCount = divContainer.querySelectorAll('input.dual-editor-text.editor-range').length; + expect(editorCount).toBe(2); + }); + + it('should have a placeholder on the left input when defined in its column definition', () => { + const testValue = 'test placeholder'; + mockColumn.internalColumnEditor.params.leftInput.placeholder = testValue; + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.dual-editor-text.editor-range.left'); + + expect(editorElm.placeholder).toBe(testValue); + }); + + it('should have a placeholder on the right input when defined in its column definition', () => { + const testValue = 'test placeholder'; + mockColumn.internalColumnEditor.params.rightInput.placeholder = testValue; + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.dual-editor-text.editor-range.right'); + + expect(editorElm.placeholder).toBe(testValue); + }); + + it('should have a title (tooltip) on left input when defined in its column definition', () => { + const testValue = 'test title'; + mockColumn.internalColumnEditor.params.leftInput.title = testValue; + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.dual-editor-text.editor-range.left'); + + expect(editorElm.title).toBe(testValue); + }); + + it('should have a title (tooltip) on right input when defined in its column definition', () => { + const testValue = 'test title'; + mockColumn.internalColumnEditor.params.rightInput.title = testValue; + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.dual-editor-text.editor-range.right'); + + expect(editorElm.title).toBe(testValue); + }); + + it('should have a left input as type number and right input as readonly text input when that right input is set to "readonly"', () => { + mockColumn.internalColumnEditor.params.leftInput.type = 'float'; + mockColumn.internalColumnEditor.params.rightInput.type = 'readonly'; + + editor = new DualInputEditor(editorArguments); + const editorLeftElm = divContainer.querySelector('input.dual-editor-text.editor-range.left'); + const editorRightElm = divContainer.querySelector('input.dual-editor-text.editor-range.right'); + + expect(editorLeftElm.type).toBe('number'); + expect(editorRightElm.type).toBe('text'); + expect(editorLeftElm.readOnly).toBe(false); + expect(editorRightElm.readOnly).toBe(true); + }); + + it('should call "columnEditor" GETTER and expect to equal the editor settings we provided', () => { + mockColumn.internalColumnEditor.params = { + leftInput: { + field: 'from', + placeholder: 'test placeholder', + range: 'test title', + alwaysSaveOnEnterKey: false, + }, + rightInput: { + field: 'to' + } + }; + + editor = new DualInputEditor(editorArguments); + + expect(editor.columnEditor).toEqual(mockColumn.internalColumnEditor); + }); + + it('should call "setValue" and expect the DOM element value to be the same string when calling "getValue"', () => { + editor = new DualInputEditor(editorArguments); + editor.setValues([12, 34]); + + expect(editor.getValue()).toEqual(['12', '34']); + }); + + it('should define an item datacontext containing a string as cell value and expect this value to be loaded in the editor when calling "loadValue"', () => { + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const editorElm = editor.editorDomElement; + + expect(editorElm).toBeTruthy(); + expect(editor.getValue()).toEqual(['1', '22']); + }); + + it('should dispatch a keyboard event and expect "stopImmediatePropagation()" to have been called when using Left Arrow key', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KeyCode.LEFT, bubbles: true, cancelable: true }); + const spyEvent = jest.spyOn(event, 'stopImmediatePropagation'); + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-range'); + + editor.focus(); + editorElm.dispatchEvent(event); + + expect(spyEvent).toHaveBeenCalled(); + }); + + it('should dispatch a keyboard event and expect "stopImmediatePropagation()" to have been called when using Right Arrow key', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KeyCode.RIGHT, bubbles: true, cancelable: true }); + const spyEvent = jest.spyOn(event, 'stopImmediatePropagation'); + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-range'); + + editor.focus(); + editorElm.dispatchEvent(event); + + expect(spyEvent).toHaveBeenCalled(); + }); + + describe('isValueChanged method', () => { + it('should return True when previously dispatched keyboard event is a new char 0', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KEY_CHAR_0, bubbles: true, cancelable: true }); + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-range'); + + editor.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(true); + }); + + it('should return False when previously dispatched keyboard event is same number as current value', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KEY_CHAR_0, bubbles: true, cancelable: true }); + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-range'); + + editor.loadValue({ id: 1, range: '1-22', from: 0, to: 22, isActive: true }); + editor.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(false); + }); + + it('should return False when previously dispatched keyboard event is same string number as current value', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KEY_CHAR_0, bubbles: true, cancelable: true }); + + editor = new DualInputEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-range'); + + editor.loadValue({ id: 1, range: '1-22', from: '0', to: '22', isActive: true }); + editor.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(false); + }); + + it('should return True when left input last dispatched keyboard event is ENTER and "alwaysSaveOnEnterKey" is enabled', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KeyCode.ENTER, bubbles: true, cancelable: true }); + mockColumn.internalColumnEditor.params.leftInput.alwaysSaveOnEnterKey = true; + + editor = new DualInputEditor(editorArguments); + const editorLeftElm = divContainer.querySelector('input.editor-range.left'); + + editor.focus(); + editorLeftElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(true); + }); + + it('should return True when right input last dispatched keyboard event is ENTER and "alwaysSaveOnEnterKey" is enabled', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KeyCode.ENTER, bubbles: true, cancelable: true }); + mockColumn.internalColumnEditor.params.rightInput.alwaysSaveOnEnterKey = true; + + editor = new DualInputEditor(editorArguments); + const editorRightElm = divContainer.querySelector('input.editor-range.right'); + + editor.focus(); + editorRightElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(true); + }); + }); + + describe('applyValue method', () => { + it('should apply the value to the range property when it passes validation', () => { + mockColumn.internalColumnEditor.params.leftInput.validator = null; + mockItemData = { id: 1, range: '1-22', from: 0, to: 22, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.applyValue(mockItemData, { id: 1, from: 33, to: 78 }); + + expect(mockItemData).toEqual({ id: 1, range: '1-22', from: 33, to: 78, isActive: true }); + }); + + it('should apply the value to the range property with a field having dot notation (complex object) that passes validation', () => { + mockColumn.internalColumnEditor.params.leftInput.validator = null; + mockColumn.field = 'part.from'; + mockColumn.internalColumnEditor.params.leftInput.field = 'part.from'; + mockColumn.internalColumnEditor.params.rightInput.field = 'part.to'; + mockItemData = { id: 1, part: { range: '1-22', from: 0, to: 44 }, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.applyValue(mockItemData, { id: 1, range: '1-22', from: 33, to: 78 }); + + expect(mockItemData).toEqual({ id: 1, part: { range: '1-22', from: 33, to: 78 }, isActive: true }); + }); + + it('should return item data with an empty string in its left input value when it fails the custom validation', () => { + mockColumn.internalColumnEditor.params.leftInput.validator = (value: any, args: EditorArgs) => { + if (+value < 10) { + return { valid: false, msg: 'From value must be over 10.' }; + } + return { valid: true, msg: '' }; + }; + mockItemData = { id: 1, range: '1-22', from: 22, to: 78, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.applyValue(mockItemData, { id: 1, range: '1-22', from: 4, to: 5 }); + + expect(mockItemData).toEqual({ id: 1, range: '1-22', from: '', to: 5, isActive: true }); + }); + + it('should return item data with an empty string in its right input value when it fails the custom validation', () => { + mockColumn.internalColumnEditor.params.rightInput.validator = (value: any, args: EditorArgs) => { + if (+value > 150) { + return { valid: false, msg: 'To value must be below 150.' }; + } + return { valid: true, msg: '' }; + }; + mockItemData = { id: 1, range: '1-22', from: 22, to: 78, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.applyValue(mockItemData, { id: 1, range: '1-22', from: 4, to: 155 }); + + expect(mockItemData).toEqual({ id: 1, range: '1-22', from: 4, to: '', isActive: true }); + }); + }); + + describe('serializeValue method', () => { + it('should return serialized value as a number', () => { + mockItemData = { id: 1, range: '13-22', from: 13, to: 22, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(0); + expect(editor.getDecimalPlaces('rightInput')).toBe(0); + expect(output).toEqual({ from: 13, to: 22 }); + }); + + it('should return serialized value as a float number when "decimal" is set to 2', () => { + mockItemData = { id: 1, range: '32.789-45.67', from: 32.789, to: 45.67, isActive: true }; + mockColumn.internalColumnEditor.params.leftInput.decimal = 1; + mockColumn.internalColumnEditor.params.rightInput.decimal = 3; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(1); + expect(editor.getDecimalPlaces('rightInput')).toBe(3); + expect(output).toEqual({ from: 32.8, to: 45.67 }); + }); + + it('should return serialized value as a number even when the item property value is a number in a string', () => { + mockItemData = { id: 1, range: '1-33', from: '1', to: '33', isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(0); + expect(editor.getDecimalPlaces('rightInput')).toBe(0); + expect(output).toEqual({ from: 1, to: 33 }); + }); + + it('should return a rounded number when a float is provided without any decimal place defined', () => { + mockItemData = { id: 1, range: '2-32.7', from: '2', to: '32.7', isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(0); + expect(editor.getDecimalPlaces('rightInput')).toBe(0); + expect(output).toEqual({ from: 2, to: 33 }); + }); + + it('should return serialized value as an empty string when item value is also an empty string', () => { + mockItemData = { id: 1, range: '', from: '', to: 2, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(0); + expect(editor.getDecimalPlaces('rightInput')).toBe(0); + expect(output).toEqual({ from: '', to: 2 }); + }); + + it('should return serialized value as an empty string when item value is null', () => { + mockItemData = { id: 1, range: null, from: null, to: 2, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(editor.getDecimalPlaces('leftInput')).toBe(0); + expect(editor.getDecimalPlaces('rightInput')).toBe(0); + expect(output).toEqual({ from: '', to: 2 }); + }); + + it('should return value as a number when using a dot (.) notation for complex object', () => { + mockColumn.field = 'part.from'; + mockColumn.internalColumnEditor.params.leftInput.field = 'part.from'; + mockColumn.internalColumnEditor.params.rightInput.field = 'part.to'; + mockItemData = { id: 1, part: { range: '5-44', from: 5, to: 44 }, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual({ part: { from: 5, to: 44 } }); + }); + }); + + describe('getInputDecimalSteps method', () => { + it('should return decimal step as 1 increment when decimal is not set', () => { + mockItemData = { id: 1, range: '2-33', from: 2, to: 33, isActive: true }; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + + expect(editor.getInputDecimalSteps('leftInput')).toBe('1'); + expect(editor.getInputDecimalSteps('rightInput')).toBe('1'); + }); + + it('should return decimal step as 0.1 increment when decimal is set to 1 decimal', () => { + mockItemData = { id: 1, range: '2-32.7', from: 2, to: 32.7, isActive: true }; + mockColumn.internalColumnEditor.params.leftInput.decimal = 1; + mockColumn.internalColumnEditor.params.rightInput.decimal = 2; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + + expect(editor.getInputDecimalSteps('leftInput')).toBe('0.1'); + expect(editor.getInputDecimalSteps('rightInput')).toBe('0.01'); + }); + + it('should return decimal step as 0.01 increment when decimal is set to 2 decimal', () => { + mockItemData = { id: 1, range: '2-32.7', from: 2, to: 32.7, isActive: true }; + mockColumn.internalColumnEditor.params.leftInput.decimal = 1; + mockColumn.internalColumnEditor.params.rightInput.decimal = 2; + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + + expect(editor.getInputDecimalSteps('leftInput')).toBe('0.1'); + expect(editor.getInputDecimalSteps('rightInput')).toBe('0.01'); + }); + }); + + describe('save method', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should call "getEditorLock" method when "hasAutoCommitEdit" is enabled', () => { + mockItemData = { id: 1, range: '3-32', from: 3, to: 32, isActive: true }; + gridOptionMock.autoCommitEdit = true; + const spy = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValues([2, 35]); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should call "commitChanges" method when "hasAutoCommitEdit" is disabled', () => { + mockItemData = { id: 1, range: '3-32', from: 3, to: 32, isActive: true }; + gridOptionMock.autoCommitEdit = false; + const spy = jest.spyOn(editorArguments, 'commitChanges'); + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValues([2, 35]); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + + // it('should not call anything when the input value is not a valid float number', () => { + // mockItemData = { id: 1, range: null, from: null, to: null, isActive: true }; + // gridOptionMock.autoCommitEdit = true; + // const spy = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + // editor = new DualInputEditor(editorArguments); + // editor.loadValue(mockItemData); + // editor.setValue(['-.', '-.']); + // editor.save(); + + // expect(spy).not.toHaveBeenCalled(); + // }); + + it('should call "getEditorLock" and "save" methods when "hasAutoCommitEdit" is enabled and the left input event "focusout" is triggered', (done) => { + mockItemData = { id: 1, range: '3-32', from: 3, to: 32, isActive: true }; + gridOptionMock.autoCommitEdit = true; + const spyGetEditor = jest.spyOn(gridStub, 'getEditorLock'); + const spyCommit = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValues([2, 35]); + const spySave = jest.spyOn(editor, 'save'); + const editorLeftElm = editor.editorDomElement.leftInput; + + editorLeftElm.dispatchEvent(new (window.window as any).Event('focusout')); + + setTimeout(() => { + expect(spyGetEditor).toHaveBeenCalled(); + expect(spyCommit).toHaveBeenCalled(); + expect(spySave).toHaveBeenCalled(); + done(); + }, 0); + }); + + it('should call "getEditorLock" and "save" methods when "hasAutoCommitEdit" is enabled and the right input event "focusout" is triggered', (done) => { + mockItemData = { id: 1, range: '3-32', from: 3, to: 32, isActive: true }; + gridOptionMock.autoCommitEdit = true; + const spyGetEditor = jest.spyOn(gridStub, 'getEditorLock'); + const spyCommit = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new DualInputEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValues([2, 35]); + const spySave = jest.spyOn(editor, 'save'); + const editorRightElm = editor.editorDomElement.rightInput; + + editorRightElm.dispatchEvent(new (window.window as any).Event('focusout')); + + setTimeout(() => { + expect(spyGetEditor).toHaveBeenCalled(); + expect(spyCommit).toHaveBeenCalled(); + expect(spySave).toHaveBeenCalled(); + done(); + }, 0); + }); + }); + + describe('validate method', () => { + it('should set isValueSaveCalled to true when grid object triggered an "onValidationError"', () => { + editor = new DualInputEditor(editorArguments); + jest.spyOn(editor.eventHandler, 'subscribe'); + + expect(editor.eventHandler).toBeTruthy(); + expect(editor.isValueSaveCalled).toBe(false); + gridStub.onValidationError.notify({ row: 0, cell: 0, validationResults: { valid: false, msg: 'Field is required' } }); + expect(editor.isValueSaveCalled).toBe(true); + }); + + it('should return False when field is required and field is empty', () => { + mockColumn.internalColumnEditor.params.leftInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: '' }); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return False when left input field is required and its value is empty when set by "setValues"', () => { + mockColumn.internalColumnEditor.params.leftInput.required = true; + editor = new DualInputEditor(editorArguments); + editor.setValues(['', 3]); + const validation = editor.validate(); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return False when left input field is required and its value is empty when set by "setValues"', () => { + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + editor.setValues([2, '']); + const validation = editor.validate(); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return False when editor is float but its field is not a valid float number', () => { + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 'abc' }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number' }); + }); + + it('should return False when editor is integer but its field is not a valid integer number', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'integer'; + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 'abc' }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid integer number' }); + }); + + it('should return False when editor is a required text input but its text value is not provided', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'text'; + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: '' }); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return False when editor is a required password input but its text value is not provided', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'password'; + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: '' }); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should return False when field is lower than a minValue defined', () => { + mockColumn.internalColumnEditor.params.leftInput.minValue = 10.2; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 10 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number that is greater than 10.2' }); + }); + + it('should return True when field is equal to the minValue defined', () => { + mockColumn.internalColumnEditor.params.rightInput.minValue = 10.2; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 10.2 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return False when field is greater than a maxValue defined', () => { + mockColumn.internalColumnEditor.params.leftInput.maxValue = 10.2; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 10.22 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number that is lower than 10.2' }); + }); + + it('should return True when field is equal to the maxValue defined', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'float'; + mockColumn.internalColumnEditor.params.rightInput.maxValue = 10.2; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 10.2 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return True when type is set to float and its field is equal to the maxValue defined and "operatorType" is set to "inclusive"', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'float'; + mockColumn.internalColumnEditor.params.leftInput.maxValue = 10.2; + mockColumn.internalColumnEditor.params.leftInput.operatorConditionalType = 'inclusive'; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 10.2 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return True when type is set to integer and its field is equal to the maxValue defined and "operatorType" is set to "inclusive"', () => { + mockColumn.internalColumnEditor.params.leftInput.type = 'integer'; + mockColumn.internalColumnEditor.params.leftInput.maxValue = 11; + mockColumn.internalColumnEditor.params.leftInput.operatorConditionalType = 'inclusive'; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 11 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return False when type is set to float and its field is equal to the maxValue defined but "operatorType" is set to "exclusive"', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'float'; + mockColumn.internalColumnEditor.params.rightInput.maxValue = 10.2; + mockColumn.internalColumnEditor.params.rightInput.operatorConditionalType = 'exclusive'; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 10.2 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number that is lower than 10.2' }); + }); + + it('should return False when type is set to float and its field is equal to the maxValue defined but "operatorType" is set to "exclusive"', () => { + mockColumn.internalColumnEditor.params.rightInput.type = 'integer'; + mockColumn.internalColumnEditor.params.rightInput.maxValue = 11; + mockColumn.internalColumnEditor.params.rightInput.operatorConditionalType = 'exclusive'; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 11 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid integer number that is lower than 11' }); + }); + + it('should return False when type is set to float and its field is not between minValue & maxValue defined', () => { + mockColumn.internalColumnEditor.params.leftInput.minValue = 10.5; + mockColumn.internalColumnEditor.params.leftInput.maxValue = 99.5; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 99.6 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number between 10.5 and 99.5' }); + }); + + it('should return False when type is set to integer and its field is not between minValue & maxValue defined', () => { + mockColumn.internalColumnEditor.params.leftInput.type = 'integer'; + mockColumn.internalColumnEditor.params.leftInput.minValue = 11; + mockColumn.internalColumnEditor.params.leftInput.maxValue = 99; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 100 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid integer number between 11 and 99' }); + }); + + it('should return True when field is is equal to maxValue defined when both min/max values are defined', () => { + mockColumn.internalColumnEditor.params.rightInput.minValue = 10.5; + mockColumn.internalColumnEditor.params.rightInput.maxValue = 99.5; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 99.5 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return True when field is is equal to minValue defined when "operatorType" is set to "inclusive" and both min/max values are defined', () => { + mockColumn.internalColumnEditor.params.leftInput.minValue = 10.5; + mockColumn.internalColumnEditor.params.leftInput.maxValue = 99.5; + mockColumn.internalColumnEditor.params.leftInput.operatorConditionalType = 'inclusive'; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 10.5 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return False when field is equal to maxValue but "operatorType" is set to "exclusive" when both min/max values are defined', () => { + mockColumn.internalColumnEditor.params.rightInput.minValue = 10.5; + mockColumn.internalColumnEditor.params.rightInput.maxValue = 99.5; + mockColumn.internalColumnEditor.params.rightInput.operatorConditionalType = 'exclusive'; + editor = new DualInputEditor(editorArguments); + const validation1 = editor.validate({ position: 'rightInput', inputValue: 99.5 }); + const validation2 = editor.validate({ position: 'rightInput', inputValue: 10.5 }); + + expect(validation1).toEqual({ valid: false, msg: 'Please enter a valid number between 10.5 and 99.5' }); + expect(validation2).toEqual({ valid: false, msg: 'Please enter a valid number between 10.5 and 99.5' }); + }); + + it('should return False when field has more decimals than the "decimal" which is the maximum decimal allowed', () => { + mockColumn.internalColumnEditor.params.leftInput.decimal = 2; + + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 99.6433 }); + + expect(validation).toEqual({ valid: false, msg: 'Please enter a valid number with a maximum of 2 decimals' }); + }); + + it('should return True when field has less decimals than the "decimal" which is valid', () => { + mockColumn.internalColumnEditor.params.rightInput.decimal = 2; + + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'rightInput', inputValue: 99.6 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return True when field has same number of decimals than the "decimal" which is also valid', () => { + mockColumn.internalColumnEditor.params.leftInput.decimal = 2; + + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 99.65 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + + it('should return True when field is required and field is a valid input value', () => { + mockColumn.internalColumnEditor.params.rightInput.required = true; + editor = new DualInputEditor(editorArguments); + const validation = editor.validate({ position: 'leftInput', inputValue: 2.5 }); + + expect(validation).toEqual({ valid: true, msg: '' }); + }); + }); + }); +}); diff --git a/packages/common/src/editors/dualInputEditor.ts b/packages/common/src/editors/dualInputEditor.ts index e0c70961a..806bde43d 100644 --- a/packages/common/src/editors/dualInputEditor.ts +++ b/packages/common/src/editors/dualInputEditor.ts @@ -71,9 +71,8 @@ export class DualInputEditor implements Editor { return this.grid.getOptions().autoCommitEdit; } - /** Get the Validator function, can be passed in Editor property or Column Definition */ - get validator(): EditorValidator | undefined { - return (this.columnEditor && this.columnEditor.validator) || (this.columnDef && this.columnDef.validator); + get isValueSaveCalled(): boolean { + return this._isValueSaveCalled; } init() { @@ -91,8 +90,8 @@ export class DualInputEditor implements Editor { containerElm.appendChild(this._rightInput); } - this._leftInput.onkeydown = this.handleKeyDown; - this._rightInput.onkeydown = this.handleKeyDown; + this._leftInput.onkeydown = this.handleKeyDown.bind(this); + this._rightInput.onkeydown = this.handleKeyDown.bind(this); // the lib does not get the focus out event for some reason, so register it here if (this.hasAutoCommitEdit) { @@ -106,7 +105,7 @@ export class DualInputEditor implements Editor { handleFocusOut(event: any, position: 'leftInput' | 'rightInput') { // when clicking outside the editable cell OR when focusing out of it const targetClassNames = event.relatedTarget?.className || ''; - if (targetClassNames.indexOf('compound-editor') === -1 && this._lastEventType !== 'focusout-right') { + if (targetClassNames.indexOf('dual-editor') === -1 && this._lastEventType !== 'focusout-right') { if (position === 'rightInput' || (position === 'leftInput' && this._lastEventType !== 'focusout-left')) { this.save(); } @@ -127,10 +126,9 @@ export class DualInputEditor implements Editor { this._eventHandler.unsubscribeAll(); const columnId = this.columnDef && this.columnDef.id; - const elm = document.querySelector(`.compound-editor-text.editor-${columnId}`); - if (elm) { - this._leftInput.removeEventListener('focusout', () => { }); - this._rightInput.removeEventListener('focusout', () => { }); + const elements = document.querySelectorAll(`.dual-editor-text.editor-${columnId}`); + if (elements.length > 0) { + elements.forEach((elm) => elm.removeEventListener('focusout', () => { })); } } @@ -146,7 +144,7 @@ export class DualInputEditor implements Editor { const input = document.createElement('input') as HTMLInputElement; input.id = `item-${itemId}`; - input.className = `compound-editor-text editor-${columnId} ${position.replace(/input/gi, '')}`; + input.className = `dual-editor-text editor-${columnId} ${position.replace(/input/gi, '')}`; input.type = fieldType || 'text'; input.setAttribute('role', 'presentation'); input.autocomplete = 'off'; @@ -165,12 +163,18 @@ export class DualInputEditor implements Editor { // do nothing since we have 2 inputs and we might focus on left/right depending on which is invalid or new } - getValue(): string { - return this._leftInput.value || ''; + getValue(): Array { + return [ + this._leftInput.value || '', + this._rightInput.value || '' + ]; } - setValue(value: string) { - this._leftInput.value = value; + setValues(values: Array) { + if (Array.isArray(values) && values.length === 2) { + this._leftInput.value = `${values[0]}`; + this._rightInput.value = `${values[1]}`; + } } applyValue(item: any, state: any) { @@ -183,15 +187,23 @@ export class DualInputEditor implements Editor { if (fieldName !== undefined) { const isComplexObject = fieldName && fieldName.indexOf('.') > 0; // is the field a complex object, "address.streetNumber" + let fieldNameToUse = fieldName; + if (isComplexObject) { + const complexFieldNames = fieldName.split(/\.(.*)/); + fieldNameToUse = (complexFieldNames.length > 1 ? complexFieldNames[1] : complexFieldNames) as string; + } + // validate the value before applying it (if not valid we'll set an empty string) - const validation = this.validate(); - const newValue = (validation && validation.valid) ? state[fieldName] : ''; + const stateValue = isComplexObject ? getDescendantProperty(state, fieldNameToUse) : state[fieldName]; + const validation = this.validate({ position, inputValue: stateValue }); // set the new value to the item datacontext if (isComplexObject) { + const newValueFromComplex = getDescendantProperty(state, fieldNameToUse); + const newValue = (validation && validation.valid) ? newValueFromComplex : ''; setDeepValue(item, fieldName, newValue); } else if (fieldName) { - item[fieldName] = newValue; + item[fieldName] = (validation && validation.valid) ? state[fieldName] : ''; } } } @@ -199,8 +211,10 @@ export class DualInputEditor implements Editor { isValueChanged(): boolean { const leftElmValue = this._leftInput.value; const rightElmValue = this._rightInput.value; + const leftEditorParams = this.editorParams && this.editorParams.leftInput; + const rightEditorParams = this.editorParams && this.editorParams.rightInput; const lastKeyEvent = this._lastInputKeyEvent && this._lastInputKeyEvent.keyCode; - if (this.columnEditor && this.columnEditor.alwaysSaveOnEnterKey && lastKeyEvent === KeyCode.ENTER) { + if ((leftEditorParams && leftEditorParams.alwaysSaveOnEnterKey || rightEditorParams && rightEditorParams.alwaysSaveOnEnterKey) && lastKeyEvent === KeyCode.ENTER) { return true; } const leftResult = (!(leftElmValue === '' && this.originalLeftValue === null)) && (leftElmValue !== this.originalLeftValue); @@ -222,7 +236,7 @@ export class DualInputEditor implements Editor { const isComplexObject = fieldName && fieldName.indexOf('.') > 0; if (item && fieldName !== undefined && this.columnDef && (item.hasOwnProperty(fieldName) || isComplexObject)) { - const itemValue = (isComplexObject) ? getDescendantProperty(item, fieldName) : (item.hasOwnProperty(fieldName) && item[fieldName] || ''); + const itemValue = (isComplexObject) ? getDescendantProperty(item, fieldName) : (item.hasOwnProperty(fieldName) ? item[fieldName] : ''); this[originalValuePosition] = itemValue; if (this.editorParams[position].type === 'float') { const decimalPlaces = this.getDecimalPlaces(position); @@ -230,14 +244,15 @@ export class DualInputEditor implements Editor { this[originalValuePosition] = (+this[originalValuePosition]).toFixed(decimalPlaces); } } - this[inputVarPosition].value = `${this[originalValuePosition]}`; + if (this[inputVarPosition]) { + this[inputVarPosition].value = `${this[originalValuePosition]}`; + } } } save() { const validation = this.validate(); const isValid = (validation && validation.valid) || false; - const isChanged = this.isValueChanged(); if (!this._isValueSaveCalled) { if (this.hasAutoCommitEdit && isValid) { @@ -250,10 +265,14 @@ export class DualInputEditor implements Editor { } serializeValue() { - return { - [this._leftFieldName]: this.serializeValueByPosition('leftInput'), - [this._rightFieldName]: this.serializeValueByPosition('rightInput') - }; + const obj = {}; + const leftValue = this.serializeValueByPosition('leftInput'); + const rightValue = this.serializeValueByPosition('rightInput'); + + setDeepValue(obj, this._leftFieldName, leftValue); + setDeepValue(obj, this._rightFieldName, rightValue); + + return obj; } serializeValueByPosition(position: 'leftInput' | 'rightInput') { @@ -277,7 +296,7 @@ export class DualInputEditor implements Editor { // returns the number of fixed decimal places or null const positionSide = position === 'leftInput' ? 'leftInput' : 'rightInput'; const sideParams = this.editorParams[positionSide]; - let rtn: number | undefined = sideParams?.decimal; + const rtn: number | undefined = sideParams?.decimal; if (rtn === undefined) { return defaultDecimalPlaces; @@ -298,25 +317,38 @@ export class DualInputEditor implements Editor { return '1'; } - validate(): EditorValidatorOutput { - const leftValidation = this.validateByPosition('leftInput'); - const rightValidation = this.validateByPosition('rightInput'); + validate(inputValidation?: { position: 'leftInput' | 'rightInput', inputValue: any }): EditorValidatorOutput { + if (inputValidation) { + const posValidation = this.validateByPosition(inputValidation.position, inputValidation.inputValue); + if (!posValidation.valid) { + inputValidation.position === 'leftInput' ? this._leftInput.select() : this._rightInput.select(); + return posValidation; + } + } else { + const leftValidation = this.validateByPosition('leftInput'); + const rightValidation = this.validateByPosition('rightInput'); - if (!leftValidation.valid) { - this._leftInput.select(); - return leftValidation; - } - if (!rightValidation.valid) { - this._rightInput.select(); - return rightValidation; + if (!leftValidation.valid) { + this._leftInput.select(); + return leftValidation; + } + if (!rightValidation.valid) { + this._rightInput.select(); + return rightValidation; + } } - return { valid: true, msg: null }; + return { valid: true, msg: '' }; } - validateByPosition(position: 'leftInput' | 'rightInput'): EditorValidatorOutput { + validateByPosition(position: 'leftInput' | 'rightInput', inputValue?: any): EditorValidatorOutput { const positionEditorParams = this.editorParams[position]; - const input = position === 'leftInput' ? this._leftInput : this._rightInput; - const currentVal = input?.value; + let currentVal = ''; + if (inputValue) { + currentVal = inputValue; + } else { + const input = position === 'leftInput' ? this._leftInput : this._rightInput; + currentVal = input && input.value; + } const baseValidatorOptions = { editorArgs: this.args, errorMessage: positionEditorParams.errorMessage, diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index d9707ee7a..f8c80f190 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -723,14 +723,14 @@ export function setDeepValue(obj: any, path: string | string[], value: any) { if (path.length > 1) { const e = path.shift(); - if (obj && e !== undefined && obj.hasOwnProperty(e)) { + if (obj && e !== undefined) { setDeepValue( obj[e] = Object.prototype.toString.call(obj[e]) === '[object Object]' ? obj[e] : {}, path, value ); } - } else if (obj && path[0] && obj.hasOwnProperty(path[0])) { + } else if (obj && path[0]) { obj[path[0]] = value; } } diff --git a/packages/common/src/styles/slick-default-theme.scss b/packages/common/src/styles/slick-default-theme.scss index 5d8057e42..2946c3548 100644 --- a/packages/common/src/styles/slick-default-theme.scss +++ b/packages/common/src/styles/slick-default-theme.scss @@ -92,7 +92,7 @@ transform: translate(0, -2px); } - input.compound-editor-text { + input.dual-editor-text { width: calc(50% + 1px - 5px); // 1px (is 2px / 2) and 5px (is space between the 2 inputs) height: 100%; outline: 0; diff --git a/packages/common/src/styles/slick-editors.scss b/packages/common/src/styles/slick-editors.scss index b5b044366..aaeb3b98d 100644 --- a/packages/common/src/styles/slick-editors.scss +++ b/packages/common/src/styles/slick-editors.scss @@ -3,7 +3,7 @@ .slick-row { .slick-cell { &.active { - input.compound-editor-text, + input.dual-editor-text, input.editor-text { border: $text-editor-border; border-radius: $text-editor-border-radius; diff --git a/packages/web-demo-vanilla-bundle/package.json b/packages/web-demo-vanilla-bundle/package.json index ee9b265b5..580ea3886 100644 --- a/packages/web-demo-vanilla-bundle/package.json +++ b/packages/web-demo-vanilla-bundle/package.json @@ -33,13 +33,13 @@ "moment-mini": "^2.24.0" }, "devDependencies": { - "@types/jquery": "^3.3.35", + "@types/jquery": "^3.3.36", "@types/moment": "^2.13.0", - "@types/node": "^13.13.2", + "@types/node": "^13.13.4", "@types/webpack": "^4.41.12", "clean-webpack-plugin": "^3.0.0", "copy-webpack-plugin": "^5.1.1", - "css-loader": "^3.5.2", + "css-loader": "^3.5.3", "file-loader": "^6.0.0", "fork-ts-checker-webpack-plugin": "^4.1.3", "html-loader": "^1.1.0", @@ -48,9 +48,9 @@ "mini-css-extract-plugin": "^0.9.0", "node-sass": "4.14.0", "sass-loader": "^8.0.2", - "style-loader": "^1.1.4", - "ts-loader": "^7.0.1", - "ts-node": "^8.9.0", + "style-loader": "^1.2.1", + "ts-loader": "^7.0.2", + "ts-node": "^8.9.1", "typescript": "^3.8.3", "url-loader": "^4.1.0", "webpack": "^4.43.0",