diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example07.html b/examples/webpack-demo-vanilla-bundle/src/examples/example07.html index 2b81f7ff3..ceaf67703 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example07.html +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example07.html @@ -23,6 +23,7 @@

Disable Sorting +
+ + + +
diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts index 39b3bd2a0..7d70a9213 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example07.ts @@ -2,10 +2,11 @@ import { BindingEventService, Column, Editors, + FieldType, Filters, Formatters, - FieldType, GridOption, + OperatorType, } from '@slickgrid-universal/common'; import { ExcelExportService } from '@slickgrid-universal/excel-export'; import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle'; @@ -79,6 +80,81 @@ export class Example7 { resolve([{ value: true, label: 'True' }, { value: false, label: 'False' }]); }, 250)), }, + }, + { + id: 'prerequisites', + name: 'Prerequisites', + field: 'prerequisites', + filterable: true, + formatter: (_row, _cell, value) => { + if (value && Array.isArray(value)) { + const values = value.map((val) => `Task ${val}`).join(', '); + return `${values}`; + } + return ''; + }, + exportWithFormatter: true, + sanitizeDataExport: true, + minWidth: 100, + sortable: true, + type: FieldType.string, + editor: { + // We can load the "collection" asynchronously (on first load only, after that we will simply use "collection") + // 2 ways are supported (aurelia-http-client, aurelia-fetch-client OR even Promise) + + // OR 1- use "aurelia-fetch-client", they are both supported + // collectionAsync: fetch(URL_SAMPLE_COLLECTION_DATA), + + // OR 2- use a Promise + collectionAsync: new Promise((resolve) => { + setTimeout(() => { + resolve(Array.from(Array(this.dataset.length).keys()).map(k => ({ value: k, label: k, prefix: 'Task', suffix: 'days' }))); + }, 500); + }), + + // OR a regular "collection" load + // collection: Array.from(Array(NB_ITEMS).keys()).map(k => ({ value: k, label: k, prefix: 'Task', suffix: 'days' })), + collectionSortBy: { + property: 'value', + sortDesc: true, + fieldType: FieldType.number + }, + customStructure: { + label: 'label', + value: 'value', + labelPrefix: 'prefix', + }, + collectionOptions: { + separatorBetweenTextLabels: ' ' + }, + model: Editors.multipleSelect, + }, + filter: { + // collectionAsync: fetch(URL_SAMPLE_COLLECTION_DATA), + collectionAsync: new Promise((resolve) => { + setTimeout(() => { + resolve(Array.from(Array(this.dataset.length).keys()).map(k => ({ value: k, label: `Task ${k}` }))); + }); + }), + + // OR a regular collection load + // collection: Array.from(Array(NB_ITEMS).keys()).map(k => ({ value: k, label: k, prefix: 'Task', suffix: 'days' })), + collectionSortBy: { + property: 'value', + sortDesc: true, + fieldType: FieldType.number + }, + customStructure: { + label: 'label', + value: 'value', + labelPrefix: 'prefix', + }, + collectionOptions: { + separatorBetweenTextLabels: ' ' + }, + model: Filters.multipleSelect, + operator: OperatorType.inContains, + }, } ]; @@ -132,21 +208,73 @@ export class Example7 { }; } - loadData(rowCount: number) { + /** Add a new row to the grid and refresh the Filter collection */ + addItem() { + const lastRowIndex = this.dataset.length; + const newRows = this.loadData(1, lastRowIndex); + + // wrap into a timer to simulate a backend async call + setTimeout(() => { + // at any time, we can poke the "collection" property and modify it + const requisiteColumnDef = this.columnDefinitions.find((column: Column) => column.id === 'prerequisites'); + if (requisiteColumnDef) { + const collectionEditor = requisiteColumnDef.editor.collection; + const collectionFilter = requisiteColumnDef.filter.collection; + + if (Array.isArray(collectionEditor) && Array.isArray(collectionFilter)) { + // add the new row to the grid + this.sgb.gridService.addItem(newRows[0], { highlightRow: false }); + + // then refresh the Editor/Filter "collection", we have 2 ways of doing it + + // 1- push to the "collection" + collectionEditor.push({ value: lastRowIndex, label: lastRowIndex, prefix: 'Task', suffix: 'days' }); + collectionFilter.push({ value: lastRowIndex, label: lastRowIndex, prefix: 'Task', suffix: 'days' }); + + // OR 2- replace the entire "collection" is also supported + // requisiteColumnDef.filter.collection = [...requisiteColumnDef.filter.collection, ...[{ value: lastRowIndex, label: lastRowIndex, prefix: 'Task' }]]; + // requisiteColumnDef.editor.collection = [...requisiteColumnDef.editor.collection, ...[{ value: lastRowIndex, label: lastRowIndex, prefix: 'Task' }]]; + } + } + }, 50); + } + + /** Delete last inserted row */ + deleteItem() { + const requisiteColumnDef = this.columnDefinitions.find((column: Column) => column.id === 'prerequisites'); + if (requisiteColumnDef) { + const collectionEditor = requisiteColumnDef.editor.collection; + const collectionFilter = requisiteColumnDef.filter.collection; + + if (Array.isArray(collectionEditor) && Array.isArray(collectionFilter)) { + // sort collection in descending order and take out last option from the collection + const selectCollectionObj = this.sortCollectionDescending(collectionEditor).pop(); + this.sortCollectionDescending(collectionFilter).pop(); + this.sgb.gridService.deleteItemById(selectCollectionObj.value); + } + } + } + + loadData(itemCount: number, startingIndex = 0) { // Set up some test columns. - const mockDataset = []; - for (let i = 0; i < rowCount; i++) { - mockDataset[i] = { + const tempDataset = []; + for (let i = startingIndex; i < (startingIndex + itemCount); i++) { + tempDataset.push({ id: i, title: 'Task ' + i, duration: Math.round(Math.random() * 25), percentComplete: Math.round(Math.random() * 100), start: new Date(2009, 0, 1), finish: new Date(2009, 0, 5), - effortDriven: (i % 5 === 0) - }; + effortDriven: (i % 5 === 0), + prerequisites: (i % 2 === 0) && i !== 0 && i < 12 ? [i, i - 1] : [], + }); } - return mockDataset; + return tempDataset; + } + + sortCollectionDescending(collection) { + return collection.sort((item1, item2) => item1.value - item2.value); } onBeforeMoveRow(e, data) { diff --git a/package.json b/package.json index bd2b1e806..42216e8c9 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@types/node": "^14.14.14", "@typescript-eslint/eslint-plugin": "^4.10.0", "@typescript-eslint/parser": "^4.10.0", - "cypress": "^6.1.0", + "cypress": "^6.2.0", "eslint": "^7.15.0", "eslint-plugin-import": "^2.22.1", "eslint-plugin-prefer-arrow": "^1.2.2", @@ -75,4 +75,4 @@ "node": ">=12.0.0", "npm": ">=6.14.0" } -} \ No newline at end of file +} diff --git a/packages/common/src/filters/__tests__/autoCompleteFilter.spec.ts b/packages/common/src/filters/__tests__/autoCompleteFilter.spec.ts index 8c863280c..b2fc357a5 100644 --- a/packages/common/src/filters/__tests__/autoCompleteFilter.spec.ts +++ b/packages/common/src/filters/__tests__/autoCompleteFilter.spec.ts @@ -6,6 +6,8 @@ import { CollectionService } from '../../services/collection.service'; import { HttpStub } from '../../../../../test/httpClientStub'; import { TranslateServiceStub } from '../../../../../test/translateServiceStub'; +jest.useFakeTimers(); + const containerId = 'demo-container'; // define a
container to simulate the grid container @@ -86,14 +88,6 @@ describe('AutoCompleteFilter', () => { } }); - it('should throw an error when "collectionAsync" Promise does not return a valid array', (done) => { - mockColumn.filter!.collectionAsync = Promise.resolve({ hello: 'world' }); - filter.init(filterArguments).catch((e) => { - expect(e.toString()).toContain(`Something went wrong while trying to pull the collection from the "collectionAsync" call in the AutoComplete Filter, the collection is not a valid array.`); - done(); - }); - }); - it('should initialize the filter', () => { mockColumn.filter!.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; filter.init(filterArguments); @@ -249,55 +243,49 @@ describe('AutoCompleteFilter', () => { expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, clearFilterTriggered: true, shouldTriggerQuery: false }); }); - it('should create the filter with a default search term when using "collectionAsync" as a Promise', (done) => { + it('should create the filter with a default search term when using "collectionAsync" as a Promise', async () => { const spyCallback = jest.spyOn(filterArguments, 'callback'); const mockCollection = ['male', 'female']; mockColumn.filter!.collectionAsync = Promise.resolve(mockCollection); filterArguments.searchTerms = ['female']; - filter.init(filterArguments); + await filter.init(filterArguments); - setTimeout(() => { - const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; - const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); - filter.setValues('male'); + const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; + const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); + filter.setValues('male'); - filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); - const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); + filterElm.focus(); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); - expect(autocompleteUlElms.length).toBe(1); - expect(filterFilledElms.length).toBe(1); - expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); - done(); - }); + expect(autocompleteUlElms.length).toBe(1); + expect(filterFilledElms.length).toBe(1); + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); }); - it('should create the filter with a default search term when using "collectionAsync" as a Promise with content to simulate http-client', (done) => { + it('should create the filter with a default search term when using "collectionAsync" as a Promise with content to simulate http-client', async () => { const spyCallback = jest.spyOn(filterArguments, 'callback'); const mockCollection = ['male', 'female']; mockColumn.filter!.collectionAsync = Promise.resolve({ content: mockCollection }); filterArguments.searchTerms = ['female']; - filter.init(filterArguments); + await filter.init(filterArguments); - setTimeout(() => { - const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; - const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); - filter.setValues('male'); + const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; + const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); + filter.setValues('male'); - filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); - const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); + filterElm.focus(); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); - expect(autocompleteUlElms.length).toBe(1); - expect(filterFilledElms.length).toBe(1); - expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); - done(); - }); + expect(autocompleteUlElms.length).toBe(1); + expect(filterFilledElms.length).toBe(1); + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); }); - it('should create the filter with a default search term when using "collectionAsync" is a Fetch Promise', (done) => { + it('should create the filter with a default search term when using "collectionAsync" is a Fetch Promise', async () => { const spyCallback = jest.spyOn(filterArguments, 'callback'); const mockCollection = ['male', 'female']; @@ -309,22 +297,19 @@ describe('AutoCompleteFilter', () => { mockColumn.filter!.collectionAsync = http.fetch('/api', { method: 'GET' }); filterArguments.searchTerms = ['female']; - filter.init(filterArguments); + await filter.init(filterArguments); - setTimeout(() => { - const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; - const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); - filter.setValues('male'); + const filterElm = divContainer.querySelector('input.filter-gender') as HTMLInputElement; + const autocompleteUlElms = document.body.querySelectorAll('ul.ui-autocomplete'); + filter.setValues('male'); - filterElm.focus(); - filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); - const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); + filterElm.focus(); + filterElm.dispatchEvent(new (window.window as any).KeyboardEvent('input', { keyCode: 97, bubbles: true, cancelable: true })); + const filterFilledElms = divContainer.querySelectorAll('input.filter-gender.filled'); - expect(autocompleteUlElms.length).toBe(1); - expect(filterFilledElms.length).toBe(1); - expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); - done(); - }); + expect(autocompleteUlElms.length).toBe(1); + expect(filterFilledElms.length).toBe(1); + expect(spyCallback).toHaveBeenCalledWith(expect.anything(), { columnDef: mockColumn, operator: 'EQ', searchTerms: ['male'], shouldTriggerQuery: true }); }); it('should create the filter and filter the string collection when "collectionFilterBy" is set', () => { @@ -410,25 +395,25 @@ describe('AutoCompleteFilter', () => { expect(filterCollection[2]).toEqual({ value: 'female', description: 'female' }); }); - it('should create the filter with a value/label pair collectionAsync that is inside an object when "collectionInsideObjectProperty" is defined with a dot notation', (done) => { - const mockCollection = { deep: { myCollection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }] } }; - mockColumn.filter = { - collectionAsync: Promise.resolve(mockCollection), - collectionOptions: { collectionInsideObjectProperty: 'deep.myCollection' }, - customStructure: { value: 'value', label: 'description', }, - }; - - filter.init(filterArguments); + it('should create the filter with a value/label pair collectionAsync that is inside an object when "collectionInsideObjectProperty" is defined with a dot notation', async () => { + try { + const mockCollection = { deep: { myCollection: [{ value: 'other', description: 'other' }, { value: 'male', description: 'male' }, { value: 'female', description: 'female' }] } }; + mockColumn.filter = { + collectionAsync: Promise.resolve(mockCollection), + collectionOptions: { collectionInsideObjectProperty: 'deep.myCollection' }, + customStructure: { value: 'value', label: 'description', }, + }; - setTimeout(() => { + await filter.init(filterArguments); const filterCollection = filter.collection as any[]; expect(filterCollection.length).toBe(3); expect(filterCollection[0]).toEqual({ value: 'other', description: 'other' }); expect(filterCollection[1]).toEqual({ value: 'male', description: 'male' }); expect(filterCollection[2]).toEqual({ value: 'female', description: 'female' }); - done(); - }, 2); + } catch (e) { + console.log('ERROR', e) + } }); it('should create the filter and sort the string collection when "collectionSortBy" is set', () => { @@ -534,27 +519,44 @@ describe('AutoCompleteFilter', () => { expect(spy).toHaveBeenCalledWith(event, { item: 'fem' }); }); - it('should expect the "onSelect" method to be called when the callback method is triggered', () => { - const spy = jest.spyOn(filter, 'onSelect'); - const event = new CustomEvent('change'); + it('should trigger a re-render of the DOM element when collection is replaced by new collection', async () => { + const renderSpy = jest.spyOn(filter, 'renderDomElement'); + const newCollection = [{ value: 'val1', label: 'label1' }, { value: 'val2', label: 'label2' }]; + const mockDataResponse = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }]; - mockColumn.filter!.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; - filter.init(filterArguments); - filter.autoCompleteOptions!.select!(event, { item: 'fem' }); + mockColumn.filter = { + collection: [], + collectionAsync: Promise.resolve(mockDataResponse), + enableCollectionWatch: true, + }; - expect(spy).toHaveBeenCalledWith(event, { item: 'fem' }); + await filter.init(filterArguments); + mockColumn.filter!.collection = newCollection; + mockColumn.filter!.collection!.push({ value: 'val3', label: 'label3' }); + + jest.runAllTimers(); // fast-forward timer] + + expect(renderSpy).toHaveBeenCalledTimes(3); + expect(renderSpy).toHaveBeenCalledWith(newCollection); }); - it('should initialize the filter with filterOptions and expect the "onSelect" method to be called when the callback method is triggered', () => { - const spy = jest.spyOn(filter, 'onSelect'); - const event = new CustomEvent('change'); + it('should trigger a re-render of the DOM element when collection changes', async () => { + const renderSpy = jest.spyOn(filter, 'renderDomElement'); + const mockDataResponse = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }]; - mockColumn.filter!.collection = [{ value: 'male', label: 'male' }, { value: 'female', label: 'female' }]; - mockColumn.filter!.filterOptions = { minLength: 3 } as AutocompleteOption; - filter.init(filterArguments); - filter.autoCompleteOptions!.select!(event, { item: 'fem' }); + mockColumn.filter = { + collection: [], + collectionAsync: new Promise((resolve) => resolve(mockDataResponse)), + enableCollectionWatch: true, + }; - expect(spy).toHaveBeenCalledWith(event, { item: 'fem' }); + await filter.init(filterArguments); + mockColumn.filter!.collection!.push({ value: 'other', label: 'other' }); + + jest.runAllTimers(); // fast-forward timer + + expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledWith(mockColumn.filter!.collection); }); }); @@ -645,5 +647,14 @@ describe('AutoCompleteFilter', () => { const liElm = ulElm.querySelector('li') as HTMLLIElement; expect(liElm.innerHTML).toBe(mockTemplateString); }); + + it('should throw an error when "collectionAsync" Promise does not return a valid array', (done) => { + const promise = Promise.resolve({ hello: 'world' }); + mockColumn.filter!.collectionAsync = promise; + filter.init(filterArguments).catch((e) => { + expect(e.toString()).toContain(`Something went wrong while trying to pull the collection from the "collectionAsync" call in the AutoComplete Filter, the collection is not a valid array.`); + done(); + }); + }); }); }); diff --git a/packages/common/src/filters/__tests__/selectFilter.spec.ts b/packages/common/src/filters/__tests__/selectFilter.spec.ts index defe3bd6c..7e4fcd24e 100644 --- a/packages/common/src/filters/__tests__/selectFilter.spec.ts +++ b/packages/common/src/filters/__tests__/selectFilter.spec.ts @@ -64,7 +64,6 @@ describe('SelectFilter', () => { }); afterEach(() => { - mockColumn.filter = undefined; filter.destroy(); jest.clearAllMocks(); }); @@ -732,6 +731,62 @@ describe('SelectFilter', () => { expect(filterListElm[2].textContent).toBe('female'); }); + it('should trigger a re-render of the DOM element when collection is replaced by new collection', async () => { + const renderSpy = jest.spyOn(filter, 'renderDomElement'); + const newCollection = [{ value: 'val1', label: 'label1' }, { value: 'val2', label: 'label2' }]; + const mockDataResponse = [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }]; + + mockColumn.filter = { + collection: [], + collectionAsync: Promise.resolve(mockDataResponse), + enableCollectionWatch: true, + }; + + await filter.init(filterArguments); + mockColumn.filter!.collection = newCollection; + mockColumn.filter!.collection!.push({ value: 'val3', label: 'label3' }); + + jest.runAllTimers(); // fast-forward timer + + expect(renderSpy).toHaveBeenCalledTimes(3); + expect(renderSpy).toHaveBeenCalledWith(newCollection); + + const filterBtnElm = divContainer.querySelector('.ms-parent.ms-filter.search-filter.filter-gender button.ms-choice') as HTMLButtonElement; + const filterListElm = divContainer.querySelectorAll(`[name=filter-gender].ms-drop ul>li span`); + filterBtnElm.click(); + + expect(filterListElm.length).toBe(3); + expect(filterListElm[0].textContent).toBe('label1'); + expect(filterListElm[1].textContent).toBe('label2'); + expect(filterListElm[2].textContent).toBe('label3'); + }); + + it('should trigger a re-render of the DOM element when collection changes', async () => { + const renderSpy = jest.spyOn(filter, 'renderDomElement'); + + mockColumn.filter = { + collection: [{ value: 'female', label: 'Female' }, { value: 'male', label: 'Male' }], + enableCollectionWatch: true, + }; + + await filter.init(filterArguments); + mockColumn.filter!.collection!.push({ value: 'other', label: 'Other' }); + + jest.runAllTimers(); // fast-forward timer + + expect(renderSpy).toHaveBeenCalledTimes(2); + expect(renderSpy).toHaveBeenCalledWith(mockColumn.filter!.collection); + + const filterBtnElm = divContainer.querySelector('.ms-parent.ms-filter.search-filter.filter-gender button.ms-choice') as HTMLButtonElement; + const filterListElm = divContainer.querySelectorAll(`[name=filter-gender].ms-drop ul>li span`); + filterBtnElm.click(); + + expect(filterListElm.length).toBe(3); + expect(filterListElm[0].textContent).toBe('Female'); + expect(filterListElm[1].textContent).toBe('Male'); + expect(filterListElm[2].textContent).toBe('Other'); + }); + it('should throw an error when "collectionAsync" Promise does not return a valid array', async (done) => { const promise = Promise.resolve({ hello: 'world' }); mockColumn.filter!.collectionAsync = promise; @@ -743,4 +798,13 @@ describe('SelectFilter', () => { done(); } }); + + it('should throw an error when "collectionAsync" Promise does not return a valid array', (done) => { + const promise = Promise.resolve({ hello: 'world' }); + mockColumn.filter!.collectionAsync = promise; + filter.init(filterArguments).catch((e) => { + expect(e.toString()).toContain(`Something went wrong while trying to pull the collection from the "collectionAsync" call in the Select Filter, the collection is not a valid array.`); + done(); + }); + }); }); diff --git a/packages/common/src/filters/autoCompleteFilter.ts b/packages/common/src/filters/autoCompleteFilter.ts index 4631e273e..2f16cecaa 100644 --- a/packages/common/src/filters/autoCompleteFilter.ts +++ b/packages/common/src/filters/autoCompleteFilter.ts @@ -19,6 +19,7 @@ import { SlickGrid, } from './../interfaces/index'; import { CollectionService } from '../services/collection.service'; +import { collectionObserver, propertyObserver } from '../services/observers'; import { getDescendantProperty, sanitizeTextByAvailableSanitizer, toKebabCase } from '../services/utilities'; import { TranslaterService } from '../services/translater.service'; @@ -158,14 +159,29 @@ export class AutoCompleteFilter implements Filter { this._collection = newCollection; this.renderDomElement(newCollection); - return new Promise(resolve => { - const collectionAsync = this.columnFilter.collectionAsync; - if (collectionAsync && !this.columnFilter.collection) { - // only read the collectionAsync once (on the 1st load), - // we do this because Http Fetch will throw an error saying body was already read and is streaming is locked - resolve(this.renderOptionsAsync(collectionAsync)); - } else { - resolve(newCollection); + return new Promise(async (resolve, reject) => { + try { + const collectionAsync = this.columnFilter.collectionAsync; + let collectionOutput: Promise | any[] | undefined; + + if (collectionAsync && !this.columnFilter.collection) { + // only read the collectionAsync once (on the 1st load), + // we do this because Http Fetch will throw an error saying body was already read and is streaming is locked + collectionOutput = this.renderOptionsAsync(collectionAsync); + resolve(collectionOutput); + } else { + collectionOutput = newCollection; + resolve(newCollection); + } + + // subscribe to both CollectionObserver and PropertyObserver + // any collection changes will trigger a re-render of the DOM element filter + if (collectionAsync || this.columnFilter.enableCollectionWatch) { + await (collectionOutput ?? collectionAsync); + this.watchCollectionChanges(); + } + } catch (e) { + reject(e); } }); } @@ -244,6 +260,33 @@ export class AutoCompleteFilter implements Filter { return outputCollection; } + /** + * Subscribe to both CollectionObserver & PropertyObserver with BindingEngine. + * They each have their own purpose, the "propertyObserver" will trigger once the collection is replaced entirely + * while the "collectionObverser" will trigger on collection changes (`push`, `unshift`, `splice`, ...) + */ + protected watchCollectionChanges() { + if (this.columnFilter?.collection) { + // subscribe to the "collection" changes (array `push`, `unshift`, `splice`, ...) + collectionObserver(this.columnFilter.collection, (updatedArray) => { + this.renderDomElement(this.columnFilter.collection || updatedArray || []); + }); + + // observe for any "collection" changes (array replace) + // then simply recreate/re-render the Select (dropdown) DOM Element + propertyObserver(this.columnFilter, 'collection', (newValue) => { + this.renderDomElement(newValue || []); + + // when new assignment arrives, we need to also reassign observer to the new reference + if (this.columnFilter.collection) { + collectionObserver(this.columnFilter.collection, (updatedArray) => { + this.renderDomElement(this.columnFilter.collection || updatedArray || []); + }); + } + }); + } + } + renderDomElement(collection: any[]) { if (!Array.isArray(collection) && this.collectionOptions?.collectionInsideObjectProperty) { const collectionInsideObjectProperty = this.collectionOptions.collectionInsideObjectProperty; diff --git a/packages/common/src/filters/selectFilter.ts b/packages/common/src/filters/selectFilter.ts index af85f77b0..180cbb7c3 100644 --- a/packages/common/src/filters/selectFilter.ts +++ b/packages/common/src/filters/selectFilter.ts @@ -15,6 +15,7 @@ import { SlickGrid } from './../interfaces/index'; import { CollectionService } from '../services/collection.service'; +import { collectionObserver, propertyObserver } from '../services/observers'; import { getDescendantProperty, getTranslationPrefix, htmlEncode, sanitizeTextByAvailableSanitizer } from '../services/utilities'; import { TranslaterService } from '../services'; @@ -140,17 +141,29 @@ export class SelectFilter implements Filter { const newCollection = this.columnFilter.collection || []; this.renderDomElement(newCollection); - // return new Promise(resolve => resolve(newCollection)); - - return new Promise(async resolve => { - const collectionAsync = this.columnFilter.collectionAsync; - - if (collectionAsync && !this.columnFilter.collection) { - // only read the collectionAsync once (on the 1st load), - // we do this because Http Fetch will throw an error saying body was already read and its streaming is locked - resolve(this.renderOptionsAsync(collectionAsync)); - } else { - resolve(newCollection); + return new Promise(async (resolve, reject) => { + try { + const collectionAsync = this.columnFilter.collectionAsync; + let collectionOutput: Promise | any[] | undefined; + + if (collectionAsync && !this.columnFilter.collection) { + // only read the collectionAsync once (on the 1st load), + // we do this because Http Fetch will throw an error saying body was already read and its streaming is locked + collectionOutput = this.renderOptionsAsync(collectionAsync); + resolve(collectionOutput); + } else { + collectionOutput = newCollection; + resolve(newCollection); + } + + // subscribe to both CollectionObserver and PropertyObserver + // any collection changes will trigger a re-render of the DOM element filter + if (collectionAsync || this.columnFilter.enableCollectionWatch) { + await (collectionOutput ?? collectionAsync); + this.watchCollectionChanges(); + } + } catch (e) { + reject(e); } }); } @@ -244,6 +257,33 @@ export class SelectFilter implements Filter { return outputCollection; } + /** + * Subscribe to both CollectionObserver & PropertyObserver with BindingEngine. + * They each have their own purpose, the "propertyObserver" will trigger once the collection is replaced entirely + * while the "collectionObverser" will trigger on collection changes (`push`, `unshift`, `splice`, ...) + */ + protected watchCollectionChanges() { + if (this.columnFilter?.collection) { + // subscribe to the "collection" changes (array `push`, `unshift`, `splice`, ...) + collectionObserver(this.columnFilter.collection, (updatedArray) => { + this.renderDomElement(this.columnFilter.collection || updatedArray || []); + }); + + // observe for any "collection" changes (array replace) + // then simply recreate/re-render the Select (dropdown) DOM Element + propertyObserver(this.columnFilter, 'collection', (newValue) => { + this.renderDomElement(newValue || []); + + // when new assignment arrives, we need to also reassign observer to the new reference + if (this.columnFilter.collection) { + collectionObserver(this.columnFilter.collection, (updatedArray) => { + this.renderDomElement(this.columnFilter.collection || updatedArray || []); + }); + } + }); + } + } + renderDomElement(inputCollection: any[]) { if (!Array.isArray(inputCollection) && this.collectionOptions?.collectionInsideObjectProperty) { const collectionInsideObjectProperty = this.collectionOptions.collectionInsideObjectProperty; @@ -302,10 +342,10 @@ export class SelectFilter implements Filter { protected buildTemplateHtmlString(optionCollection: any[], searchTerms: SearchTerm[]): string { let options = ''; const columnId = this.columnDef?.id ?? ''; - const separatorBetweenLabels = this.collectionOptions && this.collectionOptions.separatorBetweenTextLabels || ''; - const isTranslateEnabled = this.gridOptions && this.gridOptions.enableTranslate; - const isRenderHtmlEnabled = this.columnFilter && this.columnFilter.enableRenderHtml || false; - const sanitizedOptions = this.gridOptions && this.gridOptions.sanitizeHtmlOptions || {}; + const separatorBetweenLabels = this.collectionOptions?.separatorBetweenTextLabels ?? ''; + const isTranslateEnabled = this.gridOptions?.enableTranslate ?? false; + const isRenderHtmlEnabled = this.columnFilter?.enableRenderHtml ?? false; + const sanitizedOptions = this.gridOptions?.sanitizeHtmlOptions ?? {}; // collection could be an Array of Strings OR Objects if (Array.isArray(optionCollection)) { diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 6dfd90b86..20d014e63 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -1,6 +1,7 @@ import 'multiple-select-modified'; import * as BackendUtilities from './services/backend-utilities'; +import * as Observers from './services/observers'; import * as ServiceUtilities from './services/utilities'; import * as SortUtilities from './sortComparers/sortUtilities'; @@ -28,6 +29,6 @@ export * from './sortComparers/sortComparers.index'; export * from './services/index'; export { Enums } from './enums/enums.index'; -const Utilities = { ...BackendUtilities, ...ServiceUtilities, ...SortUtilities }; +const Utilities = { ...BackendUtilities, ...Observers, ...ServiceUtilities, ...SortUtilities }; export { Utilities }; export { SlickgridConfig } from './slickgrid-config'; diff --git a/packages/common/src/services/__tests__/observers.spec.ts b/packages/common/src/services/__tests__/observers.spec.ts new file mode 100644 index 000000000..b76378d50 --- /dev/null +++ b/packages/common/src/services/__tests__/observers.spec.ts @@ -0,0 +1,99 @@ +import { + collectionObserver, + propertyObserver, +} from '../observers'; + +describe('Service/Observers', () => { + describe('collectionObserver method', () => { + it('should watch for array "pop" change and expect callback to be executed', (done) => { + const expectation: any[] = [{ value: true, label: 'True' }, { value: false, label: 'False' }]; + const inputArray = [{ value: true, label: 'True' }, { value: false, label: 'False' }, { value: '', label: '' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.pop(); + }); + + it('should watch for array "push" change and expect callback to be executed', (done) => { + const expectation = [{ value: true, label: 'True' }, { value: false, label: 'False' }, { value: '', label: '' }]; + const inputArray: any[] = [{ value: true, label: 'True' }, { value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.push({ value: '', label: '' }); + }); + + it('should watch for array "unshift" change and expect callback to be executed', (done) => { + const expectation = [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }]; + const inputArray: any[] = [{ value: true, label: 'True' }, { value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.unshift({ value: '', label: '' }); + }); + + it('should watch for array "unshift" change and expect callback to be executed', (done) => { + const expectation = [{ value: true, label: 'True' }, { value: false, label: 'False' }]; + const inputArray: any[] = [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.shift(); + }); + + it('should watch for array "unshift" change and expect callback to be executed', (done) => { + const expectation = [{ value: '', label: '' }, { value: false, label: 'False' }]; + const inputArray: any[] = [{ value: '', label: '' }, { value: true, label: 'True' }, { value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.splice(1, 1); + }); + + it('should watch for array "reverse" change and expect callback to be executed', (done) => { + const expectation = [{ id: 1, value: false, label: 'False' }, { id: 2, value: true, label: 'True' }]; + const inputArray: any[] = [{ id: 2, value: true, label: 'True' }, { id: 1, value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.reverse(); + }); + + it('should watch for array "sort" change and expect callback to be executed', (done) => { + const expectation = [{ id: 1, value: false, label: 'False' }, { id: 2, value: true, label: 'True' }]; + const inputArray: any[] = [{ id: 2, value: true, label: 'True' }, { id: 1, value: false, label: 'False' }]; + + collectionObserver(inputArray, (updatedArray) => { + expect(JSON.stringify(updatedArray)).toEqual(JSON.stringify(expectation)); + done(); + }); + inputArray.sort((obj1, obj2) => obj1.id - obj2.id); + }); + }); + + describe('propertyObserver method', () => { + it('should watch for an object property change and expect the callback to be executed with new value', (done) => { + const expectation = { hello: { firstName: 'John' } }; + const inputObj = { hello: { firstName: '' } }; + + propertyObserver(inputObj.hello, 'firstName', (newValue) => { + expect(newValue).toEqual('John'); + expect(inputObj).toEqual(expectation); + done(); + }); + inputObj.hello.firstName = 'John'; + }); + }); +}); diff --git a/packages/common/src/services/observers.ts b/packages/common/src/services/observers.ts new file mode 100644 index 000000000..880a4e4bc --- /dev/null +++ b/packages/common/src/services/observers.ts @@ -0,0 +1,39 @@ +/** + * Collection Observer to watch for any array changes (pop, push, reverse, shift, unshift, splice, sort) + * and execute the callback when any of the methods are called + * @param {any[]} inputArray - array you want to listen to + * @param {Function} callback function that will be called on any change inside array + */ +export function collectionObserver(inputArray: any[], callback: (outputArray: any[], newValues: any[]) => void) { + // Add more methods here if you want to listen to them + const mutationMethods = ['pop', 'push', 'reverse', 'shift', 'unshift', 'splice', 'sort']; + + mutationMethods.forEach((changeMethod) => { + inputArray[changeMethod] = (...args: any[]) => { + const res = Array.prototype[changeMethod].apply(inputArray, args); // call normal behaviour + callback.apply(inputArray, [inputArray, args]); // finally call the callback supplied + return res; + }; + }); +} + +/** + * Object Property Observer and execute the callback whenever any of the object property changes. + * @param {*} obj - input object + * @param {String} prop - object property name + * @param {Function} callback - function that will be called on any change inside array + */ +export function propertyObserver(obj: any, prop: string, callback: (newValue: any) => void) { + let innerValue = obj[prop]; + + Object.defineProperty(obj, prop, { + configurable: true, + get() { + return innerValue; + }, + set(newValue) { + innerValue = newValue; + callback.apply(obj, [newValue, obj[prop]]); + } + }); +} diff --git a/test/cypress/integration/example07.spec.js b/test/cypress/integration/example07.spec.js index cd3b69826..839212bd9 100644 --- a/test/cypress/integration/example07.spec.js +++ b/test/cypress/integration/example07.spec.js @@ -2,7 +2,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { const GRID_ROW_HEIGHT = 45; - const fullTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed']; + const fullTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites']; it('should display Example title', () => { cy.visit(`${Cypress.config('baseExampleUrl')}/example07`); @@ -135,11 +135,11 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { }); it('should dynamically add 2x new "Title" columns', () => { - const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Title', 'Title']; + const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title', 'Title']; cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 0'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('not.exist'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('not.exist'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(10)`).should('not.exist'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`) .should('contain', 'Task 0') @@ -155,12 +155,12 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { .each(($child, index) => expect($child.text()).to.eq(updatedTitles[index])); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 0'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('contain', 'Task 0'); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('contain', 'Task 0'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(10)`).should('contain', 'Task 0'); }); it('should dynamically remove 1x of the new "Title" columns', () => { - const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Title']; + const updatedTitles = ['', '', 'Title', 'Duration', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test=remove-title-column-btn]') .click(); @@ -195,11 +195,11 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { cy.get('.flatpickr-calendar:visible .flatpickr-day').contains('22').click('bottom', { force: true }); cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(6)`).should('contain', '2009-01-22'); - cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('contain', 'Task 0000'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(9)`).should('contain', 'Task 0000'); }); it('should move "Duration" column to a different position in the grid', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Title']; + const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; cy.get('.slick-header-columns') .children('.slick-header-column:nth(3)') @@ -219,7 +219,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { }); it('should be able to hide "Duration" column', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Title']; + const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="hide-duration-btn"]').click(); @@ -230,7 +230,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { }); it('should be able to click disable Filters functionality button and expect no Filters', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Title']; + const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="disable-filters-btn"]').click().click(); // even clicking twice should have same result @@ -267,7 +267,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { }); it('should be able to toggle Filters functionality', () => { - const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Title']; + const expectedTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Completed', 'Prerequisites', 'Title']; cy.get('[data-test="toggle-filtering-btn"]').click(); // hide it @@ -280,7 +280,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { .each(($child, index) => expect($child.text()).to.eq(expectedTitles[index])); cy.get('[data-test="toggle-filtering-btn"]').click(); // show it - cy.get('.slick-headerrow-columns .slick-headerrow-column').should('have.length', 8); + cy.get('.slick-headerrow-columns .slick-headerrow-column').should('have.length', 9); cy.get('.grid7') .find('.slick-header-columns') @@ -451,7 +451,7 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { it('should open Column Picker and show the "Duration" column back to visible and expect it to have kept its position after toggling filter/sorting', () => { // first 2 cols are hidden but they do count as li item - const expectedFullPickerTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Title']; + const expectedFullPickerTitles = ['', '', 'Title', '% Complete', 'Start', 'Finish', 'Duration', 'Completed', 'Prerequisites', 'Title']; cy.get('.grid7') .find('.slick-header-column') @@ -491,4 +491,70 @@ describe('Example 07 - Row Move & Checkbox Selector Selector Plugins', () => { } }); }); + + it('should click Add Item button 2x times and expect "Task 500" and "Task 501" to be created', () => { + cy.get('[data-test="add-item-btn"]').click(); + cy.wait(200); + cy.get('[data-test="add-item-btn"]').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 501'); + cy.get(`[style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(2)`).should('contain', 'Task 500'); + + cy.get('[data-test="toggle-filtering-btn"]').click(); // show it back + }); + + it('should open the "Prerequisites" Filter and expect to have Task 500 & 501 in the Filter', () => { + cy.get('div.ms-filter.filter-prerequisites') + .trigger('click'); + + cy.get('.ms-drop') + .find('span:nth(1)') + .contains('Task 501'); + + cy.get('.ms-drop') + .find('span:nth(2)') + .contains('Task 500'); + + cy.get('div.ms-filter.filter-prerequisites') + .trigger('click'); + }); + + it('should open the "Prerequisites" Editor and expect to have Task 500 & 501 in the Editor', () => { + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`) + .should('contain', '') + .click(); + + cy.get('.ms-drop') + .find('span:nth(1)') + .contains('Task 501'); + + cy.get('.ms-drop') + .find('span:nth(2)') + .contains('Task 500'); + + cy.get('[name=editor-prerequisites].ms-drop ul > li:nth(0)') + .click(); + + cy.get('.ms-ok-button') + .last() + .click({ force: true }); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(8)`).should('contain', 'Task 501'); + }); + + it('should delete the last item "Task 501" and expect it to be removed from the Filter', () => { + cy.get('[data-test="delete-item-btn"]').click(); + + cy.get(`[style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(2)`).should('contain', 'Task 500'); + + cy.get('div.ms-filter.filter-prerequisites') + .trigger('click'); + + cy.get('.ms-drop') + .find('span:nth(1)') + .contains('Task 500'); + + cy.get('div.ms-filter.filter-prerequisites') + .trigger('click'); + }); });