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
@@ -1,5 +1,5 @@
import MembersView from "@calcom/features/ee/organizations/pages/settings/members";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
import PageWrapper from "@components/PageWrapper";
@@ -8,6 +8,10 @@ export {
type PageProps,
} from "@calcom/features/ee/organizations/pages/settings/getServerSidePropsMembers";
export const getLayout = (page: React.ReactElement) => (
<SettingsLayout containerClassName="lg:max-w-screen-2xl">{page}</SettingsLayout>
);
const Page = () => <MembersView />;
Page.getLayout = getLayout;
@@ -49,7 +49,7 @@ export async function acceptTeamOrOrgInvite(page: Page) {
}
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
await page.locator('button:text("Add")').click();
await page.getByTestId("new-organization-member-button").click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await submitAndWaitForResponse(page, "/api/trpc/teams/inviteMember?batch=1", {
action: () => page.locator('button:text("Send invite")').click(),
@@ -106,7 +106,7 @@ test.describe("Organization", () => {
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" });
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
await inviteAnEmail(page, invitedUserEmail);
await inviteAnEmail(page, invitedUserEmail, true);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
@@ -164,7 +164,7 @@ test.describe("Organization", () => {
await test.step("By invite link", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
const inviteLink = await copyInviteLink(page);
const inviteLink = await copyInviteLink(page, true);
const email = users.trackEmail({ username: "rick", domain: "domain.com" });
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
@@ -333,7 +333,7 @@ test.describe("Organization", () => {
await page.goto(`/settings/teams/${team.id}/members`);
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
await inviteAnEmail(page, invitedUserEmail);
await inviteAnEmail(page, invitedUserEmail, true);
await expectUserToBeAMemberOfTeam({
page,
teamId: team.id,
@@ -391,7 +391,7 @@ test.describe("Organization", () => {
await test.step("By invite link", async () => {
await page.goto(`/settings/teams/${team.id}/members`);
const inviteLink = await copyInviteLink(page);
const inviteLink = await copyInviteLink(page, true);
const email = users.trackEmail({ username: "rick", domain: "example.com" });
// '-domain' because the email doesn't match orgAutoAcceptEmail
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
@@ -481,8 +481,12 @@ export async function signupFromEmailInviteLink({
await signupPage.close();
}
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
await page.locator('button:text("Add")').click();
async function inviteAnEmail(page: Page, invitedUserEmail: string, teamPage?: boolean) {
if (teamPage) {
await page.getByTestId("new-member-button").click();
} else {
await page.getByTestId("new-organization-member-button").click();
}
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
await page.locator('button:text("Send invite")').click();
@@ -558,8 +562,12 @@ function assertInviteLink(inviteLink: string | null | undefined): asserts invite
if (!inviteLink) throw new Error("Invite link not found");
}
async function copyInviteLink(page: Page) {
await page.locator('button:text("Add")').click();
async function copyInviteLink(page: Page, teamPage?: boolean) {
if (teamPage) {
await page.getByTestId("new-member-button").click();
} else {
await page.getByTestId("new-organization-member-button").click();
}
const inviteLink = await getInviteLink(page);
return inviteLink;
}
@@ -25,7 +25,7 @@ test.describe("Team", () => {
username: "rick",
domain: `domain-${Date.now()}.com`,
});
await page.locator(`button:text("${t("add")}")`).click();
await page.getByTestId("new-member-button").click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
const inviteLink = await expectInvitationEmailToBeReceived(
@@ -74,7 +74,8 @@ test.describe("Team", () => {
email: `user-invite-${Date.now()}@domain.com`,
password: "P4ssw0rd!",
});
await page.locator(`button:text("${t("add")}")`).click();
await page.getByTestId("new-member-button").click();
const inviteLink = await getInviteLink(page);
const context = await browser.newContext();
@@ -105,7 +106,7 @@ test.describe("Team", () => {
username: "rick",
domain: `example.com`,
});
await page.locator(`button:text("${t("add")}")`).click();
await page.getByTestId("new-member-button").click();
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
await page.locator(`button:text("${t("send_invite")}")`).click();
await expectInvitationEmailToBeReceived(
@@ -2599,6 +2599,7 @@
"new_option": "New option",
"update_profile": "Update member",
"attribute_updated_successfully": "Attribute updated successfully",
"attribute_deleted_successfully": "Attribute deleted successfully",
"attributes_edited_successfully": "Attributes edited successfully",
"attribute_meta_description": "Manage attributes for your team members",
"attributes_edit_description": "Edit attributes for your team members",
@@ -2644,6 +2645,9 @@
"month_to_date": "month to date",
"year_to_date": "year to date",
"custom_range": "custom range",
"show_all_columns": "Show all columns",
"toggle_columns": "Toggle columns",
"no_columns_found": "No columns found",
"salesforce_create_record_as": "On booking, add events on and new attendees as:",
"salesforce_lead": "Lead",
"salesforce_contact_under_account": "Contact under an account",
@@ -1,9 +1,10 @@
import type { ColumnDef } from "@tanstack/react-table";
import { useReactTable, getCoreRowModel } from "@tanstack/react-table";
import { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { DataTable, Button } from "@calcom/ui";
import { DataTable, DataTableToolbar } from "@calcom/ui";
import CreateTeamDialog from "./CreateTeamDialog";
import GroupNameCell from "./GroupNameCell";
@@ -44,14 +45,21 @@ const GroupTeamMappingTable = () => {
},
];
const table = useReactTable({
data: data?.teamGroupMapping ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<>
<DataTable
data={data ? data.teamGroupMapping : []}
tableContainerRef={tableContainerRef}
columns={columns}
tableCTA={<Button onClick={() => setCreateTeamDialogOpen(true)}>Create team</Button>}
/>
<DataTable table={table} tableContainerRef={tableContainerRef}>
<DataTableToolbar.Root>
<DataTableToolbar.CTA onClick={() => setCreateTeamDialogOpen(true)}>
Create team
</DataTableToolbar.CTA>
</DataTableToolbar.Root>
</DataTable>
<CreateTeamDialog open={createTeamDialogOpen} onOpenChange={setCreateTeamDialogOpen} />
</>
);
@@ -1,25 +1,41 @@
"use client";
import { keepPreviousData } from "@tanstack/react-query";
import type { ColumnDef, Table } from "@tanstack/react-table";
import type { ColumnFiltersState } from "@tanstack/react-table";
import {
getCoreRowModel,
getFilteredRowModel,
getSortedRowModel,
getFacetedUniqueValues,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import classNames from "classnames";
import { m } from "framer-motion";
import { signIn } from "next-auth/react";
import { useSession } from "next-auth/react";
import { useMemo, useRef, useReducer, useState, useEffect, useCallback } from "react";
import { signIn } from "next-auth/react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { useMemo, useReducer, useRef, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
import { useFetchMoreOnBottomReached } from "@calcom/features/users/components/UserTable/useFetchMoreOnBottomReached";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar, Badge, Checkbox, DataTable } from "@calcom/ui";
import {
Avatar,
Badge,
Button,
ButtonGroup,
Checkbox,
DataTable,
DataTableToolbar,
DataTableFilters,
DataTableSelectionBar,
ConfirmationDialogContent,
Dialog,
DialogClose,
@@ -66,7 +82,12 @@ export type State = {
export type Action =
| {
type: "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "EDIT_USER_SHEET" | "TEAM_AVAILABILITY";
type:
| "SET_DELETE_ID"
| "SET_IMPERSONATE_ID"
| "EDIT_USER_SHEET"
| "TEAM_AVAILABILITY"
| "INVITE_MEMBER";
payload: Payload;
}
| {
@@ -88,6 +109,14 @@ const initialState: State = {
},
};
const initalColumnVisibility = {
select: true,
member: true,
role: true,
teams: true,
actions: true,
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_DELETE_ID":
@@ -111,18 +140,24 @@ function reducer(state: State, action: Action): State {
}
}
interface Props {
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
isOrgAdminOrOwner: boolean | undefined;
setShowMemberInvitationModal: Dispatch<SetStateAction<boolean>>;
}
export default function MemberList(props: Props) {
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
const { t, i18n } = useLocale();
const { data: session } = useSession();
const utils = trpc.useUtils();
const [state, dispatch] = useReducer(reducer, initialState);
const orgBranding = useOrgBranding();
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const tableContainerRef = useRef<HTMLDivElement>(null);
const [searchTerm, setSearchTerm] = useState("");
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const debouncedSearchTerm = useDebounce(searchTerm, 500);
const [state, dispatch] = useReducer(reducer, initialState);
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.teams.listMembers.useInfiniteQuery(
{
@@ -140,6 +175,9 @@ export default function MemberList(props: Props) {
}
);
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const removeMemberFromCache = ({
utils,
memberId,
@@ -182,7 +220,7 @@ export default function MemberList(props: Props) {
const previousValue = utils.viewer.teams.listMembers.getInfiniteData({
limit: 10,
teamId: teamIds[0],
searchTerm: searchTerm,
searchTerm: debouncedSearchTerm,
});
if (previousValue) {
@@ -190,7 +228,7 @@ export default function MemberList(props: Props) {
utils,
memberId: state.deleteMember.user?.id as number,
teamId: teamIds[0],
searchTerm: searchTerm,
searchTerm: debouncedSearchTerm,
});
}
return { previousValue };
@@ -237,8 +275,11 @@ export default function MemberList(props: Props) {
const memorisedColumns = useMemo(() => {
const cols: ColumnDef<User>[] = [
// 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()}
@@ -259,6 +300,7 @@ export default function MemberList(props: Props) {
{
id: "member",
accessorFn: (data) => data.email,
enableHiding: false,
header: `Member (${totalDBRowCount})`,
cell: ({ row }) => {
const { username, email, avatarUrl, accepted, name } = row.original;
@@ -291,9 +333,8 @@ export default function MemberList(props: Props) {
);
},
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);
},
},
{
@@ -546,137 +587,94 @@ export default function MemberList(props: Props) {
return cols;
}, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]);
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[];
const totalFetched = flatData.length;
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,
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
state: {
columnFilters,
},
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
tableContainerRef,
fetchNextPage,
isFetching,
totalFetched,
totalDBRowCount
);
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
return (
<div className="mb-6">
<>
<DataTable
data-testid="team-member-list-container"
onSearch={(value) => setSearchTerm(value)}
selectionOptions={[
{
type: "action",
icon: "handshake",
label: "Group Meeting",
needsXSelected: 2,
onClick: () => {
setDynamicLinkVisible((old) => !old);
},
},
{
type: "render",
render: (table) => <EventTypesList table={table} teamId={props.team.id} />,
},
{
type: "render",
render: (table) => (
<DeleteBulkTeamMembers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
isOrg={checkIsOrg(props.team)}
teamId={props.team.id}
/>
),
},
]}
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}
</>
);
}}
table={table}
tableContainerRef={tableContainerRef}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
tableCTA={
isAdminOrOwner || props.isOrgAdminOrOwner ? (
<Button
type="button"
color="primary"
StartIcon="plus"
size="sm"
className="rounded-md"
onClick={() => props.setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("add")}
</Button>
) : (
<></>
)
}
columns={memorisedColumns}
data={flatData}
isPending={isPending}
filterableItems={[
{
tableAccessor: "role",
title: "Role",
options: [
{ label: "Owner", value: "OWNER" },
{ label: "Admin", value: "ADMIN" },
{ label: "Member", value: "MEMBER" },
{ label: "Pending", value: "PENDING" },
],
},
]}
/>
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
<DataTableFilters.FilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
{isAdminOrOwner && (
<DataTableToolbar.CTA
type="button"
color="primary"
StartIcon="plus"
className="rounded-md"
onClick={() => props.setShowMemberInvitationModal(true)}
data-testid="new-member-button">
{t("add")}
</DataTableToolbar.CTA>
)}
</div>
<div className="flex gap-2 justify-self-start">
<DataTableFilters.ActiveFilters table={table} />
</div>
</DataTableToolbar.Root>
{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>
{numberOfSelectedRows >= 2 && (
<Button onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)} StartIcon="handshake">
Group Meeting
</Button>
)}
<EventTypesList table={table} teamId={props.team.id} />
<DeleteBulkTeamMembers
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
onRemove={() => table.toggleAllPageRowsSelected(false)}
isOrg={checkIsOrg(props.team)}
teamId={props.team.id}
/>
</DataTableSelectionBar.Root>
)}
</DataTable>
{state.deleteMember.showModal && (
<Dialog
open={true}
@@ -747,6 +745,6 @@ export default function MemberList(props: Props) {
teamId={props.team.id}
/>
)}
</div>
</>
);
}
@@ -87,13 +87,13 @@ const MembersView = ({ isAppDir }: { isAppDir?: boolean }) => {
)}
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && (
<>
<div className="mb-6">
<MemberList
team={team}
isOrgAdminOrOwner={isOrgAdminOrOwner}
setShowMemberInvitationModal={setShowMemberInvitationModal}
/>
</>
</div>
)}
{showMemberInvitationModal && team && team.id && (
<MemberInvitationModalWithoutMembers
@@ -662,6 +662,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
export type SettingsLayoutProps = {
children: React.ReactNode;
hideHeader?: boolean;
containerClassName?: string;
} & ComponentProps<typeof Shell>;
export default function SettingsLayout({ children, hideHeader, ...rest }: SettingsLayoutProps) {
@@ -707,7 +708,8 @@ export default function SettingsLayout({ children, hideHeader, ...rest }: Settin
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
{!hideHeader && <ShellHeader />}
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
@@ -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<SliderUser>[] = [
{
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 }
</div>
);
},
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 <UpgradeTeamTip />;
@@ -196,19 +207,18 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
<>
<div className="relative -mx-2 w-[calc(100%+16px)] overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6">
<DataTable
variant="compact"
searchKey="member"
table={table}
tableContainerRef={tableContainerRef}
columns={memorisedColumns}
onRowMouseclick={(row) => {
setEditSheetOpen(true);
setSelectedUser(row.original);
}}
data={flatData}
isPending={isPending}
// tableOverlay={<HoverOverview />}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
/>
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<DataTableToolbar.SearchBar table={table} searchKey="member" />
</DataTableToolbar.Root>
</DataTable>
</div>
{selectedUser && editSheetOpen ? (
<AvailabilityEditSheet
@@ -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;
};
@@ -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);
}),
});
@@ -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<TrpcSessionUser>;
};
};
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;
@@ -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<typeof ZGetFacetedValuesInput>;
@@ -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,
};
}) || []
);
@@ -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<typeof ZListMembersSchema>;
@@ -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<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: IconName;
}[];
options: FilterOption[];
}
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
const [currentOptions, setCurrentOptions] = useState<FilterOption[]>(options);
const [navigationPath, setNavigationPath] = useState<string[]>([]);
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 (
<Popover>
@@ -44,7 +97,7 @@ export function DataTableFilter<TData, TValue>({ column, title, options }: DataT
{selectedValues.size}
</Badge>
) : (
options
allOptions
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
@@ -62,32 +115,32 @@ export function DataTableFilter<TData, TValue>({ column, title, options }: DataT
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{navigationPath.length > 0 && (
<CommandGroup>
<CommandItem onSelect={handleBack}>
<Icon name="arrow-left" className="mr-2 h-4 w-4" />
Back
</CommandItem>
</CommandGroup>
)}
<CommandGroup>
{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 (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(filterValues.length ? filterValues : undefined);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
<CommandItem key={option.value} onSelect={() => handleSelect(option)}>
{!option.options && (
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
)}
{option.icon && <Icon name={option.icon} className="text-muted mr-2 h-4 w-4" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
{option.options && <Icon name="chevron-right" className="ml-auto h-4 w-4" />}
{!option.options && facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
@@ -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<TData> {
table: Table<TData>;
totalDbDataCount: number;
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
export function DataTablePagination<TData>({ table, totalDbDataCount }: DataTablePaginationProps<TData>) {
const loadedCount = table.getFilteredRowModel().rows.length;
return (
<div className="flex items-center justify-between px-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
</div>
<div className="flex items-center space-x-2">
<Button
color="secondary"
variant="icon"
StartIcon="chevrons-left"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}>
<span className="sr-only">Go to first page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
StartIcon="chevron-left">
<span className="sr-only">Go to previous page</span>
</Button>
<Button
color="secondary"
variant="icon"
StartIcon="chevron-right"
className="h-8 w-8 p-0"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}>
<span className="sr-only">Go to next page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="hidden h-8 w-8 p-0 lg:flex"
StartIcon="chevrons-right"
onClick={() => table.setPageIndex(table.getPageCount())}>
<span className="sr-only">Go to last page</span>
</Button>
</div>
</div>
</div>
<p className="text-subtle text-sm tabular-nums">
Loaded <span className="text-default font-medium">{`${loadedCount}`}</span> of
<span className="text-default font-medium"> {`${totalDbDataCount}`}</span>
</p>
);
}
@@ -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<TData> =
| {
@@ -20,51 +21,26 @@ export type ActionItem<TData> =
needsXSelected?: number;
};
interface DataTableSelectionBarProps<TData> {
table: Table<TData>;
actions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
}
export function DataTableSelectionBar<TData>({
table,
actions,
renderAboveSelection,
}: DataTableSelectionBarProps<TData>) {
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<HTMLDivElement> & { showSelectionCount?: boolean }
>(({ children, ...props }, ref) => {
return (
<AnimatePresence>
{isVisible ? (
<div className="fade-in fixed bottom-6 left-1/2 hidden -translate-x-1/2 gap-1 md:flex md:flex-col">
{renderAboveSelection && renderAboveSelection(table)}
<div className="bg-brand-default text-brand hidden items-center justify-between rounded-lg p-2 md:flex">
<p className="text-brand-subtle w-full px-2 text-center leading-none">
{numberOfSelectedRows} selected
</p>
{actionsVisible?.map((action, index) => {
return (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
);
})}
</div>
</div>
) : null}
</AnimatePresence>
<div
ref={ref}
className={classNames(
"bg-brand-default text-brand fixed bottom-4 left-1/2 flex w-fit -translate-x-1/2 transform items-center space-x-3 rounded-lg px-4 py-2",
props.className
)}
style={{ ...props.style }}
{...props}>
{children}
</div>
);
}
});
Root.displayName = "Root";
export const DataTableSelectionBar = {
Root,
};
@@ -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<TData> {
const Root = forwardRef<HTMLDivElement, DataTableToolbarProps>(function DataTableToolbar(
{ children, className },
ref
) {
return (
<div
ref={ref}
className={classNames("grid w-full items-center gap-2 py-4", className)}
style={{ gridArea: "header" }}>
{children}
</div>
);
});
interface SearchBarProps<TData> {
table: Table<TData>;
filterableItems?: FilterableItems;
searchKey?: string;
tableCTA?: React.ReactNode;
onSearch?: (value: string) => void;
}
export function DataTableToolbar<TData>({
table,
filterableItems,
tableCTA,
searchKey,
onSearch,
}: DataTableToolbarProps<TData>) {
// 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<TData>(
{ table, searchKey, onSearch }: SearchBarProps<TData>,
ref: React.Ref<HTMLInputElement>
) {
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
@@ -46,54 +47,80 @@ export function DataTableToolbar<TData>({
onSearch?.(debouncedSearchTerm);
}, [debouncedSearchTerm, onSearch]);
const { t } = useLocale();
if (onSearch) {
return (
<Input
ref={ref}
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value.trim())}
/>
);
}
if (!searchKey) {
console.error("searchKey is required if onSearch is not provided");
return null;
}
return (
<div className="flex items-center justify-end gap-2 py-4">
{searchKey && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())}
/>
)}
{onSearch && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
)}
{isFiltered && (
<Button
color="minimal"
EndIcon="x"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
{t("clear")}
</Button>
)}
{filterableItems &&
filterableItems?.map((item) => {
const foundColumn = table.getColumn(item.tableAccessor);
if (foundColumn?.getCanFilter()) {
return (
<DataTableFilter
column={foundColumn}
title={item.title}
options={item.options}
key={item.title}
/>
);
}
})}
{tableCTA ? tableCTA : null}
</div>
<Input
ref={ref}
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) => {
return table.getColumn(searchKey)?.setFilterValue(event.target.value.trim());
}}
/>
);
}
const SearchBar = forwardRef(SearchBarComponent) as <TData>(
props: SearchBarProps<TData> & { ref?: React.Ref<HTMLInputElement> }
) => ReturnType<typeof SearchBarComponent>;
interface ClearFiltersButtonProps<TData> {
table: Table<TData>;
}
function ClearFiltersButtonComponent<TData>(
{ table }: ClearFiltersButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const isFiltered = table.getState().columnFilters.length > 0;
if (!isFiltered) return null;
return (
<Button
ref={ref}
color="minimal"
EndIcon="x"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
{t("clear")}
</Button>
);
}
const ClearFiltersButton = forwardRef(ClearFiltersButtonComponent) as <TData>(
props: ClearFiltersButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement> }
) => ReturnType<typeof ClearFiltersButtonComponent>;
function CTAComponent(
{ children, onClick, color = "primary", ...rest }: ButtonProps,
ref: React.Ref<HTMLButtonElement>
) {
return (
<Button ref={ref} color={color} onClick={onClick} {...rest}>
{children}
</Button>
);
}
const CTA = forwardRef(CTAComponent) as (
props: ButtonProps & { ref?: React.Ref<HTMLButtonElement> }
) => ReturnType<typeof CTAComponent>;
export const DataTableToolbar = { Root, SearchBar, ClearFiltersButton, CTA };
@@ -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<DataTableUserStorybook>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => 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 (
<Badge
color={BadgeColor}
onClick={() => {
table.getColumn("role")?.setFilterValue(user.role);
}}>
{user.role}
</Badge>
);
},
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",
},
],
},
];
@@ -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",
},
];
@@ -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";
<Meta title="UI/table/DataTable" component={DataTable} />
<Title title="DataTable" suffix="Brief" subtitle="Version 3.0 — Last Update: 28 Aug 2023" />
## 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.
<CustomArgsTable of={DataTable} />
## Dialog Story
<Canvas>
<Story
name="DataTable"
args={{
columns: columns,
data: dataTableDemousers,
isPending: false,
searchKey: "username",
filterableItems: filterableItems,
tableContainerRef: { current: null },
selectionOptions: dataTableSelectionActions,
}}
argTypes={{
tableContainerRef: { table: { disable: true } },
searchKey: {
control: {
type: "select",
options: ["username", "email"],
},
},
selectionOptions: { control: { type: "object" } },
onScroll: { table: { disable: true } },
tableOverlay: { table: { disable: true } },
CTA: { table: { disable: true } },
tableCTA: { table: { disable: true } },
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={1000}>
<VariantRow>
<DataTable {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>
@@ -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<TData> {
table: Table<TData>;
}
function ColumnVisibilityButtonComponent<TData>(
{
children,
color = "secondary",
EndIcon = "sliders-vertical",
table,
...rest
}: ColumnVisiblityProps<TData> & ButtonProps,
ref: React.Ref<HTMLButtonElement>
) {
const { t } = useLocale();
const allColumns = table.getAllLeafColumns();
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button ref={ref} color={color} EndIcon={EndIcon} {...rest}>
{children ? children : t("View")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
<CommandGroup heading={t("toggle_columns")}>
{allColumns.map((column) => {
const canHide = column.getCanHide();
if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
return null;
const isVisible = column.getIsVisible();
return (
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!isVisible)}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isVisible ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
{column.columnDef.header}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => allColumns.forEach((column) => column.toggleVisibility(true))}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
)}>
{t("show_all_columns")}
</CommandItem>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}
const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <TData>(
props: ColumnVisiblityProps<TData> & ButtonProps & { ref?: React.Ref<HTMLButtonElement> }
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
// Filters
interface FilterButtonProps<TData> {
table: Table<TData>;
omit?: string[];
}
function FilterButtonComponent<TData>(
{ table, omit }: FilterButtonProps<TData>,
ref: React.Ref<HTMLButtonElement>
) {
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 (
<div className="flex items-center space-x-2">
<Popover>
<PopoverTrigger asChild>
<Button ref={ref} color="secondary" className="border-dashed">
<Icon name="filter" className="mr-2 h-4 w-4" />
{t("add_filter")}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={t("search_columns")} />
<CommandList>
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
{filterableColumns.map((column) => {
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
return (
<CommandItem key={column.id} onSelect={() => handleAddFilter(column.id)}>
{column.title}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
const FilterButton = forwardRef(FilterButtonComponent) as <TData>(
props: FilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
) => ReturnType<typeof FilterButtonComponent>;
// Add the new ActiveFilters component
interface ActiveFiltersProps<TData> {
table: Table<TData>;
}
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
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 (
<Popover key={column.id}>
<PopoverTrigger asChild>
<Button color="secondary">
{column.title}
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput placeholder={t("search_options")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{Array.from(column.options).map(([option]) => {
if (!option) return null;
return (
<CommandItem
key={option}
onSelect={() => {
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);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option)
? "bg-primary"
: "opacity-50"
)}>
{Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{option}
</CommandItem>
);
})}
</CommandList>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
handleRemoveFilter(column.id);
}}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
)}>
{t("clear")}
</CommandItem>
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
})}
</>
);
}
// Update the export to include ActiveFilters
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
@@ -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);
}
+100 -144
View File
@@ -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<TData, TValue> {
table: ReactTableType<TData>;
tableContainerRef: React.RefObject<HTMLDivElement>;
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
onSearch?: (value: string) => void;
filterableItems?: FilterableItems;
selectionOptions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
tableCTA?: React.ReactNode;
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
CTA?: React.ReactNode;
tableOverlay?: React.ReactNode;
variant?: "default" | "compact";
"data-testid"?: string;
children?: React.ReactNode;
}
export function DataTable<TData, TValue>({
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<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
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<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtual({
@@ -106,81 +48,95 @@ export function DataTable<TData, TValue>({
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
return (
<div className="relative space-y-4">
<DataTableToolbar
table={table}
filterableItems={filterableItems}
searchKey={searchKey}
onSearch={onSearch}
tableCTA={tableCTA}
/>
<div ref={tableContainerRef} onScroll={onScroll} data-testid={rest["data-testid"] ?? "data-table"}>
<Table data-testid="">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
className={classNames(
onRowMouseclick && "hover:cursor-pointer",
variant === "compact" && "!border-0"
)}>
{row.getVisibleCells().map((cell) => {
return (
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
<div
className={classNames(
"grid h-[75dvh]", // Set a fixed height for the container
rest.className
)}
style={{
gridTemplateRows: "auto 1fr auto",
gridTemplateAreas: "'header' 'body' 'footer'",
...rest.style,
}}
data-testid={rest["data-testid"] ?? "data-table"}>
<div className="overflow-hidden" style={{ gridArea: "body" }}>
<div ref={tableContainerRef} onScroll={onScroll} className="scrollbar-thin h-full overflow-y-auto">
<div className="inline-block min-w-full align-middle">
<Table>
<TableHeader className="sticky top-0 z-10">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
className={classNames(
header.column.getCanSort() ? "cursor-pointer select-none" : ""
)}>
<div className="flex items-center" onClick={header.column.getToggleSortingHandler()}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
{header.column.getIsSorted() && (
<Icon
name="arrow-up"
className="ml-2 h-4 w-4"
style={{
transform:
header.column.getIsSorted() === "asc" ? "rotate(0deg)" : "rotate(180deg)",
transition: "transform 0.2s ease-in-out",
}}
/>
)}
</div>
</TableHead>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</TableBody>
{tableOverlay && tableOverlay}
</Table>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
className={classNames(
onRowMouseclick && "hover:cursor-pointer",
variant === "compact" && "!border-0"
)}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* <DataTablePagination table={table} /> */}
<DataTableSelectionBar
table={table}
actions={selectionOptions}
renderAboveSelection={renderAboveSelection}
/>
{children}
</div>
);
}
@@ -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;
};
+1 -1
View File
@@ -4,7 +4,7 @@ import { classNames } from "@calcom/lib";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto md:overflow-visible">
<div className="w-full overflow-y-auto">
<table
ref={ref}
className={classNames("border-subtle w-full caption-bottom border text-sm", className)}
+5
View File
@@ -207,3 +207,8 @@ export { StorybookTrpcProvider } from "./components/mocks/trpc";
export { default as Icon } from "./components/icon/Icon";
export type { IconName } from "./components/icon/icon-names";
export { IconSprites } from "./components/icon/IconSprites";
export { DataTableSelectionBar } from "./components/data-table/DataTableSelectionBar";
export { DataTableToolbar } from "./components/data-table/DataTableToolbar";
export { DataTableFilters } from "./components/data-table/filters";
export { DataTablePagination } from "./components/data-table/DataTablePagination";