From 7eee2728a27c4bf96a1f0cff06d163dbee12ecaf Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Tue, 11 Mar 2025 09:55:41 +0100 Subject: [PATCH] feat: implement standard pagination for org member list (#19802) * feat: implement standard pagination for org member list * fix type error * i18n for pagination * add paginationMode * apply the change to PlatformManagedUsersTable * replace useInfiniteQuery with useQuery * fix type error * fix type error * fix type error * fix type error * update comment * remove optimistic update * minor changes * update usage on nuqs --------- Co-authored-by: Benny Joo --- .../bookings/views/bookings-listing-view.tsx | 1 + apps/web/public/static/locales/en/common.json | 2 + apps/web/test/lib/generateCsv.test.ts | 44 ++++- .../data-table/components/DataTable.tsx | 155 ++++++++++-------- .../components/DataTablePagination.tsx | 46 ++++-- .../components/DataTableWrapper.tsx | 57 +++++-- .../hooks/useFetchMoreOnBottomReached.ts | 8 +- packages/features/data-table/lib/context.tsx | 35 +++- .../ee/teams/components/MemberList.tsx | 4 +- .../ee/users/components/UsersTable.tsx | 6 +- .../components/RoutingFormResponsesTable.tsx | 1 + .../outOfOffice/OutOfOfficeEntriesList.tsx | 8 +- .../components/AvailabilitySliderTable.tsx | 6 +- .../UserTable/BulkActions/DeleteBulkUsers.tsx | 17 +- .../BulkActions/MassAssignAttributes.tsx | 73 --------- .../UserTable/DeleteMemberModal.tsx | 17 +- .../UserTable/PlatformManagedUsersTable.tsx | 104 +++++------- .../components/UserTable/UserListTable.tsx | 141 ++++++++-------- .../users/components/UserTable/types.ts | 28 +--- packages/lib/csvUtils.ts | 2 +- .../routers/viewer/organizations/_router.tsx | 4 +- .../organizations/listMembers.handler.ts | 16 +- .../organizations/listMembers.schema.ts | 6 +- .../ui/components/pagination/Pagination.tsx | 10 +- 24 files changed, 403 insertions(+), 388 deletions(-) diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index fe3979d208..5c7372262b 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -389,6 +389,7 @@ function BookingsContent({ status }: BookingsProps) { fetchNextPage={query.fetchNextPage} isFetching={query.isFetching} variant="compact" + paginationMode="infinite" ToolbarLeft={ <> diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b902a0b18d..7083b22731 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2990,6 +2990,8 @@ "limited_access_trial_mode": "Limited access during trial. Feature available after trial ends.", "uid": "UID", "link": "Link", + "rows_per_page": "rows per page", + "pagination_status": "{{currentRange}} of {{totalItems}}", "tip_username_plus": "Tip: You can a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting", "user_has_no_team_yet": "You don't have a team yet", "no_team_members": "You don't have team members yet", diff --git a/apps/web/test/lib/generateCsv.test.ts b/apps/web/test/lib/generateCsv.test.ts index fe4967efb4..3c0b1309f6 100644 --- a/apps/web/test/lib/generateCsv.test.ts +++ b/apps/web/test/lib/generateCsv.test.ts @@ -91,7 +91,17 @@ describe("generate Csv for Org Users Table", () => { { ...mockUser, teams: [{ id: 1, name: "Team1", slug: "team1" }], - attributes: [{ id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] }], + attributes: [ + { + id: "1", + attributeId: "attr1", + value: "value1", + slug: "slug1", + contains: [], + weight: 0, + isGroup: false, + }, + ], }, ]; @@ -115,8 +125,24 @@ describe("generate Csv for Org Users Table", () => { ...mockUser, teams: [{ id: 1, name: "Team1", slug: "team1" }], attributes: [ - { id: "1", attributeId: "attr1", value: "value1", slug: "slug1", contains: [] }, - { id: "2", attributeId: "attr1", value: "value2", slug: "slug1", contains: [] }, + { + id: "1", + attributeId: "attr1", + value: "value1", + slug: "slug1", + contains: [], + weight: 0, + isGroup: false, + }, + { + id: "2", + attributeId: "attr1", + value: "value2", + slug: "slug1", + contains: [], + weight: 0, + isGroup: false, + }, ], }, ]; @@ -166,7 +192,17 @@ describe("generate Csv for Org Users Table", () => { { ...mockUser, teams: [{ id: 1, name: "Team,1", slug: "team1" }], - attributes: [{ id: "1", attributeId: "attr1", value: "value,1", slug: "slug1", contains: [] }], + attributes: [ + { + id: "1", + attributeId: "attr1", + value: "value,1", + slug: "slug1", + contains: [], + weight: 0, + isGroup: false, + }, + ], }, ]; diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 1e741e1f47..89b1bedfe3 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -2,12 +2,12 @@ import type { Row } from "@tanstack/react-table"; import { flexRender } from "@tanstack/react-table"; -import type { Table as ReactTableType, Header } from "@tanstack/react-table"; -import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual"; +import type { Table as ReactTableType, Header, HeaderGroup } from "@tanstack/react-table"; +import { useVirtualizer, type Virtualizer, type VirtualItem } from "@tanstack/react-virtual"; // eslint-disable-next-line no-restricted-imports import kebabCase from "lodash/kebabCase"; import { usePathname } from "next/navigation"; -import { useEffect, useState, memo } from "react"; +import { useEffect, useState, memo, useMemo } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { @@ -30,7 +30,7 @@ import classNames from "@calcom/ui/classNames"; import { useColumnSizingVars } from "../hooks"; import { usePersistentColumnResizing } from "../lib/resizing"; -export type DataTableProps = { +export type DataTableProps = { table: ReactTableType; tableContainerRef: React.RefObject; isPending?: boolean; @@ -46,9 +46,10 @@ export type DataTableProps = { enableColumnResizing?: boolean; className?: string; containerClassName?: string; + paginationMode?: "infinite" | "standard"; }; -export function DataTable({ +export function DataTable({ table, tableContainerRef, isPending, @@ -63,19 +64,18 @@ export function DataTable({ bodyTestId, className, containerClassName, + paginationMode = "infinite", ...rest -}: DataTableProps & React.ComponentPropsWithoutRef<"div">) { +}: DataTableProps & React.ComponentPropsWithoutRef<"div">) { const pathname = usePathname() as string | null; const identifier = _identifier ?? pathname ?? undefined; const { rows } = table.getRowModel(); - // https://stackblitz.com/github/tanstack/table/tree/main/examples/react/virtualized-infinite-scrolling const rowVirtualizer = useVirtualizer({ count: rows.length, estimateSize: () => 100, getScrollElement: () => tableContainerRef.current, - // measure dynamic row height, except in firefox because it measures table border height incorrectly measureElement: typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element) => element?.getBoundingClientRect().height @@ -83,8 +83,10 @@ export function DataTable({ overscan: 10, }); + const virtualItemsCount = rowVirtualizer.getVirtualItems().length; + useEffect(() => { - if (rowVirtualizer.getVirtualItems().length >= rows.length && tableContainerRef.current) { + if (paginationMode === "infinite" && virtualItemsCount >= rows.length && tableContainerRef.current) { const target = tableContainerRef.current; // Right after the last row is rendered, tableContainer's scrollHeight is // temporarily larger than the actual height of the table, so we need to @@ -94,7 +96,7 @@ export function DataTable({ onScroll?.({ target }); }, 100); } - }, [rowVirtualizer.getVirtualItems().length, rows.length, tableContainerRef.current]); + }, [virtualItemsCount, rows.length, tableContainerRef.current, paginationMode, onScroll]); const columnSizingVars = useColumnSizingVars({ table }); @@ -131,8 +133,9 @@ export function DataTable({ ref={tableContainerRef} onScroll={onScroll} className={classNames( - "relative h-[80dvh] overflow-auto", // Set a fixed height for the container + "relative overflow-auto", "scrollbar-thin border-subtle relative rounded-md border", + paginationMode === "infinite" && "h-[80dvh]", // Set a fixed height for the container containerClassName )} style={{ gridArea: "body" }}> @@ -144,9 +147,9 @@ export function DataTable({ }}> {!hideHeader && ( - {table.getHeaderGroups().map((headerGroup) => ( + {table.getHeaderGroups().map((headerGroup: HeaderGroup) => ( - {headerGroup.headers.map((header) => { + {headerGroup.headers.map((header: Header) => { const { column } = header; return ( ({ variant={variant} isPending={isPending} onRowMouseclick={onRowMouseclick} + paginationMode={paginationMode} /> ) : ( ({ variant={variant} isPending={isPending} onRowMouseclick={onRowMouseclick} + paginationMode={paginationMode} /> )} @@ -218,7 +223,8 @@ const MemoizedTableBody = memo( prev.testId === next.testId && prev.variant === next.variant && prev.isPending === next.isPending && - prev.onRowMouseclick === next.onRowMouseclick + prev.onRowMouseclick === next.onRowMouseclick && + prev.paginationMode === next.paginationMode ) as typeof DataTableBody; type DataTableBodyProps = { @@ -229,6 +235,12 @@ type DataTableBodyProps = { variant?: "default" | "compact"; isPending?: boolean; onRowMouseclick?: (row: Row) => void; + paginationMode?: "infinite" | "standard"; +}; + +type RowToRender = { + row: Row; + virtualItem?: VirtualItem; }; function DataTableBody({ @@ -239,60 +251,75 @@ function DataTableBody({ 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", "group")}> - {row.getVisibleCells().map((cell) => { - const column = cell.column; - return ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - - ); - }) - ) : ( + paginationMode, +}: DataTableBodyProps & { paginationMode?: "infinite" | "standard" }) { + const { t } = useLocale(); + const virtualItems = rowVirtualizer.getVirtualItems(); + const tableHeight = paginationMode === "infinite" ? rowVirtualizer.getTotalSize() : "auto"; + + const rowsToRender = useMemo[]>( + () => + paginationMode === "infinite" + ? virtualItems.map((virtualItem) => ({ + row: rows[virtualItem.index] as Row, + virtualItem, + })) + : rows.map((row) => ({ row })), + [paginationMode, virtualItems, rows] + ); + + if (!isPending && rowsToRender.length === 0) { + return ( + - No results. + {t("no_results")} - )} + + ); + } + + return ( + + {rowsToRender.map(({ row, virtualItem }) => ( + rowVirtualizer.measureElement(node) : undefined} + key={row.id} + data-index={virtualItem?.index} //needed for dynamic row height measurement + data-state={row.getIsSelected() && "selected"} + onClick={() => onRowMouseclick && onRowMouseclick(row)} + style={{ + display: "flex", + ...(virtualItem && { + position: "absolute", + transform: `translateY(${virtualItem.start}px)`, + width: "100%", + }), + }} + className={classNames(onRowMouseclick && "hover:cursor-pointer", "group")}> + {row.getVisibleCells().map((cell) => { + const column = cell.column; + return ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + + ))} ); } diff --git a/packages/features/data-table/components/DataTablePagination.tsx b/packages/features/data-table/components/DataTablePagination.tsx index 6e333b60a4..42da0519cc 100644 --- a/packages/features/data-table/components/DataTablePagination.tsx +++ b/packages/features/data-table/components/DataTablePagination.tsx @@ -1,19 +1,45 @@ "use client"; -import type { Table } from "@tanstack/react-table"; +import { type Table } from "@tanstack/react-table"; + +import { Pagination } from "@calcom/ui"; + +import { useDataTable } from "../hooks"; interface DataTablePaginationProps { table: Table; - totalDbDataCount: number; + totalRowCount: number; + paginationMode?: "infinite" | "standard"; } -export function DataTablePagination({ table, totalDbDataCount }: DataTablePaginationProps) { - const loadedCount = table.getFilteredRowModel().rows.length; +export function DataTablePagination({ + table, + totalRowCount, + paginationMode = "infinite", +}: DataTablePaginationProps) { + const { pageIndex, pageSize, setPageIndex, setPageSize } = useDataTable(); - return ( -

- Loaded {`${loadedCount}`} of - {`${totalDbDataCount}`} -

- ); + if (paginationMode === "infinite") { + const loadedCount = table.getFilteredRowModel().rows.length; + return ( +

+ Loaded {loadedCount} of{" "} + {totalRowCount} +

+ ); + } else if (paginationMode === "standard") { + return ( + setPageIndex(page - 1)} + onPageSizeChange={(newSize) => setPageSize(newSize)} + onNext={() => setPageIndex(pageIndex + 1)} + onPrevious={() => setPageIndex(pageIndex - 1)} + /> + ); + } else { + return null; + } } diff --git a/packages/features/data-table/components/DataTableWrapper.tsx b/packages/features/data-table/components/DataTableWrapper.tsx index 6c36ad6ddd..d22675ea71 100644 --- a/packages/features/data-table/components/DataTableWrapper.tsx +++ b/packages/features/data-table/components/DataTableWrapper.tsx @@ -1,6 +1,8 @@ "use client"; import type { Table as ReactTableType, VisibilityState } from "@tanstack/react-table"; +// eslint-disable-next-line no-restricted-imports +import { noop } from "lodash"; import { useEffect, useRef } from "react"; import { @@ -12,17 +14,14 @@ import { } from "@calcom/features/data-table"; import classNames from "@calcom/ui/classNames"; -export type DataTableWrapperProps = { +type BaseDataTableWrapperProps = { testId?: string; bodyTestId?: string; table: ReactTableType; isPending: boolean; - hasNextPage: boolean; - fetchNextPage: () => void; - isFetching: boolean; hideHeader?: boolean; variant?: "default" | "compact"; - totalDBRowCount?: number; + totalRowCount?: number; ToolbarLeft?: React.ReactNode; ToolbarRight?: React.ReactNode; EmptyView?: React.ReactNode; @@ -33,7 +32,23 @@ export type DataTableWrapperProps = { tableContainerRef?: React.RefObject; }; -export function DataTableWrapper({ +type InfinitePaginationProps = BaseDataTableWrapperProps & { + paginationMode: "infinite"; + hasNextPage: boolean; + fetchNextPage: () => void; + isFetching: boolean; +}; + +type StandardPaginationProps = BaseDataTableWrapperProps & { + paginationMode: "standard"; + hasNextPage?: never; + fetchNextPage?: never; + isFetching?: never; +}; + +export type DataTableWrapperProps = InfinitePaginationProps | StandardPaginationProps; + +export function DataTableWrapper({ testId, bodyTestId, table, @@ -41,7 +56,7 @@ export function DataTableWrapper({ hasNextPage, fetchNextPage, isFetching, - totalDBRowCount, + totalRowCount, variant, hideHeader, ToolbarLeft, @@ -52,14 +67,16 @@ export function DataTableWrapper({ containerClassName, children, tableContainerRef: externalRef, -}: DataTableWrapperProps) { + paginationMode, +}: DataTableWrapperProps) { const internalRef = useRef(null); const tableContainerRef = externalRef || internalRef; const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({ tableContainerRef, - hasNextPage, - fetchNextPage, - isFetching, + hasNextPage: paginationMode === "infinite" ? hasNextPage : false, + fetchNextPage: paginationMode === "infinite" ? fetchNextPage : noop, + isFetching: paginationMode === "infinite" ? isFetching : false, + enabled: paginationMode === "infinite", }); const { sorting, setSorting, columnVisibility, setColumnVisibility } = useDataTable(); const columnFilters = useColumnFilters(); @@ -81,7 +98,7 @@ export function DataTableWrapper({ onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, })); - }, [table, sorting, columnFilters, columnVisibility]); + }, [table, sorting, columnFilters, columnVisibility, setSorting, setColumnVisibility]); let view: "loader" | "empty" | "table" = "table"; if (isPending && LoaderView) { @@ -118,10 +135,20 @@ export function DataTableWrapper({ variant={variant} className={className} containerClassName={containerClassName} - onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}> - {totalDBRowCount && ( + paginationMode={paginationMode} + onScroll={ + paginationMode === "infinite" + ? (e: Pick, "target">) => + fetchMoreOnBottomReached(e.target as HTMLDivElement) + : undefined + }> + {totalRowCount && (
- + + table={table} + totalRowCount={totalRowCount} + paginationMode={paginationMode} + />
)} diff --git a/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts b/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts index fbcf61ba84..8ffd331708 100644 --- a/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts +++ b/packages/features/data-table/hooks/useFetchMoreOnBottomReached.ts @@ -7,14 +7,17 @@ export const useFetchMoreOnBottomReached = ({ hasNextPage, fetchNextPage, isFetching, + enabled = true, }: { tableContainerRef: React.RefObject; hasNextPage: boolean; fetchNextPage: () => void; isFetching: boolean; + enabled?: boolean; }) => { const fetchMoreOnBottomReached = useCallback( (containerRefElement?: HTMLDivElement | null) => { + if (!enabled) return; if (containerRefElement) { const { scrollHeight, scrollTop, clientHeight } = containerRefElement; if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && hasNextPage) { @@ -22,12 +25,13 @@ export const useFetchMoreOnBottomReached = ({ } } }, - [fetchNextPage, isFetching] + [fetchNextPage, isFetching, hasNextPage, enabled] ); useEffect(() => { + if (!enabled) return; fetchMoreOnBottomReached(tableContainerRef.current); - }, [fetchMoreOnBottomReached, tableContainerRef]); + }, [fetchMoreOnBottomReached, tableContainerRef, enabled]); return fetchMoreOnBottomReached; }; diff --git a/packages/features/data-table/lib/context.tsx b/packages/features/data-table/lib/context.tsx index 6f65f4d581..285c4ba150 100644 --- a/packages/features/data-table/lib/context.tsx +++ b/packages/features/data-table/lib/context.tsx @@ -1,7 +1,7 @@ "use client"; import type { SortingState, OnChangeFn, VisibilityState } from "@tanstack/react-table"; -import { useQueryState, parseAsArrayOf, parseAsJson } from "nuqs"; +import { useQueryState, parseAsArrayOf, parseAsJson, parseAsInteger } from "nuqs"; import { createContext, useCallback } from "react"; import { z } from "zod"; @@ -26,6 +26,14 @@ export type DataTableContextType = { columnVisibility: VisibilityState; setColumnVisibility: OnChangeFn; + + pageIndex: number; + pageSize: number; + setPageIndex: (pageIndex: number) => void; + setPageSize: (pageSize: number) => void; + + offset: number; + limit: number; }; export const DataTableContext = createContext(null); @@ -33,8 +41,14 @@ export const DataTableContext = createContext(null) const DEFAULT_ACTIVE_FILTERS: ActiveFilter[] = []; const DEFAULT_SORTING: SortingState = []; const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {}; +const DEFAULT_PAGE_SIZE = 10; -export function DataTableProvider({ children }: { children: React.ReactNode }) { +interface DataTableProviderProps { + children: React.ReactNode; + defaultPageSize?: number; +} + +export function DataTableProvider({ children, defaultPageSize = DEFAULT_PAGE_SIZE }: DataTableProviderProps) { const [activeFilters, setActiveFilters] = useQueryState( "activeFilters", parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(DEFAULT_ACTIVE_FILTERS) @@ -48,6 +62,9 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) { parseAsJson(ZColumnVisibility.parse).withDefault(DEFAULT_COLUMN_VISIBILITY) ); + const [pageIndex, setPageIndex] = useQueryState("page", parseAsInteger.withDefault(0)); + const [pageSize, setPageSize] = useQueryState("size", parseAsInteger.withDefault(defaultPageSize)); + const clearAll = useCallback( (exclude?: string[]) => { setActiveFilters((prev) => prev.filter((filter) => exclude?.includes(filter.f))); @@ -82,6 +99,14 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) { [setActiveFilters] ); + const setPageSizeAndGoToFirstPage = useCallback( + (newPageSize: number) => { + setPageSize(newPageSize); + setPageIndex(0); + }, + [setPageSize, setPageIndex] + ); + return ( {children} diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index fa823826d8..7da6faeae9 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -286,7 +286,7 @@ function MemberListContent(props: Props) { isOrg: checkIsOrg(props.team), }); - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const memorisedColumns = useMemo(() => { const cols: ColumnDef[] = [ @@ -618,7 +618,7 @@ function MemberListContent(props: Props) { ]; return cols; - }, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]); + }, [props.isOrgAdminOrOwner, dispatch, totalRowCount, session?.user.id]); //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[]; diff --git a/packages/features/ee/users/components/UsersTable.tsx b/packages/features/ee/users/components/UsersTable.tsx index 60669a5886..2587c48661 100644 --- a/packages/features/ee/users/components/UsersTable.tsx +++ b/packages/features/ee/users/components/UsersTable.tsx @@ -126,7 +126,7 @@ function UsersTableBare() { //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]); - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const totalFetched = flatData.length; //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table @@ -135,12 +135,12 @@ function UsersTableBare() { if (containerRefElement) { const { scrollHeight, scrollTop, clientHeight } = containerRefElement; //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any - if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalRowCount) { fetchNextPage(); } } }, - [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + [fetchNextPage, isFetching, totalFetched, totalRowCount] ); useEffect(() => { diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index daa2a4a09c..cc62b9d828 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -131,6 +131,7 @@ export function RoutingFormResponsesTable() { hasNextPage={hasNextPage} fetchNextPage={fetchNextPage} isFetching={isFetching} + paginationMode="infinite" ToolbarLeft={ <> diff --git a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx index accc2fe627..77d8e3cc40 100644 --- a/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx +++ b/packages/features/settings/outOfOffice/OutOfOfficeEntriesList.tsx @@ -73,7 +73,7 @@ export const OutOfOfficeEntriesList = () => { refetch(); }, [deletedEntry, selectedTab, refetch]); - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const flatData = useMemo( () => isPending || isFetching ? new Array(5).fill(null) : data?.pages?.flatMap((page) => page.rows) ?? [], @@ -127,7 +127,7 @@ export const OutOfOfficeEntriesList = () => { } columns.push({ id: "outOfOffice", - header: `${t("out_of_office")} (${totalDBRowCount})`, + header: `${t("out_of_office")} (${totalRowCount})`, size: selectedTab === OutOfOfficeTab.TEAM ? 370 : 660, cell: ({ row }) => { const item = row.original; @@ -261,7 +261,7 @@ export const OutOfOfficeEntriesList = () => { (containerRefElement?: HTMLDivElement | null) => { if (containerRefElement) { const { scrollHeight, scrollTop, clientHeight } = containerRefElement; - if (scrollHeight - scrollTop - clientHeight < 100 && !isFetching && totalFetched < totalDBRowCount) { + if (scrollHeight - scrollTop - clientHeight < 100 && !isFetching && totalFetched < totalRowCount) { fetchNextPage(); } if (isFetching) { @@ -271,7 +271,7 @@ export const OutOfOfficeEntriesList = () => { } } }, - [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + [fetchNextPage, isFetching, totalFetched, totalRowCount] ); const table = useReactTable({ diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index 9c07735a84..9eb5d8d513 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -192,7 +192,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null; //we must flatten the array of arrays from the useInfiniteQuery hook const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as SliderUser[]; - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const totalFetched = flatData.length; //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table @@ -201,12 +201,12 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null; if (containerRefElement) { const { scrollHeight, scrollTop, clientHeight } = containerRefElement; //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any - if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalRowCount) { fetchNextPage(); } } }, - [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + [fetchNextPage, isFetching, totalFetched, totalRowCount] ); useEffect(() => { diff --git a/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx index 4dc1825436..379a3b2577 100644 --- a/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx +++ b/packages/features/users/components/UserTable/BulkActions/DeleteBulkUsers.tsx @@ -15,22 +15,9 @@ export function DeleteBulkUsers({ users, onRemove }: Props) { const selectedRows = users; // Get selected rows from table const utils = trpc.useUtils(); const deleteMutation = trpc.viewer.organizations.bulkDeleteUsers.useMutation({ - onSuccess: (_, { userIds }) => { + onSuccess: () => { showToast("Deleted Users", "success"); - utils.viewer.organizations.listMembers.setInfiniteData( - { limit: 10, searchTerm: "", expand: ["attributes"] }, - // @ts-expect-error - infinite data types are not correct - (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - rows: page.rows.filter((user) => !userIds.includes(user.id)), - })), - }; - } - ); + utils.viewer.organizations.listMembers.invalidate(); }, onError: (error) => { showToast(error.message, "error"); diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx index ff7abccabb..6fe9a7a32a 100644 --- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx +++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx @@ -4,7 +4,6 @@ import type { Dispatch, SetStateAction } from "react"; import { DataTableSelectionBar, type ColumnFilter } from "@calcom/features/data-table"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import slugify from "@calcom/lib/slugify"; import type { Attribute as _Attribute, AttributeOption } from "@calcom/prisma/client"; import { trpc } from "@calcom/trpc"; import { @@ -241,78 +240,6 @@ function MassAssignAttributesBulkActionComponent({ table, filters }: Props) { const utils = trpc.useUtils(); const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({ onSuccess: (success) => { - // Optimistically update the infinite query data - const selectedRows = table.getSelectedRowModel().flatRows; - - utils.viewer.organizations.listMembers.setInfiniteData( - { - limit: 10, - searchTerm: "", - expand: ["attributes"], - filters, - }, - // @ts-expect-error i really dont know how to type this - (oldData) => { - if (!oldData) { - return { - pages: [], - pageParams: [], - }; - } - - const newPages = oldData?.pages.map((page) => ({ - ...page, - rows: page.rows.map((row) => { - if (selectedRows.some((selectedRow) => selectedRow.original.id === row.id)) { - // Update the attributes for the selected users - - const attributeOptionValues = foundAttributeInCache?.options.filter((option) => - selectedAttributeOptions.includes(option.id) - ); - - const newAttributes = - row.attributes?.filter((attr) => attr.attributeId !== selectedAttribute) || []; - - if (attributeOptionValues && attributeOptionValues.length > 0) { - const newAttributeValues = attributeOptionValues?.map((value) => ({ - id: value.id, - attributeId: value.attributeId, - value: value.value, - slug: value.slug, - contains: value.contains, - isGroup: value.isGroup, - weight: 100, - })); - newAttributes.push(...newAttributeValues); - } else { - // Text or number input we don't have an option to fall back on - newAttributes.push({ - id: "-1", - attributeId: foundAttributeInCache?.id ?? "-1", - value: selectedAttributeOptions[0], - slug: slugify(selectedAttributeOptions[0]), - contains: [], - isGroup: false, - weight: 100, - }); - } - - return { - ...row, - attributes: newAttributes, - }; - } - return row; - }), - })); - - return { - ...oldData, - pages: newPages, - }; - } - ); - setSelectedAttribute(undefined); setSelectedAttributeOptions([]); utils.viewer.organizations.listMembers.invalidate(); diff --git a/packages/features/users/components/UserTable/DeleteMemberModal.tsx b/packages/features/users/components/UserTable/DeleteMemberModal.tsx index 2447eb9bda..de78451f70 100644 --- a/packages/features/users/components/UserTable/DeleteMemberModal.tsx +++ b/packages/features/users/components/UserTable/DeleteMemberModal.tsx @@ -19,20 +19,9 @@ export function DeleteMemberModal({ const utils = trpc.useUtils(); const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({ onSuccess() { - // @ts-expect-error rows can't be of type never[] but oldData can be due to the filter - utils.viewer.organizations.listMembers.setInfiniteData({ limit: 10, searchTerm: "" }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - rows: page.rows.filter((member) => member.id !== state.deleteMember.user?.id), - })), - }; - }); - - // Existing invalidations - Promise.all([utils.viewer.teams.get.invalidate(), utils.viewer.eventTypes.invalidate()]); + utils.viewer.organizations.listMembers.invalidate(); + utils.viewer.teams.get.invalidate(); + utils.viewer.eventTypes.invalidate(); showToast(t("success"), "success"); diff --git a/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx b/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx index b78c6dfbe9..cfb36e2b2f 100644 --- a/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx +++ b/packages/features/users/components/UserTable/PlatformManagedUsersTable.tsx @@ -2,17 +2,16 @@ import { keepPreviousData } from "@tanstack/react-query"; import { getCoreRowModel, getSortedRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table"; -import { useMemo, useReducer, useRef, useState } from "react"; +import { useMemo, useReducer, useState } from "react"; import { + DataTableWrapper, DataTableProvider, - DataTable, DataTableToolbar, - DataTableFilters, DataTableSelectionBar, - DataTablePagination, + DataTableFilters, useColumnFilters, - useFetchMoreOnBottomReached, + useDataTable, } from "@calcom/features/data-table"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -55,7 +54,7 @@ type PlatformManagedUsersTableProps = { export function PlatformManagedUsersTable(props: PlatformManagedUsersTableProps) { return ( - + ); @@ -64,36 +63,34 @@ export function PlatformManagedUsersTable(props: PlatformManagedUsersTableProps) function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) { const { t } = useLocale(); - const tableContainerRef = useRef(null); - const [state, dispatch] = useReducer(reducer, initialState); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); const [rowSelection, setRowSelection] = useState({}); const columnFilters = useColumnFilters(); - const { data, isPending, hasNextPage, fetchNextPage, isFetching } = - trpc.viewer.organizations.listMembers.useInfiniteQuery( - { - limit: 30, - searchTerm: debouncedSearchTerm, - filters: columnFilters, - oAuthClientId, - }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - placeholderData: keepPreviousData, - enabled: !!oAuthClientId, - } - ); + const { pageIndex, pageSize } = useDataTable(); + const limit = pageSize; + const offset = pageIndex * pageSize; - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const { data, isPending } = trpc.viewer.organizations.listMembers.useQuery( + { + limit, + offset, + searchTerm: debouncedSearchTerm, + filters: columnFilters, + oAuthClientId, + }, + { + placeholderData: keepPreviousData, + enabled: !!oAuthClientId, + } + ); + + const totalRowCount = data?.meta?.totalRowCount ?? 0; //we must flatten the array of arrays from the useInfiniteQuery hook - const flatData = useMemo( - () => data?.pages?.flatMap((page) => page.rows) ?? [], - [data] - ) as PlatformManagedUserTableUser[]; + const flatData = useMemo(() => data?.rows ?? [], [data]) as PlatformManagedUserTableUser[]; const columns = useMemo(() => { const cols: ColumnDef[] = [ @@ -293,46 +290,31 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) } } - const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({ - tableContainerRef, - hasNextPage, - fetchNextPage, - isFetching, - }); - const numberOfSelectedRows = useMemo(() => table.getSelectedRowModel().rows.length, [table]); return ( <> - fetchMoreOnBottomReached(e.target as HTMLDivElement)}> - -
-
- setDebouncedSearchTerm(value)} - className="sm:max-w-64 max-w-full" - /> -
-
- {/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */} - - -
-
-
- -
-
- -
- + totalRowCount={data?.meta?.totalRowCount} + paginationMode="standard" + ToolbarLeft={ + setDebouncedSearchTerm(value)} + className="sm:max-w-64 max-w-full" + /> + } + ToolbarRight={ + <> + + + + }> +
+
{numberOfSelectedRows > 0 && ( @@ -346,7 +328,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) /> )} - + {state.deleteMember.showModal && } ); diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 5746f5b5b4..a4cb586432 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -21,6 +21,7 @@ import { multiSelectFilter, ColumnFilterType, convertFacetedValuesToMap, + useDataTable, } from "@calcom/features/data-table"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -103,7 +104,7 @@ function reducer(state: UserTableState, action: UserTableAction): UserTableState export function UserListTable() { return ( - + ); @@ -117,10 +118,21 @@ function UserListTableContent() { const { data: session } = useSession(); const { isPlatformUser } = useGetUserAttributes(); - const { data: org } = trpc.viewer.organizations.listCurrent.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(); + const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const { data: attributes, isSuccess: isSuccessAttributes } = trpc.viewer.attributes.list.useQuery( + undefined, + { + refetchOnWindowFocus: false, + } + ); + const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(undefined, { + refetchOnWindowFocus: false, + }); + const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery(undefined, { + refetchOnWindowFocus: false, + }); const [state, dispatch] = useReducer(reducer, initialState); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); @@ -129,39 +141,27 @@ function UserListTableContent() { const columnFilters = useColumnFilters(); - const { data, isPending, hasNextPage, fetchNextPage, isFetching } = - trpc.viewer.organizations.listMembers.useInfiniteQuery( - { - limit: 30, - searchTerm: debouncedSearchTerm, - expand: ["attributes"], - filters: columnFilters, - }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - placeholderData: keepPreviousData, - } - ); + const { limit, offset } = useDataTable(); - const exportQuery = trpc.viewer.organizations.listMembers.useInfiniteQuery( + const { data, isPending } = trpc.viewer.organizations.listMembers.useQuery( { - limit: 100, // Max limit + limit, + offset, searchTerm: debouncedSearchTerm, expand: ["attributes"], filters: columnFilters, }, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - enabled: false, + placeholderData: keepPreviousData, } ); // TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded - const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; + const totalRowCount = data?.meta?.totalRowCount ?? 0; const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER"; //we must flatten the array of arrays from the useInfiniteQuery hook - const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[]; + const flatData = useMemo(() => data?.rows ?? [], [data]); const memorisedColumns = useMemo(() => { const permissions = { @@ -174,8 +174,8 @@ function UserListTableContent() { if (!attributes?.length) { return []; } - return ( - (attributes?.map((attribute) => { + const attributeColumns: ColumnDef[] = + attributes?.map((attribute) => { // TODO: We need to normalize AttributeOption table first // so that we can have `number_value` column for numeric operations. // Currently, `value` column is used for both text and number attributes. @@ -184,7 +184,7 @@ function UserListTableContent() { const isNumber = false; const isText = attribute.type === "TEXT"; const isSingleSelect = attribute.type === "SINGLE_SELECT"; - const isMultiSelect = attribute.type === "MULTI_SELECT"; + // const isMultiSelect = attribute.type === "MULTI_SELECT"; const filterType = isNumber ? ColumnFilterType.NUMBER : isText @@ -200,15 +200,15 @@ function UserListTableContent() { filter: { type: filterType }, }, size: 120, - accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value, + accessorFn: (data) => data.attributes?.find((attr) => attr.attributeId === attribute.id)?.value, cell: ({ row }) => { - const attributeValues = row.original.attributes.filter( + const attributeValues = row.original.attributes?.filter( (attr) => attr.attributeId === attribute.id ); - if (attributeValues.length === 0) return null; + if (attributeValues?.length === 0) return null; return (
- {attributeValues.map((attributeValue) => { + {attributeValues?.map((attributeValue) => { const isAGroupOption = attributeValue.contains?.length > 0; const suffix = attribute.isWeightsEnabled ? `${attributeValue.weight || 100}%` @@ -238,27 +238,22 @@ function UserListTableContent() { ); }, filterFn: (row, id, filterValue) => { - const attributeValues = row.original.attributes.filter((attr) => attr.attributeId === id); + const attributeValues = row.original.attributes?.filter((attr) => attr.attributeId === id); if (isTextFilterValue(filterValue)) { - return attributeValues.some((attr) => textFilter(attr.value, filterValue)); + return attributeValues?.some((attr) => textFilter(attr.value, filterValue)) ?? false; } else if (isSingleSelectFilterValue(filterValue)) { - return singleSelectFilter( - attributeValues.map((attr) => attr.value), - filterValue - ); + return singleSelectFilter(attributeValues?.map((attr) => attr.value) ?? [], filterValue); } else if (isMultiSelectFilterValue(filterValue)) { - return multiSelectFilter( - attributeValues.map((attr) => attr.value), - filterValue - ); + return multiSelectFilter(attributeValues?.map((attr) => attr.value) ?? [], filterValue); } return false; }, }; - }) as ColumnDef[]) ?? [] - ); + }) ?? []; + return attributeColumns; }; + const cols: ColumnDef[] = [ // Disabling select for this PR: Will work on actions etc in a follow up { @@ -430,14 +425,16 @@ function UserListTableContent() { ]; return cols; - }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount, attributes]); + }, [session?.user.id, adminOrOwner, dispatch, domain, attributes, org?.canAdminImpersonate]); const table = useReactTable({ data: flatData, columns: memorisedColumns, enableRowSelection: true, - debugTable: true, manualPagination: true, + state: { + rowSelection, + }, initialState: { columnVisibility: initalColumnVisibility, columnPinning: { @@ -448,9 +445,6 @@ function UserListTableContent() { defaultColumn: { size: 150, }, - state: { - rowSelection, - }, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), onRowSelectionChange: setRowSelection, @@ -489,6 +483,8 @@ function UserListTableContent() { }, }); + const utils = trpc.useUtils(); + const numberOfSelectedRows = table.getSelectedRowModel().rows.length; const handleDownload = async () => { @@ -502,30 +498,35 @@ function UserListTableContent() { throw new Error("Header is missing."); } - const result = await exportQuery.refetch(); - if (!result.data) { - throw new Error("There are no members found."); - } - const allMembers = result.data.pages.flatMap((page) => page.rows ?? []) ?? []; - let lastPage = result.data.pages[result.data.pages.length - 1]; + // Fetch all pages + let allRows: UserTableUser[] = []; + let offset: number | undefined = 0; + const limit = 100; - while (lastPage.nextCursor) { - const nextPage = await exportQuery.fetchNextPage(); - if (!nextPage.data) { - break; + while (offset !== undefined) { + const result = await utils.viewer.organizations.listMembers.fetch({ + limit, + offset, + searchTerm: debouncedSearchTerm, + expand: ["attributes"], + filters: columnFilters, + }); + + if (!result.rows?.length) { + offset = undefined; + continue; } - const latestPageItems = nextPage.data.pages[nextPage.data.pages.length - 1].rows ?? []; - allMembers.push(...latestPageItems); - lastPage = nextPage.data.pages[nextPage.data.pages.length - 1]; + + allRows = [...allRows, ...result.rows]; + offset = offset + limit; + } + + if (!allRows.length) { + throw new Error("There are no members found."); } const ATTRIBUTE_IDS = attributes?.map((attr) => attr.id) ?? []; - const csvRaw = generateCsvRawForMembersTable( - headers, - allMembers as UserTableUser[], - ATTRIBUTE_IDS, - domain - ); + const csvRaw = generateCsvRawForMembersTable(headers, allRows, ATTRIBUTE_IDS, domain); if (!csvRaw) { throw new Error("Generating CSV file failed."); } @@ -550,10 +551,8 @@ function UserListTableContent() { testId="user-list-data-table" table={table} isPending={isPending} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} - isFetching={isFetching} - totalDBRowCount={totalDBRowCount} + totalRowCount={data?.meta?.totalRowCount} + paginationMode="standard" ToolbarLeft={ <> setDebouncedSearchTerm(value)} /> diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts index cd92f7347d..4c0d27329a 100644 --- a/packages/features/users/components/UserTable/types.ts +++ b/packages/features/users/components/UserTable/types.ts @@ -1,30 +1,6 @@ -import type { MembershipRole } from "@calcom/prisma/enums"; +import type { RouterOutputs } from "@calcom/trpc"; -export interface UserTableUser { - id: number; - username: string | null; - email: string; - timeZone: string; - role: MembershipRole; - avatarUrl: string | null; - accepted: boolean; - disableImpersonation: boolean; - completedOnboarding: boolean; - lastActiveAt: string; - teams: { - id: number; - name: string; - slug: string | null; - }[]; - attributes: { - id: string; - attributeId: string; - value: string; - slug: string; - weight?: number | null; - contains: string[]; - }[]; -} +export type UserTableUser = RouterOutputs["viewer"]["organizations"]["listMembers"]["rows"][number]; export type PlatformManagedUserTableUser = Omit< UserTableUser, diff --git a/packages/lib/csvUtils.ts b/packages/lib/csvUtils.ts index 529a4dbd70..2238bb4674 100644 --- a/packages/lib/csvUtils.ts +++ b/packages/lib/csvUtils.ts @@ -117,7 +117,7 @@ export const generateCsvRawForMembersTable = ( const { email, role, teams, username, attributes } = row; // Create a map of attributeId to array of values - const attributeMap = attributes.reduce((acc, attr) => { + const attributeMap = (attributes ?? []).reduce((acc, attr) => { if (!acc[attr.attributeId]) { acc[attr.attributeId] = []; } diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 94c6e0d470..0781a51074 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -20,7 +20,7 @@ import { ZDeleteTeamInputSchema } from "./deleteTeam.schema"; import { ZGetMembersInput } from "./getMembers.schema"; import { ZGetOtherTeamInputSchema } from "./getOtherTeam.handler"; import { ZGetUserInput } from "./getUser.schema"; -import { ZListMembersSchema } from "./listMembers.schema"; +import { ZListMembersInputSchema } from "./listMembers.schema"; import { ZListOtherTeamMembersSchema } from "./listOtherTeamMembers.handler"; import { ZRemoveHostsFromEventTypes } from "./removeHostsFromEventTypes.schema"; import { ZSetPasswordSchema } from "./setPassword.schema"; @@ -71,7 +71,7 @@ export const viewerOrganizationsRouter = router({ const handler = await importHandler(namespaced("getMembers"), () => import("./getMembers.handler")); return handler(opts); }), - listMembers: authedProcedure.input(ZListMembersSchema).query(async (opts) => { + listMembers: authedProcedure.input(ZListMembersInputSchema).query(async (opts) => { const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler")); return handler(opts); }), diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 92a9805f7d..7a15b188a6 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -75,7 +75,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }; } - const { cursor, limit } = input; + const { limit, offset } = input; const getTotalMembers = await prisma.membership.count({ where: { @@ -190,19 +190,13 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }, }, }, - cursor: cursor ? { id: cursor } : undefined, - take: limit + 1, // We take +1 as itll be used for the next cursor + skip: offset, + take: limit, orderBy: { id: "asc", }, }); - let nextCursor: typeof cursor | undefined = undefined; - if (teamMembers && teamMembers.length > limit) { - const nextItem = teamMembers.pop(); - nextCursor = nextItem?.id; - } - const members = await Promise.all( teamMembers?.map(async (membership) => { const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user }); @@ -260,7 +254,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { name: team.team.name, slug: team.team.slug, }; - }), + }) + .filter((team): team is NonNullable => team !== undefined), attributes, }; }) || [] @@ -268,7 +263,6 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { return { rows: members || [], - nextCursor, meta: { totalRowCount: getTotalMembers || 0, }, diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts index 8dee8925a7..b30f71b3bc 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts @@ -9,13 +9,13 @@ const ZListMembersFilter = z.object({ value: ZFilterValue, }); -export const ZListMembersSchema = z.object({ +export const ZListMembersInputSchema = z.object({ limit: z.number().min(1).max(100), - cursor: z.number().nullish(), + offset: z.number(), searchTerm: z.string().optional(), expand: z.array(expandableColumns).optional(), filters: z.array(ZListMembersFilter).optional(), oAuthClientId: z.string().optional(), }); -export type TListMembersSchema = z.infer; +export type TListMembersSchema = z.infer; diff --git a/packages/ui/components/pagination/Pagination.tsx b/packages/ui/components/pagination/Pagination.tsx index bf115b0846..ca99447d57 100644 --- a/packages/ui/components/pagination/Pagination.tsx +++ b/packages/ui/components/pagination/Pagination.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; + import { Button } from "../button"; import { ButtonGroup } from "../buttonGroup"; import { Select } from "../form/select"; @@ -29,6 +31,7 @@ export const Pagination = ({ onNext, onPrevious, }: PaginationProps) => { + const { t } = useLocale(); const [internalPageSize, setInternalPageSize] = useState(pageSize); const totalPages = Math.ceil(totalItems / pageSize); @@ -73,11 +76,14 @@ export const Pagination = ({ onChange={handlePageSizeChange} size="sm" /> - rows per page + {t("rows_per_page")}
- {startItem}-{endItem} of {totalItems} + {t("pagination_status", { + currentRange: `${startItem}-${endItem}`, + totalItems, + })}