Skip to content

Commit

Permalink
feat(TreeData): add optional Aggregators to Tree Data grids (#1074)
Browse files Browse the repository at this point in the history
* feat(TreeData): add optional Aggregators to Tree Data grids
- add the possibility to use the same Aggregators that are used in Grouping, however all Aggregators had to be modified to support tree data which has a few differences
- also note that when Aggregators are used with Grouping then the aggregation are under a separate row with `__groupTotals` details... however on Tree Data it will have its totals under `__treeTotals` and also directly on the parent item
- currently only 5 Aggregators are supported with Tree Data: Avg, Sum, Min, Max and Count
- also note that AvgAggregator will automatically give you "avg", "count" and "sum", so if the user needs all 3 then it is a lot better to use Avg for better perf. The other reason why calling Avg+Sum will give you worst perf is that each aggregation is separate and will redo the item count and sum work for each aggregators, so it is better to simply use Avg instead of Avg+Sum
  • Loading branch information
ghiscoding authored Aug 19, 2023
1 parent 839b09a commit 6af5fd1
Show file tree
Hide file tree
Showing 30 changed files with 1,324 additions and 391 deletions.
3 changes: 3 additions & 0 deletions examples/vite-demo-vanilla-bundle/src/examples/example06.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ <h6 class="title is-6 italic">
title="console.log of the Hierarchical Tree dataset">
<span>Log Hierarchical Structure</span>
</button>
<!-- <button onclick.delegate="displaySumAggregator()" class="button is-small">
<span>Show Sum Aggregator only</span>
</button> -->
</div>
<div class="column is-4">
<div class="field is-horizontal">
Expand Down
10 changes: 10 additions & 0 deletions examples/vite-demo-vanilla-bundle/src/examples/example06.scss
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@
@import '@slickgrid-universal/common/dist/styles/sass/sass-utilities.scss';

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

.mdi-file-pdf-outline {
/** 1. use `filter` color */
// filter: invert(62%) sepia(93%) saturate(5654%) hue-rotate(325deg) brightness(100%) contrast(90%);
Expand Down
134 changes: 110 additions & 24 deletions examples/vite-demo-vanilla-bundle/src/examples/example06.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {
Aggregators,
Column,
GridOption,
decimalFormatted,
FieldType,
Filters,
findItemInTreeStructure,
Formatter,
Formatters,
GridOption,
isNumber,
SlickDataView,
// GroupTotalFormatters,
// italicFormatter,
} from '@slickgrid-universal/common';
import { ExcelExportService } from '@slickgrid-universal/excel-export';
import { TextExportService } from '@slickgrid-universal/text-export';
import { Slicker, SlickVanillaGridBundle } from '@slickgrid-universal/vanilla-bundle';

import './example06.scss';
Expand Down Expand Up @@ -60,7 +66,50 @@ export default class Example6 {
type: FieldType.number, exportWithFormatter: true,
excelExportOptions: { autoDetectCellFormat: false },
filterable: true, filter: { model: Filters.compoundInputNumber },
formatter: (_row, _cell, value) => isNaN(value) ? '' : `${value} 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
// IMPORTANT: DO NOT USE Formatters.multiple (that will fail), Formatters.treeParseTotals already accepts multiple params.formatters and IT MUST BE the Formatter entry point

// formatter: Formatters.treeParseTotals,
// treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold,
// // groupTotalsFormatter: GroupTotalFormatters.sumTotalsBold,
// params: {
// formatters: [
// // providing extra formatters for regular cell dataContext, it will only be used when `__treeTotals` is NOT detected (so when it's not a Tree Data total)
// (_row, _cell, value) => isNaN(value) ? '' : `${decimalFormatted(value, 0, 2)} MB`,
// italicFormatter,
// ],
// // 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.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 All @@ -75,10 +124,15 @@ export default class Example6 {
exportWithFormatter: true,
sanitizeDataExport: true
},
enableTextExport: true,
textExportOptions: {
exportWithFormatter: true,
sanitizeDataExport: true
},
gridMenu: {
iconCssClass: 'mdi mdi-dots-grid',
},
registerExternalResources: [new ExcelExportService()],
registerExternalResources: [new ExcelExportService(), new TextExportService()],
enableFiltering: true,
enableTreeData: true, // you must enable this flag for the filtering & sorting to work as expected
multiColumnSort: false, // multi-column sorting is not supported with Tree Data, so you need to disable it
Expand All @@ -96,7 +150,13 @@ export default class Example6 {
// 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') */]
},
showCustomFooter: true,

Expand Down Expand Up @@ -148,32 +208,35 @@ export default class Example6 {
const identifierPropName = dataView.getIdPropertyName() || 'id';
const idx = dataView.getIdxById(dataContext[identifierPropName]) as number;
const prefix = this.getFileIcon(value);
const treeLevel = dataContext[treeLevelPropName];

value = value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const spacer = `<span style="display:inline-block; width:${(15 * dataContext[treeLevelPropName])}px;"></span>`;
const spacer = `<span style="display:inline-block; width:${(15 * treeLevel)}px;"></span>`;

if (data[idx + 1] && data[idx + 1][treeLevelPropName] > data[idx][treeLevelPropName]) {
const folderPrefix = `<i class="mdi icon ${dataContext.__collapsed ? 'mdi-folder' : 'mdi-folder-open'}"></i>`;
if (data[idx + 1]?.[treeLevelPropName] > data[idx][treeLevelPropName] || data[idx]['__hasChildren']) {
const folderPrefix = `<i class="mdi mdi-22px ${dataContext.__collapsed ? 'mdi-folder' : 'mdi-folder-open'}"></i>`;
if (dataContext.__collapsed) {
return `${spacer} <span class="slick-group-toggle collapsed" aria-expanded="false" level="${dataContext[treeLevelPropName]}"></span>${folderPrefix} ${prefix}&nbsp;${value}`;
return `${spacer} <span class="slick-group-toggle collapsed" level="${treeLevel}"></span>${folderPrefix} ${prefix}&nbsp;${value}`;
} else {
return `${spacer} <span class="slick-group-toggle expanded" aria-expanded="true" level="${dataContext[treeLevelPropName]}"></span>${folderPrefix} ${prefix}&nbsp;${value}`;
return `${spacer} <span class="slick-group-toggle expanded" level="${treeLevel}"></span>${folderPrefix} ${prefix}&nbsp;${value}`;
}
} else {
return `${spacer} <span class="slick-group-toggle" aria-expanded="false" level="${dataContext[treeLevelPropName]}"></span>${prefix}&nbsp;${value}`;
return `${spacer} <span class="slick-group-toggle" level="${treeLevel}"></span>${prefix}&nbsp;${value}`;
}
};

getFileIcon(value: string) {
let prefix = '';
if (value.includes('.pdf')) {
prefix = '<i class="mdi icon mdi-file-pdf-outline"></i>';
prefix = '<i class="mdi mdi-20px mdi-file-pdf-outline"></i>';
} else if (value.includes('.txt')) {
prefix = '<i class="mdi icon mdi-file-document-outline"></i>';
} else if (value.includes('.xls')) {
prefix = '<i class="mdi icon mdi-file-excel-outline"></i>';
prefix = '<i class="mdi mdi-20px mdi-file-document-outline"></i>';
} else if (value.includes('.csv') || value.includes('.xls')) {
prefix = '<i class="mdi mdi-20px mdi-file-excel-outline"></i>';
} else if (value.includes('.mp3')) {
prefix = '<i class="mdi icon mdi-file-music-outline"></i>';
prefix = '<i class="mdi mdi-20px mdi-file-music-outline"></i>';
} else if (value.includes('.')) {
prefix = '<i class="mdi mdi-20px mdi-file-question-outline"></i>';
}
return prefix;
}
Expand All @@ -183,7 +246,7 @@ export default class Example6 {
* After adding the item, it will sort by parent/child recursively
*/
addNewFile() {
const newId = this.sgb.dataView!.getItemCount() + 100;
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');
Expand All @@ -193,17 +256,17 @@ export default class Example6 {
id: newId,
file: `pop-${newId}.mp3`,
dateModified: new Date(),
size: Math.floor(Math.random() * 100) + 50,
size: newId + 3,
});

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

// scroll into the position where the item was added with a delay since it needs to recreate the tree grid
setTimeout(() => {
const rowIndex = this.sgb.dataView?.getRowById(popItem.id) as number;
const rowIndex = this.sgb.dataView?.getRowById(newId) as number;
this.sgb.slickGrid?.scrollRowIntoView(rowIndex + 3);
}, 10);
}, 0);
}
}

Expand Down Expand Up @@ -234,12 +297,15 @@ export default class Example6 {
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 @@ -250,8 +316,9 @@ export default class Example6 {
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 All @@ -264,4 +331,23 @@ export default class Example6 {
},
];
}

/**
* for test purposes only, we can dynamically change the loaded Aggregator(s) but we'll have to reload the dataset
* also note that it bypasses the grid preset which mean that "pdf" will not be collapsed when called this way
*/
displaySumAggregatorOnly() {
this.sgb.slickGrid!.setOptions({
treeDataOptions: {
columnId: 'file',
childrenPropName: 'files',
excludeChildrenWhenFilteringTree: this.isExcludingChildWhenFiltering, // defaults to false
autoApproveParentItemWhenTreeColumnIsValid: this.isAutoApproveParentItemWhenTreeColumnIsValid,
aggregators: [new Aggregators.Sum('size')]
}
});

// reset dataset to clear all tree data stat mutations (basically recreate the grid entirely to start from scratch)
this.sgb.datasetHierarchical = this.mockDataset();
}
}
Loading

0 comments on commit 6af5fd1

Please sign in to comment.