From bc29e3606ce87540dcd05f118c83613bd2313641 Mon Sep 17 00:00:00 2001 From: Aleksandr Lesnenko <alxnddr@users.noreply.github.com> Date: Mon, 29 Aug 2022 19:08:38 +0400 Subject: [PATCH] Pivot tables conditional formatting (#25059) * Revert "Revert "Pivot table conditional formatting (#24930)" (#25055)" This reverts commit 23fc2531c797d17e41084fbbd2c3b41a8a3586e4. * fix pivot table conditional formatting * filter out not applicable conditional formatting rules --- .../src/metabase-shared/color_selector.js | 7 +- frontend/src/metabase/lib/data_grid.js | 41 ++- .../settings/ChartSettingsTableFormatting.jsx | 30 +- .../visualizations/lib/table_format.js | 21 +- .../visualizations/PivotTable.jsx | 276 ++++++++++-------- .../visualizations/PivotTable.styled.tsx | 106 ++++++- .../visualizations/visualizations/Table.jsx | 11 +- .../visualizations/pivot_tables.cy.spec.js | 61 +++- 8 files changed, 392 insertions(+), 161 deletions(-) diff --git a/frontend/src/metabase-shared/color_selector.js b/frontend/src/metabase-shared/color_selector.js index f87318bd4b1..dc51bf32b57 100644 --- a/frontend/src/metabase-shared/color_selector.js +++ b/frontend/src/metabase-shared/color_selector.js @@ -21,7 +21,12 @@ global.makeCellBackgroundGetter = function ( const cols = JSON.parse(colsJSON); const settings = JSON.parse(settingsJSON); try { - return makeCellBackgroundGetter(rows, cols, settings); + return makeCellBackgroundGetter( + rows, + cols, + settings["table.column_formatting"] ?? [], + settings["table.pivot"], + ); } catch (e) { print("ERROR", e); return () => null; diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js index 575d3853aec..1cd177fe217 100644 --- a/frontend/src/metabase/lib/data_grid.js +++ b/frontend/src/metabase/lib/data_grid.js @@ -1,6 +1,7 @@ import _ from "underscore"; import { getIn } from "icepick"; import { t } from "ttag"; +import { makeCellBackgroundGetter } from "metabase/visualizations/lib/table_format"; import { formatValue, formatColumn } from "metabase/lib/formatting"; @@ -8,6 +9,7 @@ export function isPivotGroupColumn(col) { return col.name === "pivot-grouping"; } +export const COLUMN_FORMATTING_SETTING = "table.column_formatting"; export const COLLAPSED_ROWS_SETTING = "pivot_table.collapsed_rows"; export const COLUMN_SPLIT_SETTING = "pivot_table.column_split"; export const COLUMN_SHOW_TOTALS = "pivot_table.column_show_totals"; @@ -38,12 +40,7 @@ export function multiLevelPivot(data, settings) { .filter(index => index !== -1), ); - const { pivotData, columns } = splitPivotData( - data, - rowColumnIndexes, - columnColumnIndexes, - ); - + const { pivotData, columns } = splitPivotData(data); const columnSettings = columns.map(column => settings.column(column)); const allCollapsedSubtotals = settings[COLLAPSED_ROWS_SETTING].value; const collapsedSubtotals = filterCollapsedSubtotals( @@ -83,8 +80,13 @@ export function multiLevelPivot(data, settings) { columnColumnIndexes.concat(rowColumnIndexes).map(index => row[index]), ); const values = valueColumnIndexes.map(index => row[index]); + const valueColumns = valueColumnIndexes.map( + index => columnSettings[index]?.column, + ); + valuesByKey[valueKey] = { values, + valueColumns, data: row.map((value, index) => ({ value, col: columns[index] })), dimensions: row .map((value, index) => ({ @@ -180,6 +182,13 @@ export function multiLevelPivot(data, settings) { const leftHeaderItems = treeToArray(formattedRowTree.flat()); const topHeaderItems = treeToArray(formattedColumnTree.flat()); + const colorGetter = makeCellBackgroundGetter( + pivotData[primaryRowsKey], + columns, + settings["table.column_formatting"] ?? [], + true, + ); + const getRowSection = createRowSectionGetter({ valuesByKey, subtotalValues, @@ -188,6 +197,7 @@ export function multiLevelPivot(data, settings) { rowColumnIndexes, columnIndex, rowIndex, + colorGetter, }); return { @@ -206,7 +216,7 @@ export function multiLevelPivot(data, settings) { // This pulls apart the different aggregations that were packed into one result set. // There's a column indicating which breakouts were used to compute that row. // We use that column to split apart the data and convert the field refs to indexes. -function splitPivotData(data, rowIndexes, columnIndexes) { +function splitPivotData(data) { const groupIndex = data.cols.findIndex(isPivotGroupColumn); const columns = data.cols.filter(col => !isPivotGroupColumn(col)); const breakouts = columns.filter(col => col.source === "breakout"); @@ -260,6 +270,7 @@ function createRowSectionGetter({ rowColumnIndexes, columnIndex, rowIndex, + colorGetter, }) { const formatValues = values => values === undefined @@ -292,10 +303,20 @@ function createRowSectionGetter({ const otherAttrs = rowValues.length === 0 ? { isGrandTotal: true } : {}; return getSubtotals(indexes, indexValues, otherAttrs); } - const { values, data, dimensions } = + const { values, data, dimensions, valueColumns } = valuesByKey[JSON.stringify(indexValues)] || {}; - return formatValues(values).map(o => - data === undefined ? o : { ...o, clicked: { data, dimensions } }, + return formatValues(values).map((o, index) => + data === undefined + ? o + : { + ...o, + clicked: { data, dimensions }, + backgroundColor: colorGetter( + values[index], + o.rowIndex, + valueColumns[index].name, + ), + }, ); }; return _.memoize(getter, (i1, i2) => [i1, i2].join()); diff --git a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx index b31a5575e34..cf15df53506 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx @@ -94,11 +94,12 @@ export default class ChartSettingsTableFormatting extends React.Component { editingRuleIsNew: null, }; render() { - const { value, onChange, cols } = this.props; + const { value, onChange, cols, canHighlightRow } = this.props; const { editingRule, editingRuleIsNew } = this.state; if (editingRule !== null && value[editingRule]) { return ( <RuleEditor + canHighlightRow={canHighlightRow} rule={value[editingRule]} cols={cols} isNew={editingRuleIsNew} @@ -297,7 +298,15 @@ const RuleDescription = ({ rule }) => { ); }; -const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => { +const RuleEditor = ({ + rule, + cols, + isNew, + onChange, + onDone, + onRemove, + canHighlightRow = true, +}) => { const selectedColumns = rule.columns.map(name => _.findWhere(cols, { name })); const isStringRule = selectedColumns.length > 0 && _.all(selectedColumns, isString); @@ -363,6 +372,7 @@ const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => { </Select> {hasOperand && isNumericRule ? ( <NumericInput + data-testid="conditional-formatting-value-input" className={INPUT_CLASSNAME} type="number" value={rule.value} @@ -370,6 +380,7 @@ const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => { /> ) : hasOperand ? ( <input + data-testid="conditional-formatting-value-input" className={INPUT_CLASSNAME} value={rule.value} onChange={e => onChange({ ...rule, value: e.target.value })} @@ -381,11 +392,16 @@ const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => { colors={COLORS} onChange={color => onChange({ ...rule, color })} /> - <h3 className="mt3 mb1">{t`Highlight the whole row`}</h3> - <Toggle - value={rule.highlight_row} - onChange={highlight_row => onChange({ ...rule, highlight_row })} - /> + {canHighlightRow && ( + <> + <h3 className="mt3 mb1">{t`Highlight the whole row`}</h3> + + <Toggle + value={rule.highlight_row} + onChange={highlight_row => onChange({ ...rule, highlight_row })} + /> + </> + )} </div> ) : rule.type === "range" ? ( <div> diff --git a/frontend/src/metabase/visualizations/lib/table_format.js b/frontend/src/metabase/visualizations/lib/table_format.js index 5376543b1b4..e84c01d8522 100644 --- a/frontend/src/metabase/visualizations/lib/table_format.js +++ b/frontend/src/metabase/visualizations/lib/table_format.js @@ -10,16 +10,23 @@ const GRADIENT_ALPHA = 0.75; // for simplicity wheb typing assume all values are numbers, since you can only pick numeric columns -export function makeCellBackgroundGetter(rows, cols, settings) { - const formats = settings["table.column_formatting"] || []; - const pivot = settings["table.pivot"]; +export function makeCellBackgroundGetter( + rows, + cols, + formattingSettings, + isPivoted, +) { let formatters = {}; let rowFormatters = []; const colIndexes = getColumnIndexesByName(cols); try { - const columnExtents = computeColumnExtents(formats, rows, colIndexes); - formatters = compileFormatters(formats, columnExtents); - rowFormatters = compileRowFormatters(formats, columnExtents); + const columnExtents = computeColumnExtents( + formattingSettings, + rows, + colIndexes, + ); + formatters = compileFormatters(formattingSettings, columnExtents); + rowFormatters = compileRowFormatters(formattingSettings, columnExtents); } catch (e) { console.error("Unexpected error compiling column formatters: ", e); } @@ -38,7 +45,7 @@ export function makeCellBackgroundGetter(rows, cols, settings) { } } // don't highlight row for pivoted tables - if (!pivot) { + if (!isPivoted) { for (let i = 0; i < rowFormatters.length; i++) { const rowFormatter = rowFormatters[i]; const color = rowFormatter(rows[rowIndex], colIndexes); diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx index b5b0e2124d6..a73e028e19e 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx @@ -6,9 +6,8 @@ import _ from "underscore"; import { getIn, updateIn } from "icepick"; import { Grid, Collection, ScrollSync, AutoSizer } from "react-virtualized"; -import { darken, lighten } from "metabase/lib/colors"; -import "metabase/visualizations/components/TableInteractive/TableInteractive.css"; import { getScrollBarSize } from "metabase/lib/dom"; +import ChartSettingsTableFormatting from "metabase/visualizations/components/settings/ChartSettingsTableFormatting"; import Ellipsified from "metabase/core/components/Ellipsified"; import Icon from "metabase/components/Icon"; @@ -18,6 +17,7 @@ import { COLUMN_SPLIT_SETTING, COLUMN_SORT_ORDER, COLUMN_SHOW_TOTALS, + COLUMN_FORMATTING_SETTING, isPivotGroupColumn, multiLevelPivot, } from "metabase/lib/data_grid"; @@ -27,23 +27,13 @@ import { columnSettings } from "metabase/visualizations/lib/settings/column"; import { findDOMNode } from "react-dom"; import { connect } from "react-redux"; import { PLUGIN_SELECTORS } from "metabase/plugins"; -import { RowToggleIconRoot } from "./PivotTable.styled"; - -const getBgLightColor = (hasCustomColors, isNightMode) => { - if (isNightMode) { - return lighten("bg-black", 0.3); - } - - return hasCustomColors ? darken("white", 0.01) : lighten("brand", 0.65); -}; - -const getBgDarkColor = (hasCustomColors, isNightMode) => { - if (isNightMode) { - return lighten("bg-black", 0.1); - } - - return hasCustomColors ? darken("white", 0.035) : lighten("brand", 0.6); -}; +import { + PivotTableRoot, + PivotTableCell, + PivotTableTopLeftCellsContainer, + RowToggleIconRoot, + CELL_HEIGHT, +} from "./PivotTable.styled"; const partitions = [ { @@ -71,7 +61,6 @@ const partitions = [ // cell width and height for normal body cells const CELL_WIDTH = 100; -const CELL_HEIGHT = 25; // the left header has a wider cell width and some additional spacing on the left to align with the title const LEFT_HEADER_LEFT_SPACING = 24; const LEFT_HEADER_CELL_WIDTH = 145; @@ -140,7 +129,7 @@ class PivotTable extends Component { }, }, [COLUMN_SPLIT_SETTING]: { - section: null, + section: t`Columns`, widget: "fieldsPartition", persistDefault: true, getHidden: ([{ data }]) => @@ -192,6 +181,54 @@ class PivotTable extends Component { return addMissingCardBreakouts(setting, card); }, }, + [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 = { @@ -249,6 +286,10 @@ class PivotTable extends Component { this.topHeaderRef && this.topHeaderRef.recomputeCellSizesAndPositions(); } + componentDidMount() { + this.grid = this.bodyRef && findDOMNode(this.bodyRef); + } + render() { const { settings, @@ -257,12 +298,13 @@ class PivotTable extends Component { hasCustomColors, onUpdateVisualizationSettings, isNightMode, + isDashboard, } = this.props; if (data == null || !data.cols.some(isPivotGroupColumn)) { return null; } - const grid = this.bodyRef && findDOMNode(this.bodyRef); + 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 @@ -300,49 +342,35 @@ class PivotTable extends Component { } = pivoted; const leftHeaderCellRenderer = ({ index, key, style }) => { - const { - value, - isSubtotal, - isGrandTotal, - hasChildren, - hasSubtotal, - depth, - path, - clicked, - } = leftHeaderItems[index]; + const { value, isSubtotal, hasSubtotal, depth, path, clicked } = + leftHeaderItems[index]; + return ( - <div + <Cell key={key} style={{ ...style, - backgroundColor: getBgLightColor(hasCustomColors, isNightMode), + ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}), }} - className={cx("overflow-hidden", { - "border-right border-medium": !hasChildren, - })} - > - <Cell - style={depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}} - value={value} - isSubtotal={isSubtotal} - isGrandTotal={isGrandTotal} - hasCustomColors={hasCustomColors} - onClick={this.getCellClickHander(clicked)} - isNightMode={isNightMode} - 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> + 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 }) => { @@ -364,25 +392,21 @@ class PivotTable extends Component { const topHeaderHeight = topHeaderRows * CELL_HEIGHT; const topHeaderCellRenderer = ({ index, key, style }) => { - const { value, hasChildren, clicked } = topHeaderItems[index]; + const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } = + topHeaderItems[index]; return ( - <div + <Cell key={key} - style={style} - className={cx("px1 flex align-center cursor-pointer", { - "border-bottom border-medium": !hasChildren, - })} + style={{ + ...style, + }} + value={value} + isNightMode={isNightMode} + isBorderedHeader={maxDepthBelow === 0} + isEmphasized={hasChildren} + isBold={isSubtotal} onClick={this.getCellClickHander(clicked)} - > - <div - className={cx("flex flex-full full-height align-center", { - "border-bottom": hasChildren, - })} - style={{ width: "100%" }} - > - <Ellipsified>{value}</Ellipsified> - </div> - </div> + /> ); }; const topHeaderCellSizeAndPositionGetter = ({ index }) => { @@ -405,16 +429,16 @@ class PivotTable extends Component { const bodyRenderer = ({ key, style, rowIndex, columnIndex }) => ( <div key={key} style={style} className="flex"> {getRowSection(columnIndex, rowIndex).map( - ({ value, isSubtotal, isGrandTotal, clicked }, index) => ( + ({ value, isSubtotal, clicked, backgroundColor }, index) => ( <Cell + isNightMode={isNightMode} key={index} value={value} - isSubtotal={isSubtotal} - isGrandTotal={isGrandTotal} - hasCustomColors={hasCustomColors} - isNightMode={isNightMode} + isEmphasized={isSubtotal} + isBold={isSubtotal} isBody onClick={this.getCellClickHander(clicked)} + backgroundColor={backgroundColor} /> ), )} @@ -422,34 +446,37 @@ class PivotTable extends Component { ); return ( - <div className="no-outline text-small full-height"> + <PivotTableRoot isDashboard={isDashboard} isNightMode={isNightMode}> <ScrollSync> {({ onScroll, scrollLeft, scrollTop }) => ( <div className="full-height flex flex-column"> <div className="flex" style={{ height: topHeaderHeight }}> {/* top left corner - displays left header columns */} - <div - className={cx("flex align-end", { - "border-right border-bottom border-medium": leftHeaderWidth, - })} + <PivotTableTopLeftCellsContainer + isNightMode={isNightMode} style={{ - backgroundColor: getBgLightColor( - hasCustomColors, - isNightMode, - ), - // add left spacing unless the header width is 0 - paddingLeft: leftHeaderWidth && LEFT_HEADER_LEFT_SPACING, width: leftHeaderWidth, - height: topHeaderHeight, }} > {rowIndexes.map((rowIndex, index) => ( <Cell key={rowIndex} - value={this.getColumnTitle(rowIndex)} - style={{ width: LEFT_HEADER_CELL_WIDTH }} - hasCustomColors={hasCustomColors} + isEmphasized + isBold + isBorderedHeader + isTransparent + hasTopBorder={topHeaderRows > 1} isNightMode={isNightMode} + value={this.getColumnTitle(rowIndex)} + style={{ + width: LEFT_HEADER_CELL_WIDTH, + ...(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 && @@ -465,11 +492,12 @@ class PivotTable extends Component { } /> ))} - </div> + </PivotTableTopLeftCellsContainer> {/* top header */} <Collection ref={e => (this.topHeaderRef = e)} - className="scroll-hide-all text-medium" + className="scroll-hide-all" + isNightMode={isNightMode} width={width - leftHeaderWidth} height={topHeaderHeight} cellCount={topHeaderItems.length} @@ -527,7 +555,7 @@ class PivotTable extends Component { </div> )} </ScrollSync> - </div> + </PivotTableRoot> ); } @@ -605,13 +633,6 @@ function RowToggleIcon({ return ( <RowToggleIconRoot - style={{ - padding: "4px", - borderRadius: "4px", - backgroundColor: isCollapsed - ? getBgLightColor(hasCustomColors, isNightMode) - : getBgDarkColor(hasCustomColors, isNightMode), - }} onClick={e => { e.stopPropagation(); updateSettings({ @@ -626,43 +647,42 @@ function RowToggleIcon({ function Cell({ value, - isSubtotal, - isGrandTotal, - onClick, style, - isBody = false, - className, icon, - hasCustomColors, + backgroundColor, + isBody = false, + isBold, + isEmphasized, isNightMode, + isBorderedHeader, + isTransparent, + hasTopBorder, + onClick, }) { return ( - <div + <PivotTableCell + data-testid="pivot-table-cell" + isNightMode={isNightMode} + isBold={isBold} + isEmphasized={isEmphasized} + isBorderedHeader={isBorderedHeader} + hasTopBorder={hasTopBorder} + isTransparent={isTransparent} style={{ - lineHeight: `${CELL_HEIGHT}px`, - ...(isGrandTotal ? { borderTop: "1px solid white" } : {}), ...style, - ...(isSubtotal + ...(backgroundColor ? { - backgroundColor: getBgDarkColor(hasCustomColors, isNightMode), + backgroundColor, } : {}), }} - className={cx( - "shrink-below-content-size flex-full flex-basis-none TableInteractive-cellWrapper", - className, - { - "text-bold": isSubtotal, - "cursor-pointer": onClick, - }, - )} onClick={onClick} > <div className={cx("px1 flex align-center", { "justify-end": isBody })}> <Ellipsified>{value}</Ellipsified> {icon && <div className="pl1">{icon}</div>} </div> - </div> + </PivotTableCell> ); } @@ -718,3 +738,7 @@ function isColumnValid(col) { isPivotGroupColumn(col) ); } + +function isFormattablePivotColumn(column) { + return column.source === "aggregation"; +} diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx index 48696f60a86..037a2f1ea5a 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx @@ -1,13 +1,113 @@ +import { css } from "@emotion/react"; import styled from "@emotion/styled"; -import { color } from "metabase/lib/colors"; +import { color, alpha, darken } from "metabase/lib/colors"; + +export const CELL_HEIGHT = 30; export const RowToggleIconRoot = styled.div` display: flex; align-items: center; cursor: pointer; - color: ${color("text-light")}; + color: ${color("white")}; + padding: 4px; + border-radius: 4px; + background-color: ${color("text-light")}; + transition: all 200ms; + outline: none; + + &:hover { + background-color: ${darken("text-light", 0.2)}; + } +`; + +interface PivotTableCellProps { + isBold?: boolean; + isEmphasized?: boolean; + isNightMode?: boolean; + isBorderedHeader?: boolean; + hasTopBorder?: boolean; + isTransparent?: boolean; +} + +const getCellBackgroundColor = ({ + isEmphasized, + isNightMode, + isTransparent, +}: Partial<PivotTableCellProps>) => { + if (isTransparent) { + return "transparent"; + } + + if (!isEmphasized) { + return isNightMode ? alpha("bg-black", 0.1) : color("white"); + } + + return isNightMode ? color("bg-black") : alpha("border", 0.25); +}; + +const getColor = ({ isNightMode }: PivotTableCellProps) => { + return isNightMode ? color("white") : color("text-dark"); +}; + +const getBorderColor = ({ isNightMode }: PivotTableCellProps) => { + return isNightMode ? alpha("bg-black", 0.8) : color("border"); +}; + +export const PivotTableCell = styled.div<PivotTableCellProps>` + flex: 1 0 auto; + flex-basis: 0; + line-height: ${CELL_HEIGHT}px; + min-width: 0; + min-height: 0; + font-weight: ${props => (props.isBold ? "bold" : "normal")}; + cursor: ${props => (props.onClick ? "pointer" : "default")}; + color: ${getColor}; + box-shadow: -1px 0 0 0 ${getBorderColor} inset; + border-bottom: 1px solid + ${props => + props.isBorderedHeader ? color("bg-dark") : getBorderColor(props)}; + background-color: ${getCellBackgroundColor}; + ${props => + props.hasTopBorder && + css` + // compensate the top border + line-height: ${CELL_HEIGHT - 1}px; + border-top: 1px solid ${getBorderColor(props)}; + `} &:hover { - color: ${color("brand")}; + background-color: ${color("border")}; } `; + +interface PivotTableTopLeftCellsContainerProps { + isNightMode?: boolean; +} + +export const PivotTableTopLeftCellsContainer = styled.div<PivotTableTopLeftCellsContainerProps>` + display: flex; + align-items: flex-end; + box-shadow: -1px 0 0 0 ${getBorderColor} inset; + background-color: ${props => + getCellBackgroundColor({ + isEmphasized: true, + isNightMode: props.isNightMode, + })}; +`; + +interface PivotTableRootProps { + isDashboard?: boolean; + isNightMode?: boolean; +} + +export const PivotTableRoot = styled.div<PivotTableRootProps>` + height: 100%; + font-size: 0.875em; + + ${props => + props.isDashboard + ? css` + border-top: 1px solid ${getBorderColor(props)}; + ` + : null} +`; diff --git a/frontend/src/metabase/visualizations/visualizations/Table.jsx b/frontend/src/metabase/visualizations/visualizations/Table.jsx index 37fb27f9f9f..56e5e9167f7 100644 --- a/frontend/src/metabase/visualizations/visualizations/Table.jsx +++ b/frontend/src/metabase/visualizations/visualizations/Table.jsx @@ -197,7 +197,7 @@ export default class Table extends Component { }), }, "table.column_widths": {}, - "table.column_formatting": { + [DataGrid.COLUMN_FORMATTING_SETTING]: { section: t`Conditional Formatting`, widget: ChartSettingsTableFormatting, default: [], @@ -225,9 +225,14 @@ export default class Table extends Component { ], settings, ) { - return makeCellBackgroundGetter(rows, cols, settings); + return makeCellBackgroundGetter( + rows, + cols, + settings[DataGrid.COLUMN_FORMATTING_SETTING] ?? [], + settings["table.pivot"], + ); }, - readDependencies: ["table.column_formatting", "table.pivot"], + readDependencies: [DataGrid.COLUMN_FORMATTING_SETTING, "table.pivot"], }, }; diff --git a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js index e07ead3aa48..6b54b04acc0 100644 --- a/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js +++ b/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js @@ -294,12 +294,12 @@ describe("scenarios > visualizations > pivot tables", () => { cy.log("Collapse the options panel"); cy.icon("chevronup").click(); - cy.findByText(/Formatting/).should("not.exist"); + cy.findByText("Formatting").should("not.exist"); cy.findByText(/See options/).should("not.exist"); cy.log("Expand it again"); cy.icon("chevrondown").first().click(); - cy.findByText(/Formatting/); + cy.findByText("Formatting"); cy.findByText(/See options/); }); @@ -340,7 +340,7 @@ describe("scenarios > visualizations > pivot tables", () => { .parent() .findAllByText(/Count/) .click(); - cy.findByText(/Formatting/); + cy.findByText("Formatting"); cy.findByText(/See options/).click(); cy.log("New panel for the column options"); @@ -370,7 +370,7 @@ describe("scenarios > visualizations > pivot tables", () => { .findAllByText(/Count/) .click(); - cy.findByText(/Formatting/); + cy.findByText("Formatting"); cy.findByText(/Sort order/).should("not.exist"); }); @@ -831,6 +831,59 @@ describe("scenarios > visualizations > pivot tables", () => { cy.findAllByText(/Totals for .*/i).should("have.length", 0); }); + it("should apply conditional formatting", () => { + visitQuestionAdhoc({ + dataset_query: { + type: "query", + query: { + "source-table": ORDERS_ID, + aggregation: [["sum", ["field", ORDERS.SUBTOTAL, null]]], + breakout: [ + ["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }], + ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }], + ["field", PEOPLE.STATE, { "source-field": ORDERS.USER_ID }], + ], + filter: [">", ["field", ORDERS.CREATED_AT, null], "2020-01-01"], + }, + database: SAMPLE_DB_ID, + }, + display: "pivot", + visualization_settings: { + "pivot_table.column_split": { + rows: [ + ["field", PEOPLE.STATE, { "source-field": ORDERS.USER_ID }], + ["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }], + ], + columns: [ + ["field", PRODUCTS.CATEGORY, { "source-field": ORDERS.PRODUCT_ID }], + ], + values: [["aggregation", 0]], + }, + "pivot_table.collapsed_rows": { + value: [], + rows: [ + ["field", PEOPLE.STATE, { "source-field": ORDERS.USER_ID }], + ["field", ORDERS.CREATED_AT, { "temporal-unit": "year" }], + ], + }, + }, + }); + + cy.findByText("Settings").click(); + cy.findByText("Conditional Formatting").click(); + + cy.findByText("Add a rule").click(); + cy.findByTestId("conditional-formatting-value-input").type("70"); + cy.findByText("is equal to").click(); + cy.findByText("is less than or equal to").click(); + + cy.contains("[data-testid=pivot-table-cell]", "65.09").should( + "have.css", + "background-color", + "rgba(80, 158, 227, 0.65)", + ); + }); + it.skip("should sort by metric (metabase#22872)", () => { const questionDetails = { dataset_query: { -- GitLab