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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user