feat: attributes filter / refactor of user data table (#17014)
* WIP restored from .git cache * fix exports * sortable row model * feat column visibility component * wip filters with nuqs * pull in unique values from table into filters * correctly assign filters via v/f * inital selection bar refactor * data-table selection bar + optmistic update of delete * dynamic link * migrate member list table to new data-table * total list shows filtered value > db valuie * add filters for attributes * type errors * make content bigger on lg * add mb-6 to teams user datatable to match spacing spec * correctly render multi-badge * fix: masss asignment optimistic UI * fix type errors * remove log * fix toolbar type error * chore: Remove debug artifact * type errors * Update apps/web/public/static/locales/en/common.json * use max-w-fit * chore: Remove unused translation now we don't specify 'mass' in assign * perf: fix: use the onBlur event to prevent focus loss whilst the list is rerendering * Move the data-table exports together in the main barrel, then import * fix exports that were lost in a merge * fix exports that were lost in a merge * fix groupteammapping/availbilityslider * fix overflow problems * add scrollbar-thin class * fix type error * user serverside values for faceted filters * pass filters to serverside * filter serverside * fix team server side filter * add loaded x of y * attributes icon change * correct implementation for text/input attr optimistic * type check fixes * fix platform checks * fix types again * fix types again * fix types again * add use client * add use client * fix-types * fix: Add missing translation in EN * fix e2e tests via testid * fix e2e tests via testid * fix: Member invite popup not popping up * Update copyInviteLink to new-member-button testid * Hopefully fix test ids this time * fix: Use the right buttons on the right pages --------- Co-authored-by: Alex van Andel <me@alexvanandel.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
co-authored by
Alex van Andel
Peer Richelsen
Udit Takkar
parent
0b914bef79
commit
7e44e686e8
@@ -1,5 +1,5 @@
|
||||
import MembersView from "@calcom/features/ee/organizations/pages/settings/members";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import SettingsLayout from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
@@ -8,6 +8,10 @@ export {
|
||||
type PageProps,
|
||||
} from "@calcom/features/ee/organizations/pages/settings/getServerSidePropsMembers";
|
||||
|
||||
export const getLayout = (page: React.ReactElement) => (
|
||||
<SettingsLayout containerClassName="lg:max-w-screen-2xl">{page}</SettingsLayout>
|
||||
);
|
||||
|
||||
const Page = () => <MembersView />;
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function acceptTeamOrOrgInvite(page: Page) {
|
||||
}
|
||||
|
||||
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
await page.getByTestId("new-organization-member-button").click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await submitAndWaitForResponse(page, "/api/trpc/teams/inviteMember?batch=1", {
|
||||
action: () => page.locator('button:text("Send invite")').click(),
|
||||
|
||||
@@ -106,7 +106,7 @@ test.describe("Organization", () => {
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${invitedUserEmail.split("@")[0]}-domain`;
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await inviteAnEmail(page, invitedUserEmail, true);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
@@ -164,7 +164,7 @@ test.describe("Organization", () => {
|
||||
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const inviteLink = await copyInviteLink(page, true);
|
||||
const email = users.trackEmail({ username: "rick", domain: "domain.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}-domain`;
|
||||
@@ -333,7 +333,7 @@ test.describe("Organization", () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
const usernameDerivedFromEmail = invitedUserEmail.split("@")[0];
|
||||
await inviteAnEmail(page, invitedUserEmail);
|
||||
await inviteAnEmail(page, invitedUserEmail, true);
|
||||
await expectUserToBeAMemberOfTeam({
|
||||
page,
|
||||
teamId: team.id,
|
||||
@@ -391,7 +391,7 @@ test.describe("Organization", () => {
|
||||
await test.step("By invite link", async () => {
|
||||
await page.goto(`/settings/teams/${team.id}/members`);
|
||||
|
||||
const inviteLink = await copyInviteLink(page);
|
||||
const inviteLink = await copyInviteLink(page, true);
|
||||
const email = users.trackEmail({ username: "rick", domain: "example.com" });
|
||||
// '-domain' because the email doesn't match orgAutoAcceptEmail
|
||||
const usernameDerivedFromEmail = `${email.split("@")[0]}`;
|
||||
@@ -481,8 +481,12 @@ export async function signupFromEmailInviteLink({
|
||||
await signupPage.close();
|
||||
}
|
||||
|
||||
async function inviteAnEmail(page: Page, invitedUserEmail: string) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
async function inviteAnEmail(page: Page, invitedUserEmail: string, teamPage?: boolean) {
|
||||
if (teamPage) {
|
||||
await page.getByTestId("new-member-button").click();
|
||||
} else {
|
||||
await page.getByTestId("new-organization-member-button").click();
|
||||
}
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
|
||||
await page.locator('button:text("Send invite")').click();
|
||||
@@ -558,8 +562,12 @@ function assertInviteLink(inviteLink: string | null | undefined): asserts invite
|
||||
if (!inviteLink) throw new Error("Invite link not found");
|
||||
}
|
||||
|
||||
async function copyInviteLink(page: Page) {
|
||||
await page.locator('button:text("Add")').click();
|
||||
async function copyInviteLink(page: Page, teamPage?: boolean) {
|
||||
if (teamPage) {
|
||||
await page.getByTestId("new-member-button").click();
|
||||
} else {
|
||||
await page.getByTestId("new-organization-member-button").click();
|
||||
}
|
||||
const inviteLink = await getInviteLink(page);
|
||||
return inviteLink;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ test.describe("Team", () => {
|
||||
username: "rick",
|
||||
domain: `domain-${Date.now()}.com`,
|
||||
});
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator(`button:text("${t("send_invite")}")`).click();
|
||||
const inviteLink = await expectInvitationEmailToBeReceived(
|
||||
@@ -74,7 +74,8 @@ test.describe("Team", () => {
|
||||
email: `user-invite-${Date.now()}@domain.com`,
|
||||
password: "P4ssw0rd!",
|
||||
});
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
|
||||
await page.getByTestId("new-member-button").click();
|
||||
const inviteLink = await getInviteLink(page);
|
||||
|
||||
const context = await browser.newContext();
|
||||
@@ -105,7 +106,7 @@ test.describe("Team", () => {
|
||||
username: "rick",
|
||||
domain: `example.com`,
|
||||
});
|
||||
await page.locator(`button:text("${t("add")}")`).click();
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('input[name="inviteUser"]').fill(invitedUserEmail);
|
||||
await page.locator(`button:text("${t("send_invite")}")`).click();
|
||||
await expectInvitationEmailToBeReceived(
|
||||
|
||||
@@ -2599,6 +2599,7 @@
|
||||
"new_option": "New option",
|
||||
"update_profile": "Update member",
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_deleted_successfully": "Attribute deleted successfully",
|
||||
"attributes_edited_successfully": "Attributes edited successfully",
|
||||
"attribute_meta_description": "Manage attributes for your team members",
|
||||
"attributes_edit_description": "Edit attributes for your team members",
|
||||
@@ -2644,6 +2645,9 @@
|
||||
"month_to_date": "month to date",
|
||||
"year_to_date": "year to date",
|
||||
"custom_range": "custom range",
|
||||
"show_all_columns": "Show all columns",
|
||||
"toggle_columns": "Toggle columns",
|
||||
"no_columns_found": "No columns found",
|
||||
"salesforce_create_record_as": "On booking, add events on and new attendees as:",
|
||||
"salesforce_lead": "Lead",
|
||||
"salesforce_contact_under_account": "Contact under an account",
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useReactTable, getCoreRowModel } from "@tanstack/react-table";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { DataTable, Button } from "@calcom/ui";
|
||||
import { DataTable, DataTableToolbar } from "@calcom/ui";
|
||||
|
||||
import CreateTeamDialog from "./CreateTeamDialog";
|
||||
import GroupNameCell from "./GroupNameCell";
|
||||
@@ -44,14 +45,21 @@ const GroupTeamMappingTable = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: data?.teamGroupMapping ?? [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
data={data ? data.teamGroupMapping : []}
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={columns}
|
||||
tableCTA={<Button onClick={() => setCreateTeamDialogOpen(true)}>Create team</Button>}
|
||||
/>
|
||||
<DataTable table={table} tableContainerRef={tableContainerRef}>
|
||||
<DataTableToolbar.Root>
|
||||
<DataTableToolbar.CTA onClick={() => setCreateTeamDialogOpen(true)}>
|
||||
Create team
|
||||
</DataTableToolbar.CTA>
|
||||
</DataTableToolbar.Root>
|
||||
</DataTable>
|
||||
<CreateTeamDialog open={createTeamDialogOpen} onOpenChange={setCreateTeamDialogOpen} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import type { ColumnDef, Table } from "@tanstack/react-table";
|
||||
import type { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import classNames from "classnames";
|
||||
import { m } from "framer-motion";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useMemo, useRef, useReducer, useState, useEffect, useCallback } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { DynamicLink } from "@calcom/features/users/components/UserTable/BulkActions/DynamicLink";
|
||||
import { useFetchMoreOnBottomReached } from "@calcom/features/users/components/UserTable/useFetchMoreOnBottomReached";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { Avatar, Badge, Checkbox, DataTable } from "@calcom/ui";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
DataTableToolbar,
|
||||
DataTableFilters,
|
||||
DataTableSelectionBar,
|
||||
ConfirmationDialogContent,
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -66,7 +82,12 @@ export type State = {
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type: "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "EDIT_USER_SHEET" | "TEAM_AVAILABILITY";
|
||||
type:
|
||||
| "SET_DELETE_ID"
|
||||
| "SET_IMPERSONATE_ID"
|
||||
| "EDIT_USER_SHEET"
|
||||
| "TEAM_AVAILABILITY"
|
||||
| "INVITE_MEMBER";
|
||||
payload: Payload;
|
||||
}
|
||||
| {
|
||||
@@ -88,6 +109,14 @@ const initialState: State = {
|
||||
},
|
||||
};
|
||||
|
||||
const initalColumnVisibility = {
|
||||
select: true,
|
||||
member: true,
|
||||
role: true,
|
||||
teams: true,
|
||||
actions: true,
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
switch (action.type) {
|
||||
case "SET_DELETE_ID":
|
||||
@@ -111,18 +140,24 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
team: NonNullable<RouterOutputs["viewer"]["teams"]["get"]>;
|
||||
isOrgAdminOrOwner: boolean | undefined;
|
||||
setShowMemberInvitationModal: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export default function MemberList(props: Props) {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const { t, i18n } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const orgBranding = useOrgBranding();
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const { data, isPending, fetchNextPage, isFetching } = trpc.viewer.teams.listMembers.useInfiniteQuery(
|
||||
{
|
||||
@@ -140,6 +175,9 @@ export default function MemberList(props: Props) {
|
||||
}
|
||||
);
|
||||
|
||||
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const removeMemberFromCache = ({
|
||||
utils,
|
||||
memberId,
|
||||
@@ -182,7 +220,7 @@ export default function MemberList(props: Props) {
|
||||
const previousValue = utils.viewer.teams.listMembers.getInfiniteData({
|
||||
limit: 10,
|
||||
teamId: teamIds[0],
|
||||
searchTerm: searchTerm,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
});
|
||||
|
||||
if (previousValue) {
|
||||
@@ -190,7 +228,7 @@ export default function MemberList(props: Props) {
|
||||
utils,
|
||||
memberId: state.deleteMember.user?.id as number,
|
||||
teamId: teamIds[0],
|
||||
searchTerm: searchTerm,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
});
|
||||
}
|
||||
return { previousValue };
|
||||
@@ -237,8 +275,11 @@ export default function MemberList(props: Props) {
|
||||
|
||||
const memorisedColumns = useMemo(() => {
|
||||
const cols: ColumnDef<User>[] = [
|
||||
// Disabling select for this PR: Will work on actions etc in a follow up
|
||||
{
|
||||
id: "select",
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
@@ -259,6 +300,7 @@ export default function MemberList(props: Props) {
|
||||
{
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
enableHiding: false,
|
||||
header: `Member (${totalDBRowCount})`,
|
||||
cell: ({ row }) => {
|
||||
const { username, email, avatarUrl, accepted, name } = row.original;
|
||||
@@ -291,9 +333,8 @@ export default function MemberList(props: Props) {
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Weird typing issue
|
||||
return rows.getValue(id).includes(filterValue);
|
||||
const userEmail = rows.original.email;
|
||||
return filterValue.includes(userEmail);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -546,137 +587,94 @@ export default function MemberList(props: Props) {
|
||||
|
||||
return cols;
|
||||
}, [props.isOrgAdminOrOwner, dispatch, totalDBRowCount, session?.user.id]);
|
||||
|
||||
//we must flatten the array of arrays from the useInfiniteQuery hook
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.members) ?? [], [data]) as User[];
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
columns: memorisedColumns,
|
||||
enableRowSelection: true,
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
initialState: {
|
||||
columnVisibility: initalColumnVisibility,
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
state: {
|
||||
columnFilters,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
|
||||
tableContainerRef,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
totalFetched,
|
||||
totalDBRowCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<>
|
||||
<DataTable
|
||||
data-testid="team-member-list-container"
|
||||
onSearch={(value) => setSearchTerm(value)}
|
||||
selectionOptions={[
|
||||
{
|
||||
type: "action",
|
||||
icon: "handshake",
|
||||
label: "Group Meeting",
|
||||
needsXSelected: 2,
|
||||
onClick: () => {
|
||||
setDynamicLinkVisible((old) => !old);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => <EventTypesList table={table} teamId={props.team.id} />,
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => (
|
||||
<DeleteBulkTeamMembers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
isOrg={checkIsOrg(props.team)}
|
||||
teamId={props.team.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
renderAboveSelection={(table: Table<User>) => {
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
|
||||
|
||||
const users = table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original.username)
|
||||
.filter((u) => u !== null);
|
||||
|
||||
const usersNameAsString = users.join("+");
|
||||
|
||||
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
|
||||
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible ? (
|
||||
<m.div
|
||||
layout
|
||||
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
|
||||
<div className="w-[300px] items-center truncate p-2">
|
||||
<p>{domainWithoutHttps}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Button
|
||||
StartIcon="copy"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</Button>
|
||||
<Button
|
||||
EndIcon="external-link"
|
||||
size="sm"
|
||||
href={dynamicLinkOfSelectedUsers}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
tableCTA={
|
||||
isAdminOrOwner || props.isOrgAdminOrOwner ? (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
className="rounded-md"
|
||||
onClick={() => props.setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
) : (
|
||||
<></>
|
||||
)
|
||||
}
|
||||
columns={memorisedColumns}
|
||||
data={flatData}
|
||||
isPending={isPending}
|
||||
filterableItems={[
|
||||
{
|
||||
tableAccessor: "role",
|
||||
title: "Role",
|
||||
options: [
|
||||
{ label: "Owner", value: "OWNER" },
|
||||
{ label: "Admin", value: "ADMIN" },
|
||||
{ label: "Member", value: "MEMBER" },
|
||||
{ label: "Pending", value: "PENDING" },
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
<DataTableToolbar.Root>
|
||||
<div className="flex w-full gap-2">
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
|
||||
<DataTableFilters.FilterButton table={table} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
{isAdminOrOwner && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
className="rounded-md"
|
||||
onClick={() => props.setShowMemberInvitationModal(true)}
|
||||
data-testid="new-member-button">
|
||||
{t("add")}
|
||||
</DataTableToolbar.CTA>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-self-start">
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
</div>
|
||||
</DataTableToolbar.Root>
|
||||
|
||||
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
|
||||
<DataTableSelectionBar.Root style={{ bottom: "5rem" }}>
|
||||
<DynamicLink table={table} domain={domain} />
|
||||
</DataTableSelectionBar.Root>
|
||||
)}
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
<p className="text-brand-subtle w-full px-2 text-center leading-none">
|
||||
{numberOfSelectedRows} selected
|
||||
</p>
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<Button onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)} StartIcon="handshake">
|
||||
Group Meeting
|
||||
</Button>
|
||||
)}
|
||||
<EventTypesList table={table} teamId={props.team.id} />
|
||||
<DeleteBulkTeamMembers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
isOrg={checkIsOrg(props.team)}
|
||||
teamId={props.team.id}
|
||||
/>
|
||||
</DataTableSelectionBar.Root>
|
||||
)}
|
||||
</DataTable>
|
||||
{state.deleteMember.showModal && (
|
||||
<Dialog
|
||||
open={true}
|
||||
@@ -747,6 +745,6 @@ export default function MemberList(props: Props) {
|
||||
teamId={props.team.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -87,13 +87,13 @@ const MembersView = ({ isAppDir }: { isAppDir?: boolean }) => {
|
||||
)}
|
||||
|
||||
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && team && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<MemberList
|
||||
team={team}
|
||||
isOrgAdminOrOwner={isOrgAdminOrOwner}
|
||||
setShowMemberInvitationModal={setShowMemberInvitationModal}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{showMemberInvitationModal && team && team.id && (
|
||||
<MemberInvitationModalWithoutMembers
|
||||
|
||||
@@ -662,6 +662,7 @@ const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) =>
|
||||
export type SettingsLayoutProps = {
|
||||
children: React.ReactNode;
|
||||
hideHeader?: boolean;
|
||||
containerClassName?: string;
|
||||
} & ComponentProps<typeof Shell>;
|
||||
|
||||
export default function SettingsLayout({ children, hideHeader, ...rest }: SettingsLayoutProps) {
|
||||
@@ -707,7 +708,8 @@ export default function SettingsLayout({ children, hideHeader, ...rest }: Settin
|
||||
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
|
||||
}>
|
||||
<div className="flex flex-1 [&>*]:flex-1">
|
||||
<div className="mx-auto max-w-full justify-center lg:max-w-3xl">
|
||||
<div
|
||||
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
|
||||
{!hideHeader && <ShellHeader />}
|
||||
<ErrorBoundary>
|
||||
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { getCoreRowModel, useReactTable, getFilteredRowModel } from "@tanstack/react-table";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
@@ -9,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import type { UserProfile } from "@calcom/types/UserProfile";
|
||||
import { Button, ButtonGroup, DataTable, UserAvatar } from "@calcom/ui";
|
||||
import { Button, ButtonGroup, DataTable, DataTableToolbar, UserAvatar } from "@calcom/ui";
|
||||
|
||||
import { UpgradeTip } from "../../tips/UpgradeTip";
|
||||
import { createTimezoneBuddyStore, TBContext } from "../store";
|
||||
@@ -80,7 +81,7 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
|
||||
const cols: ColumnDef<SliderUser>[] = [
|
||||
{
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
accessorFn: (data) => data.username,
|
||||
header: "Member",
|
||||
cell: ({ row }) => {
|
||||
const { username, email, timeZone, name, avatarUrl, profile } = row.original;
|
||||
@@ -104,6 +105,9 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
|
||||
</div>
|
||||
);
|
||||
},
|
||||
filterFn: (row, id, value) => {
|
||||
return row.original.username?.toLowerCase().includes(value.toLowerCase()) || false;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
@@ -185,6 +189,13 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
columns: memorisedColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
});
|
||||
|
||||
// This means they are not apart of any teams so we show the upgrade tip
|
||||
if (!flatData.length) return <UpgradeTeamTip />;
|
||||
|
||||
@@ -196,19 +207,18 @@ export function AvailabilitySliderTable(props: { userTimeFormat: number | null }
|
||||
<>
|
||||
<div className="relative -mx-2 w-[calc(100%+16px)] overflow-x-scroll px-2 lg:-mx-6 lg:w-[calc(100%+48px)] lg:px-6">
|
||||
<DataTable
|
||||
variant="compact"
|
||||
searchKey="member"
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
columns={memorisedColumns}
|
||||
onRowMouseclick={(row) => {
|
||||
setEditSheetOpen(true);
|
||||
setSelectedUser(row.original);
|
||||
}}
|
||||
data={flatData}
|
||||
isPending={isPending}
|
||||
// tableOverlay={<HoverOverview />}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
/>
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
<DataTableToolbar.Root>
|
||||
<DataTableToolbar.SearchBar table={table} searchKey="member" />
|
||||
</DataTableToolbar.Root>
|
||||
</DataTable>
|
||||
</div>
|
||||
{selectedUser && editSheetOpen ? (
|
||||
<AvailabilityEditSheet
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button, ConfirmationDialogContent, Dialog, DialogTrigger, showToast } from "@calcom/ui";
|
||||
|
||||
import type { User } from "../UserListTable";
|
||||
import type { UserTableUser } from "../types";
|
||||
|
||||
interface Props {
|
||||
users: User[];
|
||||
users: UserTableUser[];
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,22 @@ export function DeleteBulkUsers({ users, onRemove }: Props) {
|
||||
const selectedRows = users; // Get selected rows from table
|
||||
const utils = trpc.useUtils();
|
||||
const deleteMutation = trpc.viewer.organizations.bulkDeleteUsers.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.organizations.listMembers.invalidate();
|
||||
onSuccess: (_, { userIds }) => {
|
||||
showToast("Deleted Users", "success");
|
||||
utils.viewer.organizations.listMembers.setInfiniteData(
|
||||
{ limit: 10, searchTerm: "", expand: ["attributes"] },
|
||||
// @ts-expect-error - infinite data types are not correct
|
||||
(oldData) => {
|
||||
if (!oldData) return oldData;
|
||||
return {
|
||||
...oldData,
|
||||
pages: oldData.pages.map((page) => ({
|
||||
...page,
|
||||
rows: page.rows.filter((user) => !userIds.includes(user.id)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui";
|
||||
|
||||
export function DynamicLink<T extends { username: string | null }>({
|
||||
table,
|
||||
domain,
|
||||
}: {
|
||||
table: Table<T>;
|
||||
domain: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [dynamicLinkVisible, _] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
|
||||
|
||||
const users = table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original.username)
|
||||
.filter((u): u is string => u !== null);
|
||||
|
||||
const usersNameAsString = users.join("+");
|
||||
|
||||
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
|
||||
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible ? (
|
||||
<div className="w-full gap-1 rounded-lg text-sm font-medium leading-none md:flex">
|
||||
<div className="max-w-[300px] items-center truncate p-2">
|
||||
<p>{domainWithoutHttps}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Button StartIcon="copy" size="sm" onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</Button>
|
||||
<Button
|
||||
EndIcon="external-link"
|
||||
size="sm"
|
||||
href={dynamicLinkOfSelectedUsers}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,10 +22,10 @@ import {
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { User } from "../UserListTable";
|
||||
import type { UserTableUser } from "../types";
|
||||
|
||||
interface Props {
|
||||
table: Table<User>;
|
||||
table: Table<UserTableUser>;
|
||||
orgTeams: RouterOutputs["viewer"]["organizations"]["getTeams"] | undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import type { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import { parseAsString, useQueryState, parseAsArrayOf } from "nuqs";
|
||||
import { useState } from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import {
|
||||
Alert,
|
||||
@@ -22,10 +24,11 @@ import {
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { User } from "../UserListTable";
|
||||
import type { UserTableUser } from "../types";
|
||||
|
||||
interface Props {
|
||||
table: Table<User>;
|
||||
table: Table<UserTableUser>;
|
||||
filters: ColumnFiltersState;
|
||||
}
|
||||
|
||||
function useSelectedAttributes() {
|
||||
@@ -78,7 +81,7 @@ function SelectedAttributeToAssign() {
|
||||
|
||||
return (
|
||||
<CommandList>
|
||||
<div className="flex flex items-center items-center gap-2 border-b px-3 py-2">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-2">
|
||||
<span className="block">{foundAttribute.name}</span>
|
||||
{translateableType && <span className="text-muted block text-xs">({t(translateableType)})</span>}
|
||||
</div>
|
||||
@@ -119,9 +122,11 @@ function SelectedAttributeToAssign() {
|
||||
<>
|
||||
<CommandItem>
|
||||
<Input
|
||||
value={selectedAttributeOption[0] || ""}
|
||||
defaultValue={selectedAttributeOption[0] || ""}
|
||||
type={foundAttribute.type === "TEXT" ? "text" : "number"}
|
||||
onChange={(e) => {
|
||||
onBlur={(e) => {
|
||||
// trigger onBlur so it's set as Apply is pressed (but not onChange) which triggers
|
||||
// a re-render which also loses focus.
|
||||
setSelectedAttributeOption([e.target.value]);
|
||||
}}
|
||||
/>
|
||||
@@ -133,13 +138,78 @@ function SelectedAttributeToAssign() {
|
||||
);
|
||||
}
|
||||
|
||||
export function MassAssignAttributesBulkAction({ table }: Props) {
|
||||
export function MassAssignAttributesBulkAction({ table, filters }: Props) {
|
||||
const { selectedAttribute, setSelectedAttribute, foundAttributeInCache } = useSelectedAttributes();
|
||||
const [selectedAttributeOptions, setSelectedAttributeOptions] = useSelectedAttributeOption();
|
||||
const [showMultiSelectWarning, setShowMultiSelectWarning] = useState(false);
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useContext();
|
||||
const bulkAssignAttributes = trpc.viewer.attributes.bulkAssignAttributes.useMutation({
|
||||
onSuccess: (success) => {
|
||||
// Optimistically update the infinite query data
|
||||
const selectedRows = table.getSelectedRowModel().flatRows;
|
||||
|
||||
utils.viewer.organizations.listMembers.setInfiniteData(
|
||||
{
|
||||
limit: 10,
|
||||
searchTerm: "",
|
||||
expand: ["attributes"],
|
||||
filters: filters.map((filter) => ({
|
||||
id: filter.id,
|
||||
value: filter.value as string[],
|
||||
})),
|
||||
},
|
||||
// @ts-expect-error i really dont know how to type this
|
||||
(oldData) => {
|
||||
const newPages = oldData?.pages.map((page) => ({
|
||||
...page,
|
||||
rows: page.rows.map((row) => {
|
||||
if (selectedRows.some((selectedRow) => selectedRow.original.id === row.id)) {
|
||||
// Update the attributes for the selected users
|
||||
|
||||
const attributeOptionValues = foundAttributeInCache?.options.filter((option) =>
|
||||
selectedAttributeOptions.includes(option.id)
|
||||
);
|
||||
|
||||
const newAttributes =
|
||||
row.attributes?.filter((attr) => attr.attributeId !== selectedAttribute) || [];
|
||||
|
||||
if (attributeOptionValues && attributeOptionValues.length > 0) {
|
||||
const newAttributeValues = attributeOptionValues?.map((value) => ({
|
||||
id: value.id,
|
||||
attributeId: value.attributeId,
|
||||
value: value.value,
|
||||
slug: value.slug,
|
||||
}));
|
||||
newAttributes.push(...newAttributeValues);
|
||||
} else {
|
||||
// Text or number input we don't have an option to fall back on
|
||||
newAttributes.push({
|
||||
id: "-1",
|
||||
attributeId: foundAttributeInCache?.id ?? "-1",
|
||||
value: selectedAttributeOptions[0],
|
||||
slug: slugify(selectedAttributeOptions[0]),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
attributes: newAttributes,
|
||||
};
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
}));
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
pages: newPages,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
setSelectedAttribute(null);
|
||||
setSelectedAttributeOptions([]);
|
||||
showToast(success.message, "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -202,7 +272,7 @@ export function MassAssignAttributesBulkAction({ table }: Props) {
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button StartIcon="users">{t("mass_assign_attributes")}</Button>
|
||||
<Button StartIcon="map-pin">{t("add_attributes")}</Button>
|
||||
</PopoverTrigger>
|
||||
{/* We dont really use shadows much - but its needed here */}
|
||||
<PopoverContent className="p-0 shadow-md" align="start" sideOffset={12}>
|
||||
@@ -262,9 +332,6 @@ export function MassAssignAttributesBulkAction({ table }: Props) {
|
||||
attributes: attributesToAssign,
|
||||
userIds: table.getSelectedRowModel().rows.map((row) => row.original.id),
|
||||
});
|
||||
|
||||
setSelectedAttribute(null);
|
||||
setSelectedAttributeOptions([]);
|
||||
}
|
||||
}}>
|
||||
{t("apply")}
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { User } from "../UserListTable";
|
||||
import type { UserTableUser } from "../types";
|
||||
|
||||
interface Props {
|
||||
table: Table<User>;
|
||||
table: Table<UserTableUser>;
|
||||
}
|
||||
|
||||
export function TeamListBulkAction({ table }: Props) {
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Dispatch } from "react";
|
||||
|
||||
import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal";
|
||||
|
||||
import type { Action, State } from "./UserListTable";
|
||||
import type { UserTableAction, UserTableState } from "./types";
|
||||
|
||||
export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch<Action> }) {
|
||||
export function ChangeUserRoleModal(props: { state: UserTableState; dispatch: Dispatch<UserTableAction> }) {
|
||||
const { data: session } = useSession();
|
||||
const orgId = session?.user.org?.id;
|
||||
if (!orgId || !props.state.changeMemberRole.user) return null;
|
||||
|
||||
@@ -5,9 +5,15 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Dialog, ConfirmationDialogContent, showToast } from "@calcom/ui";
|
||||
|
||||
import type { State, Action } from "./UserListTable";
|
||||
import type { UserTableAction, UserTableState } from "./types";
|
||||
|
||||
export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
|
||||
export function DeleteMemberModal({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: UserTableState;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
SheetTitle,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { Action } from "../UserListTable";
|
||||
import type { UserTableAction } from "../types";
|
||||
import { useEditMode } from "./store";
|
||||
|
||||
type MembershipOption = {
|
||||
@@ -68,7 +68,7 @@ export function EditForm({
|
||||
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
|
||||
avatarUrl: string;
|
||||
domainUrl: string;
|
||||
dispatch: Dispatch<Action>;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
}) {
|
||||
const setEditMode = useEditMode((state) => state.setEditMode);
|
||||
const [mutationLoading, setMutationLoading] = useState(false);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar, Loader, Sheet, SheetContent, SheetBody, SheetHeader, SheetFooter } from "@calcom/ui";
|
||||
|
||||
import type { Action, State } from "../UserListTable";
|
||||
import type { UserTableAction, UserTableState } from "../types";
|
||||
import { DisplayInfo } from "./DisplayInfo";
|
||||
import { EditForm } from "./EditUserForm";
|
||||
import { OrganizationBanner } from "./OrganizationBanner";
|
||||
@@ -18,15 +18,20 @@ function removeProtocol(url: string) {
|
||||
return url.replace(/^(https?:\/\/)/, "");
|
||||
}
|
||||
|
||||
export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
|
||||
export function EditUserSheet({
|
||||
state,
|
||||
dispatch,
|
||||
}: {
|
||||
state: UserTableState;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { user: selectedUser } = state.editSheet;
|
||||
const orgBranding = useOrgBranding();
|
||||
const [editMode, setEditMode] = useEditMode((state) => [state.editMode, state.setEditMode], shallow);
|
||||
const { data: loadedUser, isPending } = trpc.viewer.organizations.getUser.useQuery(
|
||||
{
|
||||
// @ts-expect-error we obly enable the query if the user is selected
|
||||
userId: selectedUser.id,
|
||||
userId: selectedUser?.id,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedUser?.id,
|
||||
@@ -36,8 +41,8 @@ export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dis
|
||||
const { data: usersAttributes, isPending: usersAttributesPending } =
|
||||
trpc.viewer.attributes.getByUserId.useQuery(
|
||||
{
|
||||
// @ts-expect-error we obly enable the query if the user is selected
|
||||
userId: selectedUser.id,
|
||||
// @ts-expect-error We know it exists as it is only called when selectedUser is defined
|
||||
userId: selectedUser?.id,
|
||||
},
|
||||
{
|
||||
enabled: !!selectedUser?.id,
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { Dispatch } from "react";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button, Dialog, DialogClose, DialogContent, DialogFooter } from "@calcom/ui";
|
||||
|
||||
import type { Action, State } from "./UserListTable";
|
||||
import type { UserTableAction, UserTableState } from "./types";
|
||||
|
||||
export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch<Action> }) {
|
||||
export function ImpersonationMemberModal(props: {
|
||||
state: UserTableState;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const { data: session } = useSession();
|
||||
const teamId = session?.user.org?.id;
|
||||
|
||||
@@ -7,10 +7,10 @@ import { trpc } from "@calcom/trpc";
|
||||
import { showToast } from "@calcom/ui";
|
||||
import usePlatformMe from "@calcom/web/components/settings/platform/hooks/usePlatformMe";
|
||||
|
||||
import type { Action } from "./UserListTable";
|
||||
import type { UserTableAction } from "./types";
|
||||
|
||||
interface Props {
|
||||
dispatch: Dispatch<Action>;
|
||||
dispatch: Dispatch<UserTableAction>;
|
||||
}
|
||||
|
||||
export function InviteMemberModal(props: Props) {
|
||||
|
||||
@@ -1,21 +1,38 @@
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import type { ColumnDef, Table } from "@tanstack/react-table";
|
||||
import { m } from "framer-motion";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
|
||||
"use client";
|
||||
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import type { ColumnFiltersState } from "@tanstack/react-table";
|
||||
import {
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from "@tanstack/react-table";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useQueryState, parseAsBoolean } from "nuqs";
|
||||
import { useMemo, useReducer, useRef, useState } from "react";
|
||||
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Avatar, Badge, Button, Checkbox, DataTable } from "@calcom/ui";
|
||||
import type { ActionItem } from "@calcom/ui/components/data-table/DataTableSelectionBar";
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Checkbox,
|
||||
DataTable,
|
||||
DataTableToolbar,
|
||||
DataTableFilters,
|
||||
DataTableSelectionBar,
|
||||
DataTablePagination,
|
||||
} from "@calcom/ui";
|
||||
import { useGetUserAttributes } from "@calcom/web/components/settings/platform/hooks/useGetUserAttributes";
|
||||
|
||||
import { useOrgBranding } from "../../../ee/organizations/context/provider";
|
||||
import { DeleteBulkUsers } from "./BulkActions/DeleteBulkUsers";
|
||||
import { DynamicLink } from "./BulkActions/DynamicLink";
|
||||
import { EventTypesList } from "./BulkActions/EventTypesList";
|
||||
import { MassAssignAttributesBulkAction } from "./BulkActions/MassAssignAttributes";
|
||||
import { TeamListBulkAction } from "./BulkActions/TeamList";
|
||||
@@ -25,52 +42,10 @@ import { EditUserSheet } from "./EditSheet/EditUserSheet";
|
||||
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
|
||||
import { InviteMemberModal } from "./InviteMemberModal";
|
||||
import { TableActions } from "./UserTableActions";
|
||||
import type { UserTableState, UserTableAction, UserTableUser } from "./types";
|
||||
import { useFetchMoreOnBottomReached } from "./useFetchMoreOnBottomReached";
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string | null;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
role: MembershipRole;
|
||||
avatarUrl: string | null;
|
||||
accepted: boolean;
|
||||
disableImpersonation: boolean;
|
||||
completedOnboarding: boolean;
|
||||
teams: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
}[];
|
||||
}
|
||||
|
||||
type Payload = {
|
||||
showModal: boolean;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
changeMemberRole: Payload;
|
||||
deleteMember: Payload;
|
||||
impersonateMember: Payload;
|
||||
inviteMember: Payload;
|
||||
editSheet: Payload & { user?: User };
|
||||
};
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
type:
|
||||
| "SET_CHANGE_MEMBER_ROLE_ID"
|
||||
| "SET_DELETE_ID"
|
||||
| "SET_IMPERSONATE_ID"
|
||||
| "INVITE_MEMBER"
|
||||
| "EDIT_USER_SHEET";
|
||||
payload: Payload;
|
||||
}
|
||||
| {
|
||||
type: "CLOSE_MODAL";
|
||||
};
|
||||
|
||||
const initialState: State = {
|
||||
const initialState: UserTableState = {
|
||||
changeMemberRole: {
|
||||
showModal: false,
|
||||
},
|
||||
@@ -88,7 +63,15 @@ const initialState: State = {
|
||||
},
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
const initalColumnVisibility = {
|
||||
select: true,
|
||||
member: true,
|
||||
role: true,
|
||||
teams: true,
|
||||
actions: true,
|
||||
};
|
||||
|
||||
function reducer(state: UserTableState, action: UserTableAction): UserTableState {
|
||||
switch (action.type) {
|
||||
case "SET_CHANGE_MEMBER_ROLE_ID":
|
||||
return { ...state, changeMemberRole: action.payload };
|
||||
@@ -115,34 +98,47 @@ function reducer(state: State, action: Action): State {
|
||||
}
|
||||
|
||||
export function UserListTable() {
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useQueryState("dynamicLink", parseAsBoolean);
|
||||
const orgBranding = useOrgBranding();
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
const { t } = useLocale();
|
||||
|
||||
const { data: session } = useSession();
|
||||
const { isPlatformUser } = useGetUserAttributes();
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const { data: org } = trpc.viewer.organizations.listCurrent.useQuery();
|
||||
const { data: attributes } = trpc.viewer.attributes.list.useQuery();
|
||||
const { data: teams } = trpc.viewer.organizations.getTeams.useQuery();
|
||||
const { data: facetedTeamValues } = trpc.viewer.organizations.getFacetedValues.useQuery();
|
||||
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
const [dynamicLinkVisible, setDynamicLinkVisible] = useState(false);
|
||||
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
||||
const { data, isPending, fetchNextPage, isFetching } =
|
||||
trpc.viewer.organizations.listMembers.useInfiniteQuery(
|
||||
{
|
||||
limit: 10,
|
||||
searchTerm: debouncedSearchTerm,
|
||||
expand: ["attributes"],
|
||||
filters: columnFilters.map((filter) => ({
|
||||
id: filter.id,
|
||||
value: filter.value as string[],
|
||||
})),
|
||||
},
|
||||
{
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
placeholderData: keepPreviousData,
|
||||
}
|
||||
);
|
||||
|
||||
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
|
||||
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
|
||||
const adminOrOwner = org?.user.role === "ADMIN" || org?.user.role === "OWNER";
|
||||
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
|
||||
|
||||
//we must flatten the array of arrays from the useInfiniteQuery hook
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
|
||||
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as UserTableUser[];
|
||||
const totalFetched = flatData.length;
|
||||
|
||||
const memorisedColumns = useMemo(() => {
|
||||
@@ -152,10 +148,44 @@ export function UserListTable() {
|
||||
canResendInvitation: adminOrOwner,
|
||||
canImpersonate: false,
|
||||
};
|
||||
const cols: ColumnDef<User>[] = [
|
||||
const generateAttributeColumns = () => {
|
||||
if (!attributes?.length) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
(attributes?.map((attribute) => ({
|
||||
id: attribute.id,
|
||||
header: attribute.name,
|
||||
accessorFn: (data) => data.attributes.find((attr) => attr.attributeId === attribute.id)?.value,
|
||||
cell: ({ row }) => {
|
||||
const attributeValues = row.original.attributes.filter(
|
||||
(attr) => attr.attributeId === attribute.id
|
||||
);
|
||||
if (attributeValues.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
{attributeValues.map((attributeValue, index) => (
|
||||
<Badge key={index} variant="gray" className="mr-1">
|
||||
{attributeValue.value}
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
const attributeValues = rows.original.attributes.filter((attr) => attr.attributeId === id);
|
||||
if (attributeValues.length === 0) return false;
|
||||
return attributeValues.some((attr) => filterValue.includes(attr.value));
|
||||
},
|
||||
})) as ColumnDef<UserTableUser>[]) ?? []
|
||||
);
|
||||
};
|
||||
const cols: ColumnDef<UserTableUser>[] = [
|
||||
// Disabling select for this PR: Will work on actions etc in a follow up
|
||||
{
|
||||
id: "select",
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
@@ -176,7 +206,10 @@ export function UserListTable() {
|
||||
{
|
||||
id: "member",
|
||||
accessorFn: (data) => data.email,
|
||||
header: `Member (${isPlatformUser ? totalFetched : totalDBRowCount})`,
|
||||
enableHiding: false,
|
||||
header: () => {
|
||||
return `Members`;
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const { username, email, avatarUrl } = row.original;
|
||||
return (
|
||||
@@ -204,9 +237,8 @@ export function UserListTable() {
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore Weird typing issue
|
||||
return rows.getValue(id).includes(filterValue);
|
||||
const userEmail = rows.original.email;
|
||||
return filterValue.includes(userEmail);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -275,8 +307,10 @@ export function UserListTable() {
|
||||
return filterValue.some((value: string) => teamNames.includes(value));
|
||||
},
|
||||
},
|
||||
...generateAttributeColumns(),
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const permissionsRaw = permissions;
|
||||
@@ -304,184 +338,125 @@ export function UserListTable() {
|
||||
];
|
||||
|
||||
return cols;
|
||||
}, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount]);
|
||||
}, [session?.user.id, adminOrOwner, dispatch, domain, totalDBRowCount, attributes]);
|
||||
|
||||
const memoisedSelectionOptions = useMemo(() => {
|
||||
const selectionOptionsForOrg: ActionItem<User>[] = [
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => <TeamListBulkAction table={table} />,
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => <MassAssignAttributesBulkAction table={table} />,
|
||||
},
|
||||
{
|
||||
type: "action",
|
||||
icon: "handshake",
|
||||
label: "Group Meeting",
|
||||
needsXSelected: 2,
|
||||
onClick: () => {
|
||||
setDynamicLinkVisible((old) => !old);
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => <EventTypesList table={table} orgTeams={teams} />,
|
||||
},
|
||||
{
|
||||
type: "render",
|
||||
render: (table) => (
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const selectionOptionsForPlatform: ActionItem<User>[] = [
|
||||
{
|
||||
type: "render",
|
||||
render: (table: Table<User>) => (
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const selectionOptions = isPlatformUser ? selectionOptionsForPlatform : selectionOptionsForOrg;
|
||||
|
||||
return selectionOptions;
|
||||
}, [isPlatformUser, teams]);
|
||||
|
||||
//called on scroll and possibly on mount to fetch more data as the user scrolls and reaches bottom of table
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
//once the user has scrolled within 300px of the bottom of the table, fetch more data if there is any
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
const table = useReactTable({
|
||||
data: flatData,
|
||||
columns: memorisedColumns,
|
||||
enableRowSelection: true,
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
initialState: {
|
||||
columnVisibility: initalColumnVisibility,
|
||||
},
|
||||
state: {
|
||||
columnFilters,
|
||||
},
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// TODO(SEAN): We need to move filter state to the server so we can fetch more data when the filters change if theyre not in client cache
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedUniqueValues: (_, columnId) => () => {
|
||||
if (facetedTeamValues) {
|
||||
switch (columnId) {
|
||||
case "role":
|
||||
return new Map(facetedTeamValues.roles.map((role) => [role, 1]));
|
||||
case "teams":
|
||||
return new Map(facetedTeamValues.teams.map((team) => [team.name, 1]));
|
||||
default:
|
||||
const attribute = facetedTeamValues.attributes.find((attr) => attr.id === columnId);
|
||||
if (attribute) {
|
||||
return new Map(attribute?.options.map(({ value }) => [value, 1]) ?? []);
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
return new Map();
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
});
|
||||
|
||||
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
|
||||
tableContainerRef,
|
||||
fetchNextPage,
|
||||
isFetching,
|
||||
totalFetched,
|
||||
totalDBRowCount
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached]);
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable
|
||||
onRowMouseclick={(row) => {
|
||||
const user = row.original;
|
||||
const canEdit = adminOrOwner;
|
||||
if (canEdit) {
|
||||
// dispatch({
|
||||
// type: "EDIT_USER_SHEET",
|
||||
// payload: {
|
||||
// showModal: true,
|
||||
// user,
|
||||
// },
|
||||
// });
|
||||
}
|
||||
}}
|
||||
data-testid="user-list-data-table"
|
||||
onSearch={(value) => setDebouncedSearchTerm(value)}
|
||||
selectionOptions={memoisedSelectionOptions}
|
||||
renderAboveSelection={(table: Table<User>) => {
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
const isVisible = numberOfSelectedRows >= 2 && dynamicLinkVisible;
|
||||
|
||||
const users = table
|
||||
.getSelectedRowModel()
|
||||
.flatRows.map((row) => row.original.username)
|
||||
.filter((u) => u !== null);
|
||||
|
||||
const usersNameAsString = users.join("+");
|
||||
|
||||
const dynamicLinkOfSelectedUsers = `${domain}/${usersNameAsString}`;
|
||||
const domainWithoutHttps = dynamicLinkOfSelectedUsers.replace(/https?:\/\//g, "");
|
||||
|
||||
return (
|
||||
<>
|
||||
{isVisible ? (
|
||||
<m.div
|
||||
layout
|
||||
className="bg-brand-default text-inverted item-center animate-fade-in-bottom hidden w-full gap-1 rounded-lg p-2 text-sm font-medium leading-none md:flex">
|
||||
<div className="w-[300px] items-center truncate p-2">
|
||||
<p>{domainWithoutHttps}</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center">
|
||||
<Button
|
||||
StartIcon="copy"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(dynamicLinkOfSelectedUsers)}>
|
||||
{!isCopied ? t("copy") : t("copied")}
|
||||
</Button>
|
||||
<Button
|
||||
EndIcon="external-link"
|
||||
size="sm"
|
||||
href={dynamicLinkOfSelectedUsers}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
</m.div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}}
|
||||
// className="lg:max-w-screen-lg"
|
||||
table={table}
|
||||
tableContainerRef={tableContainerRef}
|
||||
tableCTA={
|
||||
adminOrOwner && (
|
||||
<Button
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
size="sm"
|
||||
className="rounded-md"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "INVITE_MEMBER",
|
||||
payload: {
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="new-organization-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
columns={memorisedColumns}
|
||||
data={flatData}
|
||||
isPending={isPending}
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}
|
||||
filterableItems={[
|
||||
{
|
||||
tableAccessor: "role",
|
||||
title: "Role",
|
||||
options: [
|
||||
{ label: "Owner", value: "OWNER" },
|
||||
{ label: "Admin", value: "ADMIN" },
|
||||
{ label: "Member", value: "MEMBER" },
|
||||
{ label: "Pending", value: "PENDING" },
|
||||
],
|
||||
},
|
||||
{
|
||||
tableAccessor: "teams",
|
||||
title: "Teams",
|
||||
options: teams ? teams.map((team) => ({ label: team.name, value: team.name })) : [],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
|
||||
<DataTableToolbar.Root className="lg:max-w-screen-2xl">
|
||||
<div className="flex w-full gap-2">
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
|
||||
{/* We have to omit member because we don't want the filter to show but we can't disable filtering as we need that for the search bar */}
|
||||
<DataTableFilters.FilterButton table={table} omit={["member"]} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
{adminOrOwner && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
color="primary"
|
||||
StartIcon="plus"
|
||||
className="rounded-md"
|
||||
onClick={() =>
|
||||
dispatch({
|
||||
type: "INVITE_MEMBER",
|
||||
payload: {
|
||||
showModal: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="new-organization-member-button">
|
||||
{t("add")}
|
||||
</DataTableToolbar.CTA>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 justify-self-start">
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
</div>
|
||||
</DataTableToolbar.Root>
|
||||
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
|
||||
<DataTablePagination table={table} totalDbDataCount={totalDBRowCount} />
|
||||
</div>
|
||||
|
||||
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
|
||||
<DataTableSelectionBar.Root style={{ bottom: "5rem" }}>
|
||||
<DynamicLink table={table} domain={domain} />
|
||||
</DataTableSelectionBar.Root>
|
||||
)}
|
||||
{numberOfSelectedRows > 0 && (
|
||||
<DataTableSelectionBar.Root>
|
||||
<p className="text-brand-subtle w-full px-2 text-center leading-none">
|
||||
{numberOfSelectedRows} selected
|
||||
</p>
|
||||
{!isPlatformUser ? (
|
||||
<>
|
||||
<TeamListBulkAction table={table} />
|
||||
{numberOfSelectedRows >= 2 && (
|
||||
<Button onClick={() => setDynamicLinkVisible(!dynamicLinkVisible)} StartIcon="handshake">
|
||||
Group Meeting
|
||||
</Button>
|
||||
)}
|
||||
<MassAssignAttributesBulkAction table={table} filters={columnFilters} />
|
||||
<EventTypesList table={table} orgTeams={teams} />
|
||||
</>
|
||||
) : null}
|
||||
<DeleteBulkUsers
|
||||
users={table.getSelectedRowModel().flatRows.map((row) => row.original)}
|
||||
onRemove={() => table.toggleAllPageRowsSelected(false)}
|
||||
/>
|
||||
</DataTableSelectionBar.Root>
|
||||
)}
|
||||
</DataTable>
|
||||
|
||||
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
|
||||
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { Action } from "./UserListTable";
|
||||
import type { User } from "./UserListTable";
|
||||
import type { UserTableUser, UserTableAction } from "./types";
|
||||
|
||||
export function TableActions({
|
||||
user,
|
||||
@@ -25,8 +24,8 @@ export function TableActions({
|
||||
dispatch,
|
||||
domain,
|
||||
}: {
|
||||
user: User;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
user: UserTableUser;
|
||||
dispatch: React.Dispatch<UserTableAction>;
|
||||
domain: string;
|
||||
permissionsForUser: {
|
||||
canEdit: boolean;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
export interface UserTableUser {
|
||||
id: number;
|
||||
username: string | null;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
role: MembershipRole;
|
||||
avatarUrl: string | null;
|
||||
accepted: boolean;
|
||||
disableImpersonation: boolean;
|
||||
completedOnboarding: boolean;
|
||||
teams: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string | null;
|
||||
}[];
|
||||
attributes: {
|
||||
id: string;
|
||||
attributeId: string;
|
||||
value: string;
|
||||
slug: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export type UserTablePayload = {
|
||||
showModal: boolean;
|
||||
user?: UserTableUser;
|
||||
};
|
||||
|
||||
export type UserTableState = {
|
||||
changeMemberRole: UserTablePayload;
|
||||
deleteMember: UserTablePayload;
|
||||
impersonateMember: UserTablePayload;
|
||||
inviteMember: UserTablePayload;
|
||||
editSheet: UserTablePayload & { user?: UserTableUser };
|
||||
};
|
||||
|
||||
export type UserTableAction =
|
||||
| {
|
||||
type:
|
||||
| "SET_CHANGE_MEMBER_ROLE_ID"
|
||||
| "SET_DELETE_ID"
|
||||
| "SET_IMPERSONATE_ID"
|
||||
| "INVITE_MEMBER"
|
||||
| "EDIT_USER_SHEET"
|
||||
| "INVITE_MEMBER";
|
||||
payload: UserTablePayload;
|
||||
}
|
||||
| {
|
||||
type: "CLOSE_MODAL";
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
export const useFetchMoreOnBottomReached = (
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>,
|
||||
fetchNextPage: () => void,
|
||||
isFetching: boolean,
|
||||
totalFetched: number,
|
||||
totalDBRowCount: number
|
||||
) => {
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached, tableContainerRef]);
|
||||
|
||||
return fetchMoreOnBottomReached;
|
||||
};
|
||||
@@ -169,4 +169,11 @@ export const viewerOrganizationsRouter = router({
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
getFacetedValues: authedProcedure.query(async (opts) => {
|
||||
const handler = await importHandler(
|
||||
namespaced("getFacetedValues"),
|
||||
() => import("./getFacetedValues.handler")
|
||||
);
|
||||
return handler(opts);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../trpc";
|
||||
|
||||
type DeleteOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
};
|
||||
|
||||
export const getFacetedValuesHandler = async ({ ctx }: DeleteOptions) => {
|
||||
const { user } = ctx;
|
||||
|
||||
const organizationId = user.organization?.id;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Organization not found" });
|
||||
}
|
||||
const [teams, attributes] = await Promise.all([
|
||||
prisma.team.findMany({
|
||||
where: {
|
||||
parentId: organizationId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
}),
|
||||
prisma.attribute.findMany({
|
||||
where: { teamId: organizationId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
options: {
|
||||
select: {
|
||||
value: true,
|
||||
},
|
||||
distinct: "value",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
teams: teams,
|
||||
attributes: attributes,
|
||||
roles: [MembershipRole.OWNER, MembershipRole.ADMIN, MembershipRole.MEMBER],
|
||||
};
|
||||
};
|
||||
|
||||
export default getFacetedValuesHandler;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZGetFacetedValuesInput = z.object({
|
||||
include: z.enum(["users", "roles", "teams", "attributes"]).array().optional(),
|
||||
});
|
||||
|
||||
export type TGetFacetedValuesInputSchema = z.infer<typeof ZGetFacetedValuesInput>;
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
@@ -16,6 +18,8 @@ type GetOptions = {
|
||||
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const organizationId = ctx.user.organizationId ?? ctx.user.profiles[0].organizationId;
|
||||
const searchTerm = input.searchTerm;
|
||||
const expand = input.expand;
|
||||
const filters = input.filters || [];
|
||||
|
||||
if (!organizationId) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
|
||||
@@ -39,30 +43,56 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
},
|
||||
});
|
||||
|
||||
// I couldnt get this query to work direct on membership table
|
||||
const teamMembers = await prisma.membership.findMany({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
user: {
|
||||
isPlatformManaged: false,
|
||||
},
|
||||
...(searchTerm && {
|
||||
user: {
|
||||
OR: [
|
||||
{
|
||||
email: {
|
||||
contains: searchTerm,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
contains: searchTerm,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
const whereClause = {
|
||||
user: {
|
||||
isPlatformManaged: false,
|
||||
},
|
||||
teamId: organizationId,
|
||||
...(searchTerm && {
|
||||
user: {
|
||||
OR: [{ email: { contains: searchTerm } }, { username: { contains: searchTerm } }],
|
||||
},
|
||||
}),
|
||||
} as Prisma.MembershipWhereInput;
|
||||
|
||||
filters.forEach((filter) => {
|
||||
switch (filter.id) {
|
||||
case "role":
|
||||
whereClause.role = { in: filter.value as MembershipRole[] };
|
||||
break;
|
||||
case "teams":
|
||||
whereClause.user = {
|
||||
teams: {
|
||||
some: {
|
||||
team: {
|
||||
name: {
|
||||
in: filter.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
// We assume that if the filter is not one of the above, it must be an attribute filter
|
||||
default:
|
||||
whereClause.AttributeToUser = {
|
||||
some: {
|
||||
attributeOption: {
|
||||
attribute: {
|
||||
id: filter.id,
|
||||
},
|
||||
value: {
|
||||
in: filter.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const teamMembers = await prisma.membership.findMany({
|
||||
where: whereClause,
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
@@ -106,6 +136,25 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
const members = await Promise.all(
|
||||
teamMembers?.map(async (membership) => {
|
||||
const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user });
|
||||
let attributes;
|
||||
|
||||
if (expand?.includes("attributes")) {
|
||||
attributes = await prisma.attributeOption.findMany({
|
||||
where: {
|
||||
assignedUsers: {
|
||||
some: {
|
||||
memberId: membership.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
attribute: {
|
||||
name: "asc",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
@@ -126,6 +175,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
||||
slug: team.team.slug,
|
||||
};
|
||||
}),
|
||||
attributes,
|
||||
};
|
||||
}) || []
|
||||
);
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const expandableColumns = z.enum(["attributes"]);
|
||||
|
||||
export const ZListMembersSchema = z.object({
|
||||
limit: z.number().min(1).max(100),
|
||||
cursor: z.number().nullish(),
|
||||
searchTerm: z.string().optional(),
|
||||
expand: z.array(expandableColumns).optional(),
|
||||
filters: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
value: z.array(z.string()),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export type TListMembersSchema = z.infer<typeof ZListMembersSchema>;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
@@ -17,18 +20,68 @@ import {
|
||||
} from "../command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
||||
|
||||
interface FilterOption {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: IconName;
|
||||
options?: FilterOption[];
|
||||
}
|
||||
|
||||
interface DataTableFilter<TData, TValue> {
|
||||
column?: Column<TData, TValue>;
|
||||
title?: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: IconName;
|
||||
}[];
|
||||
options: FilterOption[];
|
||||
}
|
||||
|
||||
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues();
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[]);
|
||||
const [currentOptions, setCurrentOptions] = useState<FilterOption[]>(options);
|
||||
const [navigationPath, setNavigationPath] = useState<string[]>([]);
|
||||
|
||||
const flattenOptions = (opts: FilterOption[]): FilterOption[] => {
|
||||
return opts.reduce((acc, option) => {
|
||||
acc.push(option);
|
||||
if (option.options) {
|
||||
acc.push(...flattenOptions(option.options));
|
||||
}
|
||||
return acc;
|
||||
}, [] as FilterOption[]);
|
||||
};
|
||||
|
||||
const allOptions = flattenOptions(options);
|
||||
|
||||
const handleSelect = (option: FilterOption) => {
|
||||
if (option.options) {
|
||||
setCurrentOptions(option.options);
|
||||
setNavigationPath([...navigationPath, option.label]);
|
||||
} else {
|
||||
if (selectedValues.has(option.value)) {
|
||||
selectedValues.delete(option.value);
|
||||
} else {
|
||||
selectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValues);
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (navigationPath.length > 0) {
|
||||
const newPath = [...navigationPath];
|
||||
newPath.pop();
|
||||
setNavigationPath(newPath);
|
||||
|
||||
let newOptions = options;
|
||||
for (const label of newPath) {
|
||||
const found = newOptions.find((o) => o.label === label);
|
||||
if (found && found.options) {
|
||||
newOptions = found.options;
|
||||
}
|
||||
}
|
||||
setCurrentOptions(newOptions);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
@@ -44,7 +97,7 @@ export function DataTableFilter<TData, TValue>({ column, title, options }: DataT
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
allOptions
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
|
||||
@@ -62,32 +115,32 @@ export function DataTableFilter<TData, TValue>({ column, title, options }: DataT
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
{navigationPath.length > 0 && (
|
||||
<CommandGroup>
|
||||
<CommandItem onSelect={handleBack}>
|
||||
<Icon name="arrow-left" className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
// TODO: It would be nice to pull these from data instead of options
|
||||
{currentOptions.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value);
|
||||
} else {
|
||||
selectedValues.add(option.value);
|
||||
}
|
||||
const filterValues = Array.from(selectedValues);
|
||||
column?.setFilterValue(filterValues.length ? filterValues : undefined);
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Icon name="check" className={classNames("h-4 w-4")} />
|
||||
</div>
|
||||
<CommandItem key={option.value} onSelect={() => handleSelect(option)}>
|
||||
{!option.options && (
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Icon name="check" className={classNames("h-4 w-4")} />
|
||||
</div>
|
||||
)}
|
||||
{option.icon && <Icon name={option.icon} className="text-muted mr-2 h-4 w-4" />}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
{option.options && <Icon name="chevron-right" className="ml-auto h-4 w-4" />}
|
||||
{!option.options && facets?.get(option.value) && (
|
||||
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
|
||||
@@ -1,59 +1,19 @@
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
"use client";
|
||||
|
||||
import { Button } from "../button";
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
|
||||
interface DataTablePaginationProps<TData> {
|
||||
table: Table<TData>;
|
||||
totalDbDataCount: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
|
||||
export function DataTablePagination<TData>({ table, totalDbDataCount }: DataTablePaginationProps<TData>) {
|
||||
const loadedCount = table.getFilteredRowModel().rows.length;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
|
||||
selected.
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="chevrons-left"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
StartIcon="chevron-left">
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
StartIcon="chevron-right"
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={!table.getCanNextPage()}
|
||||
onClick={() => table.nextPage()}>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
variant="icon"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
StartIcon="chevrons-right"
|
||||
onClick={() => table.setPageIndex(table.getPageCount())}>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle text-sm tabular-nums">
|
||||
Loaded <span className="text-default font-medium">{`${loadedCount}`}</span> of
|
||||
<span className="text-default font-medium"> {`${totalDbDataCount}`}</span>
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import type { Table as TableType } from "@tanstack/table-core/build/lib/types";
|
||||
import { AnimatePresence } from "framer-motion";
|
||||
import { Fragment } from "react";
|
||||
import { forwardRef } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
|
||||
import type { IconName } from "../..";
|
||||
import { Button } from "../button";
|
||||
|
||||
export type ActionItem<TData> =
|
||||
| {
|
||||
@@ -20,51 +21,26 @@ export type ActionItem<TData> =
|
||||
needsXSelected?: number;
|
||||
};
|
||||
|
||||
interface DataTableSelectionBarProps<TData> {
|
||||
table: Table<TData>;
|
||||
actions?: ActionItem<TData>[];
|
||||
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTableSelectionBar<TData>({
|
||||
table,
|
||||
actions,
|
||||
renderAboveSelection,
|
||||
}: DataTableSelectionBarProps<TData>) {
|
||||
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
|
||||
const isVisible = numberOfSelectedRows > 0;
|
||||
|
||||
// Hacky left % to center
|
||||
const actionsVisible = actions?.filter((a) => {
|
||||
if (!a.needsXSelected) return true;
|
||||
return a.needsXSelected <= numberOfSelectedRows;
|
||||
});
|
||||
|
||||
const Root = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { showSelectionCount?: boolean }
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible ? (
|
||||
<div className="fade-in fixed bottom-6 left-1/2 hidden -translate-x-1/2 gap-1 md:flex md:flex-col">
|
||||
{renderAboveSelection && renderAboveSelection(table)}
|
||||
<div className="bg-brand-default text-brand hidden items-center justify-between rounded-lg p-2 md:flex">
|
||||
<p className="text-brand-subtle w-full px-2 text-center leading-none">
|
||||
{numberOfSelectedRows} selected
|
||||
</p>
|
||||
{actionsVisible?.map((action, index) => {
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{action.type === "action" ? (
|
||||
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
|
||||
{action.label}
|
||||
</Button>
|
||||
) : action.type === "render" ? (
|
||||
action.render(table)
|
||||
) : null}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
"bg-brand-default text-brand fixed bottom-4 left-1/2 flex w-fit -translate-x-1/2 transform items-center space-x-3 rounded-lg px-4 py-2",
|
||||
props.className
|
||||
)}
|
||||
style={{ ...props.style }}
|
||||
{...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Root.displayName = "Root";
|
||||
|
||||
export const DataTableSelectionBar = {
|
||||
Root,
|
||||
};
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, forwardRef } from "react";
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
import type { ButtonProps } from "../button";
|
||||
import { Button } from "../button";
|
||||
import { Input } from "../form";
|
||||
import type { IconName } from "../icon/icon-names";
|
||||
import { DataTableFilter } from "./DataTableFilter";
|
||||
|
||||
export type FilterableItems = {
|
||||
title: string;
|
||||
tableAccessor: string;
|
||||
options: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: IconName;
|
||||
}[];
|
||||
}[];
|
||||
interface DataTableToolbarProps extends ComponentPropsWithoutRef<"div"> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableToolbarProps<TData> {
|
||||
const Root = forwardRef<HTMLDivElement, DataTableToolbarProps>(function DataTableToolbar(
|
||||
{ children, className },
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames("grid w-full items-center gap-2 py-4", className)}
|
||||
style={{ gridArea: "header" }}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
interface SearchBarProps<TData> {
|
||||
table: Table<TData>;
|
||||
filterableItems?: FilterableItems;
|
||||
searchKey?: string;
|
||||
tableCTA?: React.ReactNode;
|
||||
onSearch?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
filterableItems,
|
||||
tableCTA,
|
||||
searchKey,
|
||||
onSearch,
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
// TODO: Is there a better way to check if the table is filtered?
|
||||
// If you select ALL filters for a column, the table is not filtered and we dont get a reset button
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
function SearchBarComponent<TData>(
|
||||
{ table, searchKey, onSearch }: SearchBarProps<TData>,
|
||||
ref: React.Ref<HTMLInputElement>
|
||||
) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 500);
|
||||
|
||||
@@ -46,54 +47,80 @@ export function DataTableToolbar<TData>({
|
||||
onSearch?.(debouncedSearchTerm);
|
||||
}, [debouncedSearchTerm, onSearch]);
|
||||
|
||||
const { t } = useLocale();
|
||||
if (onSearch) {
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value.trim())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!searchKey) {
|
||||
console.error("searchKey is required if onSearch is not provided");
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 py-4">
|
||||
{searchKey && (
|
||||
<Input
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
placeholder="Search"
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())}
|
||||
/>
|
||||
)}
|
||||
{onSearch && (
|
||||
<Input
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
placeholder="Search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
color="minimal"
|
||||
EndIcon="x"
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
className="h-8 px-2 lg:px-3">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filterableItems &&
|
||||
filterableItems?.map((item) => {
|
||||
const foundColumn = table.getColumn(item.tableAccessor);
|
||||
if (foundColumn?.getCanFilter()) {
|
||||
return (
|
||||
<DataTableFilter
|
||||
column={foundColumn}
|
||||
title={item.title}
|
||||
options={item.options}
|
||||
key={item.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
|
||||
{tableCTA ? tableCTA : null}
|
||||
</div>
|
||||
<Input
|
||||
ref={ref}
|
||||
className="max-w-64 mb-0 mr-auto rounded-md"
|
||||
placeholder="Search"
|
||||
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) => {
|
||||
return table.getColumn(searchKey)?.setFilterValue(event.target.value.trim());
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const SearchBar = forwardRef(SearchBarComponent) as <TData>(
|
||||
props: SearchBarProps<TData> & { ref?: React.Ref<HTMLInputElement> }
|
||||
) => ReturnType<typeof SearchBarComponent>;
|
||||
|
||||
interface ClearFiltersButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function ClearFiltersButtonComponent<TData>(
|
||||
{ table }: ClearFiltersButtonProps<TData>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
if (!isFiltered) return null;
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
color="minimal"
|
||||
EndIcon="x"
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
className="h-8 px-2 lg:px-3">
|
||||
{t("clear")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const ClearFiltersButton = forwardRef(ClearFiltersButtonComponent) as <TData>(
|
||||
props: ClearFiltersButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement> }
|
||||
) => ReturnType<typeof ClearFiltersButtonComponent>;
|
||||
|
||||
function CTAComponent(
|
||||
{ children, onClick, color = "primary", ...rest }: ButtonProps,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
return (
|
||||
<Button ref={ref} color={color} onClick={onClick} {...rest}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const CTA = forwardRef(CTAComponent) as (
|
||||
props: ButtonProps & { ref?: React.Ref<HTMLButtonElement> }
|
||||
) => ReturnType<typeof CTAComponent>;
|
||||
|
||||
export const DataTableToolbar = { Root, SearchBar, ClearFiltersButton, CTA };
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { Badge } from "../../badge";
|
||||
import { Checkbox } from "../../form";
|
||||
import type { FilterableItems } from "../DataTableToolbar";
|
||||
import type { DataTableUserStorybook } from "./data";
|
||||
|
||||
export const columns: ColumnDef<DataTableUserStorybook>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "username",
|
||||
header: "Username",
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({ row, table }) => {
|
||||
const user = row.original;
|
||||
const BadgeColor = user.role === "admin" ? "blue" : "gray";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color={BadgeColor}
|
||||
onClick={() => {
|
||||
table.getColumn("role")?.setFilterValue(user.role);
|
||||
}}>
|
||||
{user.role}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
filterFn: (rows, id, filterValue) => {
|
||||
return filterValue.includes(rows.getValue(id));
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const filterableItems: FilterableItems = [
|
||||
{
|
||||
title: "Role",
|
||||
tableAccessor: "role",
|
||||
options: [
|
||||
{
|
||||
label: "Admin",
|
||||
value: "admin",
|
||||
},
|
||||
{
|
||||
label: "User",
|
||||
value: "user",
|
||||
},
|
||||
{
|
||||
label: "Owner",
|
||||
value: "owner",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -1,98 +0,0 @@
|
||||
export type DataTableUserStorybook = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: "admin" | "user";
|
||||
};
|
||||
|
||||
export const dataTableSelectionActions = [
|
||||
{
|
||||
label: "Add To Team",
|
||||
onClick: () => {
|
||||
console.log("Add To Team");
|
||||
},
|
||||
icon: "users",
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
onClick: () => {
|
||||
console.log("Delete");
|
||||
},
|
||||
icon: "stop-circle",
|
||||
},
|
||||
];
|
||||
|
||||
export const dataTableDemousers: DataTableUserStorybook[] = [
|
||||
{
|
||||
id: "728ed52f",
|
||||
email: "m@example.com",
|
||||
username: "m",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "489e1d42",
|
||||
email: "example@gmail.com",
|
||||
username: "e",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f",
|
||||
email: "Keshawn_Schroeder@hotmail.com",
|
||||
username: "Ava_Waelchi",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b",
|
||||
email: "Jovanny_Grant@hotmail.com",
|
||||
username: "Kamren_Gerhold",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a",
|
||||
email: "Emilie.McKenzie@yahoo.com",
|
||||
username: "Lennie_Harber",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5",
|
||||
email: "Jolie_Beatty@hotmail.com",
|
||||
username: "Lorenzo_Will",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e",
|
||||
email: "Giovanny_Cruickshank@hotmail.com",
|
||||
username: "Monserrat_Lang",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e",
|
||||
email: "Lela_Haag@hotmail.com",
|
||||
username: "Eddie_Effertz",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a",
|
||||
email: "Lura_Kohler@gmail.com",
|
||||
username: "Alyce_Olson",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a",
|
||||
email: "Maurice.Koch@hotmail.com",
|
||||
username: "Jovanny_Kiehn",
|
||||
role: "admin",
|
||||
},
|
||||
{
|
||||
id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i",
|
||||
email: "Brenda_Bernhard@yahoo.com",
|
||||
username: "Aurelia_Kemmer",
|
||||
role: "user",
|
||||
},
|
||||
{
|
||||
id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l",
|
||||
email: "Lorenzo_Rippin@hotmail.com",
|
||||
username: "Waino_Lang",
|
||||
role: "admin",
|
||||
},
|
||||
];
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
|
||||
|
||||
import {
|
||||
Examples,
|
||||
Example,
|
||||
Note,
|
||||
Title,
|
||||
CustomArgsTable,
|
||||
VariantsTable,
|
||||
VariantRow,
|
||||
} from "@calcom/storybook/components";
|
||||
|
||||
import { DataTable } from "../";
|
||||
import { columns, filterableItems } from "./columns";
|
||||
import { dataTableDemousers, dataTableSelectionActions } from "./data";
|
||||
|
||||
<Meta title="UI/table/DataTable" component={DataTable} />
|
||||
|
||||
<Title title="DataTable" suffix="Brief" subtitle="Version 3.0 — Last Update: 28 Aug 2023" />
|
||||
|
||||
## Definition
|
||||
|
||||
The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation.
|
||||
|
||||
## Structure
|
||||
|
||||
The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection.
|
||||
|
||||
<CustomArgsTable of={DataTable} />
|
||||
|
||||
## Dialog Story
|
||||
|
||||
<Canvas>
|
||||
<Story
|
||||
name="DataTable"
|
||||
args={{
|
||||
columns: columns,
|
||||
data: dataTableDemousers,
|
||||
isPending: false,
|
||||
searchKey: "username",
|
||||
filterableItems: filterableItems,
|
||||
tableContainerRef: { current: null },
|
||||
selectionOptions: dataTableSelectionActions,
|
||||
}}
|
||||
argTypes={{
|
||||
tableContainerRef: { table: { disable: true } },
|
||||
searchKey: {
|
||||
control: {
|
||||
type: "select",
|
||||
options: ["username", "email"],
|
||||
},
|
||||
},
|
||||
selectionOptions: { control: { type: "object" } },
|
||||
onScroll: { table: { disable: true } },
|
||||
tableOverlay: { table: { disable: true } },
|
||||
CTA: { table: { disable: true } },
|
||||
tableCTA: { table: { disable: true } },
|
||||
}}>
|
||||
{(args) => (
|
||||
<VariantsTable titles={["Default"]} columnMinWidth={1000}>
|
||||
<VariantRow>
|
||||
<DataTable {...args} />
|
||||
</VariantRow>
|
||||
</VariantsTable>
|
||||
)}
|
||||
</Story>
|
||||
</Canvas>
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import { type Table } from "@tanstack/react-table";
|
||||
import { forwardRef, useState, useMemo } from "react";
|
||||
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ButtonProps } from "@calcom/ui";
|
||||
import {
|
||||
Button,
|
||||
buttonClasses,
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandSeparator,
|
||||
Icon,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import { useFiltersSearchState } from "./utils";
|
||||
|
||||
interface ColumnVisiblityProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function ColumnVisibilityButtonComponent<TData>(
|
||||
{
|
||||
children,
|
||||
color = "secondary",
|
||||
EndIcon = "sliders-vertical",
|
||||
table,
|
||||
...rest
|
||||
}: ColumnVisiblityProps<TData> & ButtonProps,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const allColumns = table.getAllLeafColumns();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button ref={ref} color={color} EndIcon={EndIcon} {...rest}>
|
||||
{children ? children : t("View")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
|
||||
<CommandGroup heading={t("toggle_columns")}>
|
||||
{allColumns.map((column) => {
|
||||
const canHide = column.getCanHide();
|
||||
if (!column.columnDef.header || typeof column.columnDef.header !== "string" || !canHide)
|
||||
return null;
|
||||
const isVisible = column.getIsVisible();
|
||||
return (
|
||||
<CommandItem key={column.id} onSelect={() => column.toggleVisibility(!isVisible)}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
isVisible ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
|
||||
)}>
|
||||
<Icon name="check" className={classNames("h-4 w-4")} />
|
||||
</div>
|
||||
{column.columnDef.header}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => allColumns.forEach((column) => column.toggleVisibility(true))}
|
||||
className={classNames(
|
||||
"w-full justify-center text-center",
|
||||
buttonClasses({ color: "secondary" })
|
||||
)}>
|
||||
{t("show_all_columns")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const ColumnVisibilityButton = forwardRef(ColumnVisibilityButtonComponent) as <TData>(
|
||||
props: ColumnVisiblityProps<TData> & ButtonProps & { ref?: React.Ref<HTMLButtonElement> }
|
||||
) => ReturnType<typeof ColumnVisibilityButtonComponent>;
|
||||
|
||||
// Filters
|
||||
interface FilterButtonProps<TData> {
|
||||
table: Table<TData>;
|
||||
omit?: string[];
|
||||
}
|
||||
|
||||
function FilterButtonComponent<TData>(
|
||||
{ table, omit }: FilterButtonProps<TData>,
|
||||
ref: React.Ref<HTMLButtonElement>
|
||||
) {
|
||||
const { t } = useLocale();
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
|
||||
const activeFilters = _state.activeFilters;
|
||||
const columns = table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanFilter())
|
||||
.filter((column) => !omit?.includes(column.id));
|
||||
|
||||
const filterableColumns = useMemo(() => {
|
||||
return columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
}));
|
||||
}, [columns]);
|
||||
|
||||
const handleAddFilter = (columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
_setState({ activeFilters: [...activeFilters, { f: columnId, v: [] }] });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button ref={ref} color="secondary" className="border-dashed">
|
||||
<Icon name="filter" className="mr-2 h-4 w-4" />
|
||||
{t("add_filter")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search_columns")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
|
||||
{filterableColumns.map((column) => {
|
||||
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
|
||||
return (
|
||||
<CommandItem key={column.id} onSelect={() => handleAddFilter(column.id)}>
|
||||
{column.title}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const FilterButton = forwardRef(FilterButtonComponent) as <TData>(
|
||||
props: FilterButtonProps<TData> & { ref?: React.Ref<HTMLButtonElement>; omit?: string[] }
|
||||
) => ReturnType<typeof FilterButtonComponent>;
|
||||
|
||||
// Add the new ActiveFilters component
|
||||
interface ActiveFiltersProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
|
||||
const { t } = useLocale();
|
||||
const [_state, _setState] = useFiltersSearchState();
|
||||
|
||||
const columns = table.getAllColumns().filter((column) => column.getCanFilter());
|
||||
|
||||
const filterableColumns = useMemo(() => {
|
||||
return columns.map((column) => {
|
||||
return {
|
||||
id: column.id,
|
||||
title: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
||||
options: column.getFacetedUniqueValues(),
|
||||
};
|
||||
});
|
||||
}, [columns]);
|
||||
|
||||
const handleRemoveFilter = (columnId: string) => {
|
||||
_setState({ activeFilters: (_state.activeFilters || []).filter((filter) => filter.f !== columnId) });
|
||||
table.getColumn(columnId)?.setFilterValue(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(_state.activeFilters || []).map((filter) => {
|
||||
const column = filterableColumns.find((col) => col.id === filter.f);
|
||||
if (!column) return null;
|
||||
return (
|
||||
<Popover key={column.id}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button color="secondary">
|
||||
{column.title}
|
||||
<Icon name="chevron-down" className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={t("search_options")} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
|
||||
{Array.from(column.options).map(([option]) => {
|
||||
if (!option) return null;
|
||||
return (
|
||||
<CommandItem
|
||||
key={option}
|
||||
onSelect={() => {
|
||||
const newFilterValue = filter.v?.includes(option)
|
||||
? filter.v?.filter((value) => value !== option)
|
||||
: [...(filter.v || []), option];
|
||||
_setState({
|
||||
activeFilters: _state.activeFilters.map((f) =>
|
||||
f.f === filter.f ? { ...f, v: newFilterValue } : f
|
||||
),
|
||||
});
|
||||
table
|
||||
.getColumn(filter.f)
|
||||
?.setFilterValue(newFilterValue.length ? newFilterValue : undefined);
|
||||
}}>
|
||||
<div
|
||||
className={classNames(
|
||||
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
|
||||
Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
|
||||
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option)
|
||||
? "bg-primary"
|
||||
: "opacity-50"
|
||||
)}>
|
||||
{Array.isArray(table.getColumn(column.id)?.getFilterValue()) &&
|
||||
(table.getColumn(column.id)?.getFilterValue() as string[])?.includes(option) && (
|
||||
<Icon name="check" className="text-primary-foreground h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
{option}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
handleRemoveFilter(column.id);
|
||||
}}
|
||||
className={classNames(
|
||||
"w-full justify-center text-center",
|
||||
buttonClasses({ color: "secondary" })
|
||||
)}>
|
||||
{t("clear")}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Update the export to include ActiveFilters
|
||||
export const DataTableFilters = { ColumnVisibilityButton, FilterButton, ActiveFilters };
|
||||
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { parseAsArrayOf, parseAsJson, useQueryStates } from "nuqs";
|
||||
import { z } from "zod";
|
||||
|
||||
const filterSchema = z.object({
|
||||
f: z.string(),
|
||||
v: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const filtersSearchParams = {
|
||||
activeFilters: parseAsArrayOf(parseAsJson(filterSchema.parse)).withDefault([]),
|
||||
};
|
||||
|
||||
export function useFiltersSearchState() {
|
||||
return useQueryStates(filtersSearchParams);
|
||||
}
|
||||
@@ -1,98 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
Table as TableType,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useState } from "react";
|
||||
import type { Row } from "@tanstack/react-table";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import type { Table as ReactTableType } from "@tanstack/react-table";
|
||||
import { useVirtual } from "react-virtual";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
|
||||
import Icon from "../icon/Icon";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
|
||||
import type { ActionItem } from "./DataTableSelectionBar";
|
||||
import { DataTableSelectionBar } from "./DataTableSelectionBar";
|
||||
import type { FilterableItems } from "./DataTableToolbar";
|
||||
import { DataTableToolbar } from "./DataTableToolbar";
|
||||
|
||||
// Export DataTable components under a common namespace for better clarity
|
||||
export { DataTableToolbar } from "./DataTableToolbar";
|
||||
export { DataTableFilters } from "./filters";
|
||||
|
||||
export interface DataTableProps<TData, TValue> {
|
||||
table: ReactTableType<TData>;
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>;
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
searchKey?: string;
|
||||
onSearch?: (value: string) => void;
|
||||
filterableItems?: FilterableItems;
|
||||
selectionOptions?: ActionItem<TData>[];
|
||||
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
|
||||
tableCTA?: React.ReactNode;
|
||||
isPending?: boolean;
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
|
||||
CTA?: React.ReactNode;
|
||||
tableOverlay?: React.ReactNode;
|
||||
variant?: "default" | "compact";
|
||||
"data-testid"?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
columns,
|
||||
data,
|
||||
filterableItems,
|
||||
tableCTA,
|
||||
searchKey,
|
||||
selectionOptions,
|
||||
table,
|
||||
tableContainerRef,
|
||||
isPending,
|
||||
tableOverlay,
|
||||
variant,
|
||||
renderAboveSelection,
|
||||
/** This should only really be used if you dont have actions in a row. */
|
||||
onSearch,
|
||||
onRowMouseclick,
|
||||
onScroll,
|
||||
children,
|
||||
...rest
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
debugTable: true,
|
||||
manualPagination: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
}: DataTableProps<TData, TValue> & React.ComponentPropsWithoutRef<"div">) {
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtual({
|
||||
@@ -106,81 +48,95 @@ export function DataTable<TData, TValue>({
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
||||
|
||||
return (
|
||||
<div className="relative space-y-4">
|
||||
<DataTableToolbar
|
||||
table={table}
|
||||
filterableItems={filterableItems}
|
||||
searchKey={searchKey}
|
||||
onSearch={onSearch}
|
||||
tableCTA={tableCTA}
|
||||
/>
|
||||
<div ref={tableContainerRef} onScroll={onScroll} data-testid={rest["data-testid"] ?? "data-table"}>
|
||||
<Table data-testid="">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows && !isPending ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<TData>;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
className={classNames(
|
||||
onRowMouseclick && "hover:cursor-pointer",
|
||||
variant === "compact" && "!border-0"
|
||||
)}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={classNames(
|
||||
"grid h-[75dvh]", // Set a fixed height for the container
|
||||
rest.className
|
||||
)}
|
||||
style={{
|
||||
gridTemplateRows: "auto 1fr auto",
|
||||
gridTemplateAreas: "'header' 'body' 'footer'",
|
||||
...rest.style,
|
||||
}}
|
||||
data-testid={rest["data-testid"] ?? "data-table"}>
|
||||
<div className="overflow-hidden" style={{ gridArea: "body" }}>
|
||||
<div ref={tableContainerRef} onScroll={onScroll} className="scrollbar-thin h-full overflow-y-auto">
|
||||
<div className="inline-block min-w-full align-middle">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className={classNames(
|
||||
header.column.getCanSort() ? "cursor-pointer select-none" : ""
|
||||
)}>
|
||||
<div className="flex items-center" onClick={header.column.getToggleSortingHandler()}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() && (
|
||||
<Icon
|
||||
name="arrow-up"
|
||||
className="ml-2 h-4 w-4"
|
||||
style={{
|
||||
transform:
|
||||
header.column.getIsSorted() === "asc" ? "rotate(0deg)" : "rotate(180deg)",
|
||||
transition: "transform 0.2s ease-in-out",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
{tableOverlay && tableOverlay}
|
||||
</Table>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{paddingTop > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingTop}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
{virtualRows && !isPending ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index] as Row<TData>;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
onClick={() => onRowMouseclick && onRowMouseclick(row)}
|
||||
className={classNames(
|
||||
onRowMouseclick && "hover:cursor-pointer",
|
||||
variant === "compact" && "!border-0"
|
||||
)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns().length} className="h-24 text-center">
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{paddingBottom > 0 && (
|
||||
<tr>
|
||||
<td style={{ height: `${paddingBottom}px` }} />
|
||||
</tr>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* <DataTablePagination table={table} /> */}
|
||||
<DataTableSelectionBar
|
||||
table={table}
|
||||
actions={selectionOptions}
|
||||
renderAboveSelection={renderAboveSelection}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback, useEffect } from "react";
|
||||
|
||||
export const useFetchMoreOnBottomReached = (
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>,
|
||||
fetchNextPage: () => void,
|
||||
isFetching: boolean,
|
||||
totalFetched: number,
|
||||
totalDBRowCount: number
|
||||
) => {
|
||||
const fetchMoreOnBottomReached = useCallback(
|
||||
(containerRefElement?: HTMLDivElement | null) => {
|
||||
if (containerRefElement) {
|
||||
const { scrollHeight, scrollTop, clientHeight } = containerRefElement;
|
||||
if (scrollHeight - scrollTop - clientHeight < 300 && !isFetching && totalFetched < totalDBRowCount) {
|
||||
fetchNextPage();
|
||||
}
|
||||
}
|
||||
},
|
||||
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMoreOnBottomReached(tableContainerRef.current);
|
||||
}, [fetchMoreOnBottomReached, tableContainerRef]);
|
||||
|
||||
return fetchMoreOnBottomReached;
|
||||
};
|
||||
@@ -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)}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user