From 7b81f1d0d90d65b6af6c6075178113467fcb3da4 Mon Sep 17 00:00:00 2001 From: Ryan Laurie <30528226+iethree@users.noreply.github.com> Date: Tue, 17 Jan 2023 12:40:24 -0700 Subject: [PATCH] Refactor PivotTable Visualization into a functional typescript component (#27651) * add PivotTable unit tests * obey the linter * measure leftHeader cell content * test cell data width detection * extract static properties * convert to functional component * cleanup refs * extract cellRenderers * extract size and position getter functions * replace item measurement * convert rowToggleIcon to typescript * convert PivotTable to typescript * remove unused hasCustomColors * update tests to typescript * fix rebase --- frontend/src/metabase-types/api/card.ts | 23 +- .../visualizations/lib/settings/column.js | 2 + .../visualizations/PivotTable/PivotTable.jsx | 614 ------------------ .../PivotTable/PivotTable.styled.tsx | 26 +- .../visualizations/PivotTable/PivotTable.tsx | 421 ++++++++++++ ...unit.spec.jsx => PivotTable.unit.spec.tsx} | 31 +- .../PivotTable/PivotTableCell.jsx | 48 -- .../PivotTable/PivotTableCell.tsx | 206 ++++++ .../{RowToggleIcon.jsx => RowToggleIcon.tsx} | 40 +- .../visualizations/PivotTable/constants.ts | 2 + .../visualizations/PivotTable/settings.ts | 253 ++++++++ .../visualizations/PivotTable/types.ts | 16 +- .../visualizations/PivotTable/utils.ts | 91 ++- .../PivotTable/utils.unit.spec.ts | 12 +- 14 files changed, 1081 insertions(+), 704 deletions(-) delete mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx rename frontend/src/metabase/visualizations/visualizations/PivotTable/{PivotTable.unit.spec.jsx => PivotTable.unit.spec.tsx} (87%) delete mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx rename frontend/src/metabase/visualizations/visualizations/PivotTable/{RowToggleIcon.jsx => RowToggleIcon.tsx} (70%) create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts index 5fede8011f0..332bfee179d 100644 --- a/frontend/src/metabase-types/api/card.ts +++ b/frontend/src/metabase-types/api/card.ts @@ -1,6 +1,10 @@ import type { DatabaseId } from "./database"; import type { Field } from "./field"; -import type { DatasetQuery } from "./query"; +import type { + DatasetQuery, + FieldReference, + AggregationReference, +} from "./query"; export interface Card extends UnsavedCard { id: CardId; @@ -46,6 +50,20 @@ export type SeriesOrderSetting = { color?: string; }; +export type ColumnFormattingSetting = { + columns: string[]; // column names + color?: string; + type?: string; + operator?: string; + value?: string | number; + highlight_row?: boolean; +}; + +export type PivotTableCollapsedRowsSetting = { + rows: (FieldReference | AggregationReference)[]; + value: string[]; // identifiers for collapsed rows +}; + export type VisualizationSettings = { "graph.show_values"?: boolean; "stackable.stack_type"?: "stacked" | "normalized" | null; @@ -77,6 +95,9 @@ export type VisualizationSettings = { // Funnel settings "funnel.rows"?: SeriesOrderSetting[]; + "table.column_formatting"?: ColumnFormattingSetting[]; + "pivot_table.collapsed_rows"?: PivotTableCollapsedRowsSetting; + [key: string]: any; }; diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js index 5fc75b9c180..427622fcf24 100644 --- a/frontend/src/metabase/visualizations/lib/settings/column.js +++ b/frontend/src/metabase/visualizations/lib/settings/column.js @@ -26,6 +26,7 @@ const DEFAULT_GET_COLUMNS = (series, vizSettings) => export function columnSettings({ getColumns = DEFAULT_GET_COLUMNS, + hidden, ...def } = {}) { return nestedSettings("column_settings", { @@ -37,6 +38,7 @@ export function columnSettings({ component: ChartNestedSettingColumns, getInheritedSettingsForObject: getInhertiedSettingsForColumn, useRawSeries: true, + hidden, ...def, }); } diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx deleted file mode 100644 index 7f4df36a6cb..00000000000 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx +++ /dev/null @@ -1,614 +0,0 @@ -/* eslint-disable react/prop-types */ -import React, { Component } from "react"; -import { t } from "ttag"; -import _ from "underscore"; -import { getIn } from "icepick"; -import { Grid, Collection, ScrollSync, AutoSizer } from "react-virtualized"; - -import { findDOMNode } from "react-dom"; -import { connect } from "react-redux"; -import { getScrollBarSize } from "metabase/lib/dom"; -import { getSetting } from "metabase/selectors/settings"; -import ChartSettingsTableFormatting from "metabase/visualizations/components/settings/ChartSettingsTableFormatting"; - -import { - COLLAPSED_ROWS_SETTING, - COLUMN_SPLIT_SETTING, - COLUMN_SORT_ORDER, - COLUMN_SORT_ORDER_ASC, - COLUMN_SORT_ORDER_DESC, - COLUMN_SHOW_TOTALS, - COLUMN_FORMATTING_SETTING, - isPivotGroupColumn, - multiLevelPivot, -} from "metabase/lib/data_grid"; -import { formatColumn } from "metabase/lib/formatting"; -import { columnSettings } from "metabase/visualizations/lib/settings/column"; -import { ChartSettingIconRadio } from "metabase/visualizations/components/settings/ChartSettingIconRadio"; - -import { PLUGIN_SELECTORS } from "metabase/plugins"; -import { isDimension } from "metabase-lib/types/utils/isa"; - -import { RowToggleIcon } from "./RowToggleIcon"; -import { Cell } from "./PivotTableCell"; - -import { - PivotTableRoot, - PivotTableTopLeftCellsContainer, -} from "./PivotTable.styled"; - -import { partitions } from "./partitions"; -import { - addMissingCardBreakouts, - isColumnValid, - isFormattablePivotColumn, - updateValueWithCurrentColumns, - getLeftHeaderWidths, -} from "./utils"; - -import { CELL_WIDTH, CELL_HEIGHT, LEFT_HEADER_LEFT_SPACING } from "./constants"; - -const mapStateToProps = state => ({ - hasCustomColors: PLUGIN_SELECTORS.getHasCustomColors(state), - fontFamily: getSetting(state, "application-font"), -}); - -class PivotTable extends Component { - static uiName = t`Pivot Table`; - static identifier = "pivot"; - static iconName = "pivot_table"; - - static isLiveResizable(series) { - return false; - } - - static databaseSupportsPivotTables(query) { - if (query && query.database && query.database() != null) { - // if we don't have metadata, we can't check this - return query.database().supportsPivots(); - } - return true; - } - - static isSensible({ cols }, query) { - return ( - cols.length >= 2 && - cols.every(isColumnValid) && - this.databaseSupportsPivotTables(query) - ); - } - - static checkRenderable([{ data, card }], settings, query) { - if (data.cols.length < 2 || !data.cols.every(isColumnValid)) { - throw new Error( - t`Pivot tables can only be used with aggregated queries.`, - ); - } - if (!this.databaseSupportsPivotTables(query)) { - throw new Error(t`This database does not support pivot tables.`); - } - } - - static seriesAreCompatible(initialSeries, newSeries) { - return false; - } - - static settings = { - ...columnSettings({ hidden: true }), - [COLLAPSED_ROWS_SETTING]: { - hidden: true, - readDependencies: [COLUMN_SPLIT_SETTING], - getValue: (series, settings = {}) => { - // This is hack. Collapsed rows depend on the current column split setting. - // If the query changes or the rows are reordered, we ignore the current collapsed row setting. - // This is accomplished by snapshotting part of the column split setting *inside* this setting. - // `value` the is the actual data for this setting - // `rows` is value we check against the current setting to see if we should use `value` - const { rows, value } = settings[COLLAPSED_ROWS_SETTING] || {}; - const { rows: currentRows } = settings[COLUMN_SPLIT_SETTING] || {}; - if (!_.isEqual(rows, currentRows)) { - return { value: [], rows: currentRows }; - } - return { rows, value }; - }, - }, - [COLUMN_SPLIT_SETTING]: { - section: t`Columns`, - widget: "fieldsPartition", - persistDefault: true, - getHidden: ([{ data }]) => - // hide the setting widget if there are invalid columns - !data || data.cols.some(col => !isColumnValid(col)), - getProps: ([{ data }], settings) => ({ - partitions, - columns: data == null ? [] : data.cols, - settings, - }), - getValue: ([{ data, card }], settings = {}) => { - const storedValue = settings[COLUMN_SPLIT_SETTING]; - if (data == null) { - return undefined; - } - const columnsToPartition = data.cols.filter( - col => !isPivotGroupColumn(col), - ); - let setting; - if (storedValue == null) { - const [dimensions, values] = _.partition( - columnsToPartition, - isDimension, - ); - const [first, second, ...rest] = _.sortBy(dimensions, col => - getIn(col, ["fingerprint", "global", "distinct-count"]), - ); - let rows, columns; - if (dimensions.length < 2) { - columns = []; - rows = [first]; - } else if (dimensions.length <= 3) { - columns = [first]; - rows = [second, ...rest]; - } else { - columns = [first, second]; - rows = rest; - } - setting = _.mapObject({ rows, columns, values }, cols => - cols.map(col => col.field_ref), - ); - } else { - setting = updateValueWithCurrentColumns( - storedValue, - columnsToPartition, - ); - } - - return addMissingCardBreakouts(setting, card); - }, - }, - "pivot.show_row_totals": { - section: t`Columns`, - title: t`Show row totals`, - widget: "toggle", - default: true, - inline: true, - }, - "pivot.show_column_totals": { - section: t`Columns`, - title: t`Show column totals`, - widget: "toggle", - default: true, - inline: true, - }, - [COLUMN_FORMATTING_SETTING]: { - section: t`Conditional Formatting`, - widget: ChartSettingsTableFormatting, - default: [], - getDefault: ([{ data }], settings) => { - const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? []; - - return columnFormats - .map(columnFormat => { - const hasOnlyFormattableColumns = columnFormat.columns - .map(columnName => - data.cols.find(column => column.name === columnName), - ) - .filter(Boolean) - .every(isFormattablePivotColumn); - - if (!hasOnlyFormattableColumns) { - return null; - } - - return { - ...columnFormat, - highlight_row: false, - }; - }) - .filter(Boolean); - }, - isValid: ([{ data }], settings) => { - const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? []; - - return columnFormats.every(columnFormat => { - const hasOnlyFormattableColumns = columnFormat.columns - .map(columnName => - data.cols.find(column => column.name === columnName), - ) - .filter(Boolean) - .every(isFormattablePivotColumn); - - return hasOnlyFormattableColumns && !columnFormat.highlight_row; - }); - }, - getProps: series => ({ - canHighlightRow: false, - cols: series[0].data.cols.filter(isFormattablePivotColumn), - }), - getHidden: ([{ data }]) => - !data?.cols.some(col => isFormattablePivotColumn(col)), - }, - }; - - static columnSettings = { - [COLUMN_SORT_ORDER]: { - title: t`Sort order`, - widget: ChartSettingIconRadio, - inline: true, - borderBottom: true, - props: { - options: [ - { - iconName: "arrow_up", - value: COLUMN_SORT_ORDER_ASC, - }, - { - iconName: "arrow_down", - value: COLUMN_SORT_ORDER_DESC, - }, - ], - }, - getHidden: ({ source }) => source === "aggregation", - }, - [COLUMN_SHOW_TOTALS]: { - title: t`Show totals`, - widget: "toggle", - inline: true, - getDefault: (column, columnSettings, { settings }) => { - //Default to showing totals if appropriate - const rows = settings[COLUMN_SPLIT_SETTING].rows || []; - return rows.slice(0, -1).some(row => _.isEqual(row, column.field_ref)); - }, - getHidden: (column, columnSettings, { settings }) => { - const rows = settings[COLUMN_SPLIT_SETTING].rows || []; - // to show totals a column needs to be: - // - in the left header ("rows" in COLUMN_SPLIT_SETTING) - // - not the last column - return !rows.slice(0, -1).some(row => _.isEqual(row, column.field_ref)); - }, - }, - column_title: { - title: t`Column title`, - widget: "input", - getDefault: column => formatColumn(column), - }, - }; - - setBodyRef = element => { - this.bodyRef = element; - }; - - getColumnTitle(columnIndex) { - const { data, settings } = this.props; - const columns = data.cols.filter(col => !isPivotGroupColumn(col)); - const { column, column_title: columnTitle } = settings.column( - columns[columnIndex], - ); - return columnTitle || formatColumn(column); - } - - isColumnCollapsible(columnIndex) { - const { data, settings } = this.props; - const columns = data.cols.filter(col => !isPivotGroupColumn(col)); - const { [COLUMN_SHOW_TOTALS]: showTotals } = settings.column( - columns[columnIndex], - ); - return showTotals; - } - - componentDidUpdate() { - // This is needed in case the cell counts didn't change, but the data did - this.leftHeaderRef && this.leftHeaderRef.recomputeCellSizesAndPositions(); - this.topHeaderRef && this.topHeaderRef.recomputeCellSizesAndPositions(); - } - - componentDidMount() { - this.grid = this.bodyRef && findDOMNode(this.bodyRef); - } - - render() { - const { - settings, - data, - width, - hasCustomColors, - onUpdateVisualizationSettings, - isNightMode, - isDashboard, - fontFamily, - } = this.props; - if (data == null || !data.cols.some(isPivotGroupColumn)) { - return null; - } - - const grid = this.grid; - - // In cases where there are horizontal scrollbars are visible AND the data grid has to scroll vertically as well, - // the left sidebar and the main grid can get out of ScrollSync due to slightly differing heights - function scrollBarOffsetSize() { - if (!grid) { - return 0; - } - // get the size of the scrollbars - const scrollBarSize = getScrollBarSize(); - const scrollsHorizontally = grid.scrollWidth > parseInt(grid.style.width); - - if (scrollsHorizontally && scrollBarSize > 0) { - return scrollBarSize; - } else { - return 0; - } - } - - let pivoted; - try { - pivoted = multiLevelPivot(data, settings); - } catch (e) { - console.warn(e); - } - const { - leftHeaderItems, - topHeaderItems, - rowCount, - columnCount, - rowIndex, - getRowSection, - rowIndexes, - columnIndexes, - valueIndexes, - } = pivoted; - - const { leftHeaderWidths, totalHeaderWidths } = getLeftHeaderWidths({ - rowIndexes: rowIndexes ?? [], - getColumnTitle: idx => this.getColumnTitle(idx), - leftHeaderItems, - fontFamily: fontFamily, - }); - - const leftHeaderCellRenderer = ({ index, key, style }) => { - const { value, isSubtotal, hasSubtotal, depth, path, clicked } = - leftHeaderItems[index]; - - return ( - <Cell - key={key} - style={{ - ...style, - ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}), - }} - isNightMode={isNightMode} - value={value} - isEmphasized={isSubtotal} - isBold={isSubtotal} - onClick={this.getCellClickHander(clicked)} - icon={ - (isSubtotal || hasSubtotal) && ( - <RowToggleIcon - value={path} - settings={settings} - updateSettings={onUpdateVisualizationSettings} - hideUnlessCollapsed={isSubtotal} - rowIndex={rowIndex} // used to get a list of "other" paths when open one item in a collapsed column - isNightMode={isNightMode} - /> - ) - } - /> - // </div> - ); - }; - const leftHeaderCellSizeAndPositionGetter = ({ index }) => { - const { offset, span, depth, maxDepthBelow } = leftHeaderItems[index]; - - const columnsToSpan = rowIndexes.length - depth - maxDepthBelow; - - // add up all the widths of the columns, other than itself, that this cell spans - const spanWidth = leftHeaderWidths - .slice(depth + 1, depth + columnsToSpan) - .reduce((acc, cellWidth) => acc + cellWidth, 0); - const columnPadding = depth === 0 ? LEFT_HEADER_LEFT_SPACING : 0; - const columnWidth = leftHeaderWidths[depth]; - - return { - height: span * CELL_HEIGHT, - width: columnWidth + spanWidth + columnPadding, - x: - leftHeaderWidths - .slice(0, depth) - .reduce((acc, cellWidth) => acc + cellWidth, 0) + - (depth > 0 ? LEFT_HEADER_LEFT_SPACING : 0), - y: offset * CELL_HEIGHT, - }; - }; - - const topHeaderRows = - columnIndexes.length + (valueIndexes.length > 1 ? 1 : 0) || 1; - const topHeaderHeight = topHeaderRows * CELL_HEIGHT; - - const topHeaderCellRenderer = ({ index, key, style }) => { - const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } = - topHeaderItems[index]; - return ( - <Cell - key={key} - style={{ - ...style, - }} - value={value} - isNightMode={isNightMode} - isBorderedHeader={maxDepthBelow === 0} - isEmphasized={hasChildren} - isBold={isSubtotal} - onClick={this.getCellClickHander(clicked)} - /> - ); - }; - const topHeaderCellSizeAndPositionGetter = ({ index }) => { - const { offset, span, maxDepthBelow } = topHeaderItems[index]; - return { - height: CELL_HEIGHT, - width: span * CELL_WIDTH, - x: offset * CELL_WIDTH, - y: (topHeaderRows - maxDepthBelow - 1) * CELL_HEIGHT, - }; - }; - - const leftHeaderWidth = - rowIndexes.length > 0 ? LEFT_HEADER_LEFT_SPACING + totalHeaderWidths : 0; - - // These are tied to the `multiLevelPivot` call, so they're awkwardly shoved in render for now - - const bodyRenderer = ({ key, style, rowIndex, columnIndex }) => ( - <div key={key} style={style} className="flex"> - {getRowSection(columnIndex, rowIndex).map( - ({ value, isSubtotal, clicked, backgroundColor }, index) => ( - <Cell - isNightMode={isNightMode} - key={index} - value={value} - isEmphasized={isSubtotal} - isBold={isSubtotal} - isBody - onClick={this.getCellClickHander(clicked)} - backgroundColor={backgroundColor} - /> - ), - )} - </div> - ); - - return ( - <PivotTableRoot - isDashboard={isDashboard} - isNightMode={isNightMode} - data-testid="pivot-table" - > - <ScrollSync> - {({ onScroll, scrollLeft, scrollTop }) => ( - <div className="full-height flex flex-column"> - <div className="flex" style={{ height: topHeaderHeight }}> - {/* top left corner - displays left header columns */} - <PivotTableTopLeftCellsContainer - isNightMode={isNightMode} - style={{ - width: leftHeaderWidth, - }} - > - {rowIndexes.map((rowIndex, index) => ( - <Cell - key={rowIndex} - isEmphasized - isBold - isBorderedHeader - isTransparent - hasTopBorder={topHeaderRows > 1} - isNightMode={isNightMode} - value={this.getColumnTitle(rowIndex)} - style={{ - flex: "0 0 auto", - width: - leftHeaderWidths?.[index] + - (index === 0 ? LEFT_HEADER_LEFT_SPACING : 0), - ...(index === 0 - ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } - : {}), - ...(index === rowIndexes.length - 1 - ? { borderRight: "none" } - : {}), - }} - icon={ - // you can only collapse before the last column - index < rowIndexes.length - 1 && - this.isColumnCollapsible(rowIndex) && ( - <RowToggleIcon - value={index + 1} - settings={settings} - updateSettings={onUpdateVisualizationSettings} - hasCustomColors={hasCustomColors} - isNightMode={isNightMode} - /> - ) - } - /> - ))} - </PivotTableTopLeftCellsContainer> - {/* top header */} - <Collection - ref={e => (this.topHeaderRef = e)} - className="scroll-hide-all" - isNightMode={isNightMode} - width={width - leftHeaderWidth} - height={topHeaderHeight} - cellCount={topHeaderItems.length} - cellRenderer={topHeaderCellRenderer} - cellSizeAndPositionGetter={topHeaderCellSizeAndPositionGetter} - onScroll={({ scrollLeft }) => onScroll({ scrollLeft })} - scrollLeft={scrollLeft} - /> - </div> - <div className="flex flex-full"> - {/* left header */} - <div style={{ width: leftHeaderWidth }}> - <AutoSizer disableWidth> - {({ height }) => ( - <Collection - ref={e => (this.leftHeaderRef = e)} - className="scroll-hide-all" - cellCount={leftHeaderItems.length} - cellRenderer={leftHeaderCellRenderer} - cellSizeAndPositionGetter={ - leftHeaderCellSizeAndPositionGetter - } - width={leftHeaderWidth} - height={height - scrollBarOffsetSize()} - scrollTop={scrollTop} - onScroll={({ scrollTop }) => onScroll({ scrollTop })} - /> - )} - </AutoSizer> - </div> - {/* pivot table body */} - <div> - <AutoSizer disableWidth> - {({ height }) => ( - <Grid - width={width - leftHeaderWidth} - height={height} - className="text-dark" - rowCount={rowCount} - columnCount={columnCount} - rowHeight={CELL_HEIGHT} - columnWidth={valueIndexes.length * CELL_WIDTH} - cellRenderer={bodyRenderer} - onScroll={({ scrollLeft, scrollTop }) => - onScroll({ scrollLeft, scrollTop }) - } - ref={this.setBodyRef} - scrollTop={scrollTop} - scrollLeft={scrollLeft} - /> - )} - </AutoSizer> - </div> - </div> - </div> - )} - </ScrollSync> - </PivotTableRoot> - ); - } - - getCellClickHander(clicked) { - if (!clicked) { - return null; - } - return e => - this.props.onVisualizationClick({ - ...clicked, - event: e.nativeEvent, - settings: this.props.settings, - }); - } -} - -export default connect(mapStateToProps)(PivotTable); -export { PivotTable }; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx index 6af7e3b1538..be1af11082c 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx @@ -2,7 +2,11 @@ import { css } from "@emotion/react"; import styled from "@emotion/styled"; import { color, alpha, darken } from "metabase/lib/colors"; -import { CELL_HEIGHT, PIVOT_TABLE_FONT_SIZE } from "./constants"; +import { + CELL_HEIGHT, + PIVOT_TABLE_FONT_SIZE, + RESIZE_HANDLE_WIDTH, +} from "./constants"; export const RowToggleIconRoot = styled.div` display: flex; @@ -55,6 +59,7 @@ const getBorderColor = ({ isNightMode }: PivotTableCellProps) => { export const PivotTableCell = styled.div<PivotTableCellProps>` flex: 1 0 auto; + position: relative; flex-basis: 0; line-height: ${CELL_HEIGHT}px; min-width: 0; @@ -116,3 +121,22 @@ export const PivotTableSettingLabel = styled.span` font-weight: 700; color: ${color("text-dark")}; `; + +export const ResizeHandle = styled.div` + z-index: 99; + position: absolute; + top: 0; + bottom: 0; + left: -${RESIZE_HANDLE_WIDTH - 1}px; + width: ${RESIZE_HANDLE_WIDTH}px; + + cursor: ew-resize; + + &:active { + background-color: ${color("brand")}; + } + + &:hover { + background-color: ${color("brand")}; + } +`; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx new file mode 100644 index 00000000000..79317e415c5 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx @@ -0,0 +1,421 @@ +import React, { + useEffect, + useMemo, + useCallback, + useRef, + useState, +} from "react"; +import { t } from "ttag"; +import _ from "underscore"; +import { Grid, Collection, ScrollSync, AutoSizer } from "react-virtualized"; +import type { OnScrollParams } from "react-virtualized"; +import { findDOMNode } from "react-dom"; +import { connect } from "react-redux"; + +import { usePrevious } from "metabase/hooks/use-previous"; +import { getScrollBarSize } from "metabase/lib/dom"; +import { getSetting } from "metabase/selectors/settings"; +import { useOnMount } from "metabase/hooks/use-on-mount"; + +import { + COLUMN_SHOW_TOTALS, + isPivotGroupColumn, + multiLevelPivot, +} from "metabase/lib/data_grid"; +import { formatColumn } from "metabase/lib/formatting"; + +import type { DatasetData } from "metabase-types/types/Dataset"; +import type { VisualizationSettings } from "metabase-types/api"; +import type { State } from "metabase-types/store"; + +import type { PivotTableClicked, HeaderWidthType } from "./types"; + +import { RowToggleIcon } from "./RowToggleIcon"; + +import { + Cell, + TopHeaderCell, + LeftHeaderCell, + BodyCell, +} from "./PivotTableCell"; + +import { + PivotTableRoot, + PivotTableTopLeftCellsContainer, +} from "./PivotTable.styled"; + +import { + getLeftHeaderWidths, + databaseSupportsPivotTables, + isSensible, + checkRenderable, + leftHeaderCellSizeAndPositionGetter, + topHeaderCellSizeAndPositionGetter, +} from "./utils"; + +import { + CELL_WIDTH, + CELL_HEIGHT, + LEFT_HEADER_LEFT_SPACING, + MIN_HEADER_CELL_WIDTH, +} from "./constants"; +import { settings, _columnSettings as columnSettings } from "./settings"; + +const mapStateToProps = (state: State) => ({ + fontFamily: getSetting(state, "application-font"), +}); + +interface PivotTableProps { + data: DatasetData; + settings: VisualizationSettings; + width: number; + onUpdateVisualizationSettings: (settings: VisualizationSettings) => void; + isNightMode: boolean; + isDashboard: boolean; + fontFamily?: string; + onVisualizationClick: (options: any) => void; +} + +function PivotTable({ + data, + settings, + width, + onUpdateVisualizationSettings, + isNightMode, + isDashboard, + fontFamily, + onVisualizationClick, +}: PivotTableProps) { + const [gridElement, setGridElement] = useState<HTMLElement | null>(null); + const [{ leftHeaderWidths, totalHeaderWidths }, setHeaderWidths] = + useState<HeaderWidthType>({ + leftHeaderWidths: null, + totalHeaderWidths: null, + }); + + const bodyRef = useRef(null); + const leftHeaderRef = useRef(null); + const topHeaderRef = useRef(null); + + const getColumnTitle = useCallback( + function (columnIndex) { + const columns = data.cols.filter(col => !isPivotGroupColumn(col)); + const { column, column_title: columnTitle } = settings.column( + columns[columnIndex], + ); + return columnTitle || formatColumn(column); + }, + [data, settings], + ); + + function isColumnCollapsible(columnIndex: number) { + const columns = data.cols.filter(col => !isPivotGroupColumn(col)); + const { [COLUMN_SHOW_TOTALS]: showTotals } = settings.column( + columns[columnIndex], + ); + return showTotals; + } + + useEffect(() => { + // This is needed in case the cell counts didn't change, but the data or cell sizes did + ( + leftHeaderRef.current as Collection | null + )?.recomputeCellSizesAndPositions?.(); + ( + topHeaderRef.current as Collection | null + )?.recomputeCellSizesAndPositions?.(); + }, [data, leftHeaderRef, topHeaderRef, leftHeaderWidths]); + + useOnMount(() => { + setGridElement(bodyRef.current && findDOMNode(bodyRef.current)); + }); + + const pivoted = useMemo(() => { + if (data == null || !data.cols.some(isPivotGroupColumn)) { + return null; + } + + try { + return multiLevelPivot(data, settings); + } catch (e) { + console.warn(e); + } + return null; + }, [data, settings]); + + const previousRowIndexes = usePrevious(pivoted?.rowIndexes); + const columnsChanged = !_.isEqual(pivoted?.rowIndexes, previousRowIndexes); + + // In cases where there are horizontal scrollbars are visible AND the data grid has to scroll vertically as well, + // the left sidebar and the main grid can get out of ScrollSync due to slightly differing heights + function scrollBarOffsetSize() { + if (!gridElement) { + return 0; + } + // get the size of the scrollbars + const scrollBarSize = getScrollBarSize(); + const scrollsHorizontally = + gridElement.scrollWidth > parseInt(gridElement.style.width); + + if (scrollsHorizontally && scrollBarSize > 0) { + return scrollBarSize; + } else { + return 0; + } + } + + useEffect(() => { + if (!pivoted?.rowIndexes) { + setHeaderWidths({ leftHeaderWidths: null, totalHeaderWidths: null }); + return; + } + + if (columnsChanged) { + setHeaderWidths( + getLeftHeaderWidths({ + rowIndexes: pivoted?.rowIndexes, + getColumnTitle: idx => getColumnTitle(idx), + leftHeaderItems: pivoted?.leftHeaderItems, + fontFamily: fontFamily, + }), + ); + } + }, [ + pivoted?.rowIndexes, + pivoted?.leftHeaderItems, + fontFamily, + getColumnTitle, + columnsChanged, + ]); + + const handleColumnResize = (columnIndex: number, newWidth: number) => { + const newColumnWidths = [...(leftHeaderWidths as number[])]; + newColumnWidths[columnIndex] = Math.max(newWidth, MIN_HEADER_CELL_WIDTH); + + const newTotalWidth = newColumnWidths.reduce( + (total, current) => total + current, + 0, + ); + + setHeaderWidths({ + leftHeaderWidths: newColumnWidths, + totalHeaderWidths: newTotalWidth, + }); + }; + + if (pivoted === null || !leftHeaderWidths || columnsChanged) { + return null; + } + + const { + leftHeaderItems, + topHeaderItems, + rowCount, + columnCount, + rowIndex, + getRowSection, + rowIndexes, + columnIndexes, + valueIndexes, + } = pivoted; + + const topHeaderRows = + columnIndexes.length + (valueIndexes.length > 1 ? 1 : 0) || 1; + + const topHeaderHeight = topHeaderRows * CELL_HEIGHT; + + const leftHeaderWidth = + rowIndexes.length > 0 + ? LEFT_HEADER_LEFT_SPACING + (totalHeaderWidths ?? 0) + : 0; + + function getCellClickHandler(clicked: PivotTableClicked) { + if (!clicked) { + return undefined; + } + return (e: React.SyntheticEvent) => + onVisualizationClick({ + ...clicked, + event: e.nativeEvent, + settings, + }); + } + + return ( + <PivotTableRoot + isDashboard={isDashboard} + isNightMode={isNightMode} + data-testid="pivot-table" + > + <ScrollSync> + {({ onScroll, scrollLeft, scrollTop }) => ( + <div className="full-height flex flex-column"> + <div className="flex" style={{ height: topHeaderHeight }}> + {/* top left corner - displays left header columns */} + <PivotTableTopLeftCellsContainer + isNightMode={isNightMode} + style={{ + width: leftHeaderWidth, + }} + > + {rowIndexes.map((rowIndex: number, index: number) => ( + <Cell + key={rowIndex} + isEmphasized + isBold + isBorderedHeader + isTransparent + hasTopBorder={topHeaderRows > 1} + isNightMode={isNightMode} + value={getColumnTitle(rowIndex)} + onResize={(newWidth: number) => + handleColumnResize(index, newWidth) + } + style={{ + flex: "0 0 auto", + width: + (leftHeaderWidths?.[index] ?? 0) + + (index === 0 ? LEFT_HEADER_LEFT_SPACING : 0), + ...(index === 0 + ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } + : {}), + ...(index === rowIndexes.length - 1 + ? { borderRight: "none" } + : {}), + }} + icon={ + // you can only collapse before the last column + index < rowIndexes.length - 1 && + isColumnCollapsible(rowIndex) && ( + <RowToggleIcon + value={index + 1} + settings={settings} + updateSettings={onUpdateVisualizationSettings} + /> + ) + } + /> + ))} + </PivotTableTopLeftCellsContainer> + {/* top header */} + <Collection + ref={topHeaderRef} + className="scroll-hide-all" + isNightMode={isNightMode} + width={width - leftHeaderWidth} + height={topHeaderHeight} + cellCount={topHeaderItems.length} + cellRenderer={({ index, style, key }) => ( + <TopHeaderCell + key={key} + style={style} + item={topHeaderItems[index]} + getCellClickHandler={getCellClickHandler} + isNightMode={isNightMode} + /> + )} + cellSizeAndPositionGetter={({ index }) => + topHeaderCellSizeAndPositionGetter( + topHeaderItems[index], + topHeaderRows, + ) + } + onScroll={({ scrollLeft }) => + onScroll({ scrollLeft } as OnScrollParams) + } + scrollLeft={scrollLeft} + /> + </div> + <div className="flex flex-full"> + {/* left header */} + <div style={{ width: leftHeaderWidth }}> + <AutoSizer disableWidth> + {({ height }) => ( + <Collection + ref={leftHeaderRef} + className="scroll-hide-all" + cellCount={leftHeaderItems.length} + cellRenderer={({ index, style, key }) => ( + <LeftHeaderCell + key={key} + style={style} + item={leftHeaderItems[index]} + rowIndex={rowIndex} + onUpdateVisualizationSettings={ + onUpdateVisualizationSettings + } + settings={settings} + isNightMode={isNightMode} + getCellClickHandler={getCellClickHandler} + /> + )} + cellSizeAndPositionGetter={({ index }) => + leftHeaderCellSizeAndPositionGetter( + leftHeaderItems[index], + leftHeaderWidths ?? [0], + rowIndexes, + ) + } + width={leftHeaderWidth} + height={height - scrollBarOffsetSize()} + scrollTop={scrollTop} + onScroll={({ scrollTop }) => + onScroll({ scrollTop } as OnScrollParams) + } + /> + )} + </AutoSizer> + </div> + {/* pivot table body */} + <div> + <AutoSizer disableWidth> + {({ height }) => ( + <Grid + width={width - leftHeaderWidth} + height={height} + className="text-dark" + rowCount={rowCount} + columnCount={columnCount} + rowHeight={CELL_HEIGHT} + columnWidth={valueIndexes.length * CELL_WIDTH} + cellRenderer={({ rowIndex, columnIndex, key, style }) => ( + <BodyCell + key={key} + style={style} + rowSection={getRowSection(columnIndex, rowIndex)} + isNightMode={isNightMode} + getCellClickHandler={getCellClickHandler} + /> + )} + onScroll={({ scrollLeft, scrollTop }) => + onScroll({ scrollLeft, scrollTop } as OnScrollParams) + } + ref={bodyRef} + scrollTop={scrollTop} + scrollLeft={scrollLeft} + /> + )} + </AutoSizer> + </div> + </div> + </div> + )} + </ScrollSync> + </PivotTableRoot> + ); +} + +export default Object.assign(connect(mapStateToProps)(PivotTable), { + uiName: t`Pivot Table`, + identifier: "pivot", + iconName: "pivot_table", + databaseSupportsPivotTables, + isSensible, + checkRenderable, + settings, + columnSettings, + isLiveResizable: () => false, + seriesAreCompatible: () => false, +}); + +export { PivotTable }; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx similarity index 87% rename from frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx rename to frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx index 47205cf0d49..3bf5cad2541 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx @@ -1,5 +1,8 @@ import React from "react"; import { render, screen } from "@testing-library/react"; +import _ from "underscore"; +import type { VisualizationSettings } from "metabase-types/api"; +import type { Column } from "metabase-types/types/Dataset"; import { PivotTable } from "./PivotTable"; @@ -38,7 +41,7 @@ const cols = [ field_ref: ["aggregation", 2], display_name: "aggregation-2", }, -]; +] as Column[]; const rows = [ ["foo1", "bar1", "baz1", 0, 111, 222], @@ -64,12 +67,12 @@ const pivotSettings = { const settings = { ...pivotSettings, - column: c => ({ + column: (c: any) => ({ ...pivotSettings, column: c, column_title: c.display_name, }), -}; +} as unknown as VisualizationSettings; // 3 isn't a real column, it's a pivot-grouping const columnIndexes = [0, 1, 2, 4, 5]; @@ -80,11 +83,11 @@ describe("Visualizations > PivotTable > PivotTable", () => { const originalOffsetHeight = Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetHeight", - ); + ) as number; const originalOffsetWidth = Object.getOwnPropertyDescriptor( HTMLElement.prototype, "offsetWidth", - ); + ) as number; beforeAll(() => { Object.defineProperty(HTMLElement.prototype, "offsetHeight", { @@ -116,8 +119,8 @@ describe("Visualizations > PivotTable > PivotTable", () => { settings={settings} data={{ rows, cols }} width={600} - hasCustomColors={false} - onUpdateVisualizationSettings={() => {}} + onVisualizationClick={_.noop} + onUpdateVisualizationSettings={_.noop} isNightMode={false} isDashboard={false} />, @@ -132,8 +135,8 @@ describe("Visualizations > PivotTable > PivotTable", () => { settings={settings} data={{ rows, cols }} width={600} - hasCustomColors={false} - onUpdateVisualizationSettings={() => {}} + onVisualizationClick={_.noop} + onUpdateVisualizationSettings={_.noop} isNightMode={false} isDashboard={false} /> @@ -153,8 +156,8 @@ describe("Visualizations > PivotTable > PivotTable", () => { settings={settings} data={{ rows, cols }} width={900} - hasCustomColors={false} - onUpdateVisualizationSettings={() => {}} + onVisualizationClick={_.noop} + onUpdateVisualizationSettings={_.noop} isNightMode={false} isDashboard={false} /> @@ -177,7 +180,7 @@ describe("Visualizations > PivotTable > PivotTable", () => { rows: [cols[0].field_ref, cols[1].field_ref, cols[2].field_ref], value: ["2"], }, - }; + } as unknown as VisualizationSettings; render( <div style={{ height: 800 }}> @@ -185,8 +188,8 @@ describe("Visualizations > PivotTable > PivotTable", () => { settings={hiddenSettings} data={{ rows, cols }} width={900} - hasCustomColors={false} - onUpdateVisualizationSettings={() => {}} + onVisualizationClick={_.noop} + onUpdateVisualizationSettings={_.noop} isNightMode={false} isDashboard={false} /> diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx deleted file mode 100644 index 58c09915b1c..00000000000 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from "react"; -import cx from "classnames"; - -import Ellipsified from "metabase/core/components/Ellipsified"; - -import { PivotTableCell } from "./PivotTable.styled"; - -export function Cell({ - value, - style, - icon, - backgroundColor, - isBody = false, - isBold, - isEmphasized, - isNightMode, - isBorderedHeader, - isTransparent, - hasTopBorder, - onClick, -}) { - return ( - <PivotTableCell - data-testid="pivot-table-cell" - isNightMode={isNightMode} - isBold={isBold} - isEmphasized={isEmphasized} - isBorderedHeader={isBorderedHeader} - hasTopBorder={hasTopBorder} - isTransparent={isTransparent} - style={{ - ...style, - ...(backgroundColor - ? { - backgroundColor, - } - : {}), - }} - onClick={onClick} - > - <div className={cx("px1 flex align-center", { "justify-end": isBody })}> - <Ellipsified>{value}</Ellipsified> - {icon && <div className="pl1">{icon}</div>} - </div> - </PivotTableCell> - ); -} diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx new file mode 100644 index 00000000000..01b26672b56 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx @@ -0,0 +1,206 @@ +import React from "react"; +import cx from "classnames"; +import Draggable, { ControlPosition, DraggableBounds } from "react-draggable"; + +import Ellipsified from "metabase/core/components/Ellipsified"; + +import type { VisualizationSettings } from "metabase-types/api"; + +import { RowToggleIcon } from "./RowToggleIcon"; +import { PivotTableCell, ResizeHandle } from "./PivotTable.styled"; + +import type { HeaderItem, BodyItem, PivotTableClicked } from "./types"; +import { LEFT_HEADER_LEFT_SPACING, RESIZE_HANDLE_WIDTH } from "./constants"; + +interface CellProps { + value: React.ReactNode; + style?: React.CSSProperties; + icon?: React.ReactNode; + backgroundColor?: string; + isBody?: boolean; + isBold?: boolean; + isEmphasized?: boolean; + isNightMode?: boolean; + isBorderedHeader?: boolean; + isTransparent?: boolean; + hasTopBorder?: boolean; + onClick?: ((e: React.SyntheticEvent) => void) | undefined; + onResize?: (newWidth: number) => void; +} + +export function Cell({ + value, + style, + icon, + backgroundColor, + isBody = false, + isBold, + isEmphasized, + isNightMode, + isBorderedHeader, + isTransparent, + hasTopBorder, + onClick, + onResize, +}: CellProps) { + return ( + <PivotTableCell + data-testid="pivot-table-cell" + isNightMode={isNightMode} + isBold={isBold} + isEmphasized={isEmphasized} + isBorderedHeader={isBorderedHeader} + hasTopBorder={hasTopBorder} + isTransparent={isTransparent} + style={{ + ...style, + ...(backgroundColor + ? { + backgroundColor, + } + : {}), + }} + onClick={onClick} + > + <> + <div className={cx("px1 flex align-center", { "justify-end": isBody })}> + <Ellipsified>{value}</Ellipsified> + {icon && <div className="pl1">{icon}</div>} + </div> + {!!onResize && ( + <Draggable + axis="x" + enableUserSelectHack + bounds={{ left: RESIZE_HANDLE_WIDTH } as DraggableBounds} + position={ + { + x: style?.width ?? 0, + y: 0, + } as ControlPosition + } + onStop={(e, { x }) => { + onResize(x); + }} + > + <ResizeHandle /> + </Draggable> + )} + </> + </PivotTableCell> + ); +} + +type CellClickHandler = ( + clicked: PivotTableClicked, +) => ((e: React.SyntheticEvent) => void) | undefined; + +interface TopHeaderCellProps { + item: HeaderItem; + style: React.CSSProperties; + isNightMode: boolean; + getCellClickHandler: CellClickHandler; + onResize?: (newWidth: number) => void; +} + +export const TopHeaderCell = ({ + item, + style, + isNightMode, + getCellClickHandler, + onResize, +}: TopHeaderCellProps) => { + const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } = item; + + return ( + <Cell + style={{ + ...style, + }} + value={value} + isNightMode={isNightMode} + isBorderedHeader={maxDepthBelow === 0} + isEmphasized={hasChildren} + isBold={isSubtotal} + onClick={getCellClickHandler(clicked)} + onResize={onResize} + /> + ); +}; + +type LeftHeaderCellProps = TopHeaderCellProps & { + rowIndex: string[]; + settings: VisualizationSettings; + onUpdateVisualizationSettings: (settings: VisualizationSettings) => void; +}; + +export const LeftHeaderCell = ({ + item, + style, + isNightMode, + getCellClickHandler, + rowIndex, + settings, + onUpdateVisualizationSettings, + onResize, +}: LeftHeaderCellProps) => { + const { value, isSubtotal, hasSubtotal, depth, path, clicked } = item; + + return ( + <Cell + style={{ + ...style, + ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}), + }} + isNightMode={isNightMode} + value={value} + isEmphasized={isSubtotal} + isBold={isSubtotal} + onClick={getCellClickHandler(clicked)} + onResize={onResize} + icon={ + (isSubtotal || hasSubtotal) && ( + <RowToggleIcon + value={path} + settings={settings} + updateSettings={onUpdateVisualizationSettings} + hideUnlessCollapsed={isSubtotal} + rowIndex={rowIndex} // used to get a list of "other" paths when open one item in a collapsed column + /> + ) + } + /> + ); +}; + +interface BodyCellProps { + style: React.CSSProperties; + rowSection: BodyItem[]; + isNightMode: boolean; + getCellClickHandler: CellClickHandler; +} + +export const BodyCell = ({ + style, + rowSection, + isNightMode, + getCellClickHandler, +}: BodyCellProps) => { + return ( + <div style={style} className="flex"> + {rowSection.map( + ({ value, isSubtotal, clicked, backgroundColor }, index) => ( + <Cell + isNightMode={isNightMode} + key={index} + value={value} + isEmphasized={isSubtotal} + isBold={isSubtotal} + isBody + onClick={getCellClickHandler(clicked)} + backgroundColor={backgroundColor} + /> + ), + )} + </div> + ); +}; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx similarity index 70% rename from frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx rename to frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx index 5909f242438..c0fcf9d5bd0 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/prop-types */ import React from "react"; import { updateIn } from "icepick"; import _ from "underscore"; @@ -7,27 +6,42 @@ import Icon from "metabase/components/Icon"; import { COLLAPSED_ROWS_SETTING } from "metabase/lib/data_grid"; +import { + VisualizationSettings, + PivotTableCollapsedRowsSetting, +} from "metabase-types/api"; import { RowToggleIconRoot } from "./PivotTable.styled"; +interface RowToggleIconProps { + value: number | string[]; + settings: VisualizationSettings; + updateSettings: (settings: VisualizationSettings) => void; + hideUnlessCollapsed?: boolean; + rowIndex?: string[]; +} + export function RowToggleIcon({ value, settings, updateSettings, hideUnlessCollapsed, - rowIndex, - hasCustomColors, - isNightMode, -}) { + rowIndex = [], +}: RowToggleIconProps) { if (value == null) { return null; } - const setting = settings[COLLAPSED_ROWS_SETTING]; + const setting = settings[ + COLLAPSED_ROWS_SETTING + ] as PivotTableCollapsedRowsSetting; const ref = JSON.stringify(value); const isColumn = !Array.isArray(value); const columnRef = isColumn ? null : JSON.stringify(value.length); - const settingValue = setting.value || []; - const isColumnCollapsed = !isColumn && settingValue.includes(columnRef); + const settingValue: PivotTableCollapsedRowsSetting["value"] = + setting.value || []; + const isColumnCollapsed = + !isColumn && settingValue.includes(columnRef as string); const isCollapsed = settingValue.includes(ref) || isColumnCollapsed; + if (hideUnlessCollapsed && !isCollapsed) { // subtotal rows shouldn't have an icon unless the section is collapsed return null; @@ -37,7 +51,7 @@ export function RowToggleIcon({ // That depends on whether we're a row or column header and whether we're open or closed. const toggle = isColumn && !isCollapsed // click on open column - ? settingValue => + ? (settingValue: PivotTableCollapsedRowsSetting["value"]) => settingValue .filter(v => { const parsed = JSON.parse(v); @@ -45,7 +59,7 @@ export function RowToggleIcon({ }) // remove any already collapsed items in this column .concat(ref) // add column to list : !isColumn && isColumnCollapsed // single row in collapsed column - ? settingValue => + ? (settingValue: PivotTableCollapsedRowsSetting["value"]) => settingValue .filter(v => v !== columnRef) // remove column from list .concat( @@ -62,9 +76,11 @@ export function RowToggleIcon({ .map(item => JSON.stringify(item)), ) : isCollapsed // closed row or column - ? settingValue => settingValue.filter(v => v !== ref) + ? (settingValue: PivotTableCollapsedRowsSetting["value"]) => + settingValue.filter(v => v !== ref) : // open row or column - settingValue => settingValue.concat(ref); + (settingValue: PivotTableCollapsedRowsSetting["value"]) => + settingValue.concat(ref); return ( <RowToggleIconRoot diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts index 3ea138845d0..4a10a702d51 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts @@ -18,3 +18,5 @@ export const MAX_HEADER_CELL_WIDTH = export const LEFT_HEADER_LEFT_SPACING = 24; export const MAX_ROWS_TO_MEASURE = 100; + +export const RESIZE_HANDLE_WIDTH = 5; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts new file mode 100644 index 00000000000..fd9cca64667 --- /dev/null +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts @@ -0,0 +1,253 @@ +import { getIn } from "icepick"; +import { t } from "ttag"; +import _ from "underscore"; + +import { + COLLAPSED_ROWS_SETTING, + COLUMN_SPLIT_SETTING, + COLUMN_SORT_ORDER, + COLUMN_SORT_ORDER_ASC, + COLUMN_SORT_ORDER_DESC, + COLUMN_SHOW_TOTALS, + COLUMN_FORMATTING_SETTING, + isPivotGroupColumn, +} from "metabase/lib/data_grid"; +import { formatColumn } from "metabase/lib/formatting"; + +import { columnSettings } from "metabase/visualizations/lib/settings/column"; +import ChartSettingsTableFormatting from "metabase/visualizations/components/settings/ChartSettingsTableFormatting"; +import { ChartSettingIconRadio } from "metabase/visualizations/components/settings/ChartSettingIconRadio"; + +import type { Series, VisualizationSettings } from "metabase-types/api"; +import type { Card } from "metabase-types/types/Card"; +import type { + DatasetData, + Column, + Row, + ColumnSettings, +} from "metabase-types/types/Dataset"; + +import { isDimension } from "metabase-lib/types/utils/isa"; + +import { partitions } from "./partitions"; + +import { + addMissingCardBreakouts, + isColumnValid, + isFormattablePivotColumn, + updateValueWithCurrentColumns, +} from "./utils"; +import { PivotSetting } from "./types"; + +export const settings = { + ...columnSettings({ hidden: true }), + [COLLAPSED_ROWS_SETTING]: { + hidden: true, + readDependencies: [COLUMN_SPLIT_SETTING], + getValue: ( + series: Series, + settings: Partial<VisualizationSettings> = {}, + ) => { + // This is hack. Collapsed rows depend on the current column split setting. + // If the query changes or the rows are reordered, we ignore the current collapsed row setting. + // This is accomplished by snapshotting part of the column split setting *inside* this setting. + // `value` the is the actual data for this setting + // `rows` is value we check against the current setting to see if we should use `value` + const { rows, value } = settings[COLLAPSED_ROWS_SETTING] || {}; + const { rows: currentRows } = settings[COLUMN_SPLIT_SETTING] || {}; + if (!_.isEqual(rows, currentRows)) { + return { value: [], rows: currentRows }; + } + return { rows, value }; + }, + }, + [COLUMN_SPLIT_SETTING]: { + section: t`Columns`, + widget: "fieldsPartition", + persistDefault: true, + getHidden: ([{ data }]: [{ data: DatasetData }]) => + // hide the setting widget if there are invalid columns + !data || data.cols.some(col => !isColumnValid(col)), + getProps: ( + [{ data }]: [{ data: DatasetData }], + settings: VisualizationSettings, + ) => ({ + partitions, + columns: data == null ? [] : data.cols, + settings, + }), + getValue: ( + [{ data, card }]: [{ data: DatasetData; card: Card }], + settings: Partial<VisualizationSettings> = {}, + ) => { + const storedValue = settings[COLUMN_SPLIT_SETTING]; + if (data == null) { + return undefined; + } + const columnsToPartition = data.cols.filter( + col => !isPivotGroupColumn(col), + ); + let setting; + if (storedValue == null) { + const [dimensions, values] = _.partition( + columnsToPartition, + isDimension, + ); + const [first, second, ...rest] = _.sortBy(dimensions, col => + getIn(col, ["fingerprint", "global", "distinct-count"]), + ); + + let rows; + let columns: Column[]; + + if (dimensions.length < 2) { + columns = []; + rows = [first]; + } else if (dimensions.length <= 3) { + columns = [first]; + rows = [second, ...rest]; + } else { + columns = [first, second]; + rows = rest; + } + setting = _.mapObject({ rows, columns, values }, cols => + cols.map(col => col.field_ref), + ); + } else { + setting = updateValueWithCurrentColumns( + storedValue, + columnsToPartition, + ); + } + + return addMissingCardBreakouts(setting as PivotSetting, card); + }, + }, + "pivot.show_row_totals": { + section: t`Columns`, + title: t`Show row totals`, + widget: "toggle", + default: true, + inline: true, + }, + "pivot.show_column_totals": { + section: t`Columns`, + title: t`Show column totals`, + widget: "toggle", + default: true, + inline: true, + }, + [COLUMN_FORMATTING_SETTING]: { + section: t`Conditional Formatting`, + widget: ChartSettingsTableFormatting, + default: [], + getDefault: ( + [{ data }]: [{ data: DatasetData }], + settings: VisualizationSettings, + ) => { + const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? []; + + return columnFormats + .map(columnFormat => { + const hasOnlyFormattableColumns = + columnFormat.columns + .map((columnName: string) => + data.cols.find(column => column.name === columnName), + ) + .filter(Boolean) ?? [].every(isFormattablePivotColumn); + + if (!hasOnlyFormattableColumns) { + return null; + } + + return { + ...columnFormat, + highlight_row: false, + }; + }) + .filter(Boolean); + }, + isValid: ( + [{ data }]: [{ data: DatasetData }], + settings: VisualizationSettings, + ): boolean => { + const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? []; + + return columnFormats.every(columnFormat => { + const hasOnlyFormattableColumns = + columnFormat.columns + .map(columnName => + (data.cols as Column[]).find( + column => column.name === columnName, + ), + ) + .filter(Boolean) ?? [].every(isFormattablePivotColumn); + + return hasOnlyFormattableColumns && !columnFormat.highlight_row; + }); + }, + getProps: (series: Series) => ({ + canHighlightRow: false, + cols: (series[0].data.cols as Column[]).filter(isFormattablePivotColumn), + }), + getHidden: ([{ data }]: [{ data: DatasetData }]) => + !data?.cols.some(col => isFormattablePivotColumn(col)), + }, +}; + +export const _columnSettings = { + [COLUMN_SORT_ORDER]: { + title: t`Sort order`, + widget: ChartSettingIconRadio, + inline: true, + borderBottom: true, + props: { + options: [ + { + iconName: "arrow_up", + value: COLUMN_SORT_ORDER_ASC, + }, + { + iconName: "arrow_down", + value: COLUMN_SORT_ORDER_DESC, + }, + ], + }, + getHidden: ({ source }: { source: Column["source"] }) => + source === "aggregation", + }, + [COLUMN_SHOW_TOTALS]: { + title: t`Show totals`, + widget: "toggle", + inline: true, + getDefault: ( + column: Column, + columnSettings: ColumnSettings, + { settings }: { settings: VisualizationSettings }, + ) => { + //Default to showing totals if appropriate + const rows = settings[COLUMN_SPLIT_SETTING].rows || []; + return rows + .slice(0, -1) + .some((row: Row) => _.isEqual(row, column.field_ref)); + }, + getHidden: ( + column: Column, + columnSettings: ColumnSettings, + { settings }: { settings: VisualizationSettings }, + ) => { + const rows = settings[COLUMN_SPLIT_SETTING].rows || []; + // to show totals a column needs to be: + // - in the left header ("rows" in COLUMN_SPLIT_SETTING) + // - not the last column + return !rows + .slice(0, -1) + .some((row: Row) => _.isEqual(row, column.field_ref)); + }, + }, + column_title: { + title: t`Column title`, + widget: "input", + getDefault: formatColumn, + }, +}; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts index 3da159de665..f6606e68cfb 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts @@ -9,10 +9,11 @@ export type PivotSetting = { values: AggregationReference[]; }; -export interface LeftHeaderItem { - clicked: { value: string; column: Column }; +export type PivotTableClicked = { value: string; column: Column }; +export interface HeaderItem { + clicked: PivotTableClicked; - isCollapsed: boolean; + isCollapsed?: boolean; hasChildren: boolean; hasSubtotal?: boolean; isSubtotal?: boolean; @@ -27,3 +28,12 @@ export interface LeftHeaderItem { rawValue: string; value: string; } + +export type BodyItem = HeaderItem & { + backgroundColor?: string; +}; + +export type HeaderWidthType = { + leftHeaderWidths: number[] | null; + totalHeaderWidths: number | null; +}; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts index 20e7dbf7eb5..5c99375fba3 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts @@ -1,16 +1,21 @@ import _ from "underscore"; import { getIn } from "icepick"; +import { t } from "ttag"; import { isPivotGroupColumn } from "metabase/lib/data_grid"; import { measureText } from "metabase/lib/measure-text"; -import type { Column } from "metabase-types/types/Dataset"; +import type { Column, DatasetData } from "metabase-types/types/Dataset"; import type { Card } from "metabase-types/types/Card"; +import type { VisualizationSettings } from "metabase-types/api"; +import type StructuredQuery from "metabase-lib/queries/StructuredQuery"; + import type { PivotSetting, FieldOrAggregationReference, - LeftHeaderItem, + HeaderItem, } from "./types"; + import { partitions } from "./partitions"; import { @@ -20,6 +25,9 @@ import { MAX_HEADER_CELL_WIDTH, PIVOT_TABLE_FONT_SIZE, MAX_ROWS_TO_MEASURE, + LEFT_HEADER_LEFT_SPACING, + CELL_HEIGHT, + CELL_WIDTH, } from "./constants"; // adds or removes columns from the pivot settings based on the current query @@ -93,7 +101,7 @@ export function isFormattablePivotColumn(column: Column) { interface GetLeftHeaderWidthsProps { rowIndexes: number[]; getColumnTitle: (columnIndex: number) => string; - leftHeaderItems?: LeftHeaderItem[]; + leftHeaderItems?: HeaderItem[]; fontFamily?: string; } @@ -153,12 +161,12 @@ type ColumnValueInfo = { hasSubtotal: boolean; }; -export function getColumnValues(leftHeaderItems: LeftHeaderItem[]) { +export function getColumnValues(leftHeaderItems: HeaderItem[]) { const columnValues: ColumnValueInfo[] = []; leftHeaderItems .slice(0, MAX_ROWS_TO_MEASURE) - .forEach((leftHeaderItem: LeftHeaderItem) => { + .forEach((leftHeaderItem: HeaderItem) => { const { value, depth, isSubtotal, isGrandTotal, hasSubtotal } = leftHeaderItem; @@ -182,3 +190,76 @@ export function getColumnValues(leftHeaderItems: LeftHeaderItem[]) { return columnValues; } + +export function databaseSupportsPivotTables(query: StructuredQuery) { + if (query && query.database && query.database() != null) { + // if we don't have metadata, we can't check this + return query.database()?.supportsPivots(); + } + return true; +} + +export function isSensible( + { cols }: { cols: Column[] }, + query: StructuredQuery, +) { + return ( + cols.length >= 2 && + cols.every(isColumnValid) && + databaseSupportsPivotTables(query) + ); +} + +export function checkRenderable( + [{ data }]: [{ data: DatasetData }], + settings: VisualizationSettings, + query: StructuredQuery, +) { + if (data.cols.length < 2 || !data.cols.every(isColumnValid)) { + throw new Error(t`Pivot tables can only be used with aggregated queries.`); + } + if (!databaseSupportsPivotTables(query)) { + throw new Error(t`This database does not support pivot tables.`); + } +} + +export const leftHeaderCellSizeAndPositionGetter = ( + item: HeaderItem, + leftHeaderWidths: number[], + rowIndexes: number[], +) => { + const { offset, span, depth, maxDepthBelow } = item; + + const columnsToSpan = rowIndexes.length - depth - maxDepthBelow; + + // add up all the widths of the columns, other than itself, that this cell spans + const spanWidth = leftHeaderWidths + .slice(depth + 1, depth + columnsToSpan) + .reduce((acc, cellWidth) => acc + cellWidth, 0); + const columnPadding = depth === 0 ? LEFT_HEADER_LEFT_SPACING : 0; + const columnWidth = leftHeaderWidths[depth]; + + return { + height: span * CELL_HEIGHT, + width: columnWidth + spanWidth + columnPadding, + x: + leftHeaderWidths + .slice(0, depth) + .reduce((acc, cellWidth) => acc + cellWidth, 0) + + (depth > 0 ? LEFT_HEADER_LEFT_SPACING : 0), + y: offset * CELL_HEIGHT, + }; +}; + +export const topHeaderCellSizeAndPositionGetter = ( + item: HeaderItem, + topHeaderRows: number, +) => { + const { offset, span, maxDepthBelow } = item; + return { + height: CELL_HEIGHT, + width: span * CELL_WIDTH, + x: offset * CELL_WIDTH, + y: (topHeaderRows - maxDepthBelow - 1) * CELL_HEIGHT, + }; +}; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts index 31d5fd65cc8..5ef7256d4f1 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts @@ -1,7 +1,7 @@ import type { Column } from "metabase-types/types/Dataset"; import type { Card } from "metabase-types/types/Card"; -import type { PivotSetting, LeftHeaderItem } from "./types"; +import type { PivotSetting, HeaderItem } from "./types"; import { isColumnValid, @@ -282,7 +282,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { { depth: 1, value: "bar2" }, { depth: 2, value: "baz1" }, { depth: 4, value: "boo1" }, - ] as LeftHeaderItem[]; + ] as HeaderItem[]; const { leftHeaderWidths } = getLeftHeaderWidths({ rowIndexes: [0, 1, 2, 3, 4], @@ -307,7 +307,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { { depth: 1, value: "bar2" }, { depth: 2, value: "baz1" }, { depth: 4, value: "boo1" }, - ] as LeftHeaderItem[]; + ] as HeaderItem[]; const { leftHeaderWidths } = getLeftHeaderWidths({ rowIndexes: [0, 1, 2, 3, 4], @@ -334,7 +334,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { { depth: 1, value: "bar2" }, { depth: 2, value: "baz1" }, { depth: 4, value: "boo1" }, - ] as LeftHeaderItem[]; + ] as HeaderItem[]; const result = getColumnValues(data); @@ -354,7 +354,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { { depth: 1, value: "bar1", hasSubtotal: false }, { depth: 1, value: "bar2", hasSubtotal: false }, { depth: 2, value: "baz1", hasSubtotal: true }, - ] as LeftHeaderItem[]; + ] as HeaderItem[]; const result = getColumnValues(data); @@ -372,7 +372,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { { depth: 1, value: "bar1", hasSubtotal: false }, { depth: 1, value: "bar2", hasSubtotal: false }, { depth: 2, value: "baz1", hasSubtotal: true }, - ] as LeftHeaderItem[]; + ] as HeaderItem[]; const result = getColumnValues(data); -- GitLab