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) {