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 <sldisek783@gmail.com>
This commit is contained in:
Eunjae Lee
2025-03-11 09:55:41 +01:00
committed by GitHub
co-authored by Benny Joo
parent 861e3aa24f
commit 7eee2728a2
24 changed files with 403 additions and 388 deletions
@@ -389,6 +389,7 @@ function BookingsContent({ status }: BookingsProps) {
fetchNextPage={query.fetchNextPage}
isFetching={query.isFetching}
variant="compact"
paginationMode="infinite"
ToolbarLeft={
<>
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
@@ -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",
+40 -4
View File
@@ -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,
},
],
},
];
@@ -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<TData, TValue> = {
export type DataTableProps<TData> = {
table: ReactTableType<TData>;
tableContainerRef: React.RefObject<HTMLDivElement>;
isPending?: boolean;
@@ -46,9 +46,10 @@ export type DataTableProps<TData, TValue> = {
enableColumnResizing?: boolean;
className?: string;
containerClassName?: string;
paginationMode?: "infinite" | "standard";
};
export function DataTable<TData, TValue>({
export function DataTable<TData>({
table,
tableContainerRef,
isPending,
@@ -63,19 +64,18 @@ export function DataTable<TData, TValue>({
bodyTestId,
className,
containerClassName,
paginationMode = "infinite",
...rest
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
}: DataTableProps<TData> & 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<TData, TValue>({
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<TData, TValue>({
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<TData, TValue>({
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<TData, TValue>({
}}>
{!hideHeader && (
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
{headerGroup.headers.map((header) => {
{headerGroup.headers.map((header: Header<TData, unknown>) => {
const { column } = header;
return (
<TableHead
@@ -190,6 +193,7 @@ export function DataTable<TData, TValue>({
variant={variant}
isPending={isPending}
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
/>
) : (
<DataTableBody
@@ -200,6 +204,7 @@ export function DataTable<TData, TValue>({
variant={variant}
isPending={isPending}
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
/>
)}
</TableNew>
@@ -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<TData> = {
@@ -229,6 +235,12 @@ type DataTableBodyProps<TData> = {
variant?: "default" | "compact";
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
paginationMode?: "infinite" | "standard";
};
type RowToRender<TData> = {
row: Row<TData>;
virtualItem?: VirtualItem;
};
function DataTableBody<TData>({
@@ -239,60 +251,75 @@ function DataTableBody<TData>({
variant,
isPending,
onRowMouseclick,
}: DataTableBodyProps<TData>) {
const virtualRows = rowVirtualizer.getVirtualItems();
return (
<TableBody
className="relative grid"
data-testid={testId}
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}>
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
ref={(node) => rowVirtualizer.measureElement(node)} //measure dynamic row height
key={row.id}
data-index={virtualRow.index} //needed for dynamic row height measurement
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
style={{
display: "flex",
position: "absolute",
transform: `translateY(${virtualRow.start}px)`, //this should always be a `style` as it changes on scroll
width: "100%",
}}
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group")}>
{row.getVisibleCells().map((cell) => {
const column = cell.column;
return (
<TableCell
key={cell.id}
style={{
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})
) : (
paginationMode,
}: DataTableBodyProps<TData> & { paginationMode?: "infinite" | "standard" }) {
const { t } = useLocale();
const virtualItems = rowVirtualizer.getVirtualItems();
const tableHeight = paginationMode === "infinite" ? rowVirtualizer.getTotalSize() : "auto";
const rowsToRender = useMemo<RowToRender<TData>[]>(
() =>
paginationMode === "infinite"
? virtualItems.map((virtualItem) => ({
row: rows[virtualItem.index] as Row<TData>,
virtualItem,
}))
: rows.map((row) => ({ row })),
[paginationMode, virtualItems, rows]
);
if (!isPending && rowsToRender.length === 0) {
return (
<TableBody className="relative grid" data-testid={testId} style={{ height: tableHeight }}>
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
No results.
{t("no_results")}
</TableCell>
</TableRow>
)}
</TableBody>
);
}
return (
<TableBody className="relative grid" data-testid={testId} style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => (
<TableRow
ref={virtualItem ? (node) => 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 (
<TableCell
key={cell.id}
style={{
...(column.getIsPinned() === "left" && { left: `${column.getStart("left")}px` }),
...(column.getIsPinned() === "right" && { right: `${column.getStart("right")}px` }),
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
)}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
))}
</TableBody>
);
}
@@ -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<TData> {
table: Table<TData>;
totalDbDataCount: number;
totalRowCount: number;
paginationMode?: "infinite" | "standard";
}
export function DataTablePagination<TData>({ table, totalDbDataCount }: DataTablePaginationProps<TData>) {
const loadedCount = table.getFilteredRowModel().rows.length;
export function DataTablePagination<TData>({
table,
totalRowCount,
paginationMode = "infinite",
}: DataTablePaginationProps<TData>) {
const { pageIndex, pageSize, setPageIndex, setPageSize } = useDataTable();
return (
<p className="text-subtle text-sm tabular-nums">
Loaded <span className="text-default font-medium">{`${loadedCount}`}</span> of
<span className="text-default font-medium"> {`${totalDbDataCount}`}</span>
</p>
);
if (paginationMode === "infinite") {
const loadedCount = table.getFilteredRowModel().rows.length;
return (
<p className="text-subtle text-sm tabular-nums">
Loaded <span className="text-default font-medium">{loadedCount}</span> of{" "}
<span className="text-default font-medium">{totalRowCount}</span>
</p>
);
} else if (paginationMode === "standard") {
return (
<Pagination
currentPage={pageIndex + 1}
pageSize={pageSize}
totalItems={totalRowCount}
onPageChange={(page) => setPageIndex(page - 1)}
onPageSizeChange={(newSize) => setPageSize(newSize)}
onNext={() => setPageIndex(pageIndex + 1)}
onPrevious={() => setPageIndex(pageIndex - 1)}
/>
);
} else {
return null;
}
}
@@ -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<TData, TValue> = {
type BaseDataTableWrapperProps<TData> = {
testId?: string;
bodyTestId?: string;
table: ReactTableType<TData>;
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<TData, TValue> = {
tableContainerRef?: React.RefObject<HTMLDivElement>;
};
export function DataTableWrapper<TData, TValue>({
type InfinitePaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
paginationMode: "infinite";
hasNextPage: boolean;
fetchNextPage: () => void;
isFetching: boolean;
};
type StandardPaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
paginationMode: "standard";
hasNextPage?: never;
fetchNextPage?: never;
isFetching?: never;
};
export type DataTableWrapperProps<TData> = InfinitePaginationProps<TData> | StandardPaginationProps<TData>;
export function DataTableWrapper<TData>({
testId,
bodyTestId,
table,
@@ -41,7 +56,7 @@ export function DataTableWrapper<TData, TValue>({
hasNextPage,
fetchNextPage,
isFetching,
totalDBRowCount,
totalRowCount,
variant,
hideHeader,
ToolbarLeft,
@@ -52,14 +67,16 @@ export function DataTableWrapper<TData, TValue>({
containerClassName,
children,
tableContainerRef: externalRef,
}: DataTableWrapperProps<TData, TValue>) {
paginationMode,
}: DataTableWrapperProps<TData>) {
const internalRef = useRef<HTMLDivElement>(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<TData, TValue>({
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<TData, TValue>({
variant={variant}
className={className}
containerClassName={containerClassName}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
{totalDBRowCount && (
paginationMode={paginationMode}
onScroll={
paginationMode === "infinite"
? (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) =>
fetchMoreOnBottomReached(e.target as HTMLDivElement)
: undefined
}>
{totalRowCount && (
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<DataTablePagination table={table} totalDbDataCount={totalDBRowCount} />
<DataTablePagination<TData>
table={table}
totalRowCount={totalRowCount}
paginationMode={paginationMode}
/>
</div>
)}
</DataTable>
@@ -7,14 +7,17 @@ export const useFetchMoreOnBottomReached = ({
hasNextPage,
fetchNextPage,
isFetching,
enabled = true,
}: {
tableContainerRef: React.RefObject<HTMLDivElement>;
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;
};
+33 -2
View File
@@ -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<VisibilityState>;
pageIndex: number;
pageSize: number;
setPageIndex: (pageIndex: number) => void;
setPageSize: (pageSize: number) => void;
offset: number;
limit: number;
};
export const DataTableContext = createContext<DataTableContextType | null>(null);
@@ -33,8 +41,14 @@ export const DataTableContext = createContext<DataTableContextType | null>(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 (
<DataTableContext.Provider
value={{
@@ -94,6 +119,12 @@ export function DataTableProvider({ children }: { children: React.ReactNode }) {
setSorting,
columnVisibility,
setColumnVisibility,
pageIndex,
pageSize,
setPageIndex,
setPageSize: setPageSizeAndGoToFirstPage,
limit: pageSize,
offset: pageIndex * pageSize,
}}>
{children}
</DataTableContext.Provider>
@@ -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<User>[] = [
@@ -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[];
@@ -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(() => {
@@ -131,6 +131,7 @@ export function RoutingFormResponsesTable() {
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
paginationMode="infinite"
ToolbarLeft={
<>
<OrgTeamsFilter />
@@ -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({
@@ -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(() => {
@@ -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");
@@ -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();
@@ -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");
@@ -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 (
<DataTableProvider>
<DataTableProvider defaultPageSize={25}>
<UserListTableContent {...props} />
</DataTableProvider>
);
@@ -64,36 +63,34 @@ export function PlatformManagedUsersTable(props: PlatformManagedUsersTableProps)
function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps) {
const { t } = useLocale();
const tableContainerRef = useRef<HTMLDivElement>(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<PlatformManagedUserTableUser>[] = [
@@ -293,46 +290,31 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
}
}
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached({
tableContainerRef,
hasNextPage,
fetchNextPage,
isFetching,
});
const numberOfSelectedRows = useMemo(() => table.getSelectedRowModel().rows.length, [table]);
return (
<>
<DataTable
<DataTableWrapper
testId="managed-user-list-data-table"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full flex-col gap-2 sm:flex-row">
<div className="w-full sm:w-auto sm:min-w-[200px] sm:flex-1">
<DataTableToolbar.SearchBar
table={table}
onSearch={(value) => setDebouncedSearchTerm(value)}
className="sm:max-w-64 max-w-full"
/>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
{/* 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 */}
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
</div>
</div>
<div className="flex gap-2 justify-self-start">
<DataTableFilters.ActiveFilters table={table} />
</div>
</DataTableToolbar.Root>
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<DataTablePagination table={table} totalDbDataCount={totalDBRowCount} />
totalRowCount={data?.meta?.totalRowCount}
paginationMode="standard"
ToolbarLeft={
<DataTableToolbar.SearchBar
table={table}
onSearch={(value) => setDebouncedSearchTerm(value)}
className="sm:max-w-64 max-w-full"
/>
}
ToolbarRight={
<>
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
</>
}>
<div className="flex gap-2 justify-self-start">
<DataTableFilters.ActiveFilters table={table} />
</div>
{numberOfSelectedRows > 0 && (
@@ -346,7 +328,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
/>
</DataTableSelectionBar.Root>
)}
</DataTable>
</DataTableWrapper>
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
</>
);
@@ -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 (
<DataTableProvider>
<DataTableProvider defaultPageSize={25}>
<UserListTableContent />
</DataTableProvider>
);
@@ -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<UserTableUser[]>(() => 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<UserTableUser>[] =
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 (
<div className={classNames(isNumber ? "flex w-full justify-center" : "flex flex-wrap")}>
{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<UserTableUser>[]) ?? []
);
}) ?? [];
return attributeColumns;
};
const cols: ColumnDef<UserTableUser>[] = [
// 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={
<>
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
@@ -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,
+1 -1
View File
@@ -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] = [];
}
@@ -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);
}),
@@ -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<typeof team> => team !== undefined),
attributes,
};
}) || []
@@ -268,7 +263,6 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
return {
rows: members || [],
nextCursor,
meta: {
totalRowCount: getTotalMembers || 0,
},
@@ -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<typeof ZListMembersSchema>;
export type TListMembersSchema = z.infer<typeof ZListMembersInputSchema>;
@@ -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"
/>
<span className="text-default text-sm">rows per page</span>
<span className="text-default text-sm">{t("rows_per_page")}</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-default text-sm">
{startItem}-{endItem} of {totalItems}
{t("pagination_status", {
currentRange: `${startItem}-${endItem}`,
totalItems,
})}
</span>
<ButtonGroup containerProps={{ className: "space-x-1.5" }}>
<Button