From 9c9edbe8bc482a70109bbf368ba6ca8d4633bdf2 Mon Sep 17 00:00:00 2001 From: Kamil Gabryjelski Date: Fri, 17 Dec 2021 12:00:32 +0100 Subject: [PATCH] chore(plugin-chart-pivot-table): migrate react-pivottable into superset codebase (#17769) * chore(plugin-chart-pivot-table): migrate react-pivottable into superset codebase * Fix lint errors * Use named export * Clean up the code --- superset-frontend/package-lock.json | 76 +- .../plugin-chart-pivot-table/package.json | 3 +- .../src/PivotTableChart.tsx | 151 +-- .../src/react-pivottable/PivotTable.jsx | 33 + .../src/react-pivottable/Styles.js | 139 +++ .../src/react-pivottable/TableRenderers.jsx | 890 ++++++++++++++++++ .../src/react-pivottable/index.js | 21 + .../src/react-pivottable/utilities.js | 853 +++++++++++++++++ 8 files changed, 2033 insertions(+), 133 deletions(-) create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.jsx create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/index.js create mode 100644 superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index d2629e51ec96e..6440b40615f49 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -21153,22 +21153,6 @@ "resolved": "plugins/preset-chart-xy", "link": true }, - "node_modules/@superset-ui/react-pivottable": { - "version": "0.12.12", - "resolved": "https://registry.npmjs.org/@superset-ui/react-pivottable/-/react-pivottable-0.12.12.tgz", - "integrity": "sha512-4+wx2kQy3IRKoWHTf2bIkXjlzDA0u/eN2k0FfLfJ5bdER2GuqZErWuKtiZzARsn5kSS9hPIrvt77uv52R3FnfQ==", - "dependencies": { - "immutability-helper": "^3.1.1", - "prop-types": "^15.7.2", - "react-draggable": "^4.4.3", - "react-sortablejs": "^6.0.0", - "sortablejs": "^1.13.0" - }, - "peerDependencies": { - "react": ">=15.0.0", - "react-dom": ">=15.0.0" - } - }, "node_modules/@svgr/babel-plugin-add-jsx-attribute": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", @@ -38200,11 +38184,6 @@ "url": "https://opencollective.com/immer" } }, - "node_modules/immutability-helper": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", - "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" - }, "node_modules/immutable": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", @@ -50613,21 +50592,6 @@ "react-dom": "^0.14.0 || ^15.0.0 || ^16.0.0" } }, - "node_modules/react-sortablejs": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.0.0.tgz", - "integrity": "sha512-vzi+TWOnofcYg+dYnC/Iz/ZZkBGG76uM6KaLwuAqBk0349JQxIy3PZizbK0TJdLlK6NnLt4CiEyyQXSSnVYvEw==", - "dependencies": { - "classnames": "^2.2.6", - "tiny-invariant": "^1.1.0" - }, - "peerDependencies": { - "@types/sortablejs": "^1.10.0", - "react": "^16.9.0", - "react-dom": "^16.9.0", - "sortablejs": "^1.10.0" - } - }, "node_modules/react-split": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.9.tgz", @@ -53258,11 +53222,6 @@ "node": ">=8" } }, - "node_modules/sortablejs": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", - "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" - }, "node_modules/source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", @@ -61724,8 +61683,7 @@ "license": "Apache-2.0", "dependencies": { "@superset-ui/chart-controls": "0.18.25", - "@superset-ui/core": "0.18.25", - "@superset-ui/react-pivottable": "^0.12.12" + "@superset-ui/core": "0.18.25" }, "devDependencies": { "@babel/types": "^7.13.12", @@ -78661,7 +78619,6 @@ "@babel/types": "^7.13.12", "@superset-ui/chart-controls": "0.18.25", "@superset-ui/core": "0.18.25", - "@superset-ui/react-pivottable": "^0.12.12", "@types/jest": "^26.0.0", "jest": "^26.0.1" } @@ -78845,18 +78802,6 @@ } } }, - "@superset-ui/react-pivottable": { - "version": "0.12.12", - "resolved": "https://registry.npmjs.org/@superset-ui/react-pivottable/-/react-pivottable-0.12.12.tgz", - "integrity": "sha512-4+wx2kQy3IRKoWHTf2bIkXjlzDA0u/eN2k0FfLfJ5bdER2GuqZErWuKtiZzARsn5kSS9hPIrvt77uv52R3FnfQ==", - "requires": { - "immutability-helper": "^3.1.1", - "prop-types": "^15.7.2", - "react-draggable": "^4.4.3", - "react-sortablejs": "^6.0.0", - "sortablejs": "^1.13.0" - } - }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-5.4.0.tgz", @@ -92170,11 +92115,6 @@ "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.6.tgz", "integrity": "sha512-G95ivKpy+EvVAnAab4fVa4YGYn24J1SpEktnJX7JJ45Bd7xqME/SCplFzYFmTbrkwZbQ4xJK1xMTUYBkN6pWsQ==" }, - "immutability-helper": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/immutability-helper/-/immutability-helper-3.1.1.tgz", - "integrity": "sha512-Q0QaXjPjwIju/28TsugCHNEASwoCcJSyJV3uO1sOIQGI0jKgm9f41Lvz0DZj3n46cNCyAZTsEYoY4C2bVRUzyQ==" - }, "immutable": { "version": "3.8.2", "resolved": "https://registry.npmjs.org/immutable/-/immutable-3.8.2.tgz", @@ -101932,15 +101872,6 @@ "prop-types": "^15.5.7" } }, - "react-sortablejs": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/react-sortablejs/-/react-sortablejs-6.0.0.tgz", - "integrity": "sha512-vzi+TWOnofcYg+dYnC/Iz/ZZkBGG76uM6KaLwuAqBk0349JQxIy3PZizbK0TJdLlK6NnLt4CiEyyQXSSnVYvEw==", - "requires": { - "classnames": "^2.2.6", - "tiny-invariant": "^1.1.0" - } - }, "react-split": { "version": "2.0.9", "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.9.tgz", @@ -104007,11 +103938,6 @@ } } }, - "sortablejs": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", - "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==" - }, "source-list-map": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/package.json b/superset-frontend/plugins/plugin-chart-pivot-table/package.json index cb9c707dc1424..25e99aa297509 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/package.json +++ b/superset-frontend/plugins/plugin-chart-pivot-table/package.json @@ -27,8 +27,7 @@ }, "dependencies": { "@superset-ui/chart-controls": "0.18.25", - "@superset-ui/core": "0.18.25", - "@superset-ui/react-pivottable": "^0.12.12" + "@superset-ui/core": "0.18.25" }, "peerDependencies": { "@ant-design/icons": "^4.2.2", diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx index 446c3929c88ea..ab2d9b727ef1d 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/PivotTableChart.tsx @@ -28,15 +28,8 @@ import { styled, useTheme, } from '@superset-ui/core'; -// @ts-ignore -import PivotTable from '@superset-ui/react-pivottable/PivotTable'; -import { - sortAs, - aggregatorTemplates, - // @ts-ignore -} from '@superset-ui/react-pivottable/Utilities'; -import '@superset-ui/react-pivottable/pivottable.css'; import { isAdhocColumn } from '@superset-ui/chart-controls'; +import { PivotTable, sortAs, aggregatorTemplates } from './react-pivottable'; import { FilterType, MetricsLayoutEnum, @@ -63,6 +56,7 @@ const PivotTableWrapper = styled.div` const METRIC_KEY = 'metric'; const iconStyle = { stroke: 'black', strokeWidth: '16px' }; +const vals = ['value']; const aggregatorsFactory = (formatter: NumberFormatter) => ({ Count: aggregatorTemplates.count(formatter), @@ -142,17 +136,29 @@ export default function PivotTableChart(props: PivotTableProps) { } = props; const theme = useTheme(); - const defaultFormatter = getNumberFormatter(valueFormat); - const columnFormatsArray = Object.entries(columnFormats); + const defaultFormatter = useMemo( + () => getNumberFormatter(valueFormat), + [valueFormat], + ); + const columnFormatsArray = useMemo( + () => Object.entries(columnFormats), + [columnFormats], + ); const hasCustomMetricFormatters = columnFormatsArray.length > 0; - const metricFormatters = - hasCustomMetricFormatters && - Object.fromEntries( - columnFormatsArray.map(([metric, format]) => [ - metric, - getNumberFormatter(format), - ]), - ); + const metricFormatters = useMemo( + () => + hasCustomMetricFormatters + ? { + [METRIC_KEY]: Object.fromEntries( + columnFormatsArray.map(([metric, format]) => [ + metric, + getNumberFormatter(format), + ]), + ), + } + : undefined, + [columnFormatsArray, hasCustomMetricFormatters], + ); const metricNames = useMemo( () => @@ -179,18 +185,40 @@ export default function PivotTableChart(props: PivotTableProps) { ), [data, metricNames], ); - const groupbyRows = groupbyRowsRaw.map(getColumnLabel); - const groupbyColumns = groupbyColumnsRaw.map(getColumnLabel); + const groupbyRows = useMemo( + () => groupbyRowsRaw.map(getColumnLabel), + [groupbyRowsRaw], + ); + const groupbyColumns = useMemo( + () => groupbyColumnsRaw.map(getColumnLabel), + [groupbyColumnsRaw], + ); + + const sorters = useMemo( + () => ({ + [METRIC_KEY]: sortAs(metricNames), + }), + [metricNames], + ); - let [rows, cols] = transposePivot - ? [groupbyColumns, groupbyRows] - : [groupbyRows, groupbyColumns]; + const [rows, cols] = useMemo(() => { + let [rows_, cols_] = transposePivot + ? [groupbyColumns, groupbyRows] + : [groupbyRows, groupbyColumns]; - if (metricsLayout === MetricsLayoutEnum.ROWS) { - rows = combineMetric ? [...rows, METRIC_KEY] : [METRIC_KEY, ...rows]; - } else { - cols = combineMetric ? [...cols, METRIC_KEY] : [METRIC_KEY, ...cols]; - } + if (metricsLayout === MetricsLayoutEnum.ROWS) { + rows_ = combineMetric ? [...rows_, METRIC_KEY] : [METRIC_KEY, ...rows_]; + } else { + cols_ = combineMetric ? [...cols_, METRIC_KEY] : [METRIC_KEY, ...cols_]; + } + return [rows_, cols_]; + }, [ + combineMetric, + groupbyColumns, + groupbyRows, + metricsLayout, + transposePivot, + ]); const handleChange = useCallback( (filters: SelectedFiltersType) => { @@ -235,7 +263,7 @@ export default function PivotTableChart(props: PivotTableProps) { }, }); }, - [setDataMask], + [groupbyColumnsRaw, groupbyRowsRaw, setDataMask], ); const toggleFilter = useCallback( @@ -290,6 +318,39 @@ export default function PivotTableChart(props: PivotTableProps) { [emitFilter, selectedFilters, handleChange], ); + const tableOptions = useMemo( + () => ({ + clickRowHeaderCallback: toggleFilter, + clickColumnHeaderCallback: toggleFilter, + colTotals, + rowTotals, + highlightHeaderCellsOnHover: emitFilter, + highlightedHeaderCells: selectedFilters, + omittedHighlightHeaderGroups: [METRIC_KEY], + cellColorFormatters: { [METRIC_KEY]: metricColorFormatters }, + dateFormatters, + }), + [ + colTotals, + dateFormatters, + emitFilter, + metricColorFormatters, + rowTotals, + selectedFilters, + toggleFilter, + ], + ); + + const subtotalOptions = useMemo( + () => ({ + colSubtotalDisplay: { displayOnTop: colSubtotalPosition }, + rowSubtotalDisplay: { displayOnTop: rowSubtotalPosition }, + arrowCollapsed: , + arrowExpanded: , + }), + [colSubtotalPosition, rowSubtotalPosition], + ); + return ( @@ -299,36 +360,14 @@ export default function PivotTableChart(props: PivotTableProps) { cols={cols} aggregatorsFactory={aggregatorsFactory} defaultFormatter={defaultFormatter} - customFormatters={ - hasCustomMetricFormatters - ? { [METRIC_KEY]: metricFormatters } - : undefined - } + customFormatters={metricFormatters} aggregatorName={aggregateFunction} - vals={['value']} - rendererName="Table With Subtotal" + vals={vals} colOrder={colOrder} rowOrder={rowOrder} - sorters={{ - metric: sortAs(metricNames), - }} - tableOptions={{ - clickRowHeaderCallback: toggleFilter, - clickColumnHeaderCallback: toggleFilter, - colTotals, - rowTotals, - highlightHeaderCellsOnHover: emitFilter, - highlightedHeaderCells: selectedFilters, - omittedHighlightHeaderGroups: [METRIC_KEY], - cellColorFormatters: { [METRIC_KEY]: metricColorFormatters }, - dateFormatters, - }} - subtotalOptions={{ - colSubtotalDisplay: { displayOnTop: colSubtotalPosition }, - rowSubtotalDisplay: { displayOnTop: rowSubtotalPosition }, - arrowCollapsed: , - arrowExpanded: , - }} + sorters={sorters} + tableOptions={tableOptions} + subtotalOptions={subtotalOptions} namesMapping={verboseMap} /> diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.jsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.jsx new file mode 100644 index 0000000000000..bb79069d90192 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.jsx @@ -0,0 +1,33 @@ +/** + * 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 React from 'react'; +import { PivotData } from './utilities'; +import { TableRenderer } from './TableRenderers'; + +class PivotTable extends React.PureComponent { + render() { + return ; + } +} + +PivotTable.propTypes = PivotData.propTypes; +PivotTable.defaultProps = PivotData.defaultProps; + +export default PivotTable; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js new file mode 100644 index 0000000000000..8c68f2351871e --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/Styles.js @@ -0,0 +1,139 @@ +/** + * 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 { styled } from '@superset-ui/core'; + +export const Styles = styled.div` + table.pvtTable { + position: relative; + font-size: 12px; + text-align: left; + margin-top: 3px; + margin-left: 3px; + border-collapse: separate; + font-family: 'Inter', Helvetica, Arial, sans-serif; + line-height: 1.4; + } + + table thead { + position: sticky; + top: 0; + } + + table.pvtTable thead tr th, + table.pvtTable tbody tr th { + background-color: #fff; + border-top: 1px solid #e0e0e0; + border-left: 1px solid #e0e0e0; + font-size: 12px; + padding: 5px; + font-weight: normal; + } + + table.pvtTable tbody tr.pvtRowTotals { + position: sticky; + bottom: 0; + } + + table.pvtTable thead tr:last-of-type th, + table.pvtTable thead tr:first-of-type th.pvtTotalLabel, + table.pvtTable thead tr:nth-last-of-type(2) th.pvtColLabel, + table.pvtTable thead th.pvtSubtotalLabel, + table.pvtTable tbody tr:last-of-type th, + table.pvtTable tbody tr:last-of-type td { + border-bottom: 1px solid #e0e0e0; + } + + table.pvtTable + thead + tr:last-of-type:not(:only-child) + th.pvtAxisLabel + ~ th.pvtColLabel, + table.pvtTable tbody tr:first-of-type th, + table.pvtTable tbody tr:first-of-type td { + border-top: none; + } + + table.pvtTable tbody tr td:last-of-type, + table.pvtTable thead tr th:last-of-type:not(.pvtSubtotalLabel) { + border-right: 1px solid #e0e0e0; + } + + table.pvtTable + thead + tr:last-of-type:not(:only-child) + th.pvtAxisLabel + + .pvtTotalLabel { + border-right: none; + } + + table.pvtTable tr th.active { + background-color: #d9dbe4; + } + + table.pvtTable .pvtTotalLabel { + text-align: right; + font-weight: bold; + } + + table.pvtTable .pvtSubtotalLabel { + font-weight: bold; + } + + table.pvtTable tbody tr td { + color: #2a3f5f; + padding: 5px; + background-color: #fff; + border-top: 1px solid #e0e0e0; + border-left: 1px solid #e0e0e0; + vertical-align: top; + text-align: right; + } + + table.pvtTable tbody tr th.pvtRowLabel { + vertical-align: baseline; + } + + .pvtTotal, + .pvtGrandTotal { + font-weight: bold; + } + + table.pvtTable tbody tr td.pvtRowTotal { + vertical-align: middle; + } + + .toggle-wrapper { + white-space: nowrap; + } + + .toggle-wrapper > .toggle-val { + white-space: normal; + } + + .toggle { + padding-right: 4px; + cursor: pointer; + } + + .hoverable:hover { + background-color: #eceef2; + cursor: pointer; + } +`; 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 new file mode 100644 index 0000000000000..fe0c2fb0d522c --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.jsx @@ -0,0 +1,890 @@ +/** + * 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 React from 'react'; +import PropTypes from 'prop-types'; +import { PivotData, flatKey } from './utilities'; +import { Styles } from './Styles'; + +const parseLabel = value => { + if (typeof value === 'number' || typeof value === 'string') { + return value; + } + return String(value); +}; + +function displayHeaderCell( + needToggle, + ArrowIcon, + onArrowClick, + value, + namesMapping, +) { + const name = namesMapping[value] || value; + return needToggle ? ( + + + {ArrowIcon} + + {parseLabel(name)} + + ) : ( + parseLabel(name) + ); +} + +export class TableRenderer 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: {} }; + + this.clickHeaderHandler = this.clickHeaderHandler.bind(this); + this.clickHandler = this.clickHandler.bind(this); + } + + getBasePivotSettings() { + // One-time extraction of pivot settings that we'll use throughout the render. + + const { props } = this; + const colAttrs = props.cols; + const rowAttrs = props.rows; + + const tableOptions = { + rowTotals: true, + colTotals: true, + ...props.tableOptions, + }; + const rowTotals = tableOptions.rowTotals || colAttrs.length === 0; + const colTotals = tableOptions.colTotals || rowAttrs.length === 0; + + const namesMapping = props.namesMapping || {}; + const subtotalOptions = { + arrowCollapsed: '\u25B2', + arrowExpanded: '\u25BC', + ...props.subtotalOptions, + }; + + const colSubtotalDisplay = { + displayOnTop: false, + enabled: rowTotals, + hideOnExpand: false, + ...subtotalOptions.colSubtotalDisplay, + }; + + const rowSubtotalDisplay = { + displayOnTop: false, + enabled: colTotals, + hideOnExpand: false, + ...subtotalOptions.rowSubtotalDisplay, + }; + + const pivotData = new PivotData(props, { + 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); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + colKeys.forEach(colKey => { + cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler( + pivotData, + rowKey, + colKey, + ); + }); + }); + + // Add in totals as well. + if (rowTotals) { + rowKeys.forEach(rowKey => { + rowTotalCallbacks[flatKey(rowKey)] = this.clickHandler( + pivotData, + rowKey, + [], + ); + }); + } + if (colTotals) { + colKeys.forEach(colKey => { + colTotalCallbacks[flatKey(colKey)] = this.clickHandler( + pivotData, + [], + colKey, + ); + }); + } + if (rowTotals && colTotals) { + grandTotalCallback = this.clickHandler(pivotData, [], []); + } + } + + return { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: subtotalOptions.arrowCollapsed, + arrowExpanded: subtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + namesMapping, + }; + } + + 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 += 1) { + 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 += 1) { + const attr = rowAttrs[i]; + if (rowValues[i] !== null) { + filters[attr] = rowValues[i]; + } + } + return e => + this.props.tableOptions.clickCallback(e, value, filters, pivotData); + } + + clickHeaderHandler( + pivotData, + values, + attrs, + attrIdx, + callback, + isSubtotal = false, + isGrandTotal = false, + ) { + const filters = {}; + for (let i = 0; i <= attrIdx; i += 1) { + const attr = attrs[i]; + filters[attr] = values[i]; + } + return e => + callback( + e, + values[attrIdx], + filters, + pivotData, + isSubtotal, + isGrandTotal, + ); + } + + collapseAttr(rowOrCol, attrIdx, allKeys) { + return e => { + // Collapse an entire attribute. + e.stopPropagation(); + const keyLen = attrIdx + 1; + const collapsed = allKeys.filter(k => k.length === keyLen).map(flatKey); + + const updates = {}; + collapsed.forEach(k => { + updates[k] = true; + }); + + if (rowOrCol) { + this.setState(state => ({ + collapsedRows: { ...state.collapsedRows, ...updates }, + })); + } else { + this.setState(state => ({ + collapsedCols: { ...state.collapsedCols, ...updates }, + })); + } + }; + } + + expandAttr(rowOrCol, attrIdx, allKeys) { + return e => { + // Expand an entire attribute. This implicitly implies expanding all of the + // parents as well. It's a bit inefficient but ah well... + e.stopPropagation(); + const updates = {}; + allKeys.forEach(k => { + for (let i = 0; i <= attrIdx; i += 1) { + updates[flatKey(k.slice(0, i + 1))] = false; + } + }); + + if (rowOrCol) { + this.setState(state => ({ + collapsedRows: { ...state.collapsedRows, ...updates }, + })); + } else { + this.setState(state => ({ + collapsedCols: { ...state.collapsedCols, ...updates }, + })); + } + }; + } + + toggleRowKey(flatRowKey) { + return e => { + e.stopPropagation(); + this.setState(state => ({ + collapsedRows: { + ...state.collapsedRows, + [flatRowKey]: !state.collapsedRows[flatRowKey], + }, + })); + }; + } + + toggleColKey(flatColKey) { + return e => { + e.stopPropagation(); + this.setState(state => ({ + collapsedCols: { + ...state.collapsedCols, + [flatColKey]: !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 + // every level. The return value is a nested array of the same shape. It has + // -1's for repeated values and the span number otherwise. + + const spans = []; + // Index of the last new value + const li = Array(numAttrs).map(() => 0); + let lv = Array(numAttrs).map(() => null); + for (let i = 0; i < attrArr.length; i += 1) { + // Keep increasing span values as long as the last keys are the same. For + // the rest, record spans of 1. Update the indices too. + const cv = attrArr[i]; + const ent = []; + let depth = 0; + const limit = Math.min(lv.length, cv.length); + while (depth < limit && lv[depth] === cv[depth]) { + ent.push(-1); + spans[li[depth]][depth] += 1; + depth += 1; + } + while (depth < cv.length) { + li[depth] = i; + ent.push(1); + depth += 1; + } + spans.push(ent); + lv = cv; + } + return spans; + } + + renderColHeaderRow(attrName, attrIdx, pivotSettings) { + // Render a single row in the column header at the top of the pivot table. + + const { + rowAttrs, + colAttrs, + colKeys, + visibleColKeys, + colAttrSpans, + rowTotals, + arrowExpanded, + arrowCollapsed, + colSubtotalDisplay, + maxColVisible, + pivotData, + namesMapping, + } = pivotSettings; + const { + highlightHeaderCellsOnHover, + omittedHighlightHeaderGroups = [], + highlightedHeaderCells, + dateFormatters, + } = this.props.tableOptions; + + const spaceCell = + attrIdx === 0 && rowAttrs.length !== 0 ? ( + + ) : null; + + const needToggle = + colSubtotalDisplay.enabled && attrIdx !== colAttrs.length - 1; + let arrowClickHandle = null; + let subArrow = null; + if (needToggle) { + arrowClickHandle = + attrIdx + 1 < maxColVisible + ? this.collapseAttr(false, attrIdx, colKeys) + : this.expandAttr(false, attrIdx, colKeys); + subArrow = attrIdx + 1 < maxColVisible ? arrowExpanded : arrowCollapsed; + } + const attrNameCell = ( + + {displayHeaderCell( + needToggle, + subArrow, + arrowClickHandle, + attrName, + namesMapping, + )} + + ); + + const attrValueCells = []; + const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0; + // Iterate through columns. Jump over duplicate values. + let i = 0; + while (i < visibleColKeys.length) { + const colKey = visibleColKeys[i]; + const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1; + let colLabelClass = 'pvtColLabel'; + if (attrIdx < colKey.length) { + if ( + highlightHeaderCellsOnHover && + !omittedHighlightHeaderGroups.includes(colAttrs[attrIdx]) + ) { + colLabelClass += ' hoverable'; + } + if ( + highlightedHeaderCells && + Array.isArray(highlightedHeaderCells[colAttrs[attrIdx]]) && + highlightedHeaderCells[colAttrs[attrIdx]].includes(colKey[attrIdx]) + ) { + colLabelClass += ' active'; + } + + 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 headerCellFormattedValue = + dateFormatters && + dateFormatters[attrName] && + typeof dateFormatters[attrName] === 'function' + ? dateFormatters[attrName](colKey[attrIdx]) + : colKey[attrIdx]; + attrValueCells.push( + + {displayHeaderCell( + needToggle, + this.state.collapsedCols[flatColKey] + ? arrowCollapsed + : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + )} + , + ); + } else if (attrIdx === colKey.length) { + const rowSpan = colAttrs.length - colKey.length + rowIncrSpan; + attrValueCells.push( + + Subtotal + , + ); + } + // The next colSpan columns will have the same value anyway... + i += colSpan; + } + + const totalCell = + attrIdx === 0 && rowTotals ? ( + + {`Total (${this.props.aggregatorName})`} + + ) : null; + + const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; + return {cells}; + } + + renderRowHeaderRow(pivotSettings) { + // Render just the attribute names of the rows (the actual attribute values + // will show up in the individual rows). + + const { + rowAttrs, + colAttrs, + rowKeys, + arrowCollapsed, + arrowExpanded, + rowSubtotalDisplay, + maxRowVisible, + pivotData, + namesMapping, + } = pivotSettings; + return ( + + {rowAttrs.map((r, i) => { + const needLabelToggle = + rowSubtotalDisplay.enabled && i !== rowAttrs.length - 1; + let arrowClickHandle = null; + let subArrow = null; + if (needLabelToggle) { + arrowClickHandle = + i + 1 < maxRowVisible + ? this.collapseAttr(true, i, rowKeys) + : this.expandAttr(true, i, rowKeys); + subArrow = i + 1 < maxRowVisible ? arrowExpanded : arrowCollapsed; + } + return ( + + {displayHeaderCell( + needLabelToggle, + subArrow, + arrowClickHandle, + r, + namesMapping, + )} + + ); + })} + + {colAttrs.length === 0 + ? `Total (${this.props.aggregatorName})` + : null} + + + ); + } + + renderTableRow(rowKey, rowIdx, pivotSettings) { + // Render a single row in the pivot table. + + const { + rowAttrs, + colAttrs, + rowAttrSpans, + visibleColKeys, + pivotData, + rowTotals, + rowSubtotalDisplay, + arrowExpanded, + arrowCollapsed, + cellCallbacks, + rowTotalCallbacks, + namesMapping, + } = pivotSettings; + + const { + highlightHeaderCellsOnHover, + omittedHighlightHeaderGroups = [], + highlightedHeaderCells, + cellColorFormatters, + dateFormatters, + } = this.props.tableOptions; + const flatRowKey = flatKey(rowKey); + + const colIncrSpan = colAttrs.length !== 0 ? 1 : 0; + const attrValueCells = rowKey.map((r, i) => { + let valueCellClassName = 'pvtRowLabel'; + if ( + highlightHeaderCellsOnHover && + !omittedHighlightHeaderGroups.includes(rowAttrs[i]) + ) { + valueCellClassName += ' hoverable'; + } + if ( + highlightedHeaderCells && + Array.isArray(highlightedHeaderCells[rowAttrs[i]]) && + highlightedHeaderCells[rowAttrs[i]].includes(r) + ) { + valueCellClassName += ' active'; + } + const rowSpan = rowAttrSpans[rowIdx][i]; + if (rowSpan > 0) { + const flatRowKey = flatKey(rowKey.slice(0, i + 1)); + const colSpan = 1 + (i === rowAttrs.length - 1 ? colIncrSpan : 0); + const needRowToggle = + rowSubtotalDisplay.enabled && i !== rowAttrs.length - 1; + const onArrowClick = needRowToggle + ? this.toggleRowKey(flatRowKey) + : null; + + const headerCellFormattedValue = + dateFormatters && dateFormatters[rowAttrs[i]] + ? dateFormatters[rowAttrs[i]](r) + : r; + return ( + + {displayHeaderCell( + needRowToggle, + this.state.collapsedRows[flatRowKey] + ? arrowCollapsed + : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + )} + + ); + } + return null; + }); + + const attrValuePaddingCell = + rowKey.length < rowAttrs.length ? ( + + Subtotal + + ) : null; + + const rowClickHandlers = cellCallbacks[flatRowKey] || {}; + const valueCells = visibleColKeys.map(colKey => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator(rowKey, colKey); + const aggValue = agg.value(); + + const keys = [...rowKey, ...colKey]; + let backgroundColor; + if (cellColorFormatters) { + Object.values(cellColorFormatters).forEach(cellColorFormatter => { + if (Array.isArray(cellColorFormatter)) { + keys.forEach(key => { + if (backgroundColor) { + return; + } + cellColorFormatter + .filter(formatter => formatter.column === key) + .forEach(formatter => { + const formatterResult = formatter.getColorFromValue(aggValue); + if (formatterResult) { + backgroundColor = formatterResult; + } + }); + }); + } + }); + } + + const style = agg.isSubtotal + ? { fontWeight: 'bold' } + : { backgroundColor }; + + return ( + + {agg.format(aggValue)} + + ); + }); + + let totalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator(rowKey, []); + const aggValue = agg.value(); + totalCell = ( + + {agg.format(aggValue)} + + ); + } + + const rowCells = [ + ...attrValueCells, + attrValuePaddingCell, + ...valueCells, + totalCell, + ]; + + return {rowCells}; + } + + renderTotalsRow(pivotSettings) { + // Render the final totals rows that has the totals for all the columns. + + const { + rowAttrs, + colAttrs, + visibleColKeys, + rowTotals, + pivotData, + colTotalCallbacks, + grandTotalCallback, + } = pivotSettings; + + const totalLabelCell = ( + + {`Total (${this.props.aggregatorName})`} + + ); + + const totalValueCells = visibleColKeys.map(colKey => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator([], colKey); + const aggValue = agg.value(); + + return ( + + {agg.format(aggValue)} + + ); + }); + + let grandTotalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator([], []); + const aggValue = agg.value(); + grandTotalCell = ( + + {agg.format(aggValue)} + + ); + } + + const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell]; + + return ( + + {totalCells} + + ); + } + + visibleKeys(keys, collapsed, numAttrs, subtotalDisplay) { + return keys.filter( + key => + // Is the key hidden by one of its parents? + !key.some((k, j) => collapsed[flatKey(key.slice(0, j))]) && + // Leaf key. + (key.length === numAttrs || + // Children hidden. Must show total. + flatKey(key) in collapsed || + // Don't hide totals. + !subtotalDisplay.hideOnExpand), + ); + } + + render() { + if (this.cachedProps !== this.props) { + this.cachedProps = this.props; + this.cachedBasePivotSettings = this.getBasePivotSettings(); + } + const { + colAttrs, + rowAttrs, + rowKeys, + colKeys, + colTotals, + rowSubtotalDisplay, + colSubtotalDisplay, + } = this.cachedBasePivotSettings; + + // Need to account for exclusions to compute the effective row + // and column keys. + const visibleRowKeys = this.visibleKeys( + rowKeys, + this.state.collapsedRows, + rowAttrs.length, + rowSubtotalDisplay, + ); + const visibleColKeys = this.visibleKeys( + colKeys, + this.state.collapsedCols, + colAttrs.length, + colSubtotalDisplay, + ); + + const pivotSettings = { + visibleRowKeys, + maxRowVisible: Math.max(...visibleRowKeys.map(k => k.length)), + visibleColKeys, + maxColVisible: Math.max(...visibleColKeys.map(k => k.length)), + rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length), + colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length), + ...this.cachedBasePivotSettings, + }; + + return ( + + + + {colAttrs.map((c, j) => + this.renderColHeaderRow(c, j, pivotSettings), + )} + {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)} + + + {visibleRowKeys.map((r, i) => + this.renderTableRow(r, i, pivotSettings), + )} + {colTotals && this.renderTotalsRow(pivotSettings)} + +
+
+ ); + } +} + +TableRenderer.propTypes = { + ...PivotData.propTypes, + tableOptions: PropTypes.object, +}; +TableRenderer.defaultProps = { ...PivotData.defaultProps, tableOptions: {} }; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/index.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/index.js new file mode 100644 index 0000000000000..8de0ee08d05b7 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/index.js @@ -0,0 +1,21 @@ +/** + * 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. + */ + +export { default as PivotTable } from './PivotTable'; +export * from './utilities'; diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js new file mode 100644 index 0000000000000..e6796a6fe8544 --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.js @@ -0,0 +1,853 @@ +/** + * 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 PropTypes from 'prop-types'; +import { t } from '@superset-ui/core'; + +const addSeparators = function (nStr, thousandsSep, decimalSep) { + const x = String(nStr).split('.'); + let x1 = x[0]; + const x2 = x.length > 1 ? decimalSep + x[1] : ''; + const rgx = /(\d+)(\d{3})/; + while (rgx.test(x1)) { + x1 = x1.replace(rgx, `$1${thousandsSep}$2`); + } + return x1 + x2; +}; + +const numberFormat = function (optsIn) { + const defaults = { + digitsAfterDecimal: 2, + scaler: 1, + thousandsSep: ',', + decimalSep: '.', + prefix: '', + suffix: '', + }; + const opts = { ...defaults, ...optsIn }; + return function (x) { + if (Number.isNaN(x) || !Number.isFinite(x)) { + return ''; + } + const result = addSeparators( + (opts.scaler * x).toFixed(opts.digitsAfterDecimal), + opts.thousandsSep, + opts.decimalSep, + ); + return `${opts.prefix}${result}${opts.suffix}`; + }; +}; + +const rx = /(\d+)|(\D+)/g; +const rd = /\d/; +const rz = /^0/; +const naturalSort = (as, bs) => { + // nulls first + if (bs !== null && as === null) { + return -1; + } + if (as !== null && bs === null) { + return 1; + } + + // then raw NaNs + if (typeof as === 'number' && Number.isNaN(as)) { + return -1; + } + if (typeof bs === 'number' && Number.isNaN(bs)) { + return 1; + } + + // numbers and numbery strings group together + const nas = Number(as); + const nbs = Number(bs); + if (nas < nbs) { + return -1; + } + if (nas > nbs) { + return 1; + } + + // within that, true numbers before numbery strings + if (typeof as === 'number' && typeof bs !== 'number') { + return -1; + } + if (typeof bs === 'number' && typeof as !== 'number') { + return 1; + } + if (typeof as === 'number' && typeof bs === 'number') { + return 0; + } + + // 'Infinity' is a textual number, so less than 'A' + if (Number.isNaN(nbs) && !Number.isNaN(nas)) { + return -1; + } + if (Number.isNaN(nas) && !Number.isNaN(nbs)) { + return 1; + } + + // finally, "smart" string sorting per http://stackoverflow.com/a/4373421/112871 + let a = String(as); + let b = String(bs); + if (a === b) { + return 0; + } + if (!rd.test(a) || !rd.test(b)) { + return a > b ? 1 : -1; + } + + // special treatment for strings containing digits + a = a.match(rx); + b = b.match(rx); + while (a.length && b.length) { + const a1 = a.shift(); + const b1 = b.shift(); + if (a1 !== b1) { + if (rd.test(a1) && rd.test(b1)) { + return a1.replace(rz, '.0') - b1.replace(rz, '.0'); + } + return a1 > b1 ? 1 : -1; + } + } + return a.length - b.length; +}; + +const sortAs = function (order) { + const mapping = {}; + + // sort lowercased keys similarly + const lMapping = {}; + order.forEach((element, i) => { + mapping[element] = i; + if (typeof element === 'string') { + lMapping[element.toLowerCase()] = i; + } + }); + return function (a, b) { + if (a in mapping && b in mapping) { + return mapping[a] - mapping[b]; + } + if (a in mapping) { + return -1; + } + if (b in mapping) { + return 1; + } + if (a in lMapping && b in lMapping) { + return lMapping[a] - lMapping[b]; + } + if (a in lMapping) { + return -1; + } + if (b in lMapping) { + return 1; + } + return naturalSort(a, b); + }; +}; + +const getSort = function (sorters, attr) { + if (sorters) { + if (typeof sorters === 'function') { + const sort = sorters(attr); + if (typeof sort === 'function') { + return sort; + } + } else if (attr in sorters) { + return sorters[attr]; + } + } + return naturalSort; +}; + +// aggregator templates default to US number formatting but this is overrideable +const usFmt = numberFormat(); +const usFmtInt = numberFormat({ digitsAfterDecimal: 0 }); +const usFmtPct = numberFormat({ + digitsAfterDecimal: 1, + scaler: 100, + suffix: '%', +}); + +const baseAggregatorTemplates = { + count(formatter = usFmtInt) { + return () => + function () { + return { + count: 0, + push() { + this.count += 1; + }, + value() { + return this.count; + }, + format: formatter, + }; + }; + }, + + uniques(fn, formatter = usFmtInt) { + return function ([attr]) { + return function () { + return { + uniq: [], + push(record) { + if (!Array.from(this.uniq).includes(record[attr])) { + this.uniq.push(record[attr]); + } + }, + value() { + return fn(this.uniq); + }, + format: formatter, + numInputs: typeof attr !== 'undefined' ? 0 : 1, + }; + }; + }; + }, + + sum(formatter = usFmt) { + return function ([attr]) { + return function () { + return { + sum: 0, + push(record) { + if (!Number.isNaN(parseFloat(record[attr]))) { + this.sum += parseFloat(record[attr]); + } + }, + value() { + return this.sum; + }, + format: formatter, + numInputs: typeof attr !== 'undefined' ? 0 : 1, + }; + }; + }; + }, + + extremes(mode, formatter = usFmt) { + return function ([attr]) { + return function (data) { + return { + val: null, + sorter: getSort( + typeof data !== 'undefined' ? data.sorters : null, + attr, + ), + push(record) { + let x = record[attr]; + if (['min', 'max'].includes(mode)) { + x = parseFloat(x); + if (!Number.isNaN(x)) { + this.val = Math[mode](x, this.val !== null ? this.val : x); + } + } + if ( + mode === 'first' && + this.sorter(x, this.val !== null ? this.val : x) <= 0 + ) { + this.val = x; + } + if ( + mode === 'last' && + this.sorter(x, this.val !== null ? this.val : x) >= 0 + ) { + this.val = x; + } + }, + value() { + return this.val; + }, + format(x) { + if (Number.isNaN(x)) { + return x; + } + return formatter(x); + }, + numInputs: typeof attr !== 'undefined' ? 0 : 1, + }; + }; + }; + }, + + quantile(q, formatter = usFmt) { + return function ([attr]) { + return function () { + return { + vals: [], + push(record) { + const x = parseFloat(record[attr]); + if (!Number.isNaN(x)) { + this.vals.push(x); + } + }, + value() { + if (this.vals.length === 0) { + return null; + } + this.vals.sort((a, b) => a - b); + const i = (this.vals.length - 1) * q; + return (this.vals[Math.floor(i)] + this.vals[Math.ceil(i)]) / 2.0; + }, + format: formatter, + numInputs: typeof attr !== 'undefined' ? 0 : 1, + }; + }; + }; + }, + + runningStat(mode = 'mean', ddof = 1, formatter = usFmt) { + return function ([attr]) { + return function () { + return { + n: 0.0, + m: 0.0, + s: 0.0, + push(record) { + const x = parseFloat(record[attr]); + if (Number.isNaN(x)) { + return; + } + this.n += 1.0; + if (this.n === 1.0) { + this.m = x; + } + const mNew = this.m + (x - this.m) / this.n; + this.s += (x - this.m) * (x - mNew); + this.m = mNew; + }, + value() { + if (mode === 'mean') { + if (this.n === 0) { + return 0 / 0; + } + return this.m; + } + if (this.n <= ddof) { + return 0; + } + switch (mode) { + case 'var': + return this.s / (this.n - ddof); + case 'stdev': + return Math.sqrt(this.s / (this.n - ddof)); + default: + throw new Error('unknown mode for runningStat'); + } + }, + format: formatter, + numInputs: typeof attr !== 'undefined' ? 0 : 1, + }; + }; + }; + }, + + sumOverSum(formatter = usFmt) { + return function ([num, denom]) { + return function () { + return { + sumNum: 0, + sumDenom: 0, + push(record) { + if (!Number.isNaN(parseFloat(record[num]))) { + this.sumNum += parseFloat(record[num]); + } + if (!Number.isNaN(parseFloat(record[denom]))) { + this.sumDenom += parseFloat(record[denom]); + } + }, + value() { + return this.sumNum / this.sumDenom; + }, + format: formatter, + numInputs: + typeof num !== 'undefined' && typeof denom !== 'undefined' ? 0 : 2, + }; + }; + }; + }, + + fractionOf(wrapped, type = 'total', formatter = usFmtPct) { + return (...x) => + function (data, rowKey, colKey) { + return { + selector: { total: [[], []], row: [rowKey, []], col: [[], colKey] }[ + type + ], + inner: wrapped(...Array.from(x || []))(data, rowKey, colKey), + push(record) { + this.inner.push(record); + }, + format: formatter, + value() { + return ( + this.inner.value() / + data + .getAggregator(...Array.from(this.selector || [])) + .inner.value() + ); + }, + numInputs: wrapped(...Array.from(x || []))().numInputs, + }; + }; + }, +}; + +const extendedAggregatorTemplates = { + countUnique(f) { + return baseAggregatorTemplates.uniques(x => x.length, f); + }, + listUnique(s, f) { + return baseAggregatorTemplates.uniques(x => x.join(s), f || (x => x)); + }, + max(f) { + return baseAggregatorTemplates.extremes('max', f); + }, + min(f) { + return baseAggregatorTemplates.extremes('min', f); + }, + first(f) { + return baseAggregatorTemplates.extremes('first', f); + }, + last(f) { + return baseAggregatorTemplates.extremes('last', f); + }, + median(f) { + return baseAggregatorTemplates.quantile(0.5, f); + }, + average(f) { + return baseAggregatorTemplates.runningStat('mean', 1, f); + }, + var(ddof, f) { + return baseAggregatorTemplates.runningStat('var', ddof, f); + }, + stdev(ddof, f) { + return baseAggregatorTemplates.runningStat('stdev', ddof, f); + }, +}; + +const aggregatorTemplates = { + ...baseAggregatorTemplates, + ...extendedAggregatorTemplates, +}; + +// default aggregators & renderers use US naming and number formatting +const aggregators = (tpl => ({ + Count: tpl.count(usFmtInt), + 'Count Unique Values': tpl.countUnique(usFmtInt), + 'List Unique Values': tpl.listUnique(', '), + Sum: tpl.sum(usFmt), + 'Integer Sum': tpl.sum(usFmtInt), + Average: tpl.average(usFmt), + Median: tpl.median(usFmt), + 'Sample Variance': tpl.var(1, usFmt), + 'Sample Standard Deviation': tpl.stdev(1, usFmt), + Minimum: tpl.min(usFmt), + Maximum: tpl.max(usFmt), + First: tpl.first(usFmt), + Last: tpl.last(usFmt), + 'Sum over Sum': tpl.sumOverSum(usFmt), + 'Sum as Fraction of Total': tpl.fractionOf(tpl.sum(), 'total', usFmtPct), + 'Sum as Fraction of Rows': tpl.fractionOf(tpl.sum(), 'row', usFmtPct), + 'Sum as Fraction of Columns': tpl.fractionOf(tpl.sum(), 'col', usFmtPct), + 'Count as Fraction of Total': tpl.fractionOf(tpl.count(), 'total', usFmtPct), + 'Count as Fraction of Rows': tpl.fractionOf(tpl.count(), 'row', usFmtPct), + 'Count as Fraction of Columns': tpl.fractionOf(tpl.count(), 'col', usFmtPct), +}))(aggregatorTemplates); + +const locales = { + en: { + aggregators, + localeStrings: { + renderError: 'An error occurred rendering the PivotTable results.', + computeError: 'An error occurred computing the PivotTable results.', + uiRenderError: 'An error occurred rendering the PivotTable UI.', + selectAll: 'Select All', + selectNone: 'Select None', + tooMany: '(too many to list)', + filterResults: 'Filter values', + apply: 'Apply', + cancel: 'Cancel', + totals: 'Totals', + vs: 'vs', + by: 'by', + }, + }, +}; + +// dateFormat deriver l10n requires month and day names to be passed in directly +const mthNamesEn = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; +const dayNamesEn = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; +const zeroPad = number => `0${number}`.substr(-2, 2); // eslint-disable-line no-magic-numbers + +const derivers = { + bin(col, binWidth) { + return record => record[col] - (record[col] % binWidth); + }, + dateFormat( + col, + formatString, + utcOutput = false, + mthNames = mthNamesEn, + dayNames = dayNamesEn, + ) { + const utc = utcOutput ? 'UTC' : ''; + return function (record) { + const date = new Date(Date.parse(record[col])); + if (Number.isNaN(date)) { + return ''; + } + return formatString.replace(/%(.)/g, function (m, p) { + switch (p) { + case 'y': + return date[`get${utc}FullYear`](); + case 'm': + return zeroPad(date[`get${utc}Month`]() + 1); + case 'n': + return mthNames[date[`get${utc}Month`]()]; + case 'd': + return zeroPad(date[`get${utc}Date`]()); + case 'w': + return dayNames[date[`get${utc}Day`]()]; + case 'x': + return date[`get${utc}Day`](); + case 'H': + return zeroPad(date[`get${utc}Hours`]()); + case 'M': + return zeroPad(date[`get${utc}Minutes`]()); + case 'S': + return zeroPad(date[`get${utc}Seconds`]()); + default: + return `%${p}`; + } + }); + }; + }, +}; + +// Given an array of attribute values, convert to a key that +// can be used in objects. +const flatKey = attrVals => attrVals.join(String.fromCharCode(0)); + +/* +Data Model class +*/ + +class PivotData { + constructor(inputProps = {}, subtotals = {}) { + this.props = { ...PivotData.defaultProps, ...inputProps }; + this.processRecord = this.processRecord.bind(this); + PropTypes.checkPropTypes( + PivotData.propTypes, + this.props, + 'prop', + 'PivotData', + ); + + this.aggregator = this.props + .aggregatorsFactory(this.props.defaultFormatter) + [this.props.aggregatorName](this.props.vals); + this.formattedAggregators = + this.props.customFormatters && + Object.entries(this.props.customFormatters).reduce( + (acc, [key, columnFormatter]) => { + acc[key] = {}; + Object.entries(columnFormatter).forEach(([column, formatter]) => { + acc[key][column] = this.props + .aggregatorsFactory(formatter) + [this.props.aggregatorName](this.props.vals); + }); + return acc; + }, + {}, + ); + this.tree = {}; + this.rowKeys = []; + this.colKeys = []; + this.rowTotals = {}; + this.colTotals = {}; + this.allTotal = this.aggregator(this, [], []); + this.subtotals = subtotals; + this.sorted = false; + + // iterate through input, accumulating data for cells + PivotData.forEachRecord(this.props.data, this.processRecord); + } + + getFormattedAggregator(record, totalsKeys) { + if (!this.formattedAggregators) { + return this.aggregator; + } + const [groupName, groupValue] = + Object.entries(record).find( + ([name, value]) => + this.formattedAggregators[name] && + this.formattedAggregators[name][value], + ) || []; + if ( + !groupName || + !groupValue || + (totalsKeys && !totalsKeys.includes(groupValue)) + ) { + return this.aggregator; + } + return this.formattedAggregators[groupName][groupValue] || this.aggregator; + } + + arrSort(attrs, partialOnTop, reverse = false) { + const sortersArr = attrs.map(a => getSort(this.props.sorters, a)); + return function (a, b) { + const limit = Math.min(a.length, b.length); + for (let i = 0; i < limit; i += 1) { + const sorter = sortersArr[i]; + const comparison = reverse ? sorter(b[i], a[i]) : sorter(a[i], b[i]); + if (comparison !== 0) { + return comparison; + } + } + return partialOnTop ? a.length - b.length : b.length - a.length; + }; + } + + sortKeys() { + if (!this.sorted) { + this.sorted = true; + const v = (r, c) => this.getAggregator(r, c).value(); + switch (this.props.rowOrder) { + case 'key_z_to_a': + this.rowKeys.sort( + this.arrSort(this.props.rows, this.subtotals.rowPartialOnTop, true), + ); + break; + case 'value_a_to_z': + this.rowKeys.sort((a, b) => naturalSort(v(a, []), v(b, []))); + break; + case 'value_z_to_a': + this.rowKeys.sort((a, b) => -naturalSort(v(a, []), v(b, []))); + break; + default: + this.rowKeys.sort( + this.arrSort(this.props.rows, this.subtotals.rowPartialOnTop), + ); + } + switch (this.props.colOrder) { + case 'key_z_to_a': + this.colKeys.sort( + this.arrSort(this.props.cols, this.subtotals.colPartialOnTop, true), + ); + break; + case 'value_a_to_z': + this.colKeys.sort((a, b) => naturalSort(v([], a), v([], b))); + break; + case 'value_z_to_a': + this.colKeys.sort((a, b) => -naturalSort(v([], a), v([], b))); + break; + default: + this.colKeys.sort( + this.arrSort(this.props.cols, this.subtotals.colPartialOnTop), + ); + } + } + } + + getColKeys() { + this.sortKeys(); + return this.colKeys; + } + + getRowKeys() { + this.sortKeys(); + return this.rowKeys; + } + + processRecord(record) { + // this code is called in a tight loop + const colKey = []; + const rowKey = []; + this.props.cols.forEach(col => { + colKey.push(col in record ? record[col] : 'null'); + }); + this.props.rows.forEach(row => { + rowKey.push(row in record ? record[row] : 'null'); + }); + + this.allTotal.push(record); + + const rowStart = this.subtotals.rowEnabled ? 1 : Math.max(1, rowKey.length); + const colStart = this.subtotals.colEnabled ? 1 : Math.max(1, colKey.length); + + let isRowSubtotal; + let isColSubtotal; + for (let ri = rowStart; ri <= rowKey.length; ri += 1) { + isRowSubtotal = ri < rowKey.length; + const fRowKey = rowKey.slice(0, ri); + const flatRowKey = flatKey(fRowKey); + if (!this.rowTotals[flatRowKey]) { + this.rowKeys.push(fRowKey); + this.rowTotals[flatRowKey] = this.getFormattedAggregator( + record, + rowKey, + )(this, fRowKey, []); + } + this.rowTotals[flatRowKey].push(record); + this.rowTotals[flatRowKey].isSubtotal = isRowSubtotal; + } + + for (let ci = colStart; ci <= colKey.length; ci += 1) { + isColSubtotal = ci < colKey.length; + const fColKey = colKey.slice(0, ci); + const flatColKey = flatKey(fColKey); + if (!this.colTotals[flatColKey]) { + this.colKeys.push(fColKey); + this.colTotals[flatColKey] = this.getFormattedAggregator( + record, + colKey, + )(this, [], fColKey); + } + this.colTotals[flatColKey].push(record); + this.colTotals[flatColKey].isSubtotal = isColSubtotal; + } + + // And now fill in for all the sub-cells. + for (let ri = rowStart; ri <= rowKey.length; ri += 1) { + isRowSubtotal = ri < rowKey.length; + const fRowKey = rowKey.slice(0, ri); + const flatRowKey = flatKey(fRowKey); + if (!this.tree[flatRowKey]) { + this.tree[flatRowKey] = {}; + } + for (let ci = colStart; ci <= colKey.length; ci += 1) { + isColSubtotal = ci < colKey.length; + const fColKey = colKey.slice(0, ci); + const flatColKey = flatKey(fColKey); + if (!this.tree[flatRowKey][flatColKey]) { + this.tree[flatRowKey][flatColKey] = this.getFormattedAggregator( + record, + )(this, fRowKey, fColKey); + } + this.tree[flatRowKey][flatColKey].push(record); + + this.tree[flatRowKey][flatColKey].isRowSubtotal = isRowSubtotal; + this.tree[flatRowKey][flatColKey].isColSubtotal = isColSubtotal; + this.tree[flatRowKey][flatColKey].isSubtotal = + isRowSubtotal || isColSubtotal; + } + } + } + + getAggregator(rowKey, colKey) { + let agg; + const flatRowKey = flatKey(rowKey); + const flatColKey = flatKey(colKey); + if (rowKey.length === 0 && colKey.length === 0) { + agg = this.allTotal; + } else if (rowKey.length === 0) { + agg = this.colTotals[flatColKey]; + } else if (colKey.length === 0) { + agg = this.rowTotals[flatRowKey]; + } else { + agg = this.tree[flatRowKey][flatColKey]; + } + return ( + agg || { + value() { + return null; + }, + format() { + return ''; + }, + } + ); + } +} + +// can handle arrays or jQuery selections of tables +PivotData.forEachRecord = function (input, processRecord) { + if (Array.isArray(input)) { + // array of objects + return input.map(record => processRecord(record)); + } + throw new Error(t('Unknown input format')); +}; + +PivotData.defaultProps = { + aggregators, + cols: [], + rows: [], + vals: [], + aggregatorName: 'Count', + sorters: {}, + rowOrder: 'key_a_to_z', + colOrder: 'key_a_to_z', +}; + +PivotData.propTypes = { + data: PropTypes.oneOfType([PropTypes.array, PropTypes.object, PropTypes.func]) + .isRequired, + aggregatorName: PropTypes.string, + cols: PropTypes.arrayOf(PropTypes.string), + rows: PropTypes.arrayOf(PropTypes.string), + vals: PropTypes.arrayOf(PropTypes.string), + valueFilter: PropTypes.objectOf(PropTypes.objectOf(PropTypes.bool)), + sorters: PropTypes.oneOfType([ + PropTypes.func, + PropTypes.objectOf(PropTypes.func), + ]), + derivedAttributes: PropTypes.objectOf(PropTypes.func), + rowOrder: PropTypes.oneOf([ + 'key_a_to_z', + 'key_z_to_a', + 'value_a_to_z', + 'value_z_to_a', + ]), + colOrder: PropTypes.oneOf([ + 'key_a_to_z', + 'key_z_to_a', + 'value_a_to_z', + 'value_z_to_a', + ]), +}; + +export { + aggregatorTemplates, + aggregators, + derivers, + locales, + naturalSort, + numberFormat, + getSort, + sortAs, + flatKey, + PivotData, +};