From 7e44e686e8e9bcd8f136ee4dc578ea3e892b576f Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Tue, 15 Oct 2024 18:45:11 +0100 Subject: [PATCH] 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 Co-authored-by: Peer Richelsen Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> --- .../pages/settings/organizations/members.tsx | 6 +- .../playwright/organization/lib/inviteUser.ts | 2 +- .../organization-invitation.e2e.ts | 24 +- .../playwright/team/team-invitation.e2e.ts | 7 +- apps/web/public/static/locales/en/common.json | 4 + .../components/GroupTeamMappingTable.tsx | 22 +- .../ee/teams/components/MemberList.tsx | 272 ++++++----- .../ee/teams/pages/team-members-view.tsx | 4 +- .../settings/layouts/SettingsLayout.tsx | 4 +- .../components/AvailabilitySliderTable.tsx | 28 +- .../UserTable/BulkActions/DeleteBulkUsers.tsx | 21 +- .../UserTable/BulkActions/DynamicLink.tsx | 55 +++ .../UserTable/BulkActions/EventTypesList.tsx | 4 +- .../BulkActions/MassAssignAttributes.tsx | 87 +++- .../UserTable/BulkActions/TeamList.tsx | 4 +- .../UserTable/ChangeUserRoleModal.tsx | 4 +- .../UserTable/DeleteMemberModal.tsx | 10 +- .../UserTable/EditSheet/EditUserForm.tsx | 4 +- .../UserTable/EditSheet/EditUserSheet.tsx | 17 +- .../UserTable/ImpersonationMemberModal.tsx | 7 +- .../UserTable/InviteMemberModal.tsx | 4 +- .../components/UserTable/UserListTable.tsx | 439 +++++++++--------- .../components/UserTable/UserTableActions.tsx | 7 +- .../users/components/UserTable/types.ts | 52 +++ .../UserTable/useFetchMoreOnBottomReached.ts | 27 ++ .../routers/viewer/organizations/_router.tsx | 7 + .../organizations/getFacetedValues.handler.ts | 54 +++ .../organizations/getFacetedValues.schema.ts | 7 + .../organizations/listMembers.handler.ts | 96 +++- .../organizations/listMembers.schema.ts | 11 + .../components/data-table/DataTableFilter.tsx | 107 +++-- .../data-table/DataTablePagination.tsx | 60 +-- .../data-table/DataTableSelectionBar.tsx | 76 ++- .../data-table/DataTableToolbar.tsx | 171 ++++--- .../data-table/__storybook__/columns.tsx | 78 ---- .../data-table/__storybook__/data.tsx | 98 ---- .../__storybook__/datatable.stories.mdx | 67 --- .../components/data-table/filters/index.tsx | 269 +++++++++++ .../ui/components/data-table/filters/utils.ts | 17 + packages/ui/components/data-table/index.tsx | 244 ++++------ .../data-table/useFetchMoreOnBottomReached.ts | 27 ++ packages/ui/components/table/TableNew.tsx | 2 +- packages/ui/index.tsx | 5 + 43 files changed, 1462 insertions(+), 1049 deletions(-) create mode 100644 packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx create mode 100644 packages/features/users/components/UserTable/types.ts create mode 100644 packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts create mode 100644 packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts delete mode 100644 packages/ui/components/data-table/__storybook__/columns.tsx delete mode 100644 packages/ui/components/data-table/__storybook__/data.tsx delete mode 100644 packages/ui/components/data-table/__storybook__/datatable.stories.mdx create mode 100644 packages/ui/components/data-table/filters/index.tsx create mode 100644 packages/ui/components/data-table/filters/utils.ts create mode 100644 packages/ui/components/data-table/useFetchMoreOnBottomReached.ts diff --git a/apps/web/pages/settings/organizations/members.tsx b/apps/web/pages/settings/organizations/members.tsx index 34061d4a8c..970bfb8e31 100644 --- a/apps/web/pages/settings/organizations/members.tsx +++ b/apps/web/pages/settings/organizations/members.tsx @@ -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) => ( + {page} +); + const Page = () => ; Page.getLayout = getLayout; diff --git a/apps/web/playwright/organization/lib/inviteUser.ts b/apps/web/playwright/organization/lib/inviteUser.ts index f5f38108ff..975c0bcb32 100644 --- a/apps/web/playwright/organization/lib/inviteUser.ts +++ b/apps/web/playwright/organization/lib/inviteUser.ts @@ -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(), diff --git a/apps/web/playwright/organization/organization-invitation.e2e.ts b/apps/web/playwright/organization/organization-invitation.e2e.ts index 021ccc16e9..21333d12a2 100644 --- a/apps/web/playwright/organization/organization-invitation.e2e.ts +++ b/apps/web/playwright/organization/organization-invitation.e2e.ts @@ -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; } diff --git a/apps/web/playwright/team/team-invitation.e2e.ts b/apps/web/playwright/team/team-invitation.e2e.ts index 8cb6f6041b..89a6706b93 100644 --- a/apps/web/playwright/team/team-invitation.e2e.ts +++ b/apps/web/playwright/team/team-invitation.e2e.ts @@ -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( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index c7039de7be..de167455ca 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx b/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx index a72058caa9..f97c9ed2a8 100644 --- a/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx +++ b/packages/features/ee/dsync/components/GroupTeamMappingTable.tsx @@ -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 ( <> - setCreateTeamDialogOpen(true)}>Create team} - /> + + + setCreateTeamDialogOpen(true)}> + Create team + + + ); diff --git a/packages/features/ee/teams/components/MemberList.tsx b/packages/features/ee/teams/components/MemberList.tsx index 8f37738a57..b68c2d930c 100644 --- a/packages/features/ee/teams/components/MemberList.tsx +++ b/packages/features/ee/teams/components/MemberList.tsx @@ -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; + isOrgAdminOrOwner: boolean | undefined; + setShowMemberInvitationModal: Dispatch>; +} + 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(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([]); + 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[] = [ + // Disabling select for this PR: Will work on actions etc in a follow up { id: "select", + enableHiding: false, + enableSorting: false, header: ({ table }) => ( 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 ( -
+ <> setSearchTerm(value)} - selectionOptions={[ - { - type: "action", - icon: "handshake", - label: "Group Meeting", - needsXSelected: 2, - onClick: () => { - setDynamicLinkVisible((old) => !old); - }, - }, - { - type: "render", - render: (table) => , - }, - { - type: "render", - render: (table) => ( - row.original)} - onRemove={() => table.toggleAllPageRowsSelected(false)} - isOrg={checkIsOrg(props.team)} - teamId={props.team.id} - /> - ), - }, - ]} - renderAboveSelection={(table: Table) => { - const numberOfSelectedRows = table.getSelectedRowModel().rows.length; - const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible; - - const users = table - .getSelectedRowModel() - .flatRows.map((row) => row.original.username) - .filter((u) => u !== null); - - const usersNameAsString = users.join("+"); - - const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`; - const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, ""); - - return ( - <> - {isVisible ? ( - -
-

{domainWithoutHttps}

-
-
- - -
-
- ) : null} - - ); - }} + table={table} tableContainerRef={tableContainerRef} - onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} - tableCTA={ - isAdminOrOwner || props.isOrgAdminOrOwner ? ( - - ) : ( - <> - ) - } - 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)}> + +
+ setDebouncedSearchTerm(value)} /> + + + {isAdminOrOwner && ( + props.setShowMemberInvitationModal(true)} + data-testid="new-member-button"> + {t("add")} + + )} +
+
+ +
+
+ {numberOfSelectedRows >= 2 && dynamicLinkVisible && ( + + + + )} + {numberOfSelectedRows > 0 && ( + +

+ {numberOfSelectedRows} selected +

+ {numberOfSelectedRows >= 2 && ( + + )} + + row.original)} + onRemove={() => table.toggleAllPageRowsSelected(false)} + isOrg={checkIsOrg(props.team)} + teamId={props.team.id} + /> +
+ )} +
{state.deleteMember.showModal && ( )} -
+ ); } diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index 1d6b6ef865..5ca7653b4d 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -87,13 +87,13 @@ const MembersView = ({ isAppDir }: { isAppDir?: boolean }) => { )} {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && ( - <> +
- +
)} {showMemberInvitationModal && team && team.id && ( void }) => export type SettingsLayoutProps = { children: React.ReactNode; hideHeader?: boolean; + containerClassName?: string; } & ComponentProps; export default function SettingsLayout({ children, hideHeader, ...rest }: SettingsLayoutProps) { @@ -707,7 +708,8 @@ export default function SettingsLayout({ children, hideHeader, ...rest }: Settin setSideContainerOpen(!sideContainerOpen)} /> }>
-
+
{!hideHeader && } }>{children} diff --git a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx index 2e5b04e6bd..923c695600 100644 --- a/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx +++ b/packages/features/timezone-buddy/components/AvailabilitySliderTable.tsx @@ -1,4 +1,5 @@ import { keepPreviousData } from "@tanstack/react-query"; +import { getCoreRowModel, useReactTable, getFilteredRowModel } from "@tanstack/react-table"; import type { ColumnDef } from "@tanstack/react-table"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -9,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; import type { UserProfile } from "@calcom/types/UserProfile"; -import { Button, ButtonGroup, DataTable, UserAvatar } from "@calcom/ui"; +import { Button, ButtonGroup, DataTable, DataTableToolbar, UserAvatar } from "@calcom/ui"; import { UpgradeTip } from "../../tips/UpgradeTip"; import { createTimezoneBuddyStore, TBContext } from "../store"; @@ -80,7 +81,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null } const cols: ColumnDef[] = [ { id: "member", - accessorFn: (data) => data.email, + accessorFn: (data) => data.username, header: "Member", cell: ({ row }) => { const { username, email, timeZone, name, avatarUrl, profile } = row.original; @@ -104,6 +105,9 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
); }, + filterFn: (row, id, value) => { + return row.original.username?.toLowerCase().includes(value.toLowerCase()) || false; + }, }, { id: "timezone", @@ -185,6 +189,13 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null } fetchMoreOnBottomReached(tableContainerRef.current); }, [fetchMoreOnBottomReached]); + const table = useReactTable({ + data: flatData, + columns: memorisedColumns, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + // This means they are not apart of any teams so we show the upgrade tip if (!flatData.length) return ; @@ -196,19 +207,18 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null } <>
{ setEditSheetOpen(true); setSelectedUser(row.original); }} - data={flatData} isPending={isPending} - // tableOverlay={} - onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} - /> + onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}> + + + +
{selectedUser && editSheetOpen ? ( void; } @@ -14,9 +14,22 @@ export function DeleteBulkUsers({ users, onRemove }: Props) { const selectedRows = users; // Get selected rows from table const utils = trpc.useUtils(); const deleteMutation = trpc.viewer.organizations.bulkDeleteUsers.useMutation({ - onSuccess: () => { - utils.viewer.organizations.listMembers.invalidate(); + onSuccess: (_, { userIds }) => { showToast("Deleted Users", "success"); + utils.viewer.organizations.listMembers.setInfiniteData( + { limit: 10, searchTerm: "", expand: ["attributes"] }, + // @ts-expect-error - infinite data types are not correct + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + rows: page.rows.filter((user) => !userIds.includes(user.id)), + })), + }; + } + ); }, onError: (error) => { showToast(error.message, "error"); diff --git a/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx b/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx new file mode 100644 index 0000000000..c400468ceb --- /dev/null +++ b/packages/features/users/components/UserTable/BulkActions/DynamicLink.tsx @@ -0,0 +1,55 @@ +import type { Table } from "@tanstack/react-table"; +import { useQueryState, parseAsBoolean } from "nuqs"; + +import { useCopy } from "@calcom/lib/hooks/useCopy"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button } from "@calcom/ui"; + +export function DynamicLink({ + table, + domain, +}: { + table: Table; + domain: string; +}) { + const { t } = useLocale(); + const [dynamicLinkVisible, _] = useQueryState("dynamicLink", parseAsBoolean); + const { copyToClipboard, isCopied } = useCopy(); + const numberOfSelectedRows = table.getSelectedRowModel().rows.length; + const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible; + + const users = table + .getSelectedRowModel() + .flatRows.map((row) => row.original.username) + .filter((u): u is string => u !== null); + + const usersNameAsString = users.join("+"); + + const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`; + const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, ""); + + return ( + <> + {isVisible ? ( +
+
+

{domainWithoutHttps}

+
+
+ + +
+
+ ) : null} + + ); +} diff --git a/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx index 6374bda939..7760c207e1 100644 --- a/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx +++ b/packages/features/users/components/UserTable/BulkActions/EventTypesList.tsx @@ -22,10 +22,10 @@ import { Icon, } from "@calcom/ui"; -import type { User } from "../UserListTable"; +import type { UserTableUser } from "../types"; interface Props { - table: Table; + table: Table; orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined; } diff --git a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx index 209dd13efe..e4c27bc42b 100644 --- a/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx +++ b/packages/features/users/components/UserTable/BulkActions/MassAssignAttributes.tsx @@ -1,9 +1,11 @@ import type { Table } from "@tanstack/react-table"; +import type { ColumnFiltersState } from "@tanstack/react-table"; import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs"; import { useState } from "react"; import classNames from "@calcom/lib/classNames"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import slugify from "@calcom/lib/slugify"; import { trpc } from "@calcom/trpc"; import { Alert, @@ -22,10 +24,11 @@ import { showToast, } from "@calcom/ui"; -import type { User } from "../UserListTable"; +import type { UserTableUser } from "../types"; interface Props { - table: Table; + table: Table; + filters: ColumnFiltersState; } function useSelectedAttributes() { @@ -78,7 +81,7 @@ function SelectedAttributeToAssign() { return ( -
+
{foundAttribute.name} {translateableType && ({t(translateableType)})}
@@ -119,9 +122,11 @@ function SelectedAttributeToAssign() { <> { + onBlur={(e) => { + // trigger onBlur so it's set as Apply is pressed (but not onChange) which triggers + // a re-render which also loses focus. setSelectedAttributeOption([e.target.value]); }} /> @@ -133,13 +138,78 @@ function SelectedAttributeToAssign() { ); } -export function MassAssignAttributesBulkAction({ table }: Props) { +export function MassAssignAttributesBulkAction({ table, filters }: Props) { const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes(); const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption(); const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false); const { t } = useLocale(); + const utils = trpc.useContext(); const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({ onSuccess: (success) => { + // Optimistically update the infinite query data + const selectedRows = table.getSelectedRowModel().flatRows; + + utils.viewer.organizations.listMembers.setInfiniteData( + { + limit: 10, + searchTerm: "", + expand: ["attributes"], + filters: filters.map((filter) => ({ + id: filter.id, + value: filter.value as string[], + })), + }, + // @ts-expect-error i really dont know how to type this + (oldData) => { + const newPages = oldData?.pages.map((page) => ({ + ...page, + rows: page.rows.map((row) => { + if (selectedRows.some((selectedRow) => selectedRow.original.id === row.id)) { + // Update the attributes for the selected users + + const attributeOptionValues = foundAttributeInCache?.options.filter((option) => + selectedAttributeOptions.includes(option.id) + ); + + const newAttributes = + row.attributes?.filter((attr) => attr.attributeId !== selectedAttribute) || []; + + if (attributeOptionValues && attributeOptionValues.length > 0) { + const newAttributeValues = attributeOptionValues?.map((value) => ({ + id: value.id, + attributeId: value.attributeId, + value: value.value, + slug: value.slug, + })); + newAttributes.push(...newAttributeValues); + } else { + // Text or number input we don't have an option to fall back on + newAttributes.push({ + id: "-1", + attributeId: foundAttributeInCache?.id ?? "-1", + value: selectedAttributeOptions[0], + slug: slugify(selectedAttributeOptions[0]), + }); + } + + return { + ...row, + attributes: newAttributes, + }; + } + return row; + }), + })); + + return { + ...oldData, + pages: newPages, + }; + } + ); + + setSelectedAttribute(null); + setSelectedAttributeOptions([]); showToast(success.message, "success"); }, onError: (error) => { @@ -202,7 +272,7 @@ export function MassAssignAttributesBulkAction({ table }: Props) { <> - + {/* We dont really use shadows much - but its needed here */} @@ -262,9 +332,6 @@ export function MassAssignAttributesBulkAction({ table }: Props) { attributes: attributesToAssign, userIds: table.getSelectedRowModel().rows.map((row) => row.original.id), }); - - setSelectedAttribute(null); - setSelectedAttributeOptions([]); } }}> {t("apply")} diff --git a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx index dae4cee34b..4496229fa4 100644 --- a/packages/features/users/components/UserTable/BulkActions/TeamList.tsx +++ b/packages/features/users/components/UserTable/BulkActions/TeamList.tsx @@ -20,10 +20,10 @@ import { showToast, } from "@calcom/ui"; -import type { User } from "../UserListTable"; +import type { UserTableUser } from "../types"; interface Props { - table: Table; + table: Table; } export function TeamListBulkAction({ table }: Props) { diff --git a/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx b/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx index 1f5e51a12e..d3d223e3ec 100644 --- a/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx +++ b/packages/features/users/components/UserTable/ChangeUserRoleModal.tsx @@ -3,9 +3,9 @@ import type { Dispatch } from "react"; import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal"; -import type { Action, State } from "./UserListTable"; +import type { UserTableAction, UserTableState } from "./types"; -export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch }) { +export function ChangeUserRoleModal(props: { state: UserTableState; dispatch: Dispatch }) { const { data: session } = useSession(); const orgId = session?.user.org?.id; if (!orgId || !props.state.changeMemberRole.user) return null; diff --git a/packages/features/users/components/UserTable/DeleteMemberModal.tsx b/packages/features/users/components/UserTable/DeleteMemberModal.tsx index e5f1ff4b91..a4f2ba7377 100644 --- a/packages/features/users/components/UserTable/DeleteMemberModal.tsx +++ b/packages/features/users/components/UserTable/DeleteMemberModal.tsx @@ -5,9 +5,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc"; import { Dialog, ConfirmationDialogContent, showToast } from "@calcom/ui"; -import type { State, Action } from "./UserListTable"; +import type { UserTableAction, UserTableState } from "./types"; -export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch }) { +export function DeleteMemberModal({ + state, + dispatch, +}: { + state: UserTableState; + dispatch: Dispatch; +}) { const { t } = useLocale(); const { data: session } = useSession(); const utils = trpc.useUtils(); diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx index 2afd989ffc..c4455969c1 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx @@ -28,7 +28,7 @@ import { SheetTitle, } from "@calcom/ui"; -import type { Action } from "../UserListTable"; +import type { UserTableAction } from "../types"; import { useEditMode } from "./store"; type MembershipOption = { @@ -68,7 +68,7 @@ export function EditForm({ selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"]; avatarUrl: string; domainUrl: string; - dispatch: Dispatch; + dispatch: Dispatch; }) { const setEditMode = useEditMode((state) => state.setEditMode); const [mutationLoading, setMutationLoading] = useState(false); diff --git a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx index fd657f9f99..672a2f7b17 100644 --- a/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx +++ b/packages/features/users/components/UserTable/EditSheet/EditUserSheet.tsx @@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; import { Avatar, Loader, Sheet, SheetContent, SheetBody, SheetHeader, SheetFooter } from "@calcom/ui"; -import type { Action, State } from "../UserListTable"; +import type { UserTableAction, UserTableState } from "../types"; import { DisplayInfo } from "./DisplayInfo"; import { EditForm } from "./EditUserForm"; import { OrganizationBanner } from "./OrganizationBanner"; @@ -18,15 +18,20 @@ function removeProtocol(url: string) { return url.replace(/^(https?:\/\/)/, ""); } -export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch }) { +export function EditUserSheet({ + state, + dispatch, +}: { + state: UserTableState; + dispatch: Dispatch; +}) { const { t } = useLocale(); const { user: selectedUser } = state.editSheet; const orgBranding = useOrgBranding(); const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow); const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery( { - // @ts-expect-error we obly enable the query if the user is selected - userId: selectedUser.id, + userId: selectedUser?.id, }, { enabled: !!selectedUser?.id, @@ -36,8 +41,8 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis const { data: usersAttributes, isPending: usersAttributesPending } = trpc.viewer.attributes.getByUserId.useQuery( { - // @ts-expect-error we obly enable the query if the user is selected - userId: selectedUser.id, + // @ts-expect-error We know it exists as it is only called when selectedUser is defined + userId: selectedUser?.id, }, { enabled: !!selectedUser?.id, diff --git a/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx b/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx index 2753b6d635..2cd0d82453 100644 --- a/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx +++ b/packages/features/users/components/UserTable/ImpersonationMemberModal.tsx @@ -4,9 +4,12 @@ import type { Dispatch } from "react"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui"; -import type { Action, State } from "./UserListTable"; +import type { UserTableAction, UserTableState } from "./types"; -export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch }) { +export function ImpersonationMemberModal(props: { + state: UserTableState; + dispatch: Dispatch; +}) { const { t } = useLocale(); const { data: session } = useSession(); const teamId = session?.user.org?.id; diff --git a/packages/features/users/components/UserTable/InviteMemberModal.tsx b/packages/features/users/components/UserTable/InviteMemberModal.tsx index 0348ee6d70..d70b35036e 100644 --- a/packages/features/users/components/UserTable/InviteMemberModal.tsx +++ b/packages/features/users/components/UserTable/InviteMemberModal.tsx @@ -7,10 +7,10 @@ import { trpc } from "@calcom/trpc"; import { showToast } from "@calcom/ui"; import usePlatformMe from "@calcom/web/components/settings/platform/hooks/usePlatformMe"; -import type { Action } from "./UserListTable"; +import type { UserTableAction } from "./types"; interface Props { - dispatch: Dispatch; + dispatch: Dispatch; } export function InviteMemberModal(props: Props) { diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 108a05a84c..b113f2a4d5 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -1,21 +1,38 @@ -import { keepPreviousData } from "@tanstack/react-query"; -import type { ColumnDef, Table } from "@tanstack/react-table"; -import { m } from "framer-motion"; -import { useSession } from "next-auth/react"; -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +"use client"; +import { keepPreviousData } from "@tanstack/react-query"; +import type { ColumnFiltersState } from "@tanstack/react-table"; +import { + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, +} from "@tanstack/react-table"; +import { useSession } from "next-auth/react"; +import { useQueryState, parseAsBoolean } from "nuqs"; +import { useMemo, useReducer, useRef, useState } from "react"; + +import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { WEBAPP_URL } from "@calcom/lib/constants"; import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl"; -import { useCopy } from "@calcom/lib/hooks/useCopy"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import type { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; -import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui"; -import type { ActionItem } from "@calcom/ui/components/data-table/DataTableSelectionBar"; +import { + Avatar, + Badge, + Button, + Checkbox, + DataTable, + DataTableToolbar, + DataTableFilters, + DataTableSelectionBar, + DataTablePagination, +} from "@calcom/ui"; import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes"; -import { useOrgBranding } from "../../../ee/organizations/context/provider"; import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers"; +import { DynamicLink } from "./BulkActions/DynamicLink"; import { EventTypesList } from "./BulkActions/EventTypesList"; import { MassAssignAttributesBulkAction } from "./BulkActions/MassAssignAttributes"; import { TeamListBulkAction } from "./BulkActions/TeamList"; @@ -25,52 +42,10 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet"; import { ImpersonationMemberModal } from "./ImpersonationMemberModal"; import { InviteMemberModal } from "./InviteMemberModal"; import { TableActions } from "./UserTableActions"; +import type { UserTableState, UserTableAction, UserTableUser } from "./types"; +import { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached"; -export interface User { - id: number; - username: string | null; - email: string; - timeZone: string; - role: MembershipRole; - avatarUrl: string | null; - accepted: boolean; - disableImpersonation: boolean; - completedOnboarding: boolean; - teams: { - id: number; - name: string; - slug: string | null; - }[]; -} - -type Payload = { - showModal: boolean; - user?: User; -}; - -export type State = { - changeMemberRole: Payload; - deleteMember: Payload; - impersonateMember: Payload; - inviteMember: Payload; - editSheet: Payload & { user?: User }; -}; - -export type Action = - | { - type: - | "SET_CHANGE_MEMBER_ROLE_ID" - | "SET_DELETE_ID" - | "SET_IMPERSONATE_ID" - | "INVITE_MEMBER" - | "EDIT_USER_SHEET"; - payload: Payload; - } - | { - type: "CLOSE_MODAL"; - }; - -const initialState: State = { +const initialState: UserTableState = { changeMemberRole: { showModal: false, }, @@ -88,7 +63,15 @@ const initialState: State = { }, }; -function reducer(state: State, action: Action): State { +const initalColumnVisibility = { + select: true, + member: true, + role: true, + teams: true, + actions: true, +}; + +function reducer(state: UserTableState, action: UserTableAction): UserTableState { switch (action.type) { case "SET_CHANGE_MEMBER_ROLE_ID": return { ...state, changeMemberRole: action.payload }; @@ -115,34 +98,47 @@ function reducer(state: State, action: Action): State { } export function UserListTable() { + const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean); + const orgBranding = useOrgBranding(); + const domain = orgBranding?.fullDomain ?? WEBAPP_URL; + const { t } = useLocale(); + const { data: session } = useSession(); const { isPlatformUser } = useGetUserAttributes(); - const { copyToClipboard, isCopied } = useCopy(); const { data: org } = trpc.viewer.organizations.listCurrent.useQuery(); + const { data: attributes } = trpc.viewer.attributes.list.useQuery(); const { data: teams } = trpc.viewer.organizations.getTeams.useQuery(); + const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery(); + const tableContainerRef = useRef(null); const [state, dispatch] = useReducer(reducer, initialState); - const { t } = useLocale(); - const orgBranding = useOrgBranding(); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); - const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false); + + const [columnFilters, setColumnFilters] = useState([]); + const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.organizations.listMembers.useInfiniteQuery( { limit: 10, searchTerm: debouncedSearchTerm, + expand: ["attributes"], + filters: columnFilters.map((filter) => ({ + id: filter.id, + value: filter.value as string[], + })), }, { getNextPageParam: (lastPage) => lastPage.nextCursor, placeholderData: keepPreviousData, } ); + + // TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0; const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER"; - const domain = orgBranding?.fullDomain ?? WEBAPP_URL; //we must flatten the array of arrays from the useInfiniteQuery hook - const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[]; + const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[]; const totalFetched = flatData.length; const memorisedColumns = useMemo(() => { @@ -152,10 +148,44 @@ export function UserListTable() { canResendInvitation: adminOrOwner, canImpersonate: false, }; - const cols: ColumnDef[] = [ + const generateAttributeColumns = () => { + if (!attributes?.length) { + return []; + } + return ( + (attributes?.map((attribute) => ({ + id: attribute.id, + header: attribute.name, + accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value, + cell: ({ row }) => { + const attributeValues = row.original.attributes.filter( + (attr) => attr.attributeId === attribute.id + ); + if (attributeValues.length === 0) return null; + return ( + <> + {attributeValues.map((attributeValue, index) => ( + + {attributeValue.value} + + ))} + + ); + }, + filterFn: (rows, id, filterValue) => { + const attributeValues = rows.original.attributes.filter((attr) => attr.attributeId === id); + if (attributeValues.length === 0) return false; + return attributeValues.some((attr) => filterValue.includes(attr.value)); + }, + })) as ColumnDef[]) ?? [] + ); + }; + const cols: ColumnDef[] = [ // Disabling select for this PR: Will work on actions etc in a follow up { id: "select", + enableHiding: false, + enableSorting: false, header: ({ table }) => ( data.email, - header: `Member (${isPlatformUser ? totalFetched : totalDBRowCount})`, + enableHiding: false, + header: () => { + return `Members`; + }, cell: ({ row }) => { const { username, email, avatarUrl } = row.original; return ( @@ -204,9 +237,8 @@ export function UserListTable() { ); }, filterFn: (rows, id, filterValue) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore Weird typing issue - return rows.getValue(id).includes(filterValue); + const userEmail = rows.original.email; + return filterValue.includes(userEmail); }, }, { @@ -275,8 +307,10 @@ export function UserListTable() { return filterValue.some((value: string) => teamNames.includes(value)); }, }, + ...generateAttributeColumns(), { id: "actions", + enableHiding: false, cell: ({ row }) => { const user = row.original; const permissionsRaw = permissions; @@ -304,184 +338,125 @@ export function UserListTable() { ]; return cols; - }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]); + }, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount, attributes]); - const memoisedSelectionOptions = useMemo(() => { - const selectionOptionsForOrg: ActionItem[] = [ - { - type: "render", - render: (table) => , - }, - { - type: "render", - render: (table) => , - }, - { - type: "action", - icon: "handshake", - label: "Group Meeting", - needsXSelected: 2, - onClick: () => { - setDynamicLinkVisible((old) => !old); - }, - }, - { - type: "render", - render: (table) => , - }, - { - type: "render", - render: (table) => ( - row.original)} - onRemove={() => table.toggleAllPageRowsSelected(false)} - /> - ), - }, - ]; - - const selectionOptionsForPlatform: ActionItem[] = [ - { - type: "render", - render: (table: Table) => ( - row.original)} - onRemove={() => table.toggleAllPageRowsSelected(false)} - /> - ), - }, - ]; - - const selectionOptions = isPlatformUser ? selectionOptionsForPlatform : selectionOptionsForOrg; - - return selectionOptions; - }, [isPlatformUser, teams]); - - //called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table - const fetchMoreOnBottomReached = useCallback( - (containerRefElement?: HTMLDivElement | null) => { - if (containerRefElement) { - const { scrollHeight, scrollTop, clientHeight } = containerRefElement; - //once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any - if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { - fetchNextPage(); + const table = useReactTable({ + data: flatData, + columns: memorisedColumns, + enableRowSelection: true, + debugTable: true, + manualPagination: true, + initialState: { + columnVisibility: initalColumnVisibility, + }, + state: { + columnFilters, + }, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + // TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedUniqueValues: (_, columnId) => () => { + if (facetedTeamValues) { + switch (columnId) { + case "role": + return new Map(facetedTeamValues.roles.map((role) => [role, 1])); + case "teams": + return new Map(facetedTeamValues.teams.map((team) => [team.name, 1])); + default: + const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId); + if (attribute) { + return new Map(attribute?.options.map(({ value }) => [value, 1]) ?? []); + } + return new Map(); } } + return new Map(); }, - [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + }); + + const fetchMoreOnBottomReached = useFetchMoreOnBottomReached( + tableContainerRef, + fetchNextPage, + isFetching, + totalFetched, + totalDBRowCount ); - useEffect(() => { - fetchMoreOnBottomReached(tableContainerRef.current); - }, [fetchMoreOnBottomReached]); + const numberOfSelectedRows = table.getSelectedRowModel().rows.length; return ( <> { - const user = row.original; - const canEdit = adminOrOwner; - if (canEdit) { - // dispatch({ - // type: "EDIT_USER_SHEET", - // payload: { - // showModal: true, - // user, - // }, - // }); - } - }} data-testid="user-list-data-table" - onSearch={(value) => setDebouncedSearchTerm(value)} - selectionOptions={memoisedSelectionOptions} - renderAboveSelection={(table: Table) => { - const numberOfSelectedRows = table.getSelectedRowModel().rows.length; - const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible; - - const users = table - .getSelectedRowModel() - .flatRows.map((row) => row.original.username) - .filter((u) => u !== null); - - const usersNameAsString = users.join("+"); - - const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`; - const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, ""); - - return ( - <> - {isVisible ? ( - -
-

{domainWithoutHttps}

-
-
- - -
-
- ) : null} - - ); - }} + // className="lg:max-w-screen-lg" + table={table} tableContainerRef={tableContainerRef} - tableCTA={ - adminOrOwner && ( - - ) - } - columns={memorisedColumns} - data={flatData} isPending={isPending} - onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)} - filterableItems={[ - { - tableAccessor: "role", - title: "Role", - options: [ - { label: "Owner", value: "OWNER" }, - { label: "Admin", value: "ADMIN" }, - { label: "Member", value: "MEMBER" }, - { label: "Pending", value: "PENDING" }, - ], - }, - { - tableAccessor: "teams", - title: "Teams", - options: teams ? teams.map((team) => ({ label: team.name, value: team.name })) : [], - }, - ]} - /> + onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}> + +
+ setDebouncedSearchTerm(value)} /> + {/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */} + + + {adminOrOwner && ( + + dispatch({ + type: "INVITE_MEMBER", + payload: { + showModal: true, + }, + }) + } + data-testid="new-organization-member-button"> + {t("add")} + + )} +
+
+ +
+
+
+ +
+ + {numberOfSelectedRows >= 2 && dynamicLinkVisible && ( + + + + )} + {numberOfSelectedRows > 0 && ( + +

+ {numberOfSelectedRows} selected +

+ {!isPlatformUser ? ( + <> + + {numberOfSelectedRows >= 2 && ( + + )} + + + + ) : null} + row.original)} + onRemove={() => table.toggleAllPageRowsSelected(false)} + /> +
+ )} +
{state.deleteMember.showModal && } {state.inviteMember.showModal && } diff --git a/packages/features/users/components/UserTable/UserTableActions.tsx b/packages/features/users/components/UserTable/UserTableActions.tsx index af2fcd67c3..2cc794c734 100644 --- a/packages/features/users/components/UserTable/UserTableActions.tsx +++ b/packages/features/users/components/UserTable/UserTableActions.tsx @@ -16,8 +16,7 @@ import { showToast, } from "@calcom/ui"; -import type { Action } from "./UserListTable"; -import type { User } from "./UserListTable"; +import type { UserTableUser, UserTableAction } from "./types"; export function TableActions({ user, @@ -25,8 +24,8 @@ export function TableActions({ dispatch, domain, }: { - user: User; - dispatch: React.Dispatch; + user: UserTableUser; + dispatch: React.Dispatch; domain: string; permissionsForUser: { canEdit: boolean; diff --git a/packages/features/users/components/UserTable/types.ts b/packages/features/users/components/UserTable/types.ts new file mode 100644 index 0000000000..dfbff32eb6 --- /dev/null +++ b/packages/features/users/components/UserTable/types.ts @@ -0,0 +1,52 @@ +import type { MembershipRole } from "@calcom/prisma/enums"; + +export interface UserTableUser { + id: number; + username: string | null; + email: string; + timeZone: string; + role: MembershipRole; + avatarUrl: string | null; + accepted: boolean; + disableImpersonation: boolean; + completedOnboarding: boolean; + teams: { + id: number; + name: string; + slug: string | null; + }[]; + attributes: { + id: string; + attributeId: string; + value: string; + slug: string; + }[]; +} + +export type UserTablePayload = { + showModal: boolean; + user?: UserTableUser; +}; + +export type UserTableState = { + changeMemberRole: UserTablePayload; + deleteMember: UserTablePayload; + impersonateMember: UserTablePayload; + inviteMember: UserTablePayload; + editSheet: UserTablePayload & { user?: UserTableUser }; +}; + +export type UserTableAction = + | { + type: + | "SET_CHANGE_MEMBER_ROLE_ID" + | "SET_DELETE_ID" + | "SET_IMPERSONATE_ID" + | "INVITE_MEMBER" + | "EDIT_USER_SHEET" + | "INVITE_MEMBER"; + payload: UserTablePayload; + } + | { + type: "CLOSE_MODAL"; + }; diff --git a/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts b/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts new file mode 100644 index 0000000000..ef224bd3c6 --- /dev/null +++ b/packages/features/users/components/UserTable/useFetchMoreOnBottomReached.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from "react"; + +export const useFetchMoreOnBottomReached = ( + tableContainerRef: React.RefObject, + fetchNextPage: () => void, + isFetching: boolean, + totalFetched: number, + totalDBRowCount: number +) => { + const fetchMoreOnBottomReached = useCallback( + (containerRefElement?: HTMLDivElement | null) => { + if (containerRefElement) { + const { scrollHeight, scrollTop, clientHeight } = containerRefElement; + if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) { + fetchNextPage(); + } + } + }, + [fetchNextPage, isFetching, totalFetched, totalDBRowCount] + ); + + useEffect(() => { + fetchMoreOnBottomReached(tableContainerRef.current); + }, [fetchMoreOnBottomReached, tableContainerRef]); + + return fetchMoreOnBottomReached; +}; diff --git a/packages/trpc/server/routers/viewer/organizations/_router.tsx b/packages/trpc/server/routers/viewer/organizations/_router.tsx index 4761600aed..94c6e0d470 100644 --- a/packages/trpc/server/routers/viewer/organizations/_router.tsx +++ b/packages/trpc/server/routers/viewer/organizations/_router.tsx @@ -169,4 +169,11 @@ export const viewerOrganizationsRouter = router({ ); return handler(opts); }), + getFacetedValues: authedProcedure.query(async (opts) => { + const handler = await importHandler( + namespaced("getFacetedValues"), + () => import("./getFacetedValues.handler") + ); + return handler(opts); + }), }); diff --git a/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts new file mode 100644 index 0000000000..51565c3ee2 --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.handler.ts @@ -0,0 +1,54 @@ +import { prisma } from "@calcom/prisma"; +import { MembershipRole } from "@calcom/prisma/enums"; + +import { TRPCError } from "@trpc/server"; + +import type { TrpcSessionUser } from "../../../trpc"; + +type DeleteOptions = { + ctx: { + user: NonNullable; + }; +}; + +export const getFacetedValuesHandler = async ({ ctx }: DeleteOptions) => { + const { user } = ctx; + + const organizationId = user.organization?.id; + + if (!organizationId) { + throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" }); + } + const [teams, attributes] = await Promise.all([ + prisma.team.findMany({ + where: { + parentId: organizationId, + }, + select: { + id: true, + name: true, + }, + }), + prisma.attribute.findMany({ + where: { teamId: organizationId }, + select: { + id: true, + name: true, + options: { + select: { + value: true, + }, + distinct: "value", + }, + }, + }), + ]); + + return { + teams: teams, + attributes: attributes, + roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER], + }; +}; + +export default getFacetedValuesHandler; diff --git a/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts new file mode 100644 index 0000000000..e2981ea1df --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/getFacetedValues.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetFacetedValuesInput = z.object({ + include: z.enum(["users", "roles", "teams", "attributes"]).array().optional(), +}); + +export type TGetFacetedValuesInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index 9acdf07ea0..54f0170bac 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -1,5 +1,7 @@ import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; +import type { Prisma } from "@calcom/prisma/client"; +import type { MembershipRole } from "@calcom/prisma/enums"; import { TRPCError } from "@trpc/server"; @@ -16,6 +18,8 @@ type GetOptions = { export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId; const searchTerm = input.searchTerm; + const expand = input.expand; + const filters = input.filters || []; if (!organizationId) { throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." }); @@ -39,30 +43,56 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }, }); - // I couldnt get this query to work direct on membership table - const teamMembers = await prisma.membership.findMany({ - where: { - teamId: organizationId, - user: { - isPlatformManaged: false, - }, - ...(searchTerm && { - user: { - OR: [ - { - email: { - contains: searchTerm, - }, - }, - { - username: { - contains: searchTerm, - }, - }, - ], - }, - }), + const whereClause = { + user: { + isPlatformManaged: false, }, + teamId: organizationId, + ...(searchTerm && { + user: { + OR: [{ email: { contains: searchTerm } }, { username: { contains: searchTerm } }], + }, + }), + } as Prisma.MembershipWhereInput; + + filters.forEach((filter) => { + switch (filter.id) { + case "role": + whereClause.role = { in: filter.value as MembershipRole[] }; + break; + case "teams": + whereClause.user = { + teams: { + some: { + team: { + name: { + in: filter.value, + }, + }, + }, + }, + }; + break; + // We assume that if the filter is not one of the above, it must be an attribute filter + default: + whereClause.AttributeToUser = { + some: { + attributeOption: { + attribute: { + id: filter.id, + }, + value: { + in: filter.value, + }, + }, + }, + }; + break; + } + }); + + const teamMembers = await prisma.membership.findMany({ + where: whereClause, select: { id: true, role: true, @@ -106,6 +136,25 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const members = await Promise.all( teamMembers?.map(async (membership) => { const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user }); + let attributes; + + if (expand?.includes("attributes")) { + attributes = await prisma.attributeOption.findMany({ + where: { + assignedUsers: { + some: { + memberId: membership.id, + }, + }, + }, + orderBy: { + attribute: { + name: "asc", + }, + }, + }); + } + return { id: user.id, username: user.username, @@ -126,6 +175,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { slug: team.team.slug, }; }), + attributes, }; }) || [] ); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts index 7118e83e0d..9c5a5d5827 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.schema.ts @@ -1,9 +1,20 @@ import { z } from "zod"; +const expandableColumns = z.enum(["attributes"]); + export const ZListMembersSchema = z.object({ limit: z.number().min(1).max(100), cursor: z.number().nullish(), searchTerm: z.string().optional(), + expand: z.array(expandableColumns).optional(), + filters: z + .array( + z.object({ + id: z.string(), + value: z.array(z.string()), + }) + ) + .optional(), }); export type TListMembersSchema = z.infer; diff --git a/packages/ui/components/data-table/DataTableFilter.tsx b/packages/ui/components/data-table/DataTableFilter.tsx index cce9bef556..f5b717117a 100644 --- a/packages/ui/components/data-table/DataTableFilter.tsx +++ b/packages/ui/components/data-table/DataTableFilter.tsx @@ -1,4 +1,7 @@ +"use client"; + import type { Column } from "@tanstack/react-table"; +import { useState } from "react"; import { classNames } from "@calcom/lib"; @@ -17,18 +20,68 @@ import { } from "../command"; import { Popover, PopoverContent, PopoverTrigger } from "../popover"; +interface FilterOption { + label: string; + value: string; + icon?: IconName; + options?: FilterOption[]; +} + interface DataTableFilter { column?: Column; title?: string; - options: { - label: string; - value: string; - icon?: IconName; - }[]; + options: FilterOption[]; } + export function DataTableFilter({ column, title, options }: DataTableFilter) { const facets = column?.getFacetedUniqueValues(); const selectedValues = new Set(column?.getFilterValue() as string[]); + const [currentOptions, setCurrentOptions] = useState(options); + const [navigationPath, setNavigationPath] = useState([]); + + const flattenOptions = (opts: FilterOption[]): FilterOption[] => { + return opts.reduce((acc, option) => { + acc.push(option); + if (option.options) { + acc.push(...flattenOptions(option.options)); + } + return acc; + }, [] as FilterOption[]); + }; + + const allOptions = flattenOptions(options); + + const handleSelect = (option: FilterOption) => { + if (option.options) { + setCurrentOptions(option.options); + setNavigationPath([...navigationPath, option.label]); + } else { + if (selectedValues.has(option.value)) { + selectedValues.delete(option.value); + } else { + selectedValues.add(option.value); + } + const filterValues = Array.from(selectedValues); + column?.setFilterValue(filterValues.length ? filterValues : undefined); + } + }; + + const handleBack = () => { + if (navigationPath.length > 0) { + const newPath = [...navigationPath]; + newPath.pop(); + setNavigationPath(newPath); + + let newOptions = options; + for (const label of newPath) { + const found = newOptions.find((o) => o.label === label); + if (found && found.options) { + newOptions = found.options; + } + } + setCurrentOptions(newOptions); + } + }; return ( @@ -44,7 +97,7 @@ export function DataTableFilter({ column, title, options }: DataT {selectedValues.size} ) : ( - options + allOptions .filter((option) => selectedValues.has(option.value)) .map((option) => ( @@ -62,32 +115,32 @@ export function DataTableFilter({ column, title, options }: DataT No results found. + {navigationPath.length > 0 && ( + + + + Back + + + )} - {options.map((option) => { - // TODO: It would be nice to pull these from data instead of options + {currentOptions.map((option) => { const isSelected = selectedValues.has(option.value); return ( - { - if (isSelected) { - selectedValues.delete(option.value); - } else { - selectedValues.add(option.value); - } - const filterValues = Array.from(selectedValues); - column?.setFilterValue(filterValues.length ? filterValues : undefined); - }}> -
- -
+ handleSelect(option)}> + {!option.options && ( +
+ +
+ )} {option.icon && } {option.label} - {facets?.get(option.value) && ( + {option.options && } + {!option.options && facets?.get(option.value) && ( {facets.get(option.value)} diff --git a/packages/ui/components/data-table/DataTablePagination.tsx b/packages/ui/components/data-table/DataTablePagination.tsx index 8533ef8a01..6e333b60a4 100644 --- a/packages/ui/components/data-table/DataTablePagination.tsx +++ b/packages/ui/components/data-table/DataTablePagination.tsx @@ -1,59 +1,19 @@ -import type { Table } from "@tanstack/react-table"; +"use client"; -import { Button } from "../button"; +import type { Table } from "@tanstack/react-table"; interface DataTablePaginationProps { table: Table; + totalDbDataCount: number; } -export function DataTablePagination({ table }: DataTablePaginationProps) { +export function DataTablePagination({ table, totalDbDataCount }: DataTablePaginationProps) { + const loadedCount = table.getFilteredRowModel().rows.length; + return ( -
-
- {table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s) - selected. -
-
-
- Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1} -
-
- - - - -
-
-
+

+ Loaded {`${loadedCount}`} of + {`${totalDbDataCount}`} +

); } diff --git a/packages/ui/components/data-table/DataTableSelectionBar.tsx b/packages/ui/components/data-table/DataTableSelectionBar.tsx index 28a57a4664..b8ed3e1da0 100644 --- a/packages/ui/components/data-table/DataTableSelectionBar.tsx +++ b/packages/ui/components/data-table/DataTableSelectionBar.tsx @@ -1,10 +1,11 @@ +"use client"; + import type { Table } from "@tanstack/react-table"; -import type { Table as TableType } from "@tanstack/table-core/build/lib/types"; -import { AnimatePresence } from "framer-motion"; -import { Fragment } from "react"; +import { forwardRef } from "react"; + +import { classNames } from "@calcom/lib"; import type { IconName } from "../.."; -import { Button } from "../button"; export type ActionItem = | { @@ -20,51 +21,26 @@ export type ActionItem = needsXSelected?: number; }; -interface DataTableSelectionBarProps { - table: Table; - actions?: ActionItem[]; - renderAboveSelection?: (table: TableType) => React.ReactNode; -} - -export function DataTableSelectionBar({ - table, - actions, - renderAboveSelection, -}: DataTableSelectionBarProps) { - const numberOfSelectedRows = table.getSelectedRowModel().rows.length; - const isVisible = numberOfSelectedRows > 0; - - // Hacky left % to center - const actionsVisible = actions?.filter((a) => { - if (!a.needsXSelected) return true; - return a.needsXSelected <= numberOfSelectedRows; - }); - +const Root = forwardRef< + HTMLDivElement, + React.HTMLAttributes & { showSelectionCount?: boolean } +>(({ children, ...props }, ref) => { return ( - - {isVisible ? ( -
- {renderAboveSelection && renderAboveSelection(table)} -
-

- {numberOfSelectedRows} selected -

- {actionsVisible?.map((action, index) => { - return ( - - {action.type === "action" ? ( - - ) : action.type === "render" ? ( - action.render(table) - ) : null} - - ); - })} -
-
- ) : null} -
+
+ {children} +
); -} +}); + +Root.displayName = "Root"; + +export const DataTableSelectionBar = { + Root, +}; diff --git a/packages/ui/components/data-table/DataTableToolbar.tsx b/packages/ui/components/data-table/DataTableToolbar.tsx index aaebbb3de0..9e30abd3ae 100644 --- a/packages/ui/components/data-table/DataTableToolbar.tsx +++ b/packages/ui/components/data-table/DataTableToolbar.tsx @@ -1,44 +1,45 @@ "use client"; import type { Table } from "@tanstack/react-table"; -import { useEffect, useState } from "react"; +import { useEffect, useState, forwardRef } from "react"; +import type { ComponentPropsWithoutRef } from "react"; +import { classNames } from "@calcom/lib"; import { useDebounce } from "@calcom/lib/hooks/useDebounce"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ButtonProps } from "../button"; import { Button } from "../button"; import { Input } from "../form"; -import type { IconName } from "../icon/icon-names"; -import { DataTableFilter } from "./DataTableFilter"; -export type FilterableItems = { - title: string; - tableAccessor: string; - options: { - label: string; - value: string; - icon?: IconName; - }[]; -}[]; +interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> { + children: React.ReactNode; +} -interface DataTableToolbarProps { +const Root = forwardRef(function DataTableToolbar( + { children, className }, + ref +) { + return ( +
+ {children} +
+ ); +}); + +interface SearchBarProps { table: Table; - filterableItems?: FilterableItems; searchKey?: string; - tableCTA?: React.ReactNode; onSearch?: (value: string) => void; } -export function DataTableToolbar({ - table, - filterableItems, - tableCTA, - searchKey, - onSearch, -}: DataTableToolbarProps) { - // TODO: Is there a better way to check if the table is filtered? - // If you select ALL filters for a column, the table is not filtered and we dont get a reset button - const isFiltered = table.getState().columnFilters.length > 0; +function SearchBarComponent( + { table, searchKey, onSearch }: SearchBarProps, + ref: React.Ref +) { const [searchTerm, setSearchTerm] = useState(""); const debouncedSearchTerm = useDebounce(searchTerm, 500); @@ -46,54 +47,80 @@ export function DataTableToolbar({ onSearch?.(debouncedSearchTerm); }, [debouncedSearchTerm, onSearch]); - const { t } = useLocale(); + if (onSearch) { + return ( + setSearchTerm(event.target.value.trim())} + /> + ); + } + + if (!searchKey) { + console.error("searchKey is required if onSearch is not provided"); + return null; + } return ( -
- {searchKey && ( - table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())} - /> - )} - {onSearch && ( - { - setSearchTerm(e.target.value); - }} - /> - )} - {isFiltered && ( - - )} - - {filterableItems && - filterableItems?.map((item) => { - const foundColumn = table.getColumn(item.tableAccessor); - if (foundColumn?.getCanFilter()) { - return ( - - ); - } - })} - - {tableCTA ? tableCTA : null} -
+ { + return table.getColumn(searchKey)?.setFilterValue(event.target.value.trim()); + }} + /> ); } + +const SearchBar = forwardRef(SearchBarComponent) as ( + props: SearchBarProps & { ref?: React.Ref } +) => ReturnType; + +interface ClearFiltersButtonProps { + table: Table; +} + +function ClearFiltersButtonComponent( + { table }: ClearFiltersButtonProps, + ref: React.Ref +) { + const { t } = useLocale(); + const isFiltered = table.getState().columnFilters.length > 0; + if (!isFiltered) return null; + return ( + + ); +} + +const ClearFiltersButton = forwardRef(ClearFiltersButtonComponent) as ( + props: ClearFiltersButtonProps & { ref?: React.Ref } +) => ReturnType; + +function CTAComponent( + { children, onClick, color = "primary", ...rest }: ButtonProps, + ref: React.Ref +) { + return ( + + ); +} + +const CTA = forwardRef(CTAComponent) as ( + props: ButtonProps & { ref?: React.Ref } +) => ReturnType; + +export const DataTableToolbar = { Root, SearchBar, ClearFiltersButton, CTA }; diff --git a/packages/ui/components/data-table/__storybook__/columns.tsx b/packages/ui/components/data-table/__storybook__/columns.tsx deleted file mode 100644 index e041aa9bfa..0000000000 --- a/packages/ui/components/data-table/__storybook__/columns.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import type { ColumnDef } from "@tanstack/react-table"; - -import { Badge } from "../../badge"; -import { Checkbox } from "../../form"; -import type { FilterableItems } from "../DataTableToolbar"; -import type { DataTableUserStorybook } from "./data"; - -export const columns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - className="translate-y-[2px]" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - className="translate-y-[2px]" - /> - ), - }, - { - accessorKey: "username", - header: "Username", - }, - { - accessorKey: "email", - header: "Email", - }, - { - accessorKey: "role", - header: "Role", - cell: ({ row, table }) => { - const user = row.original; - const BadgeColor = user.role === "admin" ? "blue" : "gray"; - - return ( - { - table.getColumn("role")?.setFilterValue(user.role); - }}> - {user.role} - - ); - }, - filterFn: (rows, id, filterValue) => { - return filterValue.includes(rows.getValue(id)); - }, - }, -]; - -export const filterableItems: FilterableItems = [ - { - title: "Role", - tableAccessor: "role", - options: [ - { - label: "Admin", - value: "admin", - }, - { - label: "User", - value: "user", - }, - { - label: "Owner", - value: "owner", - }, - ], - }, -]; diff --git a/packages/ui/components/data-table/__storybook__/data.tsx b/packages/ui/components/data-table/__storybook__/data.tsx deleted file mode 100644 index fe5166485b..0000000000 --- a/packages/ui/components/data-table/__storybook__/data.tsx +++ /dev/null @@ -1,98 +0,0 @@ -export type DataTableUserStorybook = { - id: string; - username: string; - email: string; - role: "admin" | "user"; -}; - -export const dataTableSelectionActions = [ - { - label: "Add To Team", - onClick: () => { - console.log("Add To Team"); - }, - icon: "users", - }, - { - label: "Delete", - onClick: () => { - console.log("Delete"); - }, - icon: "stop-circle", - }, -]; - -export const dataTableDemousers: DataTableUserStorybook[] = [ - { - id: "728ed52f", - email: "m@example.com", - username: "m", - role: "admin", - }, - { - id: "489e1d42", - email: "example@gmail.com", - username: "e", - role: "user", - }, - { - id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f", - email: "Keshawn_Schroeder@hotmail.com", - username: "Ava_Waelchi", - role: "user", - }, - { - id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b", - email: "Jovanny_Grant@hotmail.com", - username: "Kamren_Gerhold", - role: "admin", - }, - { - id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a", - email: "Emilie.McKenzie@yahoo.com", - username: "Lennie_Harber", - role: "user", - }, - { - id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5", - email: "Jolie_Beatty@hotmail.com", - username: "Lorenzo_Will", - role: "admin", - }, - { - id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e", - email: "Giovanny_Cruickshank@hotmail.com", - username: "Monserrat_Lang", - role: "user", - }, - { - id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e", - email: "Lela_Haag@hotmail.com", - username: "Eddie_Effertz", - role: "user", - }, - { - id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a", - email: "Lura_Kohler@gmail.com", - username: "Alyce_Olson", - role: "user", - }, - { - id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a", - email: "Maurice.Koch@hotmail.com", - username: "Jovanny_Kiehn", - role: "admin", - }, - { - id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i", - email: "Brenda_Bernhard@yahoo.com", - username: "Aurelia_Kemmer", - role: "user", - }, - { - id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l", - email: "Lorenzo_Rippin@hotmail.com", - username: "Waino_Lang", - role: "admin", - }, -]; diff --git a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx b/packages/ui/components/data-table/__storybook__/datatable.stories.mdx deleted file mode 100644 index 37e33e5d0b..0000000000 --- a/packages/ui/components/data-table/__storybook__/datatable.stories.mdx +++ /dev/null @@ -1,67 +0,0 @@ -import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs"; - -import { - Examples, - Example, - Note, - Title, - CustomArgsTable, - VariantsTable, - VariantRow, -} from "@calcom/storybook/components"; - -import { DataTable } from "../"; -import { columns, filterableItems } from "./columns"; -import { dataTableDemousers, dataTableSelectionActions } from "./data"; - - - - - -## Definition - -The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation. - -## Structure - -The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection. - -<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> diff --git a/packages/ui/components/data-table/filters/index.tsx b/packages/ui/components/data-table/filters/index.tsx new file mode 100644 index 0000000000..7aedf9d60d --- /dev/null +++ b/packages/ui/components/data-table/filters/index.tsx @@ -0,0 +1,269 @@ +"use client"; + +import { type Table } from "@tanstack/react-table"; +import { forwardRef, useState, useMemo } from "react"; + +import { classNames } from "@calcom/lib"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ButtonProps } from "@calcom/ui"; +import { + Button, + buttonClasses, + Popover, + PopoverTrigger, + PopoverContent, + Command, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandSeparator, + Icon, +} from "@calcom/ui"; + +import { useFiltersSearchState } from "./utils"; + +interface ColumnVisiblityProps<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 }; diff --git a/packages/ui/components/data-table/filters/utils.ts b/packages/ui/components/data-table/filters/utils.ts new file mode 100644 index 0000000000..69c483c4e1 --- /dev/null +++ b/packages/ui/components/data-table/filters/utils.ts @@ -0,0 +1,17 @@ +"use client"; + +import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs"; +import { z } from "zod"; + +const filterSchema = z.object({ + f: z.string(), + v: z.array(z.string()).optional(), +}); + +export const filtersSearchParams = { + activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]), +}; + +export function useFiltersSearchState() { + return useQueryStates(filtersSearchParams); +} diff --git a/packages/ui/components/data-table/index.tsx b/packages/ui/components/data-table/index.tsx index ff0ac6e17c..c892e830d4 100644 --- a/packages/ui/components/data-table/index.tsx +++ b/packages/ui/components/data-table/index.tsx @@ -1,98 +1,40 @@ "use client"; -import type { - ColumnDef, - ColumnFiltersState, - Row, - SortingState, - VisibilityState, - Table as TableType, -} from "@tanstack/react-table"; -import { - flexRender, - getCoreRowModel, - getFacetedRowModel, - getFacetedUniqueValues, - getFilteredRowModel, - getSortedRowModel, - useReactTable, -} from "@tanstack/react-table"; -import { useState } from "react"; +import type { Row } from "@tanstack/react-table"; +import { flexRender } from "@tanstack/react-table"; +import type { Table as ReactTableType } from "@tanstack/react-table"; import { useVirtual } from "react-virtual"; import classNames from "@calcom/lib/classNames"; +import Icon from "../icon/Icon"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew"; -import type { ActionItem } from "./DataTableSelectionBar"; -import { DataTableSelectionBar } from "./DataTableSelectionBar"; -import type { FilterableItems } from "./DataTableToolbar"; -import { DataTableToolbar } from "./DataTableToolbar"; + +// Export DataTable components under a common namespace for better clarity +export { DataTableToolbar } from "./DataTableToolbar"; +export { DataTableFilters } from "./filters"; export interface DataTableProps<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> ); } diff --git a/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts b/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts new file mode 100644 index 0000000000..ef224bd3c6 --- /dev/null +++ b/packages/ui/components/data-table/useFetchMoreOnBottomReached.ts @@ -0,0 +1,27 @@ +import { useCallback, useEffect } from "react"; + +export const useFetchMoreOnBottomReached = ( + tableContainerRef: React.RefObject<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; +}; diff --git a/packages/ui/components/table/TableNew.tsx b/packages/ui/components/table/TableNew.tsx index e09469f3b6..ecdb234536 100644 --- a/packages/ui/components/table/TableNew.tsx +++ b/packages/ui/components/table/TableNew.tsx @@ -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)} diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 1b76068ca0..b21a462c41 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -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";