diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 3407109e67..e0381d061b 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -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 { table: ReactTableType; tableContainerRef: React.RefObject; @@ -21,6 +23,7 @@ export interface DataTableProps { variant?: "default" | "compact"; "data-testid"?: string; children?: React.ReactNode; + enableColumnResizing?: { name: string }; } export function DataTable({ table, @@ -30,6 +33,7 @@ export function DataTable({ onRowMouseclick, onScroll, children, + enableColumnResizing, ...rest }: DataTableProps & React.ComponentPropsWithoutRef<"div">) { const { rows } = table.getRowModel(); @@ -60,8 +64,6 @@ export function DataTable({ } }, [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({ return colSizes; }, [table.getFlatHeaders(), table.getState().columnSizingInfo, table.getState().columnSizing]); + usePersistentColumnResizing({ + enabled: Boolean(enableColumnResizing), + table, + name: enableColumnResizing?.name, + }); + return (
({ gridTemplateRows: "auto 1fr auto", gridTemplateAreas: "'header' 'body' 'footer'", ...rest.style, - ...columnSizeVars, }} data-testid={rest["data-testid"] ?? "data-table"}>
({ "scrollbar-thin border-subtle relative rounded-md border" )} style={{ gridArea: "body" }}> - - + + {table.getHeaderGroups().map((headerGroup) => ( - + {headerGroup.headers.map((header) => { const meta = header.column.columnDef.meta; return ( @@ -110,11 +122,13 @@ export function DataTable({ 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" )}> -
+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -130,68 +144,132 @@ export function DataTable({ /> )}
+ {Boolean(enableColumnResizing) && header.column.getCanResize() && ( +
+ )} ); })} ))} - - {virtualRows && !isPending ? ( - virtualRows.map((virtualRow) => { - const row = rows[virtualRow.index] as Row; - return ( - 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 ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ); - }) - ) : ( - - - No results. - - - )} - + {/* When resizing any column we will render this special memoized version of our table body */} + {table.getState().columnSizingInfo.isResizingColumn ? ( + + ) : ( + + )}
{children}
); } + +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 = { + table: ReactTableType; + rowVirtualizer: Virtualizer; + rows: Row[]; + variant?: "default" | "compact"; + isPending?: boolean; + onRowMouseclick?: (row: Row) => void; +}; + +function DataTableBody({ + table, + rowVirtualizer, + rows, + variant, + isPending, + onRowMouseclick, +}: DataTableBodyProps) { + const virtualRows = rowVirtualizer.getVirtualItems(); + return ( + + {virtualRows && !isPending ? ( + virtualRows.map((virtualRow) => { + const row = rows[virtualRow.index] as Row; + return ( + 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 ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ); + }) + ) : ( + + + No results. + + + )} + + ); +} diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index bc8c614cf4..0f732c504f 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -1,3 +1,4 @@ export * from "./components"; export * from "./lib/types"; export * from "./lib/utils"; +export * from "./lib/resizing"; diff --git a/packages/features/data-table/lib/resizing.ts b/packages/features/data-table/lib/resizing.ts new file mode 100644 index 0000000000..b56f1a3c5d --- /dev/null +++ b/packages/features/data-table/lib/resizing.ts @@ -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 = { + enabled: boolean; + table: Table; + 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({ + enabled, + table, + name, +}: UsePersistentColumnResizingProps) { + const [_, setColumnSizing] = useState({}); + + 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]); +} diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index f25b3450cc..ca77ced459 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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)}>