Skip to content
Snippets Groups Projects
Unverified Commit 971c5053 authored by Aleksandr Lesnenko's avatar Aleksandr Lesnenko Committed by GitHub
Browse files

Pivot table conditional formatting (#24930)

* pivot table conditional formatting

* tweak styles

* hide highlight row setting

* adjust styles

* specs
parent d87f21e5
No related branches found
No related tags found
No related merge requests found
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());
......
......@@ -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>
......
......@@ -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";
}
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}
`;
......@@ -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");
});
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment