From 548779801d06cc9ae7e319e72d351c8a868ed79f Mon Sep 17 00:00:00 2001 From: ghiscoding Date: Sat, 8 May 2021 19:26:09 -0400 Subject: [PATCH] feat(tree): improve Tree Data speed considerably --- .../src/examples/example05.html | 55 +++++--- .../src/examples/example05.ts | 38 +++--- packages/common/package.json | 3 +- .../__tests__/treeExportFormatter.spec.ts | 2 +- .../__tests__/treeFormatter.spec.ts | 2 +- .../src/formatters/treeExportFormatter.ts | 2 +- .../common/src/formatters/treeFormatter.ts | 20 ++- .../interfaces/treeDataOption.interface.ts | 3 - .../services/__tests__/grid.service.spec.ts | 6 +- .../__tests__/treeData.service.spec.ts | 18 ++- .../src/services/__tests__/utilities.spec.ts | 8 +- .../common/src/services/filter.service.ts | 7 +- packages/common/src/services/grid.service.ts | 10 +- packages/common/src/services/sort.service.ts | 12 +- .../common/src/services/treeData.service.ts | 35 ++++- packages/common/src/services/utilities.ts | 126 ++++++++++-------- .../__tests__/slick-vanilla-grid.spec.ts | 8 +- .../components/slick-vanilla-grid-bundle.ts | 42 ++++-- yarn.lock | 5 + 19 files changed, 243 insertions(+), 159 deletions(-) diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example05.html b/examples/webpack-demo-vanilla-bundle/src/examples/example05.html index 076ba207d..8b96c550f 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example05.html +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example05.html @@ -15,28 +15,39 @@
- - - - - - +
+ + + +
+ +
+ + + + + +
diff --git a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts index ad6902717..d4a85c2f4 100644 --- a/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts +++ b/examples/webpack-demo-vanilla-bundle/src/examples/example05.ts @@ -11,7 +11,7 @@ import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bu import { ExampleGridOptions } from './example-grid-options'; import './example05.scss'; -const NB_ITEMS = 200; +const NB_ITEMS = 500; export class Example5 { columnDefinitions: Column[]; @@ -26,8 +26,8 @@ export class Example5 { const gridContainerElm = document.querySelector('.grid5'); this.sgb = new Slicker.GridBundle(gridContainerElm, this.columnDefinitions, { ...ExampleGridOptions, ...this.gridOptions }); - this.dataset = this.mockDataset(); - this.sgb.dataset = this.dataset; + this.dataset = this.loadData(NB_ITEMS); + // this.sgb.dataset = this.dataset; } dispose() { @@ -93,10 +93,12 @@ export class Example5 { enableTreeData: true, // you must enable this flag for the filtering & sorting to work as expected treeDataOptions: { columnId: 'title', - // levelPropName: 'indent', // this is optional, you can define the tree level property name that will be used for the sorting/indentation, internally it will use "__treeLevel" parentPropName: 'parentId', + // this is optional, you can define the tree level property name that will be used for the sorting/indentation, internally it will use "__treeLevel" + // levelPropName: 'indent', // you can optionally sort by a different column and/or sort direction + // this is the recommend approach, unless you are 100% that your original array is already sorted (in most cases it's not) initialSort: { columnId: 'title', direction: 'ASC' @@ -160,17 +162,17 @@ export class Example5 { console.log('flat array', this.sgb.treeDataService.dataset); } - mockDataset() { + loadData(rowCount: number) { let indent = 0; const parents = []; const data = []; // prepare the data - for (let i = 0; i < NB_ITEMS; i++) { + for (let i = 0; i < rowCount; i++) { const randomYear = 2000 + Math.floor(Math.random() * 10); const randomMonth = Math.floor(Math.random() * 11); const randomDay = Math.floor((Math.random() * 29)); - const d = (data[i] = {}); + const item = (data[i] = {}); let parentId; // for implementing filtering/sorting, don't go over indent of 2 @@ -188,15 +190,19 @@ export class Example5 { parentId = null; } - d['id'] = i; - d['parentId'] = parentId; - // d['title'] = `Task ${i} - [P]: ${parentId}`; - d['title'] = `Task ${i}`; - d['duration'] = '5 days'; - d['percentComplete'] = Math.round(Math.random() * 100); - d['start'] = new Date(randomYear, randomMonth, randomDay); - d['finish'] = new Date(randomYear, (randomMonth + 1), randomDay); - d['effortDriven'] = (i % 5 === 0); + item['id'] = i; + // item['__treeLevel'] = indent; + item['parentId'] = parentId; + // item['title'] = `Task ${i}`; + item['title'] = `Task ${i} (parentId: ${parentId})`; + item['duration'] = '5 days'; + item['percentComplete'] = Math.round(Math.random() * 100); + item['start'] = new Date(randomYear, randomMonth, randomDay); + item['finish'] = new Date(randomYear, (randomMonth + 1), randomDay); + item['effortDriven'] = (i % 5 === 0); + } + if (this.sgb) { + this.sgb.dataset = data; } return data; } diff --git a/packages/common/package.json b/packages/common/package.json index a0502741b..15b88feae 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -70,7 +70,8 @@ "jquery-ui-dist": "^1.12.1", "moment-mini": "^2.24.0", "multiple-select-modified": "^1.3.11", - "slickgrid": "^2.4.34" + "slickgrid": "^2.4.34", + "un-flatten-tree": "^2.0.12" }, "devDependencies": { "@types/dompurify": "^2.2.2", diff --git a/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts b/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts index e3880d326..b63d0e32d 100644 --- a/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts +++ b/packages/common/src/formatters/__tests__/treeExportFormatter.spec.ts @@ -32,7 +32,7 @@ describe('Tree Export Formatter', () => { it('should throw an error when oarams are mmissing', () => { expect(() => treeExportFormatter(1, 1, 'blah', {} as Column, {}, gridStub)) - .toThrowError('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + .toThrowError('[Slickgrid-Universal] You must provide valid "treeDataOptions" in your Grid Options, however it seems that we could not find any tree level info on the current item datacontext row.'); }); it('should return empty string when DataView is not correctly formed', () => { diff --git a/packages/common/src/formatters/__tests__/treeFormatter.spec.ts b/packages/common/src/formatters/__tests__/treeFormatter.spec.ts index 82c7e35b0..79c10bc43 100644 --- a/packages/common/src/formatters/__tests__/treeFormatter.spec.ts +++ b/packages/common/src/formatters/__tests__/treeFormatter.spec.ts @@ -32,7 +32,7 @@ describe('Tree Formatter', () => { it('should throw an error when oarams are mmissing', () => { expect(() => treeFormatter(1, 1, 'blah', {} as Column, {}, gridStub)) - .toThrowError('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + .toThrowError('[Slickgrid-Universal] You must provide valid "treeDataOptions" in your Grid Options, however it seems that we could not find any tree level info on the current item datacontext row.'); }); it('should return empty string when DataView is not correctly formed', () => { diff --git a/packages/common/src/formatters/treeExportFormatter.ts b/packages/common/src/formatters/treeExportFormatter.ts index f61a35164..6c622dfc7 100644 --- a/packages/common/src/formatters/treeExportFormatter.ts +++ b/packages/common/src/formatters/treeExportFormatter.ts @@ -25,7 +25,7 @@ export const treeExportFormatter: Formatter = (_row, _cell, value, columnDef, da } if (!dataContext.hasOwnProperty(treeLevelPropName)) { - throw new Error('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + throw new Error('[Slickgrid-Universal] You must provide valid "treeDataOptions" in your Grid Options, however it seems that we could not find any tree level info on the current item datacontext row.'); } if (dataView?.getItemByIdx) { diff --git a/packages/common/src/formatters/treeFormatter.ts b/packages/common/src/formatters/treeFormatter.ts index c0eb46341..227cbc93b 100644 --- a/packages/common/src/formatters/treeFormatter.ts +++ b/packages/common/src/formatters/treeFormatter.ts @@ -1,10 +1,10 @@ import { SlickDataView, Formatter } from './../interfaces/index'; -import { getDescendantProperty, htmlEncode } from '../services/utilities'; +import { getDescendantProperty, sanitizeTextByAvailableSanitizer } from '../services/utilities'; /** Formatter that must be use with a Tree Data column */ export const treeFormatter: Formatter = (_row, _cell, value, columnDef, dataContext, grid) => { - const dataView = grid?.getData(); - const gridOptions = grid?.getOptions(); + const dataView = grid.getData(); + const gridOptions = grid.getOptions(); const treeDataOptions = gridOptions?.treeDataOptions; const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; const indentMarginLeft = treeDataOptions?.indentMarginLeft ?? 15; @@ -23,14 +23,12 @@ export const treeFormatter: Formatter = (_row, _cell, value, columnDef, dataCont } if (!dataContext.hasOwnProperty(treeLevelPropName)) { - throw new Error('You must provide valid "treeDataOptions" in your Grid Options and it seems that there are no tree level found in this row'); + throw new Error('[Slickgrid-Universal] You must provide valid "treeDataOptions" in your Grid Options, however it seems that we could not find any tree level info on the current item datacontext row.'); } if (dataView?.getItemByIdx) { - if (typeof outputValue === 'string') { - outputValue = htmlEncode(outputValue); - } - const identifierPropName = dataView.getIdPropertyName() || 'id'; + const sanitizedOutputValue = sanitizeTextByAvailableSanitizer(gridOptions, outputValue); + const identifierPropName = dataView.getIdPropertyName() ?? 'id'; const treeLevel = dataContext[treeLevelPropName] || 0; const spacer = ``; const idx = dataView.getIdxById(dataContext[identifierPropName]); @@ -38,12 +36,12 @@ export const treeFormatter: Formatter = (_row, _cell, value, columnDef, dataCont if (nextItemRow?.[treeLevelPropName] > treeLevel) { if (dataContext.__collapsed) { - return `${spacer} ${outputValue}`; + return `${spacer} ${sanitizedOutputValue}`; } else { - return `${spacer} ${outputValue}`; + return `${spacer} ${sanitizedOutputValue}`; } } - return `${spacer} ${outputValue}`; + return `${spacer} ${sanitizedOutputValue}`; } return ''; }; diff --git a/packages/common/src/interfaces/treeDataOption.interface.ts b/packages/common/src/interfaces/treeDataOption.interface.ts index 58177f120..b82d25fd6 100644 --- a/packages/common/src/interfaces/treeDataOption.interface.ts +++ b/packages/common/src/interfaces/treeDataOption.interface.ts @@ -32,9 +32,6 @@ export interface TreeDataOption { /** Defaults to "__treeLevel", object property name used to designate the Tree Level depth number */ levelPropName?: string; - /** Defaults to "__hasChildren", object property name used to designate if the item has Children flag */ - hasChildrenFlagPropName?: string; - /** * Defaults to 15px, margin to add from the left (calculated by the tree level multiplied by this number). * For example if tree depth level is 2, the calculation will be (2 * 15 = 30), so the column will be displayed 30px from the left diff --git a/packages/common/src/services/__tests__/grid.service.spec.ts b/packages/common/src/services/__tests__/grid.service.spec.ts index ef241e41e..8f89eb1d4 100644 --- a/packages/common/src/services/__tests__/grid.service.spec.ts +++ b/packages/common/src/services/__tests__/grid.service.spec.ts @@ -86,7 +86,7 @@ const paginationServiceStub = { } as unknown as PaginationService; const treeDataServiceStub = { - convertFlatDatasetConvertToHierarhicalView: jest.fn(), + convertFlatToHierarchicalDataset: jest.fn(), init: jest.fn(), convertToHierarchicalDatasetAndSort: jest.fn(), dispose: jest.fn(), @@ -811,7 +811,7 @@ describe('Grid Service', () => { jest.spyOn(dataviewStub, 'getItems').mockReturnValue(mockFlatDataset); jest.spyOn(dataviewStub, 'getRowById').mockReturnValue(0); - jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); jest.spyOn(gridStub, 'getOptions').mockReturnValue({ enableAutoResize: true, enableRowSelection: true, enableTreeData: true } as GridOption); jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); const setItemSpy = jest.spyOn(dataviewStub, 'setItems'); @@ -836,7 +836,7 @@ describe('Grid Service', () => { jest.spyOn(dataviewStub, 'getItems').mockReturnValue(mockFlatDataset); jest.spyOn(dataviewStub, 'getRowById').mockReturnValue(0); - jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); jest.spyOn(gridStub, 'getOptions').mockReturnValue({ enableAutoResize: true, enableRowSelection: true, enableTreeData: true } as GridOption); jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); const setItemSpy = jest.spyOn(dataviewStub, 'setItems'); diff --git a/packages/common/src/services/__tests__/treeData.service.spec.ts b/packages/common/src/services/__tests__/treeData.service.spec.ts index 35d33a224..c4e6f16f6 100644 --- a/packages/common/src/services/__tests__/treeData.service.spec.ts +++ b/packages/common/src/services/__tests__/treeData.service.spec.ts @@ -87,6 +87,16 @@ describe('TreeData Service', () => { } }); + it('should throw an error when used without filter grid option', (done) => { + try { + gridOptionsMock.multiColumnSort = true; + service.init(gridStub); + } catch (e) { + expect(e.toString()).toContain('[Slickgrid-Universal] It looks like you are trying to use Tree Data without using the filtering option'); + done(); + } + }); + it('should throw an error when enableTreeData is enabled with Pagination since that is not supported', (done) => { try { gridOptionsMock.enablePagination = true; @@ -293,7 +303,7 @@ describe('TreeData Service', () => { }]; const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); - jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); service.init(gridStub); const result = service.convertToHierarchicalDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); @@ -303,7 +313,7 @@ describe('TreeData Service', () => { sortAsc: true, sortCol: mockColumns[0] }]); - expect(result).toEqual({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); }); it('should sort by the Tree column by the "initialSort" provided', () => { @@ -318,7 +328,7 @@ describe('TreeData Service', () => { }]; const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); - jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); service.init(gridStub); const result = service.convertToHierarchicalDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); @@ -328,7 +338,7 @@ describe('TreeData Service', () => { sortAsc: false, sortCol: mockColumns[1] }]); - expect(result).toEqual({ flat: mockFlatDataset, hierarchical: mockHierarchical }); + expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); }); }); diff --git a/packages/common/src/services/__tests__/utilities.spec.ts b/packages/common/src/services/__tests__/utilities.spec.ts index 967c94640..fa1bdd22f 100644 --- a/packages/common/src/services/__tests__/utilities.spec.ts +++ b/packages/common/src/services/__tests__/utilities.spec.ts @@ -2,7 +2,7 @@ import 'jest-extended'; import { of } from 'rxjs'; import { FieldType, OperatorType } from '../../enums/index'; -import { GridOption, Subscription } from '../../interfaces/index'; +import { EventSubscription, GridOption } from '../../interfaces/index'; import { RxJsResourceStub } from '../../../../../test/rxjsResourceStub'; import { addToArrayWhenNotExists, @@ -86,6 +86,10 @@ describe('Service/Utilies', () => { it('should return the a simple string with x spaces only where x is the number of spaces provided as argument', () => { expect(addWhiteSpaces(5)).toBe(' '); }); + + it('should return the a simple html string with x   separator where x is the number of spaces provided as argument', () => { + expect(addWhiteSpaces(2)).toBe('  '); + }); }); describe('arrayRemoveItemByIndex method', () => { @@ -1488,7 +1492,7 @@ describe('Service/Utilies', () => { }); it('should be able to unsubscribe all Observables', () => { - const subscriptions: Subscription[] = []; + const subscriptions: EventSubscription[] = []; const observable1 = of([1, 2]); const observable2 = of([1, 2]); subscriptions.push(observable1.subscribe(), observable2.subscribe()); diff --git a/packages/common/src/services/filter.service.ts b/packages/common/src/services/filter.service.ts index 4232fef07..00ccc27f7 100644 --- a/packages/common/src/services/filter.service.ts +++ b/packages/common/src/services/filter.service.ts @@ -691,11 +691,12 @@ export class FilterService { /** * when we have a Filter Presets on a Tree Data View grid, we need to call the pre-filtering of tree data * we need to do this because Tree Data is the only type of grid that requires a pre-filter (preFilterTreeData) to be executed before the final filtering - * @param filters + * @param {Array} [items] - optional flat array of parent/child items to use while redoing the full sort & refresh */ - refreshTreeDataFilters() { + refreshTreeDataFilters(items?: any[]) { + const inputItems = items ?? this._dataView.getItems(); if (this._dataView && this._gridOptions?.enableTreeData) { - this._tmpPreFilteredData = this.preFilterTreeData(this._dataView.getItems(), this._columnFilters); + this._tmpPreFilteredData = this.preFilterTreeData(inputItems, this._columnFilters); this._dataView.refresh(); // and finally this refresh() is what triggers a DataView filtering check } } diff --git a/packages/common/src/services/grid.service.ts b/packages/common/src/services/grid.service.ts index b0c9fb235..293fad3d8 100644 --- a/packages/common/src/services/grid.service.ts +++ b/packages/common/src/services/grid.service.ts @@ -870,16 +870,18 @@ export class GridService { } /** - * When dealing with hierarchical dataset, we can invalidate all the rows and force a resort & re-render of the hierarchical dataset. + * When dealing with hierarchical (tree) dataset, we can invalidate all the rows and force a full resort & re-render of the hierarchical tree dataset. * This method will automatically be called anytime user called `addItem()` or `addItems()`. * However please note that it won't be called when `updateItem`, if the data that gets updated does change the tree data column then you should call this method. + * @param {Array} [items] - optional flat array of parent/child items to use while redoing the full sort & refresh */ - invalidateHierarchicalDataset() { + invalidateHierarchicalDataset(items?: any[]) { // if we add/remove item(s) from the dataset, we need to also refresh our tree data filters if (this._gridOptions?.enableTreeData && this.treeDataService) { - const sortedDatasetResult = this.treeDataService.convertToHierarchicalDatasetAndSort(this._dataView.getItems(), this.sharedService.allColumns, this._gridOptions); + const inputItems = items ?? this._dataView.getItems(); + const sortedDatasetResult = this.treeDataService.convertToHierarchicalDatasetAndSort(inputItems, this.sharedService.allColumns, this._gridOptions); this.sharedService.hierarchicalDataset = sortedDatasetResult.hierarchical; - this.filterService.refreshTreeDataFilters(); + this.filterService.refreshTreeDataFilters(items); this._dataView.setItems(sortedDatasetResult.flat); } } diff --git a/packages/common/src/services/sort.service.ts b/packages/common/src/services/sort.service.ts index 80edfd897..8109e61dd 100644 --- a/packages/common/src/services/sort.service.ts +++ b/packages/common/src/services/sort.service.ts @@ -375,7 +375,7 @@ export class SortService { onLocalSortChanged(grid: SlickGrid, sortColumns: Array, forceReSort = false, emitSortChanged = false) { const isTreeDataEnabled = this._gridOptions?.enableTreeData ?? false; const dataView = grid?.getData && grid.getData() as SlickDataView; - + console.time('sort changed'); if (grid && dataView) { if (forceReSort && !isTreeDataEnabled) { dataView.reSort(); @@ -390,7 +390,7 @@ export class SortService { } grid.invalidate(); - + console.timeEnd('sort changed'); if (emitSortChanged) { this.emitSortChanged(EmitterType.local, sortColumns.map(col => { return { @@ -403,12 +403,18 @@ export class SortService { } /** Takes a hierarchical dataset and sort it recursively, */ - sortHierarchicalDataset(hierarchicalDataset: any[], sortColumns: Array): { hierarchical: any[]; flat: any[]; } { + sortHierarchicalDataset(hierarchicalDataset: T[], sortColumns: Array) { + console.time('sort tree array'); this.sortTreeData(hierarchicalDataset, sortColumns); + console.timeEnd('sort tree array'); + const dataViewIdIdentifier = this._gridOptions?.datasetIdPropertyName ?? 'id'; const treeDataOpt: TreeDataOption = this._gridOptions?.treeDataOptions ?? { columnId: '' }; const treeDataOptions = { ...treeDataOpt, identifierPropName: treeDataOpt.identifierPropName ?? dataViewIdIdentifier }; + + console.time('reconvert to flat parent/child'); const sortedFlatArray = convertHierarchicalViewToParentChildArray(hierarchicalDataset, treeDataOptions); + console.timeEnd('reconvert to flat parent/child'); return { hierarchical: hierarchicalDataset, flat: sortedFlatArray }; } diff --git a/packages/common/src/services/treeData.service.ts b/packages/common/src/services/treeData.service.ts index f5392a364..682569c66 100644 --- a/packages/common/src/services/treeData.service.ts +++ b/packages/common/src/services/treeData.service.ts @@ -52,6 +52,10 @@ export class TreeDataService { throw new Error('[Slickgrid-Universal] It looks like you are trying to use Tree Data with multi-column sorting, unfortunately it is not supported because of its complexity, you can disable it via "multiColumnSort: false" grid option and/or help in providing support for this feature.'); } + if (!this.gridOptions?.enableFiltering) { + throw new Error('[Slickgrid-Universal] It looks like you are trying to use Tree Data without using the filtering option, unfortunately that is not possible with Tree Data since it relies heavily on the filters to expand/collapse the tree. You need to enable it via "enableFiltering: true"'); + } + if (this.gridOptions?.backendServiceApi || this.gridOptions?.enablePagination) { throw new Error('[Slickgrid-Universal] It looks like you are trying to use Tree Data with Pagination and/or a Backend Service (OData, GraphQL) but unfortunately that is simply not supported because of its complexity.'); } @@ -80,10 +84,15 @@ export class TreeDataService { }; } - /** Takes a flat dataset, converts it into a hierarchical dataset, sort it by recursion and finally return back the final and sorted flat array */ - convertToHierarchicalDatasetAndSort(flatDataset: any[], columnDefinitions: Column[], gridOptions: GridOption): { hierarchical: any[]; flat: any[]; } { + /** + * Takes a flat dataset, converts it into a hierarchical dataset, sort it by recursion and finally return back the final and sorted flat array + * @param {Array} flatDataset - parent/child flat dataset + * @param {Object} gridOptions - grid options + * @returns {Array} - tree dataset + */ + convertToHierarchicalDatasetAndSort(flatDataset: P[], columnDefinitions: Column[], gridOptions: GridOption) { // 1- convert the flat array into a hierarchical array - const datasetHierarchical = this.convertFlatDatasetConvertToHierarhicalView(flatDataset, gridOptions); + const datasetHierarchical = this.convertFlatToHierarchicalDataset(flatDataset, gridOptions); // 2- sort the hierarchical array recursively by an optional "initialSort" OR if nothing is provided we'll sort by the column defined as the Tree column // also note that multi-column is not currently supported with Tree Data @@ -96,7 +105,13 @@ export class TreeDataService { return datasetSortResult; } - convertFlatDatasetConvertToHierarhicalView(flatDataset: any[], gridOptions: GridOption): any[] { + /** + * Takes a flat dataset, converts it into a hierarchical dataset + * @param {Array} flatDataset - parent/child flat dataset + * @param {Object} gridOptions - grid options + * @returns {Array} - tree dataset + */ + convertFlatToHierarchicalDataset(flatDataset: P[], gridOptions: GridOption): T[] { const dataViewIdIdentifier = gridOptions?.datasetIdPropertyName ?? 'id'; const treeDataOpt: TreeDataOption = gridOptions?.treeDataOptions ?? { columnId: 'id' }; const treeDataOptions = { ...treeDataOpt, identifierPropName: treeDataOpt.identifierPropName ?? dataViewIdIdentifier }; @@ -125,9 +140,15 @@ export class TreeDataService { } } - sortHierarchicalDataset(hierarchicalDataset: any[]): { hierarchical: any[]; flat: any[]; } { - const columnSort = this.getInitialSort(this.sharedService.allColumns, this.gridOptions); - return this.sortService.sortHierarchicalDataset(hierarchicalDataset, [columnSort]); + /** + * Takes a hierarchical (tree) input array and sort it (if an `initialSort` exist, it will use that to sort) + * @param {Array} hierarchicalDataset - inpu + * @returns {Object} sort result object that includes both the flat & tree data arrays + */ + sortHierarchicalDataset(hierarchicalDataset: T[], inputColumnSorts?: ColumnSort | ColumnSort[]) { + const columnSorts = inputColumnSorts ?? this.getInitialSort(this.sharedService.allColumns, this.gridOptions); + const finalColumnSorts = Array.isArray(columnSorts) ? columnSorts : [columnSorts]; + return this.sortService.sortHierarchicalDataset(hierarchicalDataset, finalColumnSorts); } toggleTreeDataCollapse(collapsing: boolean) { diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index 70d894dc1..c10e2a5d0 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -1,3 +1,4 @@ +import { flatten } from 'un-flatten-tree'; import * as DOMPurify_ from 'dompurify'; import * as moment_ from 'moment-mini'; const DOMPurify = DOMPurify_; // patch to fix rollup to work @@ -29,13 +30,14 @@ export function addToArrayWhenNotExists(inputArray: T[], inputItem: T, /** * Simple function to which will loop and create as demanded the number of white spaces, * this is used in the CSV export - * @param int nbSpaces: number of white spaces to create + * @param {Number} nbSpaces - number of white spaces to create + * @param {String} spaceChar - optionally provide character to use as a space (could be override to use   in html) */ -export function addWhiteSpaces(nbSpaces: number): string { +export function addWhiteSpaces(nbSpaces: number, spaceChar = ' '): string { let result = ''; for (let i = 0; i < nbSpaces; i++) { - result += ' '; + result += spaceChar; } return result; } @@ -77,15 +79,13 @@ export function castObservableToPromise(rxjs: RxJsFacade, input: Promise | * @param options you can provide the following options:: "parentPropName" (defaults to "parent"), "childrenPropName" (defaults to "children") and "identifierPropName" (defaults to "id") * @return roots - hierarchical data view array */ -export function convertParentChildArrayToHierarchicalView(flatArray: T[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; levelPropName?: string; }): T[] { +export function convertParentChildArrayToHierarchicalView(flatArray: P[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; levelPropName?: string; }): T[] { const childrenPropName = options?.childrenPropName ?? 'children'; const parentPropName = options?.parentPropName ?? '__parentId'; const identifierPropName = options?.identifierPropName ?? 'id'; - const hasChildrenFlagPropName = '__hasChildren'; const treeLevelPropName = options?.levelPropName ?? '__treeLevel'; - const inputArray: T[] = deepCopy(flatArray || []); - - const roots: T[] = []; // things without parent + const inputArray: P[] = flatArray || []; + const roots: T[] = []; // items without parent which at the root // make them accessible by guid on this map const all: any = {}; @@ -96,80 +96,75 @@ export function convertParentChildArrayToHierarchicalView(flatArray: T[ Object.keys(all).forEach((id) => { const item = all[id]; if (!(parentPropName in item) || item[parentPropName] === null || item[parentPropName] === undefined || item[parentPropName] === '') { - delete item[parentPropName]; roots.push(item); } else if (item[parentPropName] in all) { const p = all[item[parentPropName]]; if (!(childrenPropName in p)) { p[childrenPropName] = []; } - delete item[parentPropName]; p[childrenPropName].push(item); } - - // delete any unnecessary properties that were possibly created in the flat array but shouldn't be part of the tree data - delete item[treeLevelPropName]; - delete item[hasChildrenFlagPropName]; }); - return roots; -} - -/** - * Convert a hierarchical array (with children) into a flat array structure array (where the children are pushed as next indexed item in the array) - * @param hierarchicalArray - input hierarchical array - * @param options - you can provide "childrenPropName" (defaults to "children") - * @return output - Parent/Child array - */ -export function convertHierarchicalViewToParentChildArray(hierarchicalArray: T[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; }): T[] { - const outputArray: T[] = []; - convertHierarchicalViewToParentChildArrayByReference($.extend(true, [], hierarchicalArray), outputArray, options, 0); + // we need and want to the Tree Level, + // we can do that after the tree is created and mutate the array by adding a __treeLevel property on each item + // perhaps there might be a way to add this while creating the tree for now that is the easiest way I found + addTreeLevelByMutation(roots, { childrenPropName, treeLevelPropName }, 0); - // the output array is the one passed as reference - return outputArray; + return roots; } -/** - * Convert a hierarchical array (with children) into a flat array structure array but using the array as the output (the array is the pointer reference) - * @param hierarchicalArray - input hierarchical array - * @param outputArrayRef - output array passed (and modified) by reference - * @param options - you can provide "childrenPropName" (defaults to "children") - * @param treeLevel - tree level number - * @param parentId - parent ID - */ -export function convertHierarchicalViewToParentChildArrayByReference(hierarchicalArray: T[], outputArrayRef: T[], options?: { childrenPropName?: string; parentPropName?: string; hasChildrenFlagPropName?: string; levelPropName?: string; identifierPropName?: string; }, treeLevel = 0, parentId?: string) { - const childrenPropName = options?.childrenPropName ?? 'children'; - const identifierPropName = options?.identifierPropName ?? 'id'; - const hasChildrenFlagPropName = options?.hasChildrenFlagPropName ?? '__hasChildren'; - const treeLevelPropName = options?.levelPropName ?? '__treeLevel'; - const parentPropName = options?.parentPropName ?? '__parentId'; +export function addTreeLevelByMutation(treeArray: T[], options: { childrenPropName: string; treeLevelPropName: string; }, treeLevel = 0) { + const childrenPropName = (options?.childrenPropName ?? 'children') as keyof T; - if (Array.isArray(hierarchicalArray)) { - for (const item of hierarchicalArray) { + if (Array.isArray(treeArray)) { + for (const item of treeArray) { if (item) { - const itemExist = outputArrayRef.some((itm: T) => (itm as any)[identifierPropName] === (item as any)[identifierPropName]); - if (!itemExist) { - (item as any)[treeLevelPropName] = treeLevel; // save tree level ref - (item as any)[parentPropName] = parentId ?? null; - outputArrayRef.push(item); - } - if (Array.isArray((item as any)[childrenPropName])) { + if (Array.isArray(item[childrenPropName]) && (item[childrenPropName] as unknown as Array).length > 0) { treeLevel++; - convertHierarchicalViewToParentChildArrayByReference((item as any)[childrenPropName], outputArrayRef, options, treeLevel, (item as any)[identifierPropName]); + addTreeLevelByMutation(item[childrenPropName] as unknown as Array, options, treeLevel); treeLevel--; - (item as any)[hasChildrenFlagPropName] = true; - delete (item as any)[childrenPropName]; // remove the children property } + (item as any)[options.treeLevelPropName] = treeLevel; } } } } +/** + * Convert a hierarchical array (with children) into a flat array structure array (where the children are pushed as next indexed item in the array) + * @param {Array} hierarchicalArray - input hierarchical array + * @param {Object} options - you can provide "childrenPropName" (defaults to "children") + * @return {Array} output - Parent/Child array + */ +export function convertHierarchicalViewToParentChildArray(hierarchicalArray: T[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; }) { + const childrenPropName = (options?.childrenPropName ?? 'children') as keyof T; + const identifierPropName = (options?.identifierPropName ?? 'id') as keyof T; + const parentPropName = (options?.parentPropName ?? '__parentId') as keyof T; + type FlatParentChildArray = Omit; + + console.time('flatten the tree'); + const flat = flatten( + hierarchicalArray, + (node: any) => node[childrenPropName], + (node: T, parentNode?: T) => { + return { + [identifierPropName]: node[identifierPropName], + [parentPropName]: parentNode !== undefined ? parentNode[identifierPropName] : null, + ...objectWithoutKey(node, childrenPropName as keyof T) // reuse the entire object except the children array property + } as unknown as FlatParentChildArray; + } + ); + + console.timeEnd('flatten the tree'); + return flat; +} + /** * Create an immutable clone of an array or object * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com - * @param {Array|Object} objectOrArray The array or object to copy - * @return {Array|Object} The clone of the array or object + * @param {Array|Object} objectOrArray - the array or object to copy + * @return {Array|Object} - the clone of the array or object */ export function deepCopy(objectOrArray: any | any[]): any | any[] { /** @@ -194,9 +189,7 @@ export function deepCopy(objectOrArray: any | any[]): any | any[] { * Create an immutable copy of an array * @return {Array} */ - const cloneArr = () => { - return objectOrArray.map((item: any) => deepCopy(item)); - }; + const cloneArr = () => objectOrArray.map((item: any) => deepCopy(item)); // -- init --// // Get object type @@ -812,6 +805,21 @@ export function mapOperatorByFieldType(fieldType: typeof FieldType[keyof typeof return map; } +/** + * Takes an object and allow to provide a property key to omit from the original object + * @param {Object} obj - input object + * @param {String} omitKey - object property key to omit + * @returns {String} original object without the property that user wants to omit + */ +export function objectWithoutKey(obj: T, omitKey: keyof T): T { + return Object.keys(obj).reduce((result, objKey) => { + if (objKey !== omitKey) { + (result as T)[objKey as keyof T] = obj[objKey as keyof T]; + } + return result; + }, {}) as unknown as T; +} + /** Parse any input (bool, number, string) and return a boolean or False when not possible */ export function parseBoolean(input: any): boolean { return /(true|1)/i.test(input + ''); diff --git a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts index 4f47dc7b9..198954074 100644 --- a/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts +++ b/packages/vanilla-bundle/src/components/__tests__/slick-vanilla-grid.spec.ts @@ -179,7 +179,7 @@ const sortServiceStub = { const treeDataServiceStub = { init: jest.fn(), - convertFlatDatasetConvertToHierarhicalView: jest.fn(), + convertFlatToHierarchicalDataset: jest.fn(), convertToHierarchicalDatasetAndSort: jest.fn(), dispose: jest.fn(), handleOnCellClick: jest.fn(), @@ -2033,7 +2033,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () const mockFlatDataset = [{ id: 0, file: 'documents' }, { id: 1, file: 'vacation.txt', parentId: 0 }]; const mockHierarchical = [{ id: 0, file: 'documents', files: [{ id: 1, file: 'vacation.txt' }] }]; const hierarchicalSpy = jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'set'); - jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ hierarchical: mockHierarchical, flat: mockFlatDataset }); + jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ hierarchical: mockHierarchical as any[], flat: mockFlatDataset as any[] }); const refreshTreeSpy = jest.spyOn(filterServiceStub, 'refreshTreeDataFilters'); component.gridOptions = { enableTreeData: true, treeDataOptions: { columnId: 'file', parentPropName: 'parentId', childrenPropName: 'files' } } as unknown as GridOption; @@ -2068,7 +2068,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () const clearFilterSpy = jest.spyOn(filterServiceStub, 'clearFilters'); const setItemsSpy = jest.spyOn(mockDataView, 'setItems'); const processSpy = jest.spyOn(sortServiceStub, 'processTreeDataInitialSort'); - const sortHierarchicalSpy = jest.spyOn(treeDataServiceStub, 'sortHierarchicalDataset').mockReturnValue({ hierarchical: mockHierarchical, flat: mockFlatDataset }); + const sortHierarchicalSpy = jest.spyOn(treeDataServiceStub, 'sortHierarchicalDataset').mockReturnValue({ hierarchical: mockHierarchical as any[], flat: mockFlatDataset as any[] }); component.dispose(); component.gridOptions = { enableTreeData: true, treeDataOptions: { columnId: 'file' } } as unknown as GridOption; @@ -2086,7 +2086,7 @@ describe('Slick-Vanilla-Grid-Bundle Component instantiated via Constructor', () const mockFlatDataset = [{ id: 0, file: 'documents' }, { id: 1, file: 'vacation.txt', parentId: 0 }]; const mockHierarchical = [{ id: 0, file: 'documents', files: [{ id: 1, file: 'vacation.txt' }] }]; const hierarchicalSpy = jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'set'); - jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ hierarchical: mockHierarchical, flat: mockFlatDataset }); + jest.spyOn(treeDataServiceStub, 'convertToHierarchicalDatasetAndSort').mockReturnValue({ hierarchical: mockHierarchical as any[], flat: mockFlatDataset as any[] }); const refreshTreeSpy = jest.spyOn(filterServiceStub, 'refreshTreeDataFilters'); component.dataset = [{ id: 0, file: 'documents' }]; diff --git a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts index 6ab282aa7..0d94d0f4c 100644 --- a/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts +++ b/packages/vanilla-bundle/src/components/slick-vanilla-grid-bundle.ts @@ -1369,25 +1369,39 @@ export class SlickVanillaGridBundle { this.universalContainerService.registerInstance('RxJsResource', this.rxjs); } - /** Takes a flat dataset with parent/child relationship */ - private sortTreeDataset(flatDataset: any[]) { + /** + * Takes a flat dataset with parent/child relationship, sort it (via its tree structure) and return the sorted flat array + * @returns {Array} sort flat parent/child dataset + */ + private sortTreeDataset(flatDatasetInput: T[]): T[] { const prevDatasetLn = this._currentDatasetLength; let sortedDatasetResult; + let flatDatasetOutput: T[] = []; - // if the hierarchical dataset was already initialized then no need to re-convert it, we can use it directly from the shared service ref - if (this._isDatasetHierarchicalInitialized && this.datasetHierarchical) { - sortedDatasetResult = this.treeDataService.sortHierarchicalDataset(this.datasetHierarchical); - } else { - // else we need to first convert the flat dataset to a hierarchical dataset and then sort - sortedDatasetResult = this.treeDataService.convertToHierarchicalDatasetAndSort(flatDataset, this._columnDefinitions, this.gridOptions); - this.sharedService.hierarchicalDataset = sortedDatasetResult.hierarchical; - } + if (Array.isArray(flatDatasetInput) && flatDatasetInput.length > 0) { + // if the hierarchical dataset was already initialized then no need to re-convert it, we can use it directly from the shared service ref + if (this._isDatasetHierarchicalInitialized && this.datasetHierarchical) { + sortedDatasetResult = this.treeDataService.sortHierarchicalDataset(this.datasetHierarchical); + flatDatasetOutput = sortedDatasetResult.flat; + } else { + if (this.gridOptions?.treeDataOptions?.initialSort) { + // else we need to first convert the flat dataset to a hierarchical dataset and then sort + sortedDatasetResult = this.treeDataService.convertToHierarchicalDatasetAndSort(flatDatasetInput, this._columnDefinitions, this.gridOptions); + this.sharedService.hierarchicalDataset = sortedDatasetResult.hierarchical; + } else { + // else we assume that the user provided an array that is already sorted (user's responsability) + // and so we can simply convert the array to a tree structure and we're done, no need to sort + this.sharedService.hierarchicalDataset = this.treeDataService.convertFlatToHierarchicalDataset(flatDatasetInput, this.gridOptions); + } + flatDatasetOutput = flatDatasetInput || []; + } - // if we add/remove item(s) from the dataset, we need to also refresh our tree data filters - if (flatDataset.length > 0 && prevDatasetLn > 0 && flatDataset.length !== prevDatasetLn) { - this.filterService.refreshTreeDataFilters(); + // if we add/remove item(s) from the dataset, we need to also refresh our tree data filters + if (flatDatasetInput.length > 0 && flatDatasetInput.length !== prevDatasetLn) { + this.filterService.refreshTreeDataFilters(flatDatasetOutput); + } } - return sortedDatasetResult.flat; + return flatDatasetOutput; } /** diff --git a/yarn.lock b/yarn.lock index 579bc021d..f8027d3e1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11520,6 +11520,11 @@ umask@^1.1.0: resolved "https://registry.yarnpkg.com/umask/-/umask-1.1.0.tgz#f29cebf01df517912bb58ff9c4e50fde8e33320d" integrity sha1-8pzr8B31F5ErtY/5xOUP3o4zMg0= +un-flatten-tree@^2.0.12: + version "2.0.12" + resolved "https://registry.yarnpkg.com/un-flatten-tree/-/un-flatten-tree-2.0.12.tgz#8d92ec454a1b7e1aead948ed029907e1cb9a9ed8" + integrity sha1-jZLsRUobfhrq2UjtApkH4cuantg= + unbox-primitive@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.0.tgz#eeacbc4affa28e9b3d36b5eaeccc50b3251b1d3f"