diff --git a/src/app/modules/angular-slickgrid/editors/__tests__/autoCompleteEditor.spec.ts b/src/app/modules/angular-slickgrid/editors/__tests__/autoCompleteEditor.spec.ts index c4f8a845d..35d842c74 100644 --- a/src/app/modules/angular-slickgrid/editors/__tests__/autoCompleteEditor.spec.ts +++ b/src/app/modules/angular-slickgrid/editors/__tests__/autoCompleteEditor.spec.ts @@ -1,7 +1,8 @@ import { Editors } from '../index'; import { AutoCompleteEditor } from '../autoCompleteEditor'; -import { AutocompleteOption, Column, FieldType, EditorArguments, GridOption, OperatorType, KeyCode } from '../../models'; +import { AutocompleteOption, Column, EditorArgs, EditorArguments, GridOption, KeyCode, FieldType } from '../../models'; +const KEY_CHAR_A = 97; const containerId = 'demo-container'; // define a <div> container to simulate the grid container @@ -12,13 +13,19 @@ const dataViewStub = { }; const gridOptionMock = { + autoCommitEdit: false, enableeditoring: true, enableeditorTrimWhiteSpace: true, } as GridOption; +const getEditorLockMock = { + commitCurrentEdit: jest.fn(), +}; + const gridStub = { getOptions: () => gridOptionMock, getColumns: jest.fn(), + getEditorLock: () => getEditorLockMock, getHeaderRowColumn: jest.fn(), render: jest.fn(), }; @@ -193,5 +200,225 @@ describe('AutoCompleteEditor', () => { expect(editor.elementCollection).toEqual([{ value: 'male', label: 'Male' }, { value: 'female', label: 'Female' }]); }); + + it('should return True when calling "isValueChanged()" method with previously dispatched keyboard event being char "a"', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KEY_CHAR_A, bubbles: true, cancelable: true }); + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector<HTMLInputElement>('input.editor-gender'); + + editorElm.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(true); + }); + + it('should return False when calling "isValueChanged()" method with previously dispatched keyboard event is same char as current value', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KEY_CHAR_A, bubbles: true, cancelable: true }); + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector<HTMLInputElement>('input.editor-gender'); + + editor.loadValue({ id: 123, gender: 'a', isActive: true }); + editorElm.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(false); + }); + + it('should return True when calling "isValueChanged()" method with previously dispatched keyboard event as ENTER and "alwaysSaveOnEnterKey" is enabled', () => { + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: KeyCode.ENTER, bubbles: true, cancelable: true }); + mockColumn.internalColumnEditor.alwaysSaveOnEnterKey = true; + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector<HTMLInputElement>('input.editor-gender'); + + editorElm.focus(); + editorElm.dispatchEvent(event); + + expect(editor.isValueChanged()).toBe(true); + }); + + it('should call "focus()" method and expect the DOM element to be focused and selected', async () => { + editor = new AutoCompleteEditor(editorArguments); + const editorElm = editor.editorDomElement; + const spy = jest.spyOn(editorElm, 'focus'); + editor.focus(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should return override the item data as an object found from the collection when calling "applyValue" that passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockItemData = { id: 123, gender: 'female', isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.applyValue(mockItemData, { value: 'female', label: 'female' }); + + expect(mockItemData).toEqual({ id: 123, gender: { value: 'female', label: 'female' }, isActive: true }); + }); + + it('should return override the item data as a string found from the collection when calling "applyValue" that passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockColumn.internalColumnEditor.collection = ['male', 'female']; + mockItemData = { id: 123, gender: 'female', isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.applyValue(mockItemData, 'female'); + + expect(mockItemData).toEqual({ id: 123, gender: 'female', isActive: true }); + }); + + it('should return item data with an empty string in its value when calling "applyValue" which fails the custom validation', () => { + mockColumn.internalColumnEditor.validator = (value: any, args: EditorArgs) => { + if (value.label.length < 10) { + return { valid: false, msg: 'Must be at least 10 chars long.' }; + } + return { valid: true, msg: '' }; + }; + mockItemData = { id: 123, gender: 'female', isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.applyValue(mockItemData, 'female'); + + expect(mockItemData).toEqual({ id: 123, gender: '', isActive: true }); + }); + + it('should return DOM element value when "forceUserInput" is enabled and loaded value length is greater then minLength defined when calling "serializeValue"', () => { + mockColumn.internalColumnEditor.editorOptions = { forceUserInput: true, }; + mockItemData = { id: 123, gender: { value: 'male', label: 'Male' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValue('Female'); + const output = editor.serializeValue(); + + expect(output).toBe('Female'); + }); + + it('should return DOM element value when "forceUserInput" is enabled and loaded value length is greater then custom minLength defined when calling "serializeValue"', () => { + mockColumn.internalColumnEditor.editorOptions = { forceUserInput: true, minLength: 2 } as AutocompleteOption; + mockItemData = { id: 123, gender: { value: 'male', label: 'Male' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValue('Female'); + const output = editor.serializeValue(); + + expect(output).toBe('Female'); + }); + + it('should return loaded value when "forceUserInput" is enabled and loaded value length is lower than minLength defined when calling "serializeValue"', () => { + mockColumn.internalColumnEditor.editorOptions = { forceUserInput: true, }; + mockItemData = { id: 123, gender: { value: 'male', label: 'Male' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + editor.setValue('F'); + const output = editor.serializeValue(); + + expect(output).toBe('Male'); + }); + + it('should return correct object value even when defining a "customStructure" when calling "serializeValue"', () => { + mockColumn.internalColumnEditor.collection = [{ option: 'male', text: 'Male' }, { option: 'female', text: 'Female' }]; + mockColumn.internalColumnEditor.customStructure = { value: 'option', label: 'text' }; + mockItemData = { id: 123, gender: { option: 'female', text: 'Female' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toBe('Female'); + }); + + it('should return an object output when calling "serializeValue" with its column definition set to "FieldType.object"', () => { + mockColumn.type = FieldType.object; + mockColumn.internalColumnEditor.collection = [{ value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }]; + mockItemData = { id: 123, gender: { value: 'f', label: 'Female' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + const output = editor.serializeValue(); + + expect(output).toEqual({ value: 'f', label: 'Female' }); + }); + + it('should call "getEditorLock" when "hasAutoCommitEdit" is enabled after calling "save()" method', async () => { + gridOptionMock.autoCommitEdit = true; + const spy = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + + editor = new AutoCompleteEditor(editorArguments); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should call "commitChanges" when "hasAutoCommitEdit" is disabled after calling "save()" method', async () => { + gridOptionMock.autoCommitEdit = false; + const spy = jest.spyOn(editorArguments, 'commitChanges'); + + editor = new AutoCompleteEditor(editorArguments); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + + it('should validate and return False when field is required and field is an empty string', () => { + mockColumn.internalColumnEditor.required = true; + editor = new AutoCompleteEditor(editorArguments); + const validation = editor.validate(''); + + expect(validation).toEqual({ valid: false, msg: 'Field is required' }); + }); + + it('should validate and return True when field is required and field a valid object', () => { + mockColumn.internalColumnEditor.required = true; + editor = new AutoCompleteEditor(editorArguments); + const validation = editor.validate(mockItemData); + + expect(validation).toEqual({ valid: true, msg: null }); + }); + + describe('onSelect method', () => { + it('should expect "setValue" and "autoCommitEdit" to have been called with a string when item provided is a string', (done) => { + const spyCommitEdit = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + gridOptionMock.autoCommitEdit = false; + mockColumn.internalColumnEditor.collection = ['male', 'female']; + mockItemData = { id: 123, gender: 'female', isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + const spySetValue = jest.spyOn(editor, 'setValue'); + const output = editor.onSelect(null, { item: mockItemData.gender }); + + // HOW DO WE TRIGGER the jQuery UI autocomplete select event? The following works only on "autocompleteselect" + // but that doesn't trigger the "select" (onSelect) directly + // const editorElm = editor.editorDomElement; + // editorElm.on('autocompleteselect', (event, ui) => console.log(ui)); + // editorElm[0].dispatchEvent(new (window.window as any).CustomEvent('autocompleteselect', { detail: { item: 'female' }, bubbles: true, cancelable: true })); + + setTimeout(() => { + expect(output).toBe(false); + expect(spyCommitEdit).toHaveBeenCalled(); + expect(spySetValue).toHaveBeenCalledWith('female'); + done(); + }); + }); + + it('should expect "setValue" and "autoCommitEdit" to have been called with the string label when item provided is an object', () => { + const spyCommitEdit = jest.spyOn(gridStub.getEditorLock(), 'commitCurrentEdit'); + gridOptionMock.autoCommitEdit = false; + mockColumn.internalColumnEditor.collection = [{ value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }]; + mockItemData = { id: 123, gender: { value: 'f', label: 'Female' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + const spySetValue = jest.spyOn(editor, 'setValue'); + const output = editor.onSelect(null, { item: mockItemData.gender }); + + expect(output).toBe(false); + expect(spyCommitEdit).toHaveBeenCalled(); + expect(spySetValue).toHaveBeenCalledWith('Female'); + }); + }); }); }); diff --git a/src/app/modules/angular-slickgrid/editors/autoCompleteEditor.ts b/src/app/modules/angular-slickgrid/editors/autoCompleteEditor.ts index cfd0eaa90..24956ffa5 100644 --- a/src/app/modules/angular-slickgrid/editors/autoCompleteEditor.ts +++ b/src/app/modules/angular-slickgrid/editors/autoCompleteEditor.ts @@ -16,6 +16,9 @@ import { findOrDefault } from '../services/utilities'; // using external non-typed js libraries declare var $: any; +// minimum length of chars to type before starting to start querying +const MIN_LENGTH = 3; + /* * An example of a 'detached' editor. * KeyDown events are also handled to provide handling for Tab, Shift-Tab, Esc and Ctrl-Enter. @@ -104,7 +107,7 @@ export class AutoCompleteEditor implements Editor { } focus() { - this._$editorElm.focus(); + this._$editorElm.focus().select(); } getValue() { @@ -142,15 +145,16 @@ export class AutoCompleteEditor implements Editor { } } - serializeValue() { - // if user provided a custom structure, we will serialize the value returned from the object with custom structure - const minLength = typeof this.editorOptions.minLength !== 'undefined' ? this.editorOptions.minLength : 3; + serializeValue(): any { + // if you want to add the autocomplete functionality but want the user to be able to input a new option if (this.editorOptions.forceUserInput) { - this._currentValue = this._$editorElm.val().length >= minLength ? this._$editorElm.val() : this._currentValue; + const minLength = this.editorOptions && this.editorOptions.hasOwnProperty('minLength') ? this.editorOptions.minLength : MIN_LENGTH; + this._currentValue = this._$editorElm.val().length > minLength ? this._$editorElm.val() : this._currentValue; } - if (this.customStructure && this._currentValue.hasOwnProperty(this.labelName)) { + // if user provided a custom structure, we will serialize the value returned from the object with custom structure + if (this.customStructure && this._currentValue && this._currentValue.hasOwnProperty(this.labelName)) { return this._currentValue[this.labelName]; - } else if (this._currentValue.label) { + } else if (this._currentValue && this._currentValue.label) { if (this.columnDef.type === FieldType.object) { return { [this.labelName]: this._currentValue.label, @@ -165,15 +169,16 @@ export class AutoCompleteEditor implements Editor { applyValue(item: any, state: any) { let newValue = state; const fieldName = this.columnDef && this.columnDef.field; + // if we have a collection defined, we will try to find the string within the collection and return it if (Array.isArray(this.editorCollection) && this.editorCollection.length > 0) { newValue = findOrDefault(this.editorCollection, (collectionItem: any) => { if (collectionItem && typeof state === 'object' && collectionItem.hasOwnProperty(this.labelName)) { - return collectionItem[this.labelName].toString() === state[this.labelName].toString(); + return (collectionItem.hasOwnProperty(this.labelName) && collectionItem[this.labelName].toString()) === (state.hasOwnProperty(this.labelName) && state[this.labelName].toString()); } else if (collectionItem && typeof state === 'string' && collectionItem.hasOwnProperty(this.labelName)) { - return collectionItem[this.labelName].toString() === state; + return (collectionItem.hasOwnProperty(this.labelName) && collectionItem[this.labelName].toString()) === state; } - return collectionItem.toString() === state; + return collectionItem && collectionItem.toString() === state; }); } @@ -183,7 +188,7 @@ export class AutoCompleteEditor implements Editor { item[fieldNameFromComplexObject || fieldName] = (validation && validation.valid) ? newValue : ''; } - isValueChanged() { + isValueChanged(): boolean { const lastEvent = this._lastInputEvent && this._lastInputEvent.keyCode; if (this.columnEditor && this.columnEditor.alwaysSaveOnEnterKey && lastEvent === KeyCode.ENTER) { return true; @@ -218,7 +223,9 @@ export class AutoCompleteEditor implements Editor { // private functions // ------------------ - private onSelect(event: Event, ui: any) { + // this function should be PRIVATE but for unit tests purposes we'll make it public until a better solution is found + // a better solution would be to get the autocomplete DOM element to work with selection but I couldn't find how to do that in Jest + onSelect(event: Event, ui: any): boolean { if (ui && ui.item) { this._currentValue = ui && ui.item; const itemLabel = typeof ui.item === 'string' ? ui.item : ui.item.label; @@ -282,8 +289,6 @@ export class AutoCompleteEditor implements Editor { }); } - setTimeout(() => { - this._$editorElm.focus().select(); - }, 50); + setTimeout(() => this.focus(), 50); } } diff --git a/src/app/modules/angular-slickgrid/filters/__tests__/autoCompleteFilter.spec.ts b/src/app/modules/angular-slickgrid/filters/__tests__/autoCompleteFilter.spec.ts index 4c85fb89a..ddaf91aae 100644 --- a/src/app/modules/angular-slickgrid/filters/__tests__/autoCompleteFilter.spec.ts +++ b/src/app/modules/angular-slickgrid/filters/__tests__/autoCompleteFilter.spec.ts @@ -446,4 +446,32 @@ describe('AutoCompleteFilter', () => { expect(filterCollection[1]).toEqual({ value: 'male', description: 'male' }); expect(filterCollection[2]).toEqual({ value: 'other', description: 'other' }); }); + + describe('onSelect method', () => { + it('should expect "setValue" and "autoCommitEdit" to have been called with a string when item provided is a string', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + mockColumn.filter.collection = ['male', 'female']; + + filter.init(filterArguments); + const spySetValue = jest.spyOn(filter, 'setValues'); + const output = filter.onSelect(null, { item: 'female' }); + + expect(output).toBe(false); + expect(spySetValue).toHaveBeenCalledWith('female'); + expect(spyCallback).toHaveBeenCalledWith(null, { columnDef: mockColumn, operator: 'EQ', searchTerms: ['female'], shouldTriggerQuery: true }); + }); + + it('should expect "setValue" and "autoCommitEdit" to have been called with the string label when item provided is an object', () => { + const spyCallback = jest.spyOn(filterArguments, 'callback'); + mockColumn.filter.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + + filter.init(filterArguments); + const spySetValue = jest.spyOn(filter, 'setValues'); + const output = filter.onSelect(null, { item: { value: 'f', label: 'Female' } }); + + expect(output).toBe(false); + expect(spySetValue).toHaveBeenCalledWith('Female'); + expect(spyCallback).toHaveBeenCalledWith(null, { columnDef: mockColumn, operator: 'EQ', searchTerms: ['f'], shouldTriggerQuery: true }); + }); + }); }); diff --git a/src/app/modules/angular-slickgrid/filters/autoCompleteFilter.ts b/src/app/modules/angular-slickgrid/filters/autoCompleteFilter.ts index d15088463..a0c77d95a 100644 --- a/src/app/modules/angular-slickgrid/filters/autoCompleteFilter.ts +++ b/src/app/modules/angular-slickgrid/filters/autoCompleteFilter.ts @@ -138,7 +138,7 @@ export class AutoCompleteFilter implements Filter { */ destroy() { if (this.$filterElm) { - this.$filterElm.off('keyup input change').remove(); + this.$filterElm.off('keyup').remove(); } } @@ -264,7 +264,7 @@ export class AutoCompleteFilter implements Filter { // step 3, subscribe to the keyup event and run the callback when that happens // also add/remove "filled" class for styling purposes - this.$filterElm.on('keyup input change', (e: any) => { + this.$filterElm.on('keyup', (e: any) => { let value = e && e.target && e.target.value || ''; const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; if (typeof value === 'string' && enableWhiteSpaceTrim) { @@ -354,15 +354,15 @@ export class AutoCompleteFilter implements Filter { // private functions // ------------------ - private onSelect(event: Event, ui: any) { + // this function should be PRIVATE but for unit tests purposes we'll make it public until a better solution is found + // a better solution would be to get the autocomplete DOM element to work with selection but I couldn't find how to do that in Jest + onSelect(event: Event, ui: any): boolean { if (ui && ui.item) { const itemLabel = typeof ui.item === 'string' ? ui.item : ui.item.label; const itemValue = typeof ui.item === 'string' ? ui.item : ui.item.value; - this.$filterElm.val(itemLabel); + this.setValues(itemLabel); + itemValue === '' ? this.$filterElm.removeClass('filled') : this.$filterElm.addClass('filled'); this.callback(event, { columnDef: this.columnDef, operator: this.operator, searchTerms: [itemValue], shouldTriggerQuery: this._shouldTriggerQuery }); - // reset both flags for next use - this._clearFilterTriggered = false; - this._shouldTriggerQuery = true; } return false; } diff --git a/src/app/modules/angular-slickgrid/models/editor.interface.ts b/src/app/modules/angular-slickgrid/models/editor.interface.ts index 85f029043..059de973e 100644 --- a/src/app/modules/angular-slickgrid/models/editor.interface.ts +++ b/src/app/modules/angular-slickgrid/models/editor.interface.ts @@ -42,8 +42,8 @@ export interface Editor { focus: () => void; /** - * Deserialize the value(s) saved to "state" and apply them to the data item - * this method may get called after the editor itself has been destroyed + * Deserialize the value(s) saved to "state" and apply them to the data item. + * This method may get called after the editor itself has been destroyed, * treat it as an equivalent of a Java/C# "static" method - no instance variables should be accessed */ applyValue: (item: any, state: any) => void;