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