diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 9a2e8fec7d..cc6215208f 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -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 = { @@ -40,8 +41,8 @@ export function DataTable({ enableColumnResizing, ...rest }: DataTableProps & 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({ } }, [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({ @@ -132,7 +120,7 @@ export function DataTable({ 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" )}>
({ "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())} diff --git a/packages/features/data-table/components/index.ts b/packages/features/data-table/components/index.ts index 059d7cc6a4..8467f5f68d 100644 --- a/packages/features/data-table/components/index.ts +++ b/packages/features/data-table/components/index.ts @@ -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"; diff --git a/packages/features/data-table/hooks/index.ts b/packages/features/data-table/hooks/index.ts new file mode 100644 index 0000000000..b1350185c2 --- /dev/null +++ b/packages/features/data-table/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useDebouncedWidth"; +export * from "./useFetchMoreOnBottomReached"; +export * from "./useColumnSizingVars"; diff --git a/packages/features/data-table/hooks/useColumnSizingVars.ts b/packages/features/data-table/hooks/useColumnSizingVars.ts new file mode 100644 index 0000000000..b966afc511 --- /dev/null +++ b/packages/features/data-table/hooks/useColumnSizingVars.ts @@ -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 = ({ table }: { table: Table }) => { + 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]); +}; diff --git a/packages/features/data-table/hooks/useDebouncedWidth.ts b/packages/features/data-table/hooks/useDebouncedWidth.ts new file mode 100644 index 0000000000..11019e03c3 --- /dev/null +++ b/packages/features/data-table/hooks/useDebouncedWidth.ts @@ -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, debounceMs = 100) { + const [width, setWidth] = useState(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; +} diff --git a/packages/features/data-table/components/useFetchMoreOnBottomReached.ts b/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts similarity index 100% rename from packages/features/data-table/components/useFetchMoreOnBottomReached.ts rename to packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 7ab099a4f8..474b7f614f 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -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"; diff --git a/packages/features/data-table/lib/resizing.ts b/packages/features/data-table/lib/resizing.ts index 50906cdbbb..3cf5f4dfe3 100644 --- a/packages/features/data-table/lib/resizing.ts +++ b/packages/features/data-table/lib/resizing.ts @@ -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 = { enabled: boolean; table: Table; identifier?: string; + tableContainerRef: React.RefObject; }; 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({ + headers, + containerWidth, + initialColumnSizing, + currentColumnSizing, + resizedColumns, +}: { + headers: Header[]; + containerWidth: number; + initialColumnSizing: ColumnSizingState; + currentColumnSizing: ColumnSizingState; + resizedColumns: Set; +}) { + // 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) => + 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) { + return Object.keys(columnSizing).reduce((acc, key) => { + if (columnsToExtract.has(key)) { + acc[key] = columnSizing[key]; + } + return acc; + }, {} as ColumnSizingState); +} + export function usePersistentColumnResizing({ enabled, table, identifier, + tableContainerRef, }: UsePersistentColumnResizingProps) { - const [_, setColumnSizing] = useState({}); + const initialized = useRef(false); + const columnSizing = useRef({}); + const initialColumnSizing = useRef({}); + const resizedColumns = useRef>(new Set()); const onColumnSizingChange = useCallback( (updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => { @@ -41,10 +107,24 @@ export function usePersistentColumnResizing({ // 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({ [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]); } diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 510bb825b7..277702ef3a 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -295,6 +295,7 @@ function MemberListContent(props: Props) { id: "select", enableHiding: false, enableSorting: false, + enableResizing: false, size: 30, header: ({ table }) => ( fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 55c326274f..06f9443dcc 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -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.ACCEPTED]: 1, @@ -375,8 +378,12 @@ export function RoutingFormResponsesTableContent({ const columnHelper = createColumnHelper(); - 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) => (
@@ -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) => (
@@ -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) => (
@@ -523,9 +525,8 @@ export function RoutingFormResponsesTableContent({
), }), - ], - [headers, t, copyToClipboard] - ); + ]; + }, [isHeadersSuccess, headers, t, copyToClipboard]); const table = useReactTable({ data: processedData, @@ -534,7 +535,7 @@ export function RoutingFormResponsesTableContent({ getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), defaultColumn: { - size: 200, + size: 150, }, state: { columnFilters, diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 509e256608..42969fb560 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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 ( <>