Files
cal.diy/packages/features/users/components/UserTable/EditSheet/EditUserForm.tsx
T
d6546c3107 feat: upgrade tailwind v4 (#24598)
* chore: refactor config files to prevent migration tool errors

* refactor: upgrade with the tailwind migration tool

* chore: restore pre-commit command + mc

* refactor(wip): update dependencies and migrate to Tailwind CSS v4 (mainly web)

* chore: resolve Tailwind v4 migration conflicts from merging main

* chore: remove unused Tailwind packages from config and update dependencies for v4 migration

* chore: uncomment Tailwind CSS utility classes in globals.css

* fix: resolve token conflicts between calcom and coss ui

* fix: textarea scrollbar

* Fix CUI-16

* fix: added @tailwindcss/forms plugin and cleaned up CSS classes in various components to remove unnecessary dark mode styles

* fix: selects and inputs of different sizes

* fix: remove unnecessary leading-20 class from modal titles in various components

* fix: update Checkbox component styles to remove unnecessary border on checked state

* fix: clean up styles in RequiresConfirmationController, Checkbox, and Radio components to enhance consistency

* fix: update button and filter component styles to remove unnecessary rounded classes for consistency

* fix: calendar

* fix: update KBarSearch

* fix: refine styles in Empty and Checkbox components for improved consistency

* Fix focus state email input

* fix: update button hover and active states to use 'not-disabled' instead of 'enabled'

* fix: line-height issues

* fix: update class name for muted background in BookingListItem component

* fix: sidebar spacing

* chore: update class names to use new Tailwind CSS color utilities

* fix embed

* chore: upgrade Tailwind CSS to version 4.1.16 and update related dependencies

* Map css variables and add a playground test for heavy css customization

* suggestion for coss-ui

* refactor: update CSS variable usage and clean up styles

- Replace instances of `--cal-brand-color` with `--cal-brand` in embed-related HTML files.
- Remove the now-unnecessary `addAppCssVars` function from the embed core.
- Import theme tokens in the embed core styles for better consistency.
- Clean up whitespace and formatting in CSS files for improved readability.
- Add a comment in `tokens.css` regarding its usage in both embed and webapp contexts.

* Handle within tokens.css instead of fixing coss-ui

* Remove initial, not needed. Also, remove tailwind.config.js as tailwidn scans the html automaically

* fix: examples app breaking

* fix: modal not resizing correctly

* feat: upgrade atoms to tailwind v4

* fix: atoms build breaking

* fix: atoms build breaking

* chore: upgrate examples/base to tailwind 4

* chore: update globals.css

* fix: add missing scheduler css variables

* fix: PlatformAdditionalCalendarSelector

* chore: update global styles

* chore: update tailwindcss and postcss dependencies to stable versions

* chore: remove unneeded class

* fix: dialog and toast animation

* fix: replace flex-shrink-0 with shrink-0 for consistent styling in various components

* fix: dialog modal for Apple connect

* add margin in SaveFilterSegmentButton

* Fix radix button nested states

* add cursor pointer to buttons but keep dsabled state

* Fix commandK selectors and adds cursor pointer

* Fix teams filter

* fix - round checkboxes

* fix filter checkbox

* fix select indicator's margin

* command group font size

* style: fix badge and tooltip radius

* chore: remove unneeded files

* Delete PR_REVIEW_MANAGED_EVENT_REASSIGNMENT.md

* remove ui-playground leftover

* fix: add missing react phone input styles in atoms

* Delete managed-event-reassignment-flow-and-architecture.mermaid

* fix: inter font not loading

* Add theme to skeleton container so that it can support dark mode

* fix: create custom stack-y-* utilities post tw4 upgrade

* fix: typo

* fix: atoms stack class + remove unused css file

* fix default radius valiue

* fix space-y in embed

* fix skeleton background

* Hardcode radius values to match production

* fix border in embed

* add missing externalThemeClass

* feat: create a custom stack-y-* utility

* fix: add stack utility to atom global css

* fix: Skeleton loader class modalbox

* Add stack-y utility in embed

* fix: add missing stack utilities in atoms globals.css

* update yarn.lock

* add popover portla

* update

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
2025-11-25 17:32:28 -03:00

497 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/features/components/timezone-select";
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,
}: {
selectedUser: RouterOutputs["viewer"]["organizations"]["getUser"];
avatarUrl: string;
domainUrl: string;
dispatch: Dispatch<UserTableAction>;
}) {
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">
<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 />
<TextField label={t("name")} {...form.register("name")} className="mb-6" />
<TextField label={t("username")} {...form.register("username")} className="mb-6" />
<TextAreaField label={t("about")} {...form.register("bio")} className="min-h-24 mb-6" />
<div className="mb-6">
<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 className="mb-6">
<Label>{t("timezone")}</Label>
<TimezoneSelect value={watchTimezone ?? "America/Los_Angeles"} />
</div>
<Divider />
<AttributesList selectedUserId={selectedUser?.id} />
</SheetBody>
<SheetFooter>
<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">
{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;
}