Files
calendar/apps/web/modules/users/components/UserTable/EditSheet/EditUserForm.tsx
T
Eunjae LeeGitHubClaude Opus 4.5Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
81e4241f85 refactor: apply biome formatting to apps/web (#27692)
* refactor: apply biome formatting to apps/web (batch 1)

Formats apps/web non-modules directories:
- apps/web/app
- apps/web/lib
- apps/web/pages
- apps/web/styles
- apps/web/server
- apps/web/test
- Root-level .ts and .mjs files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 2)

Formats smaller apps/web/modules directories:
- onboarding, data-table, users, apps, auth
- integration-attribute-sync, shell, availability
- feature-flags, team, webhooks, getting-started
- feature-opt-in, troubleshooter, schedules, notifications
- signup-view.tsx

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules (batch 3)

Formats medium apps/web/modules directories:
- bookings
- event-types
- insights
- embed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/modules/ee (batch 4)

Formats apps/web/modules/ee directory.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web (batch 5)

Formats remaining apps/web directories:
- apps/web/modules/settings
- apps/web/modules/booking-audit
- apps/web/playwright

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to apps/web/components (batch 6)

Formats remaining apps/web/components files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Formats newly added/modified files from main merge:
- WorkflowStepContainer.tsx (resolved merge conflicts)
- Newly moved files (blocklist, form-builder, schedules, etc.)
- Other files modified in main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: apply biome formatting to new apps/web files from main

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-10 23:25:24 +05:30

506 lines
18 KiB
TypeScript

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 { TimezoneSelect } from "@calcom/web/modules/timezone/components/TimezoneSelect";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
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 { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Divider } from "@calcom/ui/components/divider";
import {
InputField,
TextAreaField,
SelectField,
Form,
Label,
TextField,
ToggleGroup,
} from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { SheetHeader, SheetBody, SheetFooter, SheetTitle } from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import type { UserTableAction } from "../types";
import { useEditMode } from "./store";
interface MembershipOption {
value: MembershipRole | string;
label: string;
isCustomRole?: boolean;
}
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.union([z.nativeEnum(MembershipRole), z.string()]),
timeZone: timeZoneSchema,
attributes: z.array(attributeSchema).optional(),
});
type EditSchema = z.infer<typeof editSchema>;
export function EditForm({
selectedUser,
avatarUrl,
domainUrl,
dispatch,
canEditAttributesForUser,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch<UserTableAction>;
canEditAttributesForUser?: boolean;
}) {
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<EditSchema>({
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 { data: teamRoles, isLoading: isLoadingRoles } = trpc.viewer.pbac.getTeamRoles.useQuery(
// @ts-expect-error this query is only ran when we have an orgId so can ignore this
{ teamId: org?.id },
{
enabled: !!org?.id, // Only enable the query when we have a valid team ID
staleTime: 30000, // Cache for 30 seconds
}
);
const membershipOptions = useMemo<MembershipOption[]>(() => {
// Add custom roles if they exist
if (teamRoles?.length > 0) {
const roles: MembershipOption[] = [];
// Add custom roles
teamRoles.forEach((role) => {
roles.push({
value: role.id,
label: role.name,
isCustomRole: true,
});
});
return roles;
}
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, teamRoles]);
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}
className="flex h-full flex-col"
handleSubmit={(values) => {
setMutationLoading(true);
mutation.mutate({
userId: selectedUser?.id ?? "",
role: values.role as MembershipRole,
username: values.username,
name: values.name,
email: values.email,
avatar: values.avatar,
bio: values.bio,
timeZone: values.timeZone,
// @ts-expect-error they're there in local types but for some reason it errors?
attributeOptions: values.attributes
? { userId: selectedUser?.id ?? "", attributes: values.attributes }
: undefined,
});
setEditMode(false);
}}>
<SheetHeader>
<SheetTitle>{t("update_profile")}</SheetTitle>
</SheetHeader>
<SheetBody className="bg-cal-muted border-subtle mt-6 gap-4 rounded-xl border p-4 stack-y-6">
<div className="">
<Controller
control={form.control}
name="avatar"
render={({ field: { value } }) => (
<div className="flex items-center">
<Avatar alt={`${selectedUser?.name} avatar`} imageSrc={value} size="mdLg" />
<div className="ml-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("change_avatar")}
buttonSize="sm"
handleAvatarChange={(newAvatar) => {
form.setValue("avatar", newAvatar, { shouldDirty: true });
}}
imageSrc={value || undefined}
/>
</div>
</div>
)}
/>
</div>
<Divider />
<div className="stack-y-3">
<TextField label={t("name")} {...form.register("name")} />
<TextField label={t("username")} {...form.register("username")} />
<TextAreaField label={t("about")} {...form.register("bio")} />
<div>
<Label>{t("role")}</Label>
{teamRoles?.length > 0 ? (
<SelectField
defaultValue={membershipOptions.find(
(option) => option.value === (selectedUser?.role ?? "MEMBER")
)}
value={membershipOptions.find((option) => option.value === form.watch("role"))}
options={membershipOptions}
onChange={(option) => {
if (option) {
form.setValue("role", option.value);
}
}}
/>
) : (
<ToggleGroup
isFullWidth
defaultValue={selectedUser?.role ?? "MEMBER"}
value={form.watch("role")}
options={membershipOptions}
onValueChange={(value) => {
form.setValue("role", value);
}}
/>
)}
</div>
<div>
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
</div>
{canEditAttributesForUser && (
<>
<Divider />
<AttributesList selectedUserId={selectedUser?.id} />
</>
)}
</SheetBody>
<SheetFooter>
<Button
color="secondary"
type="button"
className="justify-center"
onClick={() => {
setEditMode(false);
}}>
{t("cancel")}
</Button>
<Button type="submit" className="justify-center">
{t("update")}
</Button>
</SheetFooter>
</Form>
</>
);
}
type Attribute = z.infer<typeof attributeSchema>;
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<DefaultValueType>(() => {
if (!usersAttributes || usersAttributesPending || !enabledAttributes) return {};
return enabledAttributes.reduce<DefaultValueType>((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 (
<div className="flex flex-col overflow-visible">
<div className="flex flex-col gap-3 rounded-lg">
{attributeFieldState.error && (
<p className="text-error mb-2 block text-sm font-medium leading-none">
{JSON.stringify(attributeFieldState.error)}
</p>
)}
{enabledAttributes.map((attr, index) => (
<Controller
name={`attributes.${index}`}
control={control}
key={attr.id}
defaultValue={defaultValues[`attributes.${index}`]}
render={({ field: { value, ...field } }) => {
const fieldValue = value as Attribute | undefined | null;
return (
<div className="flex w-full items-center justify-center" key={attr.id}>
{["TEXT", "NUMBER"].includes(attr.type) && (
<InputField
{...field}
containerClassName="w-full"
labelClassName="text-emphasis mb-2 block text-sm font-medium leading-none"
label={attr.name}
type={attr.type === "TEXT" ? "text" : "number"}
value={fieldValue?.value || ""}
onChange={(e) => {
field.onChange({
id: attr.id,
value: e.target.value,
});
}}
/>
)}
{["SINGLE_SELECT", "MULTI_SELECT"].includes(attr.type) && (
<div className="w-full">
<SelectField
isDisabled={attr.isLocked}
name={field.name}
containerClassName="w-full"
isMulti={attr.type === "MULTI_SELECT"}
labelProps={{
className: "text-emphasis mb-2 block text-sm font-medium leading-none",
}}
label={attr.name}
options={getOptionsByAttributeId(attr.id)}
value={attr.type === "MULTI_SELECT" ? fieldValue?.options : fieldValue?.options?.[0]}
onChange={(value) => {
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 && (
<div className="mt-3 stack-y-2">
<Label>Weights</Label>
<div className="">
{fieldValue.options.map((option, idx) => {
return (
<>
<div key={option.value} className="flex items-center justify-between">
<Label
htmlFor={`attributes.${index}.options.${idx}.weight`}
className="text-subtle">
{option.label}
</Label>
<InputField
noLabel
name={`attributes.${index}.options.${idx}.weight`}
type="number"
step={10}
value={option.weight || 100}
onChange={(e) => {
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={<span className="text-subtle text-sm">%</span>}
/>
</div>
</>
);
})}
</div>
</div>
)}
</div>
)}
</div>
);
}}
/>
))}
</div>
</div>
);
}
/**
* 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;
}