feat: attributes filter / refactor of user data table (#17014)

* WIP restored from .git cache

* fix exports

* sortable row model

* feat column visibility component

* wip filters with nuqs

* pull in unique values from table into filters

* correctly assign filters via v/f

* inital selection bar refactor

* data-table selection bar + optmistic update of delete

* dynamic link

* migrate member list table to new data-table

* total list shows filtered value > db valuie

* add filters for attributes

* type errors

* make content bigger on lg

* add mb-6 to teams user datatable to match spacing spec

* correctly render multi-badge

* fix: masss asignment optimistic UI

* fix type errors

* remove log

* fix toolbar type error

* chore: Remove debug artifact

* type errors

* Update apps/web/public/static/locales/en/common.json

* use max-w-fit

* chore: Remove unused translation now we don't specify 'mass' in assign

* perf: fix: use the onBlur event to prevent focus loss whilst the list is rerendering

* Move the data-table exports together in the main barrel, then import

* fix exports that were lost in a merge

* fix exports that were lost in a merge

* fix groupteammapping/availbilityslider

* fix overflow problems

* add scrollbar-thin class

* fix type error

* user serverside values for faceted filters

* pass filters to serverside

* filter serverside

* fix team server side filter

* add loaded x of y

* attributes icon change

* correct implementation for text/input attr optimistic

* type check fixes

* fix platform checks

* fix types again

* fix types again

* fix types again

* add use client

* add use client

* fix-types

* fix: Add missing translation in EN

* fix e2e tests via testid

* fix e2e tests via testid

* fix: Member invite popup not popping up

* Update copyInviteLink to new-member-button testid

* Hopefully fix test ids this time

* fix: Use the right buttons on the right pages

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
sean-brydon
2024-10-15 17:45:11 +00:00
committed by GitHub
co-authored by Alex van Andel Peer Richelsen Udit Takkar
parent 0b914bef79
commit 7e44e686e8
43 changed files with 1462 additions and 1049 deletions
@@ -2,10 +2,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui";
import type { User } from "../UserListTable";
import type { UserTableUser } from "../types";
interface Props {
users: User[];
users: UserTableUser[];
onRemove: () => 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");
@@ -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<T extends { username: string | null }>({
table,
domain,
}: {
table: Table<T>;
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 ? (
<div className="w-full gap-1 rounded-lg text-sm font-medium leading-none md:flex">
<div className="max-w-[300px] items-center truncate p-2">
<p>{domainWithoutHttps}</p>
</div>
<div className="ml-auto flex items-center">
<Button StartIcon="copy" size="sm" onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
{!isCopied ? t("copy") : t("copied")}
</Button>
<Button
EndIcon="external-link"
size="sm"
href={dynamicLinkOfSelectedUsers}
target="_blank"
rel="noopener noreferrer">
Open
</Button>
</div>
</div>
) : null}
</>
);
}
@@ -22,10 +22,10 @@ import {
Icon,
} from "@calcom/ui";
import type { User } from "../UserListTable";
import type { UserTableUser } from "../types";
interface Props {
table: Table<User>;
table: Table<UserTableUser>;
orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined;
}
@@ -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<User>;
table: Table<UserTableUser>;
filters: ColumnFiltersState;
}
function useSelectedAttributes() {
@@ -78,7 +81,7 @@ function SelectedAttributeToAssign() {
return (
<CommandList>
<div className="flex flex items-center items-center gap-2 border-b px-3 py-2">
<div className="flex items-center gap-2 border-b px-3 py-2">
<span className="block">{foundAttribute.name}</span>
{translateableType && <span className="text-muted block text-xs">({t(translateableType)})</span>}
</div>
@@ -119,9 +122,11 @@ function SelectedAttributeToAssign() {
<>
<CommandItem>
<Input
value={selectedAttributeOption[0] || ""}
defaultValue={selectedAttributeOption[0] || ""}
type={foundAttribute.type === "TEXT" ? "text" : "number"}
onChange={(e) => {
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) {
<>
<Popover>
<PopoverTrigger asChild>
<Button StartIcon="users">{t("mass_assign_attributes")}</Button>
<Button StartIcon="map-pin">{t("add_attributes")}</Button>
</PopoverTrigger>
{/* We dont really use shadows much - but its needed here */}
<PopoverContent className="p-0 shadow-md" align="start" sideOffset={12}>
@@ -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")}
@@ -20,10 +20,10 @@ import {
showToast,
} from "@calcom/ui";
import type { User } from "../UserListTable";
import type { UserTableUser } from "../types";
interface Props {
table: Table<User>;
table: Table<UserTableUser>;
}
export function TeamListBulkAction({ table }: Props) {
@@ -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<Action> }) {
export function ChangeUserRoleModal(props: { state: UserTableState; dispatch: Dispatch<UserTableAction> }) {
const { data: session } = useSession();
const orgId = session?.user.org?.id;
if (!orgId || !props.state.changeMemberRole.user) return null;
@@ -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<Action> }) {
export function DeleteMemberModal({
state,
dispatch,
}: {
state: UserTableState;
dispatch: Dispatch<UserTableAction>;
}) {
const { t } = useLocale();
const { data: session } = useSession();
const utils = trpc.useUtils();
@@ -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<Action>;
dispatch: Dispatch<UserTableAction>;
}) {
const setEditMode = useEditMode((state) => state.setEditMode);
const [mutationLoading, setMutationLoading] = useState(false);
@@ -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<Action> }) {
export function EditUserSheet({
state,
dispatch,
}: {
state: UserTableState;
dispatch: Dispatch<UserTableAction>;
}) {
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,
@@ -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<Action> }) {
export function ImpersonationMemberModal(props: {
state: UserTableState;
dispatch: Dispatch<UserTableAction>;
}) {
const { t } = useLocale();
const { data: session } = useSession();
const teamId = session?.user.org?.id;
@@ -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<Action>;
dispatch: Dispatch<UserTableAction>;
}
export function InviteMemberModal(props: Props) {
@@ -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<HTMLDivElement>(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<ColumnFiltersState>([]);
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<User>[] = [
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) => (
<Badge key={index} variant="gray" className="mr-1">
{attributeValue.value}
</Badge>
))}
</>
);
},
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<UserTableUser>[]) ?? []
);
};
const cols: ColumnDef<UserTableUser>[] = [
// Disabling select for this PR: Will work on actions etc in a follow up
{
id: "select",
enableHiding: false,
enableSorting: false,
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
@@ -176,7 +206,10 @@ export function UserListTable() {
{
id: "member",
accessorFn: (data) => 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<User>[] = [
{
type: "render",
render: (table) => <TeamListBulkAction table={table} />,
},
{
type: "render",
render: (table) => <MassAssignAttributesBulkAction table={table} />,
},
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} orgTeams={teams} />,
},
{
type: "render",
render: (table) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
),
},
];
const selectionOptionsForPlatform: ActionItem<User>[] = [
{
type: "render",
render: (table: Table<User>) => (
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => 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 (
<>
<DataTable
onRowMouseclick={(row) => {
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<User>) => {
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 ? (
<m.div
layout
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
<div className="w-[300px] items-center truncate p-2">
<p>{domainWithoutHttps}</p>
</div>
<div className="ml-auto flex items-center">
<Button
StartIcon="copy"
size="sm"
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
{!isCopied ? t("copy") : t("copied")}
</Button>
<Button
EndIcon="external-link"
size="sm"
href={dynamicLinkOfSelectedUsers}
target="_blank"
rel="noopener noreferrer">
Open
</Button>
</div>
</m.div>
) : null}
</>
);
}}
// className="lg:max-w-screen-lg"
table={table}
tableContainerRef={tableContainerRef}
tableCTA={
adminOrOwner && (
<Button
type="button"
color="primary"
StartIcon="plus"
size="sm"
className="rounded-md"
onClick={() =>
dispatch({
type: "INVITE_MEMBER",
payload: {
showModal: true,
},
})
}
data-testid="new-organization-member-button">
{t("add")}
</Button>
)
}
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)}>
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
<div className="flex w-full gap-2">
<DataTableToolbar.SearchBar table={table} onSearch={(value) => 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 */}
<DataTableFilters.FilterButton table={table} omit={["member"]} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{adminOrOwner && (
<DataTableToolbar.CTA
type="button"
color="primary"
StartIcon="plus"
className="rounded-md"
onClick={() =>
dispatch({
type: "INVITE_MEMBER",
payload: {
showModal: true,
},
})
}
data-testid="new-organization-member-button">
{t("add")}
</DataTableToolbar.CTA>
)}
</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} />
</div>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root style={{ bottom: "5rem" }}>
<DynamicLink table={table} domain={domain} />
</DataTableSelectionBar.Root>
)}
{numberOfSelectedRows > 0 && (
<DataTableSelectionBar.Root>
<p className="text-brand-subtle w-full px-2 text-center leading-none">
{numberOfSelectedRows} selected
</p>
{!isPlatformUser ? (
<>
<TeamListBulkAction table={table} />
{numberOfSelectedRows >= 2 && (
<Button onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)} StartIcon="handshake">
Group Meeting
</Button>
)}
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
<EventTypesList table={table} orgTeams={teams} />
</>
) : null}
<DeleteBulkUsers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
/>
</DataTableSelectionBar.Root>
)}
</DataTable>
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
@@ -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<Action>;
user: UserTableUser;
dispatch: React.Dispatch<UserTableAction>;
domain: string;
permissionsForUser: {
canEdit: boolean;
@@ -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";
};
@@ -0,0 +1,27 @@
import { useCallback, useEffect } from "react";
export const useFetchMoreOnBottomReached = (
tableContainerRef: React.RefObject<HTMLDivElement>,
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;
};