diff --git a/frontend/src/metabase/lib/data_grid.js b/frontend/src/metabase/lib/data_grid.js index 44ad4326db1feaa46ffd9833b081bef8ce9eca2c..575d3853aec57b7298223009a9c588ff8d6b3d35 100644 --- a/frontend/src/metabase/lib/data_grid.js +++ b/frontend/src/metabase/lib/data_grid.js @@ -1,7 +1,6 @@ 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"; @@ -39,7 +38,12 @@ export function multiLevelPivot(data, settings) { .filter(index => index !== -1), ); - const { pivotData, columns } = splitPivotData(data); + const { pivotData, columns } = splitPivotData( + data, + rowColumnIndexes, + columnColumnIndexes, + ); + const columnSettings = columns.map(column => settings.column(column)); const allCollapsedSubtotals = settings[COLLAPSED_ROWS_SETTING].value; const collapsedSubtotals = filterCollapsedSubtotals( @@ -79,13 +83,8 @@ 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) => ({ @@ -181,12 +180,6 @@ 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, @@ -195,7 +188,6 @@ export function multiLevelPivot(data, settings) { rowColumnIndexes, columnIndex, rowIndex, - colorGetter, }); return { @@ -214,7 +206,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) { +function splitPivotData(data, rowIndexes, columnIndexes) { const groupIndex = data.cols.findIndex(isPivotGroupColumn); const columns = data.cols.filter(col => !isPivotGroupColumn(col)); const breakouts = columns.filter(col => col.source === "breakout"); @@ -268,7 +260,6 @@ function createRowSectionGetter({ rowColumnIndexes, columnIndex, rowIndex, - colorGetter, }) { const formatValues = values => values === undefined @@ -301,20 +292,10 @@ function createRowSectionGetter({ const otherAttrs = rowValues.length === 0 ? { isGrandTotal: true } : {}; return getSubtotals(indexes, indexValues, otherAttrs); } - const { values, data, dimensions, valueColumns } = + const { values, data, dimensions } = valuesByKey[JSON.stringify(indexValues)] || {}; - return formatValues(values).map((o, index) => - data === undefined - ? o - : { - ...o, - clicked: { data, dimensions }, - backgroundColor: colorGetter( - values[index], - o.rowIndex, - valueColumns[index].name, - ), - }, + return formatValues(values).map(o => + data === undefined ? o : { ...o, clicked: { data, dimensions } }, ); }; 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 2cf33ed5bfc5ab4dd433100bb2fda137b9ac1b4a..b31a5575e343939d1994d558342dad92ce4be64e 100644 --- a/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx +++ b/frontend/src/metabase/visualizations/components/settings/ChartSettingsTableFormatting.jsx @@ -94,12 +94,11 @@ export default class ChartSettingsTableFormatting extends React.Component { editingRuleIsNew: null, }; render() { - const { value, onChange, cols, canHighlightRow } = this.props; + const { value, onChange, cols } = this.props; const { editingRule, editingRuleIsNew } = this.state; if (editingRule !== null && value[editingRule]) { return ( <RuleEditor - canHighlightRow={canHighlightRow} rule={value[editingRule]} cols={cols} isNew={editingRuleIsNew} @@ -298,15 +297,7 @@ const RuleDescription = ({ rule }) => { ); }; -const RuleEditor = ({ - rule, - cols, - isNew, - onChange, - onDone, - onRemove, - canHighlightRow = true, -}) => { +const RuleEditor = ({ rule, cols, isNew, onChange, onDone, onRemove }) => { const selectedColumns = rule.columns.map(name => _.findWhere(cols, { name })); const isStringRule = selectedColumns.length > 0 && _.all(selectedColumns, isString); @@ -390,16 +381,11 @@ const RuleEditor = ({ colors={COLORS} onChange={color => onChange({ ...rule, color })} /> - {canHighlightRow && ( - <> - <h3 className="mt3 mb1">{t`Highlight the whole row`}</h3> - - <Toggle - value={rule.highlight_row} - onChange={highlight_row => onChange({ ...rule, highlight_row })} - /> - </> - )} + <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 5c86269add94834979f80f6bc26a50717ec6f0fb..b5b0e2124d6a103fb1acba8da9cd972e7874211f 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable.jsx @@ -6,8 +6,9 @@ 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"; @@ -26,13 +27,23 @@ 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 { - PivotTableRoot, - PivotTableCell, - PivotTableTopLeftCellsContainer, - RowToggleIconRoot, - CELL_HEIGHT, -} from "./PivotTable.styled"; +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); +}; const partitions = [ { @@ -60,6 +71,7 @@ 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; @@ -128,7 +140,7 @@ class PivotTable extends Component { }, }, [COLUMN_SPLIT_SETTING]: { - section: t`Columns`, + section: null, widget: "fieldsPartition", persistDefault: true, getHidden: ([{ data }]) => @@ -180,17 +192,6 @@ 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 = { @@ -256,7 +257,6 @@ class PivotTable extends Component { hasCustomColors, onUpdateVisualizationSettings, isNightMode, - isDashboard, } = this.props; if (data == null || !data.cols.some(isPivotGroupColumn)) { return null; @@ -300,35 +300,49 @@ class PivotTable extends Component { } = pivoted; const leftHeaderCellRenderer = ({ index, key, style }) => { - const { value, isSubtotal, hasSubtotal, depth, path, clicked } = - leftHeaderItems[index]; - + const { + value, + isSubtotal, + isGrandTotal, + hasChildren, + hasSubtotal, + depth, + path, + clicked, + } = leftHeaderItems[index]; return ( - <Cell + <div key={key} style={{ ...style, - ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}), + backgroundColor: getBgLightColor(hasCustomColors, isNightMode), }} - 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> + 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> ); }; const leftHeaderCellSizeAndPositionGetter = ({ index }) => { @@ -350,21 +364,25 @@ class PivotTable extends Component { const topHeaderHeight = topHeaderRows * CELL_HEIGHT; const topHeaderCellRenderer = ({ index, key, style }) => { - const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } = - topHeaderItems[index]; + const { value, hasChildren, clicked } = topHeaderItems[index]; return ( - <Cell + <div key={key} - style={{ - ...style, - }} - value={value} - isNightMode={isNightMode} - isBorderedHeader={maxDepthBelow === 0} - isEmphasized={hasChildren} - isBold={isSubtotal} + style={style} + className={cx("px1 flex align-center cursor-pointer", { + "border-bottom border-medium": !hasChildren, + })} 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 }) => { @@ -387,16 +405,16 @@ class PivotTable extends Component { const bodyRenderer = ({ key, style, rowIndex, columnIndex }) => ( <div key={key} style={style} className="flex"> {getRowSection(columnIndex, rowIndex).map( - ({ value, isSubtotal, clicked, backgroundColor }, index) => ( + ({ value, isSubtotal, isGrandTotal, clicked }, index) => ( <Cell - isNightMode={isNightMode} key={index} value={value} - isEmphasized={isSubtotal} - isBold={isSubtotal} + isSubtotal={isSubtotal} + isGrandTotal={isGrandTotal} + hasCustomColors={hasCustomColors} + isNightMode={isNightMode} isBody onClick={this.getCellClickHander(clicked)} - backgroundColor={backgroundColor} /> ), )} @@ -404,37 +422,34 @@ class PivotTable extends Component { ); return ( - <PivotTableRoot isDashboard={isDashboard} isNightMode={isNightMode}> + <div className="no-outline text-small full-height"> <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} + <div + className={cx("flex align-end", { + "border-right border-bottom border-medium": leftHeaderWidth, + })} 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} - 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" } - : {}), - }} + style={{ width: LEFT_HEADER_CELL_WIDTH }} + hasCustomColors={hasCustomColors} + isNightMode={isNightMode} icon={ // you can only collapse before the last column index < rowIndexes.length - 1 && @@ -450,12 +465,11 @@ class PivotTable extends Component { } /> ))} - </PivotTableTopLeftCellsContainer> + </div> {/* top header */} <Collection ref={e => (this.topHeaderRef = e)} - className="scroll-hide-all" - isNightMode={isNightMode} + className="scroll-hide-all text-medium" width={width - leftHeaderWidth} height={topHeaderHeight} cellCount={topHeaderItems.length} @@ -513,7 +527,7 @@ class PivotTable extends Component { </div> )} </ScrollSync> - </PivotTableRoot> + </div> ); } @@ -591,6 +605,13 @@ function RowToggleIcon({ return ( <RowToggleIconRoot + style={{ + padding: "4px", + borderRadius: "4px", + backgroundColor: isCollapsed + ? getBgLightColor(hasCustomColors, isNightMode) + : getBgDarkColor(hasCustomColors, isNightMode), + }} onClick={e => { e.stopPropagation(); updateSettings({ @@ -605,41 +626,43 @@ function RowToggleIcon({ function Cell({ value, + isSubtotal, + isGrandTotal, + onClick, style, - icon, - backgroundColor, isBody = false, - isBold, - isEmphasized, + className, + icon, + hasCustomColors, isNightMode, - isBorderedHeader, - isTransparent, - hasTopBorder, - onClick, }) { return ( - <PivotTableCell - isNightMode={isNightMode} - isBold={isBold} - isEmphasized={isEmphasized} - isBorderedHeader={isBorderedHeader} - hasTopBorder={hasTopBorder} - isTransparent={isTransparent} + <div style={{ + lineHeight: `${CELL_HEIGHT}px`, + ...(isGrandTotal ? { borderTop: "1px solid white" } : {}), ...style, - ...(backgroundColor + ...(isSubtotal ? { - backgroundColor, + backgroundColor: getBgDarkColor(hasCustomColors, isNightMode), } : {}), }} + 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> - </PivotTableCell> + </div> ); } @@ -695,7 +718,3 @@ 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 037a2f1ea5a383e8af7a3b8b6de81a4cc2d67160..48696f60a86b52cb2ff2fa73779a43f2b87b2be5 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable.styled.tsx @@ -1,113 +1,13 @@ -import { css } from "@emotion/react"; import styled from "@emotion/styled"; -import { color, alpha, darken } from "metabase/lib/colors"; - -export const CELL_HEIGHT = 30; +import { color } from "metabase/lib/colors"; export const RowToggleIconRoot = styled.div` display: flex; align-items: center; cursor: pointer; - 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)}; - `} + color: ${color("text-light")}; &:hover { - background-color: ${color("border")}; + color: ${color("brand")}; } `; - -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 03bb272df4cefdcbaf32c60c2828e969f56c8a95..e07ead3aa48b7369fca6f1e0c49be957cf5e308e 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"); });