From 6af5fd17b582834b24655b06c34c634a99c93c6e Mon Sep 17 00:00:00 2001 From: Ghislain B Date: Fri, 18 Aug 2023 20:01:50 -0400 Subject: [PATCH] feat(TreeData): add optional Aggregators to Tree Data grids (#1074) * 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 --- .../src/examples/example06.html | 3 + .../src/examples/example06.scss | 10 ++ .../src/examples/example06.ts | 134 ++++++++++++++--- .../__tests__/avgAggregator.spec.ts | 135 +++++++++++------ .../__tests__/cloneAggregator.spec.ts | 84 ++++++----- .../__tests__/countAggregator.spec.ts | 131 ++++++++++------ .../__tests__/distinctAggregator.spec.ts | 96 ++++++------ .../__tests__/maxAggregator.spec.ts | 141 +++++++++++++----- .../__tests__/minAggregator.spec.ts | 141 +++++++++++++----- .../__tests__/sumAggregator.spec.ts | 137 +++++++++++------ .../common/src/aggregators/avgAggregator.ts | 85 +++++++++-- .../common/src/aggregators/cloneAggregator.ts | 11 +- .../common/src/aggregators/countAggregator.ts | 54 ++++++- .../src/aggregators/distinctAggregator.ts | 11 +- .../common/src/aggregators/maxAggregator.ts | 72 ++++++++- .../common/src/aggregators/minAggregator.ts | 72 ++++++++- .../common/src/aggregators/sumAggregator.ts | 73 ++++++++- .../treeParseTotalFormatters.spec.ts | 65 ++++++++ .../common/src/formatters/formatters.index.ts | 12 ++ packages/common/src/formatters/index.ts | 1 + .../src/formatters/multipleFormatter.ts | 2 +- .../formatters/treeParseTotalsFormatter.ts | 32 ++++ .../src/interfaces/aggregator.interface.ts | 7 +- .../common/src/interfaces/column.interface.ts | 6 + .../interfaces/treeDataOption.interface.ts | 4 +- .../src/services/__tests__/utilities.spec.ts | 58 +++++++ .../common/src/services/filter.service.ts | 4 +- packages/common/src/services/grid.service.ts | 1 + packages/common/src/services/utilities.ts | 54 ++++++- test/cypress/e2e/example06.cy.ts | 79 +++++++--- 30 files changed, 1324 insertions(+), 391 deletions(-) create mode 100644 packages/common/src/formatters/__tests__/treeParseTotalFormatters.spec.ts create mode 100644 packages/common/src/formatters/treeParseTotalsFormatter.ts diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example06.html b/examples/vite-demo-vanilla-bundle/src/examples/example06.html index 823d572d6..17bbdfecc 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example06.html +++ b/examples/vite-demo-vanilla-bundle/src/examples/example06.html @@ -35,6 +35,9 @@
title="console.log of the Hierarchical Tree dataset"> Log Hierarchical Structure +
diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example06.scss b/examples/vite-demo-vanilla-bundle/src/examples/example06.scss index 41479f919..5a5b00cb0 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example06.scss +++ b/examples/vite-demo-vanilla-bundle/src/examples/example06.scss @@ -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%); diff --git a/examples/vite-demo-vanilla-bundle/src/examples/example06.ts b/examples/vite-demo-vanilla-bundle/src/examples/example06.ts index c9a217649..e2c9266fc 100644 --- a/examples/vite-demo-vanilla-bundle/src/examples/example06.ts +++ b/examples/vite-demo-vanilla-bundle/src/examples/example06.ts @@ -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'; @@ -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) ? '' : `sum: ${decimalFormatted(sumVal, 0, 2)} MB / avg: ${decimalFormatted(avgVal, 0, 2)} MB (${treeLevel === 0 ? 'total' : 'sub-total'})`; + } else if (sumVal !== undefined) { + // or when only Sum is aggregated, then just show Sum + return isNaN(sumVal) ? '' : `sum: ${decimalFormatted(sumVal, 0, 2)} MB (${treeLevel === 0 ? 'total' : 'sub-total'})`; + } + } + // reaching this line means it's a regular dataContext without totals, so regular formatter output will be used + return !isNumber(value) ? '' : `${value} MB`; + }, }, ]; @@ -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 @@ -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, @@ -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, '&').replace(//g, '>'); - const spacer = ``; + const spacer = ``; - if (data[idx + 1] && data[idx + 1][treeLevelPropName] > data[idx][treeLevelPropName]) { - const folderPrefix = ``; + if (data[idx + 1]?.[treeLevelPropName] > data[idx][treeLevelPropName] || data[idx]['__hasChildren']) { + const folderPrefix = ``; if (dataContext.__collapsed) { - return `${spacer} ${folderPrefix} ${prefix} ${value}`; + return `${spacer} ${folderPrefix} ${prefix} ${value}`; } else { - return `${spacer} ${folderPrefix} ${prefix} ${value}`; + return `${spacer} ${folderPrefix} ${prefix} ${value}`; } } else { - return `${spacer} ${prefix} ${value}`; + return `${spacer} ${prefix} ${value}`; } }; getFileIcon(value: string) { let prefix = ''; if (value.includes('.pdf')) { - prefix = ''; + prefix = ''; } else if (value.includes('.txt')) { - prefix = ''; - } else if (value.includes('.xls')) { - prefix = ''; + prefix = ''; + } else if (value.includes('.csv') || value.includes('.xls')) { + prefix = ''; } else if (value.includes('.mp3')) { - prefix = ''; + prefix = ''; + } else if (value.includes('.')) { + prefix = ''; } return prefix; } @@ -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'); @@ -193,7 +256,7 @@ 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 @@ -201,9 +264,9 @@ export default class Example6 { // 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); } } @@ -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, }, ] }, { @@ -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: [] } ] }] }, @@ -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(); + } } diff --git a/packages/common/src/aggregators/__tests__/avgAggregator.spec.ts b/packages/common/src/aggregators/__tests__/avgAggregator.spec.ts index 75ca8e244..228080f04 100644 --- a/packages/common/src/aggregators/__tests__/avgAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/avgAggregator.spec.ts @@ -2,58 +2,107 @@ import { AvgAggregator } from '../avgAggregator'; describe('avgAggregator', () => { let aggregator: AvgAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, - { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, - { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, - { id: 3, title: 'Task 3', duration: '87', percentComplete: -2 }, - { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, - ] as any; - }); + let dataset: any[] = []; - it('should return undefined when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new AvgAggregator(fieldName); - aggregator.init(); + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, + { id: 3, title: 'Task 3', duration: '87', percentComplete: -2 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, + ]; + }); - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return undefined when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new AvgAggregator(fieldName); + aggregator.init(); - // assert - expect(groupTotals['avg'][fieldName]).toBe(undefined); - }); + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + // assert + expect(aggregator.isInitialized).toBeTruthy(); + expect(groupTotals['avg'][fieldName]).toBe(undefined); + }); + + it('should calculate an average when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { avg: {} }; + aggregator = new AvgAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - it('should calculate an average when the chosen field from the dataset contains only numbers', () => { - const fieldName = 'percentComplete'; - const groupTotals = { avg: {} }; - aggregator = new AvgAggregator(fieldName); - aggregator.init(); + const avg = (55 + 87 + 60 + (-2) + 15) / 5; + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('avg'); + expect(groupTotals.avg[fieldName]).toBe(avg); + }); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should calculate an average with only the valid numbers when dataset contains numbers provided as string and other and invalid char', () => { + const fieldName = 'duration'; + const groupTotals = { avg: {} }; + aggregator = new AvgAggregator(fieldName); + aggregator.init(); - const avg = (55 + 87 + 60 + (-2) + 15) / 5; - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('avg'); - expect(groupTotals.avg[fieldName]).toBe(avg); + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + const avg = (58 + 14 + 87) / 3; + expect(groupTotals.avg[fieldName]).toBe(avg); + }); }); - it('should calculate an average with only the valid numbers when dataset contains numbers provided as tring and other and invalid char', () => { - const fieldName = 'duration'; - const groupTotals = { avg: {} }; - aggregator = new AvgAggregator(fieldName); - aggregator.init(); + describe('Tree Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55, __treeLevel: 0 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87, __treeLevel: 1 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60, __treeLevel: 2 }, + { id: 3, title: 'Task 3', duration: '897', percentComplete: -2, __treeLevel: 0 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15, __treeLevel: 0 }, + ]; + }); + + it('should return the tree data sum value when accumulating an child item', () => { + const fieldName = 'percentComplete'; + aggregator = new AvgAggregator(fieldName); + aggregator.init({}, true); + + // accumulate child to current groupTotals + const groupTotals = { avg: { percentComplete: 55 }, sum: { percentComplete: 200 }, count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4]); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('avg'); + expect(groupTotals.count[fieldName]).toBe(5); + expect(groupTotals.sum[fieldName]).toBe(215); // 200 + last item 15 => 215 + expect(groupTotals.avg[fieldName]).toBe(43); // 215 / 5 => 43 + }); + + it('should return the current sum on the parent item that was accumulated so far', () => { + const fieldName = 'percentComplete'; + aggregator = new AvgAggregator(fieldName); + aggregator.init({}, true); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + // will not accumulate since it's a parent item + const groupTotals = { avg: { percentComplete: 55 }, sum: { percentComplete: 200 }, count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4], true); + aggregator.storeResult(groupTotals); - const avg = (58 + 14 + 87) / 3; - expect(groupTotals.avg[fieldName]).toBe(avg); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('avg'); + expect(groupTotals.count[fieldName]).toBe(4); + expect(groupTotals.sum[fieldName]).toBe(200); + expect(groupTotals.avg[fieldName]).toBe(50); // 200 / 4 => 50 + }); }); }); diff --git a/packages/common/src/aggregators/__tests__/cloneAggregator.spec.ts b/packages/common/src/aggregators/__tests__/cloneAggregator.spec.ts index c707f12cc..3f5388b8d 100644 --- a/packages/common/src/aggregators/__tests__/cloneAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/cloneAggregator.spec.ts @@ -2,45 +2,53 @@ import { CloneAggregator } from '../cloneAggregator'; describe('CloneAggregator', () => { let aggregator: CloneAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Product 0', price: 58.5, productGroup: 'Sub-Cat1' }, - { id: 1, title: 'Product 1', price: 14, productGroup: 'Sub-Cat1' }, - { id: 2, title: 'Product 2', price: 2, productGroup: 'Sub-Cat2' }, - { id: 3, title: 'Product 3', price: 87, productGroup: 'Sub-Cat1' }, - { id: 4, title: 'Product 4', price: null, productGroup: 'Sub-Cat2' }, - ] as any; + let dataset: any[] = []; + + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Product 0', price: 58.5, productGroup: 'Sub-Cat1' }, + { id: 1, title: 'Product 1', price: 14, productGroup: 'Sub-Cat1' }, + { id: 2, title: 'Product 2', price: 2, productGroup: 'Sub-Cat2' }, + { id: 3, title: 'Product 3', price: 87, productGroup: 'Sub-Cat1' }, + { id: 4, title: 'Product 4', price: null, productGroup: 'Sub-Cat2' }, + ]; + }); + + it('should return empty string when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new CloneAggregator(fieldName); + aggregator.init(); + + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + // assert + expect(aggregator.isInitialized).toBeTruthy(); + expect(groupTotals['clone'][fieldName]).toBe(''); + }); + + it('should return last text analyzed by the aggregator when the chosen field is the product group', () => { + const fieldName = 'productGroup'; + const lastGroupName = 'Sub-Cat2'; + const groupTotals = { clone: {} }; + aggregator = new CloneAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('clone'); + expect(groupTotals.clone[fieldName]).toBe(lastGroupName); + }); }); - it('should return empty string when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new CloneAggregator(fieldName); - aggregator.init(); - - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); - - // assert - expect(groupTotals['clone'][fieldName]).toBe(''); - }); - - it('should return last text analyzed by the aggregator when the chosen field is the product group', () => { - const fieldName = 'productGroup'; - const lastGroupName = 'Sub-Cat2'; - const groupTotals = { clone: {} }; - aggregator = new CloneAggregator(fieldName); - aggregator.init(); - - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); - - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('clone'); - expect(groupTotals.clone[fieldName]).toBe(lastGroupName); + describe('Tree Aggregator', () => { + aggregator = new CloneAggregator('title'); + expect(() => aggregator.init({}, true)).toThrow('[Slickgrid-Universal] CloneAggregator is not currently supported for use with Tree Data'); }); }); diff --git a/packages/common/src/aggregators/__tests__/countAggregator.spec.ts b/packages/common/src/aggregators/__tests__/countAggregator.spec.ts index 86311953a..d66b8ce0c 100644 --- a/packages/common/src/aggregators/__tests__/countAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/countAggregator.spec.ts @@ -2,51 +2,96 @@ import { CountAggregator } from '../countAggregator'; describe('CountAggregator', () => { let aggregator: CountAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Product 0', price: 58.5, productGroup: 'Sub-Cat1' }, - { id: 1, title: 'Product 1', price: 14, productGroup: 'Sub-Cat1' }, - { id: 2, title: 'Product 2', price: 2, productGroup: 'Sub-Cat2' }, - { id: 3, title: 'Product 3', price: 87, productGroup: 'Sub-Cat1' }, - { id: 4, title: 'Product 4', price: null, productGroup: 'Sub-Cat2' }, - ] as any; - }); + let dataset: any[] = []; + + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Product 0', price: 58.5, productGroup: 'Sub-Cat1' }, + { id: 1, title: 'Product 1', price: 14, productGroup: 'Sub-Cat1' }, + { id: 2, title: 'Product 2', price: 2, productGroup: 'Sub-Cat2' }, + { id: 3, title: 'Product 3', price: 87, productGroup: 'Sub-Cat1' }, + { id: 4, title: 'Product 4', price: null, productGroup: 'Sub-Cat2' }, + ]; + }); + + it('should return a length of 1 when the dataset found 1 item', () => { + // arrange + const fieldName = 'title'; + const groupTotals = { + group: { + rows: dataset.filter((item) => item['title'] === 'Product 1') + } + }; + aggregator = new CountAggregator(fieldName); + aggregator.init(); + + // act + aggregator.storeResult(groupTotals); + + // assert + expect(aggregator.isInitialized).toBeTruthy(); + expect(groupTotals['count'][fieldName]).toBe(1); + }); + + it('should return a count of the full dataset length when the group has all the same data', () => { + const fieldName = 'productGroup'; + const groupTotals = { + count: {}, + group: { + rows: dataset + } + }; + aggregator = new CountAggregator(fieldName); + aggregator.init(); + + aggregator.storeResult(groupTotals); - it('should return a length of 1 when the dataset found 1 item', () => { - // arrange - const fieldName = 'title'; - const groupTotals = { - group: { - rows: dataset.filter((item) => item['title'] === 'Product 1') - } - }; - aggregator = new CountAggregator(fieldName); - aggregator.init(); - - // act - aggregator.storeResult(groupTotals); - - // assert - expect(groupTotals['count'][fieldName]).toBe(1); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('count'); + expect(groupTotals.count[fieldName]).toBe(5); + }); }); - it('should return a count of the full dataset length when the group has all the same data', () => { - const fieldName = 'productGroup'; - const groupTotals = { - count: {}, - group: { - rows: dataset - } - }; - aggregator = new CountAggregator(fieldName); - aggregator.init(); - - aggregator.storeResult(groupTotals); - - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('count'); - expect(groupTotals.count[fieldName]).toBe(5); + describe('Tree Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55, __treeLevel: 0 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87, __treeLevel: 1 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60, __treeLevel: 2 }, + { id: 3, title: 'Task 3', duration: '897', percentComplete: -2, __treeLevel: 0 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15, __treeLevel: 0 }, + ]; + }); + + it('should return the tree data count value when accumulating an child item', () => { + const fieldName = 'percentComplete'; + aggregator = new CountAggregator(fieldName); + aggregator.init({}, true); + + // accumulate child to current groupTotals + const groupTotals = { count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4]); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('count'); + expect(groupTotals.count[fieldName]).toBe(5); + }); + + it('should return the current count on the parent item that was accumulated so far', () => { + const fieldName = 'percentComplete'; + aggregator = new CountAggregator(fieldName); + aggregator.init({}, true); + + // will not accumulate since it's a parent item + const groupTotals = { count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4], true); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('count'); + expect(groupTotals.count[fieldName]).toBe(4); + }); }); }); diff --git a/packages/common/src/aggregators/__tests__/distinctAggregator.spec.ts b/packages/common/src/aggregators/__tests__/distinctAggregator.spec.ts index 95a07eaf9..24d9b4025 100644 --- a/packages/common/src/aggregators/__tests__/distinctAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/distinctAggregator.spec.ts @@ -2,58 +2,66 @@ import { DistinctAggregator } from '../distinctAggregator'; describe('disctinctAggregator', () => { let aggregator: DistinctAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, - { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, - { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, - { id: 3, title: 'Task 3', duration: '58', percentComplete: 87 }, - { id: 4, title: 'Task 4', duration: null, percentComplete: 55 }, - { id: 4, title: 'Task 5', duration: 32, percentComplete: 52 }, - { id: 4, title: 'Task 6', duration: 58, percentComplete: 52 }, - ] as any; - }); + let dataset: any[] = []; - it('should return empty array when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new DistinctAggregator(fieldName); - aggregator.init(); + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, + { id: 3, title: 'Task 3', duration: '58', percentComplete: 87 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 55 }, + { id: 4, title: 'Task 5', duration: 32, percentComplete: 52 }, + { id: 4, title: 'Task 6', duration: 58, percentComplete: 52 }, + ]; + }); - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return empty array when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new DistinctAggregator(fieldName); + aggregator.init(); - // assert - expect(groupTotals['distinct'][fieldName]).toEqual([]); - }); + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - it('should return the distinct number values when provided field property values are all numbers', () => { - const fieldName = 'percentComplete'; - const groupTotals = { distinct: {} }; - aggregator = new DistinctAggregator(fieldName); - aggregator.init(); + // assert + expect(aggregator.isInitialized).toBeTruthy(); + expect(groupTotals['distinct'][fieldName]).toEqual([]); + }); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return the distinct number values when provided field property values are all numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { distinct: {} }; + aggregator = new DistinctAggregator(fieldName); + aggregator.init(); - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('distinct'); - expect(groupTotals.distinct[fieldName]).toEqual([55, 87, 60, 52]); - }); + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - it('should return the distinct mixed values when provided field property values are all mixed types', () => { - const fieldName = 'duration'; - const groupTotals = { distinct: {} }; - aggregator = new DistinctAggregator(fieldName); - aggregator.init(); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('distinct'); + expect(groupTotals.distinct[fieldName]).toEqual([55, 87, 60, 52]); + }); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return the distinct mixed values when provided field property values are all mixed types', () => { + const fieldName = 'duration'; + const groupTotals = { distinct: {} }; + aggregator = new DistinctAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(groupTotals.distinct[fieldName]).toEqual(['58', '14', '', null, 32, 58]); + }); + }); - expect(groupTotals.distinct[fieldName]).toEqual(['58', '14', '', null, 32, 58]); + describe('Tree Aggregator', () => { + aggregator = new DistinctAggregator('title'); + expect(() => aggregator.init({}, true)).toThrow('[Slickgrid-Universal] CloneAggregator is not currently supported for use with Tree Data'); }); }); diff --git a/packages/common/src/aggregators/__tests__/maxAggregator.spec.ts b/packages/common/src/aggregators/__tests__/maxAggregator.spec.ts index f9d8f01ce..bd5f91af9 100644 --- a/packages/common/src/aggregators/__tests__/maxAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/maxAggregator.spec.ts @@ -2,56 +2,115 @@ import { MaxAggregator } from '../maxAggregator'; describe('maxAggregator', () => { let aggregator: MaxAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, - { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, - { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, - { id: 3, title: 'Task 3', duration: '897', percentComplete: -2 }, - { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, - ] as any; - }); + let dataset: any[] = []; - it('should return null when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new MaxAggregator(fieldName); - aggregator.init(); + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, + { id: 3, title: 'Task 3', duration: '897', percentComplete: -2 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, + ]; + }); - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return null when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new MaxAggregator(fieldName); + aggregator.init(); - // assert - expect(groupTotals['max'][fieldName]).toBe(null); - }); + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + // assert + expect(groupTotals['max'][fieldName]).toBe(null); + expect(aggregator.isInitialized).toBeTruthy(); + }); + + it('should return the maximum value when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { max: {} }; + aggregator = new MaxAggregator(fieldName); + aggregator.init(); - it('should return the maximum value when the chosen field from the dataset contains only numbers', () => { - const fieldName = 'percentComplete'; - const groupTotals = { max: {} }; - aggregator = new MaxAggregator(fieldName); - aggregator.init(); + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('max'); + expect(groupTotals.max[fieldName]).toBe(87); + }); - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('max'); - expect(groupTotals.max[fieldName]).toBe(87); + it('should return the maximum valid number when dataset contains numbers provided as string and other and invalid char', () => { + const fieldName = 'duration'; + const groupTotals = { max: {} }; + aggregator = new MaxAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(groupTotals.max[fieldName]).toBe(897); + }); }); - it('should return the maximum valid number when dataset contains numbers provided as tring and other and invalid char', () => { - const fieldName = 'duration'; - const groupTotals = { max: {} }; - aggregator = new MaxAggregator(fieldName); - aggregator.init(); + describe('Tree Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55, __treeLevel: 0 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87, __treeLevel: 1 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60, __treeLevel: 2 }, + { id: 3, title: 'Task 3', duration: '897', percentComplete: -2, __treeLevel: 0 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15, __treeLevel: 0 }, + ]; + }); + + it('should return the tree data maximum value when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { max: {} }; + aggregator = new MaxAggregator(fieldName); + aggregator.init({}, true); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('max'); + expect(groupTotals.max[fieldName]).toBe(87); + }); + + it('should return null when accumulating on a tree parent and no maximum appears yet in the datacontext parent item', () => { + const fieldName = 'percentComplete'; + const groupTotals: any = {}; + aggregator = new MaxAggregator(fieldName); + aggregator.init({}, true); + + dataset.forEach((row) => aggregator.accumulate(row, true)); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('max'); + expect(groupTotals.max[fieldName]).toBe(null); + }); + + it('should return 99 which is the maximum value found in the Tree on a datacontext parent item', () => { + const fieldName = 'percentComplete'; + const groupTotals = { max: { percentComplete: 99 } }; + aggregator = new MaxAggregator(fieldName); + aggregator.init({ __treeTotals: { max: { percentComplete: 22 } } }, true); + dataset[1].__treeTotals = { max: { percentComplete: 88 } }; + dataset[2].__treeTotals = { max: { percentComplete: 77 } }; - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + dataset.forEach((row) => aggregator.accumulate(row, true)); + aggregator.storeResult(groupTotals); - expect(groupTotals.max[fieldName]).toBe(897); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('max'); + expect(groupTotals.max[fieldName]).toBe(99); + }); }); }); diff --git a/packages/common/src/aggregators/__tests__/minAggregator.spec.ts b/packages/common/src/aggregators/__tests__/minAggregator.spec.ts index 9db594dc2..c57428506 100644 --- a/packages/common/src/aggregators/__tests__/minAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/minAggregator.spec.ts @@ -2,56 +2,115 @@ import { MinAggregator } from '../minAggregator'; describe('minAggregator', () => { let aggregator: MinAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, - { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, - { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, - { id: 3, title: 'Task 3', duration: '91', percentComplete: -2 }, - { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, - ] as any; - }); + let dataset: any[] = []; - it('should return null when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new MinAggregator(fieldName); - aggregator.init(); + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, + { id: 3, title: 'Task 3', duration: '91', percentComplete: -2 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, + ]; + }); - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + it('should return null when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new MinAggregator(fieldName); + aggregator.init(); - // assert - expect(groupTotals['min'][fieldName]).toBe(null); - }); + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + // assert + expect(groupTotals['min'][fieldName]).toBe(null); + expect(aggregator.isInitialized).toBeTruthy(); + }); + + it('should return the minimum value when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { min: {} }; + aggregator = new MinAggregator(fieldName); + aggregator.init(); - it('should return the minimum value when the chosen field from the dataset contains only numbers', () => { - const fieldName = 'percentComplete'; - const groupTotals = { min: {} }; - aggregator = new MinAggregator(fieldName); - aggregator.init(); + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('min'); + expect(groupTotals.min[fieldName]).toBe(-2); + }); - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('min'); - expect(groupTotals.min[fieldName]).toBe(-2); + it('should return the minimum valid number when dataset contains numbers provided as string and other and invalid char', () => { + const fieldName = 'duration'; + const groupTotals = { min: {} }; + aggregator = new MinAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(groupTotals.min[fieldName]).toBe(14); + }); }); - it('should return the minimum valid number when dataset contains numbers provided as tring and other and invalid char', () => { - const fieldName = 'duration'; - const groupTotals = { min: {} }; - aggregator = new MinAggregator(fieldName); - aggregator.init(); + describe('Tree Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55, __treeLevel: 0 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87, __treeLevel: 1 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60, __treeLevel: 2 }, + { id: 3, title: 'Task 3', duration: '91', percentComplete: -2, __treeLevel: 0 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15, __treeLevel: 0 }, + ]; + }); + + it('should return the tree data maximum value when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { min: {} }; + aggregator = new MinAggregator(fieldName); + aggregator.init({}, true); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('min'); + expect(groupTotals.min[fieldName]).toBe(-2); + }); + + it('should return null when accumulating on a tree parent and no minimum appears yet in the datacontext parent item', () => { + const fieldName = 'percentComplete'; + const groupTotals: any = {}; + aggregator = new MinAggregator(fieldName); + aggregator.init({}, true); + + dataset.forEach((row) => aggregator.accumulate(row, true)); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('min'); + expect(groupTotals.min[fieldName]).toBe(null); + }); + + it('should return 88 which is the minimum value found in the Tree on a datacontext parent item', () => { + const fieldName = 'percentComplete'; + const groupTotals = { min: { percentComplete: 55 } }; + aggregator = new MinAggregator(fieldName); + aggregator.init({ __treeTotals: { min: { percentComplete: 22 } } }, true); + dataset[1].__treeTotals = { min: { percentComplete: 99 } }; + dataset[2].__treeTotals = { min: { percentComplete: 88 } }; - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + dataset.forEach((row) => aggregator.accumulate(row, true)); + aggregator.storeResult(groupTotals); - expect(groupTotals.min[fieldName]).toBe(14); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('min'); + expect(groupTotals.min[fieldName]).toBe(55); + }); }); }); diff --git a/packages/common/src/aggregators/__tests__/sumAggregator.spec.ts b/packages/common/src/aggregators/__tests__/sumAggregator.spec.ts index c02c69960..88838b47a 100644 --- a/packages/common/src/aggregators/__tests__/sumAggregator.spec.ts +++ b/packages/common/src/aggregators/__tests__/sumAggregator.spec.ts @@ -2,58 +2,105 @@ import { SumAggregator } from '../sumAggregator'; describe('sumAggregator', () => { let aggregator: SumAggregator; - let dataset = []; - - beforeEach(() => { - dataset = [ - { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, - { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, - { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, - { id: 3, title: 'Task 3', duration: '87', percentComplete: -2 }, - { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, - ] as any; - }); + let dataset: any[] = []; - it('should return null when the field provided does not exist', () => { - // arrange - const fieldName = 'invalid'; - const groupTotals = {}; - aggregator = new SumAggregator(fieldName); - aggregator.init(); - - // act - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); - - // assert - expect(aggregator.field).toBe(fieldName); - expect(aggregator.type).toBe('sum'); - expect(groupTotals['sum'][fieldName]).toBe(0); - }); + describe('Regular Group Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60 }, + { id: 3, title: 'Task 3', duration: '87', percentComplete: -2 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15 }, + ]; + }); + + it('should return null when the field provided does not exist', () => { + // arrange + const fieldName = 'invalid'; + const groupTotals = {}; + aggregator = new SumAggregator(fieldName); + aggregator.init(); + + // act + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + // assert + expect(aggregator.isInitialized).toBeTruthy(); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('sum'); + expect(groupTotals['sum'][fieldName]).toBe(0); + }); + + it('should return the sum value when the chosen field from the dataset contains only numbers', () => { + const fieldName = 'percentComplete'; + const groupTotals = { sum: {} }; + aggregator = new SumAggregator(fieldName); + aggregator.init(); - it('should return the sum value when the chosen field from the dataset contains only numbers', () => { - const fieldName = 'percentComplete'; - const groupTotals = { sum: {} }; - aggregator = new SumAggregator(fieldName); - aggregator.init(); + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + const total = (55 + 87 + 60 + (-2) + 15); + expect(groupTotals.sum[fieldName]).toBe(total); + }); - const avg = (55 + 87 + 60 + (-2) + 15); - expect(groupTotals.sum[fieldName]).toBe(avg); + it('should return the sum valid number when dataset contains numbers provided as string and other and invalid char', () => { + const fieldName = 'duration'; + const groupTotals = { sum: {} }; + aggregator = new SumAggregator(fieldName); + aggregator.init(); + + dataset.forEach((row) => aggregator.accumulate(row)); + aggregator.storeResult(groupTotals); + + const total = 58 + 14 + 87; + expect(groupTotals.sum[fieldName]).toBe(total); + }); }); - it('should return the sum valid number when dataset contains numbers provided as tring and other and invalid char', () => { - const fieldName = 'duration'; - const groupTotals = { sum: {} }; - aggregator = new SumAggregator(fieldName); - aggregator.init(); + describe('Tree Aggregator', () => { + beforeEach(() => { + dataset = [ + { id: 0, title: 'Task 0', duration: '58', percentComplete: 55, __treeLevel: 0 }, + { id: 1, title: 'Task 1', duration: '14', percentComplete: 87, __treeLevel: 1 }, + { id: 2, title: 'Task 2', duration: '', percentComplete: 60, __treeLevel: 2 }, + { id: 3, title: 'Task 3', duration: '897', percentComplete: -2, __treeLevel: 0 }, + { id: 4, title: 'Task 4', duration: null, percentComplete: 15, __treeLevel: 0 }, + ]; + }); + + it('should return the tree data sum value when accumulating an child item', () => { + const fieldName = 'percentComplete'; + aggregator = new SumAggregator(fieldName); + aggregator.init({}, true); + + // accumulate child to current groupTotals + const groupTotals = { sum: { percentComplete: 200 }, count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4]); + aggregator.storeResult(groupTotals); + + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('sum'); + expect(groupTotals.count[fieldName]).toBe(5); + expect(groupTotals.sum[fieldName]).toBe(215); // 200 + last item 15 => 215 + }); + + it('should return the current sum on the parent item that was accumulated so far', () => { + const fieldName = 'percentComplete'; + aggregator = new SumAggregator(fieldName); + aggregator.init({}, true); - dataset.forEach((row) => aggregator.accumulate(row)); - aggregator.storeResult(groupTotals); + // will not accumulate since it's a parent item + const groupTotals = { sum: { percentComplete: 200 }, count: { percentComplete: 4 } }; + aggregator.accumulate(dataset[4], true); + aggregator.storeResult(groupTotals); - const avg = 58 + 14 + 87; - expect(groupTotals.sum[fieldName]).toBe(avg); + expect(aggregator.field).toBe(fieldName); + expect(aggregator.type).toBe('sum'); + expect(groupTotals.count[fieldName]).toBe(4); + expect(groupTotals.sum[fieldName]).toBe(200); + }); }); }); diff --git a/packages/common/src/aggregators/avgAggregator.ts b/packages/common/src/aggregators/avgAggregator.ts index 9bc72f9b4..050744e22 100644 --- a/packages/common/src/aggregators/avgAggregator.ts +++ b/packages/common/src/aggregators/avgAggregator.ts @@ -1,6 +1,10 @@ +import { isNumber } from '@slickgrid-universal/utils'; + import type { Aggregator } from './../interfaces/aggregator.interface'; export class AvgAggregator implements Aggregator { + private _isInitialized = false; + private _isTreeAggregator = false; private _nonNullCount = 0; private _sum = 0; private _field: number | string; @@ -14,29 +18,90 @@ export class AvgAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init(): void { - this._nonNullCount = 0; + init(item?: any, isTreeAggregator = false) { this._sum = 0; + this._nonNullCount = 0; + this._isInitialized = true; + + // when dealing with Tree Data structure, we also need to keep sum & itemCount refs + // also while calculating Avg Aggregator, we could in theory skip completely SumAggregator because we kept the sum already for calculations + this._isTreeAggregator = isTreeAggregator; + if (isTreeAggregator) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + item.__treeTotals.sum = {}; + item.__treeTotals.count = {}; + } + item.__treeTotals[this._type][this._field] = 0; + item.__treeTotals['count'][this._field] = 0; + item.__treeTotals['sum'][this._field] = 0; + } } - accumulate(item: any) { - const val = (item && item.hasOwnProperty(this._field)) ? item[this._field] : null; - if (val !== null && val !== '' && !isNaN(val)) { - this._nonNullCount++; - this._sum += parseFloat(val); + accumulate(item: any, isTreeParent = false) { + const val = item?.hasOwnProperty(this._field) ? item[this._field] : null; + + // when dealing with Tree Data structure, we need keep only the new sum (without doing any addition) + if (!this._isTreeAggregator) { + // not a Tree structure, we'll do a regular summation + if (val !== null && val !== '' && !isNaN(val)) { + this._nonNullCount++; + this._sum += parseFloat(val); + } + } else { + if (isTreeParent) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + this.addGroupTotalPropertiesWhenNotExist(item.__treeTotals); + this._sum = parseFloat(item.__treeTotals['sum'][this._field] ?? 0); + this._nonNullCount = item.__treeTotals['count'][this._field] ?? 0; + } else if (isNumber(val)) { + this._sum = parseFloat(val); + this._nonNullCount = 1; + } } } storeResult(groupTotals: any) { - if (!groupTotals || groupTotals[this._type] === undefined) { + let sum = this._sum; + let itemCount = this._nonNullCount; + this.addGroupTotalPropertiesWhenNotExist(groupTotals); + + // when dealing with Tree Data, we also need to take the parent's total and add it to the final sum + if (this._isTreeAggregator) { + sum += groupTotals['sum'][this._field]; + itemCount += groupTotals['count'][this._field]; + + groupTotals['sum'][this._field] = sum; + groupTotals['count'][this._field] = itemCount; + } + + if (itemCount !== 0) { + groupTotals[this._type][this._field] = itemCount === 0 ? sum : sum / itemCount; + } + } + + protected addGroupTotalPropertiesWhenNotExist(groupTotals: any) { + if (groupTotals[this._type] === undefined) { groupTotals[this._type] = {}; } - if (this._nonNullCount !== 0) { - groupTotals[this._type][this._field] = this._sum / this._nonNullCount; + if (this._isTreeAggregator && groupTotals['sum'] === undefined) { + groupTotals['sum'] = {}; + } + if (this._isTreeAggregator && groupTotals['count'] === undefined) { + groupTotals['count'] = {}; } } } diff --git a/packages/common/src/aggregators/cloneAggregator.ts b/packages/common/src/aggregators/cloneAggregator.ts index d9755f312..a957c1a2f 100644 --- a/packages/common/src/aggregators/cloneAggregator.ts +++ b/packages/common/src/aggregators/cloneAggregator.ts @@ -1,6 +1,7 @@ import type { Aggregator } from './../interfaces/aggregator.interface'; export class CloneAggregator implements Aggregator { + private _isInitialized = false; private _field: number | string; private _data: any; private _type = 'clone'; @@ -13,12 +14,20 @@ export class CloneAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init(): void { + init(_item?: any, isTreeAggregator = false): void { this._data = ''; + this._isInitialized = true; + if (isTreeAggregator) { + throw new Error('[Slickgrid-Universal] CloneAggregator is not currently supported for use with Tree Data'); + } } accumulate(item: any) { diff --git a/packages/common/src/aggregators/countAggregator.ts b/packages/common/src/aggregators/countAggregator.ts index cb9c5b963..ab9a24c85 100644 --- a/packages/common/src/aggregators/countAggregator.ts +++ b/packages/common/src/aggregators/countAggregator.ts @@ -1,7 +1,12 @@ +import { isNumber } from '@slickgrid-universal/utils'; + import type { Aggregator } from './../interfaces/aggregator.interface'; export class CountAggregator implements Aggregator { + private _isInitialized = false; + private _isTreeAggregator = false; private _field: number | string; + private _count = 0; private _type = 'count'; constructor(field: number | string) { @@ -12,17 +17,62 @@ export class CountAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init(): void { + init(item?: any, isTreeAggregator = false) { + this._count = 0; + this._isInitialized = true; + this._isTreeAggregator = isTreeAggregator; + + // when dealing with Tree Data structure, we also need to keep sum & itemCount refs + if (isTreeAggregator) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + } + item.__treeTotals[this._type][this._field] = 0; + } + } + + accumulate(item: any, isTreeParent = false) { + const val = item?.hasOwnProperty(this._field) ? item[this._field] : null; + + // when dealing with Tree Data structure, we need keep only the new sum (without doing any addition) + if (this._isTreeAggregator) { + if (isTreeParent) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + } + this._count = item.__treeTotals[this._type][this._field] ?? 0; + } else if (isNumber(val)) { + this._count = 1; + } + } } storeResult(groupTotals: any) { if (!groupTotals || groupTotals[this._type] === undefined) { groupTotals[this._type] = {}; } - groupTotals[this._type][this._field] = groupTotals.group.rows.length; + let itemCount = this._count; + + if (this._isTreeAggregator) { + // when dealing with Tree Data, we also need to take the parent's total and add it to the final count + itemCount += groupTotals[this._type][this._field]; + } else { + itemCount = groupTotals.group.rows.length; + } + groupTotals[this._type][this._field] = itemCount; } } diff --git a/packages/common/src/aggregators/distinctAggregator.ts b/packages/common/src/aggregators/distinctAggregator.ts index 36e2e8636..1fe77a030 100644 --- a/packages/common/src/aggregators/distinctAggregator.ts +++ b/packages/common/src/aggregators/distinctAggregator.ts @@ -1,6 +1,7 @@ import type { Aggregator } from './../interfaces/aggregator.interface'; export class DistinctAggregator implements Aggregator { + private _isInitialized = false; private _field: number | string; private _distinctValues: any[] = []; private _type = 'distinct'; @@ -13,12 +14,20 @@ export class DistinctAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init(): void { + init(_item?: any, isTreeAggregator = false): void { this._distinctValues = []; + this._isInitialized = true; + if (isTreeAggregator) { + throw new Error('[Slickgrid-Universal] CloneAggregator is not currently supported for use with Tree Data'); + } } accumulate(item: any) { diff --git a/packages/common/src/aggregators/maxAggregator.ts b/packages/common/src/aggregators/maxAggregator.ts index 0e2a52c72..005855519 100644 --- a/packages/common/src/aggregators/maxAggregator.ts +++ b/packages/common/src/aggregators/maxAggregator.ts @@ -1,6 +1,10 @@ +import { isNumber } from '@slickgrid-universal/utils'; + import type { Aggregator } from './../interfaces/aggregator.interface'; export class MaxAggregator implements Aggregator { + private _isInitialized = false; + private _isTreeAggregator = false; private _max: number | null = null; private _field: number | string; private _type = 'max'; @@ -13,27 +17,79 @@ export class MaxAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init(): void { + init(item?: any, isTreeAggregator = false) { this._max = null; + this._isInitialized = true; + + // when dealing with Tree Data structure, we also need to clear any parent totals + this._isTreeAggregator = isTreeAggregator; + if (isTreeAggregator) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + } + item.__treeTotals[this._type][this._field] = null; + } } - accumulate(item: any) { - const val = (item && item.hasOwnProperty(this._field)) ? item[this._field] : null; - if (val !== null && val !== '' && !isNaN(val)) { - if (this._max === null || val > this._max) { - this._max = parseFloat(val); + accumulate(item: any, isTreeParent = false) { + const val = item?.hasOwnProperty(this._field) ? item[this._field] : null; + + // when dealing with Tree Data structure, we need keep only the new max (without doing any addition) + if (!this._isTreeAggregator) { + // not a Tree structure, we'll do a regular maximation + this.keepMaxValueWhenFound(val); + } else { + if (isTreeParent) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + this.addGroupTotalPropertiesWhenNotExist(item.__treeTotals); + const parentMax = item.__treeTotals[this._type][this._field] !== null ? parseFloat(item.__treeTotals[this._type][this._field]) : null; + if (parentMax !== null && isNumber(parentMax) && (this._max === null || parentMax > this._max)) { + this._max = parentMax; + } + } else if (isNumber(val)) { + this.keepMaxValueWhenFound(val); } } } storeResult(groupTotals: any) { - if (!groupTotals || groupTotals[this._type] === undefined) { + let max = this._max; + this.addGroupTotalPropertiesWhenNotExist(groupTotals); + + // when dealing with Tree Data, we also need to take the parent's total and add it to the final max + if (this._isTreeAggregator && max !== null) { + const parentMax = groupTotals[this._type][this._field]; + if (isNumber(parentMax) && parentMax > max) { + max = parentMax; + } + } + groupTotals[this._type][this._field] = max; + } + + protected addGroupTotalPropertiesWhenNotExist(groupTotals: any) { + if (groupTotals[this._type] === undefined) { groupTotals[this._type] = {}; } - groupTotals[this._type][this._field] = this._max; + } + + protected keepMaxValueWhenFound(val: any) { + if (val !== null && val !== '' && !isNaN(val)) { + if (this._max === null || val > this._max) { + this._max = parseFloat(val); + } + } } } diff --git a/packages/common/src/aggregators/minAggregator.ts b/packages/common/src/aggregators/minAggregator.ts index f32f5abf7..2340ef3b3 100644 --- a/packages/common/src/aggregators/minAggregator.ts +++ b/packages/common/src/aggregators/minAggregator.ts @@ -1,6 +1,10 @@ +import { isNumber } from '@slickgrid-universal/utils'; + import type { Aggregator } from './../interfaces/aggregator.interface'; export class MinAggregator implements Aggregator { + private _isInitialized = false; + private _isTreeAggregator = false; private _min: number | null = null; private _field: number | string; private _type = 'min'; @@ -13,27 +17,79 @@ export class MinAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init() { + init(item?: any, isTreeAggregator = false) { this._min = null; + this._isInitialized = true; + + // when dealing with Tree Data structure, we also need to clear any parent totals + this._isTreeAggregator = isTreeAggregator; + if (isTreeAggregator) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + } + item.__treeTotals[this._type][this._field] = null; + } } - accumulate(item: any) { - const val = (item && item.hasOwnProperty(this._field)) ? item[this._field] : null; - if (val !== null && val !== '' && !isNaN(val)) { - if (this._min === null || val < this._min) { - this._min = parseFloat(val); + accumulate(item: any, isTreeParent = false) { + const val = item?.hasOwnProperty(this._field) ? item[this._field] : null; + + // when dealing with Tree Data structure, we need keep only the new min (without doing any addition) + if (!this._isTreeAggregator) { + // not a Tree structure, we'll do a regular minimation + this.keepMinValueWhenFound(val); + } else { + if (isTreeParent) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + this.addGroupTotalPropertiesWhenNotExist(item.__treeTotals); + const parentMin = item.__treeTotals[this._type][this._field] !== null ? parseFloat(item.__treeTotals[this._type][this._field]) : null; + if (parentMin !== null && isNumber(parentMin) && (this._min === null || parentMin < this._min)) { + this._min = parentMin; + } + } else if (isNumber(val)) { + this.keepMinValueWhenFound(val); } } } storeResult(groupTotals: any) { - if (!groupTotals || groupTotals[this._type] === undefined) { + let min = this._min; + this.addGroupTotalPropertiesWhenNotExist(groupTotals); + + // when dealing with Tree Data, we also need to take the parent's total and add it to the final min + if (this._isTreeAggregator && min !== null) { + const parentMin = groupTotals[this._type][this._field]; + if (isNumber(parentMin) && parentMin < min) { + min = parentMin; + } + } + groupTotals[this._type][this._field] = min; + } + + protected addGroupTotalPropertiesWhenNotExist(groupTotals: any) { + if (groupTotals[this._type] === undefined) { groupTotals[this._type] = {}; } - groupTotals[this._type][this._field] = this._min; + } + + protected keepMinValueWhenFound(val: any) { + if (val !== null && val !== '' && !isNaN(val)) { + if (this._min === null || val < this._min) { + this._min = parseFloat(val); + } + } } } diff --git a/packages/common/src/aggregators/sumAggregator.ts b/packages/common/src/aggregators/sumAggregator.ts index 5d19111d7..8ab7d65ac 100644 --- a/packages/common/src/aggregators/sumAggregator.ts +++ b/packages/common/src/aggregators/sumAggregator.ts @@ -1,7 +1,12 @@ +import { isNumber } from '@slickgrid-universal/utils'; + import type { Aggregator } from './../interfaces/aggregator.interface'; export class SumAggregator implements Aggregator { + private _isInitialized = false; + private _isTreeAggregator = false; private _sum = 0; + private _itemCount = 0; private _field: number | string; private _type = 'sum'; @@ -13,18 +18,55 @@ export class SumAggregator implements Aggregator { return this._field; } + get isInitialized() { + return this._isInitialized; + } + get type(): string { return this._type; } - init() { + init(item?: any, isTreeAggregator = false) { + this._isTreeAggregator = isTreeAggregator; + this._isInitialized = true; this._sum = 0; + this._itemCount = 0; + + // when dealing with Tree Data structure, we also need to keep sum & itemCount refs + if (isTreeAggregator) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + if (item.__treeTotals[this._type] === undefined) { + item.__treeTotals[this._type] = {}; + item.__treeTotals.count = {}; + } + item.__treeTotals['count'][this._field] = 0; + item.__treeTotals[this._type][this._field] = 0; + } } - accumulate(item: any) { - const val = (item && item.hasOwnProperty(this._field)) ? item[this._field] : null; - if (val !== null && val !== '' && !isNaN(val)) { - this._sum += parseFloat(val); + accumulate(item: any, isTreeParent = false) { + const val = item?.hasOwnProperty(this._field) ? item[this._field] : null; + + // when dealing with Tree Data structure, we need keep only the new sum (without doing any addition) + if (!this._isTreeAggregator) { + // not a Tree structure, we'll do a regular summation + if (val !== null && val !== '' && !isNaN(val)) { + this._sum += parseFloat(val); + } + } else { + if (isTreeParent) { + if (!item.__treeTotals) { + item.__treeTotals = {}; + } + this.addGroupTotalPropertiesWhenNotExist(item.__treeTotals); + this._sum = parseFloat(item.__treeTotals[this._type][this._field] ?? 0); + this._itemCount = item.__treeTotals['count'][this._field] ?? 0; + } else if (isNumber(val)) { + this._sum = parseFloat(val); + this._itemCount = 1; + } } } @@ -32,6 +74,25 @@ export class SumAggregator implements Aggregator { if (!groupTotals || groupTotals[this._type] === undefined) { groupTotals[this._type] = {}; } - groupTotals[this._type][this._field] = this._sum; + this.addGroupTotalPropertiesWhenNotExist(groupTotals); + let sum = this._sum; + let itemCount = this._itemCount; + + // when dealing with Tree Data, we also need to take the parent's total and add it to the final sum + if (this._isTreeAggregator) { + sum += groupTotals[this._type][this._field]; + itemCount += groupTotals['count'][this._field]; + groupTotals['count'][this._field] = itemCount; + } + groupTotals[this._type][this._field] = sum; + } + + protected addGroupTotalPropertiesWhenNotExist(groupTotals: any) { + if (groupTotals[this._type] === undefined) { + groupTotals[this._type] = {}; + } + if (this._isTreeAggregator && groupTotals['count'] === undefined) { + groupTotals['count'] = {}; + } } } diff --git a/packages/common/src/formatters/__tests__/treeParseTotalFormatters.spec.ts b/packages/common/src/formatters/__tests__/treeParseTotalFormatters.spec.ts new file mode 100644 index 000000000..9ab9a0a8c --- /dev/null +++ b/packages/common/src/formatters/__tests__/treeParseTotalFormatters.spec.ts @@ -0,0 +1,65 @@ +import { Column, GridOption, SlickGrid } from '../../interfaces/index'; +import { italicFormatter } from '../italicFormatter'; +import { GroupTotalFormatters } from '../../grouping-formatters'; +import { treeParseTotalsFormatter } from '../treeParseTotalsFormatter'; +import { dollarFormatter } from '../dollarFormatter'; + +const gridStub = { + getData: jest.fn(), + getOptions: jest.fn(), +} as unknown as SlickGrid; + +describe('TreeParseTotalFormatters', () => { + let mockGridOptions: GridOption; + const colFieldName = 'fileSize'; + + beforeEach(() => { + mockGridOptions = { + treeDataOptions: { levelPropName: 'indent' } + } as GridOption; + jest.spyOn(gridStub, 'getOptions').mockReturnValue(mockGridOptions); + }); + + it('should return expected output of groupTotalsFormatter when detecting the dataContext has tree children and a "__treeTotals" prop', () => { + const cellValue = 2.1; + const sumTotal = 12.33; + const params = { formatters: [italicFormatter] }; + const result = treeParseTotalsFormatter(0, 0, cellValue, { field: colFieldName, groupTotalsFormatter: GroupTotalFormatters.sumTotalsBold, params } as Column, { __hasChildren: true, __treeTotals: { sum: { [colFieldName]: sumTotal } } }, gridStub); + expect(result).toBe(`${sumTotal}`); + }); + + it('should return expected output of treeTotalsFormatter when detecting the dataContext has tree children and a "__treeTotals" prop', () => { + const cellValue = 2.1; + const sumTotal = 12.33; + const params = { formatters: [italicFormatter] }; + const result = treeParseTotalsFormatter(0, 0, cellValue, { field: colFieldName, treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold, params } as Column, { __hasChildren: true, __treeTotals: { sum: { [colFieldName]: sumTotal } } }, gridStub); + expect(result).toBe(`${sumTotal}`); + }); + + it('should return expected output of italic formatter when detecting the dataContext does not has tree children, neither a "__treeTotals" prop', () => { + const cellValue = 2.1; + const params = { formatters: [italicFormatter] }; + const result = treeParseTotalsFormatter(0, 0, cellValue, { field: colFieldName, treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold, params } as Column, {}, gridStub); + expect(result).toBe(`${cellValue}`); + }); + + it('should return expected output of when multiple formatters (uppercase & italic) are provided and dataContext does not has tree children, neither a "__treeTotals" prop', () => { + const cellValue = 2.1; + const params = { formatters: [dollarFormatter, italicFormatter] }; + const result = treeParseTotalsFormatter(0, 0, cellValue, { field: colFieldName, treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold, params } as Column, {}, gridStub); + expect(result).toBe(`$2.10`); + }); + + it('should return same value as input when dataContext is not a tree total and params.formatters is not provided', () => { + const cellValue = 2.1; + const params = {}; + const result = treeParseTotalsFormatter(0, 0, cellValue, { field: colFieldName, treeTotalsFormatter: GroupTotalFormatters.sumTotalsBold, params } as Column, {}, gridStub); + expect(result).toBe(cellValue); + }); + + it('should throw an error when this formatter is used without groupTotalsFormatter or treeTotalsFormatter', () => { + const cellValue = 2.1; + expect(() => treeParseTotalsFormatter(1, 1, cellValue, {} as Column, {}, gridStub)) + .toThrowError('[Slickgrid-Universal] When using Formatters.treeParseTotals, you must provide a total formatter via "groupTotalsFormatter" or "treeTotalsFormatter".'); + }); +}); diff --git a/packages/common/src/formatters/formatters.index.ts b/packages/common/src/formatters/formatters.index.ts index d6b37b6bd..429743aca 100644 --- a/packages/common/src/formatters/formatters.index.ts +++ b/packages/common/src/formatters/formatters.index.ts @@ -35,6 +35,7 @@ import { progressBarFormatter } from './progressBarFormatter'; import { translateFormatter } from './translateFormatter'; import { treeExportFormatter } from './treeExportFormatter'; import { treeFormatter } from './treeFormatter'; +import { treeParseTotalsFormatter } from './treeParseTotalsFormatter'; import { translateBooleanFormatter } from './translateBooleanFormatter'; import { uppercaseFormatter } from './uppercaseFormatter'; import { yesNoFormatter } from './yesNoFormatter'; @@ -284,6 +285,17 @@ export const Formatters = { /** Formatter that must be use with a Tree Data column */ tree: treeFormatter, + /** + * Formatter that can be use to parse Tree Data totals and display totals using GroupTotalFormatters. + * This formatter works with both regular `Formatters` or `GroupTotalFormatters`, + * it will auto-detect if the current data context has a `__treeTotals` prop, + * then it will use the `GroupTotalFormatters`, if not then it will try to use regular `Formatters`. + * + * This mean that you can provide an array of `Formatters` & `GroupTotalFormatters` and it will use the correct formatter + * by detecting if the current data context has a `__treeTotals` prop (`GroupTotalFormatters`) or not (regular `Formatter`) + */ + treeParseTotals: treeParseTotalsFormatter, + /** Formatter that must be use with a Tree Data column for Exporting the data */ treeExport: treeExportFormatter, diff --git a/packages/common/src/formatters/index.ts b/packages/common/src/formatters/index.ts index 8fae8b29c..164eff73b 100644 --- a/packages/common/src/formatters/index.ts +++ b/packages/common/src/formatters/index.ts @@ -34,5 +34,6 @@ export * from './translateFormatter'; export * from './translateBooleanFormatter'; export * from './treeExportFormatter'; export * from './treeFormatter'; +export * from './treeParseTotalsFormatter'; export * from './uppercaseFormatter'; export * from './yesNoFormatter'; diff --git a/packages/common/src/formatters/multipleFormatter.ts b/packages/common/src/formatters/multipleFormatter.ts index a80b07438..f0990835e 100644 --- a/packages/common/src/formatters/multipleFormatter.ts +++ b/packages/common/src/formatters/multipleFormatter.ts @@ -18,7 +18,7 @@ export const multipleFormatter: Formatter = (row, cell, value, columnDef, dataCo // they are piped and executed in sequences let currentValue = value; for (const formatter of formatters) { - currentValue = formatter(row, cell, currentValue, columnDef, dataContext, grid); + currentValue = formatter.call(this, row, cell, currentValue, columnDef, dataContext, grid); } return currentValue; }; diff --git a/packages/common/src/formatters/treeParseTotalsFormatter.ts b/packages/common/src/formatters/treeParseTotalsFormatter.ts new file mode 100644 index 000000000..a7cb244d2 --- /dev/null +++ b/packages/common/src/formatters/treeParseTotalsFormatter.ts @@ -0,0 +1,32 @@ +import { Constants } from '../constants'; +import { Formatter, GridOption, GroupTotalsFormatter } from '../interfaces/index'; + +export const treeParseTotalsFormatter: Formatter = (row, cell, value, columnDef, dataContext, grid) => { + const gridOptions = grid.getOptions() as GridOption; + const hasChildrenPropName = gridOptions?.treeDataOptions?.hasChildrenPropName ?? Constants.treeDataProperties.HAS_CHILDREN_PROP; + const { groupTotalsFormatter, treeTotalsFormatter, params } = columnDef; + + // make sure that the user provided a total formatter or else it won't work + if (!groupTotalsFormatter && !treeTotalsFormatter) { + throw new Error('[Slickgrid-Universal] When using Formatters.treeParseTotals, you must provide a total formatter via "groupTotalsFormatter" or "treeTotalsFormatter".'); + } + + // treeParseTotalsFormatter will auto-detect if it should execute GroupTotalsFormatter or a list of regular Formatters (it has to be either/or, never both at same time) + if (dataContext[hasChildrenPropName] && dataContext?.__treeTotals && (groupTotalsFormatter || treeTotalsFormatter)) { + const totalFormatter = (treeTotalsFormatter ?? groupTotalsFormatter) as GroupTotalsFormatter; + return totalFormatter(dataContext?.__treeTotals, columnDef, grid); + } else if (params.formatters) { + // loop through all Formatters, the value of 1st formatter will be used by 2nd formatter and so on. + // they are piped and executed in sequences + let currentValue = value; + for (const formatter of params.formatters) { + if (!dataContext[hasChildrenPropName] && !dataContext?.__treeTotals) { + currentValue = (formatter as Formatter)(row, cell, currentValue, columnDef, dataContext, grid) || value; + } + } + return currentValue; + } + + // falling here means dataContext doesn't include any tree totals and user didn't provide any regular formatters + return value; +}; diff --git a/packages/common/src/interfaces/aggregator.interface.ts b/packages/common/src/interfaces/aggregator.interface.ts index df76e2665..92bc54b4f 100644 --- a/packages/common/src/interfaces/aggregator.interface.ts +++ b/packages/common/src/interfaces/aggregator.interface.ts @@ -2,14 +2,17 @@ export interface Aggregator { /** Column definition field Id of the associated Aggregator */ field: number | string; + /** Was the Aggregator already initialized? */ + isInitialized?: boolean; + /** Type of Aggregator (sum, avg, ...) */ type: string; /** Aggregator initialize method */ - init: () => void; + init: (item?: any, isTreeAggregator?: boolean) => void; /** Method to accumulate the result which will be different for each Aggregator type */ - accumulate?: (item: any) => void; + accumulate?: (item: any, isTreeParent?: boolean) => void; /** Method to store the result into the given group total object provided as argument */ storeResult: (groupTotals: any | undefined) => void; diff --git a/packages/common/src/interfaces/column.interface.ts b/packages/common/src/interfaces/column.interface.ts index b05fa0795..79b651c17 100644 --- a/packages/common/src/interfaces/column.interface.ts +++ b/packages/common/src/interfaces/column.interface.ts @@ -332,6 +332,12 @@ export interface Column { /** Custom Tooltip that can ben shown to the column */ toolTip?: string; + /** + * @alias `groupTotalsFormatter` Tree Totals Formatter function that can be used to add tree totals in the, + * user can provide any `GroupTotalsFormatter` and/or use `groupTotalsFormatter` which will do the same + */ + treeTotalsFormatter?: GroupTotalsFormatter; + /** What is the Field Type, this can be used in the Formatters/Editors/Filters/... */ type?: typeof FieldType[keyof typeof FieldType]; diff --git a/packages/common/src/interfaces/treeDataOption.interface.ts b/packages/common/src/interfaces/treeDataOption.interface.ts index 0f9c8fd0a..adf76eff9 100644 --- a/packages/common/src/interfaces/treeDataOption.interface.ts +++ b/packages/common/src/interfaces/treeDataOption.interface.ts @@ -1,5 +1,6 @@ // import { Aggregator } from './aggregator.interface'; import type { SortDirection, SortDirectionString } from '../enums/index'; +import type { Aggregator } from './aggregator.interface'; import type { Formatter } from './formatter.interface'; export interface TreeDataOption { @@ -26,8 +27,7 @@ export interface TreeDataOption { excludeChildrenWhenFilteringTree?: boolean; /** Grouping Aggregators array */ - // NOT YET IMPLEMENTED - // aggregators?: Aggregator[]; + aggregators?: Aggregator[]; /** Optionally define the initial sort column and direction */ initialSort?: { diff --git a/packages/common/src/services/__tests__/utilities.spec.ts b/packages/common/src/services/__tests__/utilities.spec.ts index 35dfe82d6..168650609 100644 --- a/packages/common/src/services/__tests__/utilities.spec.ts +++ b/packages/common/src/services/__tests__/utilities.spec.ts @@ -7,6 +7,7 @@ import { Column, GridOption } from '../../interfaces/index'; import { RxJsResourceStub } from '../../../../../test/rxjsResourceStub'; import { addTreeLevelByMutation, + addTreeLevelAndAggregatorsByMutation, cancellablePromise, CancelledException, castObservableToPromise, @@ -29,6 +30,7 @@ import { thousandSeparatorFormatted, unsubscribeAll, } from '../utilities'; +import { SumAggregator } from '../../aggregators'; describe('Service/Utilies', () => { describe('unflattenParentChildArrayToTree method', () => { @@ -57,6 +59,32 @@ describe('Service/Utilies', () => { { id: 18, __treeLevel: 0, parentId: null, file: 'something.txt', dateModified: '2015-03-03', size: 90, }, ]); }); + + it('should take a parent/child array with aggregators and return a hierarchical array structure', () => { + const input = [ + { id: 18, size: 90, dateModified: '2015-03-03', file: 'something.txt', parentId: null, }, + { id: 11, file: 'Music', parentId: null, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 12, file: 'mp3', parentId: 11, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 16, file: 'rock', parentId: 12, __treeTotals: { count: { size: 2, }, sum: { size: 98, }, }, }, + { id: 17, dateModified: '2015-05-13', file: 'soft.mp3', size: 98, parentId: 16, }, + { id: 14, file: 'pop', parentId: 12, __treeTotals: { count: { size: 2, }, sum: { size: 85, }, }, }, + { id: 15, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, parentId: 14, }, + ]; + + const output = unflattenParentChildArrayToTree(input, { aggregators: [new SumAggregator('size')], parentPropName: 'parentId', childrenPropName: 'files' }); + + expect(output).toEqual([ + { + id: 11, __treeLevel: 0, __collapsed: false, parentId: null, file: 'Music', __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, files: [{ + id: 12, __treeLevel: 1, __collapsed: false, parentId: 11, file: 'mp3', __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, files: [ + { id: 14, __treeLevel: 2, __collapsed: false, parentId: 12, file: 'pop', __treeTotals: { count: { size: 1, }, sum: { size: 85, }, }, 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', __treeTotals: { count: { size: 1, }, sum: { size: 98, }, }, files: [{ id: 17, __treeLevel: 3, parentId: 16, file: 'soft.mp3', dateModified: '2015-05-13', size: 98, }] }, + ] + }] + }, + { id: 18, __treeLevel: 0, parentId: null, file: 'something.txt', dateModified: '2015-03-03', size: 90, }, + ]); + }); }); describe('cancellablePromise method', () => { @@ -151,6 +179,36 @@ describe('Service/Utilies', () => { { id: 15, __treeLevel: 3, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, __parentId: 14, __hasChildren: false }, ]); }); + + it('should return a flat array from a hierarchical structure and tree totals aggregation', () => { + const aggregator = new SumAggregator('size'); + addTreeLevelAndAggregatorsByMutation(mockTreeArray, { aggregator, 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, __hasChildren: false }, + { id: 11, __treeLevel: 0, file: 'Music', __parentId: null, __hasChildren: true, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 12, __treeLevel: 1, file: 'mp3', __parentId: 11, __hasChildren: true, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 16, __treeLevel: 2, file: 'rock', __parentId: 12, __hasChildren: true, __treeTotals: { count: { size: 1, }, sum: { size: 98, }, }, }, + { 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, __treeTotals: { count: { size: 1, }, sum: { size: 85, }, } }, + { id: 15, __treeLevel: 3, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, __parentId: 14, __hasChildren: false }, + ]); + }); + + it('should return a flat array from a hierarchical structure and tree totals aggregation and tree level number as well', () => { + const aggregator = new SumAggregator('size'); + addTreeLevelAndAggregatorsByMutation(mockTreeArray, { aggregator, childrenPropName: 'files', levelPropName: '__treeLevel' }); + const output = flattenToParentChildArray(mockTreeArray, { childrenPropName: 'files', shouldAddTreeLevelNumber: true, aggregators: [aggregator] }); + expect(output).toEqual([ + { 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, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 12, __treeLevel: 1, file: 'mp3', __parentId: 11, __hasChildren: true, __treeTotals: { count: { size: 2, }, sum: { size: (98 + 85), }, }, }, + { id: 16, __treeLevel: 2, file: 'rock', __parentId: 12, __hasChildren: true, __treeTotals: { count: { size: 1, }, sum: { size: 98, }, }, }, + { 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, __treeTotals: { count: { size: 1, }, sum: { size: 85, }, } }, + { id: 15, __treeLevel: 3, dateModified: '2015-03-01', file: 'theme.mp3', size: 85, __parentId: 14, __hasChildren: false }, + ]); + }); }); describe('findItemInTreeStructure method', () => { diff --git a/packages/common/src/services/filter.service.ts b/packages/common/src/services/filter.service.ts index 32cba288f..63aa740f6 100644 --- a/packages/common/src/services/filter.service.ts +++ b/packages/common/src/services/filter.service.ts @@ -858,7 +858,7 @@ export class FilterService { * you can change the sorting icons separately by passing an array of columnId/sortAsc and that will change ONLY the icons * @param sortColumns */ - setSortColumnIcons(sortColumns: { columnId: string, sortAsc: boolean }[]) { + setSortColumnIcons(sortColumns: { columnId: string, sortAsc: boolean; }[]) { if (this._grid && Array.isArray(sortColumns)) { this._grid.setSortColumns(sortColumns); } @@ -1056,7 +1056,7 @@ export class FilterService { // ------------------- /** Add all created filters (from their template) to the header row section area */ - protected addFilterTemplateToHeaderRow(args: { column: Column; grid: SlickGrid; node: HTMLElement }, isFilterFirstRender = true) { + protected addFilterTemplateToHeaderRow(args: { column: Column; grid: SlickGrid; node: HTMLElement; }, isFilterFirstRender = true) { const columnDef = args.column; const columnId = columnDef?.id ?? ''; diff --git a/packages/common/src/services/grid.service.ts b/packages/common/src/services/grid.service.ts index 96a7ff510..4d4931980 100644 --- a/packages/common/src/services/grid.service.ts +++ b/packages/common/src/services/grid.service.ts @@ -908,6 +908,7 @@ export class GridService { this.sharedService.hierarchicalDataset = sortedDatasetResult.hierarchical; this.filterService.refreshTreeDataFilters(items); this._dataView.setItems(sortedDatasetResult.flat); + this._grid.invalidate(); } } diff --git a/packages/common/src/services/utilities.ts b/packages/common/src/services/utilities.ts index e46b0041a..e6b8a0343 100644 --- a/packages/common/src/services/utilities.ts +++ b/packages/common/src/services/utilities.ts @@ -5,7 +5,7 @@ const moment = (moment_ as any)['default'] || moment_; // patch to fix rollup "m import { Constants } from '../constants'; import { FieldType, type OperatorString, OperatorType } from '../enums/index'; -import type { CancellablePromiseWrapper, Column, GridOption, } from '../interfaces/index'; +import type { Aggregator, CancellablePromiseWrapper, Column, GridOption, } from '../interfaces/index'; import type { Observable, RxJsFacade, Subject, Subscription } from './rxjsFacade'; /** Cancelled Extension that can be only be thrown by the `cancellablePromise()` function */ @@ -72,9 +72,9 @@ export function addTreeLevelByMutation(treeArray: T[], options: { childrenPro if (Array.isArray(treeArray)) { for (const item of treeArray) { if (item) { - if (Array.isArray(item[childrenPropName]) && (item[childrenPropName] as unknown as Array).length > 0) { + if (Array.isArray(item[childrenPropName]) && (item[childrenPropName] as Array).length > 0) { treeLevel++; - addTreeLevelByMutation(item[childrenPropName] as unknown as Array, options, treeLevel); + addTreeLevelByMutation(item[childrenPropName] as Array, options, treeLevel); treeLevel--; } (item as any)[options.levelPropName] = treeLevel; @@ -83,13 +83,39 @@ export function addTreeLevelByMutation(treeArray: T[], options: { childrenPro } } +export function addTreeLevelAndAggregatorsByMutation(treeArray: T[], options: { aggregator: Aggregator; childrenPropName: string; levelPropName: string; }, treeLevel = 0, parent: T = null as any) { + const childrenPropName = (options?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP) as keyof T; + const { aggregator } = options; + + if (Array.isArray(treeArray)) { + for (const item of treeArray) { + if (item) { + const isParent = Array.isArray(item[childrenPropName]); + + if (Array.isArray(item[childrenPropName]) && (item[childrenPropName] as Array).length > 0) { + aggregator.init(item, true); + treeLevel++; + addTreeLevelAndAggregatorsByMutation(item[childrenPropName] as Array, options, treeLevel, item); + treeLevel--; + } + + if (parent && aggregator.isInitialized && typeof aggregator.accumulate === 'function') { + aggregator.accumulate(item, isParent); + aggregator.storeResult((parent as any).__treeTotals); + } + (item as any)[options.levelPropName] = treeLevel; + } + } + } +} + /** * Convert a hierarchical (tree) array (with children) into a flat array structure array (where the children are pushed as next indexed item in the array) * @param {Array} treeArray - input hierarchical (tree) array * @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; hasChildrenPropName?: string; identifierPropName?: string; shouldAddTreeLevelNumber?: boolean; levelPropName?: string; }) { +export function flattenToParentChildArray(treeArray: T[], options?: { aggregators?: Aggregator[]; parentPropName?: string; childrenPropName?: string; hasChildrenPropName?: string; identifierPropName?: string; shouldAddTreeLevelNumber?: boolean; levelPropName?: string; }) { const identifierPropName = (options?.identifierPropName ?? 'id') as keyof T & string; 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; @@ -98,7 +124,13 @@ export function flattenToParentChildArray(treeArray: T[], options?: { parentP type FlatParentChildArray = Omit; if (options?.shouldAddTreeLevelNumber) { - addTreeLevelByMutation(treeArray, { childrenPropName, levelPropName }); + if (options?.aggregators) { + options.aggregators.forEach((aggregator) => { + addTreeLevelAndAggregatorsByMutation(treeArray, { childrenPropName, levelPropName, aggregator }); + }); + } else { + addTreeLevelByMutation(treeArray, { childrenPropName, levelPropName }); + } } const flat = flatten( @@ -123,7 +155,7 @@ export function flattenToParentChildArray(treeArray: T[], options?: { parentP * @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?: { childrenPropName?: string; collapsedPropName?: string; identifierPropName?: string; levelPropName?: string; parentPropName?: string; initiallyCollapsed?: boolean; }): T[] { +export function unflattenParentChildArrayToTree(flatArray: P[], options?: { aggregators?: Aggregator[]; childrenPropName?: string; collapsedPropName?: string; identifierPropName?: string; levelPropName?: string; parentPropName?: string; initiallyCollapsed?: boolean; }): T[] { const identifierPropName = options?.identifierPropName ?? 'id'; const childrenPropName = options?.childrenPropName ?? Constants.treeDataProperties.CHILDREN_PROP; const parentPropName = options?.parentPropName ?? Constants.treeDataProperties.PARENT_PROP; @@ -154,10 +186,16 @@ export function unflattenParentChildArrayToTree { + addTreeLevelAndAggregatorsByMutation(roots, { childrenPropName, levelPropName, aggregator }, 0); + }); + } else { + addTreeLevelByMutation(roots, { childrenPropName, levelPropName }, 0); + } return roots; } diff --git a/test/cypress/e2e/example06.cy.ts b/test/cypress/e2e/example06.cy.ts index fb564ae03..4791d9ecf 100644 --- a/test/cypress/e2e/example06.cy.ts +++ b/test/cypress/e2e/example06.cy.ts @@ -1,13 +1,14 @@ -describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, () => { +describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 0 }, () => { const GRID_ROW_HEIGHT = 45; const titles = ['Files', 'Date Modified', 'Description', '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']; - const defaultGridPresetWithoutPdfDocs = ['bucket-list.txt', 'documents', 'misc', 'todo.txt', 'pdf', 'txt', 'todo.txt', 'xls', 'compilation.xls']; - const defaultSortAscList = ['bucket-list.txt', 'documents', 'misc', 'todo.txt', 'pdf', 'internet-bill.pdf', 'map.pdf', 'map2.pdf', 'phone-bill.pdf']; - // const defaultSortDescList = ['something.txt', 'music', 'mp3', 'rock', 'soft.mp3', 'pop', 'theme.mp3', 'song.mp3', 'documents', 'xls', 'compilation.xls', 'txt', 'todo.txt']; - const defaultSortDescListWithExtraSongs = ['something.txt', 'recipes', 'coffee-cake', 'chocolate-cake', 'cheesecake', 'music', 'mp3', 'rock', 'soft.mp3', 'pop', 'theme.mp3', 'song.mp3', 'pop-126.mp3', 'pop-125.mp3', 'documents', 'xls']; - const popMusicWith3ExtraSongs = ['music', 'mp3', 'pop', 'pop-125.mp3', 'pop-126.mp3', 'pop-127.mp3', 'song.mp3', 'theme.mp3',]; + // const defaultSortAscList = ['bucket-list.txt', 'documents', 'misc', 'warranties.txt', 'pdf', 'internet-bill.pdf', 'map.pdf', 'map2.pdf', 'phone-bill.pdf', 'txt', 'todo.txt', 'unclassified.csv', 'unresolved.csv', 'xls', 'compilation.xls', 'music', 'mp3', 'other', 'pop', 'song.mp3', 'theme.mp3', 'rock', 'soft.mp3', 'something.txt']; + // const defaultSortDescList = ['something.txt', 'music', 'mp3', 'rock', 'soft.mp3', 'other', 'pop', 'theme.mp3', 'song.mp3', 'documents', 'xls', 'compilation.xls', 'txt', 'todo.txt', 'unclassified.csv', 'unresolved.csv', 'pdf', 'phone-bill.pdf', 'map2.pdf', 'map.pdf', 'internet-bill.pdf', 'misc', 'todo.txt', 'bucket-list.txt']; + const defaultGridPresetWithoutPdfDocs = ['bucket-list.txt', 'documents', 'misc', 'warranties.txt', 'pdf', 'txt', 'todo.txt', 'unclassified.csv', 'unresolved.csv', 'xls', 'compilation.xls']; + const defaultSortAscList = ['bucket-list.txt', 'documents', 'misc', 'warranties.txt', 'pdf', 'internet-bill.pdf', 'map.pdf', 'map2.pdf', 'phone-bill.pdf']; + // const defaultSortDescList = ['something.txt', 'music', 'mp3', 'rock', 'soft.mp3', 'other', 'pop', 'theme.mp3', 'song.mp3', 'documents', 'xls', 'compilation.xls', 'txt', 'todo.txt']; + const defaultSortDescListWithExtraSongs = ['something.txt', 'recipes', 'coffee-cake', 'chocolate-cake', 'cheesecake', 'music', 'mp3', 'rock', 'soft.mp3', 'pop', 'theme.mp3', 'song.mp3', 'pop-80.mp3', 'pop-79.mp3', 'other', 'documents', 'xls']; + const popMusicWith3ExtraSongs = ['music', 'mp3', 'other', 'pop', 'pop-79.mp3', 'pop-80.mp3', 'pop-81.mp3', 'song.mp3', 'theme.mp3',]; + const popMusicWith3ExtraSongsWithoutEmpty = ['music', 'mp3', 'pop', 'pop-79.mp3', 'pop-80.mp3', 'pop-81.mp3', 'song.mp3', 'theme.mp3',]; it('should display Example title', () => { cy.visit(`${Cypress.config('baseUrl')}/example06`); @@ -22,9 +23,10 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .each(($child, index) => expect($child.text()).to.eq(titles[index])); }); - it('should expect the "pdf" folder to be closed by the collapsed items grid preset', () => { + it('should expect the "pdf" folder to be closed by the collapsed items grid preset with aggregators of Sum(8.8MB) / Avg(2.2MB)', () => { cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(0)`).should('contain', 'pdf'); cy.get(`.slick-group-toggle.collapsed`).should('have.length', 1); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 8.8 MB / avg: 2.2 MB'); defaultGridPresetWithoutPdfDocs.forEach((_colName, rowIdx) => { if (rowIdx < defaultGridPresetWithoutPdfDocs.length - 1) { @@ -33,6 +35,13 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, }); }); + it('should have documents folder with aggregation of Sum(14.46MB) / Avg(1.45MB)', () => { + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(0)`).should('contain', 'documents'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 1}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 14.46 MB / avg: 1.45 MB'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(0)`).should('contain', 'misc'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 2}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 0.4 MB / avg: 0.4 MB'); + }); + it('should expand "pdf" folder and expect all folders to be expanded', () => { cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 4}px"] > .slick-cell:nth(0) .slick-group-toggle.collapsed`) .click(); @@ -50,6 +59,18 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, }); }); + it('should have pop songs folder with aggregation of Sum(53.3MB) / Avg(26.65MB)', () => { + cy.get('.slick-viewport-top.slick-viewport-left') + .scrollTo('center', { force: true } as any); + + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 16}px"] > .slick-cell:nth(0)`).should('contain', 'music'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 16}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 151.3 MB / avg: 50.43 MB'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 17}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 151.3 MB / avg: 50.43 MB'); + // next folder is "other" and is empty without aggregations + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 19}px"] > .slick-cell:nth(0)`).should('contain', 'pop'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 19}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 53.3 MB / avg: 26.65 MB'); + }); + it('should be able to add 2 new pop songs into the Music folder', () => { cy.get('[data-test=add-item-btn]') .contains('Add New Pop Song') @@ -58,11 +79,27 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, cy.get('.slick-group-toggle[level=3]') .get('.slick-cell') - .contains('pop-125.mp3'); + .contains('pop-79.mp3'); cy.get('.slick-group-toggle[level=3]') .get('.slick-cell') - .contains('pop-126.mp3'); + .contains('pop-80.mp3'); + + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 20}px"] > .slick-cell:nth(3)`).should('contain', '82 MB'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 21}px"] > .slick-cell:nth(3)`).should('contain', '83 MB'); + + }); + + it('should have pop songs folder with updated aggregation including new pop songs of Sum(218.3MB) / Avg(54.58MB)', () => { + cy.get('.slick-viewport-top.slick-viewport-left') + .scrollTo('bottom', { force: true } as any); + + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 16}px"] > .slick-cell:nth(0)`).should('contain', 'music'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 16}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 316.3 MB / avg: 63.26 MB'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 17}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 316.3 MB / avg: 63.26 MB'); + // next folder is "other" and is empty without aggregations + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 19}px"] > .slick-cell:nth(0)`).should('contain', 'pop'); + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * 19}px"] > .slick-cell:nth(3)`).should('contain', 'sum: 218.3 MB / avg: 54.58 MB'); }); it('should filter the Files column with the word "map" and expect only 4 rows left', () => { @@ -197,11 +234,11 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, cy.get('.slick-group-toggle[level=3]') .get('.slick-cell') - .contains('pop-127.mp3'); + .contains('pop-81.mp3'); cy.get('.slick-group-toggle[level=3]') .get('.slick-cell') - .contains('pop-127.mp3'); + .contains('pop-81.mp3'); }); it('should return 8 rows when filtering the word "pop" music without excluding children', () => { @@ -211,9 +248,9 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, cy.get('.right-footer .item-count') .contains('8'); - popMusicWith3ExtraSongs.forEach((_colName, rowIdx) => { - if (rowIdx < popMusicWith3ExtraSongs.length - 1) { - cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', popMusicWith3ExtraSongs[rowIdx]); + popMusicWith3ExtraSongsWithoutEmpty.forEach((_colName, rowIdx) => { + if (rowIdx < popMusicWith3ExtraSongsWithoutEmpty.length - 1) { + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', popMusicWith3ExtraSongsWithoutEmpty[rowIdx]); } }); }); @@ -225,9 +262,9 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, cy.get('.right-footer .item-count') .contains('6'); - popMusicWith3ExtraSongs.forEach((_colName, rowIdx) => { - if (rowIdx < popMusicWith3ExtraSongs.length - 3) { - cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', popMusicWith3ExtraSongs[rowIdx]); + popMusicWith3ExtraSongsWithoutEmpty.forEach((_colName, rowIdx) => { + if (rowIdx < popMusicWith3ExtraSongsWithoutEmpty.length - 3) { + cy.get(`.grid6 [style="top:${GRID_ROW_HEIGHT * rowIdx}px"] > .slick-cell:nth(0)`).should('contain', popMusicWith3ExtraSongsWithoutEmpty[rowIdx]); } }); }); @@ -250,7 +287,7 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, .uncheck(); cy.get('.right-footer .item-count') - .contains('10'); + .contains('11'); const allMusic = [...popMusicWith3ExtraSongs, 'rock', 'soft.mp3']; @@ -277,7 +314,7 @@ describe('Example 06 - Tree Data (from a Hierarchical Dataset)', { retries: 1 }, expect(+$row.text()).to.be.at.least(6); }); - const expectedFiles = ['music', 'mp3', 'pop', 'pop-125.mp3', 'rock', 'soft.mp3']; + const expectedFiles = ['music', 'mp3', 'pop', 'pop-79.mp3', 'rock', 'soft.mp3']; expectedFiles.forEach((_colName, rowIdx) => { if (rowIdx < expectedFiles.length - 3) {