diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx index c2786eee63fcc2788dac8625104ba910f2e81416..7f4df36a6cb472973e0596929ddcdc189d179705 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx @@ -360,6 +360,7 @@ class PivotTable extends Component { const { leftHeaderWidths, totalHeaderWidths } = getLeftHeaderWidths({ rowIndexes: rowIndexes ?? [], getColumnTitle: idx => this.getColumnTitle(idx), + leftHeaderItems, fontFamily: fontFamily, }); diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts index f5e507fc42201e7424ea7a93faa0d2198f685e59..3ea138845d044350e9fcf8a0a230e608e5b91a90 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts @@ -16,3 +16,5 @@ export const MAX_HEADER_CELL_WIDTH = // the left header has some additional padding on the left to align with the title export const LEFT_HEADER_LEFT_SPACING = 24; + +export const MAX_ROWS_TO_MEASURE = 100; diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts index 215adc9e0ee8bcb09e4701329b89c3c14cc2ab52..3da159de6658cd6431441c93d30c3d128efdc634 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts @@ -1,4 +1,5 @@ import type { FieldReference, AggregationReference } from "metabase-types/api"; +import type { Column } from "metabase-types/types/Dataset"; export type FieldOrAggregationReference = FieldReference | AggregationReference; @@ -7,3 +8,22 @@ export type PivotSetting = { rows: FieldReference[]; values: AggregationReference[]; }; + +export interface LeftHeaderItem { + clicked: { value: string; column: Column }; + + isCollapsed: boolean; + hasChildren: boolean; + hasSubtotal?: boolean; + isSubtotal?: boolean; + isGrandTotal?: boolean; + + depth: number; + maxDepthBelow: number; + offset: number; + span: number; // rows to span + + path: string[]; + rawValue: string; + value: string; +} diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts index 94e1af6630aa212e3947b8519b680fcc0f694484..20e7dbf7eb51a4d08607e989158af3564bd9d595 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts @@ -6,7 +6,11 @@ import { measureText } from "metabase/lib/measure-text"; import type { Column } from "metabase-types/types/Dataset"; import type { Card } from "metabase-types/types/Card"; -import type { PivotSetting, FieldOrAggregationReference } from "./types"; +import type { + PivotSetting, + FieldOrAggregationReference, + LeftHeaderItem, +} from "./types"; import { partitions } from "./partitions"; import { @@ -15,6 +19,7 @@ import { MIN_HEADER_CELL_WIDTH, MAX_HEADER_CELL_WIDTH, PIVOT_TABLE_FONT_SIZE, + MAX_ROWS_TO_MEASURE, } from "./constants"; // adds or removes columns from the pivot settings based on the current query @@ -88,25 +93,44 @@ export function isFormattablePivotColumn(column: Column) { interface GetLeftHeaderWidthsProps { rowIndexes: number[]; getColumnTitle: (columnIndex: number) => string; + leftHeaderItems?: LeftHeaderItem[]; fontFamily?: string; } export function getLeftHeaderWidths({ rowIndexes, getColumnTitle, + leftHeaderItems = [], fontFamily = "Lato", }: GetLeftHeaderWidthsProps) { - const widths = rowIndexes.map(rowIndex => { + const cellValues = getColumnValues(leftHeaderItems); + + const widths = rowIndexes.map((rowIndex, depthIndex) => { + const computedHeaderWidth = Math.ceil( + measureText(getColumnTitle(rowIndex), { + weight: "bold", + family: fontFamily, + size: PIVOT_TABLE_FONT_SIZE, + }) + ROW_TOGGLE_ICON_WIDTH, + ); + + const computedCellWidth = Math.ceil( + Math.max( + // we need to use the depth index because the data is in depth order, not row index order + ...(cellValues[depthIndex]?.values?.map( + value => + measureText(value, { + weight: "normal", + family: fontFamily, + size: PIVOT_TABLE_FONT_SIZE, + }) + + (cellValues[rowIndex]?.hasSubtotal ? ROW_TOGGLE_ICON_WIDTH : 0), + ) ?? [0]), + ), + ); + const computedWidth = - Math.ceil( - measureText(getColumnTitle(rowIndex), { - weight: "bold", - family: fontFamily, - size: PIVOT_TABLE_FONT_SIZE, - }), - ) + - ROW_TOGGLE_ICON_WIDTH + - CELL_PADDING; + Math.max(computedHeaderWidth, computedCellWidth) + CELL_PADDING; if (computedWidth > MAX_HEADER_CELL_WIDTH) { return MAX_HEADER_CELL_WIDTH; @@ -123,3 +147,38 @@ export function getLeftHeaderWidths({ return { leftHeaderWidths: widths, totalHeaderWidths: total }; } + +type ColumnValueInfo = { + values: string[]; + hasSubtotal: boolean; +}; + +export function getColumnValues(leftHeaderItems: LeftHeaderItem[]) { + const columnValues: ColumnValueInfo[] = []; + + leftHeaderItems + .slice(0, MAX_ROWS_TO_MEASURE) + .forEach((leftHeaderItem: LeftHeaderItem) => { + const { value, depth, isSubtotal, isGrandTotal, hasSubtotal } = + leftHeaderItem; + + // don't size based on subtotals or grand totals + if (!isSubtotal && !isGrandTotal) { + if (!columnValues[depth]) { + columnValues[depth] = { + values: [value], + hasSubtotal: false, + }; + } else { + columnValues[depth].values.push(value); + } + + // we need to track whether the column has a subtotal to size for the row expand icon + if (hasSubtotal) { + columnValues[depth].hasSubtotal = true; + } + } + }); + + return columnValues; +} 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 830fbaa2257132c7cf51e33ca2030085f814e511..31d5fd65cc82e05b83df739009f71b95ca0535d6 100644 --- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts +++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts @@ -1,6 +1,7 @@ import type { Column } from "metabase-types/types/Dataset"; import type { Card } from "metabase-types/types/Card"; -import type { PivotSetting } from "./types"; + +import type { PivotSetting, LeftHeaderItem } from "./types"; import { isColumnValid, @@ -8,9 +9,15 @@ import { updateValueWithCurrentColumns, addMissingCardBreakouts, getLeftHeaderWidths, + getColumnValues, } from "./utils"; -import { MAX_HEADER_CELL_WIDTH, MIN_HEADER_CELL_WIDTH } from "./constants"; +import { + MAX_HEADER_CELL_WIDTH, + MIN_HEADER_CELL_WIDTH, + CELL_PADDING, + ROW_TOGGLE_ICON_WIDTH, +} from "./constants"; describe("Visualizations > Visualizations > PivotTable > utils", () => { const cols = [ @@ -266,5 +273,114 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => { MAX_HEADER_CELL_WIDTH, ]); }); + + it("should return the wider of the column header or data width", () => { + const data = [ + { depth: 0, value: "x".repeat(150) }, + { depth: 0, value: "foo2" }, + { depth: 1, value: "bar1" }, + { depth: 1, value: "bar2" }, + { depth: 2, value: "baz1" }, + { depth: 4, value: "boo1" }, + ] as LeftHeaderItem[]; + + const { leftHeaderWidths } = getLeftHeaderWidths({ + rowIndexes: [0, 1, 2, 3, 4], + leftHeaderItems: data, + getColumnTitle: () => "x".repeat(70), + }); + + expect(leftHeaderWidths).toEqual([ + 150 + CELL_PADDING, + 70 + CELL_PADDING + ROW_TOGGLE_ICON_WIDTH, + 70 + CELL_PADDING + ROW_TOGGLE_ICON_WIDTH, + 70 + CELL_PADDING + ROW_TOGGLE_ICON_WIDTH, + 70 + CELL_PADDING + ROW_TOGGLE_ICON_WIDTH, + ]); + }); + + it("should factor in the toggle icon width for columns with subtotals", () => { + const data = [ + { depth: 0, value: "x".repeat(100), hasSubtotal: true }, + { depth: 0, value: "foo2" }, + { depth: 1, value: "bar1" }, + { depth: 1, value: "bar2" }, + { depth: 2, value: "baz1" }, + { depth: 4, value: "boo1" }, + ] as LeftHeaderItem[]; + + const { leftHeaderWidths } = getLeftHeaderWidths({ + rowIndexes: [0, 1, 2, 3, 4], + leftHeaderItems: data, + getColumnTitle: () => "test-123", + }); + + expect(leftHeaderWidths).toEqual([ + 100 + CELL_PADDING + ROW_TOGGLE_ICON_WIDTH, + MIN_HEADER_CELL_WIDTH, + MIN_HEADER_CELL_WIDTH, + MIN_HEADER_CELL_WIDTH, + MIN_HEADER_CELL_WIDTH, + ]); + }); + }); + + describe("getColumnValues", () => { + it("can collect column values from left header data", () => { + const data = [ + { depth: 0, value: "foo1" }, + { depth: 0, value: "foo2" }, + { depth: 1, value: "bar1" }, + { depth: 1, value: "bar2" }, + { depth: 2, value: "baz1" }, + { depth: 4, value: "boo1" }, + ] as LeftHeaderItem[]; + + const result = getColumnValues(data); + + expect(result).toEqual([ + { values: ["foo1", "foo2"], hasSubtotal: false }, + { values: ["bar1", "bar2"], hasSubtotal: false }, + { values: ["baz1"], hasSubtotal: false }, + undefined, // no depth of 3 + { values: ["boo1"], hasSubtotal: false }, + ]); + }); + + it("detects columns with subtotals", () => { + const data = [ + { depth: 0, value: "foo1", hasSubtotal: false }, + { depth: 0, value: "foo2", hasSubtotal: true }, + { depth: 1, value: "bar1", hasSubtotal: false }, + { depth: 1, value: "bar2", hasSubtotal: false }, + { depth: 2, value: "baz1", hasSubtotal: true }, + ] as LeftHeaderItem[]; + + const result = getColumnValues(data); + + expect(result).toEqual([ + { values: ["foo1", "foo2"], hasSubtotal: true }, + { values: ["bar1", "bar2"], hasSubtotal: false }, + { values: ["baz1"], hasSubtotal: true }, + ]); + }); + + it("handles null values", () => { + const data = [ + { depth: 0, value: "foo1", hasSubtotal: false }, + { depth: 0, value: null, hasSubtotal: true }, + { depth: 1, value: "bar1", hasSubtotal: false }, + { depth: 1, value: "bar2", hasSubtotal: false }, + { depth: 2, value: "baz1", hasSubtotal: true }, + ] as LeftHeaderItem[]; + + const result = getColumnValues(data); + + expect(result).toEqual([ + { values: ["foo1", null], hasSubtotal: true }, + { values: ["bar1", "bar2"], hasSubtotal: false }, + { values: ["baz1"], hasSubtotal: true }, + ]); + }); }); });