first commit
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { useCopy } from "@calcom/lib/hooks/useCopy";
|
||||
import type { BadgeProps } from "@calcom/ui";
|
||||
import { Badge, Button, Label } from "@calcom/ui";
|
||||
|
||||
type DisplayInfoType<T extends boolean> = {
|
||||
label: string;
|
||||
value: T extends true ? string[] : string;
|
||||
asBadge?: boolean;
|
||||
isArray?: T;
|
||||
displayCopy?: boolean;
|
||||
badgeColor?: BadgeProps["variant"];
|
||||
} & (T extends false
|
||||
? { displayCopy?: boolean; displayCount?: never }
|
||||
: { displayCopy?: never; displayCount?: number }); // Only show displayCopy if its not an array is false
|
||||
|
||||
export function DisplayInfo<T extends boolean>({
|
||||
label,
|
||||
value,
|
||||
asBadge,
|
||||
isArray,
|
||||
displayCopy,
|
||||
displayCount,
|
||||
badgeColor,
|
||||
}: DisplayInfoType<T>) {
|
||||
const { copyToClipboard, isCopied } = useCopy();
|
||||
const values = (isArray ? value : [value]) as string[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
|
||||
{label} {displayCount && `(${displayCount})`}
|
||||
</Label>
|
||||
<div className={classNames(asBadge ? "mt-0.5 flex space-x-2" : "flex flex-col")}>
|
||||
<>
|
||||
{values.map((v) => {
|
||||
const content = (
|
||||
<span
|
||||
className={classNames(
|
||||
"text-emphasis inline-flex items-center gap-1 font-normal leading-5",
|
||||
asBadge ? "text-xs" : "text-sm"
|
||||
)}>
|
||||
{v}
|
||||
{displayCopy && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="icon"
|
||||
onClick={() => copyToClipboard(v)}
|
||||
color="minimal"
|
||||
className="text-subtle rounded-md"
|
||||
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return asBadge ? (
|
||||
<Badge variant={badgeColor} size="sm">
|
||||
{content}
|
||||
</Badge>
|
||||
) : (
|
||||
content
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useSession } from "next-auth/react";
|
||||
import type { Dispatch } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { trpc, type RouterOutputs } from "@calcom/trpc/react";
|
||||
import {
|
||||
Form,
|
||||
TextField,
|
||||
ToggleGroup,
|
||||
TextAreaField,
|
||||
TimezoneSelect,
|
||||
Label,
|
||||
showToast,
|
||||
Avatar,
|
||||
ImageUploader,
|
||||
SheetHeader,
|
||||
SheetBody,
|
||||
SheetFooter,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { Action } from "../UserListTable";
|
||||
import { SheetFooterControls } from "./SheetFooterControls";
|
||||
import { useEditMode } from "./store";
|
||||
|
||||
type MembershipOption = {
|
||||
value: MembershipRole;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const editSchema = z.object({
|
||||
name: z.string(),
|
||||
username: z.string(),
|
||||
email: z.string().email(),
|
||||
avatar: z.string(),
|
||||
bio: z.string(),
|
||||
role: z.enum([MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER]),
|
||||
timeZone: z.string(),
|
||||
// schedules: z.array(z.string()),
|
||||
// teams: z.array(z.string()),
|
||||
});
|
||||
|
||||
type EditSchema = z.infer<typeof editSchema>;
|
||||
|
||||
export function EditForm({
|
||||
selectedUser,
|
||||
avatarUrl,
|
||||
domainUrl,
|
||||
dispatch,
|
||||
}: {
|
||||
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
|
||||
avatarUrl: string;
|
||||
domainUrl: string;
|
||||
dispatch: Dispatch<Action>;
|
||||
}) {
|
||||
const [setMutationLoading] = useEditMode((state) => [state.setMutationloading], shallow);
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const org = session?.data?.user?.org;
|
||||
const utils = trpc.useUtils();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(editSchema),
|
||||
defaultValues: {
|
||||
name: selectedUser?.name ?? "",
|
||||
username: selectedUser?.username ?? "",
|
||||
email: selectedUser?.email ?? "",
|
||||
avatar: avatarUrl,
|
||||
bio: selectedUser?.bio ?? "",
|
||||
role: selectedUser?.role ?? "",
|
||||
timeZone: selectedUser?.timeZone ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
const isOwner = org?.role === MembershipRole.OWNER;
|
||||
|
||||
const membershipOptions = useMemo<MembershipOption[]>(() => {
|
||||
const options: MembershipOption[] = [
|
||||
{
|
||||
value: MembershipRole.MEMBER,
|
||||
label: t("member"),
|
||||
},
|
||||
{
|
||||
value: MembershipRole.ADMIN,
|
||||
label: t("admin"),
|
||||
},
|
||||
];
|
||||
|
||||
if (isOwner) {
|
||||
options.push({
|
||||
value: MembershipRole.OWNER,
|
||||
label: t("owner"),
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [t, isOwner]);
|
||||
|
||||
const mutation = trpc.viewer.organizations.updateUser.useMutation({
|
||||
onSuccess: () => {
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
utils.viewer.organizations.listMembers.invalidate();
|
||||
showToast(t("profile_updated_successfully"), "success");
|
||||
},
|
||||
onError: (error) => {
|
||||
showToast(error.message, "error");
|
||||
},
|
||||
onSettled: () => {
|
||||
/**
|
||||
* /We need to do this as the submit button lives out side
|
||||
* the form for some complicated reason so we can't relay on mutationState
|
||||
*/
|
||||
setMutationLoading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const watchTimezone = form.watch("timeZone");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form
|
||||
form={form}
|
||||
id="edit-user-form"
|
||||
className="flex h-full flex-col"
|
||||
handleSubmit={(values) => {
|
||||
setMutationLoading(true);
|
||||
mutation.mutate({
|
||||
userId: selectedUser?.id ?? "",
|
||||
role: values.role,
|
||||
username: values.username,
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
avatar: values.avatar,
|
||||
bio: values.bio,
|
||||
timeZone: values.timeZone,
|
||||
});
|
||||
}}>
|
||||
<SheetHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => (
|
||||
<div className="flex items-center">
|
||||
<Avatar alt={`${selectedUser?.name} avatar`} imageSrc={value} size="lg" />
|
||||
<div className="ml-4">
|
||||
<ImageUploader
|
||||
target="avatar"
|
||||
id="avatar-upload"
|
||||
buttonMsg={t("change_avatar")}
|
||||
handleAvatarChange={(newAvatar) => {
|
||||
form.setValue("avatar", newAvatar, { shouldDirty: true });
|
||||
}}
|
||||
imageSrc={value || undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<div className="space-between flex flex-col leading-none">
|
||||
<span className="text-emphasis text-lg font-semibold">
|
||||
{selectedUser?.name ?? "Nameless User"}
|
||||
</span>
|
||||
<p className="subtle text-sm font-normal">
|
||||
{domainUrl}/{selectedUser?.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetBody className="mt-6 flex h-full flex-col space-y-3">
|
||||
<TextField label={t("username")} {...form.register("username")} />
|
||||
<TextField label={t("name")} {...form.register("name")} />
|
||||
<TextField label={t("email")} {...form.register("email")} />
|
||||
|
||||
<TextAreaField label={t("bio")} {...form.register("bio")} className="min-h-52" />
|
||||
<div>
|
||||
<Label>{t("role")}</Label>
|
||||
<ToggleGroup
|
||||
isFullWidth
|
||||
defaultValue={selectedUser?.role ?? "MEMBER"}
|
||||
value={form.watch("role")}
|
||||
options={membershipOptions}
|
||||
onValueChange={(value: EditSchema["role"]) => {
|
||||
form.setValue("role", value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("timezone")}</Label>
|
||||
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
|
||||
</div>
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetFooterControls />
|
||||
</SheetFooter>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { Dispatch } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import { useOrgBranding } from "@calcom/ee/organizations/context/provider";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import {
|
||||
Avatar,
|
||||
Label,
|
||||
Loader,
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetBody,
|
||||
Skeleton,
|
||||
SheetHeader,
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from "@calcom/ui";
|
||||
|
||||
import type { Action, State } from "../UserListTable";
|
||||
import { DisplayInfo } from "./DisplayInfo";
|
||||
import { EditForm } from "./EditUserForm";
|
||||
import { SheetFooterControls } from "./SheetFooterControls";
|
||||
import { useEditMode } from "./store";
|
||||
|
||||
export function EditUserSheet({ state, dispatch }: { state: State; dispatch: Dispatch<Action> }) {
|
||||
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({
|
||||
userId: selectedUser?.id,
|
||||
});
|
||||
|
||||
const avatarURL = `${orgBranding?.fullDomain ?? WEBAPP_URL}/${loadedUser?.username}/avatar.png`;
|
||||
|
||||
const schedulesNames = loadedUser?.schedules && loadedUser?.schedules.map((s) => s.name);
|
||||
const teamNames =
|
||||
loadedUser?.teams && loadedUser?.teams.map((t) => `${t.name} ${!t.accepted ? "(pending)" : ""}`);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
open={true}
|
||||
onOpenChange={() => {
|
||||
setEditMode(false);
|
||||
dispatch({ type: "CLOSE_MODAL" });
|
||||
}}>
|
||||
<SheetContent>
|
||||
{!isPending && loadedUser ? (
|
||||
<>
|
||||
{!editMode ? (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<Avatar
|
||||
asChild
|
||||
className="h-[36px] w-[36px]"
|
||||
alt={`${loadedUser?.name} avatar`}
|
||||
imageSrc={loadedUser.avatarUrl}
|
||||
/>
|
||||
<SheetTitle>
|
||||
<Skeleton loading={isPending} as="p" waitForTranslation={false}>
|
||||
<span className="text-emphasis text-lg font-semibold">
|
||||
{loadedUser?.name ?? "Nameless User"}
|
||||
</span>
|
||||
</Skeleton>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
<Skeleton loading={isPending} as="p" waitForTranslation={false}>
|
||||
<p className="subtle text-sm font-normal">
|
||||
{orgBranding?.fullDomain ?? WEBAPP_URL}/{loadedUser?.username}
|
||||
</p>
|
||||
</Skeleton>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<SheetBody className="flex flex-col space-y-5">
|
||||
<DisplayInfo label={t("email")} value={loadedUser?.email ?? ""} displayCopy />
|
||||
<DisplayInfo
|
||||
label={t("bio")}
|
||||
badgeColor="gray"
|
||||
value={loadedUser?.bio ? loadedUser?.bio : t("user_has_no_bio")}
|
||||
/>
|
||||
<DisplayInfo label={t("role")} value={loadedUser?.role ?? ""} asBadge badgeColor="blue" />
|
||||
<DisplayInfo label={t("timezone")} value={loadedUser?.timeZone ?? ""} />
|
||||
<div className="flex flex-col">
|
||||
<Label className="text-subtle mb-1 text-xs font-semibold uppercase leading-none">
|
||||
{t("availability_schedules")}
|
||||
</Label>
|
||||
<div className="flex flex-col">
|
||||
{schedulesNames
|
||||
? schedulesNames.map((scheduleName) => (
|
||||
<span
|
||||
key={scheduleName}
|
||||
className="text-emphasis inline-flex items-center gap-1 text-sm font-normal leading-5">
|
||||
{scheduleName}
|
||||
</span>
|
||||
))
|
||||
: t("user_has_no_schedules")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DisplayInfo
|
||||
label={t("teams")}
|
||||
displayCount={teamNames?.length ?? 0}
|
||||
value={
|
||||
teamNames && teamNames?.length === 0 ? [t("user_isnt_in_any_teams")] : teamNames ?? "" // TS wtf
|
||||
}
|
||||
asBadge={teamNames && teamNames?.length > 0}
|
||||
/>
|
||||
</SheetBody>
|
||||
<SheetFooter>
|
||||
<SheetFooterControls />
|
||||
</SheetFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EditForm
|
||||
selectedUser={loadedUser}
|
||||
avatarUrl={loadedUser.avatarUrl ?? avatarURL}
|
||||
domainUrl={orgBranding?.fullDomain ?? WEBAPP_URL}
|
||||
dispatch={dispatch}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { SheetClose, Button } from "@calcom/ui";
|
||||
|
||||
import { useEditMode } from "./store";
|
||||
|
||||
function EditModeFooter() {
|
||||
const { t } = useLocale();
|
||||
const setEditMode = useEditMode((state) => state.setEditMode);
|
||||
const isPending = useEditMode((state) => state.mutationLoading);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
color="secondary"
|
||||
type="button"
|
||||
className="justify-center md:w-1/5"
|
||||
onClick={() => {
|
||||
setEditMode(false);
|
||||
}}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
|
||||
<Button type="submit" className="w-full justify-center" form="edit-user-form" loading={isPending}>
|
||||
{t("update")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MoreInfoFooter() {
|
||||
const { t } = useLocale();
|
||||
const setEditMode = useEditMode((state) => state.setEditMode);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SheetClose asChild>
|
||||
<Button color="secondary" type="button" className="w-full justify-center lg:w-1/5">
|
||||
{t("close")}
|
||||
</Button>
|
||||
</SheetClose>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditMode(true);
|
||||
}}
|
||||
className="w-full justify-center gap-2"
|
||||
variant="icon"
|
||||
key="EDIT_BUTTON"
|
||||
StartIcon="pencil">
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SheetFooterControls() {
|
||||
const editMode = useEditMode((state) => state.editMode);
|
||||
return <>{editMode ? <EditModeFooter /> : <MoreInfoFooter />}</>;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
interface EditModeState {
|
||||
editMode: boolean;
|
||||
setEditMode: (editMode: boolean) => void;
|
||||
mutationLoading: boolean;
|
||||
setMutationloading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const useEditMode = create<EditModeState>((set) => ({
|
||||
editMode: false,
|
||||
setEditMode: (editMode) => set({ editMode }),
|
||||
mutationLoading: false,
|
||||
setMutationloading: (loading) => set({ mutationLoading: loading }),
|
||||
}));
|
||||
Reference in New Issue
Block a user