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';