diff --git a/src/app/examples/grid-tree-data-parent-child.component.html b/src/app/examples/grid-tree-data-parent-child.component.html index 244eaec8b..131132cc5 100644 --- a/src/app/examples/grid-tree-data-parent-child.component.html +++ b/src/app/examples/grid-tree-data-parent-child.component.html @@ -24,6 +24,17 @@

Dynamically Change Filter (% complete < 40) + +
@@ -42,6 +53,9 @@

Expand All + @@ -60,6 +74,7 @@

[columnDefinitions]="columnDefinitions" [gridOptions]="gridOptions" [dataset]="dataset" + (onGridStateChanged)="handleOnGridStateChanged($event)" (onAngularGridCreated)="angularGridReady($event)"> \ No newline at end of file diff --git a/src/app/examples/grid-tree-data-parent-child.component.ts b/src/app/examples/grid-tree-data-parent-child.component.ts index 3b195bfda..9b1927a88 100644 --- a/src/app/examples/grid-tree-data-parent-child.component.ts +++ b/src/app/examples/grid-tree-data-parent-child.component.ts @@ -6,6 +6,10 @@ import { Filters, Formatters, GridOption, + GridStateChange, + GridStateType, + TreeToggledItem, + TreeToggleStateChange, } from './../modules/angular-slickgrid'; const NB_ITEMS = 500; @@ -38,6 +42,10 @@ export class GridTreeDataParentChildComponent implements OnInit { columnDefinitions!: Column[]; dataset!: any[]; datasetHierarchical: any[] = []; + loadingClass = ''; + isLargeDataset = false; + hasNoExpandCollapseChanged = true; + treeToggleItems: TreeToggledItem[] = []; constructor() { } @@ -108,6 +116,7 @@ export class GridTreeDataParentChildComponent implements OnInit { // 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: 'treeLevel', indentMarginLeft: 15, + initiallyCollapsed: true, // 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) @@ -126,7 +135,8 @@ export class GridTreeDataParentChildComponent implements OnInit { }, multiColumnSort: false, // multi-column sorting is not supported with Tree Data, so you need to disable it presets: { - filters: [{ columnId: 'percentComplete', searchTerms: [25], operator: '>=' }] + filters: [{ columnId: 'percentComplete', searchTerms: [25], operator: '>=' }], + treeData: { toggledItems: [{ itemId: 1, isCollapsed: false }] }, }, // change header/cell row height for material design theme headerRowHeight: 45, @@ -207,6 +217,10 @@ export class GridTreeDataParentChildComponent implements OnInit { this.angularGrid.treeDataService.toggleTreeDataCollapse(true); } + collapseAllWithoutEvent() { + this.angularGrid.treeDataService.toggleTreeDataCollapse(true, false); + } + expandAll() { this.angularGrid.treeDataService.toggleTreeDataCollapse(false); } @@ -216,6 +230,16 @@ export class GridTreeDataParentChildComponent implements OnInit { this.angularGrid.filterService.updateFilters([{ columnId: 'percentComplete', operator: '<', searchTerms: [40] }]); } + hideSpinner() { + setTimeout(() => this.loadingClass = '', 200); // delay the hide spinner a bit to avoid show/hide too quickly + } + + showSpinner() { + if (this.isLargeDataset) { + this.loadingClass = 'mdi mdi-load mdi-spin-1s mdi-24px color-alt-success'; + } + } + logHierarchicalStructure() { console.log('exploded array', this.angularGrid.treeDataService.datasetHierarchical); } @@ -225,6 +249,7 @@ export class GridTreeDataParentChildComponent implements OnInit { } loadData(rowCount: number) { + this.isLargeDataset = rowCount > 5000; // we'll show a spinner when it's large, else don't show show since it should be fast enough let indent = 0; const parents = []; const data = []; @@ -237,11 +262,25 @@ export class GridTreeDataParentChildComponent implements OnInit { const item: any = (data[i] = {}); let parentId; - // for implementing filtering/sorting, don't go over indent of 2 - if (Math.random() > 0.8 && i > 0 && indent < 3) { + /* + for demo & E2E testing purposes, let's make "Task 0" empty and then "Task 1" a parent that contains at least "Task 2" and "Task 3" which the latter will also contain "Task 4" (as shown below) + also for all other rows don't go over indent tree level depth of 2 + Task 0 + Task 1 + Task 2 + Task 3 + Task 4 + ... + */ + if (i === 1 || i === 0) { + indent = 0; + parents.pop(); + } if (i === 3) { + indent = 1; + } else if (i === 2 || i === 4 || (Math.random() > 0.8 && i > 0 && indent < 3 && i - 1 !== 0 && i - 1 !== 2)) { // also make sure Task 0, 2 remains empty indent++; parents.push(i - 1); - } else if (Math.random() < 0.3 && indent > 0) { + } else if ((Math.random() < 0.3 && indent > 0)) { indent--; parents.pop(); } @@ -262,7 +301,25 @@ export class GridTreeDataParentChildComponent implements OnInit { item['effortDriven'] = (i % 5 === 0); } this.dataset = data; - return data; } + + /** Dispatched event of a Grid State Changed event */ + handleOnGridStateChanged(gridStateChange: GridStateChange) { + this.hasNoExpandCollapseChanged = false; + + if (gridStateChange.change!.type === GridStateType.treeData) { + console.log('Tree Data gridStateChange', gridStateChange.gridState!.treeData); + this.treeToggleItems = gridStateChange.gridState!.treeData!.toggledItems as TreeToggledItem[]; + } + } + + logTreeDataToggledItems() { + console.log(this.angularGrid.treeDataService.getToggledItems()); + } + + reapplyToggledItems() { + this.angularGrid.treeDataService.applyToggledItemStateChanges(this.treeToggleItems); + } + } diff --git a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts index f030e1ca9..cf7473d65 100644 --- a/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts +++ b/src/app/modules/angular-slickgrid/components/angular-slickgrid.component.ts @@ -244,7 +244,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn if (newHierarchicalDataset && this.grid && this.sortService?.processTreeDataInitialSort) { this.dataView.setItems([], this.gridOptions.datasetIdPropertyName); this.sortService.processTreeDataInitialSort(); - + this.sortTreeDataset([]); // we also need to reset/refresh the Tree Data filters because if we inserted new item(s) then it might not show up without doing this refresh // however we need 1 cpu cycle before having the DataView refreshed, so we need to wrap this check in a setTimeout setTimeout(() => { @@ -333,18 +333,18 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn // dispose the Components this.slickEmptyWarning.dispose(); - if (this._eventHandler && this._eventHandler.unsubscribeAll) { + if (this._eventHandler?.unsubscribeAll) { this._eventHandler.unsubscribeAll(); } if (this.dataView) { - if (this.dataView && this.dataView.setItems) { + if (this.dataView?.setItems) { this.dataView.setItems([]); } if (this.dataView.destroy) { this.dataView.destroy(); } } - if (this.grid && this.grid.destroy) { + if (this.grid?.destroy) { this.grid.destroy(shouldEmptyDomElementContainer); } @@ -445,7 +445,7 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn this.displayEmptyDataWarning(finalTotalCount < 1); } - if (Array.isArray(dataset) && this.grid && this.dataView && typeof this.dataView.setItems === 'function') { + if (Array.isArray(dataset) && this.grid && this.dataView?.setItems) { this.dataView.setItems(dataset, this.gridOptions.datasetIdPropertyName); if (!this.gridOptions.backendServiceApi && !this.gridOptions.enableTreeData) { this.dataView.reSort(); @@ -587,7 +587,6 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn }) ); } - if (!this.customDataView) { // bind external sorting (backend) when available or default onSort (dataView) if (gridOptions.enableSorting) { @@ -1090,8 +1089,10 @@ export class AngularSlickgridComponent implements AfterViewInit, OnDestroy, OnIn private loadFilterPresetsWhenDatasetInitialized() { if (this.gridOptions && !this.customDataView) { // if user entered some Filter "presets", we need to reflect them all in the DOM - if (this.gridOptions.presets && Array.isArray(this.gridOptions.presets.filters)) { - this.filterService.populateColumnFilterSearchTermPresets(this.gridOptions.presets.filters); + // also note that a presets of Tree Data Toggling will also call this method because Tree Data toggling does work with data filtering + // (collapsing a parent will basically use Filter for hidding (aka collapsing) away the child underneat it) + if (this.gridOptions.presets && (Array.isArray(this.gridOptions.presets.filters) || Array.isArray(this.gridOptions.presets?.treeData?.toggledItems))) { + this.filterService.populateColumnFilterSearchTermPresets(this.gridOptions.presets?.filters || []); } } } diff --git a/src/app/modules/angular-slickgrid/constants.ts b/src/app/modules/angular-slickgrid/constants.ts index a7dd98f71..392cb1a49 100644 --- a/src/app/modules/angular-slickgrid/constants.ts +++ b/src/app/modules/angular-slickgrid/constants.ts @@ -53,6 +53,14 @@ export class Constants { TEXT_X_OF_Y_SELECTED: '# of % selected', }; + static readonly treeDataProperties = { + CHILDREN_PROP: 'children', + COLLAPSED_PROP: '__collapsed', + HAS_CHILDREN_PROP: '__hasChildren', + TREE_LEVEL_PROP: '__treeLevel', + PARENT_PROP: '__parentId', + }; + // some Validation default texts static readonly VALIDATION_REQUIRED_FIELD = 'Field is required'; static readonly VALIDATION_EDITOR_VALID_NUMBER = 'Please enter a valid number'; diff --git a/src/app/modules/angular-slickgrid/extensions/headerMenuExtension.ts b/src/app/modules/angular-slickgrid/extensions/headerMenuExtension.ts index 0ba72dfbf..8dbd8ec95 100644 --- a/src/app/modules/angular-slickgrid/extensions/headerMenuExtension.ts +++ b/src/app/modules/angular-slickgrid/extensions/headerMenuExtension.ts @@ -5,7 +5,6 @@ import { TranslateService } from '@ngx-translate/core'; import { Constants } from '../constants'; import { Column, - ColumnSort, CurrentSorter, EmitterType, Extension, diff --git a/src/app/modules/angular-slickgrid/formatters/__tests__/treeExportFormatter.spec.ts b/src/app/modules/angular-slickgrid/formatters/__tests__/treeExportFormatter.spec.ts index 81e6fd88d..90027051c 100644 --- a/src/app/modules/angular-slickgrid/formatters/__tests__/treeExportFormatter.spec.ts +++ b/src/app/modules/angular-slickgrid/formatters/__tests__/treeExportFormatter.spec.ts @@ -1,16 +1,10 @@ -import { Column, GridOption } from '../../models/index'; +import { Column, GridOption, SlickGrid } from '../../models/index'; import { treeExportFormatter } from '../treeExportFormatter'; -const dataViewStub = { - getIdxById: jest.fn(), - getItemByIdx: jest.fn(), - getIdPropertyName: jest.fn(), -}; - const gridStub = { getData: jest.fn(), getOptions: jest.fn(), -}; +} as unknown as SlickGrid; describe('Tree Export Formatter', () => { let dataset: any[]; @@ -18,11 +12,13 @@ describe('Tree Export Formatter', () => { beforeEach(() => { dataset = [ - { id: 0, firstName: 'John', lastName: 'Smith', fullName: 'John Smith', email: 'john.smith@movie.com', address: { zip: 123456 }, parentId: null, indent: 0 }, - { id: 1, firstName: 'Jane', lastName: 'Doe', fullName: 'Jane Doe', email: 'jane.doe@movie.com', address: { zip: 222222 }, parentId: 0, indent: 1 }, - { id: 2, firstName: 'Bob', lastName: 'Cane', fullName: 'Bob Cane', email: 'bob.cane@movie.com', address: { zip: 333333 }, parentId: 1, indent: 2, __collapsed: true }, - { id: 3, firstName: 'Barbara', lastName: 'Cane', fullName: 'Barbara Cane', email: 'barbara.cane@movie.com', address: { zip: 444444 }, parentId: null, indent: 0, __collapsed: true }, - { id: 4, firstName: 'Anonymous', lastName: 'Doe', fullName: 'Anonymous < Doe', email: 'anonymous.doe@anom.com', address: { zip: 556666 }, parentId: null, indent: 0, __collapsed: true }, + { id: 0, firstName: 'John', lastName: 'Smith', fullName: 'John Smith', email: 'john.smith@movie.com', address: { zip: 123456 }, parentId: null, indent: 0, __collapsed: false, __hasChildren: true }, + { id: 1, firstName: 'Jane', lastName: 'Doe', fullName: 'Jane Doe', email: 'jane.doe@movie.com', address: { zip: 222222 }, parentId: 0, indent: 1, __collapsed: false, __hasChildren: true }, + { id: 2, firstName: 'Bob', lastName: 'Cane', fullName: 'Bob Cane', email: 'bob.cane@movie.com', address: { zip: 333333 }, parentId: 1, indent: 2, __collapsed: true, __hasChildren: true }, + { id: 3, firstName: 'Barbara', lastName: 'Cane', fullName: 'Barbara Cane', email: 'barbara.cane@movie.com', address: { zip: 444444 }, parentId: null, indent: 0, __hasChildren: false }, + { id: 4, firstName: 'Anonymous', lastName: 'Doe', fullName: 'Anonymous < Doe', email: 'anonymous.doe@anom.com', address: { zip: 556666 }, parentId: null, indent: 0, __collapsed: true, __hasChildren: true }, + { id: 5, firstName: 'Sponge', lastName: 'Bob', fullName: 'Sponge Bob', email: 'sponge.bob@cartoon.com', address: { zip: 888888 }, parentId: 2, indent: 3, __hasChildren: false }, + { id: 6, firstName: 'Bobby', lastName: 'Blown', fullName: 'Bobby Blown', email: 'bobby.blown@dynamite.com', address: { zip: 998877 }, parentId: 4, indent: 1, __hasChildren: false }, ]; mockGridOptions = { treeDataOptions: { levelPropName: 'indent' } @@ -32,12 +28,7 @@ describe('Tree Export Formatter', () => { it('should throw an error when oarams are mmissing', () => { expect(() => treeExportFormatter(1, 1, 'blah', {} as Column, {}, gridStub)) - .toThrowError('[Angular-Slickgrid] 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', () => { - const output = treeExportFormatter(1, 1, '', {} as Column, dataset[1], gridStub); - expect(output).toBe(''); + .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 value is null', () => { @@ -55,90 +46,55 @@ describe('Tree Export Formatter', () => { expect(output).toBe(''); }); - it('should return a span without any icon which include leading char and 4 spaces to cover width of collapsing icons', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[0]); - - const output = treeExportFormatter(1, 1, dataset[0]['firstName'], {} as Column, dataset[0], gridStub); - expect(output).toBe(`. John`); // 3x spaces for exportIndentationLeadingSpaceCount + 1x space for space after collapsing icon in final string output + it('should return a span without any toggle icon which include leading char and 4 spaces to cover width of collapsing icons', () => { + const output = treeExportFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); + expect(output).toBe(`. Barbara`); // 3x spaces for exportIndentationLeadingSpaceCount + 1x space for space after collapsing icon in final string output }); - it('should return a span without any icon and 15px indentation of a tree level 1', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeExportFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); - expect(output).toBe(`. Jane`); + it('should return a span without any toggle icon and 15px indentation of a tree level 1', () => { + const output = treeExportFormatter(1, 1, dataset[6]['firstName'], {} as Column, dataset[6], gridStub); + expect(output).toBe(`. Bobby`); }); - it('should return a span without any icon and 30px indentation of a tree level 2', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeExportFormatter(1, 1, dataset[2]['firstName'], {} as Column, dataset[2], gridStub); - expect(output).toBe(`. Bob`); + it('should return a span without any toggle icon and 45px indentation of a tree level 3', () => { + const output = treeExportFormatter(1, 1, dataset[5]['firstName'], {} as Column, dataset[5], gridStub); + expect(output).toBe(`. Sponge`); }); - it('should return a span with expanded icon and 15px indentation of a tree level 1 when current item is greater than next item', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); - + it('should return a span with expanded icon and 15px indentation of a tree level 1 when current item is a parent that is expanded', () => { const output = treeExportFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); expect(output).toBe(`. ⮟ Jane`); }); - it('should return a span with collapsed icon and 0px indentation of a tree level 0 when current item is lower than next item', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeExportFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); - expect(output).toBe(`⮞ Barbara`); + it('should return a span with collapsed icon and 0px indentation of a tree level 0 when current item is a parent that is collapsed', () => { + const output = treeExportFormatter(1, 1, dataset[4]['firstName'], {} as Column, dataset[4], gridStub); + expect(output).toBe(`⮞ Anonymous`); }); it('should execute "queryFieldNameGetterFn" callback to get field name to use when it is defined', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: (dataContext) => 'fullName' } as Column; - const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); - expect(output).toBe(`⮞ Barbara Cane`); + const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; + const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[4], gridStub); + expect(output).toBe(`⮞ Anonymous < Doe`); }); it('should execute "queryFieldNameGetterFn" callback to get field name and also apply html encoding when output value includes a character that should be encoded', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(2); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); - - const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: (dataContext) => 'fullName' } as Column; + const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[4], gridStub); expect(output).toBe(`⮞ Anonymous < Doe`); }); it('should execute "queryFieldNameGetterFn" callback to get field name, which has (.) dot notation reprensenting complex object', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const mockColumn = { id: 'zip', field: 'zip', queryFieldNameGetterFn: (dataContext) => 'address.zip' } as Column; + const mockColumn = { id: 'zip', field: 'zip', queryFieldNameGetterFn: () => 'address.zip' } as Column; const output = treeExportFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); - expect(output).toBe(`⮞ 444444`); + expect(output).toBe(`. 444444`); }); it('should return a span with expanded icon and 15px indentation of a tree level 1 with a value prefix when provided', () => { mockGridOptions.treeDataOptions!.levelPropName = 'indent'; - mockGridOptions.treeDataOptions!.titleFormatter = (_row, _cell, value, _def, dataContext) => `++${value}++`; - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); + mockGridOptions.treeDataOptions!.titleFormatter = (_row, _cell, value, _def) => `++${value}++`; const output = treeExportFormatter(1, 1, dataset[3]['firstName'], { field: 'firstName' } as Column, dataset[3], gridStub); - expect(output).toEqual(`⮞ ++Barbara++`); + expect(output).toEqual(`. ++Barbara++`); }); it('should return a span with expanded icon and expected indentation and expanded icon of a tree level 1 with a value prefix when provided', () => { @@ -149,9 +105,6 @@ describe('Tree Export Formatter', () => { } return value || ''; }; - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); const output = treeExportFormatter(1, 1, { ...dataset[1]['firstName'], indent: 1 }, { field: 'firstName' } as Column, dataset[1], gridStub); expect(output).toEqual(`. ⮟ ++Jane++`); diff --git a/src/app/modules/angular-slickgrid/formatters/__tests__/treeFormatter.spec.ts b/src/app/modules/angular-slickgrid/formatters/__tests__/treeFormatter.spec.ts index fbe34a73e..0d05371f6 100644 --- a/src/app/modules/angular-slickgrid/formatters/__tests__/treeFormatter.spec.ts +++ b/src/app/modules/angular-slickgrid/formatters/__tests__/treeFormatter.spec.ts @@ -1,29 +1,24 @@ -import { Column, GridOption } from '../../models/index'; +import { Column, GridOption, SlickGrid } from '../../models/index'; import { treeFormatter } from '../treeFormatter'; -const dataViewStub = { - getIdxById: jest.fn(), - getItemByIdx: jest.fn(), - getIdPropertyName: jest.fn(), -}; - const gridStub = { getData: jest.fn(), getOptions: jest.fn(), -}; +} as unknown as SlickGrid; describe('Tree Formatter', () => { - let dataset: any; + let dataset: any[]; let mockGridOptions: GridOption; beforeEach(() => { dataset = [ - { id: 0, firstName: 'John', lastName: 'Smith', fullName: 'John Smith', email: 'john.smith@movie.com', address: { zip: 123456 }, parentId: null, indent: 0 }, - { id: 1, firstName: 'Jane', lastName: 'Doe', fullName: 'Jane Doe', email: 'jane.doe@movie.com', address: { zip: 222222 }, parentId: 0, indent: 1 }, - { id: 2, firstName: 'Bob', lastName: 'Cane', fullName: 'Bob Cane', email: 'bob.cane@movie.com', address: { zip: 333333 }, parentId: 1, indent: 2, __collapsed: true }, - { id: 3, firstName: 'Barbara', lastName: 'Cane', fullName: 'Barbara Cane', email: 'barbara.cane@movie.com', address: { zip: 444444 }, parentId: null, indent: 0, __collapsed: true }, - { id: 4, firstName: 'Anonymous', lastName: 'Doe', fullName: 'Anonymous < Doe', email: 'anonymous.doe@anom.com', address: { zip: 556666 }, parentId: null, indent: 0, __collapsed: true }, - { id: 5, firstName: 'Sponge', lastName: 'Bob', fullName: 'Sponge Bob', email: 'sponge.bob@cartoon.com', address: { zip: 888888 }, parentId: 2, indent: 3, __collapsed: true }, + { id: 0, firstName: 'John', lastName: 'Smith', fullName: 'John Smith', email: 'john.smith@movie.com', address: { zip: 123456 }, parentId: null, indent: 0, __collapsed: false, __hasChildren: true }, + { id: 1, firstName: 'Jane', lastName: 'Doe', fullName: 'Jane Doe', email: 'jane.doe@movie.com', address: { zip: 222222 }, parentId: 0, indent: 1, __collapsed: false, __hasChildren: true }, + { id: 2, firstName: 'Bob', lastName: 'Cane', fullName: 'Bob Cane', email: 'bob.cane@movie.com', address: { zip: 333333 }, parentId: 1, indent: 2, __collapsed: true, __hasChildren: true }, + { id: 3, firstName: 'Barbara', lastName: 'Cane', fullName: 'Barbara Cane', email: 'barbara.cane@movie.com', address: { zip: 444444 }, parentId: null, indent: 0, __hasChildren: false }, + { id: 4, firstName: 'Anonymous', lastName: 'Doe', fullName: 'Anonymous < Doe', email: 'anonymous.doe@anom.com', address: { zip: 556666 }, parentId: null, indent: 0, __collapsed: true, __hasChildren: true }, + { id: 5, firstName: 'Sponge', lastName: 'Bob', fullName: 'Sponge Bob', email: 'sponge.bob@cartoon.com', address: { zip: 888888 }, parentId: 2, indent: 3, __hasChildren: false }, + { id: 6, firstName: 'Bobby', lastName: 'Blown', fullName: 'Bobby Blown', email: 'bobby.blown@dynamite.com', address: { zip: 998877 }, parentId: 4, indent: 1, __hasChildren: false }, ]; mockGridOptions = { treeDataOptions: { levelPropName: 'indent' } @@ -33,12 +28,7 @@ describe('Tree Formatter', () => { it('should throw an error when oarams are mmissing', () => { expect(() => treeFormatter(1, 1, 'blah', {} as Column, {}, gridStub)) - .toThrowError('[Angular-Slickgrid] 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', () => { - const output = treeFormatter(1, 1, '', {} as Column, dataset[1], gridStub); - expect(output).toBe(''); + .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 value is null', () => { @@ -56,47 +46,31 @@ describe('Tree Formatter', () => { expect(output).toBe(''); }); - it('should return a span without any icon and ', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[0]); - - const output = treeFormatter(1, 1, dataset[0]['firstName'], {} as Column, dataset[0], gridStub); + it('should return a span without any toggle icon when item is not a parent item', () => { + const output = treeFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-0', - text: `John` + text: `Barbara` }); }); - it('should return a span without any icon and 15px indentation of a tree level 1', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); + it('should return a span without any toggle icon and have a 15px indentation with tree level 3', () => { + const output = treeFormatter(1, 1, dataset[6]['firstName'], {} as Column, dataset[6], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-1', - text: `Jane` + text: `Bobby` }); }); - it('should return a span without any icon and 30px indentation of a tree level 2', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeFormatter(1, 1, dataset[2]['firstName'], {} as Column, dataset[2], gridStub); + it('should return a span without any toggle icon and have a 45px indentation of a tree level 3', () => { + const output = treeFormatter(1, 1, dataset[5]['firstName'], {} as Column, dataset[5], gridStub); expect(output).toEqual({ - addClasses: 'slick-tree-level-2', - text: `Bob` + addClasses: 'slick-tree-level-3', + text: `Sponge` }); }); - it('should return a span with expanded icon and 15px indentation of a tree level 1 when current item is greater than next item', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); - + it('should return a span with expanded icon and 15px indentation when item is a parent and is not collapsed', () => { const output = treeFormatter(1, 1, dataset[1]['firstName'], {} as Column, dataset[1], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-1', @@ -104,15 +78,11 @@ describe('Tree Formatter', () => { }); }); - it('should return a span with collapsed icon and 0px indentation of a tree level 0 when current item is lower than next item', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - - const output = treeFormatter(1, 1, dataset[3]['firstName'], {} as Column, dataset[3], gridStub); + it('should return a span with collapsed icon and 0px indentation of a tree level 0 when item is a parent and is collapsed', () => { + const output = treeFormatter(1, 1, dataset[4]['firstName'], {} as Column, dataset[4], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-0', - text: `Barbara` + text: `Anonymous` }); }); @@ -124,9 +94,6 @@ describe('Tree Formatter', () => { } return value || ''; }; - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); const output = treeFormatter(1, 1, { ...dataset[1]['firstName'], indent: 1 }, { field: 'firstName' } as Column, dataset[1], gridStub); expect(output).toEqual({ @@ -136,23 +103,15 @@ describe('Tree Formatter', () => { }); it('should execute "queryFieldNameGetterFn" callback to get field name to use when it is defined', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-0', - text: `Barbara Cane` + text: `Barbara Cane` }); }); it('should execute "queryFieldNameGetterFn" callback to get field name and also apply html encoding when output value includes a character that should be encoded', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(2); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[2]); - const mockColumn = { id: 'firstName', field: 'firstName', queryFieldNameGetterFn: () => 'fullName' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[4], gridStub); expect(output).toEqual({ @@ -162,15 +121,11 @@ describe('Tree Formatter', () => { }); it('should execute "queryFieldNameGetterFn" callback to get field name, which has (.) dot notation reprensenting complex object', () => { - jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); - jest.spyOn(dataViewStub, 'getIdxById').mockReturnValue(1); - jest.spyOn(dataViewStub, 'getItemByIdx').mockReturnValue(dataset[1]); - const mockColumn = { id: 'zip', field: 'zip', queryFieldNameGetterFn: () => 'address.zip' } as Column; const output = treeFormatter(1, 1, null, mockColumn as Column, dataset[3], gridStub); expect(output).toEqual({ addClasses: 'slick-tree-level-0', - text: `444444` + text: `444444` }); }); }); diff --git a/src/app/modules/angular-slickgrid/formatters/treeExportFormatter.ts b/src/app/modules/angular-slickgrid/formatters/treeExportFormatter.ts index 309e81dd5..f08ee2edd 100644 --- a/src/app/modules/angular-slickgrid/formatters/treeExportFormatter.ts +++ b/src/app/modules/angular-slickgrid/formatters/treeExportFormatter.ts @@ -1,14 +1,15 @@ +import { Constants } from '../constants'; import { Formatter } from './../models/index'; +import { addWhiteSpaces, getDescendantProperty, sanitizeHtmlToText, } from '../services/utilities'; import { parseFormatterWhenExist } from './formatterUtilities'; -import { addWhiteSpaces, getDescendantProperty, sanitizeHtmlToText } from '../services/utilities'; /** Formatter that must be use with a Tree Data column */ export const treeExportFormatter: Formatter = (row, cell, value, columnDef, dataContext, grid) => { - const dataView = grid.getData(); const gridOptions = grid.getOptions(); const treeDataOptions = gridOptions?.treeDataOptions; - const collapsedPropName = treeDataOptions?.collapsedPropName ?? '__collapsed'; - const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; + const collapsedPropName = treeDataOptions?.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; + const hasChildrenPropName = treeDataOptions?.hasChildrenPropName ?? Constants.treeDataProperties.HAS_CHILDREN_PROP; + const treeLevelPropName = treeDataOptions?.levelPropName ?? Constants.treeDataProperties.TREE_LEVEL_PROP; const indentMarginLeft = treeDataOptions?.exportIndentMarginLeft ?? 5; const exportIndentationLeadingChar = treeDataOptions?.exportIndentationLeadingChar ?? '.'; const exportIndentationLeadingSpaceCount = treeDataOptions?.exportIndentationLeadingSpaceCount ?? 3; @@ -29,34 +30,28 @@ export const treeExportFormatter: Formatter = (row, cell, value, columnDef, data } if (!dataContext.hasOwnProperty(treeLevelPropName)) { - throw new Error('[Angular-Slickgrid] 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.'); + 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) { - const identifierPropName = dataView.getIdPropertyName() ?? 'id'; - const treeLevel = dataContext?.[treeLevelPropName] ?? 0; - const idx = dataView.getIdxById(dataContext[identifierPropName]); - const nextItemRow = dataView.getItemByIdx((idx || 0) + 1); - let toggleSymbol = ''; - let indentation = 0; - - if (nextItemRow?.[treeLevelPropName] > treeLevel) { - toggleSymbol = dataContext?.[collapsedPropName] ? groupCollapsedSymbol : groupExpandedSymbol; // parent with child will have a toggle icon - indentation = treeLevel === 0 ? 0 : (indentMarginLeft * treeLevel); - } else { - indentation = (indentMarginLeft * (treeLevel === 0 ? 0 : treeLevel + 1)); - } - const indentSpacer = addWhiteSpaces(indentation); - - if (treeDataOptions?.titleFormatter) { - outputValue = parseFormatterWhenExist(treeDataOptions.titleFormatter, row, cell, columnDef, dataContext, grid); - } + const treeLevel = dataContext?.[treeLevelPropName] ?? 0; + let toggleSymbol = ''; + let indentation = 0; - const leadingChar = (treeLevel === 0 && toggleSymbol) ? '' : (treeLevel === 0 ? `${exportIndentationLeadingChar}${addWhiteSpaces(exportIndentationLeadingSpaceCount)}` : exportIndentationLeadingChar); - outputValue = `${leadingChar}${indentSpacer}${toggleSymbol} ${outputValue}`; - const sanitizedOutputValue = sanitizeHtmlToText(outputValue); // also remove any html tags that might exist + if (dataContext[hasChildrenPropName]) { + toggleSymbol = dataContext?.[collapsedPropName] ? groupCollapsedSymbol : groupExpandedSymbol; // parent with child will have a toggle icon + indentation = treeLevel === 0 ? 0 : (indentMarginLeft * treeLevel); + } else { + indentation = (indentMarginLeft * (treeLevel === 0 ? 0 : treeLevel + 1)); + } + const indentSpacer = addWhiteSpaces(indentation); - return sanitizedOutputValue; + if (treeDataOptions?.titleFormatter) { + outputValue = parseFormatterWhenExist(treeDataOptions.titleFormatter, row, cell, columnDef, dataContext, grid); } - return ''; + + const leadingChar = (treeLevel === 0 && toggleSymbol) ? '' : (treeLevel === 0 ? `${exportIndentationLeadingChar}${addWhiteSpaces(exportIndentationLeadingSpaceCount)}` : exportIndentationLeadingChar); + outputValue = `${leadingChar}${indentSpacer}${toggleSymbol} ${outputValue}`; + const sanitizedOutputValue = sanitizeHtmlToText(outputValue); // also remove any html tags that might exist + + return sanitizedOutputValue; }; diff --git a/src/app/modules/angular-slickgrid/formatters/treeFormatter.ts b/src/app/modules/angular-slickgrid/formatters/treeFormatter.ts index e3fb89b17..a701438a5 100644 --- a/src/app/modules/angular-slickgrid/formatters/treeFormatter.ts +++ b/src/app/modules/angular-slickgrid/formatters/treeFormatter.ts @@ -1,18 +1,19 @@ import * as DOMPurify_ from 'dompurify'; const DOMPurify = DOMPurify_; // patch to fix rollup to work +import { Constants } from '../constants'; import { Formatter } from './../models/index'; import { parseFormatterWhenExist } from './formatterUtilities'; import { getDescendantProperty } 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 treeDataOptions = gridOptions?.treeDataOptions; - const collapsedPropName = treeDataOptions?.collapsedPropName ?? '__collapsed'; const indentMarginLeft = treeDataOptions?.indentMarginLeft ?? 15; - const treeLevelPropName = treeDataOptions?.levelPropName ?? '__treeLevel'; + const collapsedPropName = treeDataOptions?.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; + const hasChildrenPropName = treeDataOptions?.hasChildrenPropName ?? Constants.treeDataProperties.HAS_CHILDREN_PROP; + const treeLevelPropName = treeDataOptions?.levelPropName ?? Constants.treeDataProperties.TREE_LEVEL_PROP; let outputValue = value; if (typeof columnDef.queryFieldNameGetterFn === 'function') { @@ -28,29 +29,23 @@ export const treeFormatter: Formatter = (row, cell, value, columnDef, dataContex } if (!dataContext.hasOwnProperty(treeLevelPropName)) { - throw new Error('[Angular-Slickgrid] 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.'); + 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) { - const identifierPropName = dataView.getIdPropertyName() ?? 'id'; - const treeLevel = dataContext?.[treeLevelPropName] ?? 0; - const indentSpacer = ``; - const idx = dataView.getIdxById(dataContext[identifierPropName]); - const nextItemRow = dataView.getItemByIdx((idx || 0) + 1); - const slickTreeLevelClass = `slick-tree-level-${treeLevel}`; - let toggleClass = ''; + const treeLevel = dataContext?.[treeLevelPropName] ?? 0; + const indentSpacer = ``; + const slickTreeLevelClass = `slick-tree-level-${treeLevel}`; + let toggleClass = ''; - if (nextItemRow?.[treeLevelPropName] > treeLevel) { - toggleClass = dataContext?.[collapsedPropName] ? 'collapsed' : 'expanded'; // parent with child will have a toggle icon - } + if (dataContext[hasChildrenPropName]) { + toggleClass = dataContext?.[collapsedPropName] ? 'collapsed' : 'expanded'; // parent with child will have a toggle icon + } - if (treeDataOptions?.titleFormatter) { - outputValue = parseFormatterWhenExist(treeDataOptions.titleFormatter, row, cell, columnDef, dataContext, grid); - } - const sanitizedOutputValue = DOMPurify.sanitize(outputValue, { ADD_ATTR: ['target'] }); - const spanToggleClass = `slick-group-toggle ${toggleClass}`.trim(); - const outputHtml = `${indentSpacer}${sanitizedOutputValue}`; - return { addClasses: slickTreeLevelClass, text: outputHtml }; + if (treeDataOptions?.titleFormatter) { + outputValue = parseFormatterWhenExist(treeDataOptions.titleFormatter, row, cell, columnDef, dataContext, grid); } - return ''; + const sanitizedOutputValue = DOMPurify.sanitize(outputValue, { ADD_ATTR: ['target'] }); + const spanToggleClass = `slick-group-toggle ${toggleClass}`.trim(); + const outputHtml = `${indentSpacer}${sanitizedOutputValue}`; + return { addClasses: slickTreeLevelClass, text: outputHtml }; }; diff --git a/src/app/modules/angular-slickgrid/models/gridState.interface.ts b/src/app/modules/angular-slickgrid/models/gridState.interface.ts index f52d505c8..05964aff5 100644 --- a/src/app/modules/angular-slickgrid/models/gridState.interface.ts +++ b/src/app/modules/angular-slickgrid/models/gridState.interface.ts @@ -1,4 +1,12 @@ -import { CurrentColumn, CurrentFilter, CurrentPagination, CurrentPinning, CurrentRowSelection, CurrentSorter } from './index'; +import { + CurrentColumn, + CurrentFilter, + CurrentPagination, + CurrentPinning, + CurrentRowSelection, + CurrentSorter, + TreeToggleStateChange, +} from './index'; export interface GridState { /** Columns (and their state: visibility/position) that are currently applied in the grid */ @@ -18,4 +26,7 @@ export interface GridState { /** Row Selections (by their dataContext IDs and/or grid row indexes) */ rowSelection?: CurrentRowSelection | null; + + /** Tree Data changes which include toggled items (when the change is an item toggle, this could be `null` when the change is a full collapse/expand) */ + treeData?: Partial | null; } diff --git a/src/app/modules/angular-slickgrid/models/gridStateChange.interface.ts b/src/app/modules/angular-slickgrid/models/gridStateChange.interface.ts index 9af5be8e3..ffdec3ec0 100644 --- a/src/app/modules/angular-slickgrid/models/gridStateChange.interface.ts +++ b/src/app/modules/angular-slickgrid/models/gridStateChange.interface.ts @@ -1,10 +1,10 @@ -import { CurrentColumn, CurrentFilter, CurrentPagination, CurrentPinning, CurrentRowSelection, CurrentSorter, GridState, GridStateType } from './index'; +import { CurrentColumn, CurrentFilter, CurrentPagination, CurrentPinning, CurrentRowSelection, CurrentSorter, GridState, GridStateType, TreeToggleStateChange } from './index'; export interface GridStateChange { /** Last Grid State Change that was triggered (only 1 type of change at a time) */ change?: { /** Grid State change, the values of the new change */ - newValues: CurrentColumn[] | CurrentFilter[] | CurrentSorter[] | CurrentPagination | CurrentPinning | CurrentRowSelection; + newValues: CurrentColumn[] | CurrentFilter[] | CurrentSorter[] | CurrentPagination | CurrentPinning | CurrentRowSelection | Partial; /** The Grid State Type of change that was made (filter/sorter/...) */ type: GridStateType; diff --git a/src/app/modules/angular-slickgrid/models/gridStateType.enum.ts b/src/app/modules/angular-slickgrid/models/gridStateType.enum.ts index d02576847..c7643b814 100644 --- a/src/app/modules/angular-slickgrid/models/gridStateType.enum.ts +++ b/src/app/modules/angular-slickgrid/models/gridStateType.enum.ts @@ -5,4 +5,5 @@ export enum GridStateType { pinning = 'pinning', sorter = 'sorter', rowSelection = 'rowSelection', + treeData = 'treeData', } diff --git a/src/app/modules/angular-slickgrid/models/index.ts b/src/app/modules/angular-slickgrid/models/index.ts index 5aef00e91..f31be73ba 100644 --- a/src/app/modules/angular-slickgrid/models/index.ts +++ b/src/app/modules/angular-slickgrid/models/index.ts @@ -151,3 +151,6 @@ export * from './sortDirectionString'; export * from './sorter.interface'; export * from './statistic.interface'; export * from './treeDataOption.interface'; +export * from './treeToggledItem.interface'; +export * from './treeToggleStateChange.interface'; +export * from './toggleStateChangeType'; \ No newline at end of file diff --git a/src/app/modules/angular-slickgrid/models/slickDataView.interface.ts b/src/app/modules/angular-slickgrid/models/slickDataView.interface.ts index 68d0a9268..c3c35598d 100644 --- a/src/app/modules/angular-slickgrid/models/slickDataView.interface.ts +++ b/src/app/modules/angular-slickgrid/models/slickDataView.interface.ts @@ -85,7 +85,7 @@ export interface SlickDataView { getItem: (index: number) => T; /** Get an item in the DataView by its Id */ - getItemById: (id: string | number) => T; + getItemById: (id: string | number) => T | null; /** Get an item in the DataView by its row index */ getItemByIdx(idx: number): number; diff --git a/src/app/modules/angular-slickgrid/models/toggleStateChangeType.ts b/src/app/modules/angular-slickgrid/models/toggleStateChangeType.ts new file mode 100644 index 000000000..69c6f8e45 --- /dev/null +++ b/src/app/modules/angular-slickgrid/models/toggleStateChangeType.ts @@ -0,0 +1,15 @@ +export type ToggleStateChangeTypeString = 'toggle-collapse' | 'toggle-expand' | 'full-collapse' | 'full-expand'; + +export enum ToggleStateChangeType { + /** full tree collapse */ + toggleCollapse = 'toggle-collapse', + + /** full tree expand */ + fullExpand = 'full-expand', + + /** item toggle collapse */ + fullCollapse = 'full-collapse', + + /** item toggle expand */ + toggleExpand = 'toggle-expand', +} \ No newline at end of file diff --git a/src/app/modules/angular-slickgrid/models/treeDataOption.interface.ts b/src/app/modules/angular-slickgrid/models/treeDataOption.interface.ts index b33d67def..b5282b777 100644 --- a/src/app/modules/angular-slickgrid/models/treeDataOption.interface.ts +++ b/src/app/modules/angular-slickgrid/models/treeDataOption.interface.ts @@ -18,13 +18,22 @@ export interface TreeDataOption { direction: SortDirection | SortDirectionString; }; + /** Defaults to False, will the Tree be collapsed on first load? */ + initiallyCollapsed?: boolean; + /** Defaults to "children", object property name used to designate the Children array */ childrenPropName?: string; /** Defaults to "__collapsed", object property name used to designate the Collapsed flag */ collapsedPropName?: string; - /** Defaults to "id", object property name used to designate the Id field */ + /** Defaults to "__hasChildren", object property name used to designate if the item has children or not (boolean) */ + hasChildrenPropName?: string; + + /** + * Defaults to "id", object property name used to designate the Id field (you would rarely override this property, it is mostly used for internal usage). + * NOTE: by default it will read the `datasetIdPropertyName` from the grid option, so it's typically better NOT to override this property. + */ identifierPropName?: string; /** Defaults to "__parentId", object property name used to designate the Parent Id */ diff --git a/src/app/modules/angular-slickgrid/models/treeToggleStateChange.interface.ts b/src/app/modules/angular-slickgrid/models/treeToggleStateChange.interface.ts new file mode 100644 index 000000000..eb7b78b01 --- /dev/null +++ b/src/app/modules/angular-slickgrid/models/treeToggleStateChange.interface.ts @@ -0,0 +1,18 @@ +import { ToggleStateChangeType, ToggleStateChangeTypeString, TreeToggledItem } from '../models/index'; + +export interface TreeToggleStateChange { + /** Optional, what was the item Id that triggered the toggle? Only available when a parent item got toggled within the grid */ + fromItemId: number | string; + + /** What is the Type of toggle that just triggered the change event? */ + type: ToggleStateChangeType | ToggleStateChangeTypeString; + + /** What are the toggled items? This will be `null` when a full toggle is requested. */ + toggledItems: TreeToggledItem[] | null; + + /** + * What was the previous/last full toggle type? + * This will help us identify if the tree was fully collapsed or expanded when toggling items in the grid. + */ + previousFullToggleType: Exclude | Exclude; +} \ No newline at end of file diff --git a/src/app/modules/angular-slickgrid/models/treeToggledItem.interface.ts b/src/app/modules/angular-slickgrid/models/treeToggledItem.interface.ts new file mode 100644 index 000000000..e14214dd4 --- /dev/null +++ b/src/app/modules/angular-slickgrid/models/treeToggledItem.interface.ts @@ -0,0 +1,7 @@ +export interface TreeToggledItem { + /** Id of the item that was toggled (could be expanded/collapsed) */ + itemId: number | string; + + /** is the parent id collapsed or not? */ + isCollapsed: boolean; +} \ No newline at end of file diff --git a/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts index d4fa2b35d..81393a1cb 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/filter.service.spec.ts @@ -980,6 +980,10 @@ describe('FilterService', () => { }; }); + afterEach(() => { + jest.clearAllMocks(); + }); + it('should return an empty array when column definitions returns nothing as well', () => { gridStub.getColumns = undefined as any; @@ -1047,7 +1051,41 @@ describe('FilterService', () => { ]); }); - it('should pre-filter the tree dataset when the grid is a Tree Data View', () => { + it('should pre-filter the tree dataset when the grid is a Tree Data View & dataset is empty', (done) => { + const spyRefresh = jest.spyOn(dataViewStub, 'refresh'); + const spyPreFilter = jest.spyOn(service, 'preFilterTreeData'); + const spyGetCols = jest.spyOn(gridStub, 'getColumns').mockReturnValue([ + { id: 'name', field: 'name', filter: { model: Filters.input, operator: 'EQ' } }, + { id: 'gender', field: 'gender' }, + { id: 'size', field: 'size', filter: { model: Filters.input, operator: '>=' } } + ]); + const mockFlatDataset = [{ id: 0, name: 'John', gender: 'male', size: 170 }, { id: 1, name: 'Jane', gender: 'female', size: 150 }]; + jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'get').mockReturnValue(mockFlatDataset); + gridOptionMock.enableTreeData = true; + gridOptionMock.treeDataOptions = { columnId: 'file', childrenPropName: 'files' }; + gridOptionMock.presets = { + filters: [{ columnId: 'size', searchTerms: [20], operator: '>=' }] + }; + service.init(gridStub); + const output = service.populateColumnFilterSearchTermPresets(gridOptionMock.presets!.filters as any); + + expect(spyRefresh).not.toHaveBeenCalled(); + jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); + + setTimeout(() => { + expect(spyGetCols).toHaveBeenCalled(); + expect(spyPreFilter).toHaveBeenCalled(); + expect(spyRefresh).toHaveBeenCalled(); + expect(output).toEqual([ + { id: 'name', field: 'name', filter: { model: Filters.input, operator: 'EQ' } }, + { id: 'gender', field: 'gender', }, + { id: 'size', field: 'size', filter: { model: Filters.input, operator: '>=', searchTerms: [20] } }, + ]); + done(); + }); + }); + + it('should pre-filter the tree dataset when the grid is a Tree Data View & dataset is filled', () => { const spyRefresh = jest.spyOn(dataViewStub, 'refresh'); const spyPreFilter = jest.spyOn(service, 'preFilterTreeData'); const spyGetCols = jest.spyOn(gridStub, 'getColumns').mockReturnValue([ @@ -1055,6 +1093,8 @@ describe('FilterService', () => { { id: 'gender', field: 'gender' }, { id: 'size', field: 'size', filter: { model: Filters.input, operator: '>=' } } ]); + const mockFlatDataset = [{ id: 0, name: 'John', gender: 'male', size: 170 }, { id: 1, name: 'Jane', gender: 'female', size: 150 }]; + jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); gridOptionMock.enableTreeData = true; gridOptionMock.treeDataOptions = { columnId: 'file', childrenPropName: 'files' }; gridOptionMock.presets = { @@ -1561,6 +1601,9 @@ describe('FilterService', () => { beforeEach(() => { gridStub.getColumns = jest.fn(); gridOptionMock.backendServiceApi = undefined; + gridOptionMock.presets = { + treeData: { toggledItems: [{ itemId: 4, isCollapsed: true }] } + }; dataset = [ { __parentId: null, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, { __hasChildren: true, __parentId: null, __treeLevel: 0, file: 'documents', id: 21 }, diff --git a/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts index cb97bb661..eadec3377 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/gridState.service.spec.ts @@ -6,6 +6,7 @@ import { GridStateService } from '../gridState.service'; import { ResizerService } from '../resizer.service'; import { SharedService } from '../shared.service'; import { SortService } from '../sort.service'; +import { TreeDataService } from '../treeData.service'; import { BackendService, CurrentFilter, @@ -20,6 +21,7 @@ import { GridState, GridStateChange, GridStateType, + TreeToggleStateChange, } from '../../models'; declare const Slick: any; @@ -83,13 +85,17 @@ const sortServiceStub = { onSortCleared: new Subject() } as SortService; +const treeDataServiceStub = { + getCurrentToggleState: jest.fn(), +} as unknown as TreeDataService; + describe('GridStateService', () => { let service: GridStateService; let sharedService: SharedService; beforeEach(() => { sharedService = new SharedService(); - service = new GridStateService(extensionServiceStub, filterServiceStub, resizerServiceStub, sharedService, sortServiceStub); + service = new GridStateService(extensionServiceStub, filterServiceStub, resizerServiceStub, sharedService, sortServiceStub, treeDataServiceStub); service.init(gridStub, dataViewStub); jest.spyOn(gridStub, 'getSelectionModel').mockReturnValue(true); }); @@ -375,6 +381,10 @@ describe('GridStateService', () => { }); describe('getCurrentPagination method', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('should call "getCurrentPagination" and return null when no BackendService is used', () => { const output = service.getCurrentPagination(); expect(output).toBeUndefined(); @@ -407,18 +417,21 @@ describe('GridStateService', () => { }); it('should call "getCurrentGridState" method and return Pagination', () => { - const gridOptionsMock = { enablePagination: true, frozenBottom: false, frozenColumn: -1, frozenRow: -1 } as GridOption; + const gridOptionsMock = { enablePagination: true, enableTreeData: true, frozenBottom: false, frozenColumn: -1, frozenRow: -1 } as GridOption; const paginationMock = { pageNumber: 2, pageSize: 50 } as CurrentPagination; const columnMock = [{ columnId: 'field1', cssClass: 'red', headerCssClass: '', width: 100 }] as CurrentColumn[]; const filterMock = [{ columnId: 'field1', operator: 'EQ', searchTerms: [] }] as CurrentFilter[]; const sorterMock = [{ columnId: 'field1', direction: 'ASC' }, { columnId: 'field2', direction: 'DESC' }] as CurrentSorter[]; const pinningMock = { frozenBottom: false, frozenColumn: -1, frozenRow: -1 } as CurrentPinning; + const treeDataMock = { type: 'full-expand', previousFullToggleType: 'full-expand', toggledItems: null } as TreeToggleStateChange; jest.spyOn(SharedService.prototype, 'gridOptions', 'get').mockReturnValue(gridOptionsMock); + jest.spyOn(gridStub, 'getOptions').mockReturnValue(gridOptionsMock); const columnSpy = jest.spyOn(service, 'getCurrentColumns').mockReturnValue(columnMock); const filterSpy = jest.spyOn(service, 'getCurrentFilters').mockReturnValue(filterMock); const sorterSpy = jest.spyOn(service, 'getCurrentSorters').mockReturnValue(sorterMock); const paginationSpy = jest.spyOn(service, 'getCurrentPagination').mockReturnValue(paginationMock); + const treeDataSpy = jest.spyOn(service, 'getCurrentTreeDataToggleState').mockReturnValue(treeDataMock); const output = service.getCurrentGridState(); @@ -426,7 +439,8 @@ describe('GridStateService', () => { expect(filterSpy).toHaveBeenCalled(); expect(sorterSpy).toHaveBeenCalled(); expect(paginationSpy).toHaveBeenCalled(); - expect(output).toEqual({ columns: columnMock, filters: filterMock, sorters: sorterMock, pagination: paginationMock, pinning: pinningMock, } as GridState); + expect(treeDataSpy).toHaveBeenCalled(); + expect(output).toEqual({ columns: columnMock, filters: filterMock, sorters: sorterMock, pagination: paginationMock, pinning: pinningMock, treeData: treeDataMock } as GridState); }); }); @@ -828,6 +842,32 @@ describe('GridStateService', () => { }); }); + describe('getCurrentTreeDataToggleState method', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return null when Tree Data is not enabled', () => { + const gridSpy = jest.spyOn(gridStub, 'getOptions').mockReturnValue({ enableTreeData: false }); + + const output = service.getCurrentTreeDataToggleState(); + + expect(gridSpy).toHaveBeenCalled(); + expect(output).toBeNull(); + }); + + it('should expect Tree Data "getCurrentTreeDataToggleState" method to be called', () => { + jest.spyOn(gridStub, 'getOptions').mockReturnValue({ enableTreeData: true }); + const treeDataMock = { type: 'full-expand', previousFullToggleType: 'full-expand', toggledItems: null } as TreeToggleStateChange; + const getToggleSpy = jest.spyOn(treeDataServiceStub, 'getCurrentToggleState').mockReturnValue(treeDataMock); + + const output = service.getCurrentTreeDataToggleState(); + + expect(getToggleSpy).toHaveBeenCalled(); + expect(output).toEqual(treeDataMock); + }); + }); + describe('getCurrentFilters method', () => { afterEach(() => { gridStub.getOptions = () => gridOptionMock; @@ -1110,5 +1150,31 @@ describe('GridStateService', () => { expect(getAssocCurColSpy).toHaveBeenCalled(); expect(rxOnChangeSpy).toHaveBeenCalledWith(stateChangeMock); }); + + it('should trigger a "onGridStateChanged" event when "onTreeItemToggled" is triggered', () => { + const toggleChangeMock = { type: 'toggle-expand', fromItemId: 2, previousFullToggleType: 'full-collapse', toggledItems: [{ itemId: 2, isCollapsed: true }] } as TreeToggleStateChange; + const gridStateMock = { columns: [], filters: [], sorters: [], treeData: toggleChangeMock } as GridState; + const stateChangeMock = { change: { newValues: toggleChangeMock, type: GridStateType.treeData }, gridState: gridStateMock } as GridStateChange; + const rxOnChangeSpy = jest.spyOn(service.onGridStateChanged, 'next'); + const getCurGridStateSpy = jest.spyOn(service, 'getCurrentGridState').mockReturnValue(gridStateMock); + + sharedService.onTreeItemToggled.next(toggleChangeMock); + + expect(getCurGridStateSpy).toHaveBeenCalled(); + expect(rxOnChangeSpy).toHaveBeenCalledWith(stateChangeMock); + }); + + it('should trigger a "onGridStateChanged" event when "onTreeFullToggleEnd" is triggered', () => { + const toggleChangeMock = { type: 'full-expand', previousFullToggleType: 'full-expand', toggledItems: null } as TreeToggleStateChange; + const gridStateMock = { columns: [], filters: [], sorters: [], treeData: toggleChangeMock } as GridState; + const stateChangeMock = { change: { newValues: toggleChangeMock, type: GridStateType.treeData }, gridState: gridStateMock } as GridStateChange; + const rxOnChangeSpy = jest.spyOn(service.onGridStateChanged, 'next'); + const getCurGridStateSpy = jest.spyOn(service, 'getCurrentGridState').mockReturnValue(gridStateMock); + + sharedService.onTreeFullToggleEnd.next(toggleChangeMock); + + expect(getCurGridStateSpy).toHaveBeenCalled(); + expect(rxOnChangeSpy).toHaveBeenCalledWith(stateChangeMock); + }); }); }); diff --git a/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts index 28352cc4b..1ac0dca0f 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/sort.service.spec.ts @@ -1050,34 +1050,34 @@ describe('SortService', () => { describe('Hierarchical Dataset', () => { let dataset: any[] = []; const expectedSortedAscDataset = [ - { __parentId: null, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, - { __parentId: null, __treeLevel: 0, file: 'documents', id: 21 }, - { __parentId: 21, __treeLevel: 1, file: 'misc', id: 9 }, - { __parentId: 9, __treeLevel: 2, dateModified: '2015-02-26T16:50:00.123Z', file: 'todo.txt', id: 10, size: 0.4 }, - { __parentId: 21, __treeLevel: 1, file: 'pdf', id: 4 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'internet-bill.pdf', id: 6, size: 1.4 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-21T10:22:00.123Z', file: 'map.pdf', id: 5, size: 3.1 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-01T07:50:00.123Z', file: 'phone-bill.pdf', id: 23, size: 1.4 }, - { __parentId: 21, __treeLevel: 1, file: 'txt', id: 2 }, - { __parentId: 2, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'todo.txt', id: 3, size: 0.7 }, - { __parentId: 21, __treeLevel: 1, file: 'xls', id: 7 }, - { __parentId: 7, __treeLevel: 2, dateModified: '2014-10-02T14:50:00.123Z', file: 'compilation.xls', id: 8, size: 2.3 }, - { __parentId: null, __treeLevel: 0, dateModified: '2015-03-03T03:50:00.123Z', file: 'something.txt', id: 18, size: 90 }, + { __parentId: null, __hasChildren: false, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, + { __parentId: null, __hasChildren: true, __treeLevel: 0, file: 'documents', id: 21 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'misc', id: 9 }, + { __parentId: 9, __hasChildren: false, __treeLevel: 2, dateModified: '2015-02-26T16:50:00.123Z', file: 'todo.txt', id: 10, size: 0.4 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'pdf', id: 4 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'internet-bill.pdf', id: 6, size: 1.4 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-21T10:22:00.123Z', file: 'map.pdf', id: 5, size: 3.1 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-01T07:50:00.123Z', file: 'phone-bill.pdf', id: 23, size: 1.4 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'txt', id: 2 }, + { __parentId: 2, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'todo.txt', id: 3, size: 0.7 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'xls', id: 7 }, + { __parentId: 7, __hasChildren: false, __treeLevel: 2, dateModified: '2014-10-02T14:50:00.123Z', file: 'compilation.xls', id: 8, size: 2.3 }, + { __parentId: null, __hasChildren: false, __treeLevel: 0, dateModified: '2015-03-03T03:50:00.123Z', file: 'something.txt', id: 18, size: 90 }, ]; const expectedSortedDescDataset = [ - { __parentId: null, __treeLevel: 0, dateModified: '2015-03-03T03:50:00.123Z', file: 'something.txt', id: 18, size: 90 }, - { __parentId: null, __treeLevel: 0, file: 'documents', id: 21 }, - { __parentId: 21, __treeLevel: 1, file: 'xls', id: 7 }, - { __parentId: 7, __treeLevel: 2, dateModified: '2014-10-02T14:50:00.123Z', file: 'compilation.xls', id: 8, size: 2.3 }, - { __parentId: 21, __treeLevel: 1, file: 'txt', id: 2 }, - { __parentId: 2, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'todo.txt', id: 3, size: 0.7 }, - { __parentId: 21, __treeLevel: 1, file: 'pdf', id: 4 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-01T07:50:00.123Z', file: 'phone-bill.pdf', id: 23, size: 1.4 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-21T10:22:00.123Z', file: 'map.pdf', id: 5, size: 3.1 }, - { __parentId: 4, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'internet-bill.pdf', id: 6, size: 1.4 }, - { __parentId: 21, __treeLevel: 1, file: 'misc', id: 9 }, - { __parentId: 9, __treeLevel: 2, dateModified: '2015-02-26T16:50:00.123Z', file: 'todo.txt', id: 10, size: 0.4 }, - { __parentId: null, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, + { __parentId: null, __hasChildren: false, __treeLevel: 0, dateModified: '2015-03-03T03:50:00.123Z', file: 'something.txt', id: 18, size: 90 }, + { __parentId: null, __hasChildren: true, __treeLevel: 0, file: 'documents', id: 21 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'xls', id: 7 }, + { __parentId: 7, __hasChildren: false, __treeLevel: 2, dateModified: '2014-10-02T14:50:00.123Z', file: 'compilation.xls', id: 8, size: 2.3 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'txt', id: 2 }, + { __parentId: 2, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'todo.txt', id: 3, size: 0.7 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'pdf', id: 4 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-01T07:50:00.123Z', file: 'phone-bill.pdf', id: 23, size: 1.4 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-21T10:22:00.123Z', file: 'map.pdf', id: 5, size: 3.1 }, + { __parentId: 4, __hasChildren: false, __treeLevel: 2, dateModified: '2015-05-12T14:50:00.123Z', file: 'internet-bill.pdf', id: 6, size: 1.4 }, + { __parentId: 21, __hasChildren: true, __treeLevel: 1, file: 'misc', id: 9 }, + { __parentId: 9, __hasChildren: false, __treeLevel: 2, dateModified: '2015-02-26T16:50:00.123Z', file: 'todo.txt', id: 10, size: 0.4 }, + { __parentId: null, __hasChildren: false, __treeLevel: 0, dateModified: '2012-03-05T12:44:00.123Z', file: 'bucket-list.txt', id: 24, size: 0.5 }, ]; beforeEach(() => { diff --git a/src/app/modules/angular-slickgrid/services/__tests__/treeData.service.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/treeData.service.spec.ts index a95ec5c96..f28398e44 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/treeData.service.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/treeData.service.spec.ts @@ -1,3 +1,6 @@ +import 'jest-extended'; + +import { Constants } from '../../constants'; import { GridOption, SlickEventHandler, Column, BackendService } from '../../models/index'; import { SharedService } from '../shared.service'; import { SortService } from '../sort.service'; @@ -24,7 +27,10 @@ const backendServiceStub = { } as unknown as BackendService; const dataViewStub = { + beginUpdate: jest.fn(), + endUpdate: jest.fn(), getItem: jest.fn(), + getItemById: jest.fn(), getItems: jest.fn(), refresh: jest.fn(), sort: jest.fn(), @@ -156,6 +162,38 @@ describe('SortService', () => { expect(service.datasetHierarchical).toEqual(mockHierarchical); }); + describe('getTreeDataOptionPropName method', () => { + it('should return default constant children prop name', () => { + const output = service.getTreeDataOptionPropName('childrenPropName'); + expect(output).toBe(Constants.treeDataProperties.CHILDREN_PROP); + }); + + it('should return default constant collapsed prop name', () => { + const output = service.getTreeDataOptionPropName('collapsedPropName'); + expect(output).toBe(Constants.treeDataProperties.COLLAPSED_PROP); + }); + + it('should return default constant hasChildren prop name', () => { + const output = service.getTreeDataOptionPropName('hasChildrenPropName'); + expect(output).toBe(Constants.treeDataProperties.HAS_CHILDREN_PROP); + }); + + it('should return default constant level prop name', () => { + const output = service.getTreeDataOptionPropName('levelPropName'); + expect(output).toBe(Constants.treeDataProperties.TREE_LEVEL_PROP); + }); + + it('should return default constant parent prop name', () => { + const output = service.getTreeDataOptionPropName('parentPropName'); + expect(output).toBe(Constants.treeDataProperties.PARENT_PROP); + }); + + it('should return "id" as default identifier prop name', () => { + const output = service.getTreeDataOptionPropName('identifierPropName'); + expect(output).toBe('id'); + }); + }); + describe('handleOnCellClick method', () => { let div: HTMLDivElement; let mockColumn: Column; @@ -165,8 +203,12 @@ describe('SortService', () => { div = document.createElement('div'); div.innerHTML = `
Text
`; document.body.appendChild(div); - mockColumn = { id: 'firstName', field: 'firstName', onCellClick: jest.fn() } as Column; - mockRowData = { id: 123, firstName: 'John', lastName: 'Doe' }; + mockColumn = { id: 'file', field: 'file', onCellClick: jest.fn() } as Column; + mockRowData = { id: 123, file: 'myFile.txt', size: 0.5, }; + }); + + afterEach(() => { + jest.clearAllMocks(); }); it('should not do anything when "cell" property is missing', () => { @@ -213,6 +255,35 @@ describe('SortService', () => { expect(spyGetItem).toHaveBeenCalled(); expect(spyInvalidate).toHaveBeenCalled(); expect(spyUptItem).toHaveBeenCalledWith(123, { ...mockRowData, __collapsed: false }); + expect(service.getToggledItems().length).toBe(1); + expect(service.getCurrentToggleState()).toEqual({ type: 'toggle-expand', previousFullToggleType: 'full-expand', toggledItems: [{ isCollapsed: false, itemId: 123 }] }); + expect(spyUptItem).toHaveBeenCalledWith(123, { ...mockRowData, __collapsed: false }); + }); + + it('should toggle 2x times the "__collapsed" to False when the class name was found to be True prior', () => { + mockRowData.__collapsed = true; + jest.spyOn(gridStub, 'getData').mockReturnValue(dataViewStub); + const spyGetItem = jest.spyOn(dataViewStub, 'getItem').mockReturnValue(mockRowData); + jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'get').mockReturnValue([mockRowData]); + const spyUptItem = jest.spyOn(dataViewStub, 'updateItem'); + const spyInvalidate = jest.spyOn(gridStub, 'invalidate'); + + service.init(gridStub); + const eventData = new Slick.EventData(); + div.className = 'toggle'; + Object.defineProperty(eventData, 'target', { writable: true, value: div }); + service.currentToggledItems = [{ itemId: 123, isCollapsed: true }]; + + gridStub.onClick.notify({ cell: 0, row: 0, grid: gridStub }, eventData, gridStub); + + expect(service.getToggledItems().length).toBe(1); + expect(spyGetItem).toHaveBeenCalled(); + expect(spyInvalidate).toHaveBeenCalled(); + expect(service.getCurrentToggleState()).toEqual({ type: 'toggle-expand', previousFullToggleType: 'full-expand', toggledItems: [{ isCollapsed: false, itemId: 123 }] }); + expect(spyUptItem).toHaveBeenCalledWith(123, { ...mockRowData, __collapsed: false }); + expect(service.getToggledItems()).toEqual([{ itemId: 123, isCollapsed: false }]); + expect(SharedService.prototype.hierarchicalDataset![0].file).toBe('myFile.txt'); + expect(SharedService.prototype.hierarchicalDataset![0].__collapsed).toBeFalse(); }); it('should toggle the collapsed custom class name to False when that custom class name was found to be True prior', () => { @@ -230,139 +301,192 @@ describe('SortService', () => { expect(spyGetItem).toHaveBeenCalled(); expect(spyInvalidate).toHaveBeenCalled(); + expect(service.getToggledItems().length).toBe(1); + expect(service.getCurrentToggleState()).toEqual({ type: 'toggle-expand', previousFullToggleType: 'full-expand', toggledItems: [{ isCollapsed: false, itemId: 123 }] }); expect(spyUptItem).toHaveBeenCalledWith(123, { ...mockRowData, customCollapsed: false }); }); describe('toggleTreeDataCollapse method', () => { - let itemsMock: any; + let mockFlatDataset: any[]; + let mockHierarchical: any[]; beforeEach(() => { - itemsMock = [{ file: 'myFile.txt', size: 0.5 }, { file: 'myMusic.txt', size: 5.3 }]; - gridOptionsMock.treeDataOptions = { columnId: 'file' }; jest.clearAllMocks(); + mockFlatDataset = [ + { id: 0, file: 'TXT', size: 5.8, __hasChildren: true }, + { id: 1, file: 'myFile.txt', size: 0.5 }, + { id: 2, file: 'myMusic.txt', size: 5.3 }, + { id: 4, file: 'MP3', size: 3.4, __hasChildren: true }, + { id: 5, file: 'relaxation.mp3', size: 3.4 } + ]; + mockHierarchical = [ + { id: 0, file: 'TXT', files: [{ id: 1, file: 'myFile.txt', size: 0.5, }, { id: 2, file: 'myMusic.txt', size: 5.3, }] }, + { id: 4, file: 'MP3', files: [{ id: 5, file: 'relaxation.mp3', size: 3.4, }] } + ]; + gridOptionsMock.treeDataOptions = { columnId: 'file' }; }); it('should collapse all items when calling the method with collapsing True', () => { - const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(itemsMock); - const dataSetItemsSpy = jest.spyOn(dataViewStub, 'setItems'); + const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); + jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'get').mockReturnValue(mockHierarchical); + const beginUpdateSpy = jest.spyOn(dataViewStub, 'beginUpdate'); + const endUpdateSpy = jest.spyOn(dataViewStub, 'endUpdate'); + const updateItemSpy = jest.spyOn(dataViewStub, 'updateItem'); + // const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); service.init(gridStub); service.toggleTreeDataCollapse(true); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleStart`, { collapsing: true }); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleEnd`, { type: 'full-collapse', previousFullToggleType: 'full-collapse', toggledItems: null, }); expect(dataGetItemsSpy).toHaveBeenCalled(); - expect(dataSetItemsSpy).toHaveBeenCalledWith([ - { __collapsed: true, file: 'myFile.txt', size: 0.5, }, - { __collapsed: true, file: 'myMusic.txt', size: 5.3, }, - ]); + expect(beginUpdateSpy).toHaveBeenCalled(); + expect(updateItemSpy).toHaveBeenNthCalledWith(1, 0, { __collapsed: true, __hasChildren: true, id: 0, file: 'TXT', size: 5.8 }); + expect(updateItemSpy).toHaveBeenNthCalledWith(2, 4, { __collapsed: true, __hasChildren: true, id: 4, file: 'MP3', size: 3.4 }); + expect(SharedService.prototype.hierarchicalDataset![0].file).toBe('TXT'); + expect(SharedService.prototype.hierarchicalDataset![0].__collapsed).toBeTrue(); + expect(SharedService.prototype.hierarchicalDataset![1].file).toBe('MP3'); + expect(SharedService.prototype.hierarchicalDataset![1].__collapsed).toBeTrue(); + expect(endUpdateSpy).toHaveBeenCalled(); }); it('should collapse all items with a custom collapsed property when calling the method with collapsing True', () => { gridOptionsMock.treeDataOptions!.collapsedPropName = 'customCollapsed'; - const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(itemsMock); - const dataSetItemsSpy = jest.spyOn(dataViewStub, 'setItems'); + const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); + const beginUpdateSpy = jest.spyOn(dataViewStub, 'beginUpdate'); + const endUpdateSpy = jest.spyOn(dataViewStub, 'endUpdate'); + const updateItemSpy = jest.spyOn(dataViewStub, 'updateItem'); + // const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); service.init(gridStub); service.toggleTreeDataCollapse(true); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleStart`, { collapsing: true }); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleEnd`, { type: 'full-collapse', previousFullToggleType: 'full-collapse', toggledItems: null, }); expect(dataGetItemsSpy).toHaveBeenCalled(); - expect(dataSetItemsSpy).toHaveBeenCalledWith([ - { customCollapsed: true, file: 'myFile.txt', size: 0.5, }, - { customCollapsed: true, file: 'myMusic.txt', size: 5.3, }, - ]); + expect(dataGetItemsSpy).toHaveBeenCalled(); + expect(beginUpdateSpy).toHaveBeenCalled(); + expect(updateItemSpy).toHaveBeenNthCalledWith(1, 0, { customCollapsed: true, __hasChildren: true, id: 0, file: 'TXT', size: 5.8 }); + expect(updateItemSpy).toHaveBeenNthCalledWith(2, 4, { customCollapsed: true, __hasChildren: true, id: 4, file: 'MP3', size: 3.4 }); + expect(endUpdateSpy).toHaveBeenCalled(); }); it('should expand all items when calling the method with collapsing False', () => { - const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(itemsMock); - const dataSetItemsSpy = jest.spyOn(dataViewStub, 'setItems'); + const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); + const beginUpdateSpy = jest.spyOn(dataViewStub, 'beginUpdate'); + const endUpdateSpy = jest.spyOn(dataViewStub, 'endUpdate'); + const updateItemSpy = jest.spyOn(dataViewStub, 'updateItem'); + // const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish'); service.init(gridStub); service.toggleTreeDataCollapse(false); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleStart`, { collapsing: false }); + // expect(pubSubSpy).toHaveBeenCalledWith(`onTreeFullToggleEnd`, { type: 'full-expand', previousFullToggleType: 'full-expand', toggledItems: null, }); expect(dataGetItemsSpy).toHaveBeenCalled(); - expect(dataSetItemsSpy).toHaveBeenCalledWith([ - { __collapsed: false, file: 'myFile.txt', size: 0.5, }, - { __collapsed: false, file: 'myMusic.txt', size: 5.3, }, - ]); + expect(beginUpdateSpy).toHaveBeenCalled(); + expect(updateItemSpy).toHaveBeenNthCalledWith(1, 0, { __collapsed: false, __hasChildren: true, id: 0, file: 'TXT', size: 5.8 }); + expect(updateItemSpy).toHaveBeenNthCalledWith(2, 4, { __collapsed: false, __hasChildren: true, id: 4, file: 'MP3', size: 3.4 }); + expect(endUpdateSpy).toHaveBeenCalled(); + }); + + describe('applyToggledItemStateChanges method', () => { + it('should execute the method', () => { + const dataGetItemsSpy = jest.spyOn(dataViewStub, 'getItems').mockReturnValue(mockFlatDataset); + jest.spyOn(dataViewStub, 'getItemById').mockReturnValue(mockFlatDataset[3]); + jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'get').mockReturnValue(mockHierarchical); + const beginUpdateSpy = jest.spyOn(dataViewStub, 'beginUpdate'); + const endUpdateSpy = jest.spyOn(dataViewStub, 'endUpdate'); + const updateItemSpy = jest.spyOn(dataViewStub, 'updateItem'); + + service.init(gridStub); + service.applyToggledItemStateChanges([{ itemId: 4, isCollapsed: true }]); + + expect(dataGetItemsSpy).toHaveBeenCalled(); + expect(beginUpdateSpy).toHaveBeenCalled(); + expect(updateItemSpy).toHaveBeenNthCalledWith(1, 4, { __collapsed: true, __hasChildren: true, id: 4, file: 'MP3', size: 3.4 }); + expect(endUpdateSpy).toHaveBeenCalled(); + }); }); }); + }); - describe('convertFlatParentChildToTreeDatasetAndSort method', () => { - let mockColumns: Column[]; - let mockFlatDataset: any; + describe('convertFlatParentChildToTreeDatasetAndSort method', () => { + let mockColumns: Column[]; + let mockFlatDataset: any; - beforeEach(() => { - mockColumns = [{ id: 'file', field: 'file', }, { id: 'size', field: 'size', }] as Column[]; - mockFlatDataset = [{ id: 0, file: 'documents' }, { id: 1, file: 'vacation.txt', size: 1.2, parentId: 0 }, { id: 2, file: 'todo.txt', size: 2.3, parentId: 0 }]; - gridOptionsMock.treeDataOptions = { columnId: 'file', parentPropName: 'parentId' }; - jest.clearAllMocks(); - }); + beforeEach(() => { + mockColumns = [{ id: 'file', field: 'file', }, { id: 'size', field: 'size', }] as Column[]; + mockFlatDataset = [{ id: 0, file: 'documents' }, { id: 1, file: 'vacation.txt', size: 1.2, parentId: 0 }, { id: 2, file: 'todo.txt', size: 2.3, parentId: 0 }]; + gridOptionsMock.treeDataOptions = { columnId: 'file', parentPropName: 'parentId' }; + jest.clearAllMocks(); + }); - it('should sort by the Tree column when there is no initial sort provided', () => { - const mockHierarchical = [{ - id: 0, - file: 'documents', - files: [{ id: 2, file: 'todo.txt', size: 2.3, }, { id: 1, file: 'vacation.txt', size: 1.2, }] - }]; - const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); - jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); - jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); + it('should sort by the Tree column when there is no initial sort provided', () => { + const mockHierarchical = [{ + id: 0, + file: 'documents', + files: [{ id: 2, file: 'todo.txt', size: 2.3, }, { id: 1, file: 'vacation.txt', size: 1.2, }] + }]; + const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); + jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); + jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); - service.init(gridStub); - const result = service.convertFlatParentChildToTreeDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); - - expect(setSortSpy).toHaveBeenCalledWith([{ - columnId: 'file', - sortAsc: true, - sortCol: mockColumns[0] - }]); - expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); - }); + service.init(gridStub); + const result = service.convertFlatParentChildToTreeDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); + + expect(setSortSpy).toHaveBeenCalledWith([{ + columnId: 'file', + sortAsc: true, + sortCol: mockColumns[0] + }]); + expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); + }); - it('should sort by the Tree column by the "initialSort" provided', () => { - gridOptionsMock.treeDataOptions!.initialSort = { - columnId: 'size', - direction: 'desc' - }; - const mockHierarchical = [{ - id: 0, - file: 'documents', - files: [{ id: 1, file: 'vacation.txt', size: 1.2, }, { id: 2, file: 'todo.txt', size: 2.3, }] - }]; - const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); - jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); - jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); + it('should sort by the Tree column by the "initialSort" provided', () => { + gridOptionsMock.treeDataOptions!.initialSort = { + columnId: 'size', + direction: 'desc' + }; + const mockHierarchical = [{ + id: 0, + file: 'documents', + files: [{ id: 1, file: 'vacation.txt', size: 1.2, }, { id: 2, file: 'todo.txt', size: 2.3, }] + }]; + const setSortSpy = jest.spyOn(gridStub, 'setSortColumns'); + jest.spyOn(gridStub, 'getColumnIndex').mockReturnValue(0); + jest.spyOn(sortServiceStub, 'sortHierarchicalDataset').mockReturnValue({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); - service.init(gridStub); - const result = service.convertFlatParentChildToTreeDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); - - expect(setSortSpy).toHaveBeenCalledWith([{ - columnId: 'size', - sortAsc: false, - sortCol: mockColumns[1] - }]); - expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); - }); + service.init(gridStub); + const result = service.convertFlatParentChildToTreeDatasetAndSort(mockFlatDataset, mockColumns, gridOptionsMock); + + expect(setSortSpy).toHaveBeenCalledWith([{ + columnId: 'size', + sortAsc: false, + sortCol: mockColumns[1] + }]); + expect(result).toEqual({ flat: mockFlatDataset as any[], hierarchical: mockHierarchical as any[] }); }); + }); - describe('sortHierarchicalDataset method', () => { - it('should call sortHierarchicalDataset from the sort service', () => { - const mockColumns = [{ id: 'file', field: 'file', }, { id: 'size', field: 'size', }] as Column[]; - const mockHierarchical = [{ - id: 0, - file: 'documents', - files: [{ id: 2, file: 'todo.txt', size: 2.3, }, { id: 1, file: 'vacation.txt', size: 1.2, }] - }]; - const mockColumnSort = { columnId: 'size', sortAsc: true, sortCol: mockColumns[1], } - jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); - const getInitialSpy = jest.spyOn(service, 'getInitialSort').mockReturnValue(mockColumnSort); - const sortHierarchySpy = jest.spyOn(sortServiceStub, 'sortHierarchicalDataset'); + describe('sortHierarchicalDataset method', () => { + it('should call sortHierarchicalDataset from the sort service', () => { + const mockColumns = [{ id: 'file', field: 'file', }, { id: 'size', field: 'size', }] as Column[]; + const mockHierarchical = [{ + id: 0, + file: 'documents', + files: [{ id: 2, file: 'todo.txt', size: 2.3, }, { id: 1, file: 'vacation.txt', size: 1.2, }] + }]; + const mockColumnSort = { columnId: 'size', sortAsc: true, sortCol: mockColumns[1], } + jest.spyOn(SharedService.prototype, 'allColumns', 'get').mockReturnValue(mockColumns); + const getInitialSpy = jest.spyOn(service, 'getInitialSort').mockReturnValue(mockColumnSort); + const sortHierarchySpy = jest.spyOn(sortServiceStub, 'sortHierarchicalDataset'); - service.init(gridStub); - service.sortHierarchicalDataset(mockHierarchical); + service.init(gridStub); + service.sortHierarchicalDataset(mockHierarchical); - expect(getInitialSpy).toHaveBeenCalledWith(mockColumns, gridOptionsMock); - expect(sortHierarchySpy).toHaveBeenCalledWith(mockHierarchical, [mockColumnSort]); - }); + expect(getInitialSpy).toHaveBeenCalledWith(mockColumns, gridOptionsMock); + expect(sortHierarchySpy).toHaveBeenCalledWith(mockHierarchical, [mockColumnSort]); }); }); }); diff --git a/src/app/modules/angular-slickgrid/services/__tests__/utilities.spec.ts b/src/app/modules/angular-slickgrid/services/__tests__/utilities.spec.ts index d4eca04ae..9f91e8dcc 100644 --- a/src/app/modules/angular-slickgrid/services/__tests__/utilities.spec.ts +++ b/src/app/modules/angular-slickgrid/services/__tests__/utilities.spec.ts @@ -198,10 +198,10 @@ describe('Service/Utilies', () => { expect(output).toEqual([ { - id: 11, __treeLevel: 0, parentId: null, file: 'Music', files: [{ - id: 12, __treeLevel: 1, parentId: 11, file: 'mp3', files: [ - { id: 14, __treeLevel: 2, parentId: 12, file: 'pop', files: [{ id: 15, __treeLevel: 3, parentId: 14, file: 'theme.mp3', dateModified: '2015-03-01', size: 85, }] }, - { id: 16, __treeLevel: 2, parentId: 12, file: 'rock', files: [{ id: 17, __treeLevel: 3, parentId: 16, file: 'soft.mp3', dateModified: '2015-05-13', size: 98, }] }, + id: 11, __treeLevel: 0, __collapsed: false, parentId: null, file: 'Music', files: [{ + id: 12, __treeLevel: 1, __collapsed: false, parentId: 11, file: 'mp3', files: [ + { id: 14, __treeLevel: 2, __collapsed: false, parentId: 12, file: 'pop', files: [{ id: 15, __treeLevel: 3, parentId: 14, file: 'theme.mp3', dateModified: '2015-03-01', size: 85, }] }, + { id: 16, __treeLevel: 2, __collapsed: false, parentId: 12, file: 'rock', files: [{ id: 17, __treeLevel: 3, parentId: 16, file: 'soft.mp3', dateModified: '2015-05-13', size: 98, }] }, ] }] }, @@ -231,13 +231,13 @@ describe('Service/Utilies', () => { addTreeLevelByMutation(mockTreeArray, { childrenPropName: 'files', levelPropName: '__treeLevel' }); const output = flattenToParentChildArray(mockTreeArray, { childrenPropName: 'files' }); expect(output).toEqual([ - { id: 18, size: 90, __treeLevel: 0, dateModified: '2015-03-03', file: 'something.txt', __parentId: null, }, - { id: 11, __treeLevel: 0, file: 'Music', __parentId: null, }, - { id: 12, __treeLevel: 1, file: 'mp3', __parentId: 11, }, - { id: 16, __treeLevel: 2, file: 'rock', __parentId: 12, }, - { id: 17, __treeLevel: 3, dateModified: '2015-05-13', file: 'soft.mp3', size: 98, __parentId: 16, }, - { id: 14, __treeLevel: 2, file: 'pop', __parentId: 12, }, - { id: 15, __treeLevel: 3, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, __parentId: 14, }, + { id: 18, size: 90, __treeLevel: 0, dateModified: '2015-03-03', file: 'something.txt', __parentId: null, __hasChildren: false }, + { id: 11, __treeLevel: 0, file: 'Music', __parentId: null, __hasChildren: true }, + { id: 12, __treeLevel: 1, file: 'mp3', __parentId: 11, __hasChildren: true }, + { id: 16, __treeLevel: 2, file: 'rock', __parentId: 12, __hasChildren: true }, + { id: 17, __treeLevel: 3, dateModified: '2015-05-13', file: 'soft.mp3', size: 98, __parentId: 16, __hasChildren: false }, + { id: 14, __treeLevel: 2, file: 'pop', __parentId: 12, __hasChildren: true }, + { id: 15, __treeLevel: 3, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, __parentId: 14, __hasChildren: false }, ]); }); }); diff --git a/src/app/modules/angular-slickgrid/services/filter.service.ts b/src/app/modules/angular-slickgrid/services/filter.service.ts index ae7606190..2d9a2b5e1 100644 --- a/src/app/modules/angular-slickgrid/services/filter.service.ts +++ b/src/app/modules/angular-slickgrid/services/filter.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@angular/core'; import { dequal } from 'dequal/lite'; import { isObservable, Subject } from 'rxjs'; +import { Constants } from '../constants'; import { Column, ColumnFilters, @@ -19,6 +20,7 @@ import { OperatorType, SearchColumnFilter, SearchTerm, + SlickDataView, SlickEvent, SlickEventHandler, } from './../models/index'; @@ -36,6 +38,7 @@ declare const $: any; export class FilterService { protected _eventHandler: SlickEventHandler; protected _isFilterFirstRender = true; + protected _isTreePresetExecuted = false; protected _firstColumnIdRendered = ''; protected _filtersMetadata: Array = []; protected _columnFilters: ColumnFilters = {}; @@ -68,17 +71,17 @@ export class FilterService { /** Getter for the Grid Options pulled through the Grid Object */ protected get _gridOptions(): GridOption { - return (this._grid && this._grid.getOptions) ? this._grid.getOptions() : {}; + return this._grid?.getOptions?.() ?? {}; } /** Getter for the Column Definitions pulled through the Grid Object */ protected get _columnDefinitions(): Column[] { - return (this._grid && this._grid.getColumns) ? this._grid.getColumns() : []; + return this._grid?.getColumns?.() ?? []; } /** Getter of SlickGrid DataView object */ protected get _dataView(): any { - return (this._grid && this._grid.getData) ? this._grid.getData() : {}; + return this._grid?.getData?.() ?? {} as SlickDataView; } /** @@ -299,9 +302,9 @@ export class FilterService { // so we always run this check even when there are no filter search, the reason is because the user might click on the expand/collapse if (isGridWithTreeData && this._gridOptions && this._gridOptions.treeDataOptions) { treeDataOptions = this._gridOptions.treeDataOptions; - const collapsedPropName = treeDataOptions.collapsedPropName || '__collapsed'; - const parentPropName = treeDataOptions.parentPropName || '__parentId'; - const dataViewIdIdentifier = this._gridOptions.datasetIdPropertyName || 'id'; + const collapsedPropName = treeDataOptions.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; + const parentPropName = treeDataOptions.parentPropName ?? Constants.treeDataProperties.PARENT_PROP; + const dataViewIdIdentifier = this._gridOptions.datasetIdPropertyName ?? 'id'; if (item[parentPropName] !== null) { let parent = this._dataView.getItemById(item[parentPropName]); @@ -496,21 +499,23 @@ export class FilterService { * This will then be passed to the DataView setFilter(customLocalFilter), which will itself loop through the list of IDs and display/hide the row if found that array of IDs * We do this in 2 steps so that we can still use the DataSet setFilter() */ - preFilterTreeData(inputArray: any[], columnFilters: ColumnFilters) { - const treeDataOptions = this._gridOptions && this._gridOptions.treeDataOptions; - const parentPropName = treeDataOptions && treeDataOptions.parentPropName || '__parentId'; - const dataViewIdIdentifier = this._gridOptions && this._gridOptions.datasetIdPropertyName || 'id'; + preFilterTreeData(inputItems: any[], columnFilters: ColumnFilters) { + const treeDataOptions = this._gridOptions?.treeDataOptions; + const collapsedPropName = treeDataOptions?.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; + const parentPropName = treeDataOptions?.parentPropName ?? Constants.treeDataProperties.PARENT_PROP; + const dataViewIdIdentifier = this._gridOptions?.datasetIdPropertyName ?? 'id'; + const treeDataToggledItems = this._gridOptions.presets?.treeData?.toggledItems; const treeObj = {}; const filteredChildrenAndParents: any[] = []; - if (Array.isArray(inputArray)) { - for (let i = 0; i < inputArray.length; i++) { - (treeObj as any)[inputArray[i][dataViewIdIdentifier]] = inputArray[i]; + if (Array.isArray(inputItems)) { + for (let i = 0; i < inputItems.length; i++) { + (treeObj as any)[inputItems[i][dataViewIdIdentifier]] = inputItems[i]; // as the filtered data is then used again as each subsequent letter // we need to delete the .__used property, otherwise the logic below // in the while loop (which checks for parents) doesn't work: - delete (treeObj as any)[inputArray[i][dataViewIdIdentifier]].__used; + delete (treeObj as any)[inputItems[i][dataViewIdIdentifier]].__used; } // Step 1. prepare search filter by getting their parsed value(s), for example if it's a date filter then parse it to a Moment object @@ -518,12 +523,12 @@ export class FilterService { // it is much more effective to do it outside and prior to Step 2 so that we don't re-parse search filter for no reason while checking every row for (const columnId of Object.keys(columnFilters)) { const columnFilter = columnFilters[columnId] as SearchColumnFilter; - const searchValues: SearchTerm[] = (columnFilter && columnFilter.searchTerms) ? deepCopy(columnFilter.searchTerms) : []; + const searchValues: SearchTerm[] = columnFilter?.searchTerms ? deepCopy(columnFilter.searchTerms) : []; const inputSearchConditions = this.parseFormInputFilterConditions(searchValues, columnFilter); const columnDef = columnFilter.columnDef; - const fieldType = columnDef && columnDef.filter && columnDef.filter.type || columnDef && columnDef.type || FieldType.string; + const fieldType = columnDef?.filter?.type ?? columnDef?.type ?? FieldType.string; const parsedSearchTerms = getParsedSearchTermsByFieldType(inputSearchConditions.searchTerms, fieldType); // parsed term could a single value or an array of values if (parsedSearchTerms !== undefined) { columnFilter.parsedSearchTerms = parsedSearchTerms; @@ -531,8 +536,8 @@ export class FilterService { } // Step 2. loop through every item data context to execute filter condition check - for (let i = 0; i < inputArray.length; i++) { - const item = inputArray[i]; + for (let i = 0; i < inputItems.length; i++) { + const item = inputItems[i]; let matchFilter = true; // valid until proven otherwise // loop through all column filters and execute filter condition(s) @@ -541,7 +546,7 @@ export class FilterService { const conditionOptionResult = this.preProcessFilterConditionOnDataContext(item, columnFilter, this._grid); if (conditionOptionResult) { - const parsedSearchTerms = columnFilter && columnFilter.parsedSearchTerms; // parsed term could a single value or an array of values + const parsedSearchTerms = columnFilter?.parsedSearchTerms; // parsed term could a single value or an array of values const conditionResult = (typeof conditionOptionResult === 'boolean') ? conditionOptionResult : FilterConditions.executeFilterConditionTest(conditionOptionResult as FilterConditionOption, parsedSearchTerms); if (conditionResult) { // don't return true since we still need to check other keys in columnFilters @@ -558,18 +563,26 @@ export class FilterService { const len = filteredChildrenAndParents.length; // add child (id): filteredChildrenAndParents.splice(len, 0, item[dataViewIdIdentifier]); - let parent = (treeObj as any)[item[parentPropName]] || false; + let parent = (treeObj as any)[item[parentPropName]] ?? false; + + // if there are any presets of collapsed parents, let's processed them + const shouldBeCollapsed = !this._gridOptions.treeDataOptions?.initiallyCollapsed; + if (!this._isTreePresetExecuted && Array.isArray(treeDataToggledItems) && treeDataToggledItems.some(collapsedItem => collapsedItem.itemId === parent.id && collapsedItem.isCollapsed === shouldBeCollapsed)) { + parent[collapsedPropName] = shouldBeCollapsed; + } + while (parent) { // only add parent (id) if not already added: - parent.__used || filteredChildrenAndParents.splice(len, 0, parent[dataViewIdIdentifier]); + parent.__used ?? filteredChildrenAndParents.splice(len, 0, parent[dataViewIdIdentifier]); // mark each parent as used to not use them again later: (treeObj as any)[parent[dataViewIdIdentifier]].__used = true; // try to find parent of the current parent, if exists: - parent = (treeObj as any)[parent[parentPropName]] || false; + parent = (treeObj as any)[parent[parentPropName]] ?? false; } } } } + this._isTreePresetExecuted = true; return filteredChildrenAndParents; } @@ -684,10 +697,17 @@ export class FilterService { * @param {Array} [items] - optional flat array of parent/child items to use while redoing the full sort & refresh */ refreshTreeDataFilters(items?: any[]) { - const inputItems = items ?? this._dataView.getItems(); - if (this._dataView && this._gridOptions?.enableTreeData) { + const inputItems = items ?? this._dataView.getItems() ?? []; + + if (this._dataView && this._gridOptions?.enableTreeData && inputItems.length > 0) { this._tmpPreFilteredData = this.preFilterTreeData(inputItems, this._columnFilters); this._dataView.refresh(); // and finally this refresh() is what triggers a DataView filtering check + } else if (inputItems.length === 0 && Array.isArray(this.sharedService.hierarchicalDataset) && this.sharedService.hierarchicalDataset.length > 0) { + // in some occasion, we might be dealing with a dataset that is hierarchical from the start (the source dataset is already a tree structure) + // and we did not have time to convert it to a flat dataset yet (for SlickGrid to use), + // we would end up calling the pre-filter too early because these pre-filter works only a flat dataset + // for that use case (like Example 29), we need to delay for at least a cycle the pre-filtering (so we can simply recall the same method after a delay of 0 which equal to 1 CPU cycle) + setTimeout(() => this.refreshTreeDataFilters()); } } diff --git a/src/app/modules/angular-slickgrid/services/grid.service.ts b/src/app/modules/angular-slickgrid/services/grid.service.ts index a5ad3e858..8ae1562db 100644 --- a/src/app/modules/angular-slickgrid/services/grid.service.ts +++ b/src/app/modules/angular-slickgrid/services/grid.service.ts @@ -963,7 +963,7 @@ export class GridService { // 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 inputItems = items ?? this._dataView.getItems(); - const sortedDatasetResult = this.treeDataService.convertFlatParentChildToTreeDatasetAndSort(inputItems, this.sharedService.allColumns, this._gridOptions); + const sortedDatasetResult = this.treeDataService.convertFlatParentChildToTreeDatasetAndSort(inputItems || [], this.sharedService.allColumns, this._gridOptions); this.sharedService.hierarchicalDataset = sortedDatasetResult.hierarchical; this.filterService.refreshTreeDataFilters(items); this._dataView.setItems(sortedDatasetResult.flat); diff --git a/src/app/modules/angular-slickgrid/services/gridState.service.ts b/src/app/modules/angular-slickgrid/services/gridState.service.ts index 764f91891..ac365c0eb 100644 --- a/src/app/modules/angular-slickgrid/services/gridState.service.ts +++ b/src/app/modules/angular-slickgrid/services/gridState.service.ts @@ -14,7 +14,9 @@ import { GridState, GridStateChange, GridStateType, + SlickDataView, SlickEventHandler, + TreeToggleStateChange, } from './../models/index'; import { ExtensionService } from './extension.service'; import { FilterService } from './filter.service'; @@ -22,6 +24,7 @@ import { SortService } from './sort.service'; import { unsubscribeAllObservables } from './utilities'; import { ResizerService } from './resizer.service'; import { SharedService } from './shared.service'; +import { TreeDataService } from './treeData.service'; // using external non-typed js libraries declare const Slick: any; @@ -40,18 +43,19 @@ export class GridStateService { onGridStateChanged = new Subject(); constructor( - private extensionService: ExtensionService, - private filterService: FilterService, - private resizerService: ResizerService, - private sharedService: SharedService, - private sortService: SortService + private readonly extensionService: ExtensionService, + private readonly filterService: FilterService, + private readonly resizerService: ResizerService, + private readonly sharedService: SharedService, + private readonly sortService: SortService, + private readonly treeDataService: TreeDataService, ) { this._eventHandler = new Slick.EventHandler(); } /** Getter for the Grid Options pulled through the Grid Object */ private get _gridOptions(): GridOption { - return (this._grid && this._grid.getOptions) ? this._grid.getOptions() : {}; + return this._grid?.getOptions?.() ?? {}; } private get datasetIdPropName(): string { @@ -106,17 +110,28 @@ export class GridStateService { pinning: { frozenColumn, frozenRow, frozenBottom }, }; + // optional Pagination const currentPagination = this.getCurrentPagination(); if (currentPagination) { gridState.pagination = currentPagination; } + // optional Row Selection if (this.hasRowSelectionEnabled()) { const currentRowSelection = this.getCurrentRowSelections(args?.requestRefreshRowFilteredRow); if (currentRowSelection) { gridState.rowSelection = currentRowSelection; } } + + // optional Tree Data toggle items + if (this._gridOptions?.enableTreeData) { + const treeDataTreeToggleState = this.getCurrentTreeDataToggleState(); + if (treeDataTreeToggleState) { + gridState.treeData = treeDataTreeToggleState; + } + } + return gridState; } @@ -238,7 +253,7 @@ export class GridStateService { * @return current filters */ getCurrentFilters(): CurrentFilter[] | null { - if (this._gridOptions && this._gridOptions.backendServiceApi) { + if (this._gridOptions?.backendServiceApi) { const backendService = this._gridOptions.backendServiceApi.service; if (backendService?.getCurrentFilters) { return backendService.getCurrentFilters() as CurrentFilter[]; @@ -307,6 +322,17 @@ export class GridStateService { return null; } + /** + * Get the current list of Tree Data item(s) that got toggled in the grid + * @returns {Array} treeDataToggledItems - items that were toggled (array of `parentId` and `isCollapsed` flag) + */ + getCurrentTreeDataToggleState(): Omit | null { + if (this._gridOptions?.enableTreeData && this.treeDataService) { + return this.treeDataService.getCurrentToggleState(); + } + return null; + } + /** Check whether the row selection needs to be preserved */ needToPreserveRowSelection(): boolean { let preservedRowSelection = false; @@ -423,6 +449,20 @@ export class GridStateService { this.onGridStateChanged.next({ change: { newValues: currentColumns, type: GridStateType.columns }, gridState: this.getCurrentGridState() }); }) ); + + // subscribe to Tree Data toggle items changes + this._subscriptions.push( + this.sharedService.onTreeItemToggled.subscribe((toggleChange: TreeToggleStateChange) => { + this.onGridStateChanged.next({ change: { newValues: toggleChange, type: GridStateType.treeData }, gridState: this.getCurrentGridState() }); + }) + ); + + // subscribe to Tree Data full tree toggle changes + this._subscriptions.push( + this.sharedService.onTreeFullToggleEnd.subscribe((toggleChange: Omit) => { + this.onGridStateChanged.next({ change: { newValues: toggleChange, type: GridStateType.treeData }, gridState: this.getCurrentGridState() }); + }) + ); } // -- diff --git a/src/app/modules/angular-slickgrid/services/shared.service.ts b/src/app/modules/angular-slickgrid/services/shared.service.ts index bbccf42c6..71648c773 100644 --- a/src/app/modules/angular-slickgrid/services/shared.service.ts +++ b/src/app/modules/angular-slickgrid/services/shared.service.ts @@ -1,7 +1,7 @@ import { Injectable } from "@angular/core"; import { Subject } from 'rxjs'; -import { Column, CurrentPagination, GridOption } from '../models'; +import { Column, CurrentPagination, GridOption, TreeToggleStateChange } from '../models'; @Injectable() export class SharedService { @@ -17,6 +17,9 @@ export class SharedService { private _hierarchicalDataset: any[] | undefined; private _frozenVisibleColumnId: string | number | undefined; onHeaderMenuHideColumns = new Subject(); + onTreeItemToggled = new Subject(); + onTreeFullToggleStart = new Subject<{ collapsing: boolean }>(); + onTreeFullToggleEnd = new Subject>(); // -- // public diff --git a/src/app/modules/angular-slickgrid/services/sort.service.ts b/src/app/modules/angular-slickgrid/services/sort.service.ts index 8f29163fe..b7fc4b6b9 100644 --- a/src/app/modules/angular-slickgrid/services/sort.service.ts +++ b/src/app/modules/angular-slickgrid/services/sort.service.ts @@ -364,7 +364,7 @@ export class SortService { /** When a Sort Changes on a Local grid (JSON dataset) */ onLocalSortChanged(grid: any, sortColumns: Array, forceReSort = false, emitSortChanged = false) { const datasetIdPropertyName = this._gridOptions?.datasetIdPropertyName ?? 'id'; - const isTreeDataEnabled = this._gridOptions && this._gridOptions.enableTreeData || false; + const isTreeDataEnabled = this._gridOptions?.enableTreeData ?? false; const dataView = grid && grid.getData && grid.getData(); if (grid && dataView) { @@ -373,8 +373,7 @@ export class SortService { } if (isTreeDataEnabled && this.sharedService && Array.isArray(this.sharedService.hierarchicalDataset)) { - const hierarchicalDataset = this.sharedService.hierarchicalDataset; - const datasetSortResult = this.sortHierarchicalDataset(hierarchicalDataset, sortColumns); + const datasetSortResult = this.sortHierarchicalDataset(this.sharedService.hierarchicalDataset, sortColumns); // we could use the DataView sort but that would require re-sorting again (since the 2nd array that is currently in the DataView would have to be resorted against the 1st array that was sorting from tree sort) // it is simply much faster to just replace the entire dataset diff --git a/src/app/modules/angular-slickgrid/services/treeData.service.ts b/src/app/modules/angular-slickgrid/services/treeData.service.ts index cf712d84c..acd4fa939 100644 --- a/src/app/modules/angular-slickgrid/services/treeData.service.ts +++ b/src/app/modules/angular-slickgrid/services/treeData.service.ts @@ -1,36 +1,44 @@ import { Injectable } from '@angular/core'; +import { Constants } from '../constants'; -import { Column, ColumnSort, GridOption, SlickEventData, SlickEventHandler, TreeDataOption } from '../models/index'; +import { Column, ColumnSort, GridOption, SlickDataView, SlickEventData, SlickEventHandler, SlickGrid, ToggleStateChangeType, ToggleStateChangeTypeString, TreeDataOption, TreeToggledItem, TreeToggleStateChange } from '../models/index'; import { SharedService } from './shared.service'; import { SortService } from './sort.service'; -import { unflattenParentChildArrayToTree } from './utilities'; +import { findItemInTreeStructure, unflattenParentChildArrayToTree } from './utilities'; // using external non-typed js libraries declare const Slick: any; @Injectable() export class TreeDataService { - private _grid: any; + private _isLastFullToggleCollapsed = false; + private _lastToggleStateChange: Omit = { + type: this.gridOptions?.treeDataOptions?.initiallyCollapsed ? 'full-collapse' : 'full-expand', + previousFullToggleType: this.gridOptions?.treeDataOptions?.initiallyCollapsed ? 'full-collapse' : 'full-expand', + toggledItems: null + }; + private _currentToggledItems: TreeToggledItem[] = []; + private _grid!: any; private _eventHandler: SlickEventHandler; constructor(private sharedService: SharedService, private sortService: SortService) { this._eventHandler = new Slick.EventHandler(); } + set currentToggledItems(newToggledItems: TreeToggledItem[]) { + this._currentToggledItems = newToggledItems; + } get dataset(): any[] { - return this.dataView && this.dataView.getItems && this.dataView.getItems(); + return this.dataView?.getItems(); } get datasetHierarchical(): any[] | undefined { return this.sharedService.hierarchicalDataset; } - get dataView(): any { - return this._grid && this._grid.getData && this._grid.getData(); - } - - get gridOptions(): GridOption { - return this._grid && this._grid.getOptions && this._grid.getOptions() || {}; + /** Getter of SlickGrid DataView object */ + get dataView(): SlickDataView { + return this._grid?.getData?.() ?? {} as SlickDataView; } /** Getter of the SlickGrid Event Handler */ @@ -38,15 +46,26 @@ export class TreeDataService { return this._eventHandler; } + get gridOptions(): GridOption { + return this._grid?.getOptions?.() ?? {}; + } + + get treeDataOptions(): TreeDataOption { + return this.gridOptions.treeDataOptions as TreeDataOption; + } + dispose() { // unsubscribe all SlickGrid events - if (this._eventHandler && this._eventHandler.unsubscribeAll) { + if (this._eventHandler?.unsubscribeAll) { this._eventHandler.unsubscribeAll(); } } init(grid: any) { this._grid = grid; + this._isLastFullToggleCollapsed = this.gridOptions?.treeDataOptions?.initiallyCollapsed ?? false; + this._currentToggledItems = this.gridOptions.presets?.treeData?.toggledItems ?? []; + // there's a few limitations with Tree Data, we'll just throw error when that happens if (this.gridOptions?.enableTreeData) { @@ -71,6 +90,50 @@ export class TreeDataService { this._eventHandler.subscribe(grid.onClick, this.handleOnCellClick.bind(this)); } + /** + * Apply different tree toggle state changes by providing an array of parentIds that are designated as collapsed (or not). + * User will have to provide an array of `parentId` and `isCollapsed` boolean and the code will only apply the ones that are tagged as collapsed, everything else will be expanded + * @param {Array} treeToggledItems - array of parentId which are tagged as changed + */ + applyToggledItemStateChanges(treeToggledItems: TreeToggledItem[], previousFullToggleType?: Exclude | Exclude) { + if (Array.isArray(treeToggledItems)) { + const collapsedPropName = this.getTreeDataOptionPropName('collapsedPropName'); + const hasChildrenPropName = this.getTreeDataOptionPropName('hasChildrenPropName'); + + // for the rows we identified as collapsed, we'll send them to the DataView with the new updated collapsed flag + // and we'll refresh the DataView to see the collapsing applied in the grid + this.dataView.beginUpdate(true); + + // we first need to put back the previous full toggle state (whether it was a full collapse or expand) by collapsing/expanding everything depending on the last toggled that was called `isLastFullToggleCollapsed` + const previousFullToggle = previousFullToggleType ?? this._lastToggleStateChange.previousFullToggleType; + const shouldCollapseAll = previousFullToggle === 'full-collapse'; + (this.dataView.getItems() || []).forEach((item: any) => { + // collapse/expand the item but only when it's a parent item with children + if (item[hasChildrenPropName]) { + item[collapsedPropName] = shouldCollapseAll; + } + }); + + // then we reapply only the ones that changed (provided as argument to the function) + for (const collapsedItem of treeToggledItems) { + const item = this.dataView.getItemById(collapsedItem.itemId); + this.updateToggledItem(item, collapsedItem.isCollapsed); + } + + // close the update transaction & call a refresh which will trigger a re-render with filters applied (including expand/collapse) + this.dataView.endUpdate(); + this.dataView.refresh(); + } + } + + /** + * Get the current toggle state that includes the type (toggle, full-expand, full-collapse) and toggled items (only applies when it's a parent toggle) + * @returns {TreeToggleStateChange} treeDataToggledItems - items that were toggled (array of `parentId` and `isCollapsed` flag) + */ + getCurrentToggleState(): Omit { + return this._lastToggleStateChange; + } + getInitialSort(columnDefinitions: Column[], gridOptions: GridOption): ColumnSort { const treeDataOptions = gridOptions?.treeDataOptions; const initialColumnSorting = treeDataOptions?.initialSort ?? { columnId: treeDataOptions?.columnId ?? '', direction: 'ASC' }; @@ -83,6 +146,40 @@ export class TreeDataService { }; } + /** + * Get the current list of Tree Data item(s) that got toggled in the grid (basically the parents that the user clicked on the toggle icon to expand/collapse the child) + * @returns {Array} treeDataToggledItems - items that were toggled (array of `parentId` and `isCollapsed` flag) + */ + getToggledItems(): TreeToggledItem[] { + return this._currentToggledItems; + } + + /** Find the associated property name from the Tree Data option when found or return a default property name that we defined internally */ + getTreeDataOptionPropName(optionName: keyof TreeDataOption): string { + let propName = ''; + switch (optionName) { + case 'childrenPropName': + propName = this.treeDataOptions?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP; + break; + case 'collapsedPropName': + propName = this.treeDataOptions?.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; + break; + case 'hasChildrenPropName': + propName = this.treeDataOptions?.hasChildrenPropName ?? Constants.treeDataProperties.HAS_CHILDREN_PROP; + break; + case 'identifierPropName': + propName = this.treeDataOptions?.identifierPropName ?? this.gridOptions?.datasetIdPropertyName ?? 'id'; + break; + case 'levelPropName': + propName = this.treeDataOptions?.levelPropName ?? Constants.treeDataProperties.TREE_LEVEL_PROP; + break; + case 'parentPropName': + propName = this.treeDataOptions?.parentPropName ?? Constants.treeDataProperties.PARENT_PROP; + break; + } + return propName; + } + /** * 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 @@ -117,21 +214,106 @@ export class TreeDataService { return unflattenParentChildArrayToTree(flatDataset, treeDataOptions); } - handleOnCellClick(event: any, args: any) { + /** + * 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); + } + + /** + * Toggle the collapsed values of all parent items (the ones with children), we can optionally provide a flag to force a collapse or expand + * @param {Boolean} collapsing - optionally force a collapse/expand (True => collapse all, False => expand all) + * @param {Boolean} shouldTriggerEvent - defaults to true, should we trigger an event? For example, we could disable this to avoid a Grid State change event. + * @returns {Promise} - returns a void Promise, the reason we use a Promise is simply to make sure that when we add a spinner, it doesn't start/stop only at the end of the process + */ + async toggleTreeDataCollapse(collapsing: boolean, shouldTriggerEvent = true): Promise { + if (this.gridOptions?.enableTreeData) { + const hasChildrenPropName = this.getTreeDataOptionPropName('hasChildrenPropName'); + + // emit an event when full toggle starts (useful to show a spinner) + if (shouldTriggerEvent) { + this.sharedService.onTreeFullToggleStart.next({ collapsing }); + } + + // do a bulk change data update to toggle all necessary parents (the ones with children) to the new collapsed flag value + this.dataView.beginUpdate(true); + + // toggle the collapsed flag but only when it's a parent item with children + (this.dataView.getItems() || []).forEach((item: any) => { + if (item[hasChildrenPropName]) { + this.updateToggledItem(item, collapsing); + } + }); + + this.dataView.endUpdate(); + this.dataView.refresh(); + this._isLastFullToggleCollapsed = collapsing; + } + + const toggleType = collapsing ? ToggleStateChangeType.fullCollapse : ToggleStateChangeType.fullExpand; + + this._lastToggleStateChange = { + type: toggleType, + previousFullToggleType: toggleType, + toggledItems: null + } as TreeToggleStateChange; + + // emit an event when full toggle ends + if (shouldTriggerEvent) { + this.sharedService.onTreeFullToggleEnd.next(this._lastToggleStateChange); + } + } + + // -- + // private functions + // ------------------ + + private handleOnCellClick(event: any, args: any) { if (event && args) { const targetElm: any = event.target || {}; - const treeDataOptions = this.gridOptions.treeDataOptions; - const collapsedPropName = treeDataOptions && treeDataOptions.collapsedPropName || '__collapsed'; const idPropName = this.gridOptions.datasetIdPropertyName ?? 'id'; + const collapsedPropName = this.getTreeDataOptionPropName('collapsedPropName'); + const childrenPropName = this.getTreeDataOptionPropName('childrenPropName'); - if (targetElm && targetElm.className) { + if (targetElm?.className) { const hasToggleClass = targetElm.className.indexOf('toggle') >= 0 || false; if (hasToggleClass) { const item = this.dataView.getItem(args.row); if (item) { - item[collapsedPropName] = !item[collapsedPropName] ? true : false; - this.dataView.updateItem(item[idPropName], item); + item[collapsedPropName] = !item[collapsedPropName]; // toggle the collapsed flag + const isCollapsed = item[collapsedPropName]; + const itemId = item[idPropName]; + const parentFoundIdx = this._currentToggledItems.findIndex(treeChange => treeChange.itemId === itemId); + if (parentFoundIdx >= 0) { + this._currentToggledItems[parentFoundIdx].isCollapsed = isCollapsed; + } else { + this._currentToggledItems.push({ itemId, isCollapsed }); + } + + this.dataView.updateItem(itemId, item); + + // since we always keep 2 arrays as reference (flat + hierarchical) + // we also need to update the hierarchical array with the new toggle flag + const searchTreePredicate = (treeItemToSearch: any) => treeItemToSearch[idPropName] === itemId; + const treeItemFound = findItemInTreeStructure(this.sharedService.hierarchicalDataset || [], searchTreePredicate, childrenPropName); + if (treeItemFound) { + treeItemFound[collapsedPropName] = isCollapsed; + } + + // and finally we can invalidate the grid to re-render the UI this._grid.invalidate(); + + this._lastToggleStateChange = { + type: isCollapsed ? ToggleStateChangeType.toggleCollapse : ToggleStateChangeType.toggleExpand, + previousFullToggleType: this._isLastFullToggleCollapsed ? 'full-collapse' : 'full-expand', + toggledItems: this._currentToggledItems + }; + this.sharedService.onTreeItemToggled.next({ ...this._lastToggleStateChange, fromItemId: itemId } as TreeToggleStateChange); } event.stopImmediatePropagation(); } @@ -139,30 +321,22 @@ export class TreeDataService { } } - /** - * 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); - } + private updateToggledItem(item: any, isCollapsed: boolean) { + const dataViewIdIdentifier = this.gridOptions?.datasetIdPropertyName ?? 'id'; + const childrenPropName = this.getTreeDataOptionPropName('childrenPropName'); + const collapsedPropName = this.getTreeDataOptionPropName('collapsedPropName'); - async toggleTreeDataCollapse(collapsing: boolean): Promise { - if (this.gridOptions) { - const treeDataOptions = this.gridOptions.treeDataOptions; + if (item) { + // update the flat dataset item + item[collapsedPropName] = isCollapsed; + this.dataView.updateItem(item[dataViewIdIdentifier], item); - if (this.gridOptions.enableTreeData) { - const items: any[] = this.dataView.getItems() || []; - const collapsedPropName = treeDataOptions && treeDataOptions.collapsedPropName || '__collapsed'; - items.forEach((item: any) => item[collapsedPropName] = collapsing); - this.dataView.setItems(items); - this._grid.invalidate(); + // also update the hierarchical tree item + const searchTreePredicate = (treeItemToSearch: any) => treeItemToSearch[dataViewIdIdentifier] === item[dataViewIdIdentifier]; + const treeItemFound = findItemInTreeStructure(this.sharedService.hierarchicalDataset || [], searchTreePredicate, childrenPropName); + if (treeItemFound) { + treeItemFound[collapsedPropName] = isCollapsed; } } - - return true; } } diff --git a/src/app/modules/angular-slickgrid/services/utilities.ts b/src/app/modules/angular-slickgrid/services/utilities.ts index 905788477..8e0d42493 100644 --- a/src/app/modules/angular-slickgrid/services/utilities.ts +++ b/src/app/modules/angular-slickgrid/services/utilities.ts @@ -4,6 +4,7 @@ import { first } from 'rxjs/operators'; import * as moment_ from 'moment-mini'; const moment = (moment_ as any)['default'] || moment_; // patch to fix rollup "moment has no default export" issue, document here https://github.com/rollup/rollup/issues/670 +import { Constants } from '../constants'; import { FieldType, GridOption, OperatorString, OperatorType } from '../models/index'; // using external non-typed js libraries @@ -55,14 +56,15 @@ export function arrayRemoveItemByIndex(array: T[], index: number): T[] { /** * Convert a flat array (with "parentId" references) into a hierarchical (tree) dataset structure (where children are array(s) inside their parent objects) * @param flatArray input array (flat dataset) - * @param options you can provide the following options:: "parentPropName" (defaults to "parent"), "childrenPropName" (defaults to "children") and "identifierPropName" (defaults to "id") + * @param options you can provide the following tree data options (which are all prop names, except 1 boolean flag, to use or else use their defaults):: collapsedPropName, childrenPropName, parentPropName, identifierPropName and levelPropName and initiallyCollapsed (boolean) * @return roots - hierarchical (tree) data view array */ -export function unflattenParentChildArrayToTree(flatArray: P[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; levelPropName?: string; }): T[] { - const childrenPropName = options?.childrenPropName ?? 'children'; - const parentPropName = options?.parentPropName ?? '__parentId'; +export function unflattenParentChildArrayToTree(flatArray: P[], options?: { childrenPropName?: string; collapsedPropName?: string; identifierPropName?: string; levelPropName?: string; parentPropName?: string; initiallyCollapsed?: boolean; }): T[] { const identifierPropName = options?.identifierPropName ?? 'id'; - const levelPropName = options?.levelPropName ?? '__treeLevel'; + const childrenPropName = options?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP; + const parentPropName = options?.parentPropName ?? Constants.treeDataProperties.PARENT_PROP; + const levelPropName = options?.levelPropName ?? Constants.treeDataProperties.TREE_LEVEL_PROP; + const collapsedPropName = options?.collapsedPropName ?? Constants.treeDataProperties.COLLAPSED_PROP; const inputArray: P[] = flatArray || []; const roots: T[] = []; // items without parent which at the root @@ -75,15 +77,16 @@ export function unflattenParentChildArrayToTree { 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); + if (p[collapsedPropName] === undefined) { + p[collapsedPropName] = options?.initiallyCollapsed ?? false; + } } }); @@ -102,7 +105,7 @@ export function unflattenParentChildArrayToTree(treeArray: T[], options: { childrenPropName: string; levelPropName: string; }, treeLevel = 0) { - const childrenPropName = (options?.childrenPropName ?? 'children') as keyof T; + const childrenPropName = (options?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP) as keyof T; if (Array.isArray(treeArray)) { for (const item of treeArray) { @@ -124,11 +127,12 @@ export function addTreeLevelByMutation(treeArray: T[], options: { childrenPro * @param {Object} options - you can provide "childrenPropName" (defaults to "children") * @return {Array} output - Parent/Child array */ -export function flattenToParentChildArray(treeArray: T[], options?: { parentPropName?: string; childrenPropName?: string; identifierPropName?: string; shouldAddTreeLevelNumber?: boolean; levelPropName?: string; }) { - const childrenPropName = (options?.childrenPropName ?? 'children') as keyof T & string; +export function flattenToParentChildArray(treeArray: T[], options?: { parentPropName?: string; childrenPropName?: string; hasChildrenPropName?: string; identifierPropName?: string; shouldAddTreeLevelNumber?: boolean; levelPropName?: string; }) { const identifierPropName = (options?.identifierPropName ?? 'id') as keyof T & string; - const parentPropName = (options?.parentPropName ?? '__parentId') as keyof T & string; - const levelPropName = options?.levelPropName ?? '__treeLevel'; + const childrenPropName = (options?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP) as keyof T & string; + const hasChildrenPropName = (options?.hasChildrenPropName ?? Constants.treeDataProperties.HAS_CHILDREN_PROP) as keyof T & string; + const parentPropName = (options?.parentPropName ?? Constants.treeDataProperties.PARENT_PROP) as keyof T & string; + const levelPropName = options?.levelPropName ?? Constants.treeDataProperties.TREE_LEVEL_PROP; type FlatParentChildArray = Omit; if (options?.shouldAddTreeLevelNumber) { @@ -142,6 +146,7 @@ export function flattenToParentChildArray(treeArray: T[], options?: { parentP return { [identifierPropName]: node[identifierPropName], [parentPropName]: parentNode !== undefined ? parentNode[identifierPropName] : null, + [hasChildrenPropName]: !!node[childrenPropName], ...objectWithoutKey(node, childrenPropName as keyof T) // reuse the entire object except the children array property } as unknown as FlatParentChildArray; } @@ -224,9 +229,9 @@ export function findItemInHierarchicalStructure(treeArray: T[], predica /** * Find an item from a tree (hierarchical) view structure (a parent that can have children array which themseleves can children and so on) - * @param treeArray - * @param predicate - * @param childrenPropertyName + * @param {Array} treeArray - hierarchical tree dataset + * @param {Function} predicate - search predicate to find the item in the hierarchical tree structure + * @param {String} childrenPropertyName - children property name to use in the tree (defaults to "children") */ export function findItemInTreeStructure(treeArray: T[], predicate: (item: T) => boolean, childrenPropertyName: string): T | undefined { if (!childrenPropertyName) { diff --git a/test/cypress/integration/example28.spec.js b/test/cypress/integration/example28.spec.js index 282c475c9..f988f80ce 100644 --- a/test/cypress/integration/example28.spec.js +++ b/test/cypress/integration/example28.spec.js @@ -5,7 +5,7 @@ function removeExtraSpaces(text) { } describe('Example 28 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, () => { - const GRID_ROW_HEIGHT = 45; + const GRID_ROW_HEIGHT = 40; const titles = ['Title', 'Duration', '% Complete', 'Start', 'Finish', 'Effort Driven']; it('should display Example title', () => { @@ -29,6 +29,92 @@ describe('Example 28 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .contains('>='); }); + it('should have data filtered, with "% Complete" >=25, and not show the full item count in the footer', () => { + cy.get('.search-filter.filter-percentComplete .operator .form-control') + .should('have.value', '>='); + + cy.get('.rangeInput_percentComplete') + .invoke('val') + .then(text => expect(text).to.eq('25')); + + cy.get('.search-filter .input-group-text') + .should($span => expect($span.text()).to.eq('25')); + + cy.get('.right-footer') + .should($span => { + const text = removeExtraSpaces($span.text()); // remove all white spaces + expect(text).not.to.eq('500 of 500 items'); + }); + }); + + it('should open the Grid Menu "Clear all Filters" command', () => { + cy.get('#grid28') + .find('button.slick-gridmenu-button') + .trigger('click') + .click(); + + cy.get(`.slick-gridmenu:visible`) + .find('.slick-gridmenu-item') + .first() + .find('span') + .contains('Clear all Filters') + .click(); + }); + + it('should expect the "Task 1" to be expanded on page load by the grid preset toggled items array', () => { + cy.get(`.slick-group-toggle.expanded`).should('have.length', 1); + + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(0).slick-tree-level-0`).should('contain', 'Task 0'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0).slick-tree-level-0`).should('contain', 'Task 1'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0).slick-tree-level-1`).should('contain', 'Task 2'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0).slick-tree-level-1`).should('contain', 'Task 3'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0)`).should('not.contain', 'Task 4'); + }); + + it('should be able to expand the "Task 3"', () => { + /* + now we should find this structure + Task 0 + Task 1 + Task 2 + Task 3 + Task 4 + ... + */ + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`) + .click(); + + cy.get(`#grid28 .slick-group-toggle.expanded`).should('have.length', 2); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 0}px"] > .slick-cell:nth(0).slick-tree-level-0`).should('contain', 'Task 0'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0).slick-tree-level-0`).should('contain', 'Task 1'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0).slick-tree-level-1`).should('contain', 'Task 2'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0).slick-tree-level-1`).should('contain', 'Task 3'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(0).slick-tree-level-2`).should('contain', 'Task 4'); + }); + + it('should be able to click on the "Collapse All (wihout event)" button', () => { + cy.get('[data-test=collapse-all-noevent-btn]') + .contains('Collapse All (without triggering event)') + .click(); + }); + + it('should be able to click on the "Reapply Previous Toggled Items" button and expect "Task 1" and "Task 3" parents to become open (via Grid State change) while every other parents remains collapsed', () => { + cy.get('[data-test=reapply-toggled-items-btn]') + .contains('Reapply Previous Toggled Items') + .click(); + + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0)`).should('contain', 'Task 1'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1); + + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0)`).should('contain', 'Task 2'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0)`).should('contain', 'Task 3'); + cy.get(`#grid28 [style="top:${GRID_ROW_HEIGHT * 3}px"] > .slick-cell:nth(0) .slick-group-toggle.expanded`).should('have.length', 1); + + cy.get(`#grid28 .slick-group-toggle.expanded`).should('have.length', 2); + }); + it('should collapsed all rows from "Collapse All" button', () => { cy.get('[data-test=collapse-all-btn]') .contains('Collapse All') @@ -80,7 +166,7 @@ describe('Example 28 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .should(($rows) => expect($rows).to.have.length.greaterThan(0)); }); - it('should collapsed all rows from "Expand All" context menu', () => { + it('should expand all rows from "Expand All" context menu', () => { cy.get('#grid28') .contains('5 days'); @@ -103,38 +189,6 @@ describe('Example 28 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .should(($rows) => expect($rows).to.have.length.greaterThan(0)); }); - it('should have data filtered, with "% Complete" >=25, and not show the full item count in the footer', () => { - cy.get('.search-filter.filter-percentComplete .operator .form-control') - .should('have.value', '>='); - - cy.get('.rangeInput_percentComplete') - .invoke('val') - .then(text => expect(text).to.eq('25')); - - cy.get('.search-filter .input-group-text') - .should($span => expect($span.text()).to.eq('25')); - - cy.get('.right-footer') - .should($span => { - const text = removeExtraSpaces($span.text()); // remove all white spaces - expect(text).not.to.eq('500 of 500 items'); - }); - }); - - it('should open the Grid Menu "Clear all Filters" command', () => { - cy.get('#grid28') - .find('button.slick-gridmenu-button') - .trigger('click') - .click(); - - cy.get(`.slick-gridmenu:visible`) - .find('.slick-gridmenu-item') - .first() - .find('span') - .contains('Clear all Filters') - .click(); - }); - it('should no longer have filters and it should show the full item count in the footer', () => { cy.get('.search-filter.filter-percentComplete .operator .form-control') .should('have.value', ''); diff --git a/test/cypress/integration/example29.spec.js b/test/cypress/integration/example29.spec.js index 920a75efa..9b6bbdace 100644 --- a/test/cypress/integration/example29.spec.js +++ b/test/cypress/integration/example29.spec.js @@ -1,6 +1,7 @@ /// describe('Example 29 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, () => { + const GRID_ROW_HEIGHT = 33; const titles = ['Files', 'Date Modified', 'Size']; // const defaultSortAscList = ['bucket-list.txt', 'documents', 'misc', 'todo.txt', 'pdf', 'internet-bill.pdf', 'map.pdf', 'map2.pdf', 'phone-bill.pdf', 'txt', 'todo.txt', 'xls', 'compilation.xls', 'music', 'mp3', 'pop', 'song.mp3', 'theme.mp3', 'rock', 'soft.mp3', 'something.txt']; // const defaultSortDescList = ['something.txt', 'music', 'mp3', 'rock', 'soft.mp3', 'pop', 'theme.mp3', 'song.mp3', 'documents', 'xls', 'compilation.xls', 'txt', 'todo.txt', 'pdf', 'phone-bill.pdf', 'map2.pdf', 'map.pdf', 'internet-bill.pdf', 'misc', 'todo.txt', 'bucket-list.txt']; @@ -126,32 +127,24 @@ describe('Example 29 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .contains('Clear all Filters') .click(); - cy.get('#slickGridContainer-grid29') - .find('.slick-row') - .each(($row, index) => { - if (index > defaultSortAscList.length - 1) { - return; - } - cy.wrap($row).children('.slick-cell') - .first() - .should('contain', defaultSortAscList[index]); - }); + defaultSortAscList.forEach((_colName, rowIdx) => { + if (rowIdx > defaultSortAscList.length - 1) { + return; + } + cy.get(`#grid29 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', defaultSortAscList[rowIdx]); + }); }); it('should click on "Files" column to sort descending', () => { cy.get('.slick-header-columns .slick-header-column:nth(0)') .click(); - cy.get('#slickGridContainer-grid29') - .find('.slick-row') - .each(($row, index) => { - if (index > defaultSortDescListWithExtraSongs.length - 1) { - return; - } - cy.wrap($row).children('.slick-cell') - .first() - .should('contain', defaultSortDescListWithExtraSongs[index]); - }); + defaultSortDescListWithExtraSongs.forEach((_colName, rowIdx) => { + if (rowIdx > defaultSortDescListWithExtraSongs.length - 1) { + return; + } + cy.get(`#grid29 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', defaultSortDescListWithExtraSongs[rowIdx]); + }); }); it('should filter the Files by the input search string and expect 4 rows and 1st column to have ', () => { @@ -176,16 +169,12 @@ describe('Example 29 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, cy.get('[data-test=clear-search-string]') .click(); - cy.get('#slickGridContainer-grid29') - .find('.slick-row') - .each(($row, index) => { - if (index > defaultSortAscList.length - 1) { - return; - } - cy.wrap($row).children('.slick-cell') - .first() - .should('contain', defaultSortDescListWithExtraSongs[index]); - }); + defaultSortDescListWithExtraSongs.forEach((_colName, rowIdx) => { + if (rowIdx > defaultSortDescListWithExtraSongs.length - 1) { + return; + } + cy.get(`#grid29 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', defaultSortDescListWithExtraSongs[rowIdx]); + }); }); it('should be able to add a 3rd new pop song into the Music folder and see it show up in the UI', () => {