feat: resizing in DataTable (#17973)
* feat: support resizing in data table * wip * clean up colors * feat: resizable DataTable * update implementation * fix * remove unused code * improve dependency array * enable resizing at DataTable level * hide header label * fix type error
This commit is contained in:
@@ -3,14 +3,16 @@
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Table as ReactTableType } from "@tanstack/react-table";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { useMemo, useEffect, memo } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { Icon, TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
|
||||
|
||||
import { usePersistentColumnResizing } from "../lib/resizing";
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
table: ReactTableType<TData>;
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -21,6 +23,7 @@ export interface DataTableProps<TData, TValue> {
|
||||
variant?: "default" | "compact";
|
||||
"data-testid"?: string;
|
||||
children?: React.ReactNode;
|
||||
enableColumnResizing?: { name: string };
|
||||
}
|
||||
export function DataTable<TData, TValue>({
|
||||
table,
|
||||
@@ -30,6 +33,7 @@ export function DataTable<TData, TValue>({
|
||||
onRowMouseclick,
|
||||
onScroll,
|
||||
children,
|
||||
enableColumnResizing,
|
||||
...rest
|
||||
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
|
||||
const { rows } = table.getRowModel();
|
||||
@@ -60,8 +64,6 @@ export function DataTable<TData, TValue>({
|
||||
}
|
||||
}, [rowVirtualizer.getVirtualItems().length, rows.length, tableContainerRef.current]);
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
|
||||
const columnSizeVars = useMemo(() => {
|
||||
const headers = table.getFlatHeaders();
|
||||
const colSizes: { [key: string]: string } = {};
|
||||
@@ -77,6 +79,12 @@ export function DataTable<TData, TValue>({
|
||||
return colSizes;
|
||||
}, [table.getFlatHeaders(), table.getState().columnSizingInfo, table.getState().columnSizing]);
|
||||
|
||||
usePersistentColumnResizing({
|
||||
enabled: Boolean(enableColumnResizing),
|
||||
table,
|
||||
name: enableColumnResizing?.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("grid", rest.className)}
|
||||
@@ -84,7 +92,6 @@ export function DataTable<TData, TValue>({
|
||||
gridTemplateRows: "auto 1fr auto",
|
||||
gridTemplateAreas: "'header' 'body' 'footer'",
|
||||
...rest.style,
|
||||
...columnSizeVars,
|
||||
}}
|
||||
data-testid={rest["data-testid"] ?? "data-table"}>
|
||||
<div
|
||||
@@ -95,10 +102,15 @@ export function DataTable<TData, TValue>({
|
||||
"scrollbar-thin border-subtle relative rounded-md border"
|
||||
)}
|
||||
style={{ gridArea: "body" }}>
|
||||
<TableNew className="grid border-0">
|
||||
<TableHeader className="bg-subtle sticky top-0 z-10">
|
||||
<TableNew
|
||||
className="grid border-0"
|
||||
style={{
|
||||
...columnSizeVars,
|
||||
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
|
||||
}}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="flex w-full">
|
||||
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const meta = header.column.columnDef.meta;
|
||||
return (
|
||||
@@ -110,11 +122,13 @@ export function DataTable<TData, TValue>({
|
||||
width: `var(--header-${kebabCase(header?.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"flex shrink-0 items-center",
|
||||
"bg-subtle hover:bg-muted relative flex shrink-0 items-center",
|
||||
header.column.getCanSort() ? "cursor-pointer select-none" : "",
|
||||
meta?.sticky && "bg-subtle sticky top-0 z-20"
|
||||
meta?.sticky && "sticky top-0 z-20"
|
||||
)}>
|
||||
<div className="flex items-center" onClick={header.column.getToggleSortingHandler()}>
|
||||
<div
|
||||
className="flex h-full w-full items-center overflow-hidden"
|
||||
onClick={header.column.getToggleSortingHandler()}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
@@ -130,68 +144,132 @@ export function DataTable<TData, TValue>({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{Boolean(enableColumnResizing) && header.column.getCanResize() && (
|
||||
<div
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
className={classNames(
|
||||
"bg-inverted absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-0 hover:opacity-50",
|
||||
header.column.getIsResizing() && "!opacity-75"
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody className="relative grid" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
|
||||
{virtualRows && !isPending ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<TData>;
|
||||
return (
|
||||
<TableRow
|
||||
ref={(node) => rowVirtualizer.measureElement(node)} //measure dynamic row height
|
||||
key={row.id}
|
||||
data-index={virtualRow.index} //needed for dynamic row height measurement
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
|
||||
width: "100%",
|
||||
}}
|
||||
className={classNames(
|
||||
onRowMouseclick && "hover:cursor-pointer",
|
||||
variant === "compact" && "!border-0",
|
||||
"group"
|
||||
)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const column = table.getColumn(cell.column.id);
|
||||
const meta = column?.columnDef.meta;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
...(meta?.sticky?.position === "left" && { left: `${meta.sticky.gap || 0}px` }),
|
||||
...(meta?.sticky?.position === "right" && { right: `${meta.sticky.gap || 0}px` }),
|
||||
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"flex shrink-0 items-center overflow-hidden",
|
||||
variant === "compact" && "p-1.5",
|
||||
meta?.sticky && "group-hover:bg-muted bg-default sticky"
|
||||
)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
{/* When resizing any column we will render this special memoized version of our table body */}
|
||||
{table.getState().columnSizingInfo.isResizingColumn ? (
|
||||
<MemoizedTableBody
|
||||
table={table}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
rows={rows}
|
||||
variant={variant}
|
||||
isPending={isPending}
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
/>
|
||||
) : (
|
||||
<DataTableBody
|
||||
table={table}
|
||||
rowVirtualizer={rowVirtualizer}
|
||||
rows={rows}
|
||||
variant={variant}
|
||||
isPending={isPending}
|
||||
onRowMouseclick={onRowMouseclick}
|
||||
/>
|
||||
)}
|
||||
</TableNew>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MemoizedTableBody = memo(
|
||||
DataTableBody,
|
||||
(prev, next) =>
|
||||
prev.table.options.data === next.table.options.data &&
|
||||
prev.rowVirtualizer === next.rowVirtualizer &&
|
||||
prev.rows === next.rows &&
|
||||
prev.variant === next.variant &&
|
||||
prev.isPending === next.isPending &&
|
||||
prev.onRowMouseclick === next.onRowMouseclick
|
||||
) as typeof DataTableBody;
|
||||
|
||||
type DataTableBodyProps<TData> = {
|
||||
table: ReactTableType<TData>;
|
||||
rowVirtualizer: Virtualizer<HTMLDivElement, Element>;
|
||||
rows: Row<TData>[];
|
||||
variant?: "default" | "compact";
|
||||
isPending?: boolean;
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
};
|
||||
|
||||
function DataTableBody<TData>({
|
||||
table,
|
||||
rowVirtualizer,
|
||||
rows,
|
||||
variant,
|
||||
isPending,
|
||||
onRowMouseclick,
|
||||
}: DataTableBodyProps<TData>) {
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
return (
|
||||
<TableBody className="relative grid" style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
|
||||
{virtualRows && !isPending ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<TData>;
|
||||
return (
|
||||
<TableRow
|
||||
ref={(node) => rowVirtualizer.measureElement(node)} //measure dynamic row height
|
||||
key={row.id}
|
||||
data-index={virtualRow.index} //needed for dynamic row height measurement
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
|
||||
width: "100%",
|
||||
}}
|
||||
className={classNames(
|
||||
onRowMouseclick && "hover:cursor-pointer",
|
||||
variant === "compact" && "!border-0",
|
||||
"group"
|
||||
)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const column = table.getColumn(cell.column.id);
|
||||
const meta = column?.columnDef.meta;
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
...(meta?.sticky?.position === "left" && { left: `${meta.sticky.gap || 0}px` }),
|
||||
...(meta?.sticky?.position === "right" && { right: `${meta.sticky.gap || 0}px` }),
|
||||
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
|
||||
}}
|
||||
className={classNames(
|
||||
"flex shrink-0 items-center overflow-hidden",
|
||||
variant === "compact" && "p-1.5",
|
||||
meta?.sticky &&
|
||||
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sticky"
|
||||
)}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./components";
|
||||
export * from "./lib/types";
|
||||
export * from "./lib/utils";
|
||||
export * from "./lib/resizing";
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Table, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
|
||||
type UsePersistentColumnResizingProps<TData> = {
|
||||
enabled: boolean;
|
||||
table: Table<TData>;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function getLocalStorageKey(name: string) {
|
||||
return `data-table-column-sizing-${name}`;
|
||||
}
|
||||
|
||||
function loadColumnSizing(name: string) {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(getLocalStorageKey(name)) || "{}");
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function saveColumnSizing(name: string, columnSizing: ColumnSizingState) {
|
||||
localStorage.setItem(getLocalStorageKey(name), JSON.stringify(columnSizing));
|
||||
}
|
||||
|
||||
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
|
||||
|
||||
export function usePersistentColumnResizing<TData>({
|
||||
enabled,
|
||||
table,
|
||||
name,
|
||||
}: UsePersistentColumnResizingProps<TData>) {
|
||||
const [_, setColumnSizing] = useState<ColumnSizingState>({});
|
||||
|
||||
const onColumnSizingChange = useCallback(
|
||||
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
|
||||
// `!name` is checked already in the `useEffect` hook,
|
||||
// but TS doesn't know that, and this won't happen.
|
||||
if (!name) return;
|
||||
|
||||
table.setState((oldTableState) => {
|
||||
const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
|
||||
debouncedSaveColumnSizing(name, newColumnSizing);
|
||||
setColumnSizing(newColumnSizing);
|
||||
|
||||
return {
|
||||
...oldTableState,
|
||||
columnSizing: newColumnSizing,
|
||||
};
|
||||
});
|
||||
},
|
||||
[name, table]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !name) return;
|
||||
|
||||
const newColumnSizing = loadColumnSizing(name);
|
||||
setColumnSizing(newColumnSizing);
|
||||
table.setState((old) => ({
|
||||
...old,
|
||||
columnSizing: newColumnSizing,
|
||||
}));
|
||||
table.setOptions((prev) => ({
|
||||
...prev,
|
||||
columnResizeMode: "onChange",
|
||||
onColumnSizingChange,
|
||||
}));
|
||||
}, [enabled, name, table, onColumnSizingChange]);
|
||||
}
|
||||
@@ -209,6 +209,7 @@ export function UserListTable() {
|
||||
id: "select",
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: 30,
|
||||
meta: {
|
||||
sticky: {
|
||||
@@ -352,6 +353,8 @@ export function UserListTable() {
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
size: 80,
|
||||
meta: {
|
||||
sticky: { position: "right" },
|
||||
@@ -389,6 +392,7 @@ export function UserListTable() {
|
||||
data: flatData,
|
||||
columns: memorisedColumns,
|
||||
enableRowSelection: true,
|
||||
columnResizeMode: "onChange",
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
initialState: {
|
||||
@@ -490,6 +494,7 @@ export function UserListTable() {
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
isPending={isPending}
|
||||
enableColumnResizing={{ name: "UserListTable" }}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row">
|
||||
|
||||
Reference in New Issue
Block a user