+
{!hideHeader && }
}>{children}
diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx
index 2e5b04e6bd..923c695600 100644
--- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx
+++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx
@@ -1,4 +1,5 @@
import { keepPreviousData } from "@tanstack/react-query";
+import { getCoreRowModel, useReactTable, getFilteredRowModel } from "@tanstack/react-table";
import type { ColumnDef } from "@tanstack/react-table";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -9,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
-import { Button, ButtonGroup, DataTable, UserAvatar } from "@calcom/ui";
+import { Button, ButtonGroup, DataTable, DataTableToolbar, UserAvatar } from "@calcom/ui";
import { UpgradeTip } from "../../tips/UpgradeTip";
import { createTimezoneBuddyStore, TBContext } from "../store";
@@ -80,7 +81,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
const cols: ColumnDef[] = [
{
id: "member",
- accessorFn: (data) => data.email,
+ accessorFn: (data) => data.username,
header: "Member",
cell: ({ row }) => {
const { username, email, timeZone, name, avatarUrl, profile } = row.original;
@@ -104,6 +105,9 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
);
},
+ filterFn: (row, id, value) => {
+ return row.original.username?.toLowerCase().includes(value.toLowerCase()) || false;
+ },
},
{
id: "timezone",
@@ -185,6 +189,13 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
+ const table = useReactTable({
+ data: flatData,
+ columns: memorisedColumns,
+ getCoreRowModel: getCoreRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ });
+
// This means they are not apart of any teams so we show the upgrade tip
if (!flatData.length) return
;
@@ -196,19 +207,18 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
<>
{
setEditSheetOpen(true);
setSelectedUser(row.original);
}}
- data={flatData}
isPending={isPending}
- // tableOverlay={ }
- onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
- />
+ onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
+
+
+
+
{selectedUser && editSheetOpen ? (
void;
}
@@ -14,9 +14,22 @@ 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: () => {
- utils.viewer.organizations.listMembers.invalidate();
+ onSuccess: (_, { userIds }) => {
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)),
+ })),
+ };
+ }
+ );
},
onError: (error) => {
showToast(error.message, "error");
diff --git a/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx b/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx
new file mode 100644
index 0000000000..c400468ceb
--- /dev/null
+++ b/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx
@@ -0,0 +1,55 @@
+import type { Table } from "@tanstack/react-table";
+import { useQueryState, parseAsBoolean } from "nuqs";
+
+import { useCopy } from "@calcom/lib/hooks/useCopy";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { Button } from "@calcom/ui";
+
+export function DynamicLink({
+ table,
+ domain,
+}: {
+ table: Table;
+ domain: string;
+}) {
+ const { t } = useLocale();
+ const [dynamicLinkVisible, _] = useQueryState("dynamicLink", parseAsBoolean);
+ const { copyToClipboard, isCopied } = useCopy();
+ const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
+ const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
+
+ const users = table
+ .getSelectedRowModel()
+ .flatRows.map((row) => row.original.username)
+ .filter((u): u is string => u !== null);
+
+ const usersNameAsString = users.join("+");
+
+ const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
+ const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
+
+ return (
+ <>
+ {isVisible ? (
+
+
+
+ copyToClipboard(dynamicLinkOfSelectedUsers)}>
+ {!isCopied ? t("copy") : t("copied")}
+
+
+ Open
+
+
+
+ ) : null}
+ >
+ );
+}
diff --git a/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx
index 6374bda939..7760c207e1 100644
--- a/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx
+++ b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx
@@ -22,10 +22,10 @@ import {
Icon,
} from "@calcom/ui";
-import type { User } from "../UserListTable";
+import type { UserTableUser } from "../types";
interface Props {
- table: Table;
+ table: Table;
orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined;
}
diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
index 209dd13efe..e4c27bc42b 100644
--- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
+++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx
@@ -1,9 +1,11 @@
import type { Table } from "@tanstack/react-table";
+import type { ColumnFiltersState } from "@tanstack/react-table";
import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs";
import { useState } from "react";
import classNames from "@calcom/lib/classNames";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import slugify from "@calcom/lib/slugify";
import { trpc } from "@calcom/trpc";
import {
Alert,
@@ -22,10 +24,11 @@ import {
showToast,
} from "@calcom/ui";
-import type { User } from "../UserListTable";
+import type { UserTableUser } from "../types";
interface Props {
- table: Table;
+ table: Table;
+ filters: ColumnFiltersState;
}
function useSelectedAttributes() {
@@ -78,7 +81,7 @@ function SelectedAttributeToAssign() {
return (
-
+
{foundAttribute.name}
{translateableType && ({t(translateableType)}) }
@@ -119,9 +122,11 @@ function SelectedAttributeToAssign() {
<>
{
+ onBlur={(e) => {
+ // trigger onBlur so it's set as Apply is pressed (but not onChange) which triggers
+ // a re-render which also loses focus.
setSelectedAttributeOption([e.target.value]);
}}
/>
@@ -133,13 +138,78 @@ function SelectedAttributeToAssign() {
);
}
-export function MassAssignAttributesBulkAction({ table }: Props) {
+export function MassAssignAttributesBulkAction({ table, filters }: Props) {
const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes();
const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption();
const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false);
const { t } = useLocale();
+ const utils = trpc.useContext();
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: filters.map((filter) => ({
+ id: filter.id,
+ value: filter.value as string[],
+ })),
+ },
+ // @ts-expect-error i really dont know how to type this
+ (oldData) => {
+ 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,
+ }));
+ 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]),
+ });
+ }
+
+ return {
+ ...row,
+ attributes: newAttributes,
+ };
+ }
+ return row;
+ }),
+ }));
+
+ return {
+ ...oldData,
+ pages: newPages,
+ };
+ }
+ );
+
+ setSelectedAttribute(null);
+ setSelectedAttributeOptions([]);
showToast(success.message, "success");
},
onError: (error) => {
@@ -202,7 +272,7 @@ export function MassAssignAttributesBulkAction({ table }: Props) {
<>
- {t("mass_assign_attributes")}
+ {t("add_attributes")}
{/* We dont really use shadows much - but its needed here */}
@@ -262,9 +332,6 @@ export function MassAssignAttributesBulkAction({ table }: Props) {
attributes: attributesToAssign,
userIds: table.getSelectedRowModel().rows.map((row) => row.original.id),
});
-
- setSelectedAttribute(null);
- setSelectedAttributeOptions([]);
}
}}>
{t("apply")}
diff --git a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx
index dae4cee34b..4496229fa4 100644
--- a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx
+++ b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx
@@ -20,10 +20,10 @@ import {
showToast,
} from "@calcom/ui";
-import type { User } from "../UserListTable";
+import type { UserTableUser } from "../types";
interface Props {
- table: Table;
+ table: Table;
}
export function TeamListBulkAction({ table }: Props) {
diff --git a/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx b/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx
index 1f5e51a12e..d3d223e3ec 100644
--- a/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx
+++ b/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx
@@ -3,9 +3,9 @@ import type { Dispatch } from "react";
import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal";
-import type { Action, State } from "./UserListTable";
+import type { UserTableAction, UserTableState } from "./types";
-export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch }) {
+export function ChangeUserRoleModal(props: { state: UserTableState; dispatch: Dispatch }) {
const { data: session } = useSession();
const orgId = session?.user.org?.id;
if (!orgId || !props.state.changeMemberRole.user) return null;
diff --git a/packages/features/users/components/UserTable/DeleteMemberModal.tsx b/packages/features/users/components/UserTable/DeleteMemberModal.tsx
index e5f1ff4b91..a4f2ba7377 100644
--- a/packages/features/users/components/UserTable/DeleteMemberModal.tsx
+++ b/packages/features/users/components/UserTable/DeleteMemberModal.tsx
@@ -5,9 +5,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Dialog, ConfirmationDialogContent, showToast } from "@calcom/ui";
-import type { State, Action } from "./UserListTable";
+import type { UserTableAction, UserTableState } from "./types";
-export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch }) {
+export function DeleteMemberModal({
+ state,
+ dispatch,
+}: {
+ state: UserTableState;
+ dispatch: Dispatch;
+}) {
const { t } = useLocale();
const { data: session } = useSession();
const utils = trpc.useUtils();
diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
index 2afd989ffc..c4455969c1 100644
--- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
+++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
@@ -28,7 +28,7 @@ import {
SheetTitle,
} from "@calcom/ui";
-import type { Action } from "../UserListTable";
+import type { UserTableAction } from "../types";
import { useEditMode } from "./store";
type MembershipOption = {
@@ -68,7 +68,7 @@ export function EditForm({
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
- dispatch: Dispatch;
+ dispatch: Dispatch;
}) {
const setEditMode = useEditMode((state) => state.setEditMode);
const [mutationLoading, setMutationLoading] = useState(false);
diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
index fd657f9f99..672a2f7b17 100644
--- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
+++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Avatar, Loader, Sheet, SheetContent, SheetBody, SheetHeader, SheetFooter } from "@calcom/ui";
-import type { Action, State } from "../UserListTable";
+import type { UserTableAction, UserTableState } from "../types";
import { DisplayInfo } from "./DisplayInfo";
import { EditForm } from "./EditUserForm";
import { OrganizationBanner } from "./OrganizationBanner";
@@ -18,15 +18,20 @@ function removeProtocol(url: string) {
return url.replace(/^(https?:\/\/)/, "");
}
-export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch }) {
+export function EditUserSheet({
+ state,
+ dispatch,
+}: {
+ state: UserTableState;
+ dispatch: Dispatch;
+}) {
const { t } = useLocale();
const { user: selectedUser } = state.editSheet;
const orgBranding = useOrgBranding();
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery(
{
- // @ts-expect-error we obly enable the query if the user is selected
- userId: selectedUser.id,
+ userId: selectedUser?.id,
},
{
enabled: !!selectedUser?.id,
@@ -36,8 +41,8 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
const { data: usersAttributes, isPending: usersAttributesPending } =
trpc.viewer.attributes.getByUserId.useQuery(
{
- // @ts-expect-error we obly enable the query if the user is selected
- userId: selectedUser.id,
+ // @ts-expect-error We know it exists as it is only called when selectedUser is defined
+ userId: selectedUser?.id,
},
{
enabled: !!selectedUser?.id,
diff --git a/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx b/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx
index 2753b6d635..2cd0d82453 100644
--- a/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx
+++ b/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx
@@ -4,9 +4,12 @@ import type { Dispatch } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
-import type { Action, State } from "./UserListTable";
+import type { UserTableAction, UserTableState } from "./types";
-export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch }) {
+export function ImpersonationMemberModal(props: {
+ state: UserTableState;
+ dispatch: Dispatch;
+}) {
const { t } = useLocale();
const { data: session } = useSession();
const teamId = session?.user.org?.id;
diff --git a/packages/features/users/components/UserTable/InviteMemberModal.tsx b/packages/features/users/components/UserTable/InviteMemberModal.tsx
index 0348ee6d70..d70b35036e 100644
--- a/packages/features/users/components/UserTable/InviteMemberModal.tsx
+++ b/packages/features/users/components/UserTable/InviteMemberModal.tsx
@@ -7,10 +7,10 @@ import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
import usePlatformMe from "@calcom/web/components/settings/platform/hooks/usePlatformMe";
-import type { Action } from "./UserListTable";
+import type { UserTableAction } from "./types";
interface Props {
- dispatch: Dispatch;
+ dispatch: Dispatch;
}
export function InviteMemberModal(props: Props) {
diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx
index 108a05a84c..b113f2a4d5 100644
--- a/packages/features/users/components/UserTable/UserListTable.tsx
+++ b/packages/features/users/components/UserTable/UserListTable.tsx
@@ -1,21 +1,38 @@
-import { keepPreviousData } from "@tanstack/react-query";
-import type { ColumnDef, Table } from "@tanstack/react-table";
-import { m } from "framer-motion";
-import { useSession } from "next-auth/react";
-import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
+"use client";
+import { keepPreviousData } from "@tanstack/react-query";
+import type { ColumnFiltersState } from "@tanstack/react-table";
+import {
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+} from "@tanstack/react-table";
+import { useSession } from "next-auth/react";
+import { useQueryState, parseAsBoolean } from "nuqs";
+import { useMemo, useReducer, useRef, useState } from "react";
+
+import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
-import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
-import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
-import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui";
-import type { ActionItem } from "@calcom/ui/components/data-table/DataTableSelectionBar";
+import {
+ Avatar,
+ Badge,
+ Button,
+ Checkbox,
+ DataTable,
+ DataTableToolbar,
+ DataTableFilters,
+ DataTableSelectionBar,
+ DataTablePagination,
+} from "@calcom/ui";
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
-import { useOrgBranding } from "../../../ee/organizations/context/provider";
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
+import { DynamicLink } from "./BulkActions/DynamicLink";
import { EventTypesList } from "./BulkActions/EventTypesList";
import { MassAssignAttributesBulkAction } from "./BulkActions/MassAssignAttributes";
import { TeamListBulkAction } from "./BulkActions/TeamList";
@@ -25,52 +42,10 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
+import type { UserTableState, UserTableAction, UserTableUser } from "./types";
+import { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached";
-export interface User {
- id: number;
- username: string | null;
- email: string;
- timeZone: string;
- role: MembershipRole;
- avatarUrl: string | null;
- accepted: boolean;
- disableImpersonation: boolean;
- completedOnboarding: boolean;
- teams: {
- id: number;
- name: string;
- slug: string | null;
- }[];
-}
-
-type Payload = {
- showModal: boolean;
- user?: User;
-};
-
-export type State = {
- changeMemberRole: Payload;
- deleteMember: Payload;
- impersonateMember: Payload;
- inviteMember: Payload;
- editSheet: Payload & { user?: User };
-};
-
-export type Action =
- | {
- type:
- | "SET_CHANGE_MEMBER_ROLE_ID"
- | "SET_DELETE_ID"
- | "SET_IMPERSONATE_ID"
- | "INVITE_MEMBER"
- | "EDIT_USER_SHEET";
- payload: Payload;
- }
- | {
- type: "CLOSE_MODAL";
- };
-
-const initialState: State = {
+const initialState: UserTableState = {
changeMemberRole: {
showModal: false,
},
@@ -88,7 +63,15 @@ const initialState: State = {
},
};
-function reducer(state: State, action: Action): State {
+const initalColumnVisibility = {
+ select: true,
+ member: true,
+ role: true,
+ teams: true,
+ actions: true,
+};
+
+function reducer(state: UserTableState, action: UserTableAction): UserTableState {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
@@ -115,34 +98,47 @@ function reducer(state: State, action: Action): State {
}
export function UserListTable() {
+ const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
+ const orgBranding = useOrgBranding();
+ const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
+ const { t } = useLocale();
+
const { data: session } = useSession();
const { isPlatformUser } = useGetUserAttributes();
- const { copyToClipboard, isCopied } = useCopy();
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
+ const { data: attributes } = trpc.viewer.attributes.list.useQuery();
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
+ const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery();
+
const tableContainerRef = useRef(null);
const [state, dispatch] = useReducer(reducer, initialState);
- const { t } = useLocale();
- const orgBranding = useOrgBranding();
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
- const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
+
+ const [columnFilters, setColumnFilters] = useState([]);
+
const { data, isPending, fetchNextPage, isFetching } =
trpc.viewer.organizations.listMembers.useInfiniteQuery(
{
limit: 10,
searchTerm: debouncedSearchTerm,
+ expand: ["attributes"],
+ filters: columnFilters.map((filter) => ({
+ id: filter.id,
+ value: filter.value as string[],
+ })),
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
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 adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER";
- const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
//we must flatten the array of arrays from the useInfiniteQuery hook
- const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
+ const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[];
const totalFetched = flatData.length;
const memorisedColumns = useMemo(() => {
@@ -152,10 +148,44 @@ export function UserListTable() {
canResendInvitation: adminOrOwner,
canImpersonate: false,
};
- const cols: ColumnDef[] = [
+ const generateAttributeColumns = () => {
+ if (!attributes?.length) {
+ return [];
+ }
+ return (
+ (attributes?.map((attribute) => ({
+ id: attribute.id,
+ header: attribute.name,
+ accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
+ cell: ({ row }) => {
+ const attributeValues = row.original.attributes.filter(
+ (attr) => attr.attributeId === attribute.id
+ );
+ if (attributeValues.length === 0) return null;
+ return (
+ <>
+ {attributeValues.map((attributeValue, index) => (
+
+ {attributeValue.value}
+
+ ))}
+ >
+ );
+ },
+ filterFn: (rows, id, filterValue) => {
+ const attributeValues = rows.original.attributes.filter((attr) => attr.attributeId === id);
+ if (attributeValues.length === 0) return false;
+ return attributeValues.some((attr) => filterValue.includes(attr.value));
+ },
+ })) as ColumnDef[]) ?? []
+ );
+ };
+ const cols: ColumnDef[] = [
// Disabling select for this PR: Will work on actions etc in a follow up
{
id: "select",
+ enableHiding: false,
+ enableSorting: false,
header: ({ table }) => (
data.email,
- header: `Member (${isPlatformUser ? totalFetched : totalDBRowCount})`,
+ enableHiding: false,
+ header: () => {
+ return `Members`;
+ },
cell: ({ row }) => {
const { username, email, avatarUrl } = row.original;
return (
@@ -204,9 +237,8 @@ export function UserListTable() {
);
},
filterFn: (rows, id, filterValue) => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- // @ts-ignore Weird typing issue
- return rows.getValue(id).includes(filterValue);
+ const userEmail = rows.original.email;
+ return filterValue.includes(userEmail);
},
},
{
@@ -275,8 +307,10 @@ export function UserListTable() {
return filterValue.some((value: string) => teamNames.includes(value));
},
},
+ ...generateAttributeColumns(),
{
id: "actions",
+ enableHiding: false,
cell: ({ row }) => {
const user = row.original;
const permissionsRaw = permissions;
@@ -304,184 +338,125 @@ export function UserListTable() {
];
return cols;
- }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]);
+ }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount, attributes]);
- const memoisedSelectionOptions = useMemo(() => {
- const selectionOptionsForOrg: ActionItem[] = [
- {
- type: "render",
- render: (table) => ,
- },
- {
- type: "render",
- render: (table) => ,
- },
- {
- type: "action",
- icon: "handshake",
- label: "Group Meeting",
- needsXSelected: 2,
- onClick: () => {
- setDynamicLinkVisible((old) => !old);
- },
- },
- {
- type: "render",
- render: (table) => ,
- },
- {
- type: "render",
- render: (table) => (
- row.original)}
- onRemove={() => table.toggleAllPageRowsSelected(false)}
- />
- ),
- },
- ];
-
- const selectionOptionsForPlatform: ActionItem[] = [
- {
- type: "render",
- render: (table: Table) => (
- row.original)}
- onRemove={() => table.toggleAllPageRowsSelected(false)}
- />
- ),
- },
- ];
-
- const selectionOptions = isPlatformUser ? selectionOptionsForPlatform : selectionOptionsForOrg;
-
- return selectionOptions;
- }, [isPlatformUser, teams]);
-
- //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
- const fetchMoreOnBottomReached = useCallback(
- (containerRefElement?: HTMLDivElement | 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) {
- fetchNextPage();
+ const table = useReactTable({
+ data: flatData,
+ columns: memorisedColumns,
+ enableRowSelection: true,
+ debugTable: true,
+ manualPagination: true,
+ initialState: {
+ columnVisibility: initalColumnVisibility,
+ },
+ state: {
+ columnFilters,
+ },
+ onColumnFiltersChange: setColumnFilters,
+ getCoreRowModel: getCoreRowModel(),
+ // TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache
+ getFilteredRowModel: getFilteredRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFacetedUniqueValues: (_, columnId) => () => {
+ if (facetedTeamValues) {
+ switch (columnId) {
+ case "role":
+ return new Map(facetedTeamValues.roles.map((role) => [role, 1]));
+ case "teams":
+ return new Map(facetedTeamValues.teams.map((team) => [team.name, 1]));
+ default:
+ const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
+ if (attribute) {
+ return new Map(attribute?.options.map(({ value }) => [value, 1]) ?? []);
+ }
+ return new Map();
}
}
+ return new Map();
},
- [fetchNextPage, isFetching, totalFetched, totalDBRowCount]
+ });
+
+ const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
+ tableContainerRef,
+ fetchNextPage,
+ isFetching,
+ totalFetched,
+ totalDBRowCount
);
- useEffect(() => {
- fetchMoreOnBottomReached(tableContainerRef.current);
- }, [fetchMoreOnBottomReached]);
+ const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
return (
<>
{
- const user = row.original;
- const canEdit = adminOrOwner;
- if (canEdit) {
- // dispatch({
- // type: "EDIT_USER_SHEET",
- // payload: {
- // showModal: true,
- // user,
- // },
- // });
- }
- }}
data-testid="user-list-data-table"
- onSearch={(value) => setDebouncedSearchTerm(value)}
- selectionOptions={memoisedSelectionOptions}
- renderAboveSelection={(table: Table) => {
- const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
- const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
-
- const users = table
- .getSelectedRowModel()
- .flatRows.map((row) => row.original.username)
- .filter((u) => u !== null);
-
- const usersNameAsString = users.join("+");
-
- const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
- const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
-
- return (
- <>
- {isVisible ? (
-
-
-
- copyToClipboard(dynamicLinkOfSelectedUsers)}>
- {!isCopied ? t("copy") : t("copied")}
-
-
- Open
-
-
-
- ) : null}
- >
- );
- }}
+ // className="lg:max-w-screen-lg"
+ table={table}
tableContainerRef={tableContainerRef}
- tableCTA={
- adminOrOwner && (
-
- dispatch({
- type: "INVITE_MEMBER",
- payload: {
- showModal: true,
- },
- })
- }
- data-testid="new-organization-member-button">
- {t("add")}
-
- )
- }
- columns={memorisedColumns}
- data={flatData}
isPending={isPending}
- onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
- filterableItems={[
- {
- tableAccessor: "role",
- title: "Role",
- options: [
- { label: "Owner", value: "OWNER" },
- { label: "Admin", value: "ADMIN" },
- { label: "Member", value: "MEMBER" },
- { label: "Pending", value: "PENDING" },
- ],
- },
- {
- tableAccessor: "teams",
- title: "Teams",
- options: teams ? teams.map((team) => ({ label: team.name, value: team.name })) : [],
- },
- ]}
- />
+ onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
+
+
+ setDebouncedSearchTerm(value)} />
+ {/* 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 */}
+
+
+ {adminOrOwner && (
+
+ dispatch({
+ type: "INVITE_MEMBER",
+ payload: {
+ showModal: true,
+ },
+ })
+ }
+ data-testid="new-organization-member-button">
+ {t("add")}
+
+ )}
+
+
+
+
+
+
+
+
+
+ {numberOfSelectedRows >= 2 && dynamicLinkVisible && (
+
+
+
+ )}
+ {numberOfSelectedRows > 0 && (
+
+
+ {numberOfSelectedRows} selected
+
+ {!isPlatformUser ? (
+ <>
+
+ {numberOfSelectedRows >= 2 && (
+ setDynamicLinkVisible(!dynamicLinkVisible)} StartIcon="handshake">
+ Group Meeting
+
+ )}
+
+
+ >
+ ) : null}
+ row.original)}
+ onRemove={() => table.toggleAllPageRowsSelected(false)}
+ />
+
+ )}
+
{state.deleteMember.showModal && }
{state.inviteMember.showModal && }
diff --git a/packages/features/users/components/UserTable/UserTableActions.tsx b/packages/features/users/components/UserTable/UserTableActions.tsx
index af2fcd67c3..2cc794c734 100644
--- a/packages/features/users/components/UserTable/UserTableActions.tsx
+++ b/packages/features/users/components/UserTable/UserTableActions.tsx
@@ -16,8 +16,7 @@ import {
showToast,
} from "@calcom/ui";
-import type { Action } from "./UserListTable";
-import type { User } from "./UserListTable";
+import type { UserTableUser, UserTableAction } from "./types";
export function TableActions({
user,
@@ -25,8 +24,8 @@ export function TableActions({
dispatch,
domain,
}: {
- user: User;
- dispatch: React.Dispatch;
+ user: UserTableUser;
+ dispatch: React.Dispatch;
domain: string;
permissionsForUser: {
canEdit: boolean;
diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts
new file mode 100644
index 0000000000..dfbff32eb6
--- /dev/null
+++ b/packages/features/users/components/UserTable/types.ts
@@ -0,0 +1,52 @@
+import type { MembershipRole } from "@calcom/prisma/enums";
+
+export interface UserTableUser {
+ id: number;
+ username: string | null;
+ email: string;
+ timeZone: string;
+ role: MembershipRole;
+ avatarUrl: string | null;
+ accepted: boolean;
+ disableImpersonation: boolean;
+ completedOnboarding: boolean;
+ teams: {
+ id: number;
+ name: string;
+ slug: string | null;
+ }[];
+ attributes: {
+ id: string;
+ attributeId: string;
+ value: string;
+ slug: string;
+ }[];
+}
+
+export type UserTablePayload = {
+ showModal: boolean;
+ user?: UserTableUser;
+};
+
+export type UserTableState = {
+ changeMemberRole: UserTablePayload;
+ deleteMember: UserTablePayload;
+ impersonateMember: UserTablePayload;
+ inviteMember: UserTablePayload;
+ editSheet: UserTablePayload & { user?: UserTableUser };
+};
+
+export type UserTableAction =
+ | {
+ type:
+ | "SET_CHANGE_MEMBER_ROLE_ID"
+ | "SET_DELETE_ID"
+ | "SET_IMPERSONATE_ID"
+ | "INVITE_MEMBER"
+ | "EDIT_USER_SHEET"
+ | "INVITE_MEMBER";
+ payload: UserTablePayload;
+ }
+ | {
+ type: "CLOSE_MODAL";
+ };
diff --git a/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts b/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts
new file mode 100644
index 0000000000..ef224bd3c6
--- /dev/null
+++ b/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts
@@ -0,0 +1,27 @@
+import { useCallback, useEffect } from "react";
+
+export const useFetchMoreOnBottomReached = (
+ tableContainerRef: React.RefObject,
+ fetchNextPage: () => void,
+ isFetching: boolean,
+ totalFetched: number,
+ totalDBRowCount: number
+) => {
+ const fetchMoreOnBottomReached = useCallback(
+ (containerRefElement?: HTMLDivElement | null) => {
+ if (containerRefElement) {
+ const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
+ if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
+ fetchNextPage();
+ }
+ }
+ },
+ [fetchNextPage, isFetching, totalFetched, totalDBRowCount]
+ );
+
+ useEffect(() => {
+ fetchMoreOnBottomReached(tableContainerRef.current);
+ }, [fetchMoreOnBottomReached, tableContainerRef]);
+
+ return fetchMoreOnBottomReached;
+};
diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx
index 4761600aed..94c6e0d470 100644
--- a/packages/trpc/server/routers/viewer/organizations/_router.tsx
+++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx
@@ -169,4 +169,11 @@ export const viewerOrganizationsRouter = router({
);
return handler(opts);
}),
+ getFacetedValues: authedProcedure.query(async (opts) => {
+ const handler = await importHandler(
+ namespaced("getFacetedValues"),
+ () => import("./getFacetedValues.handler")
+ );
+ return handler(opts);
+ }),
});
diff --git a/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts
new file mode 100644
index 0000000000..51565c3ee2
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts
@@ -0,0 +1,54 @@
+import { prisma } from "@calcom/prisma";
+import { MembershipRole } from "@calcom/prisma/enums";
+
+import { TRPCError } from "@trpc/server";
+
+import type { TrpcSessionUser } from "../../../trpc";
+
+type DeleteOptions = {
+ ctx: {
+ user: NonNullable;
+ };
+};
+
+export const getFacetedValuesHandler = async ({ ctx }: DeleteOptions) => {
+ const { user } = ctx;
+
+ const organizationId = user.organization?.id;
+
+ if (!organizationId) {
+ throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
+ }
+ const [teams, attributes] = await Promise.all([
+ prisma.team.findMany({
+ where: {
+ parentId: organizationId,
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ }),
+ prisma.attribute.findMany({
+ where: { teamId: organizationId },
+ select: {
+ id: true,
+ name: true,
+ options: {
+ select: {
+ value: true,
+ },
+ distinct: "value",
+ },
+ },
+ }),
+ ]);
+
+ return {
+ teams: teams,
+ attributes: attributes,
+ roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
+ };
+};
+
+export default getFacetedValuesHandler;
diff --git a/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts
new file mode 100644
index 0000000000..e2981ea1df
--- /dev/null
+++ b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts
@@ -0,0 +1,7 @@
+import { z } from "zod";
+
+export const ZGetFacetedValuesInput = z.object({
+ include: z.enum(["users", "roles", "teams", "attributes"]).array().optional(),
+});
+
+export type TGetFacetedValuesInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
index 9acdf07ea0..54f0170bac 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts
@@ -1,5 +1,7 @@
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
+import type { Prisma } from "@calcom/prisma/client";
+import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
@@ -16,6 +18,8 @@ type GetOptions = {
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId;
const searchTerm = input.searchTerm;
+ const expand = input.expand;
+ const filters = input.filters || [];
if (!organizationId) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
@@ -39,30 +43,56 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
});
- // I couldnt get this query to work direct on membership table
- const teamMembers = await prisma.membership.findMany({
- where: {
- teamId: organizationId,
- user: {
- isPlatformManaged: false,
- },
- ...(searchTerm && {
- user: {
- OR: [
- {
- email: {
- contains: searchTerm,
- },
- },
- {
- username: {
- contains: searchTerm,
- },
- },
- ],
- },
- }),
+ const whereClause = {
+ user: {
+ isPlatformManaged: false,
},
+ teamId: organizationId,
+ ...(searchTerm && {
+ user: {
+ OR: [{ email: { contains: searchTerm } }, { username: { contains: searchTerm } }],
+ },
+ }),
+ } as Prisma.MembershipWhereInput;
+
+ filters.forEach((filter) => {
+ switch (filter.id) {
+ case "role":
+ whereClause.role = { in: filter.value as MembershipRole[] };
+ break;
+ case "teams":
+ whereClause.user = {
+ teams: {
+ some: {
+ team: {
+ name: {
+ in: filter.value,
+ },
+ },
+ },
+ },
+ };
+ break;
+ // We assume that if the filter is not one of the above, it must be an attribute filter
+ default:
+ whereClause.AttributeToUser = {
+ some: {
+ attributeOption: {
+ attribute: {
+ id: filter.id,
+ },
+ value: {
+ in: filter.value,
+ },
+ },
+ },
+ };
+ break;
+ }
+ });
+
+ const teamMembers = await prisma.membership.findMany({
+ where: whereClause,
select: {
id: true,
role: true,
@@ -106,6 +136,25 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const members = await Promise.all(
teamMembers?.map(async (membership) => {
const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user });
+ let attributes;
+
+ if (expand?.includes("attributes")) {
+ attributes = await prisma.attributeOption.findMany({
+ where: {
+ assignedUsers: {
+ some: {
+ memberId: membership.id,
+ },
+ },
+ },
+ orderBy: {
+ attribute: {
+ name: "asc",
+ },
+ },
+ });
+ }
+
return {
id: user.id,
username: user.username,
@@ -126,6 +175,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
slug: team.team.slug,
};
}),
+ attributes,
};
}) || []
);
diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts
index 7118e83e0d..9c5a5d5827 100644
--- a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts
+++ b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts
@@ -1,9 +1,20 @@
import { z } from "zod";
+const expandableColumns = z.enum(["attributes"]);
+
export const ZListMembersSchema = z.object({
limit: z.number().min(1).max(100),
cursor: z.number().nullish(),
searchTerm: z.string().optional(),
+ expand: z.array(expandableColumns).optional(),
+ filters: z
+ .array(
+ z.object({
+ id: z.string(),
+ value: z.array(z.string()),
+ })
+ )
+ .optional(),
});
export type TListMembersSchema = z.infer;
diff --git a/packages/ui/components/data-table/DataTableFilter.tsx b/packages/ui/components/data-table/DataTableFilter.tsx
index cce9bef556..f5b717117a 100644
--- a/packages/ui/components/data-table/DataTableFilter.tsx
+++ b/packages/ui/components/data-table/DataTableFilter.tsx
@@ -1,4 +1,7 @@
+"use client";
+
import type { Column } from "@tanstack/react-table";
+import { useState } from "react";
import { classNames } from "@calcom/lib";
@@ -17,18 +20,68 @@ import {
} from "../command";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
+interface FilterOption {
+ label: string;
+ value: string;
+ icon?: IconName;
+ options?: FilterOption[];
+}
+
interface DataTableFilter {
column?: Column;
title?: string;
- options: {
- label: string;
- value: string;
- icon?: IconName;
- }[];
+ options: FilterOption[];
}
+
export function DataTableFilter({ column, title, options }: DataTableFilter) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
+ const [currentOptions, setCurrentOptions] = useState(options);
+ const [navigationPath, setNavigationPath] = useState([]);
+
+ const flattenOptions = (opts: FilterOption[]): FilterOption[] => {
+ return opts.reduce((acc, option) => {
+ acc.push(option);
+ if (option.options) {
+ acc.push(...flattenOptions(option.options));
+ }
+ return acc;
+ }, [] as FilterOption[]);
+ };
+
+ const allOptions = flattenOptions(options);
+
+ const handleSelect = (option: FilterOption) => {
+ if (option.options) {
+ setCurrentOptions(option.options);
+ setNavigationPath([...navigationPath, option.label]);
+ } else {
+ if (selectedValues.has(option.value)) {
+ selectedValues.delete(option.value);
+ } else {
+ selectedValues.add(option.value);
+ }
+ const filterValues = Array.from(selectedValues);
+ column?.setFilterValue(filterValues.length ? filterValues : undefined);
+ }
+ };
+
+ const handleBack = () => {
+ if (navigationPath.length > 0) {
+ const newPath = [...navigationPath];
+ newPath.pop();
+ setNavigationPath(newPath);
+
+ let newOptions = options;
+ for (const label of newPath) {
+ const found = newOptions.find((o) => o.label === label);
+ if (found && found.options) {
+ newOptions = found.options;
+ }
+ }
+ setCurrentOptions(newOptions);
+ }
+ };
return (
@@ -44,7 +97,7 @@ export function DataTableFilter({ column, title, options }: DataT
{selectedValues.size}
) : (
- options
+ allOptions
.filter((option) => selectedValues.has(option.value))
.map((option) => (
@@ -62,32 +115,32 @@ export function DataTableFilter({ column, title, options }: DataT
No results found.
+ {navigationPath.length > 0 && (
+
+
+
+ Back
+
+
+ )}
- {options.map((option) => {
- // TODO: It would be nice to pull these from data instead of options
+ {currentOptions.map((option) => {
const isSelected = selectedValues.has(option.value);
return (
- {
- if (isSelected) {
- selectedValues.delete(option.value);
- } else {
- selectedValues.add(option.value);
- }
- const filterValues = Array.from(selectedValues);
- column?.setFilterValue(filterValues.length ? filterValues : undefined);
- }}>
-
-
-
+ handleSelect(option)}>
+ {!option.options && (
+
+
+
+ )}
{option.icon && }
{option.label}
- {facets?.get(option.value) && (
+ {option.options && }
+ {!option.options && facets?.get(option.value) && (
{facets.get(option.value)}
diff --git a/packages/ui/components/data-table/DataTablePagination.tsx b/packages/ui/components/data-table/DataTablePagination.tsx
index 8533ef8a01..6e333b60a4 100644
--- a/packages/ui/components/data-table/DataTablePagination.tsx
+++ b/packages/ui/components/data-table/DataTablePagination.tsx
@@ -1,59 +1,19 @@
-import type { Table } from "@tanstack/react-table";
+"use client";
-import { Button } from "../button";
+import type { Table } from "@tanstack/react-table";
interface DataTablePaginationProps {
table: Table;
+ totalDbDataCount: number;
}
-export function DataTablePagination({ table }: DataTablePaginationProps) {
+export function DataTablePagination({ table, totalDbDataCount }: DataTablePaginationProps) {
+ const loadedCount = table.getFilteredRowModel().rows.length;
+
return (
-
-
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
- selected.
-
-
-
- Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
-
-
- table.setPageIndex(0)}>
- Go to first page
-
- table.previousPage()}
- disabled={!table.getCanPreviousPage()}
- StartIcon="chevron-left">
- Go to previous page
-
- table.nextPage()}>
- Go to next page
-
- table.setPageIndex(table.getPageCount())}>
- Go to last page
-
-
-
-
+
+ Loaded {`${loadedCount}`} of
+ {`${totalDbDataCount}`}
+
);
}
diff --git a/packages/ui/components/data-table/DataTableSelectionBar.tsx b/packages/ui/components/data-table/DataTableSelectionBar.tsx
index 28a57a4664..b8ed3e1da0 100644
--- a/packages/ui/components/data-table/DataTableSelectionBar.tsx
+++ b/packages/ui/components/data-table/DataTableSelectionBar.tsx
@@ -1,10 +1,11 @@
+"use client";
+
import type { Table } from "@tanstack/react-table";
-import type { Table as TableType } from "@tanstack/table-core/build/lib/types";
-import { AnimatePresence } from "framer-motion";
-import { Fragment } from "react";
+import { forwardRef } from "react";
+
+import { classNames } from "@calcom/lib";
import type { IconName } from "../..";
-import { Button } from "../button";
export type ActionItem =
| {
@@ -20,51 +21,26 @@ export type ActionItem =
needsXSelected?: number;
};
-interface DataTableSelectionBarProps {
- table: Table;
- actions?: ActionItem[];
- renderAboveSelection?: (table: TableType) => React.ReactNode;
-}
-
-export function DataTableSelectionBar({
- table,
- actions,
- renderAboveSelection,
-}: DataTableSelectionBarProps) {
- const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
- const isVisible = numberOfSelectedRows > 0;
-
- // Hacky left % to center
- const actionsVisible = actions?.filter((a) => {
- if (!a.needsXSelected) return true;
- return a.needsXSelected <= numberOfSelectedRows;
- });
-
+const Root = forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & { showSelectionCount?: boolean }
+>(({ children, ...props }, ref) => {
return (
-
- {isVisible ? (
-
- {renderAboveSelection && renderAboveSelection(table)}
-
-
- {numberOfSelectedRows} selected
-
- {actionsVisible?.map((action, index) => {
- return (
-
- {action.type === "action" ? (
-
- {action.label}
-
- ) : action.type === "render" ? (
- action.render(table)
- ) : null}
-
- );
- })}
-
-
- ) : null}
-
+
+ {children}
+
);
-}
+});
+
+Root.displayName = "Root";
+
+export const DataTableSelectionBar = {
+ Root,
+};
diff --git a/packages/ui/components/data-table/DataTableToolbar.tsx b/packages/ui/components/data-table/DataTableToolbar.tsx
index aaebbb3de0..9e30abd3ae 100644
--- a/packages/ui/components/data-table/DataTableToolbar.tsx
+++ b/packages/ui/components/data-table/DataTableToolbar.tsx
@@ -1,44 +1,45 @@
"use client";
import type { Table } from "@tanstack/react-table";
-import { useEffect, useState } from "react";
+import { useEffect, useState, forwardRef } from "react";
+import type { ComponentPropsWithoutRef } from "react";
+import { classNames } from "@calcom/lib";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import type { ButtonProps } from "../button";
import { Button } from "../button";
import { Input } from "../form";
-import type { IconName } from "../icon/icon-names";
-import { DataTableFilter } from "./DataTableFilter";
-export type FilterableItems = {
- title: string;
- tableAccessor: string;
- options: {
- label: string;
- value: string;
- icon?: IconName;
- }[];
-}[];
+interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
+ children: React.ReactNode;
+}
-interface DataTableToolbarProps {
+const Root = forwardRef(function DataTableToolbar(
+ { children, className },
+ ref
+) {
+ return (
+
+ {children}
+
+ );
+});
+
+interface SearchBarProps {
table: Table;
- filterableItems?: FilterableItems;
searchKey?: string;
- tableCTA?: React.ReactNode;
onSearch?: (value: string) => void;
}
-export function DataTableToolbar({
- table,
- filterableItems,
- tableCTA,
- searchKey,
- onSearch,
-}: DataTableToolbarProps) {
- // TODO: Is there a better way to check if the table is filtered?
- // If you select ALL filters for a column, the table is not filtered and we dont get a reset button
- const isFiltered = table.getState().columnFilters.length > 0;
+function SearchBarComponent(
+ { table, searchKey, onSearch }: SearchBarProps,
+ ref: React.Ref
+) {
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -46,54 +47,80 @@ export function DataTableToolbar({
onSearch?.(debouncedSearchTerm);
}, [debouncedSearchTerm, onSearch]);
- const { t } = useLocale();
+ if (onSearch) {
+ return (
+ setSearchTerm(event.target.value.trim())}
+ />
+ );
+ }
+
+ if (!searchKey) {
+ console.error("searchKey is required if onSearch is not provided");
+ return null;
+ }
return (
-
- {searchKey && (
- table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())}
- />
- )}
- {onSearch && (
- {
- setSearchTerm(e.target.value);
- }}
- />
- )}
- {isFiltered && (
- table.resetColumnFilters()}
- className="h-8 px-2 lg:px-3">
- {t("clear")}
-
- )}
-
- {filterableItems &&
- filterableItems?.map((item) => {
- const foundColumn = table.getColumn(item.tableAccessor);
- if (foundColumn?.getCanFilter()) {
- return (
-
- );
- }
- })}
-
- {tableCTA ? tableCTA : null}
-
+ {
+ return table.getColumn(searchKey)?.setFilterValue(event.target.value.trim());
+ }}
+ />
);
}
+
+const SearchBar = forwardRef(SearchBarComponent) as (
+ props: SearchBarProps & { ref?: React.Ref }
+) => ReturnType;
+
+interface ClearFiltersButtonProps {
+ table: Table;
+}
+
+function ClearFiltersButtonComponent(
+ { table }: ClearFiltersButtonProps,
+ ref: React.Ref
+) {
+ const { t } = useLocale();
+ const isFiltered = table.getState().columnFilters.length > 0;
+ if (!isFiltered) return null;
+ return (
+ table.resetColumnFilters()}
+ className="h-8 px-2 lg:px-3">
+ {t("clear")}
+
+ );
+}
+
+const ClearFiltersButton = forwardRef(ClearFiltersButtonComponent) as (
+ props: ClearFiltersButtonProps & { ref?: React.Ref }
+) => ReturnType;
+
+function CTAComponent(
+ { children, onClick, color = "primary", ...rest }: ButtonProps,
+ ref: React.Ref
+) {
+ return (
+
+ {children}
+
+ );
+}
+
+const CTA = forwardRef(CTAComponent) as (
+ props: ButtonProps & { ref?: React.Ref }
+) => ReturnType;
+
+export const DataTableToolbar = { Root, SearchBar, ClearFiltersButton, CTA };
diff --git a/packages/ui/components/data-table/__storybook__/columns.tsx b/packages/ui/components/data-table/__storybook__/columns.tsx
deleted file mode 100644
index e041aa9bfa..0000000000
--- a/packages/ui/components/data-table/__storybook__/columns.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import type { ColumnDef } from "@tanstack/react-table";
-
-import { Badge } from "../../badge";
-import { Checkbox } from "../../form";
-import type { FilterableItems } from "../DataTableToolbar";
-import type { DataTableUserStorybook } from "./data";
-
-export const columns: ColumnDef[] = [
- {
- id: "select",
- header: ({ table }) => (
- table.toggleAllPageRowsSelected(!!value)}
- aria-label="Select all"
- className="translate-y-[2px]"
- />
- ),
- cell: ({ row }) => (
- row.toggleSelected(!!value)}
- aria-label="Select row"
- className="translate-y-[2px]"
- />
- ),
- },
- {
- accessorKey: "username",
- header: "Username",
- },
- {
- accessorKey: "email",
- header: "Email",
- },
- {
- accessorKey: "role",
- header: "Role",
- cell: ({ row, table }) => {
- const user = row.original;
- const BadgeColor = user.role === "admin" ? "blue" : "gray";
-
- return (
- {
- table.getColumn("role")?.setFilterValue(user.role);
- }}>
- {user.role}
-
- );
- },
- filterFn: (rows, id, filterValue) => {
- return filterValue.includes(rows.getValue(id));
- },
- },
-];
-
-export const filterableItems: FilterableItems = [
- {
- title: "Role",
- tableAccessor: "role",
- options: [
- {
- label: "Admin",
- value: "admin",
- },
- {
- label: "User",
- value: "user",
- },
- {
- label: "Owner",
- value: "owner",
- },
- ],
- },
-];
diff --git a/packages/ui/components/data-table/__storybook__/data.tsx b/packages/ui/components/data-table/__storybook__/data.tsx
deleted file mode 100644
index fe5166485b..0000000000
--- a/packages/ui/components/data-table/__storybook__/data.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-export type DataTableUserStorybook = {
- id: string;
- username: string;
- email: string;
- role: "admin" | "user";
-};
-
-export const dataTableSelectionActions = [
- {
- label: "Add To Team",
- onClick: () => {
- console.log("Add To Team");
- },
- icon: "users",
- },
- {
- label: "Delete",
- onClick: () => {
- console.log("Delete");
- },
- icon: "stop-circle",
- },
-];
-
-export const dataTableDemousers: DataTableUserStorybook[] = [
- {
- id: "728ed52f",
- email: "m@example.com",
- username: "m",
- role: "admin",
- },
- {
- id: "489e1d42",
- email: "example@gmail.com",
- username: "e",
- role: "user",
- },
- {
- id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f",
- email: "Keshawn_Schroeder@hotmail.com",
- username: "Ava_Waelchi",
- role: "user",
- },
- {
- id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b",
- email: "Jovanny_Grant@hotmail.com",
- username: "Kamren_Gerhold",
- role: "admin",
- },
- {
- id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a",
- email: "Emilie.McKenzie@yahoo.com",
- username: "Lennie_Harber",
- role: "user",
- },
- {
- id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5",
- email: "Jolie_Beatty@hotmail.com",
- username: "Lorenzo_Will",
- role: "admin",
- },
- {
- id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e",
- email: "Giovanny_Cruickshank@hotmail.com",
- username: "Monserrat_Lang",
- role: "user",
- },
- {
- id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e",
- email: "Lela_Haag@hotmail.com",
- username: "Eddie_Effertz",
- role: "user",
- },
- {
- id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a",
- email: "Lura_Kohler@gmail.com",
- username: "Alyce_Olson",
- role: "user",
- },
- {
- id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a",
- email: "Maurice.Koch@hotmail.com",
- username: "Jovanny_Kiehn",
- role: "admin",
- },
- {
- id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i",
- email: "Brenda_Bernhard@yahoo.com",
- username: "Aurelia_Kemmer",
- role: "user",
- },
- {
- id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l",
- email: "Lorenzo_Rippin@hotmail.com",
- username: "Waino_Lang",
- role: "admin",
- },
-];
diff --git a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx b/packages/ui/components/data-table/__storybook__/datatable.stories.mdx
deleted file mode 100644
index 37e33e5d0b..0000000000
--- a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
-
-import {
- Examples,
- Example,
- Note,
- Title,
- CustomArgsTable,
- VariantsTable,
- VariantRow,
-} from "@calcom/storybook/components";
-
-import { DataTable } from "../";
-import { columns, filterableItems } from "./columns";
-import { dataTableDemousers, dataTableSelectionActions } from "./data";
-
-
-
-
-
-## Definition
-
-The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation.
-
-## Structure
-
-The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection.
-
-
-
-## Dialog Story
-
-
-
- {(args) => (
-
-
-
-
-
- )}
-
-
diff --git a/packages/ui/components/data-table/filters/index.tsx b/packages/ui/components/data-table/filters/index.tsx
new file mode 100644
index 0000000000..7aedf9d60d
--- /dev/null
+++ b/packages/ui/components/data-table/filters/index.tsx
@@ -0,0 +1,269 @@
+"use client";
+
+import { type Table } from "@tanstack/react-table";
+import { forwardRef, useState, useMemo } from "react";
+
+import { classNames } from "@calcom/lib";
+import { useLocale } from "@calcom/lib/hooks/useLocale";
+import type { ButtonProps } from "@calcom/ui";
+import {
+ Button,
+ buttonClasses,
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+ Command,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandSeparator,
+ Icon,
+} from "@calcom/ui";
+
+import { useFiltersSearchState } from "./utils";
+
+interface ColumnVisiblityProps {
+ table: Table;
+}
+
+function ColumnVisibilityButtonComponent(
+ {
+ children,
+ color = "secondary",
+ EndIcon = "sliders-vertical",
+ table,
+ ...rest
+ }: ColumnVisiblityProps & ButtonProps,
+ ref: React.Ref
+) {
+ const { t } = useLocale();
+ const allColumns = table.getAllLeafColumns();
+ const [open, setOpen] = useState(false);
+
+ return (
+
+
+
+ {children ? children : t("View")}
+
+
+
+
+
+
+ {t("no_columns_found")}
+
+ {allColumns.map((column) => {
+ const canHide = column.getCanHide();
+ if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
+ return null;
+ const isVisible = column.getIsVisible();
+ return (
+ column.toggleVisibility(!isVisible)}>
+
+
+
+ {column.columnDef.header}
+
+ );
+ })}
+
+
+
+
+ allColumns.forEach((column) => column.toggleVisibility(true))}
+ className={classNames(
+ "w-full justify-center text-center",
+ buttonClasses({ color: "secondary" })
+ )}>
+ {t("show_all_columns")}
+
+
+
+
+
+ );
+}
+
+const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as (
+ props: ColumnVisiblityProps & ButtonProps & { ref?: React.Ref }
+) => ReturnType;
+
+// Filters
+interface FilterButtonProps {
+ table: Table;
+ omit?: string[];
+}
+
+function FilterButtonComponent(
+ { table, omit }: FilterButtonProps,
+ ref: React.Ref
+) {
+ const { t } = useLocale();
+ const [_state, _setState] = useFiltersSearchState();
+
+ const activeFilters = _state.activeFilters;
+ const columns = table
+ .getAllColumns()
+ .filter((column) => column.getCanFilter())
+ .filter((column) => !omit?.includes(column.id));
+
+ const filterableColumns = useMemo(() => {
+ return columns.map((column) => ({
+ id: column.id,
+ title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
+ options: column.getFacetedUniqueValues(),
+ }));
+ }, [columns]);
+
+ const handleAddFilter = (columnId: string) => {
+ if (!activeFilters?.some((filter) => filter.f === columnId)) {
+ _setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
+ }
+ };
+
+ return (
+
+
+
+
+
+ {t("add_filter")}
+
+
+
+
+
+
+ {t("no_columns_found")}
+ {filterableColumns.map((column) => {
+ if (activeFilters?.some((filter) => filter.f === column.id)) return null;
+ return (
+ handleAddFilter(column.id)}>
+ {column.title}
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+const FilterButton = forwardRef(FilterButtonComponent) as (
+ props: FilterButtonProps & { ref?: React.Ref; omit?: string[] }
+) => ReturnType;
+
+// Add the new ActiveFilters component
+interface ActiveFiltersProps {
+ table: Table;
+}
+
+function ActiveFilters({ table }: ActiveFiltersProps) {
+ const { t } = useLocale();
+ const [_state, _setState] = useFiltersSearchState();
+
+ const columns = table.getAllColumns().filter((column) => column.getCanFilter());
+
+ const filterableColumns = useMemo(() => {
+ return columns.map((column) => {
+ return {
+ id: column.id,
+ title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
+ options: column.getFacetedUniqueValues(),
+ };
+ });
+ }, [columns]);
+
+ const handleRemoveFilter = (columnId: string) => {
+ _setState({ activeFilters: (_state.activeFilters || []).filter((filter) => filter.f !== columnId) });
+ table.getColumn(columnId)?.setFilterValue(undefined);
+ };
+
+ return (
+ <>
+ {(_state.activeFilters || []).map((filter) => {
+ const column = filterableColumns.find((col) => col.id === filter.f);
+ if (!column) return null;
+ return (
+
+
+
+ {column.title}
+
+
+
+
+
+
+
+ {t("no_options_found")}
+ {Array.from(column.options).map(([option]) => {
+ if (!option) return null;
+ return (
+ {
+ const newFilterValue = filter.v?.includes(option)
+ ? filter.v?.filter((value) => value !== option)
+ : [...(filter.v || []), option];
+ _setState({
+ activeFilters: _state.activeFilters.map((f) =>
+ f.f === filter.f ? { ...f, v: newFilterValue } : f
+ ),
+ });
+ table
+ .getColumn(filter.f)
+ ?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
+ }}>
+
+ {Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
+ (table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option) && (
+
+ )}
+
+ {option}
+
+ );
+ })}
+
+
+
+ {
+ handleRemoveFilter(column.id);
+ }}
+ className={classNames(
+ "w-full justify-center text-center",
+ buttonClasses({ color: "secondary" })
+ )}>
+ {t("clear")}
+
+
+
+
+
+ );
+ })}
+ >
+ );
+}
+
+// Update the export to include ActiveFilters
+export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
diff --git a/packages/ui/components/data-table/filters/utils.ts b/packages/ui/components/data-table/filters/utils.ts
new file mode 100644
index 0000000000..69c483c4e1
--- /dev/null
+++ b/packages/ui/components/data-table/filters/utils.ts
@@ -0,0 +1,17 @@
+"use client";
+
+import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
+import { z } from "zod";
+
+const filterSchema = z.object({
+ f: z.string(),
+ v: z.array(z.string()).optional(),
+});
+
+export const filtersSearchParams = {
+ activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]),
+};
+
+export function useFiltersSearchState() {
+ return useQueryStates(filtersSearchParams);
+}
diff --git a/packages/ui/components/data-table/index.tsx b/packages/ui/components/data-table/index.tsx
index ff0ac6e17c..c892e830d4 100644
--- a/packages/ui/components/data-table/index.tsx
+++ b/packages/ui/components/data-table/index.tsx
@@ -1,98 +1,40 @@
"use client";
-import type {
- ColumnDef,
- ColumnFiltersState,
- Row,
- SortingState,
- VisibilityState,
- Table as TableType,
-} from "@tanstack/react-table";
-import {
- flexRender,
- getCoreRowModel,
- getFacetedRowModel,
- getFacetedUniqueValues,
- getFilteredRowModel,
- getSortedRowModel,
- useReactTable,
-} from "@tanstack/react-table";
-import { useState } from "react";
+import type { Row } from "@tanstack/react-table";
+import { flexRender } from "@tanstack/react-table";
+import type { Table as ReactTableType } from "@tanstack/react-table";
import { useVirtual } from "react-virtual";
import classNames from "@calcom/lib/classNames";
+import Icon from "../icon/Icon";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
-import type { ActionItem } from "./DataTableSelectionBar";
-import { DataTableSelectionBar } from "./DataTableSelectionBar";
-import type { FilterableItems } from "./DataTableToolbar";
-import { DataTableToolbar } from "./DataTableToolbar";
+
+// Export DataTable components under a common namespace for better clarity
+export { DataTableToolbar } from "./DataTableToolbar";
+export { DataTableFilters } from "./filters";
export interface DataTableProps {
+ table: ReactTableType;
tableContainerRef: React.RefObject;
- columns: ColumnDef[];
- data: TData[];
- searchKey?: string;
- onSearch?: (value: string) => void;
- filterableItems?: FilterableItems;
- selectionOptions?: ActionItem[];
- renderAboveSelection?: (table: TableType) => React.ReactNode;
- tableCTA?: React.ReactNode;
isPending?: boolean;
onRowMouseclick?: (row: Row) => void;
onScroll?: (e: React.UIEvent) => void;
- CTA?: React.ReactNode;
tableOverlay?: React.ReactNode;
variant?: "default" | "compact";
"data-testid"?: string;
+ children?: React.ReactNode;
}
-
export function DataTable({
- columns,
- data,
- filterableItems,
- tableCTA,
- searchKey,
- selectionOptions,
+ table,
tableContainerRef,
isPending,
- tableOverlay,
variant,
- renderAboveSelection,
- /** This should only really be used if you dont have actions in a row. */
- onSearch,
onRowMouseclick,
onScroll,
+ children,
...rest
-}: DataTableProps) {
- const [rowSelection, setRowSelection] = useState({});
- const [columnVisibility, setColumnVisibility] = useState({});
- const [columnFilters, setColumnFilters] = useState([]);
- const [sorting, setSorting] = useState([]);
-
- const table = useReactTable({
- data,
- columns,
- state: {
- sorting,
- columnVisibility,
- rowSelection,
- columnFilters,
- },
- enableRowSelection: true,
- debugTable: true,
- manualPagination: true,
- onRowSelectionChange: setRowSelection,
- onSortingChange: setSorting,
- onColumnFiltersChange: setColumnFilters,
- onColumnVisibilityChange: setColumnVisibility,
- getCoreRowModel: getCoreRowModel(),
- getFilteredRowModel: getFilteredRowModel(),
- getSortedRowModel: getSortedRowModel(),
- getFacetedRowModel: getFacetedRowModel(),
- getFacetedUniqueValues: getFacetedUniqueValues(),
- });
-
+}: DataTableProps & React.ComponentPropsWithoutRef<"div">) {
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtual({
@@ -106,81 +48,95 @@ export function DataTable({
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
return (
-
-
-
-
-
- {table.getHeaderGroups().map((headerGroup) => (
-
- {headerGroup.headers.map((header) => {
- return (
-
- {header.isPlaceholder
- ? null
- : flexRender(header.column.columnDef.header, header.getContext())}
-
- );
- })}
-
- ))}
-
-
- {paddingTop > 0 && (
-
-
-
- )}
- {virtualRows && !isPending ? (
- virtualRows.map((virtualRow) => {
- const row = rows[virtualRow.index] as Row;
- return (
- onRowMouseclick && onRowMouseclick(row)}
- className={classNames(
- onRowMouseclick && "hover:cursor-pointer",
- variant === "compact" && "!border-0"
- )}>
- {row.getVisibleCells().map((cell) => {
- return (
-
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
-
- );
- })}
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => (
+
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+ {header.column.getIsSorted() && (
+
+ )}
+
+
+ ))}
- );
- })
- ) : (
-
-
- No results.
-
-
- )}
- {paddingBottom > 0 && (
-
-
-
- )}
-
- {tableOverlay && tableOverlay}
-
+ ))}
+
+
+ {paddingTop > 0 && (
+
+
+
+ )}
+ {virtualRows && !isPending ? (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index] as Row;
+ return (
+ onRowMouseclick && onRowMouseclick(row)}
+ className={classNames(
+ onRowMouseclick && "hover:cursor-pointer",
+ variant === "compact" && "!border-0"
+ )}>
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ );
+ })
+ ) : (
+
+
+ No results.
+
+
+ )}
+ {paddingBottom > 0 && (
+
+
+
+ )}
+
+
+
+
- {/* */}
-
+ {children}
);
}
diff --git a/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts b/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts
new file mode 100644
index 0000000000..ef224bd3c6
--- /dev/null
+++ b/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts
@@ -0,0 +1,27 @@
+import { useCallback, useEffect } from "react";
+
+export const useFetchMoreOnBottomReached = (
+ tableContainerRef: React.RefObject