import { zodResolver } from "@hookform/resolvers/zod"; import { useSession } from "next-auth/react"; import type { Dispatch } from "react"; import { useMemo, useState } from "react"; import { Controller, useForm, useFormContext } from "react-hook-form"; import { z } from "zod"; import { emailSchema } from "@calcom/lib/emailSchema"; 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, InputField, TextAreaField, TimezoneSelect, Label, showToast, Avatar, ImageUploader, SelectField, SheetHeader, SheetBody, SheetFooter, Button, SheetTitle, Divider, } from "@calcom/ui"; import type { UserTableAction } from "../types"; import { useEditMode } from "./store"; type MembershipOption = { value: MembershipRole; label: string; }; const stringOrNumber = z.string().or(z.number()); const attributeSchema = z.object({ id: z.string(), options: z .array( z.object({ label: z.string().optional(), value: stringOrNumber.optional(), weight: z.number().optional(), createdByDSyncId: z.string().nullable().optional(), }) ) .optional(), value: stringOrNumber.optional(), }); const editSchema = z.object({ name: z.string(), username: z.string(), email: emailSchema, 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()), attributes: z.array(attributeSchema).optional(), }); type EditSchema = z.infer; export function EditForm({ selectedUser, avatarUrl, domainUrl, dispatch, }: { selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"]; avatarUrl: string; domainUrl: string; dispatch: Dispatch; }) { const setEditMode = useEditMode((state) => state.setEditMode); const [mutationLoading, setMutationLoading] = useState(false); 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(() => { 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 ( <>
{ 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, // @ts-expect-error theyre there in local types but for some reason it errors? attributeOptions: values.attributes ? // @ts-expect-error same as above { userId: selectedUser?.id ?? "", attributes: values.attributes } : undefined, }); setEditMode(false); }}> {t("update_profile")}
(
{ form.setValue("avatar", newAvatar, { shouldDirty: true }); }} imageSrc={value || undefined} />
)} />
{ form.setValue("role", value); }} />
); } type Attribute = z.infer; type DefaultValueType = { [key: `attributes.${number}`]: Attribute; }; function AttributesList(props: { selectedUserId: number }) { const { data: usersAttributes, isPending: usersAttributesPending } = trpc.viewer.attributes.getByUserId.useQuery({ userId: props.selectedUserId, }); const { data: attributes } = trpc.viewer.attributes.list.useQuery(); const enabledAttributes = attributes?.filter((attr) => attr.enabled); const { t } = useLocale(); const { control, getFieldState } = useFormContext(); const getOptionsByAttributeId = (attributeId: string) => { const attribute = attributes?.find((attr) => attr.id === attributeId); return attribute ? attribute.options.map((option) => ({ value: option.id, label: option.value, })) : []; }; const defaultValues = useMemo(() => { if (!usersAttributes || usersAttributesPending || !enabledAttributes) return {}; return enabledAttributes.reduce((acc, enabledAttr, index) => { const attr = usersAttributes.find((attr) => attr.id === enabledAttr.id); const key = `attributes.${index}` as const; if (!attr) { acc[key] = { id: enabledAttr.id, value: "", options: [] }; } else if (attr.type === "MULTI_SELECT") { acc[key] = { id: attr.id, options: attr.options.map((option) => ({ label: option.value, value: option.id, createdByDSyncId: option.createdByDSyncId ?? null, weight: option.weight ?? 100, })), }; } else if (attr.type === "SINGLE_SELECT") { acc[key] = { id: attr.id, options: [ { label: attr.options[0]?.value, value: attr.options[0]?.id, createdByDSyncId: attr.options[0]?.createdByDSyncId ?? null, weight: attr.options[0]?.weight ?? 100, }, ], }; } else if (attr.type === "TEXT") { acc[key] = { id: attr.id, value: attr.options[0]?.value || "", }; } return acc; }, {}); }, [usersAttributes, usersAttributesPending, enabledAttributes]); if (!enabledAttributes || !usersAttributes) return null; const attributeFieldState = getFieldState("attributes"); return (
{attributeFieldState.error && (

{JSON.stringify(attributeFieldState.error)}

)} {enabledAttributes.map((attr, index) => ( { const fieldValue = value as Attribute | undefined | null; return (
{["TEXT", "NUMBER"].includes(attr.type) && ( { field.onChange({ id: attr.id, value: e.target.value, }); }} /> )} {["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
{ if (!value) return; const valueAsArray = value instanceof Array ? value : [value]; const updatedOptions = attr.type === "MULTI_SELECT" ? valueAsArray.map((v) => ({ label: v.label, value: v.value, weight: v.weight || 100, })) : [ { label: valueAsArray[0].label, value: valueAsArray[0].value, weight: valueAsArray[0].weight || 100, }, ]; field.onChange({ id: attr.id, options: getOptionsEnsuringNotOwnedByCalcomNotRemoved({ earlierOptions: fieldValue?.options || [], updatedOptions, }), }); }} /> {attr.isWeightsEnabled && fieldValue?.options && (
{fieldValue.options.map((option, idx) => { return ( <>
{ const newWeight = parseFloat(e.target.value) || 1; const newOptions = fieldValue?.options?.map((opt, i) => i === idx ? { ...opt, weight: newWeight } : opt ); field.onChange({ id: attr.id, options: newOptions, }); }} addOnSuffix={%} />
); })}
)}
)}
); }} /> ))}
); } /** * Ensures that options that are not owned by cal.com are not removed * Such options are created by dsync and removed only through corresponding dsync */ function getOptionsEnsuringNotOwnedByCalcomNotRemoved< // Before assigning this option it can't have createdByDSyncId set TOptionToChoose extends { value: string | number | undefined }, // Already set option can have createdByDSyncId set TOptionAlreadySet extends { value?: string | number | undefined; createdByDSyncId?: string | null | undefined; } >({ earlierOptions, updatedOptions, }: { earlierOptions: TOptionAlreadySet[]; updatedOptions: TOptionToChoose[]; }) { const optionsNotOwnedByCalcom = earlierOptions.filter((option) => !!option.createdByDSyncId); const newUniqueOptionsPlusNotOwnedByCalcom = [...optionsNotOwnedByCalcom, ...updatedOptions].filter( (option, index, self) => index === self.findIndex((o) => o.value === option.value && o.value) ); return newUniqueOptionsPlusNotOwnedByCalcom; }