Compare commits

...

25 Commits

Author SHA1 Message Date
Aleksandr Lesnenko
892f2ad3a4 column totals w selection 2025-02-05 14:49:42 -04:00
Aleksandr Lesnenko
b390412873 use aggregation functions sent by BE 2025-02-05 12:05:42 -04:00
Aleksandr Lesnenko
5751580493 cell selection 2025-02-05 11:55:49 -04:00
Aleksandr Lesnenko
a04a16698f nano facelift 2025-02-05 11:55:49 -04:00
Aleksandr Lesnenko
f2359843fb fix pill style 2025-02-05 11:55:49 -04:00
Noah Moss
78db3061ce Add aggregation_function to cols 2025-02-05 11:17:47 -04:00
Aleksandr Lesnenko
db2997eff4 respect agg function (hack) 2025-02-04 16:53:32 -04:00
Aleksandr Lesnenko
9c1b6a080e column totals 2025-02-04 15:10:43 -04:00
Aleksandr Lesnenko
62726a67f3 fix conflicting resizing and reordering 2025-01-29 19:05:35 -05:00
Aleksandr Lesnenko
2cbf4f5dd2 polish, fix bugs 2025-01-29 19:02:23 -05:00
Aleksandr Lesnenko
4ba459287d styles 2025-01-28 23:16:16 -05:00
Aleksandr Lesnenko
b81a8b32a9 controlled column sizing state, add basic object detail, smooth resizing 2025-01-28 23:09:04 -05:00
Aleksandr Lesnenko
dbce312844 column reordering, add column button, styles 2025-01-27 23:23:33 -05:00
Aleksandr Lesnenko
dfc0ed57a9 reordering, fix missing column settings menu 2025-01-22 23:34:25 -05:00
Aleksandr Lesnenko
0963cd0e1d limit popover 2025-01-22 21:46:09 -05:00
Aleksandr Lesnenko
02aca4ed04 horizontal scroll, styles 2025-01-22 18:07:32 -05:00
Aleksandr Lesnenko
50183f464d fixes, polish 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
1cbe3c2dc5 fix layout 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
dcf0ef50b9 improve column resizing, fix bugs, polish ui 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
d1ad3608f2 fix initial and scrolling performance issues 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
7d3138c439 long cell auto collapse, minibar cells, sort order in headers, cleanup 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
0ddb236f06 built-in pivot 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
c1e39ad24e polish 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
ea82d4354d wip polish 2025-01-22 17:11:58 -05:00
Aleksandr Lesnenko
501ddd26ba build TableInteractive with tanstack table 2025-01-22 17:11:58 -05:00
48 changed files with 2783 additions and 29 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

@@ -39,6 +39,7 @@ export interface DatasetColumn {
aggregation_index?: number;
aggregation_type?: AggregationType;
aggregation_function?: AggregationType;
coercion_strategy?: string | null;
visibility_type?: FieldVisibilityType;

View File

@@ -1,6 +1,6 @@
// Support React 17 backwards compatibility for the Embedding SDK
import type React from "react";
import ReactDOM from "react-dom";
import ReactDOM, { flushSync } from "react-dom";
import { type Root, createRoot } from "react-dom/client";
import { getMajorReactVersion } from "./compat/check-version";
@@ -8,16 +8,30 @@ import { getMajorReactVersion } from "./compat/check-version";
export function renderRoot(
content: React.JSX.Element,
element: Element,
isSync = false,
): Root | undefined {
const reactVersion = getMajorReactVersion();
if (reactVersion <= 17) {
ReactDOM.render(content, element);
if (isSync) {
flushSync(() => {
ReactDOM.render(content, element);
});
} else {
ReactDOM.render(content, element);
}
return;
}
const root = createRoot(element);
root.render(content);
if (isSync) {
flushSync(() => {
root.render(content);
});
} else {
root.render(content);
}
return root;
}

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

@@ -61,8 +61,7 @@ export const ExpandButton = styled(Button)`
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
color: var(--mb-color-brand);
margin-right: 0.5rem;
margin-left: auto;
margin: 0 0.5rem;
&:hover {
color: var(--mb-color-text-white);

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,7 @@ 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 TableLegacy from "./visualizations/TableLegacy";
import { Text } from "./visualizations/Text";
import { WaterfallChart } from "./visualizations/WaterfallChart";
@@ -33,7 +33,7 @@ export default function () {
registerVisualization(SmartScalar);
registerVisualization(Progress);
registerVisualization(Gauge);
registerVisualization(Table);
registerVisualization(TableLegacy);
registerVisualization(LineChart);
registerVisualization(AreaChart);
registerVisualization(BarChart);
@@ -55,5 +55,5 @@ export default function () {
registerVisualization(Text);
registerVisualization(IFrameViz);
setDefaultVisualization(Table);
setDefaultVisualization(TableLegacy);
}

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;
@@ -75,6 +78,7 @@ export type OnChangeCardAndRun = (opts: OnChangeCardAndRunOpts) => void;
export type ColumnSettings = OptionsType & {
"pivot_table.column_show_totals"?: boolean;
text_align?: "left" | "right";
[key: string]: unknown;
};

View File

@@ -0,0 +1,13 @@
.root {
display: flex;
align-items: center;
justify-content: center;
z-index: 5;
background-color: white;
}
.sticky {
position: absolute;
top: 0;
right: 0;
}

View File

@@ -0,0 +1,37 @@
import cx from "classnames";
import type { HTMLAttributes } from "react";
import { t } from "ttag";
import { Button, Icon } from "metabase/ui";
import S from "./AddColumnButton.module.css";
interface AddColumnButtonProps extends HTMLAttributes<HTMLButtonElement> {
headerHeight: number;
isOverflowing?: boolean;
}
export const AddColumnButton = ({
headerHeight,
isOverflowing,
onClick,
}: AddColumnButtonProps) => {
return (
<div
className={cx(S.root, { [S.sticky]: isOverflowing })}
style={{
height: headerHeight,
width: headerHeight,
}}
>
<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 @@
export * from "./AddColumnButton";

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,123 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { Header } from "@tanstack/react-table";
import type React from "react";
import { type CSSProperties, memo, useCallback, useRef } from "react";
import { QueryColumnInfoPopover } from "metabase/components/MetadataInfo/ColumnInfoPopover";
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type { DatasetColumn, DatasetData, RowValues } from "metabase-types/api";
import S from "./Table.module.css";
export interface SortableHeaderProps {
id: string;
canSort?: boolean;
column: DatasetColumn;
question: Question;
hasMetadataPopovers: boolean;
data: DatasetData;
children: React.ReactNode;
header: Header<RowValues, unknown>;
onClick: (e: React.MouseEvent<HTMLDivElement>) => void;
}
// if header is dragged fewer than than this number of pixels we consider it a click instead of a drag
const HEADER_DRAG_THRESHOLD = 5;
type DragPosition = { x: number; y: number };
export const SortableHeader = memo(function SortableHeader({
id,
canSort,
onClick,
column,
question,
hasMetadataPopovers,
data,
header,
children,
}: SortableHeaderProps) {
const { attributes, isDragging, listeners, setNodeRef, transform } =
useSortable({
id,
disabled: !canSort,
});
const dragStartPosition = useRef<DragPosition | null>(null);
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: isDragging ? CSS.Translate.toString(transform) : undefined,
transition: "width transform 0.2s ease-in-out",
whiteSpace: "nowrap",
zIndex: isDragging ? 2 : 0,
cursor: canSort ? "grab" : "default",
outline: "none",
};
const handleDragStart = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
dragStartPosition.current = { x: e.clientX, y: e.clientY };
}, []);
const handleDragEnd = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (dragStartPosition.current) {
const dx = Math.abs(e.clientX - dragStartPosition.current.x);
const dy = Math.abs(e.clientY - dragStartPosition.current.y);
const isClicked = dx + dy < HEADER_DRAG_THRESHOLD;
if (isClicked && onClick) {
onClick(e);
}
dragStartPosition.current = null;
}
},
[onClick],
);
const resizeHandler: (e: React.MouseEvent | React.TouchEvent) => void =
useCallback(
e => {
header.getResizeHandler()(e);
},
[header],
);
const query = question?.query();
const stageIndex = -1;
return (
<div
ref={setNodeRef}
className={S.th}
style={style}
onMouseDown={handleDragStart}
onMouseUp={handleDragEnd}
>
<div className={S.headerWrapper} {...attributes} {...listeners}>
<QueryColumnInfoPopover
position="bottom-start"
query={query}
stageIndex={-1}
column={query && Lib.fromLegacyColumn(query, stageIndex, column)}
timezone={data.results_timezone}
disabled={!hasMetadataPopovers || isDragging}
openDelay={500}
showFingerprintInfo
>
{children}
</QueryColumnInfoPopover>
</div>
<div
className={S.resizer}
onMouseDown={resizeHandler}
onTouchStart={resizeHandler}
/>
</div>
);
});

View File

@@ -0,0 +1,102 @@
.table {
color: var(--mb-color-text-dark);
background-color: var(--mb-color-bg-white);
}
.tableGrid {
display: grid;
width: 100%;
}
.thead {
display: grid;
position: sticky;
top: 0;
z-index: 2;
background-color: var(--mb-color-bg-white);
border-bottom: 1px solid var(--mb-color-border);
}
.tbody {
display: grid;
position: relative;
}
.tr {
display: flex;
width: 100%;
}
.th {
display: flex;
align-items: center;
height: 36px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background-color: var(--mb-color-bg-white);
border-right: 1px solid var(--mb-color-border-alpha-30);
position: relative;
user-select: none;
outline: none;
}
.td {
position: relative;
background-color: var(--mb-color-bg-white);
cursor: pointer;
user-select: none;
outline: none;
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: transparent;
cursor: col-resize;
user-select: none;
touch-action: none;
opacity: 0;
transition: opacity 0.2s;
}
.resizer:hover {
opacity: 1;
background: var(--mb-color-brand);
}
.resizer:active {
opacity: 1;
background: var(--mb-color-brand);
}
.th:last-child,
.td:last-child {
border-right: none;
}
.td:hover {
background-color: color-mix(in srgb, var(--mb-color-brand), #fff 80%);
}
.headerWrapper {
width: 100%;
height: 100%;
outline: none;
}
.headerWrapper:hover {
cursor: grab;
}
.tfoot {
position: sticky;
bottom: 0;
background-color: #fff;
}
.cellSelected {
background-color: color-mix(in srgb, var(--mb-color-brand), #fff 90%);
}

View File

@@ -0,0 +1,635 @@
import type {
DragEndEvent,
DragOverEvent,
DragStartEvent,
} from "@dnd-kit/core";
import {
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
pointerWithin,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import {
SortableContext,
arrayMove,
horizontalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
flexRender,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import cx from "classnames";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import _ from "underscore";
import { connect } from "metabase/lib/redux";
import {
getIsShowingRawTable,
getQueryBuilderMode,
getUiControls,
} from "metabase/query_builder/selectors";
import { getIsEmbeddingSdk } from "metabase/selectors/embed";
import {
getTableCellClickedObject,
getTableClickedObjectRowData,
getTableHeaderClickedObject,
} from "metabase/visualizations/lib/table";
import type { VisualizationProps } from "metabase/visualizations/types";
import type { OrderByDirection } from "metabase-lib/types";
import type Question from "metabase-lib/v1/Question";
import type { DatasetColumn, VisualizationSettings } from "metabase-types/api";
import { AddColumnButton } from "./AddColumnButton";
import { SortableHeader } from "./SortableHeader";
import styles from "./Table.module.css";
import { HEADER_HEIGHT, INDEX_COLUMN_ID, ROW_HEIGHT } from "./constants";
import { useTableCellsMeasure } from "./hooks/use-cell-measure";
import { useCellSelection } from "./hooks/use-cell-selection";
import { useColumnResizeObserver } from "./hooks/use-column-resize-observer";
import { useColumnTotals } from "./hooks/use-column-totals";
import { useColumns } from "./hooks/use-columns";
import { useObjectDetail } from "./hooks/use-object-detail";
import { useVirtualGrid } from "./hooks/use-virtual-grid";
interface TableProps extends VisualizationProps {
onZoomRow?: (objectId: number | string) => void;
rowIndexToPkMap?: Record<number, string>;
getColumnSortDirection: (columnIndex: number) => OrderByDirection | undefined;
onUpdateVisualizationSettings: (settings: VisualizationSettings) => void;
isPivoted?: boolean;
hasMetadataPopovers?: boolean;
question: Question;
}
const getColumnOrder = (cols: DatasetColumn[], hasIndexColumn: boolean) => {
const dataColumns = cols.map(col => col.name);
if (!hasIndexColumn) {
return dataColumns;
}
return [INDEX_COLUMN_ID, ...dataColumns];
};
export const _Table = ({
data,
series,
height,
settings,
width,
onVisualizationClick,
onUpdateVisualizationSettings,
isPivoted = false,
getColumnSortDirection,
question,
clicked,
hasMetadataPopovers = true,
isDashboard,
}: TableProps) => {
const { rows, cols } = data;
const bodyRef = useRef<HTMLDivElement>(null);
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
getColumnOrder(cols, settings["table.row_index"]),
);
useEffect(() => {
setColumnOrder(getColumnOrder(cols, settings["table.row_index"]));
}, [cols, settings["table.row_index"]]);
const {
measureBodyCellDimensions,
measureHeaderCellDimensions,
measureRoot,
} = useTableCellsMeasure();
const {
columns,
columnFormatters,
onResizeColumn,
columnSizing,
setColumnSizing,
} = useColumns({
settings,
onVisualizationClick,
data,
measureBodyCellDimensions,
measureHeaderCellDimensions,
isPivoted,
getColumnSortDirection,
question,
hasMetadataPopovers,
});
const wrappedColumns = useMemo(() => {
return columns.filter(col => col.meta?.wrap);
}, [columns]);
const table = useReactTable({
data: rows,
columns,
initialState: {
columnPinning: {
left: [INDEX_COLUMN_ID],
},
},
state: {
columnSizing,
columnOrder,
},
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
onColumnOrderChange: setColumnOrder,
onColumnSizingChange: setColumnSizing,
});
const onOpenObjectDetail = useObjectDetail(data);
const measureRowHeight = useCallback(
(rowIndex: number) => {
if (wrappedColumns.length === 0) {
return ROW_HEIGHT;
}
const height = Math.max(
...wrappedColumns.map(column => {
const datasetIndex = column.meta?.datasetIndex;
const columnName = column.id;
if (!datasetIndex || !columnName) {
return 0;
}
const value = data.rows[rowIndex][datasetIndex];
const formattedValue = columnFormatters[datasetIndex](value);
if (!formattedValue || _.isEmpty(formattedValue)) {
return ROW_HEIGHT;
}
const formattedString = formattedValue?.toString() ?? "";
const tableColumn = table.getColumn(columnName);
const cellDimensions = measureBodyCellDimensions(
formattedString,
tableColumn?.getSize(),
);
return cellDimensions.height;
}),
ROW_HEIGHT,
);
return height;
},
[
columnFormatters,
data.rows,
measureBodyCellDimensions,
table,
wrappedColumns,
],
);
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {}),
);
const handleDragStart = useCallback((_event: DragStartEvent) => {
if (bodyRef.current) {
bodyRef.current.style.overflow = "hidden";
}
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
setColumnOrder(columnOrder => {
const oldIndex = columnOrder.indexOf(active.id as string);
const newIndex = columnOrder.indexOf(over.id as string);
return arrayMove(columnOrder, oldIndex, newIndex);
});
}
}, []);
const handleDragEnd = useCallback(
(_event: DragEndEvent) => {
if (bodyRef.current) {
bodyRef.current.style.overflow = "auto";
}
if (isPivoted) {
return;
}
const columns = settings["table.columns"]?.slice() ?? [];
const enabledColumns = columns
.map((c, index) => ({ ...c, index }))
.filter(c => c.enabled);
const columnOrderPositions = new Map(
columnOrder.map((name, index) => [name, index]),
);
enabledColumns.sort(
(a, b) =>
(columnOrderPositions.get(a.name) ?? 0) -
(columnOrderPositions.get(b.name) ?? 0),
);
const reorderedColumns = [...columns];
enabledColumns.forEach((col, i) => {
const prevCol = enabledColumns[i - 1];
const targetIndex = prevCol ? prevCol.index + 1 : 0;
if (targetIndex !== col.index) {
reorderedColumns.splice(
targetIndex,
0,
reorderedColumns.splice(col.index, 1)[0],
);
}
});
onUpdateVisualizationSettings({
"table.columns": reorderedColumns,
});
},
[columnOrder, settings, onUpdateVisualizationSettings, isPivoted],
);
const {
virtualColumns,
virtualRows,
virtualPaddingLeft,
virtualPaddingRight,
rowVirtualizer,
measureGrid,
scrollToRow,
} = useVirtualGrid({
bodyRef,
table,
measureRowHeight,
});
const {
isCellSelected,
isRowSelected,
isCellCopied,
selectedCells,
...cellSelection
} = useCellSelection({
table,
scrollToRow,
});
const columnTotals = useColumnTotals(
cols,
rows,
settings,
selectedCells,
table,
);
const handleColumnResize = useCallback(
(columnName: string, width: number) => {
const newColumnWidths = onResizeColumn(columnName, width);
onUpdateVisualizationSettings({
"table.column_widths": newColumnWidths,
});
measureGrid();
},
[measureGrid, onResizeColumn, onUpdateVisualizationSettings],
);
useColumnResizeObserver(table.getState(), handleColumnResize);
const measureRef = useCallback(
(element: HTMLElement | null) => {
rowVirtualizer.measureElement(element);
},
[rowVirtualizer],
);
const isAddColumnButtonSticky = table.getTotalSize() >= width;
const hasAddColumnButton = onVisualizationClick != null && !isDashboard;
const addColumnButton = useMemo(
() =>
hasAddColumnButton ? (
<AddColumnButton
headerHeight={HEADER_HEIGHT}
isOverflowing={isAddColumnButtonSticky}
onClick={e =>
onVisualizationClick?.({
columnShortcuts: true,
element: e.currentTarget,
})
}
/>
) : null,
[isAddColumnButtonSticky, onVisualizationClick, hasAddColumnButton],
);
const handleBodyCellClick = useCallback(
(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
rowIndex: number,
columnName: string,
) => {
if (columnName === INDEX_COLUMN_ID) {
onOpenObjectDetail(rowIndex);
return;
}
const columnIndex = data.cols.findIndex(col => col.name === columnName);
const clickedRowData = getTableClickedObjectRowData(
series as any,
rowIndex,
columnIndex,
isPivoted,
data,
);
const clicked = getTableCellClickedObject(
data,
settings,
rowIndex,
columnIndex,
isPivoted,
clickedRowData,
);
onVisualizationClick?.({
...clicked,
element: event.currentTarget,
});
},
[
data,
isPivoted,
onOpenObjectDetail,
onVisualizationClick,
series,
settings,
],
);
const handleHeaderCellClick = useCallback(
(
event: React.MouseEvent<HTMLDivElement, MouseEvent>,
columnName: string,
) => {
const columnIndex = data.cols.findIndex(col => col.name === columnName);
if (columnIndex === -1) {
return;
}
const clicked = getTableHeaderClickedObject(data, columnIndex, isPivoted);
onVisualizationClick({ ...clicked, element: event.currentTarget });
},
[data, isPivoted, onVisualizationClick],
);
if (width == null || height == null) {
return null;
}
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
modifiers={[restrictToHorizontalAxis]}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div style={{ height, width }} className={styles.table} tabIndex={-1}>
<div
ref={bodyRef}
style={{
height: `${height}px`,
width: `${width}px`,
overflow: "auto",
position: "relative",
}}
>
<div className={styles.tableGrid}>
<div className={styles.thead}>
{table.getHeaderGroups().map(headerGroup => (
<div key={headerGroup.id} className={styles.tr}>
{virtualPaddingLeft ? (
<div
className={styles.th}
style={{ width: virtualPaddingLeft }}
/>
) : null}
<SortableContext
items={columnOrder}
strategy={horizontalListSortingStrategy}
>
{virtualColumns.map(virtualColumn => {
const header = headerGroup.headers[virtualColumn.index];
const datasetColumn =
header.column.columnDef.meta?.column;
const isDataColumn = datasetColumn != null;
const headerCell = flexRender(
header.column.columnDef.header,
header.getContext(),
);
return (
<div
key={header.id}
style={{
width: header.getSize(),
position: "relative",
}}
>
{isDataColumn ? (
<SortableHeader
id={header.id}
column={datasetColumn}
data={data}
question={question}
canSort={!isPivoted}
hasMetadataPopovers={
hasMetadataPopovers && clicked == null
}
header={header}
onClick={event =>
handleHeaderCellClick(event, datasetColumn.name)
}
>
{headerCell}
</SortableHeader>
) : (
headerCell
)}
</div>
);
})}
</SortableContext>
{!isAddColumnButtonSticky ? addColumnButton : null}
{virtualPaddingRight ? (
<div
className={styles.th}
style={{ width: virtualPaddingRight }}
/>
) : null}
</div>
))}
</div>
<div
className={styles.tbody}
style={{
display: "grid",
position: "relative",
height: `${rowVirtualizer.getTotalSize()}px`,
}}
>
{virtualRows.map(virtualRow => {
const row = table.getRowModel().rows[virtualRow.index];
return (
<div
key={row.id}
ref={measureRef}
data-index={virtualRow.index}
className={styles.tr}
style={{
position: "absolute",
width: "100%",
minHeight: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{virtualPaddingLeft ? (
<div
className={styles.td}
style={{ width: virtualPaddingLeft }}
/>
) : null}
{virtualColumns.map(virtualColumn => {
const cell = row.getVisibleCells()[virtualColumn.index];
return (
<div
key={cell.id}
className={cx(styles.td, {
[styles.cellSelected]: isCellSelected(cell),
})}
tabIndex={-1}
onKeyDown={cellSelection.handleCellsKeyDown}
onMouseDown={e =>
cellSelection.handleCellMouseDown(e, cell)
}
onMouseUp={e =>
cellSelection.handleCellMouseUp(e, cell)
}
onMouseOver={e =>
cellSelection.handleCellMouseOver(e, cell)
}
onClick={e =>
handleBodyCellClick(
e,
cell.row.index,
cell.column.id,
)
}
style={{
position: "relative",
width: cell.column.getSize(),
}}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</div>
);
})}
{virtualPaddingRight ? (
<div
className={styles.td}
style={{ width: virtualPaddingRight }}
/>
) : null}
</div>
);
})}
</div>
{columnTotals != null ? (
<div className={styles.tfoot}>
{table.getFooterGroups().map(footerGroup => (
<div
key={footerGroup.id}
className={styles.tr}
style={{ height: "32px" }}
>
{virtualPaddingLeft ? (
<div style={{ width: virtualPaddingLeft }} />
) : null}
{virtualColumns.map(virtualColumn => {
const header = footerGroup.headers[virtualColumn.index];
const datasetIndex =
header.column.columnDef.meta?.datasetIndex;
const columnTotal = columnTotals[datasetIndex];
const rawValue = columnTotal?.value;
const value =
rawValue != null
? columnFormatters[datasetIndex]?.(rawValue)
: null;
const footerCell = flexRender(
header.column.columnDef.footer,
{
...header.getContext(),
value,
isSelected: columnTotal?.isSelected,
},
);
return (
<div
key={header.id}
style={{
width: header.getSize(),
position: "relative",
}}
>
{footerCell}
</div>
);
})}
{virtualPaddingRight ? (
<div style={{ width: virtualPaddingRight }} />
) : null}
</div>
))}
</div>
) : null}
</div>
</div>
{isAddColumnButtonSticky ? addColumnButton : null}
</div>
{measureRoot}
</DndContext>
);
};
interface StateProps {
queryBuilderMode: string;
isEmbeddingSdk: boolean;
scrollToLastColumn: boolean;
isRawTable: boolean;
}
const mapStateToProps = (state: any): StateProps => ({
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,21 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
cursor: default;
font-size: 12.5px;
padding: 0 12px;
}
.leftAligned {
text-align: left;
justify-content: flex-start;
}
.rightAligned {
text-align: right;
justify-content: flex-end;
}

View File

@@ -0,0 +1,32 @@
import cx from "classnames";
import type React from "react";
import { memo } from "react";
import styles from "./BaseCell.module.css";
export type BaseCellProps = {
align?: "left" | "right";
children?: React.ReactNode;
className?: string;
};
export const BaseCell = memo(function BaseCell({
align = "left",
children,
className,
}: BaseCellProps) {
return (
<div
className={cx(
styles.root,
{
[styles.leftAligned]: align === "left",
[styles.rightAligned]: align === "right",
},
className,
)}
>
{children}
</div>
);
});

View File

@@ -0,0 +1,59 @@
.root {
display: flex;
width: 100%;
height: 100%;
font-size: 12.5px;
line-height: 18px;
font-weight: 700;
align-items: center;
justify-content: center;
padding: 8px 12px;
border-bottom: 1px solid var(--mb-color-border-alpha-30);
}
.content {
width: 100%;
overflow: visible;
line-height: 1.4;
}
.noWrap {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.clickable {
cursor: pointer;
}
.pill {
padding: 0 12px;
}
.pill .content {
color: var(--mb-color-brand);
border: 1px solid var(--mb-color-brand-light);
background-color: var(--mb-color-brand-lighter);
border-radius: 99px;
padding: 2px 8px;
width: auto;
min-width: 30px;
text-overflow: ellipsis;
text-align: center;
}
.minibar {
border: 1px solid transparent;
border-radius: 99px;
}
.alignLeft {
text-align: left;
justify-content: left;
}
.alignRight {
text-align: right;
justify-content: right;
}

View File

@@ -0,0 +1,91 @@
import cx from "classnames";
import {
type HTMLAttributes,
type MouseEventHandler,
memo,
useCallback,
} from "react";
import CS from "metabase/css/core/index.css";
import { ExpandButton } from "metabase/visualizations/components/TableInteractive/TableInteractive.styled";
import type { TableCellFormatter } from "metabase/visualizations/types";
import type { RowValue } from "metabase-types/api";
import S from "./BodyCell.module.css";
export type BodyCellVariant = "text" | "pill" | "minibar";
export type BodyCellProps = {
value: RowValue;
formatter?: TableCellFormatter;
backgroundColor?: string;
align?: "left" | "right";
variant?: BodyCellVariant;
wrap?: boolean;
canExpand?: boolean;
onClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
onExpand?: () => void;
contentAttributes?: HTMLAttributes<HTMLDivElement>;
};
export const BodyCell = memo(function BodyCell({
value,
formatter,
backgroundColor,
align = "left",
variant = "text",
wrap = false,
canExpand = false,
contentAttributes,
onClick,
onExpand,
}: BodyCellProps) {
const formattedValue = formatter ? formatter(value) : value;
const handleExpandClick: MouseEventHandler<HTMLButtonElement> = useCallback(
e => {
e.stopPropagation();
onExpand?.();
},
[onExpand],
);
return (
<div
className={cx(S.root, CS.hoverParent, CS.hoverVisibility, {
[S.clickable]: !!onClick,
[S.alignLeft]: align === "left",
[S.alignRight]: align === "right",
[S.pill]: variant === "pill",
})}
style={{
backgroundColor,
}}
onClick={onClick}
>
<div
data-grid-cell-content
className={cx(S.content, {
[S.noWrap]: !wrap,
[S.minibar]: variant === "minibar",
})}
{...contentAttributes}
>
{formattedValue}
</div>
{canExpand && (
<ExpandButton
className={CS.hoverChild}
data-testid="expand-column"
small
borderless
iconSize={10}
icon="ellipsis"
onlyIcon
onClick={handleExpandClick}
/>
)}
</div>
);
});

View File

@@ -0,0 +1,18 @@
.root {
display: flex;
align-items: center;
height: 100%;
width: 100%;
cursor: default;
font-weight: 600;
padding: 0 12px;
border-top: 1px solid var(--mb-color-border-alpha-30);
font-size: 12px;
background-color: var(--mb-color-bg-light);
text-overflow: ellipsis;
overflow: hidden;
}
.selected {
color: var(--mb-color-text-brand);
}

View File

@@ -0,0 +1,28 @@
import cx from "classnames";
import type React from "react";
import { memo } from "react";
import { BaseCell, type BaseCellProps } from "./BaseCell";
import S from "./FooterCell.module.css";
export interface FooterCellProps extends BaseCellProps {
value: React.ReactNode;
isSelected?: boolean;
}
export const FooterCell = memo(function FooterCell({
value,
isSelected,
...props
}: FooterCellProps) {
return (
<BaseCell
className={cx(S.root, {
[S.selected]: isSelected,
})}
{...props}
>
{value}
</BaseCell>
);
});

View File

@@ -0,0 +1,38 @@
.root {
overflow: hidden;
display: flex;
align-items: center;
height: 100%;
width: 100%;
cursor: default;
font-size: 12.5px;
font-weight: 700;
padding: 0 12px;
}
.content {
display: flex;
align-items: center;
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;
outline: none;
}
.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,40 @@
import cx from "classnames";
import type React from "react";
import { memo } 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";
};
export const HeaderCell = memo(function HeaderCell({
name,
align = "left",
sort,
}: HeaderCellProps) {
return (
<div
className={cx(styles.root, {
[styles.leftAligned]: align === "left",
[styles.rightAligned]: align === "right",
})}
>
<div data-grid-header-cell-content className={styles.content}>
{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,29 @@
.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;
font-size: 13px;
border-bottom: 1px solid var(--mb-color-border-alpha-30);
}
.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,8 @@
.root {
position: relative;
display: flex;
align-items: center;
justify-content: end;
height: 100%;
padding: 0 8px;
}

View File

@@ -0,0 +1,110 @@
/* eslint-disable react/prop-types */
import cx from "classnames";
import CS from "metabase/css/core/index.css";
import { alpha, color } from "metabase/lib/colors";
import type { TableCellFormatter } from "metabase/visualizations/types";
import type { RowValue } from "metabase-types/api";
import S from "./MiniBarCell.module.css";
const BAR_HEIGHT = 8;
const BAR_WIDTH = 70;
const BORDER_RADIUS = 3;
const LABEL_MIN_WIDTH = 30;
export interface MiniBarProps {
value: RowValue;
extent: [number, number];
formatter: TableCellFormatter;
backgroundColor: string;
}
export const MiniBar = ({
value,
extent: [min, max],
formatter,
backgroundColor,
}: MiniBarProps) => {
if (typeof value !== "number") {
return null;
}
const hasNegative = min < 0;
const isNegative = value < 0;
const barPercent =
(Math.abs(value) / Math.max(Math.abs(min), Math.abs(max))) * 100;
const barColor = isNegative ? color("error") : color("brand");
const barStyle = !hasNegative
? {
width: barPercent + "%",
left: 0,
borderRadius: BORDER_RADIUS,
}
: isNegative
? {
width: barPercent / 2 + "%",
right: "50%",
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
borderTopLeftRadius: BORDER_RADIUS,
borderBottomLeftRadius: BORDER_RADIUS,
}
: {
width: barPercent / 2 + "%",
left: "50%",
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
borderTopRightRadius: BORDER_RADIUS,
borderBottomRightRadius: BORDER_RADIUS,
};
return (
<div className={S.root} style={{ backgroundColor }}>
{/* TEXT VALUE */}
<div
className={cx(CS.textEllipsis, CS.textBold, CS.textRight, CS.flexFull)}
style={{ minWidth: LABEL_MIN_WIDTH }}
>
{formatter(value)}
</div>
{/* OUTER CONTAINER BAR */}
<div
data-testid="mini-bar"
className={CS.ml1}
style={{
position: "relative",
width: BAR_WIDTH,
height: BAR_HEIGHT,
backgroundColor: alpha(barColor, 0.2),
borderRadius: BORDER_RADIUS,
}}
>
{/* INNER PROGRESS BAR */}
<div
style={{
position: "absolute",
top: 0,
bottom: 0,
backgroundColor: barColor,
...barStyle,
}}
/>
{/* CENTER LINE */}
{hasNegative && (
<div
style={{
position: "absolute",
left: "50%",
top: 0,
bottom: 0,
borderLeft: `1px solid ${color("white")}`,
}}
/>
)}
</div>
</div>
);
};

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,4 @@
export const ROW_HEIGHT = 36;
export const HEADER_HEIGHT = 36;
export const MIN_COLUMN_WIDTH = 60;
export const INDEX_COLUMN_ID = `\0_INDEX`;

View File

@@ -0,0 +1,101 @@
import { useCallback, useMemo, useRef } from "react";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { ThemeProvider } from "metabase/ui";
import { BodyCell } from "../cell/BodyCell";
import { HeaderCell } from "../cell/HeaderCell";
export type CellMeasurer = (content: string, width?: number) => CellSize;
export interface CellSize {
width: number;
height: number;
}
export const useCellMeasure = (
cell: React.ReactNode,
contentNodeSelector: string,
) => {
const rootRef = useRef<HTMLDivElement>(null);
const measureRoot = useMemo(() => {
return (
<div
ref={rootRef}
style={{
position: "absolute",
top: "-9999px",
left: "-9999px",
visibility: "hidden",
pointerEvents: "none",
zIndex: -999,
}}
>
{cell}
</div>
);
}, [cell]);
const measureDimensions: CellMeasurer = useCallback(
(content: string, containerWidth?: number) => {
const rootEl = rootRef.current;
const contentCell = rootEl?.querySelector(contentNodeSelector);
if (!rootEl || !contentCell) {
throw new Error(
`Trying to measure content "${content}" before measure root mounted`,
);
}
rootEl.style.width =
containerWidth != null ? `${containerWidth}px` : "auto";
contentCell.textContent = content;
const boundingRect = rootEl.getBoundingClientRect();
return {
width: boundingRect.width,
height: boundingRect.height,
};
},
[contentNodeSelector],
);
return {
measureRoot,
measureDimensions,
};
};
export const useTableCellsMeasure = () => {
const bodyCellToMeasure = useMemo(
() => <BodyCell wrap={true} value="" />,
[],
);
const {
measureDimensions: measureBodyCellDimensions,
measureRoot: measureBodyCellRoot,
} = useCellMeasure(bodyCellToMeasure, "[data-grid-cell-content]");
const headerCellToMeasure = useMemo(() => <HeaderCell name="" />, []);
const {
measureDimensions: measureHeaderCellDimensions,
measureRoot: measureHeaderCellRoot,
} = useCellMeasure(headerCellToMeasure, "[data-grid-header-cell-content]");
const measureRoot = useMemo(
() => (
<EmotionCacheProvider>
<ThemeProvider>
{measureBodyCellRoot}
{measureHeaderCellRoot}
</ThemeProvider>
</EmotionCacheProvider>
),
[measureBodyCellRoot, measureHeaderCellRoot],
);
return {
measureBodyCellDimensions,
measureHeaderCellDimensions,
measureRoot,
};
};

View File

@@ -0,0 +1,353 @@
import type { Cell, Table } from "@tanstack/react-table";
import { useState } from "react";
export type UseCellSelectionProps = {
table: Table<unknown>;
scrollToRow?: (index: number) => void;
};
export type SelectedCell = {
rowId: string;
columnId: string;
cellId: string;
};
export const useCellSelection = ({
table,
scrollToRow,
}: UseCellSelectionProps) => {
const [selectedCells, setSelectedCells] = useState<SelectedCell[]>([]);
const [copiedCells, setCopiedCells] = useState<SelectedCell[]>([]);
const [selectedStartCell, setSelectedStartCell] =
useState<SelectedCell | null>(null);
const [isMouseDown, setIsMouseDown] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(getCellValues(table, selectedCells));
setCopiedCells(selectedCells);
setTimeout(() => {
setCopiedCells([]);
}, 500);
};
const handleCellsKeyDown = (e: React.KeyboardEvent<HTMLElement>) => {
switch (e.key) {
case "c": {
if (e.metaKey || e.ctrlKey) {
handleCopy();
}
break;
}
case "ArrowDown": {
e.preventDefault();
navigateDown();
break;
}
case "ArrowUp": {
e.preventDefault();
navigateUp();
break;
}
case "ArrowLeft": {
e.preventDefault();
navigateLeft();
break;
}
case "ArrowRight": {
e.preventDefault();
navigateRight();
break;
}
case "Home": {
e.preventDefault();
navigateHome();
break;
}
case "End": {
e.preventDefault();
navigateEnd();
break;
}
}
};
const navigateHome = () => {
const firstCell = table.getRowModel().rows[0]?.getAllCells()[0];
if (!firstCell) {
return;
}
setSelectedCells([getCellSelectionData(firstCell)]);
scrollToRow?.(0);
};
const navigateEnd = () => {
const lastRow =
table.getRowModel().rows[table.getRowModel().rows.length - 1];
const lastCell = lastRow?.getAllCells()[lastRow.getAllCells().length - 1];
if (!lastCell) {
return;
}
setSelectedCells([getCellSelectionData(lastCell)]);
scrollToRow?.(table.getRowModel().rows.length);
};
const navigateUp = () => {
const selectedCell = selectedCells[selectedCells.length - 1];
if (!selectedCell) {
return;
}
const selectedRowIndex = table
.getRowModel()
.rows.findIndex(row => row.id === selectedCell.rowId);
const nextRowIndex = selectedRowIndex - 1;
const previousRow = table.getRowModel().rows[nextRowIndex];
if (previousRow) {
setSelectedCells([
getCellSelectionData(
previousRow
.getAllCells()
.find(c => c.column.id === selectedCell.columnId)!,
),
]);
scrollToRow?.(nextRowIndex);
}
};
const navigateDown = () => {
const selectedCell = selectedCells[selectedCells.length - 1];
if (!selectedCell) {
return;
}
const selectedRowIndex = table
.getRowModel()
.rows.findIndex(row => row.id === selectedCell.rowId);
const nextRowIndex = selectedRowIndex + 1;
const nextRow = table.getRowModel().rows[nextRowIndex];
if (nextRow) {
setSelectedCells([
getCellSelectionData(
nextRow
.getAllCells()
.find(c => c.column.id === selectedCell.columnId)!,
),
]);
scrollToRow?.(nextRowIndex);
}
};
const navigateLeft = () => {
const selectedCell = selectedCells[selectedCells.length - 1];
if (!selectedCell) {
return;
}
const selectedRow = table.getRow(selectedCell.rowId);
const selectedColumnIndex = selectedRow
.getAllCells()
.findIndex(c => c.id === selectedCell.cellId);
const previousCell = selectedRow.getAllCells()[selectedColumnIndex - 1];
if (previousCell) {
setSelectedCells([getCellSelectionData(previousCell)]);
}
};
const navigateRight = () => {
const selectedCell = selectedCells[selectedCells.length - 1];
if (!selectedCell) {
return;
}
const selectedRow = table.getRow(selectedCell.rowId);
const selectedColumnIndex = selectedRow
.getAllCells()
.findIndex(c => c.id === selectedCell.cellId);
const nextCell = selectedRow.getAllCells()[selectedColumnIndex + 1];
if (nextCell) {
setSelectedCells([getCellSelectionData(nextCell)]);
}
};
const isRowSelected = (rowId: string) =>
selectedCells.find(c => c.rowId === rowId) !== undefined;
const isCellSelected = (cell: Cell<any, any>) =>
selectedCells.find(c => c.cellId === cell.id) !== undefined;
const isCellCopied = (cell: Cell<any, any>) =>
copiedCells.find(c => c.cellId === cell.id) !== undefined;
const updateRangeSelection = (cell: Cell<any, any>) => {
if (!selectedStartCell) {
return;
}
const selectedCellsInRange = getCellsBetween(
table,
selectedStartCell,
getCellSelectionData(cell),
) as SelectedCell[];
setSelectedCells(prev => {
const startIndex = prev.findIndex(
c => c.cellId === selectedStartCell.cellId,
);
const prevSelectedCells = prev.slice(0, startIndex);
const newCellSelection = selectedCellsInRange.filter(
c => c.cellId !== selectedStartCell.cellId,
);
return [...prevSelectedCells, selectedStartCell, ...newCellSelection];
});
};
const handleCellMouseDown = (
e: React.MouseEvent<HTMLElement>,
cell: Cell<any, any>,
) => {
if (!e.ctrlKey && !e.shiftKey) {
setSelectedCells([getCellSelectionData(cell)]);
if (!isMouseDown) {
setSelectedStartCell(getCellSelectionData(cell));
}
}
if (e.ctrlKey) {
setSelectedCells(prev =>
prev.find(c => c.cellId === cell.id) !== undefined
? prev.filter(({ cellId }) => cellId !== cell.id)
: [...prev, getCellSelectionData(cell)],
);
if (!isMouseDown) {
setSelectedStartCell(getCellSelectionData(cell));
}
}
if (e.shiftKey) {
updateRangeSelection(cell);
}
setIsMouseDown(true);
};
const handleCellMouseUp = (
e: React.MouseEvent<HTMLElement>,
_cell: Cell<any, any>,
) => {
setIsMouseDown(false);
};
const handleCellMouseOver = (
e: React.MouseEvent<HTMLElement>,
cell: Cell<any, any>,
) => {
if (e.buttons !== 1) {
return;
}
if (isMouseDown) {
updateRangeSelection(cell);
}
};
return {
handleCellMouseDown,
handleCellMouseUp,
handleCellMouseOver,
handleCellsKeyDown,
isCellSelected,
isRowSelected,
isCellCopied,
selectedCells,
};
};
type SelectedCellRowMap = Record<string, SelectedCell[]>;
const getCellValues = (table: Table<unknown>, cells: SelectedCell[]) => {
const rows = cells.reduce(
(acc: SelectedCellRowMap, cellIds: SelectedCell) => {
const cellsForRow = acc[cellIds.rowId] ?? [];
return {
...acc,
[cellIds.rowId]: [...cellsForRow, cellIds],
};
},
{} as SelectedCellRowMap,
);
return Object.keys(rows)
.map(rowId => {
const selectedCells = rows[rowId]!;
const row = table.getRow(rowId);
const cellValues = [];
for (const cell of row.getAllCells()) {
if (selectedCells.find(c => c.cellId === cell.id)) {
cellValues.push(cell?.getValue());
}
}
return cellValues.join("\t");
})
.join("\n");
};
const getCellSelectionData = (cell: Cell<any, any>) => ({
rowId: cell.row.id,
columnId: cell.column.id,
cellId: cell.id,
});
const getSelectedCellTableData = (table: Table<any>, cell: SelectedCell) => {
const row = table.getRow(cell.rowId);
return row.getAllCells().find(c => c.id === cell.cellId);
};
const getCellsBetween = (
table: Table<any>,
cell1: SelectedCell,
cell2: SelectedCell,
) => {
const cell1Data = getSelectedCellTableData(table, cell1);
const cell2Data = getSelectedCellTableData(table, cell2);
if (!cell1Data || !cell2Data) {
return [];
}
const rows = table.getRowModel().rows;
const cell1RowIndex = rows.findIndex(({ id }) => id === cell1Data.row.id);
const cell2RowIndex = rows.findIndex(({ id }) => id === cell2Data.row.id);
const cell1ColumnIndex = cell1Data.column.getIndex();
const cell2ColumnIndex = cell2Data.column.getIndex();
const selectedRows = rows.slice(
Math.min(cell1RowIndex, cell2RowIndex),
Math.max(cell1RowIndex, cell2RowIndex) + 1,
);
const columns = table
.getAllColumns()
.slice(
Math.min(cell1ColumnIndex, cell2ColumnIndex),
Math.max(cell1ColumnIndex, cell2ColumnIndex) + 1,
);
return selectedRows.flatMap(row =>
columns.map(column => {
const tableCell = row
.getAllCells()
.find(cell => cell.column.id === column.id);
if (!tableCell) {
return null;
}
return getCellSelectionData(tableCell);
}),
);
};

View File

@@ -0,0 +1,22 @@
import type { TableState } from "@tanstack/table-core";
import { useEffect, useRef } from "react";
export const useColumnResizeObserver = (
state: TableState,
onChange: (columnId: string, columnSize: number) => void,
) => {
const columnResizeRef = useRef<string | false>();
useEffect(() => {
if (
state.columnSizingInfo &&
!state.columnSizingInfo?.isResizingColumn &&
columnResizeRef.current
) {
onChange(
columnResizeRef.current,
state.columnSizing[columnResizeRef.current],
);
}
columnResizeRef.current = state.columnSizingInfo?.isResizingColumn;
}, [onChange, state.columnSizingInfo, state.columnSizing]);
};

View File

@@ -0,0 +1,142 @@
import type { Table } from "@tanstack/react-table";
import { useMemo } from "react";
import { isNotNull } from "metabase/lib/types";
import type { ComputedVisualizationSettings } from "metabase/visualizations/types";
import { isNumber } from "metabase-lib/v1/types/utils/isa";
import type {
AggregationType,
DatasetColumn,
RowValue,
RowValues,
} from "metabase-types/api";
import type { SelectedCell } from "./use-cell-selection";
const sum = (values: number[]) => values.reduce((sum, value) => sum + value, 0);
const avg = (values: number[]) => sum(values) / values.length;
const min = (values: number[]) => Math.min(...values);
const max = (values: number[]) => Math.max(...values);
const aggFunctionByName: Record<AggregationType, any> = {
sum,
count: sum,
"cum-sum": max,
max,
min,
avg,
};
const calculateColumnTotals = (
cols: DatasetColumn[],
rows: RowValues[],
selectedValuesByColumnId: Map<string, RowValue[]>,
): ColumnTotal[] => {
return cols.map((col, colIndex) => {
const aggregationFunctionName = col.aggregation_function ?? "sum";
const aggregationFunction = aggFunctionByName[aggregationFunctionName];
if (
isNumber(col) &&
col.binning_info == null &&
aggregationFunction != null
) {
const selectedValues = selectedValuesByColumnId.get(col.name);
const hasSelectedValues = selectedValues != null;
const valuesToAggregate = hasSelectedValues
? selectedValues
: rows.map(row => row[colIndex]).filter(isNotNull);
return {
value: aggregationFunction(valuesToAggregate),
aggregation: aggregationFunctionName,
isSelected: hasSelectedValues,
};
}
return null;
});
};
const getSelectedValuesByColumnId = (
selectedCells: SelectedCell[],
table: Table<unknown>,
): Map<string, RowValue[]> => {
const selectedCellsByColumnId = selectedCells.reduce(
(acc: Map<string, SelectedCell[]>, cell) => {
if (!acc.has(cell.columnId)) {
acc.set(cell.columnId, []);
}
acc.get(cell.columnId)?.push(cell);
return acc;
},
new Map(),
);
const selectedColumnIds = Array.from(selectedCellsByColumnId.keys());
const selectedCellByRowId = selectedCells.reduce(
(acc: Map<string, SelectedCell[]>, cell) => {
if (!acc.has(cell.rowId)) {
acc.set(cell.rowId, []);
}
acc.get(cell.rowId)?.push(cell);
return acc;
},
new Map(),
);
const selectedValuesByColumnId = new Map<string, RowValue[]>();
const selectedRowIds = Array.from(selectedCellByRowId.keys());
if (selectedRowIds.length < 2) {
return new Map();
}
selectedRowIds.map(rowId => {
const cellByColumnId = table.getRow(rowId)._getAllCellsByColumnId();
selectedColumnIds.forEach(columnId => {
const cell = cellByColumnId[columnId];
if (!selectedValuesByColumnId.has(columnId)) {
selectedValuesByColumnId.set(columnId, []);
}
selectedValuesByColumnId.get(columnId)?.push(cell.getValue());
});
});
return selectedValuesByColumnId;
};
export type ColumnTotal = {
value: number;
isSelected: boolean;
aggregation: string;
} | null;
export const useColumnTotals = (
cols: DatasetColumn[],
rows: RowValues[],
settings: ComputedVisualizationSettings,
selectedCells: SelectedCell[],
table: Table<unknown>,
): ColumnTotal[] | null => {
return useMemo(() => {
if (!settings["table.column_totals"]) {
return null;
}
const selectedValuesByColumnId = getSelectedValuesByColumnId(
selectedCells,
table,
);
const columnTotals = calculateColumnTotals(
cols,
rows,
selectedValuesByColumnId,
);
if (columnTotals.every(total => total == null)) {
return null;
}
return columnTotals;
}, [cols, rows, selectedCells, settings, table]);
};

View File

@@ -0,0 +1,410 @@
import React, {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import type { Root } from "react-dom/client";
import { type CSSProperties } from "react";
import { formatValue } from "metabase/lib/formatting";
import { renderRoot } from "metabase/lib/react-compat";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { ThemeProvider } from "metabase/ui";
import { cachedFormatter } from "metabase/visualizations/echarts/cartesian/utils/formatter";
import { getColumnExtent } from "metabase/visualizations/lib/utils";
import type {
ComputedVisualizationSettings,
VisualizationProps,
} from "metabase/visualizations/types";
import type { OrderByDirection } from "metabase-lib/types";
import { isFK, isNumber, isPK } from "metabase-lib/v1/types/utils/isa";
import type {
ColumnSettings,
DatasetColumn,
DatasetData,
RowValue,
RowValues,
} from "metabase-types/api";
import { BodyCell, type BodyCellVariant } from "../cell/BodyCell";
import { HeaderCell } from "../cell/HeaderCell";
import { MiniBar } from "../cell/MiniBarCell";
import { pickRowsToMeasure } from "../utils";
import "@tanstack/react-table";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
column: DatasetColumn;
datasetIndex: number;
wrap: boolean;
isPivoted: boolean;
}
}
import type { CellMeasurer } from "./use-cell-measure";
import {
ColumnDef,
ColumnSizingState,
RowData,
createColumnHelper,
} from "@tanstack/react-table";
import { INDEX_COLUMN_ID, MIN_COLUMN_WIDTH } from "../constants";
import type Question from "metabase-lib/v1/Question";
import { IndexCell } from "../cell/IndexCell";
import { IndexHeaderCell } from "../cell/IndexHeaderCell";
import { FooterCell } from "../cell/FooterCell";
import _ from "underscore";
import { sumMetric } from "metabase/visualizations/lib/dataset";
import { t } from "ttag";
// approximately 120 chars
const TRUNCATE_WIDTH = 780;
const CELL_BORDERS_WIDTH = 2;
// if header is dragged fewer than than this number of pixels we consider it a click instead of a drag
const HEADER_DRAG_THRESHOLD = 5;
const getBodyCellVariant = (
column: DatasetColumn,
settings: ColumnSettings,
): BodyCellVariant => {
if (settings["show_mini_bar"]) {
return "minibar";
}
const isPill = isPK(column) || isFK(column);
if (isPill) {
return "pill";
}
return "text";
};
interface UseColumnsProps {
settings: ComputedVisualizationSettings;
data: DatasetData;
isPivoted?: boolean;
onVisualizationClick?: VisualizationProps["onVisualizationClick"];
measureBodyCellDimensions: CellMeasurer;
measureHeaderCellDimensions: CellMeasurer;
getColumnSortDirection: (columnIndex: number) => OrderByDirection | undefined;
question: Question;
hasMetadataPopovers?: boolean;
}
const columnHelper = createColumnHelper<RowValues>();
const getColumnSizing = (
cols: DatasetColumn[],
widths: number[] = [],
expandedState?: ExpandedColumnsState,
): ColumnSizingState => {
return cols.reduce((acc: ColumnSizingState, column, index) => {
const width = widths[index];
if (width != null) {
acc[column.name] =
expandedState == null || expandedState[column.name]
? width
: Math.min(width, TRUNCATE_WIDTH);
}
return acc;
}, {});
};
const getColumnWidthsForSettings = (
cols: DatasetColumn[],
columnSizing: ColumnSizingState,
) => {
return cols.map(col => columnSizing[col.name] ?? 0);
};
export type ExpandedColumnsState = Record<string, boolean>;
export const useColumns = ({
settings,
onVisualizationClick,
data,
isPivoted = false,
getColumnSortDirection,
measureBodyCellDimensions,
question,
hasMetadataPopovers = false,
}: UseColumnsProps) => {
const { cols, rows } = data;
const [expandedColumns, setExpandedColumns] = useState<ExpandedColumnsState>(
() => {
return cols.reduce((acc: ExpandedColumnsState, col) => {
acc[col.name] = false;
return acc;
}, {});
},
);
const savedColumnSizing = useMemo(
() =>
getColumnSizing(cols, settings["table.column_widths"], expandedColumns),
[settings],
);
const [columnSizing, setColumnSizing] =
useState<ColumnSizingState>(savedColumnSizing);
const [measuredColumnSizing, setMeasuredColumnSizing] =
useState<ColumnSizingState>({});
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 handleExpandButtonClick = useCallback(
(columnName: string, content: string) => {
const newColumnWidth = Math.max(
measureBodyCellDimensions(content).width,
measuredColumnSizing[columnName],
);
const newColumnSizing = { ...columnSizing, [columnName]: newColumnWidth };
setColumnSizing(newColumnSizing);
onUpdateColumnExpanded(columnName);
},
[columnSizing, measureBodyCellDimensions, measuredColumnSizing],
);
const onUpdateColumnExpanded = useCallback(
(columnName: string, isExpanded = true) => {
setExpandedColumns(prev => {
return { ...prev, [columnName]: isExpanded };
});
},
[expandedColumns],
);
const onResizeColumn = useCallback(
(columnName: string, width: number) => {
const newWidth = Math.max(MIN_COLUMN_WIDTH, width);
const newColumnSizing = { ...columnSizing, [columnName]: newWidth };
setColumnSizing(newColumnSizing);
if (newWidth > TRUNCATE_WIDTH) {
onUpdateColumnExpanded(columnName);
}
return getColumnWidthsForSettings(cols, columnSizing);
},
[onUpdateColumnExpanded, columnSizing, cols],
);
const columns: ColumnDef<RowValues, RowValue>[] = useMemo(() => {
const indexColumn = columnHelper.display({
id: INDEX_COLUMN_ID,
size: 52,
enableResizing: false,
cell: props => {
return <IndexCell rowNumber={props.row.index + 1} />;
},
header: () => {
return <IndexHeaderCell />;
},
footer: () => {
return <FooterCell value={t`Totals`} />;
},
});
const dataColumns = cols.map((col, index) => {
const columnWidth = columnSizing[col.name] ?? 0;
const measuredColumnWidth = measuredColumnSizing[col.name] ?? 0;
const isTruncated =
!expandedColumns[col.name] &&
columnWidth < measuredColumnWidth &&
measuredColumnWidth > TRUNCATE_WIDTH;
const columnSettings = settings.column?.(col) ?? {};
const columnName = columnSettings["column_title"];
const wrap = Boolean(columnSettings["text_wrapping"]);
const align = columnSettings["text_align"] ?? "left";
const variant = getBodyCellVariant(col, columnSettings);
const sortDirection = getColumnSortDirection(index);
const id = isPivoted ? `${index}:${col.name}` : col.name;
return columnHelper.accessor(row => row[index], {
id,
header: memo(() => {
return (
<HeaderCell name={columnName} align={align} sort={sortDirection} />
);
}),
footer: memo(({ value, isSelected }) => {
return (
<FooterCell align={align} value={value} isSelected={isSelected} />
);
}),
cell: memo(
(props: { getValue: () => RowValue; row: { index: number } }) => {
const value = props.getValue();
const backgroundColor = settings["table._cell_background_getter"]?.(
value,
props.row.index,
col.name,
);
if (variant === "minibar") {
return (
<MiniBar
backgroundColor={backgroundColor}
value={value}
formatter={columnFormatters[index]}
extent={getColumnExtent(cols, rows, index)}
/>
);
}
return (
<BodyCell
value={value}
align={align}
canExpand={!wrap && isTruncated}
formatter={columnFormatters[index]}
backgroundColor={backgroundColor}
onExpand={() => {
const formatter = columnFormatters[index];
const formattedValue = formatter(value);
handleExpandButtonClick(col.name, formattedValue);
}}
variant={variant}
wrap={wrap}
/>
);
},
),
meta: {
isPivoted,
column: col,
datasetIndex: index,
wrap,
},
});
});
if (!settings["table.row_index"]) {
return dataColumns;
}
return [indexColumn, ...dataColumns];
}, [
cols,
expandedColumns,
measuredColumnSizing,
settings,
isPivoted,
getColumnSortDirection,
data,
onVisualizationClick,
columnFormatters,
question,
hasMetadataPopovers,
]);
const measureRootRef = useRef<HTMLDivElement>();
const measureRootTree = useRef<Root>();
const measureColumnWidths = useCallback(
(updateCurrent: boolean) => {
const onMeasureHeaderRender = (div: HTMLDivElement) => {
if (div === null) {
return;
}
const contentWidths = Array.from(
div.getElementsByClassName("fake-column"),
).map(
columnElement =>
(columnElement as HTMLElement).offsetWidth + CELL_BORDERS_WIDTH,
);
if (updateCurrent) {
setColumnSizing(
getColumnSizing(cols, contentWidths, expandedColumns),
);
}
setMeasuredColumnSizing(getColumnSizing(cols, contentWidths));
};
const content = (
<EmotionCacheProvider>
<ThemeProvider>
<div style={{ display: "flex" }} ref={onMeasureHeaderRender}>
{cols.map((column, columnIndex) => {
const columnSettings = settings.column?.(column) ?? {};
const variant = getBodyCellVariant(column, columnSettings);
return (
<div className="fake-column" key={"column-" + columnIndex}>
<HeaderCell name={column.display_name} sort="asc" />
{pickRowsToMeasure(rows, columnIndex).map(rowIndex => (
<BodyCell
key={rowIndex}
value={rows[rowIndex][columnIndex]}
formatter={columnFormatters[columnIndex]}
variant={variant}
/>
))}
</div>
);
})}
</div>
</ThemeProvider>
</EmotionCacheProvider>
);
measureRootTree.current = renderRoot(content, measureRootRef.current!);
},
[cols, columnFormatters, rows, settings],
);
useEffect(() => {
if (!measureRootRef.current) {
const measureRoot = document.createElement("div");
measureRoot.style.position = "absolute";
measureRoot.style.top = "-9999px";
measureRoot.style.left = "-9999px";
measureRoot.style.visibility = "hidden";
measureRoot.style.pointerEvents = "none";
measureRoot.style.zIndex = "-999";
document.body.appendChild(measureRoot);
measureRootRef.current = measureRoot;
}
const shouldUpdateCurrentWidths =
!savedColumnSizing || Object.values(savedColumnSizing).length === 0;
measureColumnWidths(shouldUpdateCurrentWidths);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
columns,
columnFormatters,
columnSizing,
setColumnSizing,
measureColumnWidths,
onResizeColumn,
};
};

View File

@@ -0,0 +1,50 @@
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 { ObjectId } from "metabase/visualizations/components/ObjectDetail/types";
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 = ({ rows, cols }: DatasetData) => {
const dispatch = useDispatch();
const rowIndexToPkMap: Record<number, ObjectId> =
useSelector(getRowIndexToPKMap);
const primaryKeyColumn: ColumnDescriptor | null = useMemo(() => {
const primaryKeyColumns = cols.filter(isPK);
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: number | string;
if (primaryKeyColumn) {
const value = rows[rowIndex][primaryKeyColumn.index];
objectId =
typeof value === "number" || typeof value === "string"
? value
: rowIndex;
} else {
objectId = rowIndexToPkMap?.[rowIndex] ?? rowIndex;
}
dispatch(zoomInRow({ objectId }));
},
[dispatch, primaryKeyColumn, rowIndexToPkMap, rows],
);
return onOpenObjectDetail;
};

View File

@@ -0,0 +1,81 @@
import type { Table as ReactTable } from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import type React from "react";
import { useCallback, useLayoutEffect } from "react";
import _ from "underscore";
import type { RowValues } from "metabase-types/api";
import { ROW_HEIGHT } from "../constants";
interface UseVirtualGridProps {
bodyRef: React.RefObject<HTMLDivElement>;
table: ReactTable<RowValues>;
measureRowHeight: (rowIndex: number) => number;
}
export const useVirtualGrid = ({
bodyRef,
table,
measureRowHeight,
}: UseVirtualGridProps) => {
const { rows: tableRows } = table.getRowModel();
const visibleColumns = table.getVisibleLeafColumns();
const columnVirtualizer = useVirtualizer({
count: visibleColumns.length,
getScrollElement: () => bodyRef.current,
estimateSize: index => visibleColumns[index].getSize(),
horizontal: true,
overscan: 5,
});
const rowVirtualizer = useVirtualizer({
count: tableRows.length,
getScrollElement: () => bodyRef.current,
estimateSize: () => ROW_HEIGHT,
overscan: 5,
measureElement: element => {
const rowIndexRaw = element?.getAttribute("data-index");
const rowIndex = rowIndexRaw != null ? parseInt(rowIndexRaw, 10) : null;
if (rowIndex == null || !isFinite(rowIndex)) {
return ROW_HEIGHT;
}
return measureRowHeight(rowIndex);
},
});
const virtualColumns = columnVirtualizer.getVirtualItems();
const virtualRows = rowVirtualizer.getVirtualItems();
let virtualPaddingLeft: number | undefined;
let virtualPaddingRight: number | undefined;
if (columnVirtualizer && virtualColumns?.length) {
virtualPaddingLeft = virtualColumns[0]?.start ?? 0;
virtualPaddingRight =
columnVirtualizer.getTotalSize() -
(virtualColumns[virtualColumns.length - 1]?.end ?? 0);
}
const measureGrid = useCallback(() => {
Array.from(rowVirtualizer.elementsCache.values()).forEach(el =>
rowVirtualizer.measureElement(el),
);
columnVirtualizer.measure();
}, [rowVirtualizer, columnVirtualizer]);
useLayoutEffect(() => {
measureGrid();
}, [measureRowHeight, measureGrid]);
return {
virtualColumns,
virtualRows,
virtualPaddingLeft,
virtualPaddingRight,
rowVirtualizer,
measureGrid,
scrollToRow: rowVirtualizer.scrollToIndex,
};
};

View File

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

View File

@@ -0,0 +1,19 @@
import type { DatasetData } from "metabase-types/api";
export const pickRowsToMeasure = (
rows: DatasetData["rows"],
columnIndex: number,
count = 10,
) => {
const rowIndexes = [];
for (
let rowIndex = 0;
rowIndex < rows.length && rowIndexes.length < count;
rowIndex++
) {
if (rows[rowIndex][columnIndex] != null) {
rowIndexes.push(rowIndex);
}
}
return rowIndexes;
};

View File

@@ -36,16 +36,17 @@ import {
isImageURL,
isMetric,
isNumber,
isString,
isURL,
} from "metabase-lib/v1/types/utils/isa";
import type {
DatasetColumn,
DatasetData,
RawSeries,
Series,
VisualizationSettings,
} from "metabase-types/api";
import TableInteractive from "../components/TableInteractive/TableInteractive.jsx";
import { TableSimple } from "../components/TableSimple";
import type {
ColumnSettingDefinition,
@@ -53,6 +54,8 @@ import type {
VisualizationProps,
} from "../types";
import { Table as TableInteractive } from "./Table";
interface TableProps extends VisualizationProps {
isShowingDetailsOnlyColumns?: boolean;
}
@@ -64,7 +67,7 @@ interface TableState {
class Table extends Component<TableProps, TableState> {
static uiName = t`Table`;
static identifier = "table";
static identifier = "table-old";
static iconName = "table2";
static canSavePng = false;
@@ -87,6 +90,21 @@ class Table extends Component<TableProps, TableState> {
static settings = {
...columnSettings({ hidden: true }),
"table.row_index": {
section: t`Columns`,
title: t`Show row index`,
inline: true,
widget: "toggle",
default: true, // TODO: should be false by default
},
"table.column_totals": {
section: t`Columns`,
title: t`Show column totals`,
inline: true,
widget: "toggle",
getHidden: ([{ data }]: RawSeries) => data.rows_truncated != null,
getDefault: ([{ data }]: RawSeries) => data.rows_truncated == null,
},
"table.pivot": {
section: t`Columns`,
title: t`Pivot table`,
@@ -110,6 +128,7 @@ class Table extends Component<TableProps, TableState> {
return getDefaultPivotColumn(data.cols, data.rows) != null;
},
},
"table.pivot_column": {
section: t`Columns`,
title: t`Pivot column`,
@@ -209,6 +228,19 @@ class Table extends Component<TableProps, TableState> {
getDefault: column => displayNameForColumn(column),
},
click_behavior: {},
text_align: {
title: t`Align`,
widget: "segmentedControl",
getDefault: column => {
return isNumber(column) ? "right" : "left";
},
props: {
options: [
{ name: t`Left`, value: "left" },
{ name: t`Right`, value: "right" },
],
},
},
};
if (isNumber(column)) {
@@ -219,6 +251,15 @@ class Table extends Component<TableProps, TableState> {
};
}
if (isString(column)) {
settings["text_wrapping"] = {
title: t`Wrap text`,
default: false,
widget: "toggle",
inline: true,
};
}
let defaultValue = !column.semantic_type || isURL(column) ? "link" : null;
const options = [
@@ -416,7 +457,7 @@ class Table extends Component<TableProps, TableState> {
const isPivoted = Table.isPivoted(series, settings);
const areAllColumnsHidden = data?.cols.length === 0;
const TableComponent = isDashboard ? TableSimple : TableInteractive;
const TableComponent = TableInteractive;
if (!data) {
return null;

View File

@@ -24,7 +24,8 @@
"@react-oauth/google": "^0.11.1",
"@reduxjs/toolkit": "^2.5.0",
"@snowplow/browser-tracker": "^3.1.6",
"@tanstack/react-virtual": "^3.1.2",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-virtual": "^3.11.2",
"@tippyjs/react": "^4.2.6",
"@types/react-router": "3.0.27",
"@types/redux-auth-wrapper": "^2.0.16",

View File

@@ -429,9 +429,10 @@
:source :breakout))
(for [[i aggregation] (m/indexed aggregations)]
(assoc (col-info-for-aggregation-clause inner-query aggregation)
:source :aggregation
:field_ref [:aggregation i]
:aggregation_index i))))
:source :aggregation
:field_ref [:aggregation i]
:aggregation_function (get-in aggregation [1 0])
:aggregation_index i))))
(mu/defn cols-for-mbql-query
"Return results metadata about the expected columns in an 'inner' MBQL query."

View File

@@ -4811,17 +4811,29 @@
dependencies:
defer-to-connect "^1.0.1"
"@tanstack/react-virtual@^3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.1.2.tgz#eb62b73cc82e34860604cd3d682a17db590f3c45"
integrity sha512-qibmxtctgOZo2I+3Rw5GR9kXgaa15U5r3/idDY1ItUKW15UK7GhCfyIfE6qYuJ1fxQF6dJDsD8SbpPyuJgpxuA==
"@tanstack/react-table@^8.20.6":
version "8.20.6"
resolved "https://registry.yarnpkg.com/@tanstack/react-table/-/react-table-8.20.6.tgz#a1f3103327aa59aa621931f4087a7604a21054d0"
integrity sha512-w0jluT718MrOKthRcr2xsjqzx+oEM7B7s/XXyfs19ll++hlId3fjTm+B2zrR3ijpANpkzBAr15j1XGVOMxpggQ==
dependencies:
"@tanstack/virtual-core" "3.1.2"
"@tanstack/table-core" "8.20.5"
"@tanstack/virtual-core@3.1.2":
version "3.1.2"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.1.2.tgz#ca76f28f826fbd3310f88c3cd355d9c4aba80abb"
integrity sha512-DATZJs8iejkIUqXZe6ruDAnjFo78BKnIIgqQZrc7CmEFqfLEN/TPD91n4hRfo6hpRB6xC00bwKxv7vdjFNEmOg==
"@tanstack/react-virtual@^3.11.2":
version "3.11.2"
resolved "https://registry.yarnpkg.com/@tanstack/react-virtual/-/react-virtual-3.11.2.tgz#d6b9bd999c181f0a2edce270c87a2febead04322"
integrity sha512-OuFzMXPF4+xZgx8UzJha0AieuMihhhaWG0tCqpp6tDzlFwOmNBPYMuLOtMJ1Tr4pXLHmgjcWhG6RlknY2oNTdQ==
dependencies:
"@tanstack/virtual-core" "3.11.2"
"@tanstack/table-core@8.20.5":
version "8.20.5"
resolved "https://registry.yarnpkg.com/@tanstack/table-core/-/table-core-8.20.5.tgz#3974f0b090bed11243d4107283824167a395cf1d"
integrity sha512-P9dF7XbibHph2PFRz8gfBKEXEY/HJPOhym8CHmjF8y3q5mWpKx9xtZapXQUWCgkqvsK0R46Azuz+VaxD4Xl+Tg==
"@tanstack/virtual-core@3.11.2":
version "3.11.2"
resolved "https://registry.yarnpkg.com/@tanstack/virtual-core/-/virtual-core-3.11.2.tgz#00409e743ac4eea9afe5b7708594d5fcebb00212"
integrity sha512-vTtpNt7mKCiZ1pwU9hfKPhpdVO2sVzFQsxoVBGtOSHxlrRRzYr8iQ2TlwbAcRYCcEiZ9ECAM8kBzH0v2+VzfKw==
"@testing-library/cypress@^10.0.2":
version "10.0.2"