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
This commit is contained in:
Kamil Gabryjelski 2021-12-17 12:00:32 +01:00 committed by GitHub
parent c5af7a48df
commit 9c9edbe8bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 2033 additions and 133 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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],
);
let [rows, cols] = transposePivot
? [groupbyColumns, groupbyRows]
: [groupbyRows, groupbyColumns];
const sorters = useMemo(
() => ({
[METRIC_KEY]: sortAs(metricNames),
}),
[metricNames],
);
if (metricsLayout === MetricsLayoutEnum.ROWS) {
rows = combineMetric ? [...rows, METRIC_KEY] : [METRIC_KEY, ...rows];
} else {
cols = combineMetric ? [...cols, METRIC_KEY] : [METRIC_KEY, ...cols];
}
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_];
}
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: <PlusSquareOutlined style={iconStyle} />,
arrowExpanded: <MinusSquareOutlined style={iconStyle} />,
}),
[colSubtotalPosition, rowSubtotalPosition],
);
return (
<Styles height={height} width={width} margin={theme.gridUnit * 4}>
<PivotTableWrapper>
@ -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: <PlusSquareOutlined style={iconStyle} />,
arrowExpanded: <MinusSquareOutlined style={iconStyle} />,
}}
sorters={sorters}
tableOptions={tableOptions}
subtotalOptions={subtotalOptions}
namesMapping={verboseMap}
/>
</PivotTableWrapper>

View File

@ -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 <TableRenderer {...this.props} />;
}
}
PivotTable.propTypes = PivotData.propTypes;
PivotTable.defaultProps = PivotData.defaultProps;
export default PivotTable;

View File

@ -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;
}
`;

View File

@ -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 ? (
<span className="toggle-wrapper">
<span
role="button"
tabIndex="0"
className="toggle"
onClick={onArrowClick}
>
{ArrowIcon}
</span>
<span className="toggle-val">{parseLabel(name)}</span>
</span>
) : (
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 ? (
<th
key="padding"
colSpan={rowAttrs.length}
rowSpan={colAttrs.length}
aria-hidden="true"
/>
) : 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 = (
<th key="label" className="pvtAxisLabel">
{displayHeaderCell(
needToggle,
subArrow,
arrowClickHandle,
attrName,
namesMapping,
)}
</th>
);
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(
<th
className={colLabelClass}
key={`colKey-${flatColKey}`}
colSpan={colSpan}
rowSpan={rowSpan}
onClick={this.clickHeaderHandler(
pivotData,
colKey,
this.props.cols,
attrIdx,
this.props.tableOptions.clickColumnHeaderCallback,
)}
>
{displayHeaderCell(
needToggle,
this.state.collapsedCols[flatColKey]
? arrowCollapsed
: arrowExpanded,
onArrowClick,
headerCellFormattedValue,
namesMapping,
)}
</th>,
);
} else if (attrIdx === colKey.length) {
const rowSpan = colAttrs.length - colKey.length + rowIncrSpan;
attrValueCells.push(
<th
className={`${colLabelClass} pvtSubtotalLabel`}
key={`colKeyBuffer-${flatKey(colKey)}`}
colSpan={colSpan}
rowSpan={rowSpan}
onClick={this.clickHeaderHandler(
pivotData,
colKey,
this.props.cols,
attrIdx,
this.props.tableOptions.clickColumnHeaderCallback,
true,
)}
>
Subtotal
</th>,
);
}
// The next colSpan columns will have the same value anyway...
i += colSpan;
}
const totalCell =
attrIdx === 0 && rowTotals ? (
<th
key="total"
className="pvtTotalLabel"
rowSpan={colAttrs.length + Math.min(rowAttrs.length, 1)}
onClick={this.clickHeaderHandler(
pivotData,
[],
this.props.cols,
attrIdx,
this.props.tableOptions.clickColumnHeaderCallback,
false,
true,
)}
>
{`Total (${this.props.aggregatorName})`}
</th>
) : null;
const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
}
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 (
<tr key="rowHdr">
{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 (
<th className="pvtAxisLabel" key={`rowAttr-${i}`}>
{displayHeaderCell(
needLabelToggle,
subArrow,
arrowClickHandle,
r,
namesMapping,
)}
</th>
);
})}
<th
className="pvtTotalLabel"
key="padding"
onClick={this.clickHeaderHandler(
pivotData,
[],
this.props.rows,
0,
this.props.tableOptions.clickRowHeaderCallback,
false,
true,
)}
>
{colAttrs.length === 0
? `Total (${this.props.aggregatorName})`
: null}
</th>
</tr>
);
}
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 (
<th
key={`rowKeyLabel-${i}`}
className={valueCellClassName}
rowSpan={rowSpan}
colSpan={colSpan}
onClick={this.clickHeaderHandler(
pivotData,
rowKey,
this.props.rows,
i,
this.props.tableOptions.clickRowHeaderCallback,
)}
>
{displayHeaderCell(
needRowToggle,
this.state.collapsedRows[flatRowKey]
? arrowCollapsed
: arrowExpanded,
onArrowClick,
headerCellFormattedValue,
namesMapping,
)}
</th>
);
}
return null;
});
const attrValuePaddingCell =
rowKey.length < rowAttrs.length ? (
<th
className="pvtRowLabel pvtSubtotalLabel"
key="rowKeyBuffer"
colSpan={rowAttrs.length - rowKey.length + colIncrSpan}
rowSpan={1}
onClick={this.clickHeaderHandler(
pivotData,
rowKey,
this.props.rows,
rowKey.length,
this.props.tableOptions.clickRowHeaderCallback,
true,
)}
>
Subtotal
</th>
) : 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 (
<td
role="gridcell"
className="pvtVal"
key={`pvtVal-${flatColKey}`}
onClick={rowClickHandlers[flatColKey]}
style={style}
>
{agg.format(aggValue)}
</td>
);
});
let totalCell = null;
if (rowTotals) {
const agg = pivotData.getAggregator(rowKey, []);
const aggValue = agg.value();
totalCell = (
<td
role="gridcell"
key="total"
className="pvtTotal"
onClick={rowTotalCallbacks[flatRowKey]}
>
{agg.format(aggValue)}
</td>
);
}
const rowCells = [
...attrValueCells,
attrValuePaddingCell,
...valueCells,
totalCell,
];
return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
}
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 = (
<th
key="label"
className="pvtTotalLabel pvtRowTotalLabel"
colSpan={rowAttrs.length + Math.min(colAttrs.length, 1)}
onClick={this.clickHeaderHandler(
pivotData,
[],
this.props.rows,
0,
this.props.tableOptions.clickRowHeaderCallback,
false,
true,
)}
>
{`Total (${this.props.aggregatorName})`}
</th>
);
const totalValueCells = visibleColKeys.map(colKey => {
const flatColKey = flatKey(colKey);
const agg = pivotData.getAggregator([], colKey);
const aggValue = agg.value();
return (
<td
role="gridcell"
className="pvtTotal pvtRowTotal"
key={`total-${flatColKey}`}
onClick={colTotalCallbacks[flatColKey]}
style={{ padding: '5px' }}
>
{agg.format(aggValue)}
</td>
);
});
let grandTotalCell = null;
if (rowTotals) {
const agg = pivotData.getAggregator([], []);
const aggValue = agg.value();
grandTotalCell = (
<td
role="gridcell"
key="total"
className="pvtGrandTotal pvtRowTotal"
onClick={grandTotalCallback}
>
{agg.format(aggValue)}
</td>
);
}
const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
return (
<tr key="total" className="pvtRowTotals">
{totalCells}
</tr>
);
}
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 (
<Styles>
<table className="pvtTable" role="grid">
<thead>
{colAttrs.map((c, j) =>
this.renderColHeaderRow(c, j, pivotSettings),
)}
{rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)}
</thead>
<tbody>
{visibleRowKeys.map((r, i) =>
this.renderTableRow(r, i, pivotSettings),
)}
{colTotals && this.renderTotalsRow(pivotSettings)}
</tbody>
</table>
</Styles>
);
}
}
TableRenderer.propTypes = {
...PivotData.propTypes,
tableOptions: PropTypes.object,
};
TableRenderer.defaultProps = { ...PivotData.defaultProps, tableOptions: {} };

View File

@ -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';

View File

@ -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,
};