feat: apply full width automatically for DataTable (#18357)

* feat: apply full width automatically for DataTable

* change implementation

* load all columns of insights routing table at the same time

* update team member list

* sticky columns for >= sm

* fix type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Eunjae LeeandUdit Takkar authored and GitHub committed 2025-01-02 14:32:39 +00:00
1 parent c10c87c98e
commit c415b2d7f5
11 files changed
+230 -64

No files matched your search

@@ -7,11 +7,12 @@ import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
// eslint-disable-next-line no-restricted-imports
import kebabCase from "lodash/kebabCase";
import { usePathname } from "next/navigation";
import { useMemo, useEffect, memo } from "react";
import { useEffect, memo } from "react";
import classNames from "@calcom/lib/classNames";
import { Icon, TableNew, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@calcom/ui";
import { useColumnSizingVars } from "../hooks";
import { usePersistentColumnResizing } from "../lib/resizing";
export type DataTableProps<TData, TValue> = {
@@ -40,8 +41,8 @@ export function DataTable<TData, TValue>({
enableColumnResizing,
...rest
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
const pathname = usePathname();
const identifier = _identifier ?? pathname;
const pathname = usePathname() as string | null;
const identifier = _identifier ?? pathname ?? undefined;
const { rows } = table.getRowModel();
@@ -71,26 +72,13 @@ export function DataTable<TData, TValue>({
}
}, [rowVirtualizer.getVirtualItems().length, rows.length, tableContainerRef.current]);
const columnSizeVars = useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: string } = {};
for (let i = 0; i < headers.length; i++) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const header = headers[i]!;
const isAutoWidth = header.column.columnDef.meta?.autoWidth;
colSizes[`--header-${kebabCase(header.id)}-size`] = isAutoWidth ? "auto" : `${header.getSize()}px`;
colSizes[`--col-${kebabCase(header.column.id)}-size`] = isAutoWidth
? "auto"
: `${header.column.getSize()}px`;
}
return colSizes;
}, [table.getFlatHeaders(), table.getState().columnSizingInfo, table.getState().columnSizing]);
const columnSizingVars = useColumnSizingVars({ table });
usePersistentColumnResizing({
enabled: Boolean(enableColumnResizing),
table,
// TODO: Figure out why 'identifier' somehow types to string | null
identifier: identifier || undefined,
tableContainerRef,
identifier,
});
return (
@@ -113,7 +101,7 @@ export function DataTable<TData, TValue>({
<TableNew
className="grid border-0"
style={{
...columnSizeVars,
...columnSizingVars,
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
}}>
<TableHeader className="sticky top-0 z-10">
@@ -132,7 +120,7 @@ export function DataTable<TData, TValue>({
className={classNames(
"bg-subtle hover:bg-muted relative flex shrink-0 items-center",
header.column.getCanSort() ? "cursor-pointer select-none" : "",
meta?.sticky && "sticky top-0 z-20"
meta?.sticky && "top-0 z-20 sm:sticky"
)}>
<div
className="flex h-full w-full items-center overflow-hidden"
@@ -262,7 +250,7 @@ function DataTableBody<TData>({
"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"
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
@@ -2,7 +2,6 @@ export { DataTableToolbar } from "./DataTableToolbar";
export { DataTableSelectionBar } from "./DataTableSelectionBar";
export { DataTablePagination } from "./DataTablePagination";
export { DataTableFilters } from "./filters";
export { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached";
export { DataTable } from "./DataTable";
export { DataTableSkeleton } from "./DataTableSkeleton";
export type { DataTableProps } from "./DataTable";
@@ -0,0 +1,3 @@
export * from "./useDebouncedWidth";
export * from "./useFetchMoreOnBottomReached";
export * from "./useColumnSizingVars";
@@ -0,0 +1,26 @@
import type { Table } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import kebabCase from "lodash/kebabCase";
import { useMemo } from "react";
export const useColumnSizingVars = <TData>({ table }: { table: Table<TData> }) => {
const headers = table.getFlatHeaders();
const columnSizingInfo = table.getState().columnSizingInfo;
const columnSizing = table.getState().columnSizing;
return useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: string } = {};
headers.forEach((header) => {
const isAutoWidth = header.column.columnDef.meta?.autoWidth;
colSizes[`--header-${kebabCase(header.id)}-size`] = isAutoWidth ? "auto" : `${header.getSize()}px`;
colSizes[`--col-${kebabCase(header.column.id)}-size`] = isAutoWidth
? "auto"
: `${header.column.getSize()}px`;
});
return colSizes;
// `columnSizing` and `columnSizingInfo` are not used in the memo,
// but they're included in the deps to ensure the memo is re-evaluated when they change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [headers, columnSizingInfo, columnSizing]);
};
@@ -0,0 +1,31 @@
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
import { useState, useEffect } from "react";
export function useDebouncedWidth(ref: React.RefObject<HTMLDivElement>, debounceMs = 100) {
const [width, setWidth] = useState<number>(0);
useEffect(() => {
if (!ref.current) return;
const element = ref.current;
setWidth(element.clientWidth);
const debouncedSetWidth = debounce((width: number) => {
setWidth(width);
}, debounceMs);
const resizeObserver = new ResizeObserver(([entry]) => {
debouncedSetWidth(entry.target.clientWidth);
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
debouncedSetWidth.cancel();
};
}, [ref]);
return width;
}
+1 -1
View File
@@ -2,4 +2,4 @@ export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/context";
export * from "./lib/resizing";
export * from "./hooks";
+123 -12
View File
@@ -1,25 +1,27 @@
import type { Table, ColumnSizingState } from "@tanstack/react-table";
import type { Header, Table, ColumnSizingState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
import { useState, useCallback, useEffect } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useDebouncedWidth } from "../hooks";
type UsePersistentColumnResizingProps<TData> = {
enabled: boolean;
table: Table<TData>;
identifier?: string;
tableContainerRef: React.RefObject<HTMLDivElement>;
};
function getLocalStorageKey(identifier: string) {
return `data-table-column-sizing-${identifier}`;
}
function loadColumnSizing(identifier: string) {
function loadColumnSizing(identifier: string): ColumnSizingState {
try {
return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}");
} catch (error) {
return {};
}
return {};
}
function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
@@ -28,12 +30,76 @@ function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
function getAdjustedColumnSizing<TData>({
headers,
containerWidth,
initialColumnSizing,
currentColumnSizing,
resizedColumns,
}: {
headers: Header<TData, unknown>[];
containerWidth: number;
initialColumnSizing: ColumnSizingState;
currentColumnSizing: ColumnSizingState;
resizedColumns: Set<string>;
}) {
// Return early if any column has auto width
const hasAutoWidthColumn = headers.some((header) => header.column.columnDef.meta?.autoWidth);
if (hasAutoWidthColumn) {
return currentColumnSizing;
}
const getColumnSize = (id: string) =>
resizedColumns.has(id) ? currentColumnSizing[id] : initialColumnSizing[id];
const isAdjustable = (header: Header<TData, unknown>) =>
header.column.columnDef.enableResizing !== false && !resizedColumns.has(header.id);
// Calculate how many columns can be adjusted
const numberOfAdjustableColumns = headers.filter(isAdjustable).length;
// Calculate widths and required adjustments
const totalColumnsWidth = headers.reduce((total, header) => total + getColumnSize(header.id), 0);
const widthToFill = Math.max(0, containerWidth - totalColumnsWidth);
const widthPerAdjustableColumn =
numberOfAdjustableColumns === 0 ? 0 : Math.floor(widthToFill / numberOfAdjustableColumns);
// Handle any leftover width (due to rounding)
const leftoverWidth = widthToFill - widthPerAdjustableColumn * numberOfAdjustableColumns;
// Build the new column sizing object
const newColumnSizing = headers.reduce((acc, header, index) => {
const baseWidth = getColumnSize(header.id);
const adjustmentWidth = isAdjustable(header) ? widthPerAdjustableColumn : 0;
const isLastColumn = index === headers.length - 1;
const extraWidth = isLastColumn ? leftoverWidth : 0;
acc[header.id] = baseWidth + adjustmentWidth + extraWidth;
return acc;
}, {} as ColumnSizingState);
return newColumnSizing;
}
function getPartialColumnSizing(columnSizing: ColumnSizingState, columnsToExtract: Set<string>) {
return Object.keys(columnSizing).reduce((acc, key) => {
if (columnsToExtract.has(key)) {
acc[key] = columnSizing[key];
}
return acc;
}, {} as ColumnSizingState);
}
export function usePersistentColumnResizing<TData>({
enabled,
table,
identifier,
tableContainerRef,
}: UsePersistentColumnResizingProps<TData>) {
const [_, setColumnSizing] = useState<ColumnSizingState>({});
const initialized = useRef(false);
const columnSizing = useRef<ColumnSizingState>({});
const initialColumnSizing = useRef<ColumnSizingState>({});
const resizedColumns = useRef<Set<string>>(new Set());
const onColumnSizingChange = useCallback(
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
@@ -41,10 +107,24 @@ export function usePersistentColumnResizing<TData>({
// but TS doesn't know that, and this won't happen.
if (!identifier) return;
const isResizingColumn = table.getState().columnSizingInfo.isResizingColumn;
if (isResizingColumn) {
resizedColumns.current.add(isResizingColumn);
}
table.setState((oldTableState) => {
const newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
debouncedSaveColumnSizing(identifier, newColumnSizing);
setColumnSizing(newColumnSizing);
let newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
newColumnSizing = getAdjustedColumnSizing({
headers: table.getFlatHeaders(),
containerWidth: tableContainerRef.current?.clientWidth || 0,
initialColumnSizing: initialColumnSizing.current,
currentColumnSizing: newColumnSizing,
resizedColumns: resizedColumns.current,
});
debouncedSaveColumnSizing(
identifier,
getPartialColumnSizing(newColumnSizing, resizedColumns.current)
);
columnSizing.current = newColumnSizing;
return {
...oldTableState,
@@ -55,19 +135,50 @@ export function usePersistentColumnResizing<TData>({
[identifier, table]
);
useEffect(() => {
if (!enabled || !identifier) return;
const debouncedContainerWidth = useDebouncedWidth(tableContainerRef);
const newColumnSizing = loadColumnSizing(identifier);
setColumnSizing(newColumnSizing);
useEffect(() => {
if (!enabled || !identifier || !initialized.current) return;
const newColumnSizing = getAdjustedColumnSizing({
headers: table.getFlatHeaders(),
containerWidth: debouncedContainerWidth,
initialColumnSizing: initialColumnSizing.current,
currentColumnSizing: columnSizing.current,
resizedColumns: resizedColumns.current,
});
columnSizing.current = newColumnSizing;
table.setState((old) => ({
...old,
columnSizing: newColumnSizing,
}));
}, [debouncedContainerWidth]);
useEffect(() => {
if (!enabled || !identifier) return;
// loadedColumnSizing is a partial object of explicitly resized columns.
const loadedColumnSizing = loadColumnSizing(identifier);
// combine loaded sizes and the default sizes from TanStack Table
initialColumnSizing.current = table.getFlatHeaders().reduce((acc, header) => {
acc[header.id] = loadedColumnSizing[header.id] || header.getSize();
return acc;
}, {} as ColumnSizingState);
columnSizing.current = loadedColumnSizing;
resizedColumns.current = new Set(Object.keys(loadedColumnSizing));
table.setState((old) => ({
...old,
columnSizing: loadedColumnSizing,
}));
table.setOptions((prev) => ({
...prev,
columnResizeMode: "onChange",
onColumnSizingChange,
}));
initialized.current = true;
}, [enabled, identifier, table, onColumnSizingChange]);
}
@@ -295,6 +295,7 @@ function MemberListContent(props: Props) {
id: "select",
enableHiding: false,
enableSorting: false,
enableResizing: false,
size: 30,
header: ({ table }) => (
<Checkbox
@@ -402,7 +403,8 @@ function MemberListContent(props: Props) {
},
{
id: "actions",
size: 80,
size: 90,
enableResizing: false,
meta: {
sticky: { position: "right" },
},
@@ -659,6 +661,7 @@ function MemberListContent(props: Props) {
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
@@ -282,18 +282,21 @@ export function RoutingFormResponsesTableContent({
const columnFilters = useColumnFilters();
const { data: headers, isLoading: isHeadersLoading } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const {
data: headers,
isLoading: isHeadersLoading,
isSuccess: isHeadersSuccess,
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery(
{
userId: selectedUserId ?? undefined,
teamId: selectedTeamId ?? undefined,
isAll: isAll ?? false,
routingFormId: selectedRoutingFormId ?? undefined,
},
{
enabled: initialConfigIsReady,
}
);
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
@@ -323,7 +326,7 @@ export function RoutingFormResponsesTableContent({
const totalFetched = flatData.length;
const processedData = useMemo(() => {
if (isHeadersLoading) return [];
if (!isHeadersSuccess) return [];
return flatData.map((response) => {
const row: RoutingFormTableRow = {
id: response.id,
@@ -363,7 +366,7 @@ export function RoutingFormResponsesTableContent({
return row;
});
}, [flatData, headers, isHeadersLoading]);
}, [flatData, headers, isHeadersSuccess]);
const statusOrder: Record<BookingStatus, number> = {
[BookingStatus.ACCEPTED]: 1,
@@ -375,8 +378,12 @@ export function RoutingFormResponsesTableContent({
const columnHelper = createColumnHelper<RoutingFormTableRow>();
const columns = useMemo(
() => [
const columns = useMemo(() => {
if (!isHeadersSuccess) {
return [];
}
return [
columnHelper.accessor("routedToBooking", {
id: "bookedBy",
header: t("routing_form_insights_booked_by"),
@@ -412,7 +419,6 @@ export function RoutingFormResponsesTableContent({
return columnHelper.accessor(fieldHeader.id, {
id: fieldHeader.id,
header: convertToTitleCase(fieldHeader.label),
size: 200,
cell: (info) => {
const values = info.getValue();
if (isMultiSelect) {
@@ -456,7 +462,6 @@ export function RoutingFormResponsesTableContent({
columnHelper.accessor("routedToBooking", {
id: "bookingStatus",
header: t("routing_form_insights_booking_status"),
size: 250,
cell: (info) => (
<div className="max-w-[250px]">
<BookingStatusBadge booking={info.getValue()} />
@@ -480,7 +485,6 @@ export function RoutingFormResponsesTableContent({
columnHelper.accessor("routedToBooking", {
id: "bookingAt",
header: t("routing_form_insights_booking_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="max-w-[250px]">
@@ -496,7 +500,6 @@ export function RoutingFormResponsesTableContent({
columnHelper.accessor("routedToBooking", {
id: "assignmentReason",
header: t("routing_form_insights_assignment_reason"),
size: 250,
meta: {
filter: { type: "text" },
},
@@ -515,7 +518,6 @@ export function RoutingFormResponsesTableContent({
columnHelper.accessor("createdAt", {
id: "submittedAt",
header: t("routing_form_insights_submitted_at"),
size: 250,
enableColumnFilter: false,
cell: (info) => (
<div className="whitespace-nowrap">
@@ -523,9 +525,8 @@ export function RoutingFormResponsesTableContent({
</div>
),
}),
],
[headers, t, copyToClipboard]
);
];
}, [isHeadersSuccess, headers, t, copyToClipboard]);
const table = useReactTable<RoutingFormTableRow>({
data: processedData,
@@ -534,7 +535,7 @@ export function RoutingFormResponsesTableContent({
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
defaultColumn: {
size: 200,
size: 150,
},
state: {
columnFilters,
@@ -118,7 +118,7 @@ function UserListTableContent() {
const { data: session } = useSession();
const { isPlatformUser } = useGetUserAttributes();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
const { data: attributes } = trpc.viewer.attributes.list.useQuery();
const { data: attributes, isSuccess: isSuccessAttributes } = trpc.viewer.attributes.list.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery();
@@ -163,7 +163,6 @@ function UserListTableContent() {
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[];
const totalFetched = flatData.length;
const memorisedColumns = useMemo(() => {
const permissions = {
@@ -543,6 +542,11 @@ function UserListTableContent() {
}
};
if (!isSuccessAttributes) {
// do not render the table until the attributes are fetched
return null;
}
return (
<>
<DataTable