From a8f2675afe1b5db62dfb90c580a53c6893254cab Mon Sep 17 00:00:00 2001 From: jl Date: Tue, 3 Jun 2025 16:07:54 +0200 Subject: [PATCH 01/14] fix(PivotTableUI): prevent duplicate item state from React 18 double render - Added guard logic in propUpdater to prevent duplicate items from being pushed to state. - Improved state initialization to handle React 18's double rendering in Strict Mode. - Added unit test to ensure duplicate state is not introduced on re-renders. --- src/PivotTableUI.jsx | 30 ++++- src/__tests__/PivotTableUI-test.js | 190 +++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/PivotTableUI-test.js diff --git a/src/PivotTableUI.jsx b/src/PivotTableUI.jsx index 0de055f..5d9acd1 100644 --- a/src/PivotTableUI.jsx +++ b/src/PivotTableUI.jsx @@ -284,12 +284,38 @@ class PivotTableUI extends React.PureComponent { this.setState(newState); } + handleDuplicates(newAttributes, existingAttributes) { + if (!newAttributes || !existingAttributes) { + return existingAttributes || []; + } + const duplicates = newAttributes.filter(item => existingAttributes.includes(item)); + return duplicates.length > 0 + ? existingAttributes.filter(item => !duplicates.includes(item)) + : existingAttributes; + } + sendPropUpdate(command) { this.props.onChange(update(this.props, command)); } propUpdater(key) { - return value => this.sendPropUpdate({[key]: {$set: value}}); + return value => { + const update = {[key]: {$set: value}}; + + if (key === 'rows') { + const updatedCols = this.handleDuplicates(value, this.props.cols); + if (updatedCols.length !== this.props.cols.length) { + update.cols = {$set: updatedCols}; + } + } else if (key === 'cols') { + const updatedRows = this.handleDuplicates(value, this.props.rows); + if (updatedRows.length !== this.props.rows.length) { + update.rows = {$set: updatedRows}; + } + } + + this.sendPropUpdate(update); + }; } setValuesInFilter(attribute, values) { @@ -502,7 +528,6 @@ class PivotTableUI extends React.PureComponent { !this.props.hiddenAttributes.includes(e) && !this.props.hiddenFromDragDrop.includes(e) ); - const colAttrsCell = this.makeDnDCell( colAttrs, this.propUpdater('cols'), @@ -519,6 +544,7 @@ class PivotTableUI extends React.PureComponent { this.propUpdater('rows'), 'pvtAxisContainer pvtVertList pvtRows' ); + const outputCell = ( { + describe('handleDuplicates', () => { + // Create a minimal instance of PivotTableUI for testing + const getInstance = () => { + const pivotTableUI = new PivotTableUI({ + onChange: () => {}, + renderers: {}, + aggregators: {}, + rows: [], + cols: [], + rendererName: '', + aggregatorName: '', + vals: [], + valueFilter: {}, + rowOrder: 'key_a_to_z', + colOrder: 'key_a_to_z', + derivedAttributes: {}, + data: [] + }); + return pivotTableUI; + }; + + it('returns existingAttributes when newAttributes is null or undefined', () => { + const instance = getInstance(); + expect(instance.handleDuplicates(null, ['a', 'b'])).toEqual(['a', 'b']); + expect(instance.handleDuplicates(undefined, ['a', 'b'])).toEqual(['a', 'b']); + }); + + it('returns empty array when both inputs are null or undefined', () => { + const instance = getInstance(); + expect(instance.handleDuplicates(null, null)).toEqual([]); + expect(instance.handleDuplicates(undefined, undefined)).toEqual([]); + }); + + it('returns existingAttributes when there are no duplicates', () => { + const instance = getInstance(); + const newAttributes = ['a', 'b', 'c']; + const existingAttributes = ['d', 'e', 'f']; + expect(instance.handleDuplicates(newAttributes, existingAttributes)) + .toEqual(existingAttributes); + }); + + it('removes duplicates from existingAttributes', () => { + const instance = getInstance(); + const newAttributes = ['a', 'b', 'c']; + const existingAttributes = ['b', 'c', 'd']; + // 'b' and 'c' are duplicates and should be removed + expect(instance.handleDuplicates(newAttributes, existingAttributes)) + .toEqual(['d']); + }); + + it('handles empty newAttributes', () => { + const instance = getInstance(); + const newAttributes = []; + const existingAttributes = ['a', 'b', 'c']; + expect(instance.handleDuplicates(newAttributes, existingAttributes)) + .toEqual(existingAttributes); + }); + + it('handles empty existingAttributes', () => { + const instance = getInstance(); + const newAttributes = ['a', 'b', 'c']; + const existingAttributes = []; + expect(instance.handleDuplicates(newAttributes, existingAttributes)) + .toEqual([]); + }); + + it('handles case with all attributes being duplicates', () => { + const instance = getInstance(); + const newAttributes = ['a', 'b', 'c']; + const existingAttributes = ['a', 'b', 'c']; + expect(instance.handleDuplicates(newAttributes, existingAttributes)) + .toEqual([]); + }); + }); + + describe('propUpdater', () => { + // We'll use a mock to check if sendPropUpdate is called with the right arguments + let mockSendPropUpdate; + let instance; + + beforeEach(() => { + instance = new PivotTableUI({ + onChange: () => {}, + renderers: {}, + aggregators: {}, + rows: ['gender', 'age'], + cols: ['country', 'year'], + rendererName: '', + aggregatorName: '', + vals: [], + valueFilter: {}, + rowOrder: 'key_a_to_z', + colOrder: 'key_a_to_z', + derivedAttributes: {}, + data: [] + }); + // Mock the sendPropUpdate method + mockSendPropUpdate = jest.spyOn(instance, 'sendPropUpdate').mockImplementation(() => {}); + // Mock the handleDuplicates method to control its return value + jest.spyOn(instance, 'handleDuplicates'); + }); + + afterEach(() => { + mockSendPropUpdate.mockRestore(); + instance.handleDuplicates.mockRestore(); + }); + + it('calls handleDuplicates when key is "rows"', () => { + const newRows = ['gender', 'name']; + const updater = instance.propUpdater('rows'); + + // Set up the mock to return the same cols (no duplicates found) + instance.handleDuplicates.mockReturnValueOnce(instance.props.cols); + + updater(newRows); + + expect(instance.handleDuplicates).toHaveBeenCalledWith(newRows, instance.props.cols); + expect(mockSendPropUpdate).toHaveBeenCalledWith({ + rows: { $set: newRows } + }); + }); + + it('calls handleDuplicates when key is "cols"', () => { + const newCols = ['country', 'city']; + const updater = instance.propUpdater('cols'); + + // Set up the mock to return the same rows (no duplicates found) + instance.handleDuplicates.mockReturnValueOnce(instance.props.rows); + + updater(newCols); + + expect(instance.handleDuplicates).toHaveBeenCalledWith(newCols, instance.props.rows); + expect(mockSendPropUpdate).toHaveBeenCalledWith({ + cols: { $set: newCols } + }); + }); + + it('updates cols when duplicate is found in rows update', () => { + const newRows = ['gender', 'country']; // 'country' is duplicate + const updater = instance.propUpdater('rows'); + + // 'country' is removed from cols + const updatedCols = ['year']; + instance.handleDuplicates.mockReturnValueOnce(updatedCols); + + updater(newRows); + + expect(mockSendPropUpdate).toHaveBeenCalledWith({ + rows: { $set: newRows }, + cols: { $set: updatedCols } + }); + }); + + it('updates rows when duplicate is found in cols update', () => { + const newCols = ['country', 'gender']; // 'gender' is duplicate + const updater = instance.propUpdater('cols'); + + // 'gender' is removed from rows + const updatedRows = ['age']; + instance.handleDuplicates.mockReturnValueOnce(updatedRows); + + updater(newCols); + + expect(mockSendPropUpdate).toHaveBeenCalledWith({ + cols: { $set: newCols }, + rows: { $set: updatedRows } + }); + }); + + it('does not update the other attribute if no duplicates found', () => { + const newRows = ['gender', 'name']; + const updater = instance.propUpdater('rows'); + + // No change to cols (same array reference) + instance.handleDuplicates.mockReturnValueOnce(instance.props.cols); + + updater(newRows); + + expect(mockSendPropUpdate).toHaveBeenCalledWith({ + rows: { $set: newRows } + }); + // We shouldn't have cols in the update + expect(mockSendPropUpdate.mock.calls[0][0].cols).toBeUndefined(); + }); + }); +}); From 068fc65e62eec5d86dfeb18326a779ebe83e0474 Mon Sep 17 00:00:00 2001 From: jl Date: Wed, 18 Jun 2025 16:30:24 +0200 Subject: [PATCH 02/14] feat: close dropdowns after selecting an item --- src/PivotTableUI.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/PivotTableUI.jsx b/src/PivotTableUI.jsx index 5d9acd1..0010c0f 100644 --- a/src/PivotTableUI.jsx +++ b/src/PivotTableUI.jsx @@ -210,6 +210,7 @@ export class Dropdown extends React.PureComponent { this.props.toggle(); } else { this.props.setValue(r); + this.props.toggle(); // Close the dropdown after selection } }} className={ From e62f56313b173db59bc62d74ee3409e93cf55dbd Mon Sep 17 00:00:00 2001 From: jl Date: Mon, 23 Jun 2025 17:23:46 +0200 Subject: [PATCH 03/14] feat: first implementation of heatmaps, without subtotal --- package.json | 1 + src/SubtotalRenderers.jsx | 1349 +++++++++++++++++++++++++++++++++++++ src/Utilities.js | 27 + 3 files changed, 1377 insertions(+) create mode 100644 src/SubtotalRenderers.jsx diff --git a/package.json b/package.json index f4adaf6..3fd05df 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "PivotTableUI.js", "PlotlyRenderers.js", "TableRenderers.js", + "SubtotalsRenderers.js", "Utilities.js", "PivotTable.js.map", "PivotTableUI.js.map", diff --git a/src/SubtotalRenderers.jsx b/src/SubtotalRenderers.jsx new file mode 100644 index 0000000..1e5010f --- /dev/null +++ b/src/SubtotalRenderers.jsx @@ -0,0 +1,1349 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {PivotData, flatKey} from './Utilities'; + +// helper function for setting row/col-span in pivotTableRenderer +const spanSize = function(arr, i, j) { + let x; + if (i !== 0) { + let asc, end; + let noDraw = true; + for ( + x = 0, end = j, asc = end >= 0; + asc ? x <= end : x >= end; + asc ? x++ : x-- + ) { + if (arr[i - 1][x] !== arr[i][x]) { + noDraw = false; + } + } + if (noDraw) { + return -1; + } + } + let len = 0; + while (i + len < arr.length) { + let asc1, end1; + let stop = false; + for ( + x = 0, end1 = j, asc1 = end1 >= 0; + asc1 ? x <= end1 : x >= end1; + asc1 ? x++ : x-- + ) { + if (arr[i][x] !== arr[i + len][x]) { + stop = true; + } + } + if (stop) { + break; + } + len++; + } + return len; +}; + +function redColorScaleGenerator(values) { + const min = Math.min.apply(Math, values); + const max = Math.max.apply(Math, values); + return x => { + // eslint-disable-next-line no-magic-numbers + const nonRed = 255 - Math.round(255 * (x - min) / (max - min)); + return {backgroundColor: `rgb(255,${nonRed},${nonRed})`}; + }; +} + +function makeRenderer(opts = {}) { + class SubtotalRenderer extends React.Component { + constructor(props) { + super(props); + + // 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: {}}; + } + + getBasePivotSettings() { + // One-time extraction of pivot settings that we'll use throughout the render. + + const props = this.props; + const colAttrs = props.cols; + const rowAttrs = props.rows; + + const tableOptions = Object.assign( + { + rowTotals: true, + colTotals: true, + }, + props.tableOptions + ); + const rowTotals = tableOptions.rowTotals || colAttrs.length === 0; + const colTotals = tableOptions.colTotals || rowAttrs.length === 0; + + const subtotalOptions = Object.assign( + { + arrowCollapsed: '\u25B6', + arrowExpanded: '\u25E2', + }, + props.subtotalOptions + ); + + const colSubtotalDisplay = Object.assign( + { + displayOnTop: false, + enabled: rowTotals, + hideOnExpand: false, + }, + subtotalOptions.colSubtotalDisplay + ); + + const rowSubtotalDisplay = Object.assign( + { + displayOnTop: true, + enabled: colTotals, + hideOnExpand: false, + }, + subtotalOptions.rowSubtotalDisplay + ); + + const pivotData = new PivotData( + props, + !opts.subtotals + ? {} + : { + rowEnabled: rowSubtotalDisplay.enabled, + colEnabled: colSubtotalDisplay.enabled, + rowPartialOnTop: rowSubtotalDisplay.displayOnTop, + colPartialOnTop: colSubtotalDisplay.displayOnTop, + } + ); + const rowKeys = pivotData.getRowKeys(); + const colKeys = pivotData.getColKeys(); + + // Also pre-calculate all the callbacks for cells, etc... This is nice to have to + // avoid re-calculations of the call-backs on cell expansions, etc... + const cellCallbacks = {}; + const rowTotalCallbacks = {}; + const colTotalCallbacks = {}; + let grandTotalCallback = null; + if (tableOptions.clickCallback) { + rowKeys.forEach(rowKey => { + const flatRowKey = flatKey(rowKey); + cellCallbacks[flatRowKey] = {}; + colKeys.forEach(colKey => { + const flatColKey = flatKey(colKey); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + cellCallbacks[flatRowKey][flatColKey] = this.clickHandler( + pivotData, + rowKey, + colKey + ); + }); + rowTotalCallbacks[flatRowKey] = this.clickHandler( + pivotData, + rowKey, + [] + ); + }); + colKeys.forEach(colKey => { + const flatColKey = flatKey(colKey); + colTotalCallbacks[flatColKey] = this.clickHandler( + pivotData, + [], + colKey + ); + }); + grandTotalCallback = this.clickHandler(pivotData, [], []); + } + + return Object.assign( + { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: subtotalOptions.arrowCollapsed, + arrowExpanded: subtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + }, + SubtotalRenderer.heatmapMappers( + pivotData, + props.tableColorScaleGenerator, + colTotals, + rowTotals + ) + ); + } + + clickHandler(pivotData, rowValues, colValues) { + const colAttrs = this.props.cols; + const rowAttrs = this.props.rows; + const value = pivotData.getAggregator(rowValues, colValues).value(); + const filters = {}; + const colLimit = Math.min(colAttrs.length, colValues.length); + for (let i = 0; i < colLimit; i++) { + const attr = colAttrs[i]; + if (colValues[i] != null) { + filters[attr] = colValues[i]; + } + } + const rowLimit = Math.min(rowAttrs.length, rowValues.length); + for (let i = 0; i < rowLimit; i++) { + const attr = rowAttrs[i]; + if (rowValues[i] != null) { + filters[attr] = rowValues[i]; + } + } + return e => + this.props.tableOptions.clickCallback(e, value, filters, pivotData); + } + + collapseAttr(rowOrCol, attrIdx, allKeys){ + return () => { + const flatCollapseKeys = {}; + for (const k of allKeys) { + const slicedKey = k.slice(0, attrIdx + 1); + flatCollapseKeys[flatKey(slicedKey)] = true; + } + if (rowOrCol === 'row') { + this.setState({ + collapsedRows: Object.assign( + {}, + this.state.collapsedRows, + flatCollapseKeys + ), + }); + } else if (rowOrCol === 'col') { + this.setState({ + collapsedCols: Object.assign( + {}, + this.state.collapsedCols, + flatCollapseKeys + ), + }); + } + }; + } + + expandAttr(rowOrCol, attrIdx, allKeys) { + return () => { + const flatCollapseKeys = {}; + for (const k of allKeys) { + const slicedKey = k.slice(0, attrIdx + 1); + flatCollapseKeys[flatKey(slicedKey)] = false; + } + if (rowOrCol === 'row') { + this.setState({ + collapsedRows: Object.assign( + {}, + this.state.collapsedRows, + flatCollapseKeys + ), + }); + } else if (rowOrCol === 'col') { + this.setState({ + collapsedCols: Object.assign( + {}, + this.state.collapsedCols, + flatCollapseKeys + ), + }); + } + }; + } + + toggleRowKey(flatRowKey) { + return () => { + this.setState({ + collapsedRows: Object.assign({}, this.state.collapsedRows, { + [flatRowKey]: !this.state.collapsedRows[flatRowKey], + }), + }); + }; + } + + toggleColKey(flatColKey) { + return () => { + this.setState({ + collapsedCols: Object.assign({}, this.state.collapsedCols, { + [flatColKey]: !this.state.collapsedCols[flatColKey], + }), + }); + }; + } + + calcAttrSpans(attrArr, numAttrs) { + // Given an array of attribute values (i.e. each element is another array with + // the value at every level), compute the spans for every attribute value at + // each level. + const spans = {}; + const keys = {}; + for (let i = 0; i < numAttrs; i++) { + spans[i] = {}; + keys[i] = {}; + } + const matched = {}; + for (let i = 0; i < attrArr.length; i++) { + const arr = attrArr[i]; + const flatArr = []; + for (let j = 0; j < arr.length; j++) { + flatArr.push(flatKey(arr.slice(0, j + 1))); + } + for (let j = 0; j < arr.length; j++) { + if (flatArr[j] in matched) { + continue; + } + matched[flatArr[j]] = 1; + if (j > 0) { + if (arr[j - 1] === arr[j]) { + spans[j][flatArr[j]] = 0; + continue; + } + } + let count = 1; + while (i + count < attrArr.length) { + if (j >= attrArr[i + count].length) { + break; + } + if ( + flatKey(attrArr[i + count].slice(0, j + 1)) !== flatArr[j] + ) { + break; + } + count++; + } + spans[j][flatArr[j]] = count; + keys[j][flatArr[j]] = arr[j]; + } + } + return {spans, keys}; + } + + static heatmapMappers( + pivotData, + colorScaleGenerator, + colTotals, + rowTotals + ) { + const colMapper = {}; + const rowMapper = {}; + + if (colorScaleGenerator && opts.heatmapMode) { + const valueCellColors = {}; + const rowTotalColors = {}; + const colTotalColors = {}; + let grandTotalColor = null; + + const allValues = []; + const rowValues = {}; + const colValues = {}; + + pivotData.forEachCell((val, rowKey, colKey) => { + if (val !== null && val !== undefined && !isNaN(val)) { + allValues.push(val); + + const flatRow = flatKey(rowKey); + if (!rowValues[flatRow]) rowValues[flatRow] = []; + rowValues[flatRow].push(val); + + const flatCol = flatKey(colKey); + if (!colValues[flatCol]) colValues[flatCol] = []; + colValues[flatCol].push(val); + } + }); + + if (colTotals) { + const rowTotalValues = []; + pivotData.forEachTotal(([valKey, x]) => { + const val = pivotData.getAggregator([valKey], []).value(); + if (val !== null && val !== undefined && !isNaN(val)) { + rowTotalValues.push(val); + if (opts.heatmapMode === 'full') allValues.push(val); + } + }); + + const rowTotalColorScale = opts.heatmapMode === 'full' ? + colorScaleGenerator(allValues) : + colorScaleGenerator(rowTotalValues); + + pivotData.forEachTotal(([valKey, x], idx) => { + const val = pivotData.getAggregator([valKey], []).value(); + if (val !== null && val !== undefined && !isNaN(val)) { + rowTotalColors[flatKey([valKey])] = rowTotalColorScale(val); + } + }); + } + + if (rowTotals) { + const colTotalValues = []; + pivotData.forEachTotal(([x, valKey]) => { + const val = pivotData.getAggregator([], [valKey]).value(); + if (val !== null && val !== undefined && !isNaN(val)) { + colTotalValues.push(val); + if (opts.heatmapMode === 'full') allValues.push(val); + } + }); + + const colTotalColorScale = opts.heatmapMode === 'full' ? + colorScaleGenerator(allValues) : + colorScaleGenerator(colTotalValues); + + pivotData.forEachTotal(([x, valKey], idx) => { + const val = pivotData.getAggregator([], [valKey]).value(); + if (val !== null && val !== undefined && !isNaN(val)) { + colTotalColors[flatKey([valKey])] = colTotalColorScale(val); + } + }); + } + + if (colTotals && rowTotals) { + const grandTotalVal = pivotData.getAggregator([], []).value(); + if (grandTotalVal !== null && grandTotalVal !== undefined && !isNaN(grandTotalVal)) { + if (opts.heatmapMode === 'full') { + allValues.push(grandTotalVal); + const grandTotalColorScale = colorScaleGenerator(allValues); + grandTotalColor = grandTotalColorScale(grandTotalVal); + } + } + } + + if (rowTotals) { + colMapper.totalColor = key => colTotalColors[flatKey([key])]; + } + if (colTotals) { + rowMapper.totalColor = key => rowTotalColors[flatKey([key])]; + } + if (grandTotalColor) { + colMapper.grandTotalColor = grandTotalColor; + } + + if (opts.heatmapMode === 'full') { + // Full heatmap: Compare values across the entire table + // Note: allValues already contains all cell values from earlier collection + const colorScale = colorScaleGenerator(allValues); + + pivotData.forEachCell((val, rowKey, colKey) => { + if (val !== null && val !== undefined && !isNaN(val)) { + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`] = colorScale(val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + } + else if (opts.heatmapMode === 'row') { + // Row heatmap: Compare values within each row + // Note: rowValues already populated from earlier collection + + const rowColorScales = {}; + Object.entries(rowValues).forEach(([flatRow, values]) => { + if (values.length > 0) { + rowColorScales[flatRow] = colorScaleGenerator(values); + } + }); + + pivotData.forEachCell((val, rowKey, colKey) => { + const flatRow = flatKey(rowKey); + if (val !== null && val !== undefined && !isNaN(val) && rowColorScales[flatRow]) { + valueCellColors[`${flatRow}_${flatKey(colKey)}`] = rowColorScales[flatRow](val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + } + else if (opts.heatmapMode === 'col') { + // Column heatmap: Compare values within each column + // Note: colValues already populated from earlier collection + + const colColorScales = {}; + Object.entries(colValues).forEach(([flatCol, values]) => { + if (values.length > 0) { + colColorScales[flatCol] = colorScaleGenerator(values); + } + }); + + pivotData.forEachCell((val, rowKey, colKey) => { + const flatCol = flatKey(colKey); + if (val !== null && val !== undefined && !isNaN(val) && colColorScales[flatCol]) { + valueCellColors[`${flatKey(rowKey)}_${flatCol}`] = colColorScales[flatCol](val); + } + }); + + colMapper.bgColorFromRowColKey = (rowKey, colKey) => + valueCellColors[`${flatKey(rowKey)}_${flatKey(colKey)}`]; + } + } + return {colMapper, rowMapper}; + } + + renderColHeaderRow(attrName, attrIdx, pivotSettings) { + const { + colKeys, + colAttrs, + rowAttrs, + colSubtotalDisplay, + arrowCollapsed, + arrowExpanded, + } = pivotSettings; + const numAttrs = colAttrs.length; + const attrSpan = colKeys.length; + const totalHeadRowSpan = colAttrs.length + (rowAttrs.length ? 1 : 0); + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols, + numAttrs, + colSubtotalDisplay + ); + + const colSpans = this.calcAttrSpans(visibleColKeys, numAttrs); + const cells = []; + let colKeyIdx = 0; + + if (attrIdx === 0) { + const rowspan = rowAttrs.length === 0 ? 1 : 2; + if (rowAttrs.length !== 0) { + cells.push( + + ); + } + cells.push( + + {attrName} + + ); + } + + while (colKeyIdx < visibleColKeys.length) { + const colKey = visibleColKeys[colKeyIdx]; + const flatColKey = flatKey(colKey.slice(0, attrIdx + 1)); + const colSpan = colSpans.spans[attrIdx][flatColKey]; + if (colSpan > 0) { + let isCollapsed = false; + let isHidden = false; + if (attrIdx + 1 < colAttrs.length) { + isCollapsed = this.state.collapsedCols[flatColKey]; + isHidden = + attrIdx + 1 < colKey.length && colSubtotalDisplay.hideOnExpand; + } + const expandHandler = + attrIdx + 1 === colAttrs.length + ? null + : this.expandAttr('col', attrIdx, colKeys); + const collapseHandler = + attrIdx + 1 === colAttrs.length + ? null + : this.collapseAttr('col', attrIdx, colKeys); + const toggleHandler = this.toggleColKey(flatColKey); + const attrValue = colSpans.keys[attrIdx][flatColKey]; + let className = 'pvtColLabel'; + let clickHandler = null; + let icon = null; + if (attrIdx + 1 < colAttrs.length) { + if (isCollapsed) { + clickHandler = expandHandler; + className += ' collapsed'; + icon = arrowCollapsed; + } else { + clickHandler = collapseHandler; + className += ' expanded'; + icon = arrowExpanded; + } + } + if (isHidden) { + cells.push(null); + } else { + cells.push( + + {icon && {icon}} + {attrValue} + + ); + } + } + colKeyIdx += colSpan; + } + + if (attrIdx !== 0) { + cells.unshift( + + {attrName} + + ); + } + + if (pivotSettings.rowTotals && attrIdx === 0) { + cells.push( + + Totals + + ); + } + + return cells; + } + + renderRowHeaderRow(pivotSettings) { + const {colAttrs, rowAttrs} = pivotSettings; + const cells = []; + if (rowAttrs.length !== 0) { + rowAttrs.map(function(r, i) { + cells.push( + + {r} + + ); + }); + cells.push( + + {colAttrs.length === 0 ? 'Totals' : null} + + ); + } + return cells; + } + + renderTableRow(rowKey, rowIdx, pivotSettings) { + const { + colKeys, + rowAttrs, + colAttrs, + rowTotals, + pivotData, + rowMapper, + colMapper, + cellCallbacks, + rowTotalCallbacks, + } = pivotSettings; + + const flatRowKey = flatKey(rowKey); + const isCollapsed = this.state.collapsedRows[flatRowKey]; + + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols, + colAttrs.length, + pivotSettings.colSubtotalDisplay + ); + + const cells = []; + + const isParentWithChildren = rowKey.length < rowAttrs.length; + const isCollapsedParent = isCollapsed && isParentWithChildren; + + visibleColKeys.forEach((colKey, i) => { + try { + if (!rowKey || !colKey) { + console.warn('Invalid rowKey or colKey', rowKey, colKey); + cells.push( + + - + + ); + return; + } + + let aggregator, val, className, valCss = {}; + + if (isCollapsedParent) { + aggregator = pivotData.getAggregator(rowKey, colKey); + className = "pvtSubtotal"; + + if (opts.heatmapMode && rowMapper.totalColor) { + const cellColor = rowMapper.totalColor(rowKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + } + else if (colKey.length < colAttrs.length && this.state.collapsedCols[flatKey(colKey)]) { + aggregator = pivotData.getAggregator(rowKey, colKey); + className = "pvtSubtotal"; + + if (opts.heatmapMode && colMapper.totalColor) { + const cellColor = colMapper.totalColor(colKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + } + else { + aggregator = pivotData.getAggregator(rowKey, colKey); + className = "pvtVal"; + + if (opts.heatmapMode && colMapper.bgColorFromRowColKey) { + const cellColor = colMapper.bgColorFromRowColKey(rowKey, colKey); + if (cellColor) { + valCss = cellColor; + } + } + } + + if (!aggregator) { + console.warn('No aggregator found for', rowKey, colKey); + cells.push( + + - + + ); + return; + } + + val = aggregator.value(); + const formattedVal = (val === null || val === undefined) ? '-' : aggregator.format(val); + + cells.push( + + {formattedVal} + + ); + } catch (error) { + console.error('Error rendering table cell:', error, {rowKey, colKey, i}); + cells.push( + + - + + ); + } + }); + + if (rowTotals) { + try { + let rowTotal = 0; + let validValuesFound = false; + let valCss = {}; + + const className = isCollapsedParent ? "pvtTotal pvtSubtotal" : "pvtTotal"; + + if (opts.heatmapMode && rowMapper.totalColor) { + const cellColor = rowMapper.totalColor(rowKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + + visibleColKeys.forEach(colKey => { + try { + const flatColKey = flatKey(colKey); + const isColParent = colKey.length < colAttrs.length; + const isColCollapsed = this.state.collapsedCols[flatColKey]; + + if (!isColParent || isColCollapsed) { + const colAggregator = pivotData.getAggregator(rowKey, colKey); + if (colAggregator) { + const colVal = colAggregator.value(); + if (colVal !== null && colVal !== undefined && !isNaN(colVal)) { + rowTotal += colVal; + validValuesFound = true; + } + } + } + } catch (e) { + console.warn('Error calculating column value for row total', rowKey, colKey, e); + } + }); + + const totalAggregator = pivotData.getAggregator(rowKey, []); + const format = totalAggregator ? totalAggregator.format : null; + + cells.push( + + {validValuesFound ? (format ? format(rowTotal) : rowTotal) : '-'} + + ); + } catch (error) { + console.error('Error rendering row total:', error, {rowKey}); + cells.push( + + - + + ); + } + } + + return cells; + } + + renderTotalsRow(pivotSettings) { + const { + colKeys, + colAttrs, + rowAttrs, + rowKeys, + colTotals, + pivotData, + colMapper, + grandTotalCallback, + colTotalCallbacks, + } = pivotSettings; + + const totalRowSpan = colAttrs.length + (rowAttrs.length === 0 ? 0 : 1); + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols, + colAttrs.length, + pivotSettings.colSubtotalDisplay + ); + + const visibleRowKeys = this.visibleKeys( + rowKeys, + this.state.collapsedRows, + rowAttrs.length, + pivotSettings.rowSubtotalDisplay + ); + + const cells = []; + cells.push( + + Totals + + ); + + visibleColKeys.forEach((colKey, i) => { + try { + if (!colKey) { + console.warn('Invalid colKey in renderTotalsRow', colKey); + cells.push( + + - + + ); + return; + } + + let colTotal = 0; + let hasCollapsed = Object.values(this.state.collapsedRows).some(Boolean); + + // Always calculate manually to ensure accuracy with visible elements + // and avoid double counting parents and children + const processedRows = new Set(); + + visibleRowKeys.forEach(rowKey => { + const flatRowKey = flatKey(rowKey); + + if (processedRows.has(flatRowKey)) { + return; + } + + processedRows.add(flatRowKey); + + const isCollapsed = this.state.collapsedRows[flatRowKey]; + const isParent = rowKey.length < rowAttrs.length; + + if (isCollapsed && isParent) { + try { + const aggregator = pivotData.getAggregator(rowKey, colKey); + if (aggregator) { + const val = aggregator.value(); + if (val !== null && val !== undefined && !isNaN(val)) { + colTotal += val; + } + } + } catch (e) { + console.warn('Error calculating subtotal for collapsed parent', rowKey, colKey, e); + } + } else { + try { + const aggregator = pivotData.getAggregator(rowKey, colKey); + if (aggregator) { + const val = aggregator.value(); + if (val !== null && val !== undefined && !isNaN(val)) { + colTotal += val; + } + } + } catch (e) { + console.warn('Error calculating cell value', rowKey, colKey, e); + } + } + }); + + let valCss = {}; + if (opts.heatmapMode && colMapper.totalColor) { + const cellColor = colMapper.totalColor(colKey[0]); + if (cellColor) { + valCss = cellColor; + } + } + + const format = pivotData.getAggregator([], colKey).format; + + cells.push( + + {format ? format(colTotal) : colTotal} + + ); + } catch (error) { + console.error('Error rendering column total:', error, {colKey, i}); + cells.push( + + - + + ); + } + }); + + if (colTotals) { + try { + // We will calculate the grand total in two ways and compare the results: + // 1. By summing the column totals (which are already correctly calculated) + // 2. By summing all visible values + // This will allow us to verify the consistency of the calculations + + let grandTotal = 0; + let validValuesFound = false; + + // Méthode 1: Sommer les totaux de lignes visibles + const rowTotals = []; + + visibleRowKeys.forEach(rowKey => { + // Calculer le total de cette ligne en fonction des colonnes visibles + let rowTotal = 0; + const flatRowKey = flatKey(rowKey); + const isRowParent = rowKey.length < rowAttrs.length; + const isRowCollapsed = this.state.collapsedRows[flatRowKey]; + + // If it's a collapsed parent, use its subtotal value directly + if (isRowCollapsed && isRowParent) { + try { + const rowAggregator = pivotData.getAggregator(rowKey, []); + if (rowAggregator) { + const val = rowAggregator.value(); + if (val !== null && val !== undefined && !isNaN(val)) { + rowTotals.push(val); + validValuesFound = true; + } + } + } catch (e) { + console.warn('Error calculating row subtotal for grand total', rowKey, e); + } + } + // Otherwise, manually calculate the row total from visible columns + else { + let rowHasValues = false; + visibleColKeys.forEach(colKey => { + const flatColKey = flatKey(colKey); + const isColParent = colKey.length < colAttrs.length; + const isColCollapsed = this.state.collapsedCols[flatColKey]; + + if (!isColParent || isColCollapsed) { + try { + const cellAggregator = pivotData.getAggregator(rowKey, colKey); + if (cellAggregator) { + const val = cellAggregator.value(); + if (val !== null && val !== undefined && !isNaN(val)) { + rowTotal += val; + rowHasValues = true; + } + } + } catch (e) { + console.warn('Error calculating cell value for row total', rowKey, colKey, e); + } + } + }); + + if (rowHasValues) { + rowTotals.push(rowTotal); + validValuesFound = true; + } + } + }); + + grandTotal = rowTotals.reduce((sum, val) => sum + val, 0); + + const format = pivotData.getAggregator([], []).format; + + cells.push( + + {validValuesFound ? (format ? format(grandTotal) : grandTotal) : '-'} + + ); + } catch (error) { + console.error('Error rendering grand total:', error); + cells.push( + + - + + ); + } + } + + return cells; + } + + visibleKeys(keys, collapsed, numAttrs, subtotalDisplay) { + try { + if (!keys || !Array.isArray(keys)) { + console.warn('Invalid keys in visibleKeys', keys); + return []; + } + + if (!collapsed) { + console.warn('Invalid collapsed state in visibleKeys', collapsed); + collapsed = {}; + } + + if (!subtotalDisplay) { + console.warn('Invalid subtotalDisplay in visibleKeys', subtotalDisplay); + subtotalDisplay = { enabled: true, hideOnExpand: false }; + } + + const result = []; + const addedKeys = new Set(); + + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + + if (!key || !Array.isArray(key)) { + console.warn('Invalid key in keys array', key); + continue; + } + + let isChildOfCollapsed = false; + let collapsedParent = null; + let collapsedLevel = -1; + + for (let j = 0; j < key.length - 1; j++) { + const parentKey = key.slice(0, j + 1); + const parentFlatKey = flatKey(parentKey); + + if (collapsed[parentFlatKey]) { + isChildOfCollapsed = true; + collapsedParent = parentKey; + collapsedLevel = j; + break; + } + } + + const flatKeyStr = flatKey(key); + + if (key.length < numAttrs && collapsed[flatKeyStr]) { + if (!addedKeys.has(flatKeyStr)) { + result.push(key); + addedKeys.add(flatKeyStr); + } + } + else if (!isChildOfCollapsed) { + if (key.length < numAttrs && subtotalDisplay.enabled) { + const showSubtotal = !subtotalDisplay.hideOnExpand || collapsed[flatKeyStr]; + + if (showSubtotal && !addedKeys.has(flatKeyStr)) { + result.push(key); + addedKeys.add(flatKeyStr); + } + } + + if (!addedKeys.has(flatKeyStr)) { + result.push(key); + addedKeys.add(flatKeyStr); + } + } + else if (isChildOfCollapsed && collapsedParent) { + const parentFlatKey = flatKey(collapsedParent); + if (!addedKeys.has(parentFlatKey)) { + result.push(collapsedParent); + addedKeys.add(parentFlatKey); + } + } + } + + return result; + } catch (error) { + console.error('Error in visibleKeys method:', error); + return []; + } + } + + render() { + const pivotSettings = this.getBasePivotSettings(); + const { + colAttrs, + rowAttrs, + rowKeys, + colKeys, + rowTotals, + colTotals, + } = pivotSettings; + + const renderedLabels = {}; + + const visibleRowKeys = this.visibleKeys( + rowKeys, + this.state.collapsedRows, + rowAttrs.length, + pivotSettings.rowSubtotalDisplay + ); + + const rowspans = {}; + visibleRowKeys.forEach((rowKey, rowIdx) => { + for (let level = 0; level < rowKey.length; level++) { + const cellKey = `${rowIdx}-${level}`; + const value = rowKey[level]; + + let span = 1; + let j = rowIdx + 1; + while (j < visibleRowKeys.length) { + const nextKey = visibleRowKeys[j]; + if (level >= nextKey.length) break; + + let matches = true; + for (let l = 0; l <= level; l++) { + if (l >= nextKey.length || nextKey[l] !== rowKey[l]) { + matches = false; + break; + } + } + + if (!matches) break; + span++; + j++; + } + + rowspans[cellKey] = span; + } + }); + + const renderedRows = visibleRowKeys.map((rowKey, i) => { + const rowCells = []; + + for (let level = 0; level < rowKey.length; level++) { + const labelKey = `${rowKey.slice(0, level+1).join('|')}`; + + if (!renderedLabels[labelKey]) { + renderedLabels[labelKey] = true; + + const cellKey = `${i}-${level}`; + const rowspan = rowspans[cellKey] || 1; + + const flatRowKey = flatKey(rowKey.slice(0, level+1)); + const isCollapsed = this.state.collapsedRows[flatRowKey]; + + let className = 'pvtRowLabel'; + let icon = null; + + if (level + 1 < rowAttrs.length) { + if (isCollapsed) { + className += ' collapsed'; + icon = pivotSettings.arrowCollapsed; + } else { + className += ' expanded'; + icon = pivotSettings.arrowExpanded; + } + } + + rowCells.push( + + {icon && {icon}} + {rowKey[level]} + + ); + } + } + + if (rowKey.length < rowAttrs.length) { + rowCells.push( + + ); + } + + rowCells.push( + + ); + + const dataCells = this.renderTableRow(rowKey, i, pivotSettings); + + return ( + + {rowCells} + {dataCells} + + ); + }); + + const colAttrsHeaders = colAttrs.map((attrName, i) => { + return ( + + {this.renderColHeaderRow(attrName, i, pivotSettings)} + + ); + }); + + let rowAttrsHeader = null; + if (rowAttrs.length > 0) { + rowAttrsHeader = ( + {this.renderRowHeaderRow(pivotSettings)} + ); + } + + let totalHeader = null; + if (rowTotals) { + totalHeader = ( + {this.renderTotalsRow(pivotSettings)} + ); + } + + return ( + + + {colAttrsHeaders} + {rowAttrsHeader} + + + {renderedRows} + {totalHeader} + +
+ ); + } + } + + SubtotalRenderer.defaultProps = PivotData.defaultProps; + SubtotalRenderer.propTypes = PivotData.propTypes; + SubtotalRenderer.defaultProps.tableColorScaleGenerator = redColorScaleGenerator; + SubtotalRenderer.defaultProps.tableOptions = {}; + SubtotalRenderer.propTypes.tableColorScaleGenerator = PropTypes.func; + SubtotalRenderer.propTypes.tableOptions = PropTypes.object; + return SubtotalRenderer; +} + +class TSVExportRenderer extends React.PureComponent { + render() { + const pivotData = new PivotData(this.props); + const rowKeys = pivotData.getRowKeys(); + const colKeys = pivotData.getColKeys(); + if (rowKeys.length === 0) { + rowKeys.push([]); + } + if (colKeys.length === 0) { + colKeys.push([]); + } + + const headerRow = pivotData.props.rows.map(r => r); + if (colKeys.length === 1 && colKeys[0].length === 0) { + headerRow.push(this.props.aggregatorName); + } else { + colKeys.map(c => headerRow.push(c.join('-'))); + } + + const result = rowKeys.map(r => { + const row = r.map(x => x); + colKeys.map(c => { + const aggregator = pivotData.getAggregator(r, c); + row.push(aggregator.value()); + }); + return row; + }); + + result.unshift(headerRow); + + return ( +