Skip to content

Commit

Permalink
feat(TreeData): add optional Aggregators for Tree Data totals calc (#191
Browse files Browse the repository at this point in the history
)
  • Loading branch information
ghiscoding committed Aug 21, 2023
1 parent 4ca5cd6 commit 26bfac5
Show file tree
Hide file tree
Showing 6 changed files with 657 additions and 337 deletions.
22 changes: 11 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,11 @@
]
},
"dependencies": {
"@slickgrid-universal/common": "3.1.0",
"@slickgrid-universal/custom-footer-component": "3.1.0",
"@slickgrid-universal/empty-warning-component": "3.1.0",
"@slickgrid-universal/common": "3.2.0",
"@slickgrid-universal/custom-footer-component": "3.2.0",
"@slickgrid-universal/empty-warning-component": "3.2.0",
"@slickgrid-universal/event-pub-sub": "3.1.0",
"@slickgrid-universal/pagination-component": "3.1.0",
"@slickgrid-universal/pagination-component": "3.2.0",
"dequal": "^2.0.3",
"dompurify": "^3.0.5",
"font-awesome": "^4.7.0",
Expand All @@ -110,13 +110,13 @@
"@fnando/sparkline": "^0.3.10",
"@popperjs/core": "^2.11.8",
"@release-it/conventional-changelog": "^7.0.0",
"@slickgrid-universal/composite-editor-component": "3.1.0",
"@slickgrid-universal/custom-tooltip-plugin": "3.1.0",
"@slickgrid-universal/excel-export": "3.1.0",
"@slickgrid-universal/graphql": "3.1.0",
"@slickgrid-universal/odata": "3.1.0",
"@slickgrid-universal/rxjs-observable": "3.1.0",
"@slickgrid-universal/text-export": "3.1.0",
"@slickgrid-universal/composite-editor-component": "3.2.0",
"@slickgrid-universal/custom-tooltip-plugin": "3.2.0",
"@slickgrid-universal/excel-export": "3.2.0",
"@slickgrid-universal/graphql": "3.2.0",
"@slickgrid-universal/odata": "3.2.0",
"@slickgrid-universal/rxjs-observable": "3.2.0",
"@slickgrid-universal/text-export": "3.2.0",
"@testing-library/jest-dom": "^6.0.1",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
Expand Down
168 changes: 146 additions & 22 deletions src/examples/slickgrid/Example28.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { ExcelExportService } from '@slickgrid-universal/excel-export';
import {
SlickgridReactInstance,
Aggregators,
Column,
decimalFormatted,
FieldType,
Filters,
findItemInTreeStructure,
Formatter,
Formatters,
GridOption,
findItemInTreeStructure,
isNumber,
// GroupTotalFormatters,
// italicFormatter,
SlickgridReact,
SlickGrid,
SlickgridReactInstance,
TreeToggledItem,
} from '../../slickgrid-react';
import React from 'react';
Expand All @@ -25,12 +30,16 @@ interface State extends BaseSlickGridState {
treeToggleItems: TreeToggledItem[];
isExcludingChildWhenFiltering: boolean;
isAutoApproveParentItemWhenTreeColumnIsValid: boolean;
isAutoRecalcTotalsOnFilterChange: boolean;
isRemoveLastInsertedPopSongDisabled: boolean;
lastInsertedPopSongId: number | undefined;
}

export default class Example28 extends React.Component<Props, State> {
title = 'Example 28: Tree Data <small>(from a Hierarchical Dataset)</small>';
subTitle = `<ul>
<li><b>NOTE:</b> The grid will automatically sort Ascending with the column that has the Tree Data, you could add a "sortByFieldId" in your column "treeData" option if you wish to sort on a different column</li>
<li><b>NOTE #1:</b> The grid will automatically sort Ascending with the column that has the Tree Data, you could add a "sortByFieldId" in your column "treeData" option if you wish to sort on a different column</li>
<li><b>NOTE #2:</b> Tree Totals are only calculated once and are <b>NOT</b> recalculated while filtering data, if you do want that feature then you will need to enable <code>autoRecalcTotalsOnFilterChange</code> <i>(see checkbox below)</i></li>
<li><b>Styling - Salesforce Theme</b></li>
<ul>
<li>The Salesforce Theme was created with SASS and compiled in CSS (<a href="https://github.com/slickgrid-universal/slickgrid-universal/blob/master/packages/common/src/styles/slickgrid-theme-salesforce.scss" target="_blank">slickgrid-theme-salesforce.scss</a>), you can override any of its SASS variables</li>
Expand All @@ -49,6 +58,9 @@ export default class Example28 extends React.Component<Props, State> {
datasetHierarchical: undefined,
isExcludingChildWhenFiltering: false,
isAutoApproveParentItemWhenTreeColumnIsValid: true,
isAutoRecalcTotalsOnFilterChange: false,
isRemoveLastInsertedPopSongDisabled: true,
lastInsertedPopSongId: undefined,
isLargeDataset: false,
hasNoExpandCollapseChanged: true,
loadingClass: '',
Expand Down Expand Up @@ -88,8 +100,44 @@ export default class Example28 extends React.Component<Props, State> {
{
id: 'size', name: 'Size', field: 'size', minWidth: 90,
type: FieldType.number, exportWithFormatter: true,
excelExportOptions: { autoDetectCellFormat: false },
filterable: true, filter: { model: Filters.compoundInputNumber },
formatter: (_row, _cell, value) => isNaN(value) ? '' : `${value || 0} MB`,

// Formatter option #1 (treeParseTotalFormatters)
// if you wish to use any of the GroupTotalFormatters (or even regular Formatters), we can do so with the code below
// use `treeTotalsFormatter` or `groupTotalsFormatter` to show totals in a Tree Data grid
// provide any regular formatters inside the params.formatters

// formatter: Formatters.treeParseTotals,
// treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold,
// // groupTotalsFormatter: GroupTotalFormatters.sumTotalsBold,
// params: {
// // we can also supply extra params for Formatters/GroupTotalFormatters like min/max decimals
// groupFormatterSuffix: ' MB', minDecimal: 0, maxDecimal: 2,
// },

// OR option #2 (custom Formatter)
formatter: (_row, _cell, value, column, dataContext) => {
// parent items will a "__treeTotals" property (when creating the Tree and running Aggregation, it mutates all items, all extra props starts with "__" prefix)
const fieldId = column.field;

// Tree Totals, if exists, will be found under `__treeTotals` prop
if (dataContext?.__treeTotals !== undefined) {
const treeLevel = dataContext[this.state.gridOptions!.treeDataOptions?.levelPropName || '__treeLevel'];
const sumVal = dataContext?.__treeTotals?.['sum'][fieldId];
const avgVal = dataContext?.__treeTotals?.['avg'][fieldId];

if (avgVal !== undefined && sumVal !== undefined) {
// when found Avg & Sum, we'll display both
return isNaN(sumVal) ? '' : `<span class="color-primary bold">sum: ${decimalFormatted(sumVal, 0, 2)} MB</span> / <span class="avg-total">avg: ${decimalFormatted(avgVal, 0, 2)} MB</span> <span class="total-suffix">(${treeLevel === 0 ? 'total' : 'sub-total'})</span>`;
} else if (sumVal !== undefined) {
// or when only Sum is aggregated, then just show Sum
return isNaN(sumVal) ? '' : `<span class="color-primary bold">sum: ${decimalFormatted(sumVal, 0, 2)} MB</span> <span class="total-suffix">(${treeLevel === 0 ? 'total' : 'sub-total'})</span>`;
}
}
// reaching this line means it's a regular dataContext without totals, so regular formatter output will be used
return !isNumber(value) ? '' : `${value} MB`;
},
},
];

Expand Down Expand Up @@ -123,7 +171,20 @@ export default class Example28 extends React.Component<Props, State> {
// initialSort: {
// columnId: 'file',
// direction: 'DESC'
// }
// },

// Aggregators are also supported and must always be an array even when single one is provided
// 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') */],

// 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.state.isAutoRecalcTotalsOnFilterChange,

// add optional debounce time to limit number of execution that recalc is called, mostly useful on large dataset
// autoRecalcTotalsDebounce: 250
},
// change header/cell row height for salesforce theme
headerRowHeight: 35,
Expand Down Expand Up @@ -154,6 +215,19 @@ export default class Example28 extends React.Component<Props, State> {
return true;
}

changeAutoRecalcTotalsOnFilterChange() {
const isAutoRecalcTotalsOnFilterChange = !this.state.isAutoRecalcTotalsOnFilterChange;
this.setState((state: State) => ({ ...state, isAutoRecalcTotalsOnFilterChange }));

this.state.gridOptions!.treeDataOptions!.autoRecalcTotalsOnFilterChange = isAutoRecalcTotalsOnFilterChange;
this.reactGrid.slickGrid.setOptions(this.state.gridOptions!);

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

changeExcludeChildWhenFiltering() {
const isExcludingChildWhenFiltering = !this.state.isExcludingChildWhenFiltering;
this.setState((state: State) => ({ ...state, isExcludingChildWhenFiltering }));
Expand All @@ -177,7 +251,7 @@ export default class Example28 extends React.Component<Props, State> {
this.reactGrid.filterService.updateFilters([{ columnId: 'file', searchTerms: [val] }], true, false, true);
}

treeFormatter(_row: number, _cell: number, value: any, _columnDef: Column, dataContext: any, grid: SlickGrid) {
treeFormatter: Formatter = (_row, _cell, value, _columnDef, dataContext, grid) => {
const gridOptions = grid.getOptions() as GridOption;
const treeLevelPropName = gridOptions.treeDataOptions && gridOptions.treeDataOptions.levelPropName || '__treeLevel';
if (value === null || value === undefined || dataContext === undefined) {
Expand All @@ -192,7 +266,7 @@ export default class Example28 extends React.Component<Props, State> {
value = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const spacer = `<span style="display:inline-block; width:${(15 * dataContext[treeLevelPropName])}px;"></span>`;

if (data[idx + 1] && data[idx + 1][treeLevelPropName] > data[idx][treeLevelPropName]) {
if (data[idx + 1]?.[treeLevelPropName] > data[idx][treeLevelPropName] || data[idx]['__hasChildren']) {
const folderPrefix = `<span class="text-warning fa ${dataContext.__collapsed ? 'fa-folder' : 'fa-folder-open'}"></span>`;
if (dataContext.__collapsed) {
return `${spacer} <span class="slick-group-toggle collapsed" level="${dataContext[treeLevelPropName]}"></span>${folderPrefix} ${prefix}&nbsp;${value}`;
Expand All @@ -202,7 +276,7 @@ export default class Example28 extends React.Component<Props, State> {
} else {
return `${spacer} <span class="slick-group-toggle" level="${dataContext[treeLevelPropName]}"></span>${prefix}&nbsp;${value}`;
}
}
};

getFileIcon(value: string) {
let prefix = '';
Expand All @@ -223,31 +297,61 @@ export default class Example28 extends React.Component<Props, State> {
* After adding the item, it will sort by parent/child recursively
*/
addNewFile() {
const newId = this.reactGrid.dataView.getLength() + 100;
const newId = this.reactGrid.dataView.getLength() + 50;

// find first parent object and add the new item as a child
const tmpDatasetHierarchical = [...this.state?.datasetHierarchical ?? []];
const popItem = findItemInTreeStructure(tmpDatasetHierarchical, x => x.file === 'pop', 'files');
const popFolderItem = findItemInTreeStructure(tmpDatasetHierarchical, 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: Math.floor(Math.random() * 100) + 50,
size: newId + 3,
});
this.setState((state: State) => ({
...state,
lastInsertedPopSongId: newId,
isRemoveLastInsertedPopSongDisabled: false,

// overwrite hierarchical dataset which will also trigger a grid sort and rendering
this.setState((state: State) => ({ ...state, datasetHierarchical: tmpDatasetHierarchical }));
// overwrite hierarchical dataset which will also trigger a grid sort and rendering
datasetHierarchical: tmpDatasetHierarchical,
}));

// scroll into the position, after insertion cycle, where the item was added
setTimeout(() => {
const rowIndex = this.reactGrid.dataView.getRowById(popItem.id) as number;
const rowIndex = this.reactGrid.dataView.getRowById(popFolderItem.id) as number;
this.reactGrid.slickGrid.scrollRowIntoView(rowIndex + 3);
}, 10);
}
}

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

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

// overwrite hierarchical dataset which will also trigger a grid sort and rendering
datasetHierarchical: tmpDatasetHierarchical,
}));
}
}
}

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

collapseAll() {
this.reactGrid.treeDataService.toggleTreeDataCollapse(true);
}
Expand Down Expand Up @@ -275,12 +379,15 @@ export default class Example28 extends React.Component<Props, State> {
id: 4, file: 'pdf', files: [
{ id: 22, file: 'map2.pdf', dateModified: '2015-07-21T08:22:00.123Z', size: 2.9, },
{ 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: 6, file: 'internet-bill.pdf', dateModified: '2015-05-12T14:50:00.123Z', size: 1.3, },
{ id: 23, file: 'phone-bill.pdf', dateModified: '2015-05-01T07:50:00.123Z', size: 1.5, },
]
},
{ 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', description: 'movie compilation', dateModified: '2014-10-02T14:50:00.123Z', size: 2.3, }] },
{ id: 9, file: 'misc', files: [{ id: 10, file: 'warranties.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, }] },
{ id: 55, file: 'unclassified.csv', dateModified: '2015-04-08T03:44:12.333Z', size: 0.25, },
{ id: 56, file: 'unresolved.csv', dateModified: '2015-04-03T03:21:12.000Z', size: 0.79, },
{ id: 57, file: 'zebra.dll', dateModified: '2016-12-08T13:22:12.432', size: 1.22, },
]
},
{
Expand All @@ -291,8 +398,9 @@ export default class Example28 extends React.Component<Props, State> {
id: 14, file: 'pop', files: [
{ id: 15, file: 'theme.mp3', description: 'Movie Theme Song', dateModified: '2015-03-01T17:05:00Z', size: 47, },
{ id: 25, file: 'song.mp3', description: 'it is a song...', dateModified: '2016-10-04T06:33:44Z', size: 6.3, }
]
],
},
{ id: 33, file: 'other', files: [] }
]
}]
},
Expand Down Expand Up @@ -327,6 +435,10 @@ export default class Example28 extends React.Component<Props, State> {
<span className="fa fa-plus me-1"></span>
<span>Add New Pop Song</span>
</button>
<button onClick={() => this.deleteFile()} data-test="remove-item-btn" className="btn btn-outline-secondary btn-sm" disabled={this.state.isRemoveLastInsertedPopSongDisabled}>
<span className="fa fa-minus me-1"></span>
<span>Remove Last Inserted Pop Song</span>
</button>
<button onClick={() => this.collapseAll()} data-test="collapse-all-btn" className="btn btn-outline-secondary btn-sm mx-1">
<span className="fa fa-compress me-1"></span>
<span>Collapse All</span>
Expand All @@ -335,6 +447,10 @@ export default class Example28 extends React.Component<Props, State> {
<span className="fa fa-expand me-1"></span>
<span>Expand All</span>
</button>
<button className='btn btn-outline-secondary btn-sm' data-test="clear-filters-btn" onClick={() => this.reactGrid.filterService.clearFilters()}>
<span className="fa fa-close me-1"></span>
<span>Clear Filters</span>
</button>
<button onClick={() => this.logFlatStructure()} className="btn btn-outline-secondary btn-sm mx-1">
<span>Log Flat Structure</span>
</button>
Expand Down Expand Up @@ -374,6 +490,14 @@ export default class Example28 extends React.Component<Props, State> {
Skip Other Filter Criteria when Parent with Tree is valid
</span>
</label>
<label className="checkbox-inline control-label" htmlFor="autoRecalcTotalsOnFilterChange" style={{ marginLeft: '20px' }}>
<input type="checkbox" id="autoRecalcTotalsOnFilterChange" data-test="auto-recalc-totals" className="me-1"
defaultChecked={this.state.isAutoRecalcTotalsOnFilterChange}
onClick={() => this.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>

<br />
Expand Down
10 changes: 10 additions & 0 deletions src/examples/slickgrid/example28.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@
width: 1.5rem;
}

.avg-total {
color: #ac76ff;
}
.bold {
font-weight: bold;
}
.total-suffix {
margin-left: 10px;
}

/** You can use the following code OR use the .color-x CSS classes */

// .icon.mdi-file-pdf-outline {
Expand Down
2 changes: 1 addition & 1 deletion test/cypress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export default defineConfig({
projectId: 'wmnjof',
video: false,
viewportWidth: 1200,
viewportHeight: 950,
viewportHeight: 1020,
fixturesFolder: 'test/cypress/fixtures',
screenshotsFolder: 'test/cypress/screenshots',
videosFolder: 'test/cypress/videos',
Expand Down
Loading

0 comments on commit 26bfac5

Please sign in to comment.