feat: orgs - user management v2

figma: https://www.figma.com/file/ZJNAFHCAq6rnuGRgUFxjzQ/Organizations----New-features?type=design&node-id=4304-330348&mode=design&t=nKonHZpucwxnqwQI-4 (Not all features are in this PR)
loom: https://www.loom.com/share/30e8ef20a5b34a708dca160d8c04ae6f

shoutout @shadcn for the base of this table <3 

Member View: 
![CleanShot 2023-07-14 at 14 09 02](https://github.com/calcom/cal.com/assets/55134778/5db6eedb-bece-4955-91f0-9f2b3b042300)

Admin view:
![CleanShot 2023-07-14 at 14 09 07](https://github.com/calcom/cal.com/assets/55134778/708c624d-1990-4075-8b32-d4587d09deff)

How to test 
- Create ORG
- add members
- view settings -> orgs -> members
This commit is contained in:
sean-brydon
2023-07-19 12:45:13 -07:00
committed by GitHub
parent 676452b4f4
commit 610c86837a
48 changed files with 2635 additions and 260 deletions
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";
import { z } from "zod";
import type { z } from "zod";
import type { EventNameObjectType } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
@@ -17,14 +17,23 @@ import {
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import { classNames } from "@calcom/lib";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { Button, Checkbox, Label, SettingsToggle, showToast, TextField, Tooltip, Alert } from "@calcom/ui";
import {
Button,
CheckboxField,
Label,
SettingsToggle,
showToast,
TextField,
Tooltip,
Alert,
} from "@calcom/ui";
import { Edit, Copy } from "@calcom/ui/components/icon";
import RequiresConfirmationController from "./RequiresConfirmationController";
@@ -342,7 +351,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
}}
/>
<div className="mt-2">
<Checkbox
<CheckboxField
description={t("show_attendees")}
disabled={seatsLocked.disabled}
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
@@ -5,7 +5,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import { AnimatedPopover } from "@calcom/ui";
import { Checkbox } from "@calcom/ui";
import { CheckboxField } from "@calcom/ui";
import { groupBy } from "../groupBy";
import { useFilterQuery } from "../lib/useFilterQuery";
@@ -73,7 +73,7 @@ export const EventTypeFilter = () => {
</div>
{groupedEventTypes[teamName].map((eventType) => (
<div key={eventType.id} className="flex items-center px-4 py-1.5">
<Checkbox
<CheckboxField
checked={query.eventTypeIds?.includes(eventType.id)}
onChange={(e) => {
if (e.target.checked) {
@@ -1,133 +1,17 @@
import { useRouter } from "next/router";
import { useState } from "react";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import MemberListItem from "@calcom/features/ee/teams/components/MemberListItem";
import TeamInviteList from "@calcom/features/ee/teams/components/TeamInviteList";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { UserListTable } from "@calcom/features/users/components/UserTable/UserListTable";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Button, Meta, TextField, showToast } from "@calcom/ui";
import { Plus } from "@calcom/ui/components/icon";
type Team = RouterOutputs["viewer"]["teams"]["get"];
interface MembersListProps {
team: Team | undefined;
}
const checkIfExist = (comp: string, query: string) =>
comp.toLowerCase().replace(/\s+/g, "").includes(query.toLowerCase().replace(/\s+/g, ""));
function MembersList(props: MembersListProps) {
const { team } = props;
const { t } = useLocale();
const [query, setQuery] = useState<string>("");
const members = team?.members;
const membersList = members
? members && query === ""
? members
: members.filter((member) => {
const email = member.email ? checkIfExist(member.email, query) : false;
const username = member.username ? checkIfExist(member.username, query) : false;
const name = member.name ? checkIfExist(member.name, query) : false;
return email || username || name;
})
: undefined;
return (
<div className="flex flex-col gap-y-3">
<TextField
type="search"
autoComplete="false"
onChange={(e) => setQuery(e.target.value)}
value={query}
placeholder={`${t("search")}...`}
/>
{membersList?.length && team ? (
<ul className="divide-subtle border-subtle divide-y rounded-md border ">
{membersList.map((member) => {
return <MemberListItem key={member.id} team={team} member={member} />;
})}
</ul>
) : null}
</div>
);
}
import { Meta } from "@calcom/ui";
const MembersView = () => {
const { t, i18n } = useLocale();
const router = useRouter();
const utils = trpc.useContext();
const showDialog = router.query.inviteModal === "true";
const [showMemberInvitationModal, setShowMemberInvitationModal] = useState(showDialog);
const { data: team, isLoading } = trpc.viewer.organizations.listMembers.useQuery(undefined, {
onError: () => {
router.push("/settings");
},
});
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
await utils.viewer.organizations.listMembers.invalidate();
setShowMemberInvitationModal(false);
if (data.sendEmailInvitation) {
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.usernameOrEmail.length,
}),
"success"
);
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
const isInviteOpen = !team?.membership.accepted;
const isAdminOrOwner =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
const { t } = useLocale();
return (
<LicenseRequired>
<Meta
title={t("organization_members")}
description={t("organization_description")}
CTA={
isAdminOrOwner ? (
<Button
type="button"
color="primary"
StartIcon={Plus}
className="ml-auto"
onClick={() => setShowMemberInvitationModal(true)}
data-testid="new-organization-member-button">
{t("add")}
</Button>
) : (
<></>
)
}
/>
{!isLoading && (
<>
<div>
{team && (
<Meta title={t("organization_members")} description={t("organization_description")} />
<div>
{/* {team && (
<>
{isInviteOpen && (
<TeamInviteList
@@ -144,30 +28,9 @@ const MembersView = () => {
/>
)}
</>
)}
<MembersList team={team} />
</div>
{showMemberInvitationModal && team && (
<MemberInvitationModal
teamId={team.id}
isOpen={showMemberInvitationModal}
members={team.members}
onExit={() => setShowMemberInvitationModal(false)}
isLoading={inviteMemberMutation.isLoading}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: team.id,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
isOrg: true,
});
}}
/>
)}
</>
)}
)} */}
<UserListTable />
</div>
</LicenseRequired>
);
};
@@ -11,7 +11,7 @@ import type { StripePaymentData, StripeSetupIntentData } from "@calcom/app-store
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Checkbox } from "@calcom/ui";
import { Button, CheckboxField } from "@calcom/ui";
import type { EventType } from ".prisma/client";
@@ -101,7 +101,7 @@ const PaymentForm = (props: Props) => {
</div>
{paymentOption === "HOLD" && (
<div className="bg-info mb-5 mt-2 rounded-md p-3">
<Checkbox
<CheckboxField
description={t("acknowledge_booking_no_show_fee", {
amount: props.payment.amount / 100,
formatParams: { amount: { currency: props.payment.currency } },
@@ -102,7 +102,7 @@ export default function MemberChangeRoleModal(props: {
{errorMessage}
</p>
)}
<DialogFooter showDivider className="mt-12 sm:flex sm:flex-row-reverse">
<DialogFooter className="mt-12 sm:flex sm:flex-row-reverse">
<Button type="button" color="secondary" onClick={props.onExit}>
{t("cancel")}
</Button>
@@ -13,7 +13,7 @@ import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import {
Button,
Checkbox as CheckboxField,
CheckboxField,
Dialog,
DialogContent,
DialogFooter,
@@ -11,7 +11,7 @@ import { WorkflowActions } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import {
Button,
Checkbox,
CheckboxField,
Dialog,
DialogClose,
DialogContent,
@@ -239,7 +239,7 @@ export const AddActionDialog = (props: IAddActionDialog) => {
name="numberRequired"
control={form.control}
render={() => (
<Checkbox
<CheckboxField
defaultChecked={form.getValues("numberRequired") || false}
description={t("make_phone_number_required")}
onChange={(e) => form.setValue("numberRequired", e.target.checked)}
@@ -17,7 +17,7 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import {
Badge,
Button,
Checkbox,
CheckboxField,
Dialog,
DialogClose,
DialogContent,
@@ -657,7 +657,7 @@ export default function WorkflowStepContainer(props: WorkflowStepProps) {
name={`steps.${step.stepNumber - 1}.numberRequired`}
control={form.control}
render={() => (
<Checkbox
<CheckboxField
disabled={props.readOnly}
defaultChecked={
form.getValues(`steps.${step.stepNumber - 1}.numberRequired`) || false
@@ -4,7 +4,7 @@ import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, Checkbox } from "@calcom/ui";
import { Dialog, DialogContent, Form, DialogFooter, DialogClose, Button, CheckboxField } from "@calcom/ui";
export const BulkUpdateEventSchema = z.object({
eventTypeIds: z.array(z.number()),
@@ -47,7 +47,7 @@ export function BulkEditDefaultConferencingModal(props: { open: boolean; setOpen
<div className="flex flex-col space-y-2">
{data.eventTypes.length > 0 && (
<div className="flex items-center space-x-2 rounded-md px-3 pb-2.5 pt-1">
<Checkbox
<CheckboxField
description={t("select_all")}
descriptionAsLabel
onChange={(e) => {
@@ -59,7 +59,7 @@ export function BulkEditDefaultConferencingModal(props: { open: boolean; setOpen
)}
{data.eventTypes.map((eventType) => (
<div key={eventType.id} className="bg-muted flex items-center space-x-2 rounded-md px-3 py-2.5">
<Checkbox
<CheckboxField
description={eventType.title}
descriptionAsLabel
checked={eventTypesSelected.includes(eventType.id)}
@@ -17,7 +17,7 @@ import {
RadioField,
EmailField,
Tooltip,
Checkbox,
CheckboxField,
} from "@calcom/ui";
import { UserPlus, X } from "@calcom/ui/components/icon";
@@ -370,7 +370,7 @@ export const Components: Record<BookingFieldType, Component> = {
factory: ({ readOnly, label, value, setValue }) => {
return (
<div className="flex">
<Checkbox
<CheckboxField
onChange={(e) => {
if (e.target.checked) {
setValue(true);
@@ -4,7 +4,7 @@ import { FilterCheckboxFieldsContainer } from "@calcom/features/filters/componen
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc";
import { trpc } from "@calcom/trpc";
import { AnimatedPopover, Checkbox } from "@calcom/ui";
import { AnimatedPopover, CheckboxField } from "@calcom/ui";
import { useFilterContext } from "../context/provider";
@@ -49,7 +49,7 @@ export const EventTypeList = memo(() => {
<FilterCheckboxFieldsContainer>
{filterOptions?.map((eventType) => (
<div key={eventType.value} className="item-center hover:bg-muted flex cursor-pointer px-4 py-2">
<Checkbox
<CheckboxField
checked={eventTypeValue?.value === eventType.value}
onChange={(e) => {
if (e.target.checked) {
+2
View File
@@ -12,11 +12,13 @@
"@calcom/trpc": "*",
"@calcom/ui": "*",
"@lexical/react": "^0.9.0",
"@tanstack/react-table": "^8.9.3",
"dompurify": "^2.4.1",
"framer-motion": "^10.12.8",
"lexical": "^0.9.0",
"react-select": "^5.7.0",
"react-sticky-box": "^2.0.4",
"react-virtual": "^2.10.4",
"zustand": "^4.3.2"
},
"devDependencies": {
@@ -28,7 +28,7 @@ import {
Select,
SkeletonText,
Switch,
Checkbox,
CheckboxField,
} from "@calcom/ui";
import { Copy, Plus, Trash } from "@calcom/ui/components/icon";
@@ -447,7 +447,7 @@ const CopyTimes = ({
<li key="select all">
<label className="text-default flex w-full items-center justify-between">
<span className="px-1">{t("select_all")}</span>
<Checkbox
<CheckboxField
description=""
value={t("select_all")}
checked={selected.length === 7}
@@ -472,7 +472,7 @@ const CopyTimes = ({
<li key={weekday}>
<label className="text-default flex w-full items-center justify-between">
<span className="px-1">{weekday}</span>
<Checkbox
<CheckboxField
description=""
value={weekdayIndex}
checked={selected.includes(weekdayIndex) || disabled === weekdayIndex}
@@ -10,7 +10,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Label, Checkbox, Button } from "@calcom/ui";
import { Label, CheckboxField, Button } from "@calcom/ui";
type BookerLayoutSelectorProps = {
title?: string;
@@ -156,7 +156,7 @@ const BookerLayoutFields = ({ settings, onChange, showUserSettings, isDark }: Bo
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
alt="Layout preview"
/>
<Checkbox
<CheckboxField
value={layout}
name={`bookerlayout_${layout}`}
description={t(`bookerlayout_${layout}`)}
@@ -487,7 +487,7 @@ export default function SettingsLayout({
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div className="mx-auto max-w-full justify-center md:max-w-3xl">
<div className="mx-auto max-w-full justify-center md:max-w-4xl">
<ShellHeader />
<ErrorBoundary>
<Suspense fallback={<Loader />}>{children}</Suspense>
@@ -0,0 +1,27 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import MemberChangeRoleModal from "@calcom/features/ee/teams/components/MemberChangeRoleModal";
import type { Action, State } from "./UserListTable";
export function ChangeUserRoleModal(props: { state: State; dispatch: Dispatch<Action> }) {
const { data: session } = useSession();
const orgId = session?.user.organizationId;
if (!orgId || !props.state.changeMemberRole.user) return null;
return (
<MemberChangeRoleModal
isOpen={true}
currentMember={props.state.changeMemberRole.user?.role}
teamId={orgId}
memberId={props.state.changeMemberRole.user?.id}
initialRole={props.state.changeMemberRole.user?.role}
onExit={() =>
props.dispatch({
type: "CLOSE_MODAL",
})
}
/>
);
}
@@ -0,0 +1,55 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
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";
export function DeleteMemberModal({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { data: session } = useSession();
const utils = trpc.useContext();
const removeMemberMutation = trpc.viewer.teams.removeMember.useMutation({
onSuccess() {
// We don't need to wait for invalidate to finish
Promise.all([
utils.viewer.teams.get.invalidate(),
utils.viewer.eventTypes.invalidate(),
utils.viewer.organizations.listMembers.invalidate(),
]);
showToast(t("success"), "success");
},
async onError(err) {
showToast(err.message, "error");
},
});
return (
<Dialog
open={state.deleteMember.showModal}
onOpenChange={(open) =>
!open &&
dispatch({
type: "CLOSE_MODAL",
})
}>
<ConfirmationDialogContent
variety="danger"
title={t("remove_member")}
confirmBtnText={t("confirm_remove_member")}
onConfirm={() => {
// Shouldnt ever happen just for type safety
if (!session?.user.organizationId || !state?.deleteMember?.user?.id) return;
removeMemberMutation.mutate({
teamId: session?.user.organizationId,
memberId: state?.deleteMember?.user.id,
isOrg: true,
});
}}>
{t("remove_member_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
);
}
@@ -0,0 +1,47 @@
import { signIn, useSession } from "next-auth/react";
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";
export function ImpersonationMemberModal(props: { state: State; dispatch: Dispatch<Action> }) {
const { t } = useLocale();
const { data: session } = useSession();
const teamId = session?.user.organizationId;
const user = props.state.impersonateMember.user;
if (!user || !teamId) return null;
return (
<Dialog
open={true}
onOpenChange={() =>
props.dispatch({
type: "CLOSE_MODAL",
})
}>
<DialogContent type="creation" title={t("impersonate")} description={t("impersonation_user_tip")}>
<form
onSubmit={async (e) => {
e.preventDefault();
await signIn("impersonation-auth", {
username: user.username || user.email,
teamId: teamId,
});
props.dispatch({
type: "CLOSE_MODAL",
});
}}>
<DialogFooter showDivider className="mt-8">
<DialogClose color="secondary">{t("cancel")}</DialogClose>
<Button color="primary" type="submit">
{t("impersonate")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,76 @@
import { useSession } from "next-auth/react";
import type { Dispatch } from "react";
import MemberInvitationModal from "@calcom/features/ee/teams/components/MemberInvitationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { showToast } from "@calcom/ui";
import type { Action } from "./UserListTable";
interface Props {
dispatch: Dispatch<Action>;
}
export function InviteMemberModal(props: Props) {
const { data: session } = useSession();
const utils = trpc.useContext();
const { t, i18n } = useLocale();
const inviteMemberMutation = trpc.viewer.teams.inviteMember.useMutation({
async onSuccess(data) {
props.dispatch({ type: "CLOSE_MODAL" });
// Need to figure out if invalidating here is the right approach - we could have already
// loaded a bunch of data and idk how pagination works with invalidation. We may need to use
// Optimistic updates here instead.
await utils.viewer.organizations.listMembers.invalidate();
if (data.sendEmailInvitation) {
if (Array.isArray(data.usernameOrEmail)) {
showToast(
t("email_invite_team_bulk", {
userCount: data.usernameOrEmail.length,
}),
"success"
);
} else {
showToast(
t("email_invite_team", {
email: data.usernameOrEmail,
}),
"success"
);
}
}
},
onError: (error) => {
showToast(error.message, "error");
},
});
if (!session?.user.organizationId) return null;
const orgId = session.user.organizationId;
return (
<MemberInvitationModal
members={[]}
isOpen={true}
onExit={() => {
props.dispatch({
type: "CLOSE_MODAL",
});
}}
teamId={orgId}
isLoading={inviteMemberMutation.isLoading}
onSubmit={(values) => {
inviteMemberMutation.mutate({
teamId: orgId,
language: i18n.language,
role: values.role,
usernameOrEmail: values.emailOrUsername,
sendEmailInvitation: values.sendInviteEmail,
isOrg: true,
});
}}
/>
);
}
@@ -0,0 +1,331 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Plus, StopCircle, Users } from "lucide-react";
import { useSession } from "next-auth/react";
import { useMemo, useRef, useCallback, useEffect, useReducer } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc";
import { Avatar, Badge, Button, DataTable } from "@calcom/ui";
import { useOrgBrandingValues } from "../../../ee/organizations/hooks";
import { ChangeUserRoleModal } from "./ChangeUserRoleModal";
import { DeleteMemberModal } from "./DeleteMemberModal";
import { ImpersonationMemberModal } from "./ImpersonationMemberModal";
import { InviteMemberModal } from "./InviteMemberModal";
import { TableActions } from "./UserTableActions";
export interface User {
id: number;
username: string | null;
email: string;
timeZone: string;
role: MembershipRole;
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;
};
export type Action =
| {
type: "SET_CHANGE_MEMBER_ROLE_ID" | "SET_DELETE_ID" | "SET_IMPERSONATE_ID" | "INVITE_MEMBER";
payload: Payload;
}
| {
type: "CLOSE_MODAL";
};
const initialState: State = {
changeMemberRole: {
showModal: false,
},
deleteMember: {
showModal: false,
},
impersonateMember: {
showModal: false,
},
inviteMember: {
showModal: false,
},
};
function reducer(state: State, action: Action): State {
switch (action.type) {
case "SET_CHANGE_MEMBER_ROLE_ID":
return { ...state, changeMemberRole: action.payload };
case "SET_DELETE_ID":
return { ...state, deleteMember: action.payload };
case "SET_IMPERSONATE_ID":
return { ...state, impersonateMember: action.payload };
case "INVITE_MEMBER":
return { ...state, inviteMember: action.payload };
case "CLOSE_MODAL":
return {
...state,
changeMemberRole: { showModal: false },
deleteMember: { showModal: false },
impersonateMember: { showModal: false },
inviteMember: { showModal: false },
};
default:
return state;
}
}
export function UserListTable() {
const { data: session } = useSession();
const { data: currentMembership } = trpc.viewer.organizations.listCurrent.useQuery();
const tableContainerRef = useRef<HTMLDivElement>(null);
const [state, dispatch] = useReducer(reducer, initialState);
const { t } = useLocale();
const orgBranding = useOrgBrandingValues();
const { data, isLoading, fetchNextPage, isFetching } =
trpc.viewer.organizations.listMembers.useInfiniteQuery(
{
limit: 10,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
keepPreviousData: true,
}
);
const adminOrOwner = currentMembership?.user.role === "ADMIN" || currentMembership?.user.role === "OWNER";
const domain = orgBranding?.fullDomain ?? WEBAPP_URL;
const memorisedColumns = useMemo(() => {
const permissions = {
canEdit: adminOrOwner,
canRemove: adminOrOwner,
canImpersonate: false,
};
const cols: ColumnDef<User>[] = [
// Disabling select for this PR: Will work on actions etc in a follow up
// {
// 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]"
// />
// ),
// },
{
id: "member",
accessorFn: (data) => data.email,
header: "Member",
cell: ({ row }) => {
const { username, email } = row.original;
return (
<div className="flex items-center gap-2">
<Avatar
size="sm"
alt={username || email}
imageSrc={domain + "/" + username + "/avatar.png"}
gravatarFallbackMd5="fallback"
/>
<div className="">
<div className="text-emphasis text-sm font-medium leading-none">
{username || "No username"}
</div>
<div className="text-subtle text-sm leading-none">{email}</div>
</div>
</div>
);
},
filterFn: (rows, id, filterValue) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Weird typing issue
return rows.getValue(id).includes(filterValue);
},
},
{
id: "role",
accessorFn: (data) => data.role,
header: "Role",
cell: ({ row, table }) => {
const { role } = row.original;
return (
<Badge
variant={role === "MEMBER" ? "gray" : "blue"}
onClick={() => {
table.getColumn("role")?.setFilterValue(role);
}}>
{role}
</Badge>
);
},
filterFn: (rows, id, filterValue) => {
return filterValue.includes(rows.getValue(id));
},
},
{
id: "teams",
header: "Teams",
cell: ({ row }) => {
const { teams, accepted } = row.original;
// TODO: Implement click to filter
return (
<div className="flex h-full flex-wrap items-center gap-2">
{accepted ? null : (
<Badge variant="red" className="text-xs">
Pending
</Badge>
)}
{teams.map((team) => (
<Badge key={team.id} variant="gray">
{team.name}
</Badge>
))}
</div>
);
},
},
{
id: "actions",
cell: ({ row }) => {
const user = row.original;
const permissionsRaw = permissions;
const isSelf = user.id === session?.user.id;
const permissionsForUser = {
canEdit: permissionsRaw.canEdit && user.accepted && !isSelf,
canRemove: permissionsRaw.canRemove && !isSelf,
canImpersonate: user.accepted && !user.disableImpersonation && !isSelf,
canLeave: user.accepted && isSelf,
};
return (
<TableActions
user={user}
permissionsForUser={permissionsForUser}
dispatch={dispatch}
domain={domain}
/>
);
},
},
];
return cols;
}, [session?.user.id, adminOrOwner, dispatch, domain]);
//we must flatten the array of arrays from the useInfiniteQuery hook
const flatData = useMemo(() => data?.pages?.flatMap((page) => page.rows) ?? [], [data]) as User[];
const totalDBRowCount = data?.pages?.[0]?.meta?.totalRowCount ?? 0;
const totalFetched = flatData.length;
//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();
}
}
},
[fetchNextPage, isFetching, totalFetched, totalDBRowCount]
);
useEffect(() => {
fetchMoreOnBottomReached(tableContainerRef.current);
}, [fetchMoreOnBottomReached]);
return (
<>
<DataTable
searchKey="member"
selectionOptions={[
{
label: "Add To Team",
onClick: () => {
console.log("Add To Team");
},
icon: Users,
},
{
label: "Delete",
onClick: () => {
console.log("Delete");
},
icon: StopCircle,
},
]}
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}
isLoading={isLoading}
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" },
],
},
]}
/>
{state.deleteMember.showModal && <DeleteMemberModal state={state} dispatch={dispatch} />}
{state.inviteMember.showModal && <InviteMemberModal dispatch={dispatch} />}
{state.impersonateMember.showModal && <ImpersonationMemberModal dispatch={dispatch} state={state} />}
{state.changeMemberRole.showModal && <ChangeUserRoleModal dispatch={dispatch} state={state} />}
</>
);
}
@@ -0,0 +1,182 @@
import { ExternalLink, MoreHorizontal, Edit2, UserX, Lock } from "lucide-react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import {
ButtonGroup,
Tooltip,
Button,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuSeparator,
} from "@calcom/ui";
import { useOrgBrandingValues } from "../../../ee/organizations/hooks";
import type { Action } from "./UserListTable";
import type { User } from "./UserListTable";
export function TableActions({
user,
permissionsForUser,
dispatch,
domain,
}: {
user: User;
dispatch: React.Dispatch<Action>;
domain: string;
permissionsForUser: {
canEdit: boolean;
canRemove: boolean;
canImpersonate: boolean;
};
}) {
const { t } = useLocale();
const orgBranding = useOrgBrandingValues();
const usersProfileUrl = domain + "/" + user.username;
return (
<>
<ButtonGroup combined containerProps={{ className: "border-default hidden md:flex" }}>
<Tooltip content={t("view_public_page")}>
<Button
target="_blank"
href={usersProfileUrl}
color="secondary"
className={classNames(!permissionsForUser.canEdit ? "rounded-r-md" : "")}
variant="icon"
StartIcon={ExternalLink}
/>
</Tooltip>
{(permissionsForUser.canEdit || permissionsForUser.canRemove) && (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
className="radix-state-open:rounded-r-md"
color="secondary"
variant="icon"
StartIcon={MoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
{permissionsForUser.canEdit && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_CHANGE_MEMBER_ROLE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon={Edit2}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
)}
{permissionsForUser.canImpersonate && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon={Lock}>
{t("impersonate")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
color="destructive"
StartIcon={UserX}>
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
)}
</ButtonGroup>
<div className="flex md:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" variant="icon" color="minimal" StartIcon={MoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" StartIcon={ExternalLink}>
{t("view_public_page")}
</DropdownItem>
</DropdownMenuItem>
{permissionsForUser.canEdit && (
<>
<DropdownMenuItem>
<DropdownItem
type="button"
onClick={() =>
dispatch({
type: "SET_IMPERSONATE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon={Edit2}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{permissionsForUser.canRemove && (
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
onClick={() =>
dispatch({
type: "SET_DELETE_ID",
payload: {
user,
showModal: true,
},
})
}
StartIcon={UserX}>
{t("remove")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
</div>
</>
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { PaginationState } from "@tanstack/react-table";
import { useState, useMemo } from "react";
export function usePagination({
defaultPageIndex = 1,
defaultPageSize = 20,
}: {
defaultPageIndex?: number;
defaultPageSize?: number;
}) {
const [{ pageIndex, pageSize }, setPagination] = useState<PaginationState>({
pageIndex: defaultPageIndex,
pageSize: defaultPageSize,
});
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
}),
[pageIndex, pageSize]
);
return {
pagination,
setPagination,
};
}
@@ -4,6 +4,7 @@ import { ZAdminVerifyInput } from "./adminVerify.schema";
import { ZCreateInputSchema } from "./create.schema";
import { ZCreateTeamsSchema } from "./createTeams.schema";
import { ZGetMembersInput } from "./getMembers.schema";
import { ZListMembersSchema } from "./listMembers.schema";
import { ZSetPasswordSchema } from "./setPassword.schema";
import { ZUpdateInputSchema } from "./update.schema";
import { ZVerifyCodeInputSchema } from "./verifyCode.schema";
@@ -194,7 +195,7 @@ export const viewerOrganizationsRouter = router({
input,
});
}),
listMembers: authedProcedure.query(async ({ ctx }) => {
listMembers: authedProcedure.input(ZListMembersSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.listMembers) {
UNSTABLE_HANDLER_CACHE.listMembers = await import("./listMembers.handler").then(
(mod) => mod.listMembersHandler
@@ -208,6 +209,7 @@ export const viewerOrganizationsRouter = router({
return UNSTABLE_HANDLER_CACHE.listMembers({
ctx,
input,
});
}),
getBrand: authedProcedure.query(async ({ ctx }) => {
@@ -75,7 +75,6 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
});
const userIds = foundUsersWithMatchingEmailDomain.map((user) => user.id);
const userEmails = foundUsersWithMatchingEmailDomain.map((user) => user.email);
await prisma.$transaction([
prisma.membership.updateMany({
@@ -108,14 +107,6 @@ export const adminVerifyHandler = async ({ input }: AdminVerifyOptions) => {
organizationId: input.orgId,
},
}),
prisma.verificationToken.deleteMany({
where: {
identifier: {
in: userEmails,
},
teamId: input.orgId,
},
}),
]);
return {
@@ -1,36 +1,102 @@
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import type { MembershipRole } from "@calcom/prisma/enums";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TListMembersSchema } from "./listMembers.schema";
type GetOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TListMembersSchema;
};
export const listMembersHandler = async ({ ctx }: GetOptions) => {
export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const organizationId = ctx.user.organizationId;
if (!organizationId) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization." });
}
const team = await getTeamWithMembers(organizationId, undefined, ctx.user.id);
if (!team) {
throw new TRPCError({ code: "NOT_FOUND", message: "Team not found." });
const { cursor, limit } = input;
const getTotalMembers = await prisma?.membership.count({
where: {
teamId: organizationId,
},
});
// I couldnt get this query to work direct on membership table
const teamMembers = await prisma?.membership.findMany({
where: {
teamId: organizationId,
},
select: {
id: true,
role: true,
accepted: true,
user: {
select: {
id: true,
username: true,
email: true,
timeZone: true,
disableImpersonation: true,
completedOnboarding: true,
teams: {
select: {
team: {
select: {
id: true,
name: true,
slug: true,
},
},
},
},
},
},
},
cursor: cursor ? { id: cursor } : undefined,
take: limit + 1, // We take +1 as itll be used for the next cursor
orderBy: {
id: "asc",
},
});
let nextCursor: typeof cursor | undefined = undefined;
if (teamMembers && teamMembers.length > limit) {
const nextItem = teamMembers.pop();
nextCursor = nextItem!.id;
}
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
const members = teamMembers?.map((member) => {
return {
id: member.user.id,
username: member.user.username,
email: member.user.email,
timeZone: member.user.timeZone,
role: member.role,
accepted: member.accepted,
disableImpersonation: member.user.disableImpersonation,
completedOnboarding: member.user.completedOnboarding,
teams: member.user.teams
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
.map((team) => {
if (team.team.id === organizationId) return;
return {
id: team.team.id,
name: team.team.name,
slug: team.team.slug,
};
}),
};
});
return {
...team,
safeBio: markdownToSafeHTML(team.bio),
membership: {
role: membership?.role as MembershipRole,
accepted: membership?.accepted,
rows: members || [],
nextCursor,
meta: {
totalRowCount: getTotalMembers || 0,
},
};
};
@@ -0,0 +1,8 @@
import { z } from "zod";
export const ZListMembersSchema = z.object({
limit: z.number().min(1).max(100),
cursor: z.number().nullish(),
});
export type TListMembersSchema = z.infer<typeof ZListMembersSchema>;
+7
View File
@@ -0,0 +1,7 @@
import "@tanstack/react-table";
declare module "@tanstack/table-core" {
interface ColumnMeta<TData extends RowData, TValue> {
hasPermissions?: (data?: TData) => boolean; // We can conditionally render a column based on this callback -> User doesnt have admin for example.
}
}
+136
View File
@@ -0,0 +1,136 @@
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import * as React from "react";
import { classNames } from "@calcom/lib";
import { Dialog, DialogContent } from "../dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={classNames(
"bg-popover text-default flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:text-muted hover:bg-subtle [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3 py-2" cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={classNames(
"placeholder:text-muted hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default block flex h-[28px] w-full rounded-md rounded-md border bg-transparent px-3 py-1.5 text-sm text-sm leading-4 outline-none focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={classNames("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={classNames(
"text-default [&_[cmdk-group-heading]]:text-muted overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={classNames("bg-border -mx-1 h-px", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={classNames(
"aria-selected:bg-subtle aria-selected:text-emphasis relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={classNames("text-muted ml-auto text-xs tracking-widest", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
@@ -0,0 +1,116 @@
import type { Column } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { Check, Filter } from "lucide-react";
import { classNames } from "@calcom/lib";
import { Badge } from "../badge";
import { Button } from "../button";
import {
Command,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandSeparator,
} from "../command";
import { Popover, PopoverTrigger, PopoverContent } from "../popover";
interface DataTableFilter<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" size="sm" className="border-subtle h-8 rounded-md">
<Filter className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<div className="ml-2 hidden space-x-1 md:flex">
{selectedValues.size > 2 ? (
<Badge color="gray" className="rounded-sm px-1 font-normal">
{selectedValues.size}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
// TODO: It would be nice to pull these from data instead of options
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"
)}>
<Check className={classNames("h-4 w-4")} />
</div>
{option.icon && <option.icon className="text-muted mr-2 h-4 w-4" />}
<span>{option.label}</span>
{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>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,60 @@
import type { Table } from "@tanstack/react-table";
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-react";
import { Button } from "../button";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
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={ChevronsLeft}
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={ChevronLeft}>
<span className="sr-only">Go to previous page</span>
</Button>
<Button
color="secondary"
variant="icon"
StartIcon={ChevronRight}
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={ChevronsRight}
onClick={() => table.setPageIndex(table.getPageCount())}>
<span className="sr-only">Go to last page</span>
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { Table } from "@tanstack/react-table";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { Button } from "../button";
interface DataTableSelectionBarProps<TData> {
table: Table<TData>;
actions?: {
label: string;
onClick: () => void;
icon?: SVGComponent;
}[];
}
export function DataTableSelectionBar<TData>({ table, actions }: DataTableSelectionBarProps<TData>) {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
if (numberOfSelectedRows === 0) return null;
return (
<div className="bg-brand-default text-brand item-center absolute bottom-0 left-1/2 flex -translate-x-1/2 gap-4 rounded-lg p-2">
<div className="text-brand-subtle my-auto px-2">{numberOfSelectedRows} selected</div>
{actions?.map((action) => (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon} key={action.label}>
{action.label}
</Button>
))}
</div>
);
}
@@ -0,0 +1,76 @@
"use client";
import type { Table } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { X } from "lucide-react";
import { Button } from "../button";
import { Input } from "../form";
import { DataTableFilter } from "./DataTableFilter";
export type FilterableItems = {
title: string;
tableAccessor: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}[];
interface DataTableToolbarProps<TData> {
table: Table<TData>;
filterableItems?: FilterableItems;
searchKey?: string;
tableCTA?: React.ReactNode;
}
export function DataTableToolbar<TData>({
table,
filterableItems,
tableCTA,
searchKey,
}: 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;
return (
<div className="flex items-center justify-end space-x-2">
{searchKey && (
<Input
className="max-w-64 mb-0 mr-auto"
placeholder="Search"
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value)}
/>
)}
{isFiltered && (
<Button
color="minimal"
EndIcon={X}
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
Reset
</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>
);
}
@@ -0,0 +1,78 @@
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",
},
],
},
];
@@ -0,0 +1,100 @@
import { StopCircle, Users } from "lucide-react";
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: StopCircle,
},
];
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",
},
];
@@ -0,0 +1,27 @@
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/data-table" component={DataTable} />
<Title title="Data-table" suffix="Brief" subtitle="Version 2.0 — Last Update: 06 Jan 2023" />
## Definition
<Canvas>
<Story name="Datatable">
<DataTable columns={columns} data={dataTableDemousers} filterableItems={filterableItems} searchKey="username" selectionOptions={dataTableSelectionActions} />
</Story>
</Canvas>
+173
View File
@@ -0,0 +1,173 @@
import type {
ColumnDef,
ColumnFiltersState,
Row,
SortingState,
VisibilityState,
} from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import { useVirtual } from "react-virtual";
import type { SVGComponent } from "@calcom/types/SVGComponent";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
import { DataTableSelectionBar } from "./DataTableSelectionBar";
import type { FilterableItems } from "./DataTableToolbar";
import { DataTableToolbar } from "./DataTableToolbar";
export interface DataTableProps<TData, TValue> {
tableContainerRef: React.RefObject<HTMLDivElement>;
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
filterableItems?: FilterableItems;
selectionOptions?: {
label: string;
onClick: () => void;
icon?: SVGComponent;
}[];
tableCTA?: React.ReactNode;
isLoading?: boolean;
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
CTA?: React.ReactNode;
}
export function DataTable<TData, TValue>({
columns,
data,
filterableItems,
tableCTA,
searchKey,
selectionOptions,
tableContainerRef,
isLoading,
onScroll,
}: 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(),
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtual({
parentRef: tableContainerRef,
size: rows.length,
overscan: 10,
});
const { virtualItems: virtualRows, totalSize } = rowVirtualizer;
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
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}
tableCTA={tableCTA}
/>
<div
className="rounded-md border"
ref={tableContainerRef}
onScroll={onScroll}
style={{
height: "calc(100vh - 30vh)",
overflow: "auto",
}}>
<Table>
<TableHeader
style={{
position: "sticky",
top: 0,
zIndex: 1,
}}>
{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 && !isLoading ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
{row.getVisibleCells().map((cell) => {
return (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</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>
</Table>
</div>
{/* <DataTablePagination table={table} /> */}
<DataTableSelectionBar table={table} actions={selectionOptions} />
</div>
);
}
+1
View File
@@ -143,6 +143,7 @@ export function DialogFooter(props: {
return (
<div className={classNames("bg-default sticky bottom-0", props.className)}>
{props.showDivider && (
// TODO: the -mx-8 is causing overflow in the dialog buttons
<hr className={classNames("border-subtle -mx-8", props.customDividerClassNames)} />
)}
<div
@@ -1,4 +1,6 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { useId } from "@radix-ui/react-id";
import { Check } from "lucide-react";
import type { InputHTMLAttributes } from "react";
import React, { forwardRef } from "react";
@@ -14,6 +16,24 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
descriptionClassName?: string;
};
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={classNames(
"border-default data-[state=checked]:bg-brand-default data-[state=checked]:text-brand peer h-4 w-4 shrink-0 rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed",
className
)}
{...props}>
<CheckboxPrimitive.Indicator className={classNames("flex items-center justify-center text-current")}>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
const CheckboxField = forwardRef<HTMLInputElement, Props>(
({ label, description, error, disabled, ...rest }, ref) => {
const descriptionAsLabel = !label || rest.descriptionAsLabel;
@@ -79,4 +99,4 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
CheckboxField.displayName = "CheckboxField";
export default CheckboxField;
export { Checkbox, CheckboxField };
@@ -1,6 +1,6 @@
import { Canvas, Meta, Story, ArgsTable } from '@storybook/addon-docs';
import { Examples, Example, Note, Title,CustomArgsTable,VariantsTable,VariantRow } from '@calcom/storybook/components'
import Checkbox from './Checkbox';
import {CheckboxField as Checkbox} from './Checkbox';
<Meta title="UI/Form/Checkbox" component={Checkbox} />
@@ -1,3 +1,3 @@
export { default as Checkbox } from "./Checkbox";
export { Checkbox, CheckboxField } from "./Checkbox";
export { default as MultiSelectCheckbox } from "./MultiSelectCheckboxes";
export type { Option } from "./MultiSelectCheckboxes";
@@ -74,7 +74,7 @@ type DropdownMenuCheckboxItemProps = ComponentProps<(typeof DropdownMenuPrimitiv
export const DropdownMenuCheckboxItem = forwardRef<HTMLDivElement, DropdownMenuCheckboxItemProps>(
({ children, ...props }, forwardedRef) => {
return (
<DropdownMenuPrimitive.CheckboxItem {...props} ref={forwardedRef}>
<DropdownMenuPrimitive.CheckboxItem {...props} ref={forwardedRef} className="">
{children}
<DropdownMenuPrimitive.ItemIndicator>
<CheckCircle />
@@ -138,7 +138,8 @@ export const DropdownItem = (props: DropdownItemProps) => {
{...rest}
className={classNames(
"hover:text-emphasis text-default inline-flex w-full items-center space-x-2 px-3 py-2 disabled:cursor-not-allowed",
color === "destructive" ? "hover:bg-error hover:text-red-700" : "hover:bg-subtle"
color === "destructive" ? "hover:bg-error hover:text-red-700" : "hover:bg-subtle",
props.className
)}>
<>
{StartIcon && <StartIcon className="h-4 w-4" />}
+1 -1
View File
@@ -1,4 +1,4 @@
export { Checkbox, MultiSelectCheckbox } from "./checkbox";
export { Checkbox, MultiSelectCheckbox, CheckboxField } from "./checkbox";
export type { Option } from "./checkbox";
export { HintsOrErrors } from "./inputs/HintOrErrors";
export {
-2
View File
@@ -1,2 +0,0 @@
export { AnimatedPopover } from "./AnimatedPopover";
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";
+32
View File
@@ -0,0 +1,32 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as React from "react";
import { classNames } from "@calcom/lib";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={classNames(
"bg-default text-emphasis z-50 w-72 rounded-md border p-4 outline-none",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent };
export { AnimatedPopover } from "./AnimatedPopover";
export { default as MeetingTimeInTimezones } from "./MeetingTimeInTimezones";
+78
View File
@@ -0,0 +1,78 @@
import * as React from "react";
import { classNames } from "@calcom/lib";
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="w-full overflow-auto">
<table ref={ref} className={classNames("w-full caption-bottom text-sm", className)} {...props} />
</div>
)
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<thead ref={ref} className={classNames("[&_tr]:bg-subtle [&_tr]:border-b", className)} {...props} />
)
);
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tbody ref={ref} className={classNames("[&_tr:last-child]:border-0", className)} {...props} />
)
);
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
({ className, ...props }, ref) => (
<tfoot ref={ref} className={classNames("bg-default text-emphasis font-medium", className)} {...props} />
)
);
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={classNames("hover:bg-subtle data-[state=selected]:bg-subtle border-b", className)}
{...props}
/>
)
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<th
ref={ref}
className={classNames(
"text-default h-12 px-4 text-left align-middle font-medium [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
);
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
({ className, ...props }, ref) => (
<td
ref={ref}
className={classNames("text-default p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
)
);
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
({ className, ...props }, ref) => (
<caption ref={ref} className={classNames("text-default mt-4 text-sm", className)} {...props} />
)
);
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption };
+2
View File
@@ -8,6 +8,7 @@ export type { ButtonBaseProps, ButtonProps } from "./components/button";
export { ButtonGroup } from "./components/buttonGroup";
export {
Checkbox,
CheckboxField,
EmailField,
EmailInput,
FieldsetLegend,
@@ -135,3 +136,4 @@ export { CreateButton, CreateButtonWithTeamsList } from "./components/createButt
export { useCalcomTheme } from "./styles/useCalcomTheme";
export { ScrollableArea } from "./components/scrollable/ScrollableArea";
export { WizardLayout } from "./layouts/WizardLayout";
export { DataTable } from "./components/data-table";
+6 -1
View File
@@ -23,14 +23,18 @@
"@calcom/lib": "*",
"@calcom/trpc": "*",
"@formkit/auto-animate": "^1.0.0-beta.5",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.4",
"@radix-ui/react-popover": "^1.0.2",
"@radix-ui/react-portal": "^1.0.0",
"@radix-ui/react-select": "^0.1.1",
"@react-icons/all-files": "^4.1.0",
"@tanstack/react-query": "^4.3.9",
"@tanstack/react-table": "^8.9.3",
"@wojtekmaj/react-daterange-picker": "^3.3.1",
"class-variance-authority": "^0.4.0",
"cmdk": "^0.2.0",
"cmk": "^0.1.1",
"downshift": "^6.1.9",
"lucide-react": "^0.171.0",
"next": "^13.4.6",
@@ -39,7 +43,8 @@
"react-feather": "^2.0.10",
"react-hook-form": "^7.43.3",
"react-icons": "^4.4.0",
"react-select": "^5.7.0"
"react-select": "^5.7.0",
"react-virtual": "^2.10.4"
},
"devDependencies": {
"@calcom/config": "*",
+801 -54
View File
File diff suppressed because it is too large Load Diff