From a4c1df365819d859e136b2267ffbfd93a4d2d17a Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Thu, 16 Nov 2023 21:48:24 +0530 Subject: [PATCH] refactor: team settings redesign (#12230) Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Co-authored-by: Peer Richelsen --- .../pages/settings/my-account/appearance.tsx | 10 +- apps/web/public/static/locales/en/common.json | 4 + .../ee/components/BrandColorsForm.tsx | 130 +++++ .../ee/components/CommonSkeletonLoaders.tsx | 31 + .../pages/settings/appearance.tsx | 190 +----- .../organizations/pages/settings/profile.tsx | 19 - .../components/DisableTeamImpersonation.tsx | 43 +- .../components/MakeTeamPrivateSwitch.tsx | 46 +- .../ee/teams/pages/team-appearance-view.tsx | 373 ++++++------ .../ee/teams/pages/team-members-view.tsx | 6 +- .../ee/teams/pages/team-profile-view.tsx | 550 ++++++++++-------- .../routers/viewer/teams/update.handler.ts | 10 + .../routers/viewer/teams/update.schema.ts | 2 +- .../image-uploader/ImageUploader.tsx | 12 +- 14 files changed, 698 insertions(+), 728 deletions(-) create mode 100644 packages/features/ee/components/BrandColorsForm.tsx create mode 100644 packages/features/ee/components/CommonSkeletonLoaders.tsx diff --git a/apps/web/pages/settings/my-account/appearance.tsx b/apps/web/pages/settings/my-account/appearance.tsx index f2475a02a9..a8b479974a 100644 --- a/apps/web/pages/settings/my-account/appearance.tsx +++ b/apps/web/pages/settings/my-account/appearance.tsx @@ -250,10 +250,7 @@ const AppearanceView = ({ /> {lightModeError ? (
- +
) : null} @@ -282,10 +279,7 @@ const AppearanceView = ({ /> {darkModeError ? (
- +
) : null} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 94506cb885..69906ee67e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -56,6 +56,8 @@ "a_refund_failed": "A refund failed", "awaiting_payment_subject": "Awaiting Payment: {{title}} on {{date}}", "meeting_awaiting_payment": "Your meeting is awaiting payment", + "dark_theme_contrast_error":"Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", + "light_theme_contrast_error":"Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible.", "payment_not_created_error": "Payment could not be created", "couldnt_charge_card_error": "Could not charge card for Payment", "no_available_users_found_error": "No available users found. Could you try another time slot?", @@ -615,6 +617,7 @@ "hide_book_a_team_member_description": "Hide Book a Team Member Button from your public pages.", "danger_zone": "Danger zone", "account_deletion_cannot_be_undone":"Be Careful. Account deletion cannot be undone.", + "team_deletion_cannot_be_undone":"Be Careful. Team deletion cannot be undone", "back": "Back", "cancel": "Cancel", "cancel_all_remaining": "Cancel all remaining", @@ -1413,6 +1416,7 @@ "slot_length": "Slot length", "booking_appearance": "Booking Appearance", "appearance_team_description": "Manage settings for your team's booking appearance", + "appearance_org_description": "Manage settings for your organization's booking appearance", "only_owner_change": "Only the owner of this team can make changes to the team's booking ", "team_disable_cal_branding_description": "Removes any {{appName}} related brandings, i.e. 'Powered by {{appName}}'", "invited_by_team": "{{teamName}} has invited you to join their team as a {{role}}", diff --git a/packages/features/ee/components/BrandColorsForm.tsx b/packages/features/ee/components/BrandColorsForm.tsx new file mode 100644 index 0000000000..a3181ac24d --- /dev/null +++ b/packages/features/ee/components/BrandColorsForm.tsx @@ -0,0 +1,130 @@ +import { useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; +import { classNames } from "@calcom/lib"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; +import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { Button, ColorPicker, SettingsToggle, Alert } from "@calcom/ui"; + +type BrandColorsFormValues = { + brandColor: string; + darkBrandColor: string; +}; + +const BrandColorsForm = ({ + onSubmit, + brandColor, + darkBrandColor, +}: { + onSubmit: (values: BrandColorsFormValues) => void; + brandColor: string | undefined; + darkBrandColor: string | undefined; +}) => { + const { t } = useLocale(); + const brandColorsFormMethods = useFormContext(); + const { + formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty }, + handleSubmit, + } = brandColorsFormMethods; + + const [isCustomBrandColorChecked, setIsCustomBrandColorChecked] = useState( + brandColor !== DEFAULT_LIGHT_BRAND_COLOR || darkBrandColor !== DEFAULT_DARK_BRAND_COLOR + ); + const [darkModeError, setDarkModeError] = useState(false); + const [lightModeError, setLightModeError] = useState(false); + return ( +
+ { + setIsCustomBrandColorChecked(checked); + if (!checked) { + onSubmit({ + brandColor: DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: DEFAULT_DARK_BRAND_COLOR, + }); + } + }} + childrenClassName="lg:ml-0" + switchContainerClassName={classNames( + "py-6 px-4 sm:px-6 border-subtle rounded-xl border", + isCustomBrandColorChecked && "rounded-b-none" + )}> +
+ ( +
+

{t("light_brand_color")}

+ { + try { + checkWCAGContrastColor("#ffffff", value); + setLightModeError(false); + brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); + } catch (err) { + setLightModeError(false); + } + }} + /> + {lightModeError ? ( +
+ +
+ ) : null} +
+ )} + /> + + ( +
+

{t("dark_brand_color")}

+ { + try { + checkWCAGContrastColor("#101010", value); + setDarkModeError(false); + brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); + } catch (err) { + setDarkModeError(true); + } + }} + /> + {darkModeError ? ( +
+ +
+ ) : null} +
+ )} + /> +
+ + + +
+
+ ); +}; + +export default BrandColorsForm; diff --git a/packages/features/ee/components/CommonSkeletonLoaders.tsx b/packages/features/ee/components/CommonSkeletonLoaders.tsx new file mode 100644 index 0000000000..38bdaf8785 --- /dev/null +++ b/packages/features/ee/components/CommonSkeletonLoaders.tsx @@ -0,0 +1,31 @@ +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; +import { Meta, SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui"; + +export const AppearanceSkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+ +
+
+
+
+
+
+
+
+ + +
+ + +
+
+ + + +
+ + ); +}; diff --git a/packages/features/ee/organizations/pages/settings/appearance.tsx b/packages/features/ee/organizations/pages/settings/appearance.tsx index 04a004e9e3..507b38dab1 100644 --- a/packages/features/ee/organizations/pages/settings/appearance.tsx +++ b/packages/features/ee/organizations/pages/settings/appearance.tsx @@ -1,60 +1,20 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Controller, useForm, useFormContext } from "react-hook-form"; +import { useForm } from "react-hook-form"; import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; +import BrandColorsForm from "@calcom/features/ee/components/BrandColorsForm"; +import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders"; import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import ThemeLabel from "@calcom/features/settings/ThemeLabel"; import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout"; -import { classNames } from "@calcom/lib"; import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { APP_NAME } from "@calcom/lib/constants"; -import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours"; 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, - ColorPicker, - Form, - Meta, - showToast, - SkeletonButton, - SkeletonContainer, - SkeletonText, - SettingsToggle, - Alert, -} from "@calcom/ui"; - -const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { - return ( - - -
- -
-
-
- - - -
-
- - -
- - -
-
- - - -
-
- ); -}; +import { Button, Form, Meta, showToast, SettingsToggle } from "@calcom/ui"; type BrandColorsFormValues = { brandColor: string; @@ -100,11 +60,13 @@ const OrgAppearanceView = ({ await utils.viewer.organizations.listCurrent.invalidate(); showToast(t("your_team_updated_successfully"), "success"); - brandColorsFormMethods.reset({ - brandColor: res.data.brandColor as string, - darkBrandColor: res.data.darkBrandColor as string, - }); - resetOrgThemeReset({ theme: res.data.theme as string | undefined }); + if (res) { + brandColorsFormMethods.reset({ + brandColor: res.data.brandColor as string, + darkBrandColor: res.data.darkBrandColor as string, + }); + resetOrgThemeReset({ theme: res.data.theme as string | undefined }); + } }, }); @@ -116,7 +78,7 @@ const OrgAppearanceView = ({ {isAdminOrOwner ? ( @@ -175,8 +137,8 @@ const OrgAppearanceView = ({ }}> @@ -190,7 +152,7 @@ const OrgAppearanceView = ({ setHideBrandingValue(checked); mutation.mutate({ hideBranding: checked }); }} - switchContainerClassName="border-subtle mt-6 rounded-xl border py-6 px-4 sm:px-6" + switchContainerClassName="mt-6" />
) : ( @@ -202,126 +164,6 @@ const OrgAppearanceView = ({ ); }; -const BrandColorsForm = ({ - onSubmit, - orgBrandColor, - orgDarkBrandColor, -}: { - onSubmit: (values: BrandColorsFormValues) => void; - orgBrandColor: string | undefined; - orgDarkBrandColor: string | undefined; -}) => { - const { t } = useLocale(); - const brandColorsFormMethods = useFormContext(); - const { - formState: { isSubmitting: isBrandColorsFormSubmitting, isDirty: isBrandColorsFormDirty }, - handleSubmit, - } = brandColorsFormMethods; - - const [isCustomBrandColorChecked, setIsCustomBrandColorChecked] = useState( - orgBrandColor !== DEFAULT_LIGHT_BRAND_COLOR || orgDarkBrandColor !== DEFAULT_DARK_BRAND_COLOR - ); - const [darkModeError, setDarkModeError] = useState(false); - const [lightModeError, setLightModeError] = useState(false); - return ( -
- { - setIsCustomBrandColorChecked(checked); - if (!checked) { - onSubmit({ - brandColor: DEFAULT_LIGHT_BRAND_COLOR, - darkBrandColor: DEFAULT_DARK_BRAND_COLOR, - }); - } - }} - childrenClassName="lg:ml-0" - switchContainerClassName={classNames( - "py-6 px-4 sm:px-6 border-subtle rounded-xl border", - isCustomBrandColorChecked && "rounded-b-none" - )}> -
- ( -
-

{t("light_brand_color")}

- { - try { - checkWCAGContrastColor("#ffffff", value); - setLightModeError(false); - brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true }); - } catch (err) { - setLightModeError(false); - } - }} - /> - {lightModeError ? ( -
- -
- ) : null} -
- )} - /> - - ( -
-

{t("dark_brand_color")}

- { - try { - checkWCAGContrastColor("#101010", value); - setDarkModeError(false); - brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true }); - } catch (err) { - setDarkModeError(true); - } - }} - /> - {darkModeError ? ( -
- -
- ) : null} -
- )} - /> -
- - - -
-
- ); -}; - const OrgAppearanceViewWrapper = () => { const router = useRouter(); const { t } = useLocale(); @@ -332,7 +174,7 @@ const OrgAppearanceViewWrapper = () => { }); if (isLoading) { - return ; + return ; } if (!currentOrg) return null; diff --git a/packages/features/ee/organizations/pages/settings/profile.tsx b/packages/features/ee/organizations/pages/settings/profile.tsx index e6d652dfd1..ba40ed4c69 100644 --- a/packages/features/ee/organizations/pages/settings/profile.tsx +++ b/packages/features/ee/organizations/pages/settings/profile.tsx @@ -141,25 +141,6 @@ const OrgProfileView = () => {
)} - {/* Disable Org disbanding */} - {/*
-
{t("danger_zone")}
- {currentOrganisation?.user.role === "OWNER" ? ( - - - - - - {t("disband_org_confirmation_message")} - - - ) : null} */} {/* LEAVE ORG should go above here ^ */} diff --git a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx index c514210f27..7e27a7c355 100644 --- a/packages/features/ee/teams/components/DisableTeamImpersonation.tsx +++ b/packages/features/ee/teams/components/DisableTeamImpersonation.tsx @@ -1,7 +1,8 @@ -import { classNames } from "@calcom/lib"; +import { useState } from "react"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { showToast, Switch } from "@calcom/ui"; +import { showToast, SettingsToggle } from "@calcom/ui"; const DisableTeamImpersonation = ({ teamId, @@ -24,35 +25,23 @@ const DisableTeamImpersonation = ({ await utils.viewer.teams.getMembershipbyUser.invalidate(); }, }); + const [allowImpersonation, setAllowImpersonation] = useState(!query.data?.disableImpersonation ?? true); if (query.isLoading) return <>; return ( <> -
-
-
-

- {t("user_impersonation_heading")} -

-
-

- {t("team_impersonation_description")} -

-
-
- { - mutation.mutate({ teamId, memberId, disableImpersonation: !isChecked }); - }} - /> -
-
+ { + setAllowImpersonation(_allowImpersonation); + mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation }); + }} + switchContainerClassName="mt-6" + /> ); }; diff --git a/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx b/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx index 73041200e6..3a1bd4a368 100644 --- a/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx +++ b/packages/features/ee/teams/components/MakeTeamPrivateSwitch.tsx @@ -1,7 +1,8 @@ -import { classNames } from "@calcom/lib"; +import { useState } from "react"; + import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; -import { showToast, Switch } from "@calcom/ui"; +import { showToast, SettingsToggle } from "@calcom/ui"; const MakeTeamPrivateSwitch = ({ teamId, @@ -26,34 +27,23 @@ const MakeTeamPrivateSwitch = ({ }, }); + const [isTeamPrivate, setTeamPrivate] = useState(isPrivate); + return ( <> -
-
-
-

- {t("make_team_private")} -

-
-

- {t("make_team_private_description")} -

-
-
- { - mutation.mutate({ id: teamId, isPrivate: isChecked }); - }} - /> -
-
+ { + setTeamPrivate(checked); + mutation.mutate({ id: teamId, isPrivate: checked }); + }} + switchContainerClassName="mt-6" + data-testid="make-team-private-check" + /> ); }; diff --git a/packages/features/ee/teams/pages/team-appearance-view.tsx b/packages/features/ee/teams/pages/team-appearance-view.tsx index 3ce599ab4c..a8cc2ac269 100644 --- a/packages/features/ee/teams/pages/team-appearance-view.tsx +++ b/packages/features/ee/teams/pages/team-appearance-view.tsx @@ -1,73 +1,188 @@ import { useRouter } from "next/navigation"; -import { Controller, useForm } from "react-hook-form"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import BrandColorsForm from "@calcom/features/ee/components/BrandColorsForm"; +import { AppearanceSkeletonLoader } from "@calcom/features/ee/components/CommonSkeletonLoaders"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { APP_NAME } from "@calcom/lib/constants"; +import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import { MembershipRole } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc/react"; -import { - Button, - ColorPicker, - Form, - Meta, - showToast, - SkeletonButton, - SkeletonContainer, - SkeletonText, - Switch, -} from "@calcom/ui"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { Button, Form, Meta, showToast, SettingsToggle } from "@calcom/ui"; import ThemeLabel from "../../../settings/ThemeLabel"; import { getLayout } from "../../../settings/layouts/SettingsLayout"; -const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { - return ( - - -
-
- - - -
-
- - -
- - - - -
-
- ); -}; - -interface TeamAppearanceValues { - hideBranding: boolean; - hideBookATeamMember: boolean; +type BrandColorsFormValues = { brandColor: string; darkBrandColor: string; - theme: string | null | undefined; -} +}; -const ProfileView = () => { - const params = useParamsWithFallback(); +type ProfileViewProps = { team: RouterOutputs["viewer"]["teams"]["get"] }; + +const ProfileView = ({ team }: ProfileViewProps) => { const { t } = useLocale(); - const router = useRouter(); const utils = trpc.useContext(); + const [hideBrandingValue, setHideBrandingValue] = useState(team?.hideBranding ?? false); + const [hideBookATeamMember, setHideBookATeamMember] = useState(team?.hideBookATeamMember ?? false); + + const themeForm = useForm<{ theme: string | null | undefined }>({ + defaultValues: { + theme: team?.theme, + }, + }); + + const { + formState: { isSubmitting: isThemeSubmitting, isDirty: isThemeDirty }, + reset: resetTheme, + } = themeForm; + + const brandColorsFormMethods = useForm({ + defaultValues: { + brandColor: team?.brandColor || DEFAULT_LIGHT_BRAND_COLOR, + darkBrandColor: team?.darkBrandColor || DEFAULT_DARK_BRAND_COLOR, + }, + }); + + const { reset: resetBrandColors } = brandColorsFormMethods; + const mutation = trpc.viewer.teams.update.useMutation({ onError: (err) => { showToast(err.message, "error"); }, - async onSuccess() { + async onSuccess(res) { await utils.viewer.teams.get.invalidate(); + if (res) { + resetTheme({ theme: res.theme }); + resetBrandColors({ brandColor: res.brandColor, darkBrandColor: res.darkBrandColor }); + } + showToast(t("your_team_updated_successfully"), "success"); }, }); + const onBrandColorsFormSubmit = (values: BrandColorsFormValues) => { + mutation.mutate({ ...values, id: team.id }); + }; + + const isAdmin = + team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN); + + return ( + <> + + {isAdmin ? ( + <> +
{ + mutation.mutate({ + id: team.id, + theme: values.theme || null, + }); + }}> +
+
+

{t("theme")}

+

{t("theme_applies_note")}

+
+
+
+ + + +
+ + + +
+ +
{ + onBrandColorsFormSubmit(values); + }}> + + + +
+ { + setHideBrandingValue(checked); + mutation.mutate({ id: team.id, hideBranding: checked }); + }} + /> + + { + setHideBookATeamMember(checked); + mutation.mutate({ id: team.id, hideBookATeamMember: checked }); + }} + /> +
+ + ) : ( +
+ {t("only_owner_change")} +
+ )} + + ); +}; + +const ProfileViewWrapper = () => { + const router = useRouter(); + const params = useParamsWithFallback(); + + const { t } = useLocale(); + const { data: team, isLoading } = trpc.viewer.teams.get.useQuery( { teamId: Number(params.id) }, { @@ -77,170 +192,16 @@ const ProfileView = () => { } ); - const form = useForm({ - defaultValues: { - theme: team?.theme, - brandColor: team?.brandColor, - darkBrandColor: team?.darkBrandColor, - hideBranding: team?.hideBranding, - }, - }); + if (isLoading) + return ( + + ); - const isAdmin = - team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN); + if (!team) return null; - if (isLoading) { - return ; - } - return ( - <> - - {isAdmin ? ( -
{ - mutation.mutate({ - id: team.id, - ...values, - theme: values.theme || null, - }); - }}> -
-
-

{t("theme")}

-

{t("theme_applies_note")}

-
-
-
- - - -
- -
-
-
-

{t("custom_brand_colors")}

-

{t("customize_your_brand_colors")}

-
-
- -
- ( -
-

{t("light_brand_color")}

- form.setValue("brandColor", value, { shouldDirty: true })} - /> -
- )} - /> - ( -
-

{t("dark_brand_color")}

- form.setValue("darkBrandColor", value, { shouldDirty: true })} - /> -
- )} - /> -
-
- -
-
-
- -

- {t("team_disable_cal_branding_description", { appName: APP_NAME })} -

-
- -
- ( - { - form.setValue("hideBranding", isChecked); - }} - /> - )} - /> -
-
-
-
- -

{t("hide_book_a_team_member_description")}

-
-
- ( - { - form.setValue("hideBookATeamMember", isChecked); - }} - /> - )} - /> -
-
-
- -
- ) : ( -
- {t("only_owner_change")} -
- )} - - ); + return ; }; -ProfileView.getLayout = getLayout; +ProfileViewWrapper.getLayout = getLayout; -export default ProfileView; +export default ProfileViewWrapper; diff --git a/packages/features/ee/teams/pages/team-members-view.tsx b/packages/features/ee/teams/pages/team-members-view.tsx index da8128d734..6b7ff9b14a 100644 --- a/packages/features/ee/teams/pages/team-members-view.tsx +++ b/packages/features/ee/teams/pages/team-members-view.tsx @@ -170,7 +170,6 @@ const MembersView = () => { {((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && ( <> -
)} @@ -183,10 +182,7 @@ const MembersView = () => { )} {team && (isAdmin || isOrgAdminOrOwner) && ( - <> -
- - + )} {showMemberInvitationModal && team && ( diff --git a/packages/features/ee/teams/pages/team-profile-view.tsx b/packages/features/ee/teams/pages/team-profile-view.tsx index 69179974d0..294ce36fbd 100644 --- a/packages/features/ee/teams/pages/team-profile-view.tsx +++ b/packages/features/ee/teams/pages/team-profile-view.tsx @@ -9,6 +9,7 @@ import { z } from "zod"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains"; +import SectionBottomActions from "@calcom/features/settings/SectionBottomActions"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -19,6 +20,7 @@ import objectKeys from "@calcom/lib/objectKeys"; import slugify from "@calcom/lib/slugify"; import turndown from "@calcom/lib/turndownService"; import { MembershipRole } from "@calcom/prisma/enums"; +import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; import { Avatar, @@ -36,6 +38,8 @@ import { SkeletonContainer, SkeletonText, TextField, + SkeletonAvatar, + SkeletonButton, } from "@calcom/ui"; import { ExternalLink, Link as LinkIcon, LogOut, Trash2 } from "@calcom/ui/components/icon"; @@ -51,10 +55,31 @@ const teamProfileFormSchema = z.object({ message: "Url can only have alphanumeric characters(a-z, 0-9) and hyphen(-) symbol.", }) .min(1, { message: "Url cannot be left empty" }), - logo: z.string(), + logo: z.string().nullable(), bio: z.string(), }); +type FormValues = z.infer; + +const SkeletonLoader = ({ title, description }: { title: string; description: string }) => { + return ( + + +
+
+ + +
+ + + + + +
+
+ ); +}; + const ProfileView = () => { const params = useParamsWithFallback(); const teamId = Number(params.id); @@ -62,27 +87,11 @@ const ProfileView = () => { const router = useRouter(); const utils = trpc.useContext(); const session = useSession(); - const [firstRender, setFirstRender] = useState(true); - const orgBranding = useOrgBranding(); useLayoutEffect(() => { document.body.focus(); }, []); - const mutation = trpc.viewer.teams.update.useMutation({ - onError: (err) => { - showToast(err.message, "error"); - }, - async onSuccess() { - await utils.viewer.teams.get.invalidate(); - showToast(t("your_team_updated_successfully"), "success"); - }, - }); - - const form = useForm({ - resolver: zodResolver(teamProfileFormSchema), - }); - const { data: team, isLoading } = trpc.viewer.teams.get.useQuery( { teamId, includeTeamLogo: true }, { @@ -90,17 +99,6 @@ const ProfileView = () => { onError: () => { router.push("/settings"); }, - onSuccess: (team) => { - if (team) { - form.setValue("name", team.name || ""); - form.setValue("slug", team.slug || ""); - form.setValue("bio", team.bio || ""); - form.setValue("logo", team.logo || ""); - if (team.slug === null && (team?.metadata as Prisma.JsonObject)?.requestedSlug) { - form.setValue("slug", ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || ""); - } - } - }, } ); @@ -131,17 +129,6 @@ const ProfileView = () => { }, }); - const publishMutation = trpc.viewer.teams.publish.useMutation({ - async onSuccess(data: { url?: string }) { - if (data.url) { - router.push(data.url); - } - }, - async onError(err) { - showToast(err.message, "error"); - }, - }); - function deleteTeam() { if (team?.id) deleteTeamMutation.mutate({ teamId: team.id }); } @@ -154,233 +141,284 @@ const ProfileView = () => { }); } + if (isLoading) { + return ; + } + return ( <> - - {!isLoading ? ( - <> - {isAdmin ? ( -
{ - if (team) { - const variables = { - name: values.name, - slug: values.slug, - bio: values.bio, - logo: values.logo, - }; - objectKeys(variables).forEach((key) => { - if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key]; - }); - mutation.mutate({ id: team.id, ...variables }); - } - }}> - {!team.parent && ( - <> -
- ( - <> - -
- { - form.setValue("logo", newLogo); - }} - imageSrc={value} - /> -
- - )} - /> -
-
- - )} + - ( -
- { - form.setValue("name", e?.target.value); - }} - /> -
- )} - /> - ( -
- { - form.clearErrors("slug"); - form.setValue("slug", slugify(e?.target.value, true)); - }} - /> -
- )} - /> -
- - md.render(form.getValues("bio") || "")} - setText={(value: string) => form.setValue("bio", turndown(value))} - excludedToolbarItems={["blockType"]} - disableLists - firstRender={firstRender} - setFirstRender={setFirstRender} - /> -
-

{t("team_description")}

- - {IS_TEAM_BILLING_ENABLED && - team.slug === null && - (team.metadata as Prisma.JsonObject)?.requestedSlug && ( - - )} - - ) : ( -
-
-
- -

{team?.name}

-
- {team && !isBioEmpty && ( - <> - -
- - )} -
-
- - {t("preview")} - - { - navigator.clipboard.writeText(permalink); - showToast("Copied to clipboard", "success"); - }}> - {t("copy_link_team")} - -
-
- )} -
- -
{t("danger_zone")}
- {team?.membership.role === "OWNER" ? ( - - - - - - {t("disband_team_confirmation_message")} - - - ) : ( - - - - - - {t("leave_team_confirmation_message")} - - - )} - + {isAdmin ? ( + ) : ( - <> - -
-
- -
- -
+
+
+
+ +

{team?.name}

-
- -
- -
-
- -
- -
-
-
- -
- - -
- -
- -
-
-
- + {team && !isBioEmpty && ( + <> + +
+ + )} +
+
+ + {t("preview")} + + { + navigator.clipboard.writeText(permalink); + showToast("Copied to clipboard", "success"); + }}> + {t("copy_link_team")} + +
+
+ )} + +
+ + {team?.membership.role === "OWNER" && ( +

{t("team_deletion_cannot_be_undone")}

+ )} +
+ {team?.membership.role === "OWNER" ? ( + + + + + + + + {t("disband_team_confirmation_message")} + + + ) : ( + + + + + + + + {t("leave_team_confirmation_message")} + + )} ); }; +export type TeamProfileFormProps = { team: RouterOutputs["viewer"]["teams"]["get"] }; + +const TeamProfileForm = ({ team }: TeamProfileFormProps) => { + const utils = trpc.useContext(); + const { t } = useLocale(); + const router = useRouter(); + + const mutation = trpc.viewer.teams.update.useMutation({ + onError: (err) => { + showToast(err.message, "error"); + }, + async onSuccess(res) { + reset({ + logo: (res?.logo || "") as string, + name: (res?.name || "") as string, + bio: (res?.bio || "") as string, + slug: res?.slug as string, + }); + await utils.viewer.teams.get.invalidate(); + showToast(t("your_team_updated_successfully"), "success"); + }, + }); + + const defaultValues: FormValues = { + name: team?.name || "", + logo: team?.logo || "", + bio: team?.bio || "", + slug: team?.slug || ((team?.metadata as Prisma.JsonObject)?.requestedSlug as string) || "", + }; + + const form = useForm({ + defaultValues, + resolver: zodResolver(teamProfileFormSchema), + }); + + const [firstRender, setFirstRender] = useState(true); + const orgBranding = useOrgBranding(); + + const { + formState: { isSubmitting, isDirty }, + reset, + } = form; + + const isDisabled = isSubmitting || !isDirty; + + const publishMutation = trpc.viewer.teams.publish.useMutation({ + async onSuccess(data: { url?: string }) { + if (data.url) { + router.push(data.url); + } + }, + async onError(err) { + showToast(err.message, "error"); + }, + }); + + return ( +
{ + if (team) { + const variables = { + name: values.name, + slug: values.slug, + bio: values.bio, + logo: values.logo, + }; + objectKeys(variables).forEach((key) => { + if (variables[key as keyof typeof variables] === team?.[key]) delete variables[key]; + }); + mutation.mutate({ id: team.id, ...variables }); + } + }}> +
+ {!team.parent && ( +
+ { + const showRemoveLogoButton = !!value; + + return ( + <> + +
+ { + form.setValue("logo", newLogo, { shouldDirty: true }); + }} + triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"} + imageSrc={value ?? undefined} + /> + {showRemoveLogoButton && ( + + )} +
+ + ); + }} + /> +
+ )} + + ( +
+ { + form.setValue("name", e?.target.value, { shouldDirty: true }); + }} + /> +
+ )} + /> + ( +
+ { + form.clearErrors("slug"); + form.setValue("slug", slugify(e?.target.value, true), { shouldDirty: true }); + }} + /> +
+ )} + /> +
+ + md.render(form.getValues("bio") || "")} + setText={(value: string) => form.setValue("bio", turndown(value), { shouldDirty: true })} + excludedToolbarItems={["blockType"]} + disableLists + firstRender={firstRender} + setFirstRender={setFirstRender} + /> +
+

{t("team_description")}

+
+ + + {IS_TEAM_BILLING_ENABLED && + team.slug === null && + (team.metadata as Prisma.JsonObject)?.requestedSlug && ( + + )} + +
+ ); +}; + ProfileView.getLayout = getLayout; export default ProfileView; diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index e578eefc2c..ff5692f0ac 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -86,4 +86,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { // Sync Services: Close.com if (prevTeam) closeComUpdateTeam(prevTeam, updatedTeam); + + return { + logo: updatedTeam.logo, + name: updatedTeam.name, + bio: updatedTeam.bio, + slug: updatedTeam.slug, + theme: updatedTeam.theme, + brandColor: updatedTeam.brandColor, + darkBrandColor: updatedTeam.darkBrandColor, + }; }; diff --git a/packages/trpc/server/routers/viewer/teams/update.schema.ts b/packages/trpc/server/routers/viewer/teams/update.schema.ts index 5c88cd84f5..3eb58f5024 100644 --- a/packages/trpc/server/routers/viewer/teams/update.schema.ts +++ b/packages/trpc/server/routers/viewer/teams/update.schema.ts @@ -6,7 +6,7 @@ export const ZUpdateInputSchema = z.object({ id: z.number(), bio: z.string().optional(), name: z.string().optional(), - logo: z.string().optional(), + logo: z.string().nullable().optional(), slug: z .string() .transform((val) => slugify(val.trim())) diff --git a/packages/ui/components/image-uploader/ImageUploader.tsx b/packages/ui/components/image-uploader/ImageUploader.tsx index c8d13b716e..1f2c5089e5 100644 --- a/packages/ui/components/image-uploader/ImageUploader.tsx +++ b/packages/ui/components/image-uploader/ImageUploader.tsx @@ -163,9 +163,12 @@ export default function ImageUploader({ return ( !opened && setFile(null) // unset file on close - }> + onOpenChange={(opened) => { + // unset file on close + if (!opened) { + setFile(null); + } + }}>
+ {t("cancel")} + showCroppedImage(croppedAreaPixels)}> {t("save")} - {t("cancel")}