Compare commits

...

4 Commits

Author SHA1 Message Date
Aleksandr Lesnenko
35aa1ecc4b reorg, measure cells 2025-01-06 21:40:09 -05:00
Aleksandr Lesnenko
352a97d374 object detail, visuals, polish 2025-01-03 22:48:41 -05:00
Aleksandr Lesnenko
bc35cf95e1 styles, conditional formatting, header actions 2025-01-02 20:08:23 -05:00
Aleksandr Lesnenko
7d88424bd8 wip 2024-12-24 17:32:00 -05:00
31 changed files with 969 additions and 7 deletions

View File

@@ -13,7 +13,7 @@ import { displayNameForColumn, formatValue } from "metabase/lib/formatting";
import { Icon } from "metabase/ui";
import { registerVisualization } from "metabase/visualizations/index";
import { isColumnRightAligned } from "metabase/visualizations/lib/table";
import Table from "metabase/visualizations/visualizations/Table";
import Table from "metabase/visualizations/visualizations/TableLegacy";
import { getColumnName, getRowValuesByColumns } from "../../lib/mode";

View File

@@ -17,6 +17,7 @@ export const isVirtualCardDisplayType = (
const cardDisplayTypes = [
"table",
"table-new",
"bar",
"line",
"pie",

View File

@@ -4,7 +4,7 @@ import { VisualizationWrapper } from "__support__/storybook";
import { Box } from "metabase/ui";
import { registerVisualization } from "metabase/visualizations";
import Visualization from "metabase/visualizations/components/Visualization";
import Table from "metabase/visualizations/visualizations/Table";
import Table from "metabase/visualizations/visualizations/TableLegacy";
import RAW_SERIES from "../TableSimple/stories-data/table-simple-orders-with-people.json";

View File

@@ -1,8 +1,10 @@
import type { RowValue } from "metabase-types/api";
import type { RawValueFormatter } from "../model/types";
type BaseFormatter = (value: unknown) => unknown;
export const cachedFormatter = (formatter: RawValueFormatter) => {
export const cachedFormatter = <TFormatter extends BaseFormatter>(
formatter: TFormatter,
) => {
const cache = new Map();
return (value: RowValue) => {
if (cache.has(value)) {

View File

@@ -24,7 +24,8 @@ import { SankeyChart } from "./visualizations/SankeyChart";
import { Scalar } from "./visualizations/Scalar";
import { ScatterPlot } from "./visualizations/ScatterPlot";
import { SmartScalar } from "./visualizations/SmartScalar";
import Table from "./visualizations/Table";
// import { Table } from "./visualizations/Table";
import Table from "./visualizations/TableLegacy";
import { Text } from "./visualizations/Text";
import { WaterfallChart } from "./visualizations/WaterfallChart";
@@ -33,6 +34,7 @@ export default function () {
registerVisualization(SmartScalar);
registerVisualization(Progress);
registerVisualization(Gauge);
// registerVisualization(Table);
registerVisualization(Table);
registerVisualization(LineChart);
registerVisualization(AreaChart);

View File

@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import type React from "react";
import type { OptionsType } from "metabase/lib/formatting/types";
import type { IconName, IconProps } from "metabase/ui";
@@ -14,13 +15,14 @@ import type {
DatasetColumn,
DatasetData,
RawSeries,
RowValue,
Series,
TimelineEvent,
TimelineEventId,
TransformedSeries,
VisualizationDisplay,
VisualizationSettings,
} from "metabase-types/api";
import type { VisualizationDisplay } from "metabase-types/api/visualization";
import type { RemappingHydratedDatasetColumn } from "./columns";
import type { HoveredObject } from "./hover";
@@ -33,6 +35,7 @@ export interface Padding {
}
export type Formatter = (value: unknown, options?: OptionsType) => string;
export type TableCellFormatter = (value: RowValue) => React.ReactNode;
export type ColorGetter = (colorName: string) => string;

View File

@@ -0,0 +1,48 @@
import type { HTMLAttributes } from "react";
import { t } from "ttag";
import { Button, Icon } from "metabase/ui";
interface AddColumnButtonProps extends HTMLAttributes<HTMLButtonElement> {
headerHeight: number;
pageWidth: number;
tableContentWidth?: number;
}
export const AddColumnButton = ({
headerHeight,
pageWidth,
tableContentWidth,
onClick,
}: AddColumnButtonProps) => {
if (!tableContentWidth) {
return null;
}
const isOverflowing = tableContentWidth > pageWidth;
if (!tableContentWidth) {
return null;
}
return (
<div
style={{
position: "absolute",
height: headerHeight,
width: headerHeight,
top: 0,
left: isOverflowing ? undefined : tableContentWidth,
right: isOverflowing ? 0 : undefined,
}}
>
<Button
variant="outline"
compact
leftIcon={<Icon name="add" />}
title={t`Add column`}
aria-label={t`Add column`}
onClick={onClick}
/>
</div>
);
};

View File

@@ -0,0 +1,7 @@
.icon {
opacity: 0.5;
}
.icon:hover {
opacity: 1;
}

View File

@@ -0,0 +1,21 @@
import type { MouseEvent } from "react";
import { Icon } from "metabase/ui";
import styles from "./ExpandButton.module.css";
interface ExpandButtonProps {
className?: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}
export const ExpandButton = ({ className, onClick }: ExpandButtonProps) => (
<button
className={className}
onClick={onClick}
data-testid="expand-column"
aria-label="Expand column"
>
<Icon name="ellipsis" size={10} className={styles.icon} />
</button>
);

View File

@@ -0,0 +1,7 @@
.icon {
opacity: 0.5;
}
.icon:hover {
opacity: 1;
}

View File

@@ -0,0 +1,21 @@
import type { MouseEvent } from "react";
import { Icon } from "metabase/ui";
import styles from "./ExpandButton.module.css";
interface ExpandButtonProps {
className?: string;
onClick: (event: MouseEvent<HTMLButtonElement>) => void;
}
export const ExpandButton = ({ className, onClick }: ExpandButtonProps) => (
<button
className={className}
onClick={onClick}
data-testid="expand-column"
aria-label="Expand column"
>
<Icon name="ellipsis" size={10} className={styles.icon} />
</button>
);

View File

@@ -0,0 +1 @@
export { ExpandButton } from "./ExpandButton";

View File

@@ -0,0 +1,22 @@
.table {
block-size: 100%;
border: none;
font-size: 12.5px;
font-weight: 700;
--rdg-color: var(--mb-color-text-dark);
--rdg-border-color: var(--mb-color-border-alpha-30);
--rdg-background-color: var(--mb-color-bg-white);
--rdg-header-background-color: var(--mb-color-bg-white);
--rdg-row-hover-background-color: var(--mb-color-background-hover);
--rdg-selection-color: transparent;
}
.table [role="columnheader"],
.table [role="gridcell"] {
padding: 0;
border-right: 0;
}
.table [role="columnheader"] {
border-color: var(--mb-color-border);
}

View File

@@ -0,0 +1,267 @@
import { useCallback, useMemo, useRef } from "react";
import DataGrid, { type Column, type DataGridHandle } from "react-data-grid";
import _ from "underscore";
import { formatValue } from "metabase/lib/formatting";
import { connect } from "metabase/lib/redux";
import {
getIsShowingRawTable,
getQueryBuilderMode,
getUiControls,
} from "metabase/query_builder/selectors";
import { getIsEmbeddingSdk } from "metabase/selectors/embed";
import { Box } from "metabase/ui";
import { cachedFormatter } from "metabase/visualizations/echarts/cartesian/utils/formatter";
import type { VisualizationProps } from "metabase/visualizations/types";
import { isFK, isNumber, isPK } from "metabase-lib/v1/types/utils/isa";
import type { RowValue } from "metabase-types/api";
import "react-data-grid/lib/styles.css";
import { AddColumnButton } from "./AddColumnButton";
import styles from "./Table.module.css";
import { BodyCell } from "./cell/BodyCell";
import { HeaderCell } from "./cell/HeaderCell";
import { IndexCell } from "./cell/IndexCell";
import { IndexHeaderCell } from "./cell/IndexHeaderCell";
import { useMeasureCells } from "./hooks/use-measure-cells";
import { useObjectDetail } from "./hooks/use-object-detail";
const ROW_HEIGHT = 36;
const HEADER_ROW_HEIGHT = 36;
const MIN_COLUMN_WIDTH = 50;
const INDEX_COLUMN_ID = "\0_index";
// Table adds an index column at the beginning of the columns array so we need to subtract 1 to get the correct index in the data array
const toDataColumnIndex = (displayIndex: number) => displayIndex - 1;
export const _Table = ({
data,
height,
settings,
width,
onVisualizationClick,
onUpdateVisualizationSettings,
}: VisualizationProps) => {
const onOpenObjectDetail = useObjectDetail(data);
const dataGridRef = useRef<DataGridHandle>(null);
const { rows, cols } = data;
const columnWidthsSetting = useMemo(
() => settings["table.column_widths"] || [],
[settings],
);
const handleColumnResize = useCallback(
(idx: number, width: number) => {
const dataIndex = toDataColumnIndex(idx);
if (dataIndex < 0) {
return;
}
const newColumnWidths = [...columnWidthsSetting];
newColumnWidths[dataIndex] = width;
onUpdateVisualizationSettings({
"table.column_widths": newColumnWidths,
});
},
[columnWidthsSetting, onUpdateVisualizationSettings],
);
const handleColumnReorder = useCallback(
(sourceKey: string, targetKey: string) => {
if (sourceKey === INDEX_COLUMN_ID || targetKey === INDEX_COLUMN_ID) {
return;
}
const sourceIdx = parseInt(sourceKey, 10);
const targetIdx = parseInt(targetKey, 10);
const sourceDataIdx = toDataColumnIndex(sourceIdx);
const targetDataIdx = toDataColumnIndex(targetIdx);
if (sourceDataIdx < 0 || targetDataIdx < 0) {
return;
}
const columns =
settings["table.columns"] ||
cols.map(col => ({ ...col, enabled: true }));
const reorderedColumns = [...columns];
const [removed] = reorderedColumns.splice(sourceDataIdx, 1);
reorderedColumns.splice(targetDataIdx, 0, removed);
onUpdateVisualizationSettings({
"table.columns": reorderedColumns,
});
},
[cols, settings, onUpdateVisualizationSettings],
);
const handleHeaderClick = useCallback(
(column: any, element: HTMLElement) => {
onVisualizationClick?.({
column,
element,
});
},
[onVisualizationClick],
);
const columnFormatters = useMemo(() => {
return cols.map(col => {
const columnSettings = settings.column?.(col);
return cachedFormatter(value =>
formatValue(value, {
...columnSettings,
column: col,
type: "cell",
jsx: true,
rich: true,
}),
);
});
}, [cols, settings]);
const renderBodyCell = useCallback(
({ row, column }: { row: RowValue[]; column: Column<RowValue[]> }) => {
const columnIndex = parseInt(column.key as string, 10);
const value = row[columnIndex];
const col = cols[columnIndex];
const align = isNumber(col) ? "right" : "left";
const isPill = isPK(col) || isFK(col);
const backgroundColor = settings["table._cell_background_getter"]?.(
value,
columnIndex,
col.name,
);
return (
<BodyCell
variant={isPill ? "pill" : "text"}
value={value}
formatter={columnFormatters[columnIndex]}
align={align}
backgroundColor={backgroundColor}
onClick={event => {
onVisualizationClick?.({
value,
column: col,
element: event.currentTarget,
});
}}
/>
);
},
[cols, columnFormatters, onVisualizationClick, settings],
);
const renderHeaderCell = useCallback(
({ column }: { column: Column<RowValue[]> }) => {
const columnIndex = parseInt(column.key as string, 10);
const col = cols[columnIndex];
const align = isNumber(col) ? "right" : "left";
return (
<HeaderCell
name={col.display_name}
align={align}
onClick={event => handleHeaderClick(col, event.currentTarget)}
/>
);
},
[cols, handleHeaderClick],
);
const { columnWidths } = useMeasureCells({
columns: cols.map((col, index) => ({
key: index.toString(),
name: col.display_name,
})),
rows,
renderCell: renderBodyCell,
renderHeaderCell,
});
const columns = useMemo(() => {
const indexColumn: Column<RowValue[]> = {
key: INDEX_COLUMN_ID,
name: "#",
frozen: true,
draggable: false,
resizable: false,
width: "max-content",
renderHeaderCell: () => <IndexHeaderCell />,
renderCell: ({ rowIdx }) => (
<IndexCell
rowNumber={rowIdx + 1}
onClick={() => onOpenObjectDetail(rowIdx)}
/>
),
};
const valueColumns = cols.map<Column<RowValue[]>>((col, index) => {
return {
key: index.toString(),
name: col.display_name,
width:
columnWidthsSetting[index] || columnWidths[index] || "max-content",
resizable: true,
draggable: true,
minWidth: MIN_COLUMN_WIDTH,
renderHeaderCell,
renderCell: renderBodyCell,
};
});
return [indexColumn, ...valueColumns];
}, [
cols,
onOpenObjectDetail,
columnWidths,
columnWidthsSetting,
renderHeaderCell,
renderBodyCell,
]);
return (
<Box style={{ height, position: "relative" }}>
<DataGrid
ref={dataGridRef}
className={styles.table}
columns={columns}
rows={rows}
rowHeight={ROW_HEIGHT}
headerRowHeight={HEADER_ROW_HEIGHT}
onColumnResize={handleColumnResize}
onColumnsReorder={handleColumnReorder}
enableVirtualization
defaultColumnOptions={{
sortable: true,
resizable: true,
}}
/>
<AddColumnButton
headerHeight={HEADER_ROW_HEIGHT}
pageWidth={width}
tableContentWidth={width}
onClick={e =>
onVisualizationClick?.({
columnShortcuts: true,
element: e.currentTarget,
})
}
/>
</Box>
);
};
const mapStateToProps = (state: any) => ({
queryBuilderMode: getQueryBuilderMode(state),
isEmbeddingSdk: getIsEmbeddingSdk(state),
scrollToLastColumn: getUiControls(state).scrollToLastColumn,
isRawTable: getIsShowingRawTable(state),
});
export const Table = connect(mapStateToProps, null)(_Table);

View File

@@ -0,0 +1,44 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
height: 100%;
width: 100%;
padding: 0 0.75em;
}
.content {
display: block;
white-space: nowrap;
text-overflow: ellipsis;
overflow-x: hidden;
width: 100%;
}
.clickable {
cursor: pointer;
}
.pill {
color: var(--mb-color-brand);
border: 1px solid rgba(80, 158, 227, 0.14);
background-color: rgba(80, 158, 227, 0.08);
border-radius: 99px;
padding: 0.25em 0.65em;
width: auto;
min-width: 35px;
text-align: center;
}
.minibar {
border: 1px solid transparent;
border-radius: 99px;
}
.alignLeft {
text-align: left;
}
.alignRight {
text-align: right;
}

View File

@@ -0,0 +1,51 @@
import cx from "classnames";
import { memo } from "react";
import type { TableCellFormatter } from "metabase/visualizations/types";
import type { RowValue } from "metabase-types/api";
import S from "./BodyCell.module.css";
export type BodyCellProps = {
value: RowValue;
formatter?: TableCellFormatter;
backgroundColor?: string;
align?: "left" | "right";
variant?: "text" | "pill" | "minibar";
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
export const BodyCell = memo(function BodyCell({
value,
formatter,
backgroundColor,
align = "left",
variant = "text",
onClick,
}: BodyCellProps) {
const formattedValue = formatter ? formatter(value) : value;
return (
<div
className={cx(S.root, {
[S.clickable]: !!onClick,
[S.alignLeft]: align === "left",
[S.alignRight]: align === "right",
})}
style={{
backgroundColor,
}}
onClick={onClick}
>
<div
className={cx(S.content, {
[S.pill]: variant === "pill",
[S.minibar]: variant === "minibar",
})}
>
{formattedValue}
</div>
</div>
);
});

View File

@@ -0,0 +1,35 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
height: 100%;
width: 100%;
cursor: default;
}
.content {
display: flex;
align-items: center;
margin: 0 0.75em;
padding: 0.25rem 0.625em;
white-space: nowrap;
text-overflow: ellipsis;
background-color: var(--mb-color-background-hover);
transition: background-color 200ms ease-in-out;
color: var(--mb-color-brand);
border-radius: 4px;
overflow: auto;
cursor: pointer;
}
.content:hover {
background-color: var(--mb-base-color-brand-20);
}
.leftAligned {
justify-content: flex-start;
}
.rightAligned {
justify-content: flex-end;
}

View File

@@ -0,0 +1,42 @@
import cx from "classnames";
import type { MouseEvent } from "react";
import type React from "react";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import { Icon } from "metabase/ui";
import styles from "./HeaderCell.module.css";
export type HeaderCellProps = {
align?: "left" | "right";
name?: React.ReactNode;
sort?: "asc" | "desc";
onClick?: (event: MouseEvent<HTMLDivElement>) => void;
};
export const HeaderCell = ({
name,
align = "left",
sort,
onClick,
}: HeaderCellProps) => {
return (
<div
className={cx(styles.root, {
[styles.leftAligned]: align === "left",
[styles.rightAligned]: align === "right",
})}
>
<div className={styles.content} onClick={onClick}>
{sort != null ? (
<Icon
mr="0.25rem"
name={sort === "asc" ? "chevronup" : "chevrondown"}
size={10}
/>
) : null}
<Ellipsified tooltip={name}>{name}</Ellipsified>
</div>
</div>
);
};

View File

@@ -0,0 +1,27 @@
.root {
height: 100%;
padding: 0 0.75em;
text-align: right;
color: var(--mb-color-text-light);
font-weight: 400;
display: flex;
align-items: center;
justify-content: end;
cursor: pointer;
}
.rowNumber {
display: block;
}
.root:hover > .rowNumber {
display: none;
}
.expandIcon {
display: none;
}
.root:hover > .expandIcon {
display: block;
}

View File

@@ -0,0 +1,22 @@
import { memo } from "react";
import { Icon } from "metabase/ui";
import S from "./IndexCell.module.css";
export type IndexCellProps = {
rowNumber: number;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
};
export const IndexCell = memo(function IndexCell({
rowNumber,
onClick,
}: IndexCellProps) {
return (
<div className={S.root} onClick={onClick}>
<span className={S.rowNumber}>{rowNumber}</span>
<Icon className={S.expandIcon} name="expand" size={14} />
</div>
);
});

View File

@@ -0,0 +1,9 @@
.root {
height: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
padding: 0 0.75em;
color: var(--mb-color-text-light);
cursor: default;
}

View File

@@ -0,0 +1,5 @@
import S from "./IndexHeaderCell.module.css";
export const IndexHeaderCell = () => {
return <div className={S.root}>#</div>;
};

View File

@@ -0,0 +1,22 @@
.wrapper {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
}
.bar {
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
opacity: 0.2;
z-index: 0;
}
.value {
position: relative;
z-index: 1;
padding: 0 0.5em;
}

View File

@@ -0,0 +1,32 @@
import type { RenderCellProps } from "react-data-grid";
import { Box } from "metabase/ui";
import type { RowValue } from "metabase-types/api";
import styles from "./MiniBarCell.module.css";
interface MiniBarCellProps extends RenderCellProps<RowValue[]> {
value: number;
extent: [number, number];
options: Record<string, any>;
}
export const MiniBarCell = ({ value, extent, options }: MiniBarCellProps) => {
const [min, max] = extent;
const range = max - min;
const width = range === 0 ? 0 : ((value - min) / range) * 100;
return (
<Box className={styles.wrapper}>
<Box
className={styles.bar}
style={{
width: `${width}%`,
backgroundColor:
options?.["mini_bar_colors"]?.[0] || "var(--color-brand)",
}}
/>
<Box className={styles.value}>{value}</Box>
</Box>
);
};

View File

@@ -0,0 +1,18 @@
import { t } from "ttag";
import {
getDefaultSize,
getMinSize,
} from "metabase/visualizations/shared/utils/sizes";
import TableLegacy from "../TableLegacy";
export const TABLE_DEFINITION = {
uiName: t`Table New`,
identifier: "table",
iconName: "table",
noun: t`table`,
minSize: getMinSize("table"),
defaultSize: getDefaultSize("table"),
settings: TableLegacy.settings,
};

View File

@@ -0,0 +1,193 @@
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
import _ from "underscore";
import type { DatasetColumn, RowValue } from "metabase-types/api";
interface MeasureCellsOptions {
columns: DatasetColumn[];
rows: RowValue[][];
renderCell: (props: {
row: RowValue;
column: DatasetColumn;
}) => React.ReactNode;
renderHeaderCell: (props: { column: DatasetColumn }) => React.ReactNode;
enableWrapping?: boolean;
}
interface MeasureResult {
columnWidths: number[];
rowHeights?: number[];
}
const MIN_COLUMN_WIDTH = 50;
const DEFAULT_ROW_HEIGHT = 36;
const createMeasureContainer = () => {
const div = document.createElement("div");
div.style.position = "absolute";
div.style.visibility = "hidden";
div.style.zIndex = "-1";
div.style.top = "-9999px";
div.style.left = "-9999px";
document.body.appendChild(div);
return div;
};
const renderToString = (node: React.ReactNode): string => {
if (node == null) {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (typeof node === "boolean") {
return "";
}
if (Array.isArray(node)) {
return node.map(renderToString).join("");
}
if (typeof node === "object") {
if ("props" in node && "children" in node.props) {
return renderToString(node.props.children);
}
return "";
}
return "";
};
const getCacheKey = (columns: DatasetColumn[], rows: RowValue[][]) => {
return (
JSON.stringify(columns.map(col => col.name)) +
JSON.stringify(rows.map(row => row.map(value => value)))
);
};
export const useMeasureCells = ({
columns,
rows,
renderCell,
renderHeaderCell,
enableWrapping = false,
}: MeasureCellsOptions): MeasureResult => {
const [columnWidths, setColumnWidths] = useState<number[]>([]);
const [rowHeights, setRowHeights] = useState<number[]>();
const measureContainerRef = useRef<HTMLDivElement | null>(null);
const measurementCacheRef = useRef<{
cacheKey: string;
columnWidths: number[];
rowHeights?: number[];
} | null>(null);
const measureTimeoutRef = useRef<number>();
// Pick sample rows for measurement (up to 10 non-null rows per column)
const sampleRowIndices = useMemo(() => {
return columns.map((_column, columnIndex) => {
const indices: number[] = [];
for (let i = 0; i < rows.length && indices.length < 10; i++) {
const row = rows[i];
const value = row[columnIndex];
if (value != null) {
indices.push(i);
}
}
return indices;
});
}, [columns, rows]);
const measure = useCallback(() => {
if (measureTimeoutRef.current) {
window.clearTimeout(measureTimeoutRef.current);
}
measureTimeoutRef.current = window.setTimeout(() => {
// Check if we already have measurements for these columns/rows
const cacheKey = getCacheKey(columns, rows);
const cache = measurementCacheRef.current;
if (cache && cache.cacheKey === cacheKey) {
setColumnWidths(cache.columnWidths);
setRowHeights(cache.rowHeights);
return;
}
const container = measureContainerRef.current;
if (!container) {
return;
}
const newColumnWidths: number[] = [];
const newRowHeights: number[] = enableWrapping ? [] : [];
columns.forEach((column, columnIndex) => {
const columnDiv = document.createElement("div");
columnDiv.style.display = "inline-block";
columnDiv.style.position = "absolute";
columnDiv.style.whiteSpace = enableWrapping ? "normal" : "nowrap";
if (enableWrapping) {
columnDiv.style.width = "300px";
}
// Measure header
const headerContent = renderToString(renderHeaderCell({ column }));
columnDiv.textContent = headerContent;
container.appendChild(columnDiv);
let maxWidth = columnDiv.offsetWidth;
// Measure sample rows
sampleRowIndices[columnIndex].forEach(rowIndex => {
const cellContent = renderToString(
renderCell({ row: rows[rowIndex], column }),
);
columnDiv.textContent = cellContent;
maxWidth = Math.max(maxWidth, columnDiv.offsetWidth);
if (enableWrapping) {
newRowHeights[rowIndex] = Math.max(
newRowHeights[rowIndex] ?? DEFAULT_ROW_HEIGHT,
columnDiv.offsetHeight,
);
}
});
container.removeChild(columnDiv);
newColumnWidths[columnIndex] = Math.max(maxWidth, MIN_COLUMN_WIDTH);
});
// Cache measurements
measurementCacheRef.current = {
cacheKey,
columnWidths: newColumnWidths,
rowHeights: enableWrapping ? newRowHeights : undefined,
};
setColumnWidths(newColumnWidths);
if (enableWrapping) {
setRowHeights(newRowHeights);
}
}, 0);
}, [
columns,
rows,
sampleRowIndices,
renderCell,
renderHeaderCell,
enableWrapping,
]);
useLayoutEffect(() => {
if (!measureContainerRef.current) {
measureContainerRef.current = createMeasureContainer();
}
measure();
return () => {
if (measureContainerRef.current) {
document.body.removeChild(measureContainerRef.current);
measureContainerRef.current = null;
}
if (measureTimeoutRef.current) {
window.clearTimeout(measureTimeoutRef.current);
}
};
}, [measure]);
return { columnWidths, rowHeights };
};

View File

@@ -0,0 +1,45 @@
import { useCallback, useMemo } from "react";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { zoomInRow } from "metabase/query_builder/actions";
import { getRowIndexToPKMap } from "metabase/query_builder/selectors";
import type { ColumnDescriptor } from "metabase/visualizations/lib/graph/columns";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { DatasetData } from "metabase-types/api";
export const useObjectDetail = ({ cols, rows }: DatasetData) => {
const dispatch = useDispatch();
const rowIndexToPkMap = useSelector(getRowIndexToPKMap);
const primaryKeyColumn: ColumnDescriptor | null = useMemo(() => {
const primaryKeyColumns = cols.filter(isPK);
// As of now, we support object detail drill on datasets with single column PK
if (primaryKeyColumns.length !== 1) {
return null;
}
const primaryKeyColumn = primaryKeyColumns[0];
return {
column: primaryKeyColumn,
index: cols.indexOf(primaryKeyColumn),
};
}, [cols]);
const onOpenObjectDetail = useCallback(
(rowIndex: number) => {
let objectId;
if (primaryKeyColumn) {
objectId = rows[rowIndex][primaryKeyColumn.index];
} else {
objectId = rowIndexToPkMap[rowIndex] ?? rowIndex;
}
dispatch(zoomInRow({ objectId }));
},
[dispatch, primaryKeyColumn, rowIndexToPkMap, rows],
);
return onOpenObjectDetail;
};

View File

@@ -0,0 +1 @@
export * from "./Table";

View File

@@ -44,10 +44,11 @@ import type {
VisualizationSettings,
} from "metabase-types/api";
import TableInteractive from "../components/TableInteractive/TableInteractive.jsx";
import { TableSimple } from "../components/TableSimple";
import type { ColumnSettingDefinition, VisualizationProps } from "../types";
import { Table as TableInteractive } from "./Table";
interface TableProps extends VisualizationProps {
isShowingDetailsOnlyColumns?: boolean;
}

View File

@@ -90,6 +90,7 @@
"react-beautiful-dnd": "^13.1.1",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1",
"react-data-grid": "^7.0.0-beta.47",
"react-dnd": "4",
"react-dnd-html5-backend": "4",
"react-dom": "^18.2.0",

View File

@@ -8851,6 +8851,11 @@ clsx@^1.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
clsx@^2.0.0:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -19936,6 +19941,13 @@ react-copy-to-clipboard@^5.0.1:
copy-to-clipboard "^3"
prop-types "^15.5.8"
react-data-grid@^7.0.0-beta.47:
version "7.0.0-beta.47"
resolved "https://registry.yarnpkg.com/react-data-grid/-/react-data-grid-7.0.0-beta.47.tgz#5c4b324f57a1e6fe76ae1659e566dc6cb36fcb79"
integrity sha512-28kjsmwQGD/9RXYC50zn5Zv/SQMhBBoSvG5seq0fM8XXi9TZ0zr9Z5T3YJqLwcEtoNzTOq3y0njkmdujGkIwQQ==
dependencies:
clsx "^2.0.0"
react-devtools-core@^4.19.1:
version "4.28.5"
resolved "https://registry.yarnpkg.com/react-devtools-core/-/react-devtools-core-4.28.5.tgz#c8442b91f068cdf0c899c543907f7f27d79c2508"