diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js index 575d3853aec57b7298223009a9c588ff8d6b3d35..44ad4326db1feaa46ffd9833b081bef8ce9eca2c 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"; @@ -38,12 +39,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 +79,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 +181,12 @@ export function multiLevelPivot(data, settings) { const leftHeaderItems = treeToArray(formattedRowTree.flat()); const topHeaderItems = treeToArray(formattedColumnTree.flat()); + const colorGetter = makeCellBackgroundGetter( + pivotData[primaryRowsKey], + columns, + settings, + ); + const getRowSection = createRowSectionGetter({ valuesByKey, subtotalValues, @@ -188,6 +195,7 @@ export function multiLevelPivot(data, settings) { rowColumnIndexes, columnIndex, rowIndex, + colorGetter, }); return { @@ -206,7 +214,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 +268,7 @@ function createRowSectionGetter({ rowColumnIndexes, columnIndex, rowIndex, + colorGetter, }) { const formatValues = values => values === undefined @@ -292,10 +301,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 b31a5575e343939d1994d558342dad92ce4be64e..2cf33ed5bfc5ab4dd433100bb2fda137b9ac1b4a 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); @@ -381,11 +390,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/visualizations/PivotTable.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx index b5b0e2124d6a103fb1acba8da9cd972e7874211f..5c86269add94834979f80f6bc26a50717ec6f0fb 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"; @@ -27,23 +26,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 +60,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 +128,7 @@ class PivotTable extends Component { }, }, [COLUMN_SPLIT_SETTING]: { - section: null, + section: t`Columns`, widget: "fieldsPartition", persistDefault: true, getHidden: ([{ data }]) => @@ -192,6 +180,17 @@ class PivotTable extends Component { return addMissingCardBreakouts(setting, card); }, }, + "table.column_formatting": { + section: t`Conditional Formatting`, + widget: ChartSettingsTableFormatting, + default: [], + getProps: series => ({ + canHighlightRow: false, + cols: series[0].data.cols.filter(isFormattablePivotColumn), + }), + getHidden: ([{ data }]) => + !data?.cols.some(col => isFormattablePivotColumn(col)), + }, }; static columnSettings = { @@ -257,6 +256,7 @@ class PivotTable extends Component { hasCustomColors, onUpdateVisualizationSettings, isNightMode, + isDashboard, } = this.props; if (data == null || !data.cols.some(isPivotGroupColumn)) { return null; @@ -300,49 +300,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 +350,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 +387,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 +404,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 +450,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 +513,7 @@ class PivotTable extends Component { </div> )} </ScrollSync> - </div> + </PivotTableRoot> ); } @@ -605,13 +591,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 +605,41 @@ 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 + 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 +695,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 48696f60a86b52cb2ff2fa73779a43f2b87b2be5..037a2f1ea5a383e8af7a3b8b6de81a4cc2d67160 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/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js b/frontend/test/metabase/scenarios/visualizations/pivot_tables.cy.spec.js index e07ead3aa48b7369fca6f1e0c49be957cf5e308e..03bb272df4cefdcbaf32c60c2828e969f56c8a95 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"); });