From 7b81f1d0d90d65b6af6c6075178113467fcb3da4 Mon Sep 17 00:00:00 2001
From: Ryan Laurie <30528226+iethree@users.noreply.github.com>
Date: Tue, 17 Jan 2023 12:40:24 -0700
Subject: [PATCH] Refactor PivotTable Visualization into a functional
 typescript component (#27651)

* add PivotTable unit tests

* obey the linter

* measure leftHeader cell content

* test cell data width detection

* extract static properties

* convert to functional component

* cleanup refs

* extract cellRenderers

* extract size and position getter functions

* replace item measurement

* convert rowToggleIcon to typescript

* convert PivotTable to typescript

* remove unused hasCustomColors

* update tests to typescript

* fix rebase
---
 frontend/src/metabase-types/api/card.ts       |  23 +-
 .../visualizations/lib/settings/column.js     |   2 +
 .../visualizations/PivotTable/PivotTable.jsx  | 614 ------------------
 .../PivotTable/PivotTable.styled.tsx          |  26 +-
 .../visualizations/PivotTable/PivotTable.tsx  | 421 ++++++++++++
 ...unit.spec.jsx => PivotTable.unit.spec.tsx} |  31 +-
 .../PivotTable/PivotTableCell.jsx             |  48 --
 .../PivotTable/PivotTableCell.tsx             | 206 ++++++
 .../{RowToggleIcon.jsx => RowToggleIcon.tsx}  |  40 +-
 .../visualizations/PivotTable/constants.ts    |   2 +
 .../visualizations/PivotTable/settings.ts     | 253 ++++++++
 .../visualizations/PivotTable/types.ts        |  16 +-
 .../visualizations/PivotTable/utils.ts        |  91 ++-
 .../PivotTable/utils.unit.spec.ts             |  12 +-
 14 files changed, 1081 insertions(+), 704 deletions(-)
 delete mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx
 create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx
 rename frontend/src/metabase/visualizations/visualizations/PivotTable/{PivotTable.unit.spec.jsx => PivotTable.unit.spec.tsx} (87%)
 delete mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx
 create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
 rename frontend/src/metabase/visualizations/visualizations/PivotTable/{RowToggleIcon.jsx => RowToggleIcon.tsx} (70%)
 create mode 100644 frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts

diff --git a/frontend/src/metabase-types/api/card.ts b/frontend/src/metabase-types/api/card.ts
index 5fede8011f0..332bfee179d 100644
--- a/frontend/src/metabase-types/api/card.ts
+++ b/frontend/src/metabase-types/api/card.ts
@@ -1,6 +1,10 @@
 import type { DatabaseId } from "./database";
 import type { Field } from "./field";
-import type { DatasetQuery } from "./query";
+import type {
+  DatasetQuery,
+  FieldReference,
+  AggregationReference,
+} from "./query";
 
 export interface Card extends UnsavedCard {
   id: CardId;
@@ -46,6 +50,20 @@ export type SeriesOrderSetting = {
   color?: string;
 };
 
+export type ColumnFormattingSetting = {
+  columns: string[]; // column names
+  color?: string;
+  type?: string;
+  operator?: string;
+  value?: string | number;
+  highlight_row?: boolean;
+};
+
+export type PivotTableCollapsedRowsSetting = {
+  rows: (FieldReference | AggregationReference)[];
+  value: string[]; // identifiers for collapsed rows
+};
+
 export type VisualizationSettings = {
   "graph.show_values"?: boolean;
   "stackable.stack_type"?: "stacked" | "normalized" | null;
@@ -77,6 +95,9 @@ export type VisualizationSettings = {
   // Funnel settings
   "funnel.rows"?: SeriesOrderSetting[];
 
+  "table.column_formatting"?: ColumnFormattingSetting[];
+  "pivot_table.collapsed_rows"?: PivotTableCollapsedRowsSetting;
+
   [key: string]: any;
 };
 
diff --git a/frontend/src/metabase/visualizations/lib/settings/column.js b/frontend/src/metabase/visualizations/lib/settings/column.js
index 5fc75b9c180..427622fcf24 100644
--- a/frontend/src/metabase/visualizations/lib/settings/column.js
+++ b/frontend/src/metabase/visualizations/lib/settings/column.js
@@ -26,6 +26,7 @@ const DEFAULT_GET_COLUMNS = (series, vizSettings) =>
 
 export function columnSettings({
   getColumns = DEFAULT_GET_COLUMNS,
+  hidden,
   ...def
 } = {}) {
   return nestedSettings("column_settings", {
@@ -37,6 +38,7 @@ export function columnSettings({
     component: ChartNestedSettingColumns,
     getInheritedSettingsForObject: getInhertiedSettingsForColumn,
     useRawSeries: true,
+    hidden,
     ...def,
   });
 }
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx
deleted file mode 100644
index 7f4df36a6cb..00000000000
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.jsx
+++ /dev/null
@@ -1,614 +0,0 @@
-/* eslint-disable react/prop-types */
-import React, { Component } from "react";
-import { t } from "ttag";
-import _ from "underscore";
-import { getIn } from "icepick";
-import { Grid, Collection, ScrollSync, AutoSizer } from "react-virtualized";
-
-import { findDOMNode } from "react-dom";
-import { connect } from "react-redux";
-import { getScrollBarSize } from "metabase/lib/dom";
-import { getSetting } from "metabase/selectors/settings";
-import ChartSettingsTableFormatting from "metabase/visualizations/components/settings/ChartSettingsTableFormatting";
-
-import {
-  COLLAPSED_ROWS_SETTING,
-  COLUMN_SPLIT_SETTING,
-  COLUMN_SORT_ORDER,
-  COLUMN_SORT_ORDER_ASC,
-  COLUMN_SORT_ORDER_DESC,
-  COLUMN_SHOW_TOTALS,
-  COLUMN_FORMATTING_SETTING,
-  isPivotGroupColumn,
-  multiLevelPivot,
-} from "metabase/lib/data_grid";
-import { formatColumn } from "metabase/lib/formatting";
-import { columnSettings } from "metabase/visualizations/lib/settings/column";
-import { ChartSettingIconRadio } from "metabase/visualizations/components/settings/ChartSettingIconRadio";
-
-import { PLUGIN_SELECTORS } from "metabase/plugins";
-import { isDimension } from "metabase-lib/types/utils/isa";
-
-import { RowToggleIcon } from "./RowToggleIcon";
-import { Cell } from "./PivotTableCell";
-
-import {
-  PivotTableRoot,
-  PivotTableTopLeftCellsContainer,
-} from "./PivotTable.styled";
-
-import { partitions } from "./partitions";
-import {
-  addMissingCardBreakouts,
-  isColumnValid,
-  isFormattablePivotColumn,
-  updateValueWithCurrentColumns,
-  getLeftHeaderWidths,
-} from "./utils";
-
-import { CELL_WIDTH, CELL_HEIGHT, LEFT_HEADER_LEFT_SPACING } from "./constants";
-
-const mapStateToProps = state => ({
-  hasCustomColors: PLUGIN_SELECTORS.getHasCustomColors(state),
-  fontFamily: getSetting(state, "application-font"),
-});
-
-class PivotTable extends Component {
-  static uiName = t`Pivot Table`;
-  static identifier = "pivot";
-  static iconName = "pivot_table";
-
-  static isLiveResizable(series) {
-    return false;
-  }
-
-  static databaseSupportsPivotTables(query) {
-    if (query && query.database && query.database() != null) {
-      // if we don't have metadata, we can't check this
-      return query.database().supportsPivots();
-    }
-    return true;
-  }
-
-  static isSensible({ cols }, query) {
-    return (
-      cols.length >= 2 &&
-      cols.every(isColumnValid) &&
-      this.databaseSupportsPivotTables(query)
-    );
-  }
-
-  static checkRenderable([{ data, card }], settings, query) {
-    if (data.cols.length < 2 || !data.cols.every(isColumnValid)) {
-      throw new Error(
-        t`Pivot tables can only be used with aggregated queries.`,
-      );
-    }
-    if (!this.databaseSupportsPivotTables(query)) {
-      throw new Error(t`This database does not support pivot tables.`);
-    }
-  }
-
-  static seriesAreCompatible(initialSeries, newSeries) {
-    return false;
-  }
-
-  static settings = {
-    ...columnSettings({ hidden: true }),
-    [COLLAPSED_ROWS_SETTING]: {
-      hidden: true,
-      readDependencies: [COLUMN_SPLIT_SETTING],
-      getValue: (series, settings = {}) => {
-        // This is hack. Collapsed rows depend on the current column split setting.
-        // If the query changes or the rows are reordered, we ignore the current collapsed row setting.
-        // This is accomplished by snapshotting part of the column split setting *inside* this setting.
-        // `value` the is the actual data for this setting
-        // `rows` is value we check against the current setting to see if we should use `value`
-        const { rows, value } = settings[COLLAPSED_ROWS_SETTING] || {};
-        const { rows: currentRows } = settings[COLUMN_SPLIT_SETTING] || {};
-        if (!_.isEqual(rows, currentRows)) {
-          return { value: [], rows: currentRows };
-        }
-        return { rows, value };
-      },
-    },
-    [COLUMN_SPLIT_SETTING]: {
-      section: t`Columns`,
-      widget: "fieldsPartition",
-      persistDefault: true,
-      getHidden: ([{ data }]) =>
-        // hide the setting widget if there are invalid columns
-        !data || data.cols.some(col => !isColumnValid(col)),
-      getProps: ([{ data }], settings) => ({
-        partitions,
-        columns: data == null ? [] : data.cols,
-        settings,
-      }),
-      getValue: ([{ data, card }], settings = {}) => {
-        const storedValue = settings[COLUMN_SPLIT_SETTING];
-        if (data == null) {
-          return undefined;
-        }
-        const columnsToPartition = data.cols.filter(
-          col => !isPivotGroupColumn(col),
-        );
-        let setting;
-        if (storedValue == null) {
-          const [dimensions, values] = _.partition(
-            columnsToPartition,
-            isDimension,
-          );
-          const [first, second, ...rest] = _.sortBy(dimensions, col =>
-            getIn(col, ["fingerprint", "global", "distinct-count"]),
-          );
-          let rows, columns;
-          if (dimensions.length < 2) {
-            columns = [];
-            rows = [first];
-          } else if (dimensions.length <= 3) {
-            columns = [first];
-            rows = [second, ...rest];
-          } else {
-            columns = [first, second];
-            rows = rest;
-          }
-          setting = _.mapObject({ rows, columns, values }, cols =>
-            cols.map(col => col.field_ref),
-          );
-        } else {
-          setting = updateValueWithCurrentColumns(
-            storedValue,
-            columnsToPartition,
-          );
-        }
-
-        return addMissingCardBreakouts(setting, card);
-      },
-    },
-    "pivot.show_row_totals": {
-      section: t`Columns`,
-      title: t`Show row totals`,
-      widget: "toggle",
-      default: true,
-      inline: true,
-    },
-    "pivot.show_column_totals": {
-      section: t`Columns`,
-      title: t`Show column totals`,
-      widget: "toggle",
-      default: true,
-      inline: true,
-    },
-    [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 = {
-    [COLUMN_SORT_ORDER]: {
-      title: t`Sort order`,
-      widget: ChartSettingIconRadio,
-      inline: true,
-      borderBottom: true,
-      props: {
-        options: [
-          {
-            iconName: "arrow_up",
-            value: COLUMN_SORT_ORDER_ASC,
-          },
-          {
-            iconName: "arrow_down",
-            value: COLUMN_SORT_ORDER_DESC,
-          },
-        ],
-      },
-      getHidden: ({ source }) => source === "aggregation",
-    },
-    [COLUMN_SHOW_TOTALS]: {
-      title: t`Show totals`,
-      widget: "toggle",
-      inline: true,
-      getDefault: (column, columnSettings, { settings }) => {
-        //Default to showing totals if appropriate
-        const rows = settings[COLUMN_SPLIT_SETTING].rows || [];
-        return rows.slice(0, -1).some(row => _.isEqual(row, column.field_ref));
-      },
-      getHidden: (column, columnSettings, { settings }) => {
-        const rows = settings[COLUMN_SPLIT_SETTING].rows || [];
-        // to show totals a column needs to be:
-        //  - in the left header ("rows" in COLUMN_SPLIT_SETTING)
-        //  - not the last column
-        return !rows.slice(0, -1).some(row => _.isEqual(row, column.field_ref));
-      },
-    },
-    column_title: {
-      title: t`Column title`,
-      widget: "input",
-      getDefault: column => formatColumn(column),
-    },
-  };
-
-  setBodyRef = element => {
-    this.bodyRef = element;
-  };
-
-  getColumnTitle(columnIndex) {
-    const { data, settings } = this.props;
-    const columns = data.cols.filter(col => !isPivotGroupColumn(col));
-    const { column, column_title: columnTitle } = settings.column(
-      columns[columnIndex],
-    );
-    return columnTitle || formatColumn(column);
-  }
-
-  isColumnCollapsible(columnIndex) {
-    const { data, settings } = this.props;
-    const columns = data.cols.filter(col => !isPivotGroupColumn(col));
-    const { [COLUMN_SHOW_TOTALS]: showTotals } = settings.column(
-      columns[columnIndex],
-    );
-    return showTotals;
-  }
-
-  componentDidUpdate() {
-    // This is needed in case the cell counts didn't change, but the data did
-    this.leftHeaderRef && this.leftHeaderRef.recomputeCellSizesAndPositions();
-    this.topHeaderRef && this.topHeaderRef.recomputeCellSizesAndPositions();
-  }
-
-  componentDidMount() {
-    this.grid = this.bodyRef && findDOMNode(this.bodyRef);
-  }
-
-  render() {
-    const {
-      settings,
-      data,
-      width,
-      hasCustomColors,
-      onUpdateVisualizationSettings,
-      isNightMode,
-      isDashboard,
-      fontFamily,
-    } = this.props;
-    if (data == null || !data.cols.some(isPivotGroupColumn)) {
-      return null;
-    }
-
-    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
-    function scrollBarOffsetSize() {
-      if (!grid) {
-        return 0;
-      }
-      // get the size of the scrollbars
-      const scrollBarSize = getScrollBarSize();
-      const scrollsHorizontally = grid.scrollWidth > parseInt(grid.style.width);
-
-      if (scrollsHorizontally && scrollBarSize > 0) {
-        return scrollBarSize;
-      } else {
-        return 0;
-      }
-    }
-
-    let pivoted;
-    try {
-      pivoted = multiLevelPivot(data, settings);
-    } catch (e) {
-      console.warn(e);
-    }
-    const {
-      leftHeaderItems,
-      topHeaderItems,
-      rowCount,
-      columnCount,
-      rowIndex,
-      getRowSection,
-      rowIndexes,
-      columnIndexes,
-      valueIndexes,
-    } = pivoted;
-
-    const { leftHeaderWidths, totalHeaderWidths } = getLeftHeaderWidths({
-      rowIndexes: rowIndexes ?? [],
-      getColumnTitle: idx => this.getColumnTitle(idx),
-      leftHeaderItems,
-      fontFamily: fontFamily,
-    });
-
-    const leftHeaderCellRenderer = ({ index, key, style }) => {
-      const { value, isSubtotal, hasSubtotal, depth, path, clicked } =
-        leftHeaderItems[index];
-
-      return (
-        <Cell
-          key={key}
-          style={{
-            ...style,
-            ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}),
-          }}
-          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 }) => {
-      const { offset, span, depth, maxDepthBelow } = leftHeaderItems[index];
-
-      const columnsToSpan = rowIndexes.length - depth - maxDepthBelow;
-
-      // add up all the widths of the columns, other than itself, that this cell spans
-      const spanWidth = leftHeaderWidths
-        .slice(depth + 1, depth + columnsToSpan)
-        .reduce((acc, cellWidth) => acc + cellWidth, 0);
-      const columnPadding = depth === 0 ? LEFT_HEADER_LEFT_SPACING : 0;
-      const columnWidth = leftHeaderWidths[depth];
-
-      return {
-        height: span * CELL_HEIGHT,
-        width: columnWidth + spanWidth + columnPadding,
-        x:
-          leftHeaderWidths
-            .slice(0, depth)
-            .reduce((acc, cellWidth) => acc + cellWidth, 0) +
-          (depth > 0 ? LEFT_HEADER_LEFT_SPACING : 0),
-        y: offset * CELL_HEIGHT,
-      };
-    };
-
-    const topHeaderRows =
-      columnIndexes.length + (valueIndexes.length > 1 ? 1 : 0) || 1;
-    const topHeaderHeight = topHeaderRows * CELL_HEIGHT;
-
-    const topHeaderCellRenderer = ({ index, key, style }) => {
-      const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } =
-        topHeaderItems[index];
-      return (
-        <Cell
-          key={key}
-          style={{
-            ...style,
-          }}
-          value={value}
-          isNightMode={isNightMode}
-          isBorderedHeader={maxDepthBelow === 0}
-          isEmphasized={hasChildren}
-          isBold={isSubtotal}
-          onClick={this.getCellClickHander(clicked)}
-        />
-      );
-    };
-    const topHeaderCellSizeAndPositionGetter = ({ index }) => {
-      const { offset, span, maxDepthBelow } = topHeaderItems[index];
-      return {
-        height: CELL_HEIGHT,
-        width: span * CELL_WIDTH,
-        x: offset * CELL_WIDTH,
-        y: (topHeaderRows - maxDepthBelow - 1) * CELL_HEIGHT,
-      };
-    };
-
-    const leftHeaderWidth =
-      rowIndexes.length > 0 ? LEFT_HEADER_LEFT_SPACING + totalHeaderWidths : 0;
-
-    // These are tied to the `multiLevelPivot` call, so they're awkwardly shoved in render for now
-
-    const bodyRenderer = ({ key, style, rowIndex, columnIndex }) => (
-      <div key={key} style={style} className="flex">
-        {getRowSection(columnIndex, rowIndex).map(
-          ({ value, isSubtotal, clicked, backgroundColor }, index) => (
-            <Cell
-              isNightMode={isNightMode}
-              key={index}
-              value={value}
-              isEmphasized={isSubtotal}
-              isBold={isSubtotal}
-              isBody
-              onClick={this.getCellClickHander(clicked)}
-              backgroundColor={backgroundColor}
-            />
-          ),
-        )}
-      </div>
-    );
-
-    return (
-      <PivotTableRoot
-        isDashboard={isDashboard}
-        isNightMode={isNightMode}
-        data-testid="pivot-table"
-      >
-        <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}
-                  style={{
-                    width: leftHeaderWidth,
-                  }}
-                >
-                  {rowIndexes.map((rowIndex, index) => (
-                    <Cell
-                      key={rowIndex}
-                      isEmphasized
-                      isBold
-                      isBorderedHeader
-                      isTransparent
-                      hasTopBorder={topHeaderRows > 1}
-                      isNightMode={isNightMode}
-                      value={this.getColumnTitle(rowIndex)}
-                      style={{
-                        flex: "0 0 auto",
-                        width:
-                          leftHeaderWidths?.[index] +
-                          (index === 0 ? LEFT_HEADER_LEFT_SPACING : 0),
-                        ...(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 &&
-                        this.isColumnCollapsible(rowIndex) && (
-                          <RowToggleIcon
-                            value={index + 1}
-                            settings={settings}
-                            updateSettings={onUpdateVisualizationSettings}
-                            hasCustomColors={hasCustomColors}
-                            isNightMode={isNightMode}
-                          />
-                        )
-                      }
-                    />
-                  ))}
-                </PivotTableTopLeftCellsContainer>
-                {/* top header */}
-                <Collection
-                  ref={e => (this.topHeaderRef = e)}
-                  className="scroll-hide-all"
-                  isNightMode={isNightMode}
-                  width={width - leftHeaderWidth}
-                  height={topHeaderHeight}
-                  cellCount={topHeaderItems.length}
-                  cellRenderer={topHeaderCellRenderer}
-                  cellSizeAndPositionGetter={topHeaderCellSizeAndPositionGetter}
-                  onScroll={({ scrollLeft }) => onScroll({ scrollLeft })}
-                  scrollLeft={scrollLeft}
-                />
-              </div>
-              <div className="flex flex-full">
-                {/* left header */}
-                <div style={{ width: leftHeaderWidth }}>
-                  <AutoSizer disableWidth>
-                    {({ height }) => (
-                      <Collection
-                        ref={e => (this.leftHeaderRef = e)}
-                        className="scroll-hide-all"
-                        cellCount={leftHeaderItems.length}
-                        cellRenderer={leftHeaderCellRenderer}
-                        cellSizeAndPositionGetter={
-                          leftHeaderCellSizeAndPositionGetter
-                        }
-                        width={leftHeaderWidth}
-                        height={height - scrollBarOffsetSize()}
-                        scrollTop={scrollTop}
-                        onScroll={({ scrollTop }) => onScroll({ scrollTop })}
-                      />
-                    )}
-                  </AutoSizer>
-                </div>
-                {/* pivot table body */}
-                <div>
-                  <AutoSizer disableWidth>
-                    {({ height }) => (
-                      <Grid
-                        width={width - leftHeaderWidth}
-                        height={height}
-                        className="text-dark"
-                        rowCount={rowCount}
-                        columnCount={columnCount}
-                        rowHeight={CELL_HEIGHT}
-                        columnWidth={valueIndexes.length * CELL_WIDTH}
-                        cellRenderer={bodyRenderer}
-                        onScroll={({ scrollLeft, scrollTop }) =>
-                          onScroll({ scrollLeft, scrollTop })
-                        }
-                        ref={this.setBodyRef}
-                        scrollTop={scrollTop}
-                        scrollLeft={scrollLeft}
-                      />
-                    )}
-                  </AutoSizer>
-                </div>
-              </div>
-            </div>
-          )}
-        </ScrollSync>
-      </PivotTableRoot>
-    );
-  }
-
-  getCellClickHander(clicked) {
-    if (!clicked) {
-      return null;
-    }
-    return e =>
-      this.props.onVisualizationClick({
-        ...clicked,
-        event: e.nativeEvent,
-        settings: this.props.settings,
-      });
-  }
-}
-
-export default connect(mapStateToProps)(PivotTable);
-export { PivotTable };
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx
index 6af7e3b1538..be1af11082c 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.styled.tsx
@@ -2,7 +2,11 @@ import { css } from "@emotion/react";
 import styled from "@emotion/styled";
 import { color, alpha, darken } from "metabase/lib/colors";
 
-import { CELL_HEIGHT, PIVOT_TABLE_FONT_SIZE } from "./constants";
+import {
+  CELL_HEIGHT,
+  PIVOT_TABLE_FONT_SIZE,
+  RESIZE_HANDLE_WIDTH,
+} from "./constants";
 
 export const RowToggleIconRoot = styled.div`
   display: flex;
@@ -55,6 +59,7 @@ const getBorderColor = ({ isNightMode }: PivotTableCellProps) => {
 
 export const PivotTableCell = styled.div<PivotTableCellProps>`
   flex: 1 0 auto;
+  position: relative;
   flex-basis: 0;
   line-height: ${CELL_HEIGHT}px;
   min-width: 0;
@@ -116,3 +121,22 @@ export const PivotTableSettingLabel = styled.span`
   font-weight: 700;
   color: ${color("text-dark")};
 `;
+
+export const ResizeHandle = styled.div`
+  z-index: 99;
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: -${RESIZE_HANDLE_WIDTH - 1}px;
+  width: ${RESIZE_HANDLE_WIDTH}px;
+
+  cursor: ew-resize;
+
+  &:active {
+    background-color: ${color("brand")};
+  }
+
+  &:hover {
+    background-color: ${color("brand")};
+  }
+`;
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx
new file mode 100644
index 00000000000..79317e415c5
--- /dev/null
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.tsx
@@ -0,0 +1,421 @@
+import React, {
+  useEffect,
+  useMemo,
+  useCallback,
+  useRef,
+  useState,
+} from "react";
+import { t } from "ttag";
+import _ from "underscore";
+import { Grid, Collection, ScrollSync, AutoSizer } from "react-virtualized";
+import type { OnScrollParams } from "react-virtualized";
+import { findDOMNode } from "react-dom";
+import { connect } from "react-redux";
+
+import { usePrevious } from "metabase/hooks/use-previous";
+import { getScrollBarSize } from "metabase/lib/dom";
+import { getSetting } from "metabase/selectors/settings";
+import { useOnMount } from "metabase/hooks/use-on-mount";
+
+import {
+  COLUMN_SHOW_TOTALS,
+  isPivotGroupColumn,
+  multiLevelPivot,
+} from "metabase/lib/data_grid";
+import { formatColumn } from "metabase/lib/formatting";
+
+import type { DatasetData } from "metabase-types/types/Dataset";
+import type { VisualizationSettings } from "metabase-types/api";
+import type { State } from "metabase-types/store";
+
+import type { PivotTableClicked, HeaderWidthType } from "./types";
+
+import { RowToggleIcon } from "./RowToggleIcon";
+
+import {
+  Cell,
+  TopHeaderCell,
+  LeftHeaderCell,
+  BodyCell,
+} from "./PivotTableCell";
+
+import {
+  PivotTableRoot,
+  PivotTableTopLeftCellsContainer,
+} from "./PivotTable.styled";
+
+import {
+  getLeftHeaderWidths,
+  databaseSupportsPivotTables,
+  isSensible,
+  checkRenderable,
+  leftHeaderCellSizeAndPositionGetter,
+  topHeaderCellSizeAndPositionGetter,
+} from "./utils";
+
+import {
+  CELL_WIDTH,
+  CELL_HEIGHT,
+  LEFT_HEADER_LEFT_SPACING,
+  MIN_HEADER_CELL_WIDTH,
+} from "./constants";
+import { settings, _columnSettings as columnSettings } from "./settings";
+
+const mapStateToProps = (state: State) => ({
+  fontFamily: getSetting(state, "application-font"),
+});
+
+interface PivotTableProps {
+  data: DatasetData;
+  settings: VisualizationSettings;
+  width: number;
+  onUpdateVisualizationSettings: (settings: VisualizationSettings) => void;
+  isNightMode: boolean;
+  isDashboard: boolean;
+  fontFamily?: string;
+  onVisualizationClick: (options: any) => void;
+}
+
+function PivotTable({
+  data,
+  settings,
+  width,
+  onUpdateVisualizationSettings,
+  isNightMode,
+  isDashboard,
+  fontFamily,
+  onVisualizationClick,
+}: PivotTableProps) {
+  const [gridElement, setGridElement] = useState<HTMLElement | null>(null);
+  const [{ leftHeaderWidths, totalHeaderWidths }, setHeaderWidths] =
+    useState<HeaderWidthType>({
+      leftHeaderWidths: null,
+      totalHeaderWidths: null,
+    });
+
+  const bodyRef = useRef(null);
+  const leftHeaderRef = useRef(null);
+  const topHeaderRef = useRef(null);
+
+  const getColumnTitle = useCallback(
+    function (columnIndex) {
+      const columns = data.cols.filter(col => !isPivotGroupColumn(col));
+      const { column, column_title: columnTitle } = settings.column(
+        columns[columnIndex],
+      );
+      return columnTitle || formatColumn(column);
+    },
+    [data, settings],
+  );
+
+  function isColumnCollapsible(columnIndex: number) {
+    const columns = data.cols.filter(col => !isPivotGroupColumn(col));
+    const { [COLUMN_SHOW_TOTALS]: showTotals } = settings.column(
+      columns[columnIndex],
+    );
+    return showTotals;
+  }
+
+  useEffect(() => {
+    // This is needed in case the cell counts didn't change, but the data or cell sizes did
+    (
+      leftHeaderRef.current as Collection | null
+    )?.recomputeCellSizesAndPositions?.();
+    (
+      topHeaderRef.current as Collection | null
+    )?.recomputeCellSizesAndPositions?.();
+  }, [data, leftHeaderRef, topHeaderRef, leftHeaderWidths]);
+
+  useOnMount(() => {
+    setGridElement(bodyRef.current && findDOMNode(bodyRef.current));
+  });
+
+  const pivoted = useMemo(() => {
+    if (data == null || !data.cols.some(isPivotGroupColumn)) {
+      return null;
+    }
+
+    try {
+      return multiLevelPivot(data, settings);
+    } catch (e) {
+      console.warn(e);
+    }
+    return null;
+  }, [data, settings]);
+
+  const previousRowIndexes = usePrevious(pivoted?.rowIndexes);
+  const columnsChanged = !_.isEqual(pivoted?.rowIndexes, previousRowIndexes);
+
+  // 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
+  function scrollBarOffsetSize() {
+    if (!gridElement) {
+      return 0;
+    }
+    // get the size of the scrollbars
+    const scrollBarSize = getScrollBarSize();
+    const scrollsHorizontally =
+      gridElement.scrollWidth > parseInt(gridElement.style.width);
+
+    if (scrollsHorizontally && scrollBarSize > 0) {
+      return scrollBarSize;
+    } else {
+      return 0;
+    }
+  }
+
+  useEffect(() => {
+    if (!pivoted?.rowIndexes) {
+      setHeaderWidths({ leftHeaderWidths: null, totalHeaderWidths: null });
+      return;
+    }
+
+    if (columnsChanged) {
+      setHeaderWidths(
+        getLeftHeaderWidths({
+          rowIndexes: pivoted?.rowIndexes,
+          getColumnTitle: idx => getColumnTitle(idx),
+          leftHeaderItems: pivoted?.leftHeaderItems,
+          fontFamily: fontFamily,
+        }),
+      );
+    }
+  }, [
+    pivoted?.rowIndexes,
+    pivoted?.leftHeaderItems,
+    fontFamily,
+    getColumnTitle,
+    columnsChanged,
+  ]);
+
+  const handleColumnResize = (columnIndex: number, newWidth: number) => {
+    const newColumnWidths = [...(leftHeaderWidths as number[])];
+    newColumnWidths[columnIndex] = Math.max(newWidth, MIN_HEADER_CELL_WIDTH);
+
+    const newTotalWidth = newColumnWidths.reduce(
+      (total, current) => total + current,
+      0,
+    );
+
+    setHeaderWidths({
+      leftHeaderWidths: newColumnWidths,
+      totalHeaderWidths: newTotalWidth,
+    });
+  };
+
+  if (pivoted === null || !leftHeaderWidths || columnsChanged) {
+    return null;
+  }
+
+  const {
+    leftHeaderItems,
+    topHeaderItems,
+    rowCount,
+    columnCount,
+    rowIndex,
+    getRowSection,
+    rowIndexes,
+    columnIndexes,
+    valueIndexes,
+  } = pivoted;
+
+  const topHeaderRows =
+    columnIndexes.length + (valueIndexes.length > 1 ? 1 : 0) || 1;
+
+  const topHeaderHeight = topHeaderRows * CELL_HEIGHT;
+
+  const leftHeaderWidth =
+    rowIndexes.length > 0
+      ? LEFT_HEADER_LEFT_SPACING + (totalHeaderWidths ?? 0)
+      : 0;
+
+  function getCellClickHandler(clicked: PivotTableClicked) {
+    if (!clicked) {
+      return undefined;
+    }
+    return (e: React.SyntheticEvent) =>
+      onVisualizationClick({
+        ...clicked,
+        event: e.nativeEvent,
+        settings,
+      });
+  }
+
+  return (
+    <PivotTableRoot
+      isDashboard={isDashboard}
+      isNightMode={isNightMode}
+      data-testid="pivot-table"
+    >
+      <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}
+                style={{
+                  width: leftHeaderWidth,
+                }}
+              >
+                {rowIndexes.map((rowIndex: number, index: number) => (
+                  <Cell
+                    key={rowIndex}
+                    isEmphasized
+                    isBold
+                    isBorderedHeader
+                    isTransparent
+                    hasTopBorder={topHeaderRows > 1}
+                    isNightMode={isNightMode}
+                    value={getColumnTitle(rowIndex)}
+                    onResize={(newWidth: number) =>
+                      handleColumnResize(index, newWidth)
+                    }
+                    style={{
+                      flex: "0 0 auto",
+                      width:
+                        (leftHeaderWidths?.[index] ?? 0) +
+                        (index === 0 ? LEFT_HEADER_LEFT_SPACING : 0),
+                      ...(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 &&
+                      isColumnCollapsible(rowIndex) && (
+                        <RowToggleIcon
+                          value={index + 1}
+                          settings={settings}
+                          updateSettings={onUpdateVisualizationSettings}
+                        />
+                      )
+                    }
+                  />
+                ))}
+              </PivotTableTopLeftCellsContainer>
+              {/* top header */}
+              <Collection
+                ref={topHeaderRef}
+                className="scroll-hide-all"
+                isNightMode={isNightMode}
+                width={width - leftHeaderWidth}
+                height={topHeaderHeight}
+                cellCount={topHeaderItems.length}
+                cellRenderer={({ index, style, key }) => (
+                  <TopHeaderCell
+                    key={key}
+                    style={style}
+                    item={topHeaderItems[index]}
+                    getCellClickHandler={getCellClickHandler}
+                    isNightMode={isNightMode}
+                  />
+                )}
+                cellSizeAndPositionGetter={({ index }) =>
+                  topHeaderCellSizeAndPositionGetter(
+                    topHeaderItems[index],
+                    topHeaderRows,
+                  )
+                }
+                onScroll={({ scrollLeft }) =>
+                  onScroll({ scrollLeft } as OnScrollParams)
+                }
+                scrollLeft={scrollLeft}
+              />
+            </div>
+            <div className="flex flex-full">
+              {/* left header */}
+              <div style={{ width: leftHeaderWidth }}>
+                <AutoSizer disableWidth>
+                  {({ height }) => (
+                    <Collection
+                      ref={leftHeaderRef}
+                      className="scroll-hide-all"
+                      cellCount={leftHeaderItems.length}
+                      cellRenderer={({ index, style, key }) => (
+                        <LeftHeaderCell
+                          key={key}
+                          style={style}
+                          item={leftHeaderItems[index]}
+                          rowIndex={rowIndex}
+                          onUpdateVisualizationSettings={
+                            onUpdateVisualizationSettings
+                          }
+                          settings={settings}
+                          isNightMode={isNightMode}
+                          getCellClickHandler={getCellClickHandler}
+                        />
+                      )}
+                      cellSizeAndPositionGetter={({ index }) =>
+                        leftHeaderCellSizeAndPositionGetter(
+                          leftHeaderItems[index],
+                          leftHeaderWidths ?? [0],
+                          rowIndexes,
+                        )
+                      }
+                      width={leftHeaderWidth}
+                      height={height - scrollBarOffsetSize()}
+                      scrollTop={scrollTop}
+                      onScroll={({ scrollTop }) =>
+                        onScroll({ scrollTop } as OnScrollParams)
+                      }
+                    />
+                  )}
+                </AutoSizer>
+              </div>
+              {/* pivot table body */}
+              <div>
+                <AutoSizer disableWidth>
+                  {({ height }) => (
+                    <Grid
+                      width={width - leftHeaderWidth}
+                      height={height}
+                      className="text-dark"
+                      rowCount={rowCount}
+                      columnCount={columnCount}
+                      rowHeight={CELL_HEIGHT}
+                      columnWidth={valueIndexes.length * CELL_WIDTH}
+                      cellRenderer={({ rowIndex, columnIndex, key, style }) => (
+                        <BodyCell
+                          key={key}
+                          style={style}
+                          rowSection={getRowSection(columnIndex, rowIndex)}
+                          isNightMode={isNightMode}
+                          getCellClickHandler={getCellClickHandler}
+                        />
+                      )}
+                      onScroll={({ scrollLeft, scrollTop }) =>
+                        onScroll({ scrollLeft, scrollTop } as OnScrollParams)
+                      }
+                      ref={bodyRef}
+                      scrollTop={scrollTop}
+                      scrollLeft={scrollLeft}
+                    />
+                  )}
+                </AutoSizer>
+              </div>
+            </div>
+          </div>
+        )}
+      </ScrollSync>
+    </PivotTableRoot>
+  );
+}
+
+export default Object.assign(connect(mapStateToProps)(PivotTable), {
+  uiName: t`Pivot Table`,
+  identifier: "pivot",
+  iconName: "pivot_table",
+  databaseSupportsPivotTables,
+  isSensible,
+  checkRenderable,
+  settings,
+  columnSettings,
+  isLiveResizable: () => false,
+  seriesAreCompatible: () => false,
+});
+
+export { PivotTable };
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
similarity index 87%
rename from frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx
rename to frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
index 47205cf0d49..3bf5cad2541 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTable.unit.spec.tsx
@@ -1,5 +1,8 @@
 import React from "react";
 import { render, screen } from "@testing-library/react";
+import _ from "underscore";
+import type { VisualizationSettings } from "metabase-types/api";
+import type { Column } from "metabase-types/types/Dataset";
 
 import { PivotTable } from "./PivotTable";
 
@@ -38,7 +41,7 @@ const cols = [
     field_ref: ["aggregation", 2],
     display_name: "aggregation-2",
   },
-];
+] as Column[];
 
 const rows = [
   ["foo1", "bar1", "baz1", 0, 111, 222],
@@ -64,12 +67,12 @@ const pivotSettings = {
 
 const settings = {
   ...pivotSettings,
-  column: c => ({
+  column: (c: any) => ({
     ...pivotSettings,
     column: c,
     column_title: c.display_name,
   }),
-};
+} as unknown as VisualizationSettings;
 
 // 3 isn't a real column, it's a pivot-grouping
 const columnIndexes = [0, 1, 2, 4, 5];
@@ -80,11 +83,11 @@ describe("Visualizations > PivotTable > PivotTable", () => {
   const originalOffsetHeight = Object.getOwnPropertyDescriptor(
     HTMLElement.prototype,
     "offsetHeight",
-  );
+  ) as number;
   const originalOffsetWidth = Object.getOwnPropertyDescriptor(
     HTMLElement.prototype,
     "offsetWidth",
-  );
+  ) as number;
 
   beforeAll(() => {
     Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
@@ -116,8 +119,8 @@ describe("Visualizations > PivotTable > PivotTable", () => {
         settings={settings}
         data={{ rows, cols }}
         width={600}
-        hasCustomColors={false}
-        onUpdateVisualizationSettings={() => {}}
+        onVisualizationClick={_.noop}
+        onUpdateVisualizationSettings={_.noop}
         isNightMode={false}
         isDashboard={false}
       />,
@@ -132,8 +135,8 @@ describe("Visualizations > PivotTable > PivotTable", () => {
           settings={settings}
           data={{ rows, cols }}
           width={600}
-          hasCustomColors={false}
-          onUpdateVisualizationSettings={() => {}}
+          onVisualizationClick={_.noop}
+          onUpdateVisualizationSettings={_.noop}
           isNightMode={false}
           isDashboard={false}
         />
@@ -153,8 +156,8 @@ describe("Visualizations > PivotTable > PivotTable", () => {
           settings={settings}
           data={{ rows, cols }}
           width={900}
-          hasCustomColors={false}
-          onUpdateVisualizationSettings={() => {}}
+          onVisualizationClick={_.noop}
+          onUpdateVisualizationSettings={_.noop}
           isNightMode={false}
           isDashboard={false}
         />
@@ -177,7 +180,7 @@ describe("Visualizations > PivotTable > PivotTable", () => {
         rows: [cols[0].field_ref, cols[1].field_ref, cols[2].field_ref],
         value: ["2"],
       },
-    };
+    } as unknown as VisualizationSettings;
 
     render(
       <div style={{ height: 800 }}>
@@ -185,8 +188,8 @@ describe("Visualizations > PivotTable > PivotTable", () => {
           settings={hiddenSettings}
           data={{ rows, cols }}
           width={900}
-          hasCustomColors={false}
-          onUpdateVisualizationSettings={() => {}}
+          onVisualizationClick={_.noop}
+          onUpdateVisualizationSettings={_.noop}
           isNightMode={false}
           isDashboard={false}
         />
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx
deleted file mode 100644
index 58c09915b1c..00000000000
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.jsx
+++ /dev/null
@@ -1,48 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from "react";
-import cx from "classnames";
-
-import Ellipsified from "metabase/core/components/Ellipsified";
-
-import { PivotTableCell } from "./PivotTable.styled";
-
-export function Cell({
-  value,
-  style,
-  icon,
-  backgroundColor,
-  isBody = false,
-  isBold,
-  isEmphasized,
-  isNightMode,
-  isBorderedHeader,
-  isTransparent,
-  hasTopBorder,
-  onClick,
-}) {
-  return (
-    <PivotTableCell
-      data-testid="pivot-table-cell"
-      isNightMode={isNightMode}
-      isBold={isBold}
-      isEmphasized={isEmphasized}
-      isBorderedHeader={isBorderedHeader}
-      hasTopBorder={hasTopBorder}
-      isTransparent={isTransparent}
-      style={{
-        ...style,
-        ...(backgroundColor
-          ? {
-              backgroundColor,
-            }
-          : {}),
-      }}
-      onClick={onClick}
-    >
-      <div className={cx("px1 flex align-center", { "justify-end": isBody })}>
-        <Ellipsified>{value}</Ellipsified>
-        {icon && <div className="pl1">{icon}</div>}
-      </div>
-    </PivotTableCell>
-  );
-}
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
new file mode 100644
index 00000000000..01b26672b56
--- /dev/null
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/PivotTableCell.tsx
@@ -0,0 +1,206 @@
+import React from "react";
+import cx from "classnames";
+import Draggable, { ControlPosition, DraggableBounds } from "react-draggable";
+
+import Ellipsified from "metabase/core/components/Ellipsified";
+
+import type { VisualizationSettings } from "metabase-types/api";
+
+import { RowToggleIcon } from "./RowToggleIcon";
+import { PivotTableCell, ResizeHandle } from "./PivotTable.styled";
+
+import type { HeaderItem, BodyItem, PivotTableClicked } from "./types";
+import { LEFT_HEADER_LEFT_SPACING, RESIZE_HANDLE_WIDTH } from "./constants";
+
+interface CellProps {
+  value: React.ReactNode;
+  style?: React.CSSProperties;
+  icon?: React.ReactNode;
+  backgroundColor?: string;
+  isBody?: boolean;
+  isBold?: boolean;
+  isEmphasized?: boolean;
+  isNightMode?: boolean;
+  isBorderedHeader?: boolean;
+  isTransparent?: boolean;
+  hasTopBorder?: boolean;
+  onClick?: ((e: React.SyntheticEvent) => void) | undefined;
+  onResize?: (newWidth: number) => void;
+}
+
+export function Cell({
+  value,
+  style,
+  icon,
+  backgroundColor,
+  isBody = false,
+  isBold,
+  isEmphasized,
+  isNightMode,
+  isBorderedHeader,
+  isTransparent,
+  hasTopBorder,
+  onClick,
+  onResize,
+}: CellProps) {
+  return (
+    <PivotTableCell
+      data-testid="pivot-table-cell"
+      isNightMode={isNightMode}
+      isBold={isBold}
+      isEmphasized={isEmphasized}
+      isBorderedHeader={isBorderedHeader}
+      hasTopBorder={hasTopBorder}
+      isTransparent={isTransparent}
+      style={{
+        ...style,
+        ...(backgroundColor
+          ? {
+              backgroundColor,
+            }
+          : {}),
+      }}
+      onClick={onClick}
+    >
+      <>
+        <div className={cx("px1 flex align-center", { "justify-end": isBody })}>
+          <Ellipsified>{value}</Ellipsified>
+          {icon && <div className="pl1">{icon}</div>}
+        </div>
+        {!!onResize && (
+          <Draggable
+            axis="x"
+            enableUserSelectHack
+            bounds={{ left: RESIZE_HANDLE_WIDTH } as DraggableBounds}
+            position={
+              {
+                x: style?.width ?? 0,
+                y: 0,
+              } as ControlPosition
+            }
+            onStop={(e, { x }) => {
+              onResize(x);
+            }}
+          >
+            <ResizeHandle />
+          </Draggable>
+        )}
+      </>
+    </PivotTableCell>
+  );
+}
+
+type CellClickHandler = (
+  clicked: PivotTableClicked,
+) => ((e: React.SyntheticEvent) => void) | undefined;
+
+interface TopHeaderCellProps {
+  item: HeaderItem;
+  style: React.CSSProperties;
+  isNightMode: boolean;
+  getCellClickHandler: CellClickHandler;
+  onResize?: (newWidth: number) => void;
+}
+
+export const TopHeaderCell = ({
+  item,
+  style,
+  isNightMode,
+  getCellClickHandler,
+  onResize,
+}: TopHeaderCellProps) => {
+  const { value, hasChildren, clicked, isSubtotal, maxDepthBelow } = item;
+
+  return (
+    <Cell
+      style={{
+        ...style,
+      }}
+      value={value}
+      isNightMode={isNightMode}
+      isBorderedHeader={maxDepthBelow === 0}
+      isEmphasized={hasChildren}
+      isBold={isSubtotal}
+      onClick={getCellClickHandler(clicked)}
+      onResize={onResize}
+    />
+  );
+};
+
+type LeftHeaderCellProps = TopHeaderCellProps & {
+  rowIndex: string[];
+  settings: VisualizationSettings;
+  onUpdateVisualizationSettings: (settings: VisualizationSettings) => void;
+};
+
+export const LeftHeaderCell = ({
+  item,
+  style,
+  isNightMode,
+  getCellClickHandler,
+  rowIndex,
+  settings,
+  onUpdateVisualizationSettings,
+  onResize,
+}: LeftHeaderCellProps) => {
+  const { value, isSubtotal, hasSubtotal, depth, path, clicked } = item;
+
+  return (
+    <Cell
+      style={{
+        ...style,
+        ...(depth === 0 ? { paddingLeft: LEFT_HEADER_LEFT_SPACING } : {}),
+      }}
+      isNightMode={isNightMode}
+      value={value}
+      isEmphasized={isSubtotal}
+      isBold={isSubtotal}
+      onClick={getCellClickHandler(clicked)}
+      onResize={onResize}
+      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
+          />
+        )
+      }
+    />
+  );
+};
+
+interface BodyCellProps {
+  style: React.CSSProperties;
+  rowSection: BodyItem[];
+  isNightMode: boolean;
+  getCellClickHandler: CellClickHandler;
+}
+
+export const BodyCell = ({
+  style,
+  rowSection,
+  isNightMode,
+  getCellClickHandler,
+}: BodyCellProps) => {
+  return (
+    <div style={style} className="flex">
+      {rowSection.map(
+        ({ value, isSubtotal, clicked, backgroundColor }, index) => (
+          <Cell
+            isNightMode={isNightMode}
+            key={index}
+            value={value}
+            isEmphasized={isSubtotal}
+            isBold={isSubtotal}
+            isBody
+            onClick={getCellClickHandler(clicked)}
+            backgroundColor={backgroundColor}
+          />
+        ),
+      )}
+    </div>
+  );
+};
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
similarity index 70%
rename from frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx
rename to frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
index 5909f242438..c0fcf9d5bd0 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/RowToggleIcon.tsx
@@ -1,4 +1,3 @@
-/* eslint-disable react/prop-types */
 import React from "react";
 import { updateIn } from "icepick";
 import _ from "underscore";
@@ -7,27 +6,42 @@ import Icon from "metabase/components/Icon";
 
 import { COLLAPSED_ROWS_SETTING } from "metabase/lib/data_grid";
 
+import {
+  VisualizationSettings,
+  PivotTableCollapsedRowsSetting,
+} from "metabase-types/api";
 import { RowToggleIconRoot } from "./PivotTable.styled";
 
+interface RowToggleIconProps {
+  value: number | string[];
+  settings: VisualizationSettings;
+  updateSettings: (settings: VisualizationSettings) => void;
+  hideUnlessCollapsed?: boolean;
+  rowIndex?: string[];
+}
+
 export function RowToggleIcon({
   value,
   settings,
   updateSettings,
   hideUnlessCollapsed,
-  rowIndex,
-  hasCustomColors,
-  isNightMode,
-}) {
+  rowIndex = [],
+}: RowToggleIconProps) {
   if (value == null) {
     return null;
   }
-  const setting = settings[COLLAPSED_ROWS_SETTING];
+  const setting = settings[
+    COLLAPSED_ROWS_SETTING
+  ] as PivotTableCollapsedRowsSetting;
   const ref = JSON.stringify(value);
   const isColumn = !Array.isArray(value);
   const columnRef = isColumn ? null : JSON.stringify(value.length);
-  const settingValue = setting.value || [];
-  const isColumnCollapsed = !isColumn && settingValue.includes(columnRef);
+  const settingValue: PivotTableCollapsedRowsSetting["value"] =
+    setting.value || [];
+  const isColumnCollapsed =
+    !isColumn && settingValue.includes(columnRef as string);
   const isCollapsed = settingValue.includes(ref) || isColumnCollapsed;
+
   if (hideUnlessCollapsed && !isCollapsed) {
     // subtotal rows shouldn't have an icon unless the section is collapsed
     return null;
@@ -37,7 +51,7 @@ export function RowToggleIcon({
   // That depends on whether we're a row or column header and whether we're open or closed.
   const toggle =
     isColumn && !isCollapsed // click on open column
-      ? settingValue =>
+      ? (settingValue: PivotTableCollapsedRowsSetting["value"]) =>
           settingValue
             .filter(v => {
               const parsed = JSON.parse(v);
@@ -45,7 +59,7 @@ export function RowToggleIcon({
             }) // remove any already collapsed items in this column
             .concat(ref) // add column to list
       : !isColumn && isColumnCollapsed // single row in collapsed column
-      ? settingValue =>
+      ? (settingValue: PivotTableCollapsedRowsSetting["value"]) =>
           settingValue
             .filter(v => v !== columnRef) // remove column from list
             .concat(
@@ -62,9 +76,11 @@ export function RowToggleIcon({
                 .map(item => JSON.stringify(item)),
             )
       : isCollapsed // closed row or column
-      ? settingValue => settingValue.filter(v => v !== ref)
+      ? (settingValue: PivotTableCollapsedRowsSetting["value"]) =>
+          settingValue.filter(v => v !== ref)
       : // open row or column
-        settingValue => settingValue.concat(ref);
+        (settingValue: PivotTableCollapsedRowsSetting["value"]) =>
+          settingValue.concat(ref);
 
   return (
     <RowToggleIconRoot
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts
index 3ea138845d0..4a10a702d51 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/constants.ts
@@ -18,3 +18,5 @@ export const MAX_HEADER_CELL_WIDTH =
 export const LEFT_HEADER_LEFT_SPACING = 24;
 
 export const MAX_ROWS_TO_MEASURE = 100;
+
+export const RESIZE_HANDLE_WIDTH = 5;
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts
new file mode 100644
index 00000000000..fd9cca64667
--- /dev/null
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/settings.ts
@@ -0,0 +1,253 @@
+import { getIn } from "icepick";
+import { t } from "ttag";
+import _ from "underscore";
+
+import {
+  COLLAPSED_ROWS_SETTING,
+  COLUMN_SPLIT_SETTING,
+  COLUMN_SORT_ORDER,
+  COLUMN_SORT_ORDER_ASC,
+  COLUMN_SORT_ORDER_DESC,
+  COLUMN_SHOW_TOTALS,
+  COLUMN_FORMATTING_SETTING,
+  isPivotGroupColumn,
+} from "metabase/lib/data_grid";
+import { formatColumn } from "metabase/lib/formatting";
+
+import { columnSettings } from "metabase/visualizations/lib/settings/column";
+import ChartSettingsTableFormatting from "metabase/visualizations/components/settings/ChartSettingsTableFormatting";
+import { ChartSettingIconRadio } from "metabase/visualizations/components/settings/ChartSettingIconRadio";
+
+import type { Series, VisualizationSettings } from "metabase-types/api";
+import type { Card } from "metabase-types/types/Card";
+import type {
+  DatasetData,
+  Column,
+  Row,
+  ColumnSettings,
+} from "metabase-types/types/Dataset";
+
+import { isDimension } from "metabase-lib/types/utils/isa";
+
+import { partitions } from "./partitions";
+
+import {
+  addMissingCardBreakouts,
+  isColumnValid,
+  isFormattablePivotColumn,
+  updateValueWithCurrentColumns,
+} from "./utils";
+import { PivotSetting } from "./types";
+
+export const settings = {
+  ...columnSettings({ hidden: true }),
+  [COLLAPSED_ROWS_SETTING]: {
+    hidden: true,
+    readDependencies: [COLUMN_SPLIT_SETTING],
+    getValue: (
+      series: Series,
+      settings: Partial<VisualizationSettings> = {},
+    ) => {
+      // This is hack. Collapsed rows depend on the current column split setting.
+      // If the query changes or the rows are reordered, we ignore the current collapsed row setting.
+      // This is accomplished by snapshotting part of the column split setting *inside* this setting.
+      // `value` the is the actual data for this setting
+      // `rows` is value we check against the current setting to see if we should use `value`
+      const { rows, value } = settings[COLLAPSED_ROWS_SETTING] || {};
+      const { rows: currentRows } = settings[COLUMN_SPLIT_SETTING] || {};
+      if (!_.isEqual(rows, currentRows)) {
+        return { value: [], rows: currentRows };
+      }
+      return { rows, value };
+    },
+  },
+  [COLUMN_SPLIT_SETTING]: {
+    section: t`Columns`,
+    widget: "fieldsPartition",
+    persistDefault: true,
+    getHidden: ([{ data }]: [{ data: DatasetData }]) =>
+      // hide the setting widget if there are invalid columns
+      !data || data.cols.some(col => !isColumnValid(col)),
+    getProps: (
+      [{ data }]: [{ data: DatasetData }],
+      settings: VisualizationSettings,
+    ) => ({
+      partitions,
+      columns: data == null ? [] : data.cols,
+      settings,
+    }),
+    getValue: (
+      [{ data, card }]: [{ data: DatasetData; card: Card }],
+      settings: Partial<VisualizationSettings> = {},
+    ) => {
+      const storedValue = settings[COLUMN_SPLIT_SETTING];
+      if (data == null) {
+        return undefined;
+      }
+      const columnsToPartition = data.cols.filter(
+        col => !isPivotGroupColumn(col),
+      );
+      let setting;
+      if (storedValue == null) {
+        const [dimensions, values] = _.partition(
+          columnsToPartition,
+          isDimension,
+        );
+        const [first, second, ...rest] = _.sortBy(dimensions, col =>
+          getIn(col, ["fingerprint", "global", "distinct-count"]),
+        );
+
+        let rows;
+        let columns: Column[];
+
+        if (dimensions.length < 2) {
+          columns = [];
+          rows = [first];
+        } else if (dimensions.length <= 3) {
+          columns = [first];
+          rows = [second, ...rest];
+        } else {
+          columns = [first, second];
+          rows = rest;
+        }
+        setting = _.mapObject({ rows, columns, values }, cols =>
+          cols.map(col => col.field_ref),
+        );
+      } else {
+        setting = updateValueWithCurrentColumns(
+          storedValue,
+          columnsToPartition,
+        );
+      }
+
+      return addMissingCardBreakouts(setting as PivotSetting, card);
+    },
+  },
+  "pivot.show_row_totals": {
+    section: t`Columns`,
+    title: t`Show row totals`,
+    widget: "toggle",
+    default: true,
+    inline: true,
+  },
+  "pivot.show_column_totals": {
+    section: t`Columns`,
+    title: t`Show column totals`,
+    widget: "toggle",
+    default: true,
+    inline: true,
+  },
+  [COLUMN_FORMATTING_SETTING]: {
+    section: t`Conditional Formatting`,
+    widget: ChartSettingsTableFormatting,
+    default: [],
+    getDefault: (
+      [{ data }]: [{ data: DatasetData }],
+      settings: VisualizationSettings,
+    ) => {
+      const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? [];
+
+      return columnFormats
+        .map(columnFormat => {
+          const hasOnlyFormattableColumns =
+            columnFormat.columns
+              .map((columnName: string) =>
+                data.cols.find(column => column.name === columnName),
+              )
+              .filter(Boolean) ?? [].every(isFormattablePivotColumn);
+
+          if (!hasOnlyFormattableColumns) {
+            return null;
+          }
+
+          return {
+            ...columnFormat,
+            highlight_row: false,
+          };
+        })
+        .filter(Boolean);
+    },
+    isValid: (
+      [{ data }]: [{ data: DatasetData }],
+      settings: VisualizationSettings,
+    ): boolean => {
+      const columnFormats = settings[COLUMN_FORMATTING_SETTING] ?? [];
+
+      return columnFormats.every(columnFormat => {
+        const hasOnlyFormattableColumns =
+          columnFormat.columns
+            .map(columnName =>
+              (data.cols as Column[]).find(
+                column => column.name === columnName,
+              ),
+            )
+            .filter(Boolean) ?? [].every(isFormattablePivotColumn);
+
+        return hasOnlyFormattableColumns && !columnFormat.highlight_row;
+      });
+    },
+    getProps: (series: Series) => ({
+      canHighlightRow: false,
+      cols: (series[0].data.cols as Column[]).filter(isFormattablePivotColumn),
+    }),
+    getHidden: ([{ data }]: [{ data: DatasetData }]) =>
+      !data?.cols.some(col => isFormattablePivotColumn(col)),
+  },
+};
+
+export const _columnSettings = {
+  [COLUMN_SORT_ORDER]: {
+    title: t`Sort order`,
+    widget: ChartSettingIconRadio,
+    inline: true,
+    borderBottom: true,
+    props: {
+      options: [
+        {
+          iconName: "arrow_up",
+          value: COLUMN_SORT_ORDER_ASC,
+        },
+        {
+          iconName: "arrow_down",
+          value: COLUMN_SORT_ORDER_DESC,
+        },
+      ],
+    },
+    getHidden: ({ source }: { source: Column["source"] }) =>
+      source === "aggregation",
+  },
+  [COLUMN_SHOW_TOTALS]: {
+    title: t`Show totals`,
+    widget: "toggle",
+    inline: true,
+    getDefault: (
+      column: Column,
+      columnSettings: ColumnSettings,
+      { settings }: { settings: VisualizationSettings },
+    ) => {
+      //Default to showing totals if appropriate
+      const rows = settings[COLUMN_SPLIT_SETTING].rows || [];
+      return rows
+        .slice(0, -1)
+        .some((row: Row) => _.isEqual(row, column.field_ref));
+    },
+    getHidden: (
+      column: Column,
+      columnSettings: ColumnSettings,
+      { settings }: { settings: VisualizationSettings },
+    ) => {
+      const rows = settings[COLUMN_SPLIT_SETTING].rows || [];
+      // to show totals a column needs to be:
+      //  - in the left header ("rows" in COLUMN_SPLIT_SETTING)
+      //  - not the last column
+      return !rows
+        .slice(0, -1)
+        .some((row: Row) => _.isEqual(row, column.field_ref));
+    },
+  },
+  column_title: {
+    title: t`Column title`,
+    widget: "input",
+    getDefault: formatColumn,
+  },
+};
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts
index 3da159de665..f6606e68cfb 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/types.ts
@@ -9,10 +9,11 @@ export type PivotSetting = {
   values: AggregationReference[];
 };
 
-export interface LeftHeaderItem {
-  clicked: { value: string; column: Column };
+export type PivotTableClicked = { value: string; column: Column };
+export interface HeaderItem {
+  clicked: PivotTableClicked;
 
-  isCollapsed: boolean;
+  isCollapsed?: boolean;
   hasChildren: boolean;
   hasSubtotal?: boolean;
   isSubtotal?: boolean;
@@ -27,3 +28,12 @@ export interface LeftHeaderItem {
   rawValue: string;
   value: string;
 }
+
+export type BodyItem = HeaderItem & {
+  backgroundColor?: string;
+};
+
+export type HeaderWidthType = {
+  leftHeaderWidths: number[] | null;
+  totalHeaderWidths: number | null;
+};
diff --git a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts
index 20e7dbf7eb5..5c99375fba3 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.ts
@@ -1,16 +1,21 @@
 import _ from "underscore";
 import { getIn } from "icepick";
+import { t } from "ttag";
 
 import { isPivotGroupColumn } from "metabase/lib/data_grid";
 import { measureText } from "metabase/lib/measure-text";
 
-import type { Column } from "metabase-types/types/Dataset";
+import type { Column, DatasetData } from "metabase-types/types/Dataset";
 import type { Card } from "metabase-types/types/Card";
+import type { VisualizationSettings } from "metabase-types/api";
+import type StructuredQuery from "metabase-lib/queries/StructuredQuery";
+
 import type {
   PivotSetting,
   FieldOrAggregationReference,
-  LeftHeaderItem,
+  HeaderItem,
 } from "./types";
+
 import { partitions } from "./partitions";
 
 import {
@@ -20,6 +25,9 @@ import {
   MAX_HEADER_CELL_WIDTH,
   PIVOT_TABLE_FONT_SIZE,
   MAX_ROWS_TO_MEASURE,
+  LEFT_HEADER_LEFT_SPACING,
+  CELL_HEIGHT,
+  CELL_WIDTH,
 } from "./constants";
 
 // adds or removes columns from the pivot settings based on the current query
@@ -93,7 +101,7 @@ export function isFormattablePivotColumn(column: Column) {
 interface GetLeftHeaderWidthsProps {
   rowIndexes: number[];
   getColumnTitle: (columnIndex: number) => string;
-  leftHeaderItems?: LeftHeaderItem[];
+  leftHeaderItems?: HeaderItem[];
   fontFamily?: string;
 }
 
@@ -153,12 +161,12 @@ type ColumnValueInfo = {
   hasSubtotal: boolean;
 };
 
-export function getColumnValues(leftHeaderItems: LeftHeaderItem[]) {
+export function getColumnValues(leftHeaderItems: HeaderItem[]) {
   const columnValues: ColumnValueInfo[] = [];
 
   leftHeaderItems
     .slice(0, MAX_ROWS_TO_MEASURE)
-    .forEach((leftHeaderItem: LeftHeaderItem) => {
+    .forEach((leftHeaderItem: HeaderItem) => {
       const { value, depth, isSubtotal, isGrandTotal, hasSubtotal } =
         leftHeaderItem;
 
@@ -182,3 +190,76 @@ export function getColumnValues(leftHeaderItems: LeftHeaderItem[]) {
 
   return columnValues;
 }
+
+export function databaseSupportsPivotTables(query: StructuredQuery) {
+  if (query && query.database && query.database() != null) {
+    // if we don't have metadata, we can't check this
+    return query.database()?.supportsPivots();
+  }
+  return true;
+}
+
+export function isSensible(
+  { cols }: { cols: Column[] },
+  query: StructuredQuery,
+) {
+  return (
+    cols.length >= 2 &&
+    cols.every(isColumnValid) &&
+    databaseSupportsPivotTables(query)
+  );
+}
+
+export function checkRenderable(
+  [{ data }]: [{ data: DatasetData }],
+  settings: VisualizationSettings,
+  query: StructuredQuery,
+) {
+  if (data.cols.length < 2 || !data.cols.every(isColumnValid)) {
+    throw new Error(t`Pivot tables can only be used with aggregated queries.`);
+  }
+  if (!databaseSupportsPivotTables(query)) {
+    throw new Error(t`This database does not support pivot tables.`);
+  }
+}
+
+export const leftHeaderCellSizeAndPositionGetter = (
+  item: HeaderItem,
+  leftHeaderWidths: number[],
+  rowIndexes: number[],
+) => {
+  const { offset, span, depth, maxDepthBelow } = item;
+
+  const columnsToSpan = rowIndexes.length - depth - maxDepthBelow;
+
+  // add up all the widths of the columns, other than itself, that this cell spans
+  const spanWidth = leftHeaderWidths
+    .slice(depth + 1, depth + columnsToSpan)
+    .reduce((acc, cellWidth) => acc + cellWidth, 0);
+  const columnPadding = depth === 0 ? LEFT_HEADER_LEFT_SPACING : 0;
+  const columnWidth = leftHeaderWidths[depth];
+
+  return {
+    height: span * CELL_HEIGHT,
+    width: columnWidth + spanWidth + columnPadding,
+    x:
+      leftHeaderWidths
+        .slice(0, depth)
+        .reduce((acc, cellWidth) => acc + cellWidth, 0) +
+      (depth > 0 ? LEFT_HEADER_LEFT_SPACING : 0),
+    y: offset * CELL_HEIGHT,
+  };
+};
+
+export const topHeaderCellSizeAndPositionGetter = (
+  item: HeaderItem,
+  topHeaderRows: number,
+) => {
+  const { offset, span, maxDepthBelow } = item;
+  return {
+    height: CELL_HEIGHT,
+    width: span * CELL_WIDTH,
+    x: offset * CELL_WIDTH,
+    y: (topHeaderRows - maxDepthBelow - 1) * CELL_HEIGHT,
+  };
+};
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 31d5fd65cc8..5ef7256d4f1 100644
--- a/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts
+++ b/frontend/src/metabase/visualizations/visualizations/PivotTable/utils.unit.spec.ts
@@ -1,7 +1,7 @@
 import type { Column } from "metabase-types/types/Dataset";
 import type { Card } from "metabase-types/types/Card";
 
-import type { PivotSetting, LeftHeaderItem } from "./types";
+import type { PivotSetting, HeaderItem } from "./types";
 
 import {
   isColumnValid,
@@ -282,7 +282,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => {
         { depth: 1, value: "bar2" },
         { depth: 2, value: "baz1" },
         { depth: 4, value: "boo1" },
-      ] as LeftHeaderItem[];
+      ] as HeaderItem[];
 
       const { leftHeaderWidths } = getLeftHeaderWidths({
         rowIndexes: [0, 1, 2, 3, 4],
@@ -307,7 +307,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => {
         { depth: 1, value: "bar2" },
         { depth: 2, value: "baz1" },
         { depth: 4, value: "boo1" },
-      ] as LeftHeaderItem[];
+      ] as HeaderItem[];
 
       const { leftHeaderWidths } = getLeftHeaderWidths({
         rowIndexes: [0, 1, 2, 3, 4],
@@ -334,7 +334,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => {
         { depth: 1, value: "bar2" },
         { depth: 2, value: "baz1" },
         { depth: 4, value: "boo1" },
-      ] as LeftHeaderItem[];
+      ] as HeaderItem[];
 
       const result = getColumnValues(data);
 
@@ -354,7 +354,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => {
         { depth: 1, value: "bar1", hasSubtotal: false },
         { depth: 1, value: "bar2", hasSubtotal: false },
         { depth: 2, value: "baz1", hasSubtotal: true },
-      ] as LeftHeaderItem[];
+      ] as HeaderItem[];
 
       const result = getColumnValues(data);
 
@@ -372,7 +372,7 @@ describe("Visualizations > Visualizations > PivotTable > utils", () => {
         { depth: 1, value: "bar1", hasSubtotal: false },
         { depth: 1, value: "bar2", hasSubtotal: false },
         { depth: 2, value: "baz1", hasSubtotal: true },
-      ] as LeftHeaderItem[];
+      ] as HeaderItem[];
 
       const result = getColumnValues(data);
 
-- 
GitLab