diff --git a/.eslintrc.js b/.eslintrc.js index 627237b37..6a23cb515 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -135,6 +135,7 @@ module.exports = { "prefer-const": "error", "prefer-object-spread": "error", "radix": "error", + "semi": [2, "always"], "space-in-parens": [ "error" ], diff --git a/README.md b/README.md index 2ed58e2bf..7fe1a8219 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ npm run test:watch #### Code - [x] Aggregators (6) - [ ] Editors - - [ ] Autocomplete + - [x] Autocomplete - [x] Checkbox - [ ] Date - [x] Float @@ -75,7 +75,7 @@ npm run test:watch - [x] Slider - [x] Text - [ ] Filters - - [ ] Autocomplete + - [x] Autocomplete - [ ] Compound Date - [x] Compound Input(s) - [x] Compound Slider diff --git a/packages/common/src/editors/__tests__/autoCompleteEditor.spec.ts b/packages/common/src/editors/__tests__/autoCompleteEditor.spec.ts new file mode 100644 index 000000000..29cb474a2 --- /dev/null +++ b/packages/common/src/editors/__tests__/autoCompleteEditor.spec.ts @@ -0,0 +1,492 @@ +import { Editors } from '../index'; +import { AutoCompleteEditor } from '../autoCompleteEditor'; +import { KeyCode, FieldType } from '../../enums/index'; +import { AutocompleteOption, Column, EditorArgs, EditorArguments, GridOption, } from '../../interfaces/index'; + +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(), + render: jest.fn(), +}; + +describe('AutoCompleteEditor', () => { + let divContainer: HTMLDivElement; + let editor: AutoCompleteEditor; + let editorArguments: EditorArguments; + let mockColumn: Column; + let mockItemData: any; + + beforeEach(() => { + divContainer = document.createElement('div'); + divContainer.innerHTML = template; + document.body.appendChild(divContainer); + + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.autoComplete }, 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 AutoCompleteEditor(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 collection is not a valid array', (done) => { + try { + // @ts-ignore + mockColumn.internalColumnEditor.collection = { hello: 'world' }; + editor = new AutoCompleteEditor(editorArguments); + } catch (e) { + expect(e.toString()).toContain(`The "collection" passed to the Autocomplete Editor is not a valid array.`); + done(); + } + }); + }); + + describe('with valid Editor instance', () => { + beforeEach(() => { + mockItemData = { id: 123, gender: 'male', isActive: true }; + mockColumn = { id: 'gender', field: 'gender', editable: true, editor: { model: Editors.autoComplete }, internalColumnEditor: {} } as Column; + mockColumn.internalColumnEditor.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; + + editorArguments.column = mockColumn; + editorArguments.item = mockItemData; + }); + + afterEach(() => { + editor.destroy(); + }); + + it('should initialize the editor', () => { + editor = new AutoCompleteEditor(editorArguments); + const editorCount = divContainer.querySelectorAll('input.editor-text.editor-gender').length; + const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); + + expect(autocompleteUlElms.length).toBe(1); + expect(editorCount).toBe(1); + }); + + it('should initialize the editor even when user define his own editor options', () => { + mockColumn.internalColumnEditor.editorOptions = { minLength: 3 } as AutocompleteOption; + editor = new AutoCompleteEditor(editorArguments); + const editorCount = divContainer.querySelectorAll('input.editor-text.editor-gender').length; + const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); + + expect(autocompleteUlElms.length).toBe(1); + expect(editorCount).toBe(1); + }); + + it('should have a placeholder when defined in its column definition', () => { + const testValue = 'test placeholder'; + mockColumn.internalColumnEditor.placeholder = testValue; + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-text.editor-gender'); + + expect(editorElm.placeholder).toBe(testValue); + }); + + it('should have a title (tooltip) when defined in its column definition', () => { + const testValue = 'test title'; + mockColumn.internalColumnEditor.title = testValue; + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-text.editor-gender'); + + expect(editorElm.title).toBe(testValue); + }); + + it('should call "setValue" and expect the DOM element to have the same value when calling "getValue"', () => { + editor = new AutoCompleteEditor(editorArguments); + editor.setValue('male'); + + expect(editor.getValue()).toBe('male'); + }); + + 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 AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + + expect(editor.getValue()).toBe('male'); + }); + + it('should define an item datacontext containing a complex object as cell value and expect this value to be loaded in the editor when calling "loadValue"', () => { + mockItemData = { id: 123, gender: { value: 'male', label: 'Male' }, isActive: true }; + mockColumn.field = 'gender.value'; + editor = new AutoCompleteEditor(editorArguments); + editor.loadValue(mockItemData); + + expect(editor.getValue()).toBe('male'); + }); + + 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 AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-gender'); + + editorElm.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 AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-gender'); + + editorElm.focus(); + editorElm.dispatchEvent(event); + + expect(spyEvent).toHaveBeenCalled(); + }); + + it('should render the DOM element with different key/value pair when user provide its own customStructure', () => { + mockColumn.internalColumnEditor.collection = [{ option: 'male', text: 'Male' }, { option: 'female', text: 'Female' }]; + mockColumn.internalColumnEditor.customStructure = { value: 'option', label: 'text' }; + const event = new (window.window as any).KeyboardEvent('keydown', { keyCode: 109, bubbles: true, cancelable: true }); + + editor = new AutoCompleteEditor(editorArguments); + const editorElm = divContainer.querySelector('input.editor-gender'); + + editorElm.focus(); + editorElm.dispatchEvent(event); + + 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('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('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('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', () => { + editor = new AutoCompleteEditor(editorArguments); + const editorElm = editor.editorDomElement; + const spy = jest.spyOn(editorElm, 'focus'); + editor.focus(); + + expect(spy).toHaveBeenCalled(); + }); + + describe('applyValue method', () => { + it('should apply the value to the gender property when it 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 apply the value to the gender property with a field having dot notation (complex object) that passes validation', () => { + mockColumn.internalColumnEditor.validator = null; + mockColumn.field = 'user.gender'; + mockItemData = { id: 1, user: { gender: 'female' }, isActive: true }; + + editor = new AutoCompleteEditor(editorArguments); + editor.applyValue(mockItemData, { value: 'female', label: 'female' }); + + expect(mockItemData).toEqual({ id: 1, user: { gender: { value: 'female', label: 'female' } }, isActive: true }); + }); + + it('should return override the item data as a string found from the collection 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 }); + }); + }); + + describe('forceUserInput flag', () => { + 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'); + }); + }); + + describe('serializeValue method', () => { + 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' }); + }); + }); + + describe('save method', () => { + it('should call "getEditorLock" when "hasAutoCommitEdit" is enabled after calling "save()" method', () => { + 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', () => { + gridOptionMock.autoCommitEdit = false; + const spy = jest.spyOn(editorArguments, 'commitChanges'); + + editor = new AutoCompleteEditor(editorArguments); + editor.save(); + + expect(spy).toHaveBeenCalled(); + }); + }); + + describe('validate method', () => { + 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 is a valid input value', () => { + mockColumn.internalColumnEditor.required = true; + editor = new AutoCompleteEditor(editorArguments); + const validation = editor.validate('gender'); + + expect(validation).toEqual({ valid: true, msg: null }); + }); + }); + + describe('onSelect method', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should expect "setValue" to have been called but not "autoCommitEdit" when "autoCommitEdit" is disabled', () => { + const spyCommitEdit = jest.spyOn(gridStub, 'getEditorLock'); + 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 }); + + expect(output).toBe(false); + expect(spyCommitEdit).not.toHaveBeenCalled(); + expect(spySetValue).toHaveBeenCalledWith('female'); + }); + + 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'); + gridOptionMock.autoCommitEdit = true; + 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'); + gridOptionMock.autoCommitEdit = true; + 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'); + }); + + it('should expect the "onSelect" method to be called when the callback method is triggered', () => { + gridOptionMock.autoCommitEdit = true; + mockColumn.internalColumnEditor.collection = [{ value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }]; + mockItemData = { id: 123, gender: { value: 'f', label: 'Female' }, isActive: true }; + + const event = new CustomEvent('change'); + editor = new AutoCompleteEditor(editorArguments); + const spy = jest.spyOn(editor, 'onSelect'); + editor.autoCompleteOptions.select(event, { item: 'fem' }); + + expect(spy).toHaveBeenCalledWith(event, { item: 'fem' }); + }); + + it('should initialize the editor with editorOptions and expect the "onSelect" method to be called when the callback method is triggered', (done) => { + gridOptionMock.autoCommitEdit = true; + mockColumn.internalColumnEditor.collection = [{ value: 'm', label: 'Male' }, { value: 'f', label: 'Female' }]; + mockColumn.internalColumnEditor.editorOptions = { minLength: 3 } as AutocompleteOption; + mockItemData = { id: 123, gender: { value: 'f', label: 'Female' }, isActive: true }; + + const event = new CustomEvent('change'); + editor = new AutoCompleteEditor(editorArguments); + const onSelectSpy = jest.spyOn(editor, 'onSelect'); + const focusSpy = jest.spyOn(editor, 'focus'); + editor.autoCompleteOptions.select(event, { item: 'fem' }); + + expect(onSelectSpy).toHaveBeenCalledWith(event, { item: 'fem' }); + setTimeout(() => { + expect(focusSpy).toHaveBeenCalled(); + done(); + }, 52); + }); + }); + }); +}); diff --git a/packages/common/src/editors/autoCompleteEditor.ts b/packages/common/src/editors/autoCompleteEditor.ts new file mode 100644 index 000000000..d876d8ebd --- /dev/null +++ b/packages/common/src/editors/autoCompleteEditor.ts @@ -0,0 +1,315 @@ +import { FieldType, KeyCode, } from '../enums/index'; +import { + AutocompleteOption, + CollectionCustomStructure, + Column, + ColumnEditor, + Editor, + EditorArguments, + EditorValidator, + EditorValidatorOutput, +} from './../interfaces/index'; +import { Constants } from './../constants'; +import { findOrDefault, getDescendantProperty, setDeepValue } from '../services/utilities'; + +// using external non-typed js libraries +declare const $: 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. + */ +export class AutoCompleteEditor implements Editor { + private _autoCompleteOptions: AutocompleteOption; + private _currentValue: any; + private _defaultTextValue: string; + private _elementCollection: any[]; + private _lastInputEvent: JQueryEventObject; + + /** The JQuery DOM element */ + private _$editorElm: any; + + /** SlickGrid Grid object */ + grid: any; + + /** The property name for labels in the collection */ + labelName: string; + + /** The property name for values in the collection */ + valueName: string; + + forceUserInput: boolean; + + constructor(private args: EditorArguments) { + if (!args) { + throw new Error('[Slickgrid-Universal] Something is wrong with this grid, an Editor must always have valid arguments.'); + } + this.grid = args.grid; + this.init(); + } + + /** Getter for the Autocomplete Option */ + get autoCompleteOptions(): Partial { + return this._autoCompleteOptions || {}; + } + + /** Get the Collection */ + get editorCollection(): any[] { + return this.columnDef && this.columnDef.internalColumnEditor && this.columnDef.internalColumnEditor.collection || []; + } + + /** Get the Final Collection used in the AutoCompleted Source (this may vary from the "collection" especially when providing a customStructure) */ + get elementCollection(): any[] { + return this._elementCollection; + } + + /** Get Column Definition object */ + get columnDef(): Column | undefined { + return this.args && this.args.column; + } + + /** Get Column Editor object */ + get columnEditor(): ColumnEditor { + return this.columnDef && this.columnDef.internalColumnEditor || {}; + } + + /** Getter for the Custom Structure if exist */ + get customStructure(): CollectionCustomStructure | undefined { + return this.columnEditor && this.columnEditor.customStructure; + } + + get editorOptions() { + return this.columnEditor && this.columnEditor.editorOptions || {}; + } + + get hasAutoCommitEdit() { + 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 the Editor DOM Element */ + get editorDomElement(): any { + return this._$editorElm; + } + + init() { + this.labelName = this.customStructure && this.customStructure.label || 'label'; + this.valueName = this.customStructure && this.customStructure.value || 'value'; + + // always render the DOM element, even if user passed a "collectionAsync", + const newCollection = this.columnEditor.collection || []; + this.renderDomElement(newCollection); + } + + destroy() { + this._$editorElm.off('keydown.nav').remove(); + } + + focus() { + this._$editorElm.focus().select(); + } + + getValue() { + return this._$editorElm.val(); + } + + setValue(value: string) { + this._$editorElm.val(value); + } + + applyValue(item: any, state: any) { + let newValue = state; + const fieldName = this.columnDef && this.columnDef.field; + + if (fieldName !== undefined) { + // 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.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.hasOwnProperty(this.labelName) && collectionItem[this.labelName].toString()) === state; + } + return collectionItem && collectionItem.toString() === state; + }); + } + + // is the field a complex object, "address.streetNumber" + const isComplexObject = fieldName.indexOf('.') > 0; + + // validate the value before applying it (if not valid we'll set an empty string) + const validation = this.validate(newValue); + newValue = (validation && validation.valid) ? newValue : ''; + + // set the new value to the item datacontext + if (isComplexObject) { + setDeepValue(item, fieldName, newValue); + } else { + item[fieldName] = newValue; + } + } + } + + isValueChanged(): boolean { + const lastEvent = this._lastInputEvent && this._lastInputEvent.keyCode; + if (this.columnEditor && this.columnEditor.alwaysSaveOnEnterKey && lastEvent === KeyCode.ENTER) { + return true; + } + return (!(this._$editorElm.val() === '' && this._defaultTextValue === null)) && (this._$editorElm.val() !== this._defaultTextValue); + } + + loadValue(item: any) { + const fieldName = this.columnDef && this.columnDef.field; + + if (fieldName !== undefined) { + // is the field a complex object, "address.streetNumber" + const isComplexObject = fieldName.indexOf('.') > 0; + + if (item && this.columnDef && (item.hasOwnProperty(fieldName) || isComplexObject)) { + const data = (isComplexObject) ? getDescendantProperty(item, fieldName) : item[fieldName]; + this._currentValue = data; + this._defaultTextValue = typeof data === 'string' ? data : data[this.labelName]; + this._$editorElm.val(this._defaultTextValue); + this._$editorElm.select(); + } + } + } + + save() { + const validation = this.validate(); + if (validation && validation.valid && this.isValueChanged()) { + if (this.hasAutoCommitEdit) { + this.grid.getEditorLock().commitCurrentEdit(); + } else { + this.args.commitChanges(); + } + } + } + + 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) { + 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 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 && this._currentValue.label) { + if (this.columnDef && this.columnDef.type === FieldType.object) { + return { + [this.labelName]: this._currentValue.label, + [this.valueName]: this._currentValue.value + }; + } + return this._currentValue.label; + } + return this._currentValue; + } + + validate(inputValue?: any): EditorValidatorOutput { + const isRequired = this.columnEditor.required; + const elmValue = (inputValue !== undefined) ? inputValue : this._$editorElm && this._$editorElm.val && this._$editorElm.val(); + const errorMsg = this.columnEditor.errorMessage; + + if (this.validator) { + return this.validator(elmValue, this.args); + } + + // by default the editor is almost always valid (except when it's required but not provided) + if (isRequired && elmValue === '') { + return { + valid: false, + msg: errorMsg || Constants.VALIDATION_REQUIRED_FIELD + }; + } + + return { + valid: true, + msg: null + }; + } + + // + // private functions + // ------------------ + + // 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; + this.setValue(itemLabel); + + if (this.hasAutoCommitEdit) { + // do not use args.commitChanges() as this sets the focus to the next row. + const validation = this.validate(); + if (validation && validation.valid) { + this.grid.getEditorLock().commitCurrentEdit(); + } + } + } + return false; + } + + private renderDomElement(collection: any[]) { + if (!Array.isArray(collection)) { + throw new Error('The "collection" passed to the Autocomplete Editor is not a valid array.'); + } + const columnId = this.columnDef && this.columnDef.id; + const placeholder = this.columnEditor && this.columnEditor.placeholder || ''; + const title = this.columnEditor && this.columnEditor.title || ''; + + this._$editorElm = $(``) + .appendTo(this.args.container) + .on('keydown.nav', (event: JQueryEventObject) => { + this._lastInputEvent = event; + if (event.keyCode === KeyCode.LEFT || event.keyCode === KeyCode.RIGHT) { + event.stopImmediatePropagation(); + } + }); + + // user might pass his own autocomplete options + const autoCompleteOptions: AutocompleteOption = this.columnEditor.editorOptions; + + // assign the collection to a temp variable before filtering/sorting the collection + let finalCollection = collection; + + // user might provide his own custom structure + // jQuery UI autocomplete requires a label/value pair, so we must remap them when user provide different ones + if (Array.isArray(finalCollection) && this.customStructure) { + finalCollection = finalCollection.map((item) => { + return { label: item[this.labelName], value: item[this.valueName] }; + }); + } + + // keep the final source collection used in the AutoComplete as reference + this._elementCollection = finalCollection; + + // when user passes it's own autocomplete options + // we still need to provide our own "select" callback implementation + if (autoCompleteOptions) { + autoCompleteOptions.select = (event: Event, ui: any) => this.onSelect(event, ui); + this._autoCompleteOptions = { ...autoCompleteOptions }; + this._$editorElm.autocomplete(autoCompleteOptions); + } else { + const definedOptions: AutocompleteOption = { + source: finalCollection, + minLength: 0, + select: (event: Event, ui: any) => this.onSelect(event, ui), + }; + this._autoCompleteOptions = { ...definedOptions, ...(this.columnEditor.editorOptions as AutocompleteOption) }; + this._$editorElm.autocomplete(this._autoCompleteOptions); + } + + setTimeout(() => this.focus(), 50); + } +} diff --git a/packages/common/src/editors/index.ts b/packages/common/src/editors/index.ts index fdc8c79e6..952182127 100644 --- a/packages/common/src/editors/index.ts +++ b/packages/common/src/editors/index.ts @@ -1,4 +1,4 @@ -// import { AutoCompleteEditor } from './autoCompleteEditor'; +import { AutoCompleteEditor } from './autoCompleteEditor'; import { CheckboxEditor } from './checkboxEditor'; // import { DateEditor } from './dateEditor'; import { FloatEditor } from './floatEditor'; @@ -11,7 +11,7 @@ import { TextEditor } from './textEditor'; export const Editors = { /** AutoComplete Editor (using jQuery UI autocomplete feature) */ - // autoComplete: AutoCompleteEditor, + autoComplete: AutoCompleteEditor, /** Checkbox Editor (uses native checkbox DOM element) */ checkbox: CheckboxEditor, diff --git a/packages/common/src/filters/autoCompleteFilter.ts b/packages/common/src/filters/autoCompleteFilter.ts new file mode 100644 index 000000000..6005b548d --- /dev/null +++ b/packages/common/src/filters/autoCompleteFilter.ts @@ -0,0 +1,339 @@ +import { + OperatorType, + OperatorString, + SearchTerm, +} from '../enums/index'; +import { + AutocompleteOption, + CollectionCustomStructure, + CollectionOption, + Column, + ColumnFilter, + Filter, + FilterArguments, + FilterCallback, + GridOption, +} from './../interfaces/index'; +import { CollectionService } from '../services/collection.service'; +import { getDescendantProperty } from '../services/utilities'; + +// using external non-typed js libraries +declare const $: any; + +export class AutoCompleteFilter implements Filter { + private _autoCompleteOptions: AutocompleteOption; + private _clearFilterTriggered = false; + private _collection: any[]; + private _shouldTriggerQuery = true; + + /** DOM Element Name, useful for auto-detecting positioning (dropup / dropdown) */ + elementName: string; + + /** The JQuery DOM element */ + $filterElm: any; + + grid: any; + searchTerms: SearchTerm[]; + columnDef: Column; + callback: FilterCallback; + isFilled = false; + + /** The property name for labels in the collection */ + labelName: string; + + /** The property name for values in the collection */ + optionLabel: string; + + /** The property name for values in the collection */ + valueName = 'label'; + + enableTranslateLabel = false; + + /** + * Initialize the Filter + */ + constructor(protected collectionService: CollectionService) { } + + /** Getter for the Autocomplete Option */ + get autoCompleteOptions(): Partial { + return this._autoCompleteOptions || {}; + } + + /** Getter for the Collection Options */ + protected get collectionOptions(): CollectionOption { + return this.columnDef && this.columnDef.filter && this.columnDef.filter.collectionOptions || {}; + } + + /** Getter for the Collection Used by the Filter */ + get collection(): any[] { + return this._collection; + } + + /** Getter for the Filter Operator */ + get columnFilter(): ColumnFilter { + return this.columnDef && this.columnDef.filter || {}; + } + + /** Getter for the Custom Structure if exist */ + get customStructure(): CollectionCustomStructure | undefined { + return this.columnDef && this.columnDef.filter && this.columnDef.filter.customStructure; + } + + /** Getter to know what would be the default operator when none is specified */ + get defaultOperator(): OperatorType | OperatorString { + return OperatorType.equal; + } + + /** Getter for the Grid Options pulled through the Grid Object */ + get gridOptions(): GridOption { + return (this.grid && this.grid.getOptions) ? this.grid.getOptions() : {}; + } + + /** Getter of the Operator to use when doing the filter comparing */ + get operator(): OperatorType | OperatorString { + return this.columnFilter && this.columnFilter.operator || this.defaultOperator; + } + + /** Setter for the filter operator */ + set operator(operator: OperatorType | OperatorString) { + if (this.columnFilter) { + this.columnFilter.operator = operator; + } + } + + /** + * Initialize the filter template + */ + init(args: FilterArguments) { + if (!args) { + throw new Error('[Slickgrid-Universal] A filter must always have an "init()" with valid arguments.'); + } + this.grid = args.grid; + this.callback = args.callback; + this.columnDef = args.columnDef; + this.searchTerms = (args.hasOwnProperty('searchTerms') ? args.searchTerms : []) || []; + + if (!this.grid || !this.columnDef || !this.columnFilter || (!this.columnFilter.collection && !this.columnFilter.filterOptions)) { + throw new Error(`[Slickgrid-Universal] You need to pass a "collection" for the AutoComplete Filter to work correctly. Also each option should include a value/label pair (or value/labelKey when using Locale). For example:: { filter: model: Filters.autoComplete, collection: [{ value: true, label: 'True' }, { value: false, label: 'False'}] }`); + } + + this.enableTranslateLabel = this.columnFilter && this.columnFilter.enableTranslateLabel || false; + this.labelName = this.customStructure && this.customStructure.label || 'label'; + this.valueName = this.customStructure && this.customStructure.value || 'value'; + + // always render the DOM element + const newCollection = this.columnFilter.collection || []; + this._collection = newCollection; + this.renderDomElement(newCollection); + } + + /** + * Clear the filter value + */ + clear(shouldTriggerQuery = true) { + if (this.$filterElm) { + this._clearFilterTriggered = true; + this._shouldTriggerQuery = shouldTriggerQuery; + this.searchTerms = []; + this.$filterElm.val(''); + this.$filterElm.trigger('keyup'); + } + } + + /** + * destroy the filter + */ + destroy() { + if (this.$filterElm) { + this.$filterElm.off('keyup').remove(); + } + } + + /** Set value(s) on the DOM element */ + setValues(values: SearchTerm | SearchTerm[], operator?: OperatorType | OperatorString) { + if (values) { + this.$filterElm.val(values); + } + + // set the operator when defined + this.operator = operator || this.defaultOperator; + } + + // + // protected functions + // ------------------ + + /** + * user might want to filter certain items of the collection + * @param inputCollection + * @return outputCollection filtered and/or sorted collection + */ + protected filterCollection(inputCollection: any[]): any[] { + let outputCollection = inputCollection; + + // user might want to filter certain items of the collection + if (this.columnFilter && this.columnFilter.collectionFilterBy) { + const filterBy = this.columnFilter.collectionFilterBy; + const filterCollectionBy = this.columnFilter.collectionOptions && this.columnFilter.collectionOptions.filterResultAfterEachPass || null; + outputCollection = this.collectionService.filterCollection(outputCollection, filterBy, filterCollectionBy); + } + + return outputCollection; + } + + /** + * user might want to sort the collection in a certain way + * @param inputCollection + * @return outputCollection filtered and/or sorted collection + */ + protected sortCollection(inputCollection: any[]): any[] { + let outputCollection = inputCollection; + + // user might want to sort the collection + if (this.columnFilter && this.columnFilter.collectionSortBy) { + const sortBy = this.columnFilter.collectionSortBy; + outputCollection = this.collectionService.sortCollection(this.columnDef, outputCollection, sortBy, this.enableTranslateLabel); + } + + return outputCollection; + } + + renderDomElement(collection: any[]) { + if (!Array.isArray(collection) && this.collectionOptions && (this.collectionOptions.collectionInsideObjectProperty)) { + const collectionInsideObjectProperty = this.collectionOptions.collectionInsideObjectProperty; + collection = getDescendantProperty(collection, collectionInsideObjectProperty || ''); + } + if (!Array.isArray(collection)) { + throw new Error('The "collection" passed to the Autocomplete Filter is not a valid array.'); + } + + // assign the collection to a temp variable before filtering/sorting the collection + let newCollection = collection; + + // user might want to filter and/or sort certain items of the collection + newCollection = this.filterCollection(newCollection); + newCollection = this.sortCollection(newCollection); + + // filter input can only have 1 search term, so we will use the 1st array index if it exist + const searchTerm = (Array.isArray(this.searchTerms) && this.searchTerms.length >= 0) ? this.searchTerms[0] : ''; + + // step 1, create HTML string template + const filterTemplate = this.buildTemplateHtmlString(); + + // step 2, create the DOM Element of the filter & pre-load search term + // also subscribe to the onSelect event + this._collection = newCollection; + this.$filterElm = this.createDomElement(filterTemplate, newCollection, searchTerm); + + // 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', (e: any) => { + let value = e && e.target && e.target.value || ''; + const enableWhiteSpaceTrim = this.gridOptions.enableFilterTrimWhiteSpace || this.columnFilter.enableTrimWhiteSpace; + if (typeof value === 'string' && enableWhiteSpaceTrim) { + value = value.trim(); + } + + if (this._clearFilterTriggered) { + this.callback(e, { columnDef: this.columnDef, clearFilterTriggered: this._clearFilterTriggered, shouldTriggerQuery: this._shouldTriggerQuery }); + this.$filterElm.removeClass('filled'); + } else { + value === '' ? this.$filterElm.removeClass('filled') : this.$filterElm.addClass('filled'); + this.callback(e, { columnDef: this.columnDef, operator: this.operator, searchTerms: [value], shouldTriggerQuery: this._shouldTriggerQuery }); + } + // reset both flags for next use + this._clearFilterTriggered = false; + this._shouldTriggerQuery = true; + }); + } + + /** + * Create the HTML template as a string + */ + private buildTemplateHtmlString() { + const columnId = this.columnDef && this.columnDef.id; + let placeholder = (this.gridOptions) ? (this.gridOptions.defaultFilterPlaceholder || '') : ''; + if (this.columnFilter && this.columnFilter.placeholder) { + placeholder = this.columnFilter.placeholder; + } + return ``; + } + + /** + * From the html template string, create a DOM element + * @param filterTemplate + */ + private createDomElement(filterTemplate: string, collection: any[], searchTerm?: SearchTerm) { + this._collection = collection; + const columnId = this.columnDef && this.columnDef.id; + const $headerElm = this.grid.getHeaderRowColumn(columnId); + $($headerElm).empty(); + + // create the DOM element & add an ID and filter class + const $filterElm = $(filterTemplate) as any; + const searchTermInput = searchTerm as string; + + // user might provide his own custom structure + // jQuery UI autocomplete requires a label/value pair, so we must remap them when user provide different ones + if (Array.isArray(collection) && this.customStructure) { + collection = collection.map((item) => { + return { label: item[this.labelName], value: item[this.valueName] }; + }); + } + + // user might pass his own autocomplete options + const autoCompleteOptions: AutocompleteOption = this.columnFilter.filterOptions; + + // when user passes it's own autocomplete options + // we still need to provide our own "select" callback implementation + if (autoCompleteOptions) { + autoCompleteOptions.select = (event: Event, ui: any) => this.onSelect(event, ui); + this._autoCompleteOptions = { ...autoCompleteOptions }; + $filterElm.autocomplete(autoCompleteOptions); + } else { + const definedOptions: AutocompleteOption = { + minLength: 0, + source: collection, + select: (event: Event, ui: any) => this.onSelect(event, ui), + }; + this._autoCompleteOptions = { ...definedOptions, ...(this.columnFilter.filterOptions as AutocompleteOption) }; + $filterElm.autocomplete(this._autoCompleteOptions); + } + + $filterElm.val(searchTermInput); + $filterElm.data('columnId', columnId); + + // if there's a search term, we will add the "filled" class for styling purposes + if (searchTerm) { + $filterElm.addClass('filled'); + } + + // append the new DOM element to the header row + if ($filterElm && typeof $filterElm.appendTo === 'function') { + $filterElm.appendTo($headerElm); + } + + return $filterElm; + } + + // + // private functions + // ------------------ + + // 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.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/packages/common/src/index.ts b/packages/common/src/index.ts index 9501646b9..90c638400 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -15,6 +15,5 @@ export * from './formatters/index'; export * from './grouping-formatters/index'; export * from './sorters/index'; -import * as Enums from './enums/index'; -export { Enums } +export * as Enums from './enums/index'; export { SlickgridConfig } from './slickgrid-config';