diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 8fd12ad99c9..9db8f8070d1 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -66465,6 +66465,7 @@ "peerDependencies": { "@ant-design/icons": "^5.2.6", "@apache-superset/core": "*", + "@react-icons/all-files": "^4.1.0", "@superset-ui/chart-controls": "*", "@superset-ui/core": "*", "lodash": "^4.17.11", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index 0ae2b1c429f..17b5bd887ae 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -27,6 +27,7 @@ "access": "public" }, "peerDependencies": { + "@react-icons/all-files": "^4.1.0", "@apache-superset/core": "*", "@ant-design/icons": "^5.2.6", "@superset-ui/chart-controls": "*", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx index 5fefac2904c..489813de6cb 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx @@ -21,6 +21,9 @@ import { Component } from 'react'; import { safeHtmlSpan } from '@superset-ui/core'; import { t } from '@apache-superset/core/ui'; import PropTypes from 'prop-types'; +import { FaSort } from '@react-icons/all-files/fa/FaSort'; +import { FaSortDown as FaSortDesc } from '@react-icons/all-files/fa/FaSortDown'; +import { FaSortUp as FaSortAsc } from '@react-icons/all-files/fa/FaSortUp'; import { PivotData, flatKey } from './utilities'; import { Styles } from './Styles'; @@ -72,6 +75,88 @@ function displayHeaderCell( ); } +function sortHierarchicalObject(obj, objSort, rowPartialOnTop) { + // Performs a recursive sort of nested object structures. Sorts objects based on + // their currentVal property. The function preserves the hierarchical structure + // while sorting each level according to the specified criteria. + const sortedKeys = Object.keys(obj).sort((a, b) => { + const valA = obj[a].currentVal || 0; + const valB = obj[b].currentVal || 0; + if (rowPartialOnTop) { + if (obj[a].currentVal !== undefined && obj[b].currentVal === undefined) { + return -1; + } + if (obj[b].currentVal !== undefined && obj[a].currentVal === undefined) { + return 1; + } + } + return objSort === 'asc' ? valA - valB : valB - valA; + }); + + const result = new Map(); + sortedKeys.forEach(key => { + const value = obj[key]; + if (typeof value === 'object' && !Array.isArray(value)) { + result.set(key, sortHierarchicalObject(value, objSort, rowPartialOnTop)); + } else { + result.set(key, value); + } + }); + return result; +} + +function convertToArray( + obj, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + parentKeys = [], + result = [], + flag = false, +) { + // Recursively flattens a hierarchical Map structure into an array of key paths. + // Handles different rendering scenarios based on row grouping configurations and + // depth limitations. The function supports complex hierarchy flattening with + let updatedFlag = flag; + + const keys = Array.from(obj.keys()); + const getValue = key => obj.get(key); + + keys.forEach(key => { + if (key === 'currentVal') { + return; + } + const value = getValue(key); + if (rowEnabled && rowPartialOnTop && parentKeys.length < maxRowIndex - 1) { + result.push(parentKeys.length > 0 ? [...parentKeys, key] : [key]); + updatedFlag = true; + } + if (typeof value === 'object' && !Array.isArray(value)) { + convertToArray( + value, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + [...parentKeys, key], + result, + ); + } + if ( + parentKeys.length >= maxRowIndex - 1 || + (rowEnabled && !rowPartialOnTop) + ) { + if (!updatedFlag) { + result.push(parentKeys.length > 0 ? [...parentKeys, key] : [key]); + return; + } + } + if (parentKeys.length === 0 && maxRowIndex === 1) { + result.push([key]); + } + }); + return result; +} + export class TableRenderer extends Component { constructor(props) { super(props); @@ -79,8 +164,8 @@ export class TableRenderer extends Component { // We need state to record which entries are collapsed and which aren't. // This is an object with flat-keys indicating if the corresponding rows // should be collapsed. - this.state = { collapsedRows: {}, collapsedCols: {} }; - + this.state = { collapsedRows: {}, collapsedCols: {}, sortingOrder: [] }; + this.sortCache = new Map(); this.clickHeaderHandler = this.clickHeaderHandler.bind(this); this.clickHandler = this.clickHandler.bind(this); } @@ -349,6 +434,108 @@ export class TableRenderer extends Component { return spans; } + getAggregatedData(pivotData, visibleColName, rowPartialOnTop) { + // Transforms flat row keys into a hierarchical group structure where each level + // represents a grouping dimension. For each row key path, it calculates the + // aggregated value for the specified column and builds a nested object that + // preserves the hierarchy while storing aggregation values at each level. + const groups = {}; + const rows = pivotData.rowKeys; + rows.forEach(rowKey => { + const aggValue = + pivotData.getAggregator(rowKey, visibleColName).value() ?? 0; + + if (rowPartialOnTop) { + const parent = rowKey + .slice(0, -1) + .reduce((acc, key) => (acc[key] ??= {}), groups); + parent[rowKey.at(-1)] = { currentVal: aggValue }; + } else { + rowKey.reduce((acc, key) => { + acc[key] = acc[key] || { currentVal: 0 }; + acc[key].currentVal = aggValue; + return acc[key]; + }, groups); + } + }); + return groups; + } + + sortAndCacheData( + groups, + sortOrder, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + ) { + // Processes hierarchical data by first sorting it according to the specified order + // and then converting the sorted structure into a flat array format. This function + // serves as an intermediate step between hierarchical data representation and + // flat array representation needed for rendering. + const sortedGroups = sortHierarchicalObject( + groups, + sortOrder, + rowPartialOnTop, + ); + return convertToArray( + sortedGroups, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + ); + } + + sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex) { + // Handles column sorting with direction toggling (asc/desc) and implements + // caching mechanism to avoid redundant sorting operations. When sorting the same + // column multiple times, it cycles through sorting directions. Uses composite + // cache keys based on sorting parameters for optimal performance. + this.setState(state => { + const { sortingOrder, activeSortColumn } = state; + + const newSortingOrder = []; + let newDirection = 'asc'; + + if (activeSortColumn === columnIndex) { + newDirection = sortingOrder[columnIndex] === 'asc' ? 'desc' : 'asc'; + } + + const { rowEnabled, rowPartialOnTop } = pivotData.subtotals; + newSortingOrder[columnIndex] = newDirection; + + const cacheKey = `${columnIndex}-${visibleColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`; + let newRowKeys; + if (this.sortCache.has(cacheKey)) { + const cachedRowKeys = this.sortCache.get(cacheKey); + newRowKeys = cachedRowKeys; + } else { + const groups = this.getAggregatedData( + pivotData, + visibleColKeys[columnIndex], + rowPartialOnTop, + ); + const sortedRowKeys = this.sortAndCacheData( + groups, + newDirection, + rowEnabled, + rowPartialOnTop, + maxRowIndex, + ); + this.sortCache.set(cacheKey, sortedRowKeys); + newRowKeys = sortedRowKeys; + } + this.cachedBasePivotSettings = { + ...this.cachedBasePivotSettings, + rowKeys: newRowKeys, + }; + + return { + sortingOrder: newSortingOrder, + activeSortColumn: columnIndex, + }; + }); + } + renderColHeaderRow(attrName, attrIdx, pivotSettings) { // Render a single row in the column header at the top of the pivot table. @@ -434,11 +621,35 @@ export class TableRenderer extends Component { ) { colLabelClass += ' active'; } + const { maxRowVisible: maxRowIndex, maxColVisible } = pivotSettings; + const visibleSortIcon = maxColVisible - 1 === attrIdx; + const columnName = colKey[maxColVisible - 1]; const rowSpan = 1 + (attrIdx === colAttrs.length - 1 ? rowIncrSpan : 0); const flatColKey = flatKey(colKey.slice(0, attrIdx + 1)); const onArrowClick = needToggle ? this.toggleColKey(flatColKey) : null; + const getSortIcon = key => { + const { activeSortColumn, sortingOrder } = this.state; + if (activeSortColumn !== key) { + return ( + + this.sortData(key, visibleColKeys, pivotData, maxRowIndex) + } + /> + ); + } + + const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc; + return ( + + this.sortData(key, visibleColKeys, pivotData, maxRowIndex) + } + /> + ); + }; const headerCellFormattedValue = dateFormatters && dateFormatters[attrName] && @@ -471,6 +682,22 @@ export class TableRenderer extends Component { namesMapping, allowRenderHtml, )} + { + e.stopPropagation(); + }} + aria-label={ + this.state.activeSortColumn === i + ? `Sorted by ${columnName} ${this.state.sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}` + : undefined + } + > + {visibleSortIcon && getSortIcon(i)} + , ); } else if (attrIdx === colKey.length) { @@ -879,8 +1106,15 @@ export class TableRenderer extends Component { return document.contains(document.querySelector('.dashboard--editing')); } + componentWillUnmount() { + this.sortCache.clear(); + } + render() { if (this.cachedProps !== this.props) { + this.sortCache.clear(); + this.state.sortingOrder = []; + this.state.activeSortColumn = null; this.cachedProps = this.props; this.cachedBasePivotSettings = this.getBasePivotSettings(); } diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx new file mode 100644 index 00000000000..29828720d4d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx @@ -0,0 +1,591 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { TableRenderer } from '../../src/react-pivottable/TableRenderers'; + +let tableRenderer: TableRenderer; +let mockGetAggregatedData: jest.Mock; +let mockSortAndCacheData: jest.Mock; + +const columnIndex = 0; +const visibleColKeys = [['col1'], ['col2']]; +const pivotData = { + subtotals: { + rowEnabled: true, + rowPartialOnTop: false, + }, +} as any; +const maxRowIndex = 2; + +const mockProps = { + rows: ['row1'], + cols: ['col1'], + data: [], + aggregatorName: 'Sum', + vals: ['value'], + valueFilter: {}, + sorters: {}, + rowOrder: 'key_a_to_z', + colOrder: 'key_a_to_z', + tableOptions: {}, + namesMapping: {}, + allowRenderHtml: false, + onContextMenu: jest.fn(), + aggregatorsFactory: jest.fn(), + defaultFormatter: jest.fn(), + customFormatters: {}, + rowEnabled: true, + rowPartialOnTop: false, + colEnabled: false, + colPartialOnTop: false, +}; + +beforeEach(() => { + tableRenderer = new TableRenderer(mockProps); + + mockGetAggregatedData = jest.fn(); + mockSortAndCacheData = jest.fn(); + + tableRenderer.getAggregatedData = mockGetAggregatedData; + tableRenderer.sortAndCacheData = mockSortAndCacheData; + + tableRenderer.cachedBasePivotSettings = { + pivotData: { + subtotals: { + rowEnabled: true, + rowPartialOnTop: false, + colEnabled: false, + colPartialOnTop: false, + }, + }, + rowKeys: [['A'], ['B'], ['C']], + } as any; + + tableRenderer.state = { + sortingOrder: [], + activeSortColumn: null, + collapsedRows: {}, + collapsedCols: {}, + } as any; +}); + +const mockGroups = { + B: { + currentVal: 20, + B1: { currentVal: 15 }, + B2: { currentVal: 5 }, + }, + A: { + currentVal: 10, + A1: { currentVal: 8 }, + A2: { currentVal: 2 }, + }, + C: { + currentVal: 30, + C1: { currentVal: 25 }, + C2: { currentVal: 5 }, + }, +}; + +const createMockPivotData = (rowData: Record) => { + return { + rowKeys: Object.keys(rowData).map(key => key.split('.')), + getAggregator: (rowKey: string[], colName: string) => ({ + value: () => rowData[rowKey.join('.')], + }), + }; +}; + +test('should set initial ascending sort when no active sort column', () => { + mockGetAggregatedData.mockReturnValue({ + A: { currentVal: 30 }, + B: { currentVal: 10 }, + C: { currentVal: 20 }, + }); + + const setStateMock = jest.fn(); + tableRenderer.setState = setStateMock; + + tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); + + expect(setStateMock).toHaveBeenCalled(); + + const [stateUpdater] = setStateMock.mock.calls[0]; + + expect(typeof stateUpdater).toBe('function'); + + const previousState = { + sortingOrder: [], + activeSortColumn: 0, + }; + + const newState = stateUpdater(previousState); + + expect(newState.sortingOrder[columnIndex]).toBe('asc'); + expect(newState.activeSortColumn).toBe(columnIndex); + + expect(mockGetAggregatedData).toHaveBeenCalledWith( + pivotData, + visibleColKeys[columnIndex], + false, + ); + + expect(mockSortAndCacheData).toHaveBeenCalledWith( + { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, + 'asc', + true, + false, + maxRowIndex, + ); +}); + +test('should toggle from asc to desc when clicking same column', () => { + mockGetAggregatedData.mockReturnValue({ + A: { currentVal: 30 }, + B: { currentVal: 10 }, + C: { currentVal: 20 }, + }); + const setStateMock = jest.fn(stateUpdater => { + if (typeof stateUpdater === 'function') { + const newState = stateUpdater({ + sortingOrder: ['asc' as never], + activeSortColumn: 0, + }); + + tableRenderer.state = { + ...tableRenderer.state, + ...newState, + }; + } + }); + tableRenderer.setState = setStateMock; + + tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); + + expect(mockSortAndCacheData).toHaveBeenCalledWith( + { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, + 'desc', + true, + false, + maxRowIndex, + ); +}); + +test('should check second call in sequence', () => { + mockGetAggregatedData.mockReturnValue({ + A: { currentVal: 30 }, + B: { currentVal: 10 }, + C: { currentVal: 20 }, + }); + + mockSortAndCacheData.mockClear(); + + const setStateMock = jest.fn(stateUpdater => { + if (typeof stateUpdater === 'function') { + const newState = stateUpdater(tableRenderer.state); + tableRenderer.state = { + ...tableRenderer.state, + ...newState, + }; + } + }); + tableRenderer.setState = setStateMock; + + tableRenderer.state = { + sortingOrder: [], + activeSortColumn: 0, + collapsedRows: {}, + collapsedCols: {}, + } as any; + tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); + + tableRenderer.state = { + sortingOrder: ['asc' as never], + activeSortColumn: 0 as any, + collapsedRows: {}, + collapsedCols: {}, + } as any; + tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex); + + expect(mockSortAndCacheData).toHaveBeenCalledTimes(2); + + expect(mockSortAndCacheData.mock.calls[0]).toEqual([ + { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, + 'asc', + true, + false, + maxRowIndex, + ]); + + expect(mockSortAndCacheData.mock.calls[1]).toEqual([ + { A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } }, + 'desc', + true, + false, + maxRowIndex, + ]); +}); + +test('should sort hierarchical data in descending order', () => { + tableRenderer = new TableRenderer(mockProps); + const groups = { + A: { + currentVal: 30, + A1: { currentVal: 13 }, + A2: { currentVal: 17 }, + }, + B: { + currentVal: 10, + B1: { currentVal: 7 }, + B2: { currentVal: 3 }, + }, + + C: { + currentVal: 18, + C1: { currentVal: 7 }, + C2: { currentVal: 11 }, + }, + }; + + const result = tableRenderer.sortAndCacheData(groups, 'desc', true, false, 2); + + expect(result).toBeDefined(); + + expect(Array.isArray(result)).toBe(true); + + expect(result).toEqual([ + ['A', 'A2'], + ['A', 'A1'], + ['A'], + ['C', 'C2'], + ['C', 'C1'], + ['C'], + ['B', 'B1'], + ['B', 'B2'], + ['B'], + ]); +}); + +test('should sort hierarchical data in ascending order', () => { + tableRenderer = new TableRenderer(mockProps); + const groups = { + A: { + currentVal: 30, + A1: { currentVal: 13 }, + A2: { currentVal: 17 }, + }, + B: { + currentVal: 10, + B1: { currentVal: 7 }, + B2: { currentVal: 3 }, + }, + + C: { + currentVal: 18, + C1: { currentVal: 7 }, + C2: { currentVal: 11 }, + }, + }; + + const result = tableRenderer.sortAndCacheData(groups, 'asc', true, false, 2); + + expect(result).toBeDefined(); + + expect(Array.isArray(result)).toBe(true); + + expect(result).toEqual([ + ['B', 'B2'], + ['B', 'B1'], + ['B'], + ['C', 'C1'], + ['C', 'C2'], + ['C'], + ['A', 'A1'], + ['A', 'A2'], + ['A'], + ]); +}); + +test('should calculate groups from pivot data', () => { + tableRenderer = new TableRenderer(mockProps); + const mockAggregator = (value: number) => ({ + value: () => value, + format: jest.fn(), + isSubtotal: false, + }); + + const mockPivotData = { + rowKeys: [['A'], ['B'], ['C']], + getAggregator: jest + .fn() + .mockReturnValueOnce(mockAggregator(30)) + .mockReturnValueOnce(mockAggregator(10)) + .mockReturnValueOnce(mockAggregator(20)), + }; + + const result = tableRenderer.getAggregatedData( + mockPivotData as any, + ['col1'], + false, + ); + + expect(result).toEqual({ + A: { currentVal: 30 }, + B: { currentVal: 10 }, + C: { currentVal: 20 }, + }); +}); + +test('should sort groups and convert to array in ascending order', () => { + tableRenderer = new TableRenderer(mockProps); + const result = tableRenderer.sortAndCacheData( + mockGroups, + 'asc', + true, + false, + 2, + ); + + expect(result).toEqual([ + ['A', 'A2'], + ['A', 'A1'], + ['A'], + ['B', 'B2'], + ['B', 'B1'], + ['B'], + ['C', 'C2'], + ['C', 'C1'], + ['C'], + ]); +}); + +test('should sort groups and convert to array in descending order', () => { + tableRenderer = new TableRenderer(mockProps); + const result = tableRenderer.sortAndCacheData( + mockGroups, + 'desc', + true, + false, + 2, + ); + + expect(result).toEqual([ + ['C', 'C1'], + ['C', 'C2'], + ['C'], + ['B', 'B1'], + ['B', 'B2'], + ['B'], + ['A', 'A1'], + ['A', 'A2'], + ['A'], + ]); +}); + +test('should handle rowPartialOnTop = true configuration', () => { + tableRenderer = new TableRenderer(mockProps); + const result = tableRenderer.sortAndCacheData( + mockGroups, + 'asc', + true, + true, + 2, + ); + + expect(result).toEqual([ + ['A'], + ['A', 'A2'], + ['A', 'A1'], + ['B'], + ['B', 'B2'], + ['B', 'B1'], + ['C'], + ['C', 'C2'], + ['C', 'C1'], + ]); +}); + +test('should handle rowEnabled = false and rowPartialOnTop = false, sorting asc', () => { + tableRenderer = new TableRenderer(mockProps); + + const result = tableRenderer.sortAndCacheData( + mockGroups, + 'asc', + false, + false, + 2, + ); + + expect(result).toEqual([ + ['A', 'A2'], + ['A', 'A1'], + ['B', 'B2'], + ['B', 'B1'], + ['C', 'C2'], + ['C', 'C1'], + ]); +}); + +test('should handle rowEnabled = false and rowPartialOnTop = false , sorting desc', () => { + tableRenderer = new TableRenderer(mockProps); + + const result = tableRenderer.sortAndCacheData( + mockGroups, + 'desc', + false, + false, + 2, + ); + + expect(result).toEqual([ + ['C', 'C1'], + ['C', 'C2'], + ['B', 'B1'], + ['B', 'B2'], + ['A', 'A1'], + ['A', 'A2'], + ]); +}); + +test('create hierarchical structure with subtotal at bottom', () => { + tableRenderer = new TableRenderer(mockProps); + const rowData = { + 'A.A1': 10, + 'A.A2': 20, + A: 30, + 'B.B1': 30, + 'B.B2': 40, + B: 70, + 'C.C1': 50, + 'C.C2': 60, + C: 110, + }; + + const pivotData = createMockPivotData(rowData); + const result = tableRenderer.getAggregatedData(pivotData, 'Col1', false); + + expect(result).toEqual({ + A: { + A1: { currentVal: 10 }, + A2: { currentVal: 20 }, + currentVal: 30, + }, + B: { + B1: { currentVal: 30 }, + B2: { currentVal: 40 }, + currentVal: 70, + }, + C: { + C1: { currentVal: 50 }, + C2: { currentVal: 60 }, + currentVal: 110, + }, + }); +}); + +test('create hierarchical structure with subtotal at top', () => { + tableRenderer = new TableRenderer(mockProps); + const rowData = { + A: 30, + 'A.A1': 10, + 'A.A2': 20, + B: 70, + 'B.B1': 30, + 'B.B2': 40, + C: 110, + 'C.C1': 50, + 'C.C2': 60, + }; + + const pivotData = createMockPivotData(rowData); + const result = tableRenderer.getAggregatedData(pivotData, 'Col1', true); + + expect(result).toEqual({ + A: { + A1: { currentVal: 10 }, + A2: { currentVal: 20 }, + currentVal: 30, + }, + B: { + B1: { currentVal: 30 }, + B2: { currentVal: 40 }, + currentVal: 70, + }, + C: { + C1: { currentVal: 50 }, + C2: { currentVal: 60 }, + currentVal: 110, + }, + }); +}); + +test('values ​​from the 3rd level of the hierarchy with a subtotal at the bottom', () => { + tableRenderer = new TableRenderer(mockProps); + const rowData = { + 'A.A1.A11': 10, + 'A.A1.A12': 20, + 'A.A1': 30, + 'A.A2': 30, + 'A.A3': 50, + A: 110, + }; + + const pivotData = createMockPivotData(rowData); + const result = tableRenderer.getAggregatedData(pivotData, 'Col1', false); + + expect(result).toEqual({ + A: { + A1: { + A11: { currentVal: 10 }, + A12: { currentVal: 20 }, + currentVal: 30, + }, + A2: { currentVal: 30 }, + A3: { currentVal: 50 }, + currentVal: 110, + }, + }); +}); + +test('values ​​from the 3rd level of the hierarchy with a subtotal at the top', () => { + tableRenderer = new TableRenderer(mockProps); + const rowData = { + A: 110, + 'A.A1': 30, + 'A.A1.A11': 10, + 'A.A1.A12': 20, + 'A.A2': 30, + 'A.A3': 50, + }; + + const pivotData = createMockPivotData(rowData); + const result = tableRenderer.getAggregatedData(pivotData, 'Col1', true); + + expect(result).toEqual({ + A: { + A1: { + A11: { currentVal: 10 }, + A12: { currentVal: 20 }, + currentVal: 30, + }, + A2: { currentVal: 30 }, + A3: { currentVal: 50 }, + currentVal: 110, + }, + }); +});