Compare commits
25 Commits
python-run
...
dashviz-ta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
892f2ad3a4 | ||
|
|
b390412873 | ||
|
|
5751580493 | ||
|
|
a04a16698f | ||
|
|
f2359843fb | ||
|
|
78db3061ce | ||
|
|
db2997eff4 | ||
|
|
9c1b6a080e | ||
|
|
62726a67f3 | ||
|
|
2cbf4f5dd2 | ||
|
|
4ba459287d | ||
|
|
b81a8b32a9 | ||
|
|
dbce312844 | ||
|
|
dfc0ed57a9 | ||
|
|
0963cd0e1d | ||
|
|
02aca4ed04 | ||
|
|
50183f464d | ||
|
|
1cbe3c2dc5 | ||
|
|
dcf0ef50b9 | ||
|
|
d1ad3608f2 | ||
|
|
7d3138c439 | ||
|
|
0ddb236f06 | ||
|
|
c1e39ad24e | ||
|
|
ea82d4354d | ||
|
|
501ddd26ba |
@@ -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";
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface DatasetColumn {
|
||||
aggregation_index?: number;
|
||||
|
||||
aggregation_type?: AggregationType;
|
||||
aggregation_function?: AggregationType;
|
||||
|
||||
coercion_strategy?: string | null;
|
||||
visibility_type?: FieldVisibilityType;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./AddColumnButton";
|
||||
@@ -0,0 +1,7 @@
|
||||
.icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1,7 @@
|
||||
.icon {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export { ExpandButton } from "./ExpandButton";
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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%);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import S from "./IndexHeaderCell.module.css";
|
||||
|
||||
export const IndexHeaderCell = () => {
|
||||
return <div className={S.root}>#</div>;
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
height: 100%;
|
||||
padding: 0 8px;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`;
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./Table";
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
30
yarn.lock
30
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user