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:
Eunjae Lee
2024-12-09 08:26:02 +00:00
committed by GitHub
parent e559e7a46f
commit 5e95018870
4 changed files with 221 additions and 64 deletions
@@ -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
View File
@@ -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">