Skip to content

Commit

Permalink
feat(TreeData): add auto-recalc feature for Tree Totals w/Aggregators (
Browse files Browse the repository at this point in the history
…#1084)

* feat(TreeData): add auto-recalc feature for Tree Totals w/Aggregators
- add a new auto-recalc tree totals feature which is disabled by default, this new feature comes with 2 new properties
  - `autoRecalcTotalsOnFilterChange` to enabled the feature
  - `autoRecalcTotalsDebounce` to limit recalc execution when used with large tree dataset
- add more functionalities into Example 6 to not just add new file but also remove last inserted song
  • Loading branch information
ghiscoding authored Aug 19, 2023
1 parent 6af5fd1 commit e884c03
Show file tree
Hide file tree
Showing 13 changed files with 730 additions and 298 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export default class Example1 {
...this.gridOptions1,
...{
gridHeight: 255,
headerRowHeight: 40,
columnPicker: {
onColumnsChanged: (_e, args) => console.log('onColumnPickerColumnsChanged - visible columns count', args.visibleColumns.length),
},
Expand Down
17 changes: 17 additions & 0 deletions examples/vite-demo-vanilla-bundle/src/examples/example06.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ <h6 class="title is-6 italic">
<span class="icon mdi mdi-plus"></span>
<span>Add New Pop Song</span>
</button>
<button onclick.delegate="deleteFile()" class="button is-small" data-test="remove-item-btn" disabled.bind="isRemoveLastInsertedPopSongDisabled">
<span class="icon mdi mdi-minus"></span>
<span>Remove Last Inserted Pop Song</span>
</button>
<button onclick.delegate="collapseAll()" class="button is-small" data-test="collapse-all-btn">
<span class="icon mdi mdi-arrow-collapse"></span>
<span>Collapse All</span>
Expand All @@ -28,6 +32,10 @@ <h6 class="title is-6 italic">
<span class="icon mdi mdi-arrow-expand"></span>
<span>Expand All</span>
</button>
<button class="button is-small" data-test="clear-filters-btn" onclick.delegate="clearFilters()">
<span class="icon mdi mdi-close"></span>
<span>Clear Filters</span>
</button>
<button onclick.delegate="logFlatStructure()" class="button is-small" title="console.log of the Flat dataset">
<span>Log Flat Structure</span>
</button>
Expand Down Expand Up @@ -84,6 +92,15 @@ <h6 class="title is-6 italic">
Skip Other Filter Criteria when Parent with Tree is valid
</span>
</label>
<label class="checkbox-inline control-label" for="autoRecalcTotalsOnFilterChange" style="margin-left: 20px">
<input type="checkbox" id="autoRecalcTotalsOnFilterChange" data-test="auto-recalc-totals"
checked.bind="isAutoRecalcTotalsOnFilterChange"
onclick.delegate="changeAutoRecalcTotalsOnFilterChange()">
<span
title="Should we recalculate Tree Data Totals (when Aggregators are defined) while filtering? This feature is disabled by default.">
auto-recalc Tree Data totals on filter changed
</span>
</label>
</div>

<div class="grid6">
Expand Down
52 changes: 48 additions & 4 deletions examples/vite-demo-vanilla-bundle/src/examples/example06.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export default class Example6 {
durationOrderByCount = false;
isExcludingChildWhenFiltering = false;
isAutoApproveParentItemWhenTreeColumnIsValid = true;
isAutoRecalcTotalsOnFilterChange = false;
isRemoveLastInsertedPopSongDisabled = true;
lastInsertedPopSongId: number | undefined;
searchString = '';

attached() {
Expand Down Expand Up @@ -156,7 +159,14 @@ export default class Example6 {
// Note: only 5 are currently supported: Avg, Sum, Min, Max and Count
// Note 2: also note that Avg Aggregator will automatically give you the "avg", "count" and "sum" so if you need these 3 then simply calling Avg will give you better perf
// aggregators: [new Aggregators.Sum('size')]
aggregators: [new Aggregators.Avg('size'), new Aggregators.Sum('size') /* , new Aggregators.Min('size'), new Aggregators.Max('size') */]
aggregators: [new Aggregators.Avg('size'), new Aggregators.Sum('size') /* , new Aggregators.Min('size'), new Aggregators.Max('size') */],

// should we auto-recalc Tree Totals (when using Aggregators) anytime a filter changes
// it is disabled by default for perf reason, by default it will only calculate totals on first load
autoRecalcTotalsOnFilterChange: this.isAutoRecalcTotalsOnFilterChange,

// add optional debounce time to limit number of execution that recalc is called, mostly useful on large dataset
// autoRecalcTotalsDebounce: 750
},
showCustomFooter: true,

Expand All @@ -175,6 +185,17 @@ export default class Example6 {
return true;
}

changeAutoRecalcTotalsOnFilterChange() {
this.isAutoRecalcTotalsOnFilterChange = !this.isAutoRecalcTotalsOnFilterChange;
this.gridOptions.treeDataOptions!.autoRecalcTotalsOnFilterChange = this.isAutoRecalcTotalsOnFilterChange;
this.sgb.slickGrid?.setOptions(this.gridOptions);

// since it doesn't take current filters in consideration, we better clear them
this.sgb.filterService.clearFilters();
this.sgb.treeDataService.enableAutoRecalcTotalsFeature();
return true;
}

changeExcludeChildWhenFiltering() {
this.isExcludingChildWhenFiltering = !this.isExcludingChildWhenFiltering;
this.gridOptions.treeDataOptions!.excludeChildrenWhenFilteringTree = this.isExcludingChildWhenFiltering;
Expand Down Expand Up @@ -249,15 +270,17 @@ export default class Example6 {
const newId = this.sgb.dataView!.getItemCount() + 50;

// find first parent object and add the new item as a child
const popItem = findItemInTreeStructure(this.datasetHierarchical, x => x.file === 'pop', 'files');
const popFolderItem = findItemInTreeStructure(this.datasetHierarchical, x => x.file === 'pop', 'files');

if (popItem && Array.isArray(popItem.files)) {
popItem.files.push({
if (popFolderItem && Array.isArray(popFolderItem.files)) {
popFolderItem.files.push({
id: newId,
file: `pop-${newId}.mp3`,
dateModified: new Date(),
size: newId + 3,
});
this.lastInsertedPopSongId = newId;
this.isRemoveLastInsertedPopSongDisabled = false;

// overwrite hierarchical dataset which will also trigger a grid sort and rendering
this.sgb.datasetHierarchical = this.datasetHierarchical;
Expand All @@ -270,6 +293,27 @@ export default class Example6 {
}
}

deleteFile() {
const popFolderItem = findItemInTreeStructure(this.datasetHierarchical, x => x.file === 'pop', 'files');
const songItemFound = findItemInTreeStructure(this.datasetHierarchical, x => x.id === this.lastInsertedPopSongId, 'files');

if (popFolderItem && songItemFound) {
const songIdx = popFolderItem.files.findIndex(f => f.id === songItemFound.id);
if (songIdx >= 0) {
popFolderItem.files.splice(songIdx, 1);
this.lastInsertedPopSongId = undefined;
this.isRemoveLastInsertedPopSongDisabled = true;

// overwrite hierarchical dataset which will also trigger a grid sort and rendering
this.sgb.datasetHierarchical = this.datasetHierarchical;
}
}
}

clearFilters() {
this.sgb.filterService.clearFilters();
}

collapseAll() {
this.sgb.treeDataService.toggleTreeDataCollapse(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,6 @@ export default class Example7 {
this.sgb.filterService.clearFilters();
}


allFilters() {
const grid = this.sgb;
const modalHtml = `<div id="modal-allFilter" class="modal is-active">
Expand Down
18 changes: 12 additions & 6 deletions packages/common/src/interfaces/treeDataOption.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import type { Aggregator } from './aggregator.interface';
import type { Formatter } from './formatter.interface';

export interface TreeDataOption {
/** Tree Data Aggregators array that can be provided to aggregate the tree (avg, sum, ...) */
aggregators?: Aggregator[];

/** Defaults to 0, optional debounce to limit the number of recalc execution (when enabled) of Tree Totals (Aggregators), this is especially useful with large tree dataset. */
autoRecalcTotalsDebounce?: number;

/** Defaults to false, should we recalculate aggregator tree totals on a filter changed triggered */
autoRecalcTotalsOnFilterChange?: boolean;

/** Column Id of which column in the column definitions has the Tree Data, there can only be one with a Tree Data. */
columnId: string;

Expand All @@ -26,9 +35,6 @@ export interface TreeDataOption {
*/
excludeChildrenWhenFilteringTree?: boolean;

/** Grouping Aggregators array */
aggregators?: Aggregator[];

/** Optionally define the initial sort column and direction */
initialSort?: {
/** Column Id of the initial Sort */
Expand Down Expand Up @@ -56,12 +62,12 @@ export interface TreeDataOption {
*/
identifierPropName?: string;

/** Defaults to "__parentId", object property name used to designate the Parent Id */
parentPropName?: string;

/** Defaults to "__treeLevel", object property name used to designate the Tree Level depth number */
levelPropName?: string;

/** Defaults to "__parentId", object property name used to designate the Parent Id */
parentPropName?: string;

/**
* Defaults to 15px, margin to add from the left (calculated by the tree level multiplied by this number).
* For example if tree depth level is 2, the calculation will be (2 * 15 = 30), so the column will be displayed 30px from the left
Expand Down
59 changes: 55 additions & 4 deletions packages/common/src/services/__tests__/filter.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,7 +431,7 @@ describe('FilterService', () => {
tmpDivElm.className = 'some-classes';
const inputEvent = new Event('input');
Object.defineProperty(inputEvent, 'target', { writable: true, configurable: true, value: tmpDivElm });
service.getFiltersMetadata()[0].callback(inputEvent, { columnDef: mockColumn, operator: 'EQ', searchTerms: [''], shouldTriggerQuery: true, target: tmpDivElm });
service.getFiltersMetadata()[0].callback(inputEvent, { columnDef: mockColumn, operator: 'EQ', searchTerms: [''], shouldTriggerQuery: true, target: tmpDivElm } as any);

expect(service.getColumnFilters()).toContainEntry(['firstName', expectationColumnFilter]);
expect(spySearchChange).toHaveBeenCalledWith({
Expand Down Expand Up @@ -1073,7 +1073,7 @@ describe('FilterService', () => {
});

it('should return an empty array when column definitions returns nothing as well', () => {
gridStub.getColumns = undefined as any;
gridStub.getColumns = jest.fn().mockReturnValue(undefined);

service.init(gridStub);
const output = service.populateColumnFilterSearchTermPresets(undefined as any);
Expand Down Expand Up @@ -1786,7 +1786,8 @@ describe('FilterService', () => {
});

describe('bindLocalOnFilter method', () => {
let dataset = [];
let dataset: any[] = [];
let datasetHierarchical: any[] = [];
let mockColumn1;
let mockColumn2;
let mockColumn3;
Expand Down Expand Up @@ -1814,7 +1815,26 @@ describe('FilterService', () => {
{ __hasChildren: true, __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 },
] as any;
];

datasetHierarchical = [
{ id: 24, file: 'bucket-list.txt', dateModified: '2012-03-05T12:44:00.123Z', size: 0.5 },
{ id: 18, file: 'something.txt', dateModified: '2015-03-03T03:50:00.123Z', size: 90 },
{
id: 21, file: 'documents', files: [
{ id: 2, file: 'txt', files: [{ id: 3, file: 'todo.txt', dateModified: '2015-05-12T14:50:00.123Z', size: 0.7, }] },
{
id: 4, file: 'pdf', files: [
{ id: 5, file: 'map.pdf', dateModified: '2015-05-21T10:22:00.123Z', size: 3.1, },
{ id: 6, file: 'internet-bill.pdf', dateModified: '2015-05-12T14:50:00.123Z', size: 1.4, },
{ id: 23, file: 'phone-bill.pdf', dateModified: '2015-05-01T07:50:00.123Z', size: 1.4, },
]
},
{ id: 9, file: 'misc', files: [{ id: 10, file: 'todo.txt', dateModified: '2015-02-26T16:50:00.123Z', size: 0.4, }] },
{ id: 7, file: 'xls', files: [{ id: 8, file: 'compilation.xls', dateModified: '2014-10-02T14:50:00.123Z', size: 2.3, }] },
]
},
];

gridOptionMock.enableFiltering = true;
gridOptionMock.backendServiceApi = undefined;
Expand All @@ -1827,6 +1847,7 @@ describe('FilterService', () => {
jest.spyOn(dataViewStub, 'getItems').mockReturnValue(dataset);
jest.spyOn(gridStub, 'getColumns').mockReturnValue([mockColumn1, mockColumn2, mockColumn3]);
sharedService.allColumns = [mockColumn1, mockColumn2, mockColumn3];
jest.spyOn(SharedService.prototype, 'hierarchicalDataset', 'get').mockReturnValue(datasetHierarchical);
});

afterEach(() => {
Expand Down Expand Up @@ -1858,6 +1879,36 @@ describe('FilterService', () => {
expect(preFilterSpy).toHaveReturnedWith(initSetWithValues([21, 4, 5]));
});

it('should return True when item is found and its parent is not collapsed', async () => {
const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish');
const preFilterSpy = jest.spyOn(service, 'preFilterTreeData');
jest.spyOn(dataViewStub, 'getItemById').mockReturnValueOnce({ ...dataset[4] as any, __collapsed: false })
.mockReturnValueOnce(dataset[5])
.mockReturnValueOnce(dataset[6]);

gridOptionMock.treeDataOptions!.autoRecalcTotalsOnFilterChange = true;
const mockItem1 = { __parentId: 4, id: 5, file: 'map.pdf', dateModified: '2015-05-21T10:22:00.123Z', size: 3.1 };

service.init(gridStub);
service.bindLocalOnFilter(gridStub);
gridStub.onHeaderRowCellRendered.notify(mockArgs1 as any, new Slick.EventData(), gridStub);
gridStub.onHeaderRowCellRendered.notify(mockArgs2 as any, new Slick.EventData(), gridStub);

const columnFilters = { file: { columnDef: mockColumn1, columnId: 'file', operator: 'Contains', searchTerms: ['map'], parsedSearchTerms: ['map'], targetSelector: '', type: FieldType.string } } as ColumnFilters;
await service.updateFilters([{ columnId: 'file', operator: '', searchTerms: ['map'] }], true, true, true);
const output = service.customLocalFilter(mockItem1, { dataView: dataViewStub, grid: gridStub, columnFilters });

const pdfFolder = datasetHierarchical[2].files[1];
const mapPdfItem = pdfFolder.files[0];
expect(mapPdfItem.file).toBe('map.pdf');
expect(mapPdfItem.__filteredOut).toBe(false);
expect(pubSubSpy).toHaveBeenCalledWith(`onBeforeFilterChange`, [{ columnId: 'file', operator: 'Contains', searchTerms: ['map',] }]);
expect(pubSubSpy).toHaveBeenCalledWith(`onFilterChanged`, [{ columnId: 'file', operator: 'Contains', searchTerms: ['map',] }]);
expect(output).toBe(true);
expect(preFilterSpy).toHaveBeenCalledWith(dataset, columnFilters);
expect(preFilterSpy).toHaveReturnedWith(initSetWithValues([21, 4, 5]));
});

it('should return False when item is found BUT its parent is collapsed', async () => {
const pubSubSpy = jest.spyOn(pubSubServiceStub, 'publish');
const preFilterSpy = jest.spyOn(service, 'preFilterTreeData');
Expand Down
Loading

0 comments on commit e884c03

Please sign in to comment.