refactor: team settings redesign (#12230)

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Udit Takkar 2023-11-16 21:48:24 +05:30 committed by GitHub
parent 0b96ef5476
commit a4c1df3658
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 698 additions and 728 deletions

View File

@ -250,10 +250,7 @@ const AppearanceView = ({
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<Alert severity="warning" message={t("light_theme_contrast_error")} />
</div>
) : null}
</div>
@ -282,10 +279,7 @@ const AppearanceView = ({
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
</div>
) : null}
</div>

View File

@ -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}}",

View File

@ -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 (
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
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"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={brandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={brandColor || DEFAULT_LIGHT_BRAND_COLOR}
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert severity="warning" message={t("light_theme_contrast_error")} />
</div>
) : null}
</div>
)}
/>
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={darkBrandColor || DEFAULT_DARK_BRAND_COLOR}
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert severity="warning" message={t("dark_theme_contrast_error")} />
</div>
) : null}
</div>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
);
};
export default BrandColorsForm;

View File

@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<SkeletonText className="h-8 w-1/3" />
</div>
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
<div className="flex w-full items-center justify-center gap-6">
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
<div className="bg-emphasis h-32 flex-1 animate-pulse rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};

View File

@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<SkeletonText className="h-8 w-1/3" />
</div>
<div className="border-subtle space-y-6 border-x px-4 py-6 sm:px-6">
<div className="flex items-center justify-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
</div>
<div className="rounded-b-xl">
<SectionBottomActions align="end">
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</SectionBottomActions>
</div>
</SkeletonContainer>
);
};
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 = ({
<LicenseRequired>
<Meta
title={t("appearance")}
description={t("appearance_team_description")}
description={t("appearance_org_description")}
borderInShellHeader={false}
/>
{isAdminOrOwner ? (
@ -175,8 +137,8 @@ const OrgAppearanceView = ({
}}>
<BrandColorsForm
onSubmit={onBrandColorsFormSubmit}
orgBrandColor={currentOrg?.brandColor}
orgDarkBrandColor={currentOrg?.darkBrandColor}
brandColor={currentOrg?.brandColor}
darkBrandColor={currentOrg?.darkBrandColor}
/>
</Form>
@ -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"
/>
</div>
) : (
@ -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 (
<div className="mt-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("custom_brand_colors")}
description={t("customize_your_brand_colors")}
checked={isCustomBrandColorChecked}
onCheckedChange={(checked) => {
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"
)}>
<div className="border-subtle flex flex-col gap-6 border-x p-6">
<Controller
name="brandColor"
control={brandColorsFormMethods.control}
defaultValue={orgBrandColor}
render={() => (
<div>
<p className="text-default mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={orgBrandColor || DEFAULT_LIGHT_BRAND_COLOR}
resetDefaultValue={DEFAULT_LIGHT_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#ffffff", value);
setLightModeError(false);
brandColorsFormMethods.setValue("brandColor", value, { shouldDirty: true });
} catch (err) {
setLightModeError(false);
}
}}
/>
{lightModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Light Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
<Controller
name="darkBrandColor"
control={brandColorsFormMethods.control}
defaultValue={orgDarkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-default mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={orgDarkBrandColor || DEFAULT_DARK_BRAND_COLOR}
resetDefaultValue={DEFAULT_DARK_BRAND_COLOR}
onChange={(value) => {
try {
checkWCAGContrastColor("#101010", value);
setDarkModeError(false);
brandColorsFormMethods.setValue("darkBrandColor", value, { shouldDirty: true });
} catch (err) {
setDarkModeError(true);
}
}}
/>
{darkModeError ? (
<div className="mt-4">
<Alert
severity="warning"
message="Dark Theme color doesn't pass contrast check. We recommend you change this colour so your buttons will be more visible."
/>
</div>
) : null}
</div>
)}
/>
</div>
<SectionBottomActions align="end">
<Button
disabled={isBrandColorsFormSubmitting || !isBrandColorsFormDirty}
color="primary"
type="submit">
{t("update")}
</Button>
</SectionBottomActions>
</SettingsToggle>
</div>
);
};
const OrgAppearanceViewWrapper = () => {
const router = useRouter();
const { t } = useLocale();
@ -332,7 +174,7 @@ const OrgAppearanceViewWrapper = () => {
});
if (isLoading) {
return <SkeletonLoader title={t("appearance")} description={t("appearance_team_description")} />;
return <AppearanceSkeletonLoader title={t("appearance")} description={t("appearance_org_description")} />;
}
if (!currentOrg) return null;

View File

@ -141,25 +141,6 @@ const OrgProfileView = () => {
</div>
</div>
)}
{/* Disable Org disbanding */}
{/* <hr className="border-subtle my-8 border" />
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
{currentOrganisation?.user.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
{t("disband_org")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_org")}
confirmBtnText={t("confirm")}
onConfirm={deleteTeam}>
{t("disband_org_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : null} */}
{/* LEAVE ORG should go above here ^ */}
</>
</LicenseRequired>

View File

@ -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 (
<>
<div className="flex flex-col justify-between sm:flex-row">
<div>
<div className="flex flex-row items-center">
<h2
className={classNames(
"font-cal mb-0.5 text-sm font-semibold leading-6",
disabled ? "text-muted " : "text-emphasis "
)}>
{t("user_impersonation_heading")}
</h2>
</div>
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-default")}>
{t("team_impersonation_description")}
</p>
</div>
<div className="mt-5 sm:mt-0 sm:self-center">
<Switch
disabled={disabled}
defaultChecked={!query.data?.disableImpersonation}
onCheckedChange={(isChecked) => {
mutation.mutate({ teamId, memberId, disableImpersonation: !isChecked });
}}
/>
</div>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("user_impersonation_heading")}
disabled={disabled || mutation?.isLoading}
description={t("team_impersonation_description")}
checked={allowImpersonation}
onCheckedChange={(_allowImpersonation) => {
setAllowImpersonation(_allowImpersonation);
mutation.mutate({ teamId, memberId, disableImpersonation: !_allowImpersonation });
}}
switchContainerClassName="mt-6"
/>
</>
);
};

View File

@ -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 (
<>
<div className="flex flex-col justify-between sm:flex-row">
<div>
<div className="flex flex-row items-center">
<h2
className={classNames(
"font-cal mb-0.5 text-sm font-semibold leading-6",
disabled ? "text-muted " : "text-emphasis "
)}>
{t("make_team_private")}
</h2>
</div>
<p className={classNames("text-sm leading-5 ", disabled ? "text-gray-300" : "text-default")}>
{t("make_team_private_description")}
</p>
</div>
<div className="mt-5 sm:mt-0 sm:self-center">
<Switch
disabled={disabled}
data-testid="make-team-private-check"
defaultChecked={isPrivate}
onCheckedChange={(isChecked) => {
mutation.mutate({ id: teamId, isPrivate: isChecked });
}}
/>
</div>
</div>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("make_team_private")}
disabled={disabled || mutation?.isLoading}
description={t("make_team_private_description")}
checked={isTeamPrivate}
onCheckedChange={(checked) => {
setTeamPrivate(checked);
mutation.mutate({ id: teamId, isPrivate: checked });
}}
switchContainerClassName="mt-6"
data-testid="make-team-private-check"
/>
</>
);
};

View File

@ -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 (
<SkeletonContainer>
<Meta title={title} description={description} />
<div className="mb-8 mt-6 space-y-6">
<div className="flex items-center">
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
<SkeletonButton className="mr-6 h-32 w-48 rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
<SkeletonText className="h-8 w-1/3" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
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<BrandColorsFormValues>({
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 (
<>
<Meta
title={t("booking_appearance")}
description={t("appearance_team_description")}
borderInShellHeader={false}
/>
{isAdmin ? (
<>
<Form
form={themeForm}
handleSubmit={(values) => {
mutation.mutate({
id: team.id,
theme: values.theme || null,
});
}}>
<div className="border-subtle mt-6 flex items-center rounded-t-xl border p-6 text-sm">
<div>
<p className="font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={team.theme === null}
register={themeForm.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={team.theme === "light"}
register={themeForm.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={team.theme === "dark"}
register={themeForm.register}
/>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isThemeSubmitting || !isThemeDirty}
type="submit"
data-testid="update-org-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<Form
form={brandColorsFormMethods}
handleSubmit={(values) => {
onBrandColorsFormSubmit(values);
}}>
<BrandColorsForm
onSubmit={onBrandColorsFormSubmit}
brandColor={team?.brandColor}
darkBrandColor={team?.darkBrandColor}
/>
</Form>
<div className="mt-6 flex flex-col gap-6">
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("disable_cal_branding", { appName: APP_NAME })}
disabled={mutation?.isLoading}
description={t("removes_cal_branding", { appName: APP_NAME })}
checked={hideBrandingValue}
onCheckedChange={(checked) => {
setHideBrandingValue(checked);
mutation.mutate({ id: team.id, hideBranding: checked });
}}
/>
<SettingsToggle
toggleSwitchAtTheEnd={true}
title={t("hide_book_a_team_member")}
disabled={mutation?.isLoading}
description={t("hide_book_a_team_member_description", { appName: APP_NAME })}
checked={hideBookATeamMember ?? false}
onCheckedChange={(checked) => {
setHideBookATeamMember(checked);
mutation.mutate({ id: team.id, hideBookATeamMember: checked });
}}
/>
</div>
</>
) : (
<div className="border-subtle rounded-md border p-5">
<span className="text-default text-sm">{t("only_owner_change")}</span>
</div>
)}
</>
);
};
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<TeamAppearanceValues>({
defaultValues: {
theme: team?.theme,
brandColor: team?.brandColor,
darkBrandColor: team?.darkBrandColor,
hideBranding: team?.hideBranding,
},
});
if (isLoading)
return (
<AppearanceSkeletonLoader title={t("appearance")} description={t("appearance_team_description")} />
);
const isAdmin =
team && (team.membership.role === MembershipRole.OWNER || team.membership.role === MembershipRole.ADMIN);
if (!team) return null;
if (isLoading) {
return <SkeletonLoader title={t("booking_appearance")} description={t("appearance_team_description")} />;
}
return (
<>
<Meta title={t("booking_appearance")} description={t("appearance_team_description")} />
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
mutation.mutate({
id: team.id,
...values,
theme: values.theme || null,
});
}}>
<div className="mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("theme")}</p>
<p className="text-default">{t("theme_applies_note")}</p>
</div>
</div>
<div className="flex flex-col justify-between sm:flex-row">
<ThemeLabel
variant="system"
value={null}
label={t("theme_system")}
defaultChecked={team.theme === null}
register={form.register}
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={team.theme === "light"}
register={form.register}
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={team.theme === "dark"}
register={form.register}
/>
</div>
<hr className="border-subtle my-8" />
<div className="text-default mb-6 flex items-center text-sm">
<div>
<p className="font-semibold">{t("custom_brand_colors")}</p>
<p className="mt-0.5 leading-5">{t("customize_your_brand_colors")}</p>
</div>
</div>
<div className="block justify-between sm:flex">
<Controller
name="brandColor"
control={form.control}
defaultValue={team.brandColor}
render={() => (
<div>
<p className="text-emphasis mb-2 block text-sm font-medium">{t("light_brand_color")}</p>
<ColorPicker
defaultValue={team.brandColor}
resetDefaultValue="#292929"
onChange={(value) => form.setValue("brandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
<Controller
name="darkBrandColor"
control={form.control}
defaultValue={team.darkBrandColor}
render={() => (
<div className="mt-6 sm:mt-0">
<p className="text-emphasis mb-2 block text-sm font-medium">{t("dark_brand_color")}</p>
<ColorPicker
defaultValue={team.darkBrandColor}
resetDefaultValue="#fafafa"
onChange={(value) => form.setValue("darkBrandColor", value, { shouldDirty: true })}
/>
</div>
)}
/>
</div>
<hr className="border-subtle my-8" />
<div className="flex flex-col gap-8">
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="text-default font-medium">
{t("disable_cal_branding", { appName: APP_NAME })}
</label>
<p className="text-subtle">
{t("team_disable_cal_branding_description", { appName: APP_NAME })}
</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBranding ?? false}
name="hideBranding"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBranding", isChecked);
}}
/>
)}
/>
</div>
</div>
<div className="relative flex items-start">
<div className="flex-grow text-sm">
<label htmlFor="hide-branding" className="text-default font-medium">
{t("hide_book_a_team_member")}
</label>
<p className="text-subtle">{t("hide_book_a_team_member_description")}</p>
</div>
<div className="flex-none">
<Controller
control={form.control}
defaultValue={team?.hideBookATeamMember ?? false}
name="hideBookATeamMember"
render={({ field }) => (
<Switch
defaultChecked={field.value}
onCheckedChange={(isChecked) => {
form.setValue("hideBookATeamMember", isChecked);
}}
/>
)}
/>
</div>
</div>
</div>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
</Form>
) : (
<div className="border-subtle rounded-md border p-5">
<span className="text-default text-sm">{t("only_owner_change")}</span>
</div>
)}
</>
);
return <ProfileView team={team} />;
};
ProfileView.getLayout = getLayout;
ProfileViewWrapper.getLayout = getLayout;
export default ProfileView;
export default ProfileViewWrapper;

View File

@ -170,7 +170,6 @@ const MembersView = () => {
{((team?.isPrivate && isAdmin) || !team?.isPrivate || isOrgAdminOrOwner) && (
<>
<MembersList team={team} isOrgAdminOrOwner={isOrgAdminOrOwner} />
<hr className="border-subtle my-8" />
</>
)}
@ -183,10 +182,7 @@ const MembersView = () => {
)}
{team && (isAdmin || isOrgAdminOrOwner) && (
<>
<hr className="border-subtle my-8" />
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={isInviteOpen} />
</>
<MakeTeamPrivateSwitch teamId={team.id} isPrivate={team.isPrivate} disabled={isInviteOpen} />
)}
</div>
{showMemberInvitationModal && team && (

View File

@ -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<typeof teamProfileFormSchema>;
const SkeletonLoader = ({ title, description }: { title: string; description: string }) => {
return (
<SkeletonContainer>
<Meta title={title} description={description} borderInShellHeader={true} />
<div className="border-subtle space-y-6 rounded-b-xl border border-t-0 px-4 py-8">
<div className="flex items-center">
<SkeletonAvatar className="me-4 mt-0 h-16 w-16 px-4" />
<SkeletonButton className="h-6 w-32 rounded-md p-5" />
</div>
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
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 <SkeletonLoader title={t("profile")} description={t("profile_team_description")} />;
}
return (
<>
<Meta title={t("profile")} description={t("profile_team_description")} />
{!isLoading ? (
<>
{isAdmin ? (
<Form
form={form}
handleSubmit={(values) => {
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 && (
<>
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => (
<>
<Avatar
alt=""
imageSrc={getPlaceholderAvatar(value, team?.name as string)}
size="lg"
/>
<div className="ms-4">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("update")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo);
}}
imageSrc={value}
/>
</div>
</>
)}
/>
</div>
<hr className="border-subtle my-8" />
</>
)}
<Meta title={t("profile")} description={t("profile_team_description")} borderInShellHeader={true} />
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value);
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="slug"
label={t("team_url")}
value={value}
addOnLeading={
team.parent && orgBranding
? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/`
: `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");
form.setValue("slug", slugify(e?.target.value, true));
}}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value))}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
<Button color="primary" className="mt-8" type="submit" loading={mutation.isLoading}>
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
team.slug === null &&
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2 mt-8"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
}}>
Publish
</Button>
)}
</Form>
) : (
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("team_name")}</Label>
<p className="text-default text-sm">{team?.name}</p>
</div>
{team && !isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
/>
</>
)}
</div>
<div className="">
<Link href={permalink} passHref={true} target="_blank">
<LinkIconButton Icon={ExternalLink}>{t("preview")}</LinkIconButton>
</Link>
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<hr className="border-subtle my-8 border" />
<div className="text-default mb-3 text-base font-semibold">{t("danger_zone")}</div>
{team?.membership.role === "OWNER" ? (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
{t("disband_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : (
<Dialog>
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={LogOut}>
{t("leave_team")}
</Button>
</DialogTrigger>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={leaveTeam}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
)}
</>
{isAdmin ? (
<TeamProfileForm team={team} />
) : (
<>
<SkeletonContainer as="form">
<div className="flex items-center">
<div className="ms-4">
<SkeletonContainer>
<div className="bg-emphasis h-16 w-16 rounded-full" />
</SkeletonContainer>
</div>
<div className="flex">
<div className="flex-grow">
<div>
<Label className="text-emphasis">{t("team_name")}</Label>
<p className="text-default text-sm">{team?.name}</p>
</div>
<hr className="border-subtle my-8" />
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-6 w-48" />
</div>
</SkeletonContainer>
<div className="mt-8">
<SkeletonContainer>
<div className="bg-emphasis h-24 rounded-md" />
</SkeletonContainer>
<SkeletonText className="mt-4 h-12 w-32" />
</div>
<SkeletonContainer>
<div className="mt-8">
<SkeletonText className="h-9 w-24" />
</div>
</SkeletonContainer>
</SkeletonContainer>
</>
{team && !isBioEmpty && (
<>
<Label className="text-emphasis mt-5">{t("about")}</Label>
<div
className=" text-subtle break-words text-sm [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600"
dangerouslySetInnerHTML={{ __html: md.render(markdownToSafeHTML(team.bio)) }}
/>
</>
)}
</div>
<div className="">
<Link href={permalink} passHref={true} target="_blank">
<LinkIconButton Icon={ExternalLink}>{t("preview")}</LinkIconButton>
</Link>
<LinkIconButton
Icon={LinkIcon}
onClick={() => {
navigator.clipboard.writeText(permalink);
showToast("Copied to clipboard", "success");
}}>
{t("copy_link_team")}
</LinkIconButton>
</div>
</div>
)}
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
{team?.membership.role === "OWNER" && (
<p className="text-subtle text-sm">{t("team_deletion_cannot_be_undone")}</p>
)}
</div>
{team?.membership.role === "OWNER" ? (
<Dialog>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={Trash2}>
{t("disband_team")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<ConfirmationDialogContent
variety="danger"
title={t("disband_team")}
confirmBtnText={t("confirm_disband_team")}
onConfirm={deleteTeam}>
{t("disband_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
) : (
<Dialog>
<SectionBottomActions align="end">
<DialogTrigger asChild>
<Button color="destructive" className="border" StartIcon={LogOut}>
{t("leave_team")}
</Button>
</DialogTrigger>
</SectionBottomActions>
<ConfirmationDialogContent
variety="danger"
title={t("leave_team")}
confirmBtnText={t("confirm_leave_team")}
onConfirm={leaveTeam}>
{t("leave_team_confirmation_message")}
</ConfirmationDialogContent>
</Dialog>
)}
</>
);
};
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 (
<Form
form={form}
handleSubmit={(values) => {
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 });
}
}}>
<div className="border-subtle border-x px-4 py-8 sm:px-6">
{!team.parent && (
<div className="flex items-center">
<Controller
control={form.control}
name="logo"
render={({ field: { value } }) => {
const showRemoveLogoButton = !!value;
return (
<>
<Avatar
alt={defaultValues.name || ""}
imageSrc={getPlaceholderAvatar(value, team?.name as string)}
size="lg"
/>
<div className="ms-4 flex gap-2">
<ImageUploader
target="avatar"
id="avatar-upload"
buttonMsg={t("upload_logo")}
handleAvatarChange={(newLogo) => {
form.setValue("logo", newLogo, { shouldDirty: true });
}}
triggerButtonColor={showRemoveLogoButton ? "secondary" : "primary"}
imageSrc={value ?? undefined}
/>
{showRemoveLogoButton && (
<Button
color="secondary"
onClick={() => {
form.setValue("logo", null, { shouldDirty: true });
}}>
{t("remove")}
</Button>
)}
</div>
</>
);
}}
/>
</div>
)}
<Controller
control={form.control}
name="name"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="name"
label={t("team_name")}
value={value}
onChange={(e) => {
form.setValue("name", e?.target.value, { shouldDirty: true });
}}
/>
</div>
)}
/>
<Controller
control={form.control}
name="slug"
render={({ field: { value } }) => (
<div className="mt-8">
<TextField
name="slug"
label={t("team_url")}
value={value}
addOnLeading={
team.parent && orgBranding
? `${getOrgFullOrigin(orgBranding?.slug, { protocol: false })}/`
: `${WEBAPP_URL}/team/`
}
onChange={(e) => {
form.clearErrors("slug");
form.setValue("slug", slugify(e?.target.value, true), { shouldDirty: true });
}}
/>
</div>
)}
/>
<div className="mt-8">
<Label>{t("about")}</Label>
<Editor
getText={() => md.render(form.getValues("bio") || "")}
setText={(value: string) => form.setValue("bio", turndown(value), { shouldDirty: true })}
excludedToolbarItems={["blockType"]}
disableLists
firstRender={firstRender}
setFirstRender={setFirstRender}
/>
</div>
<p className="text-default mt-2 text-sm">{t("team_description")}</p>
</div>
<SectionBottomActions align="end">
<Button color="primary" type="submit" loading={mutation.isLoading} disabled={isDisabled}>
{t("update")}
</Button>
{IS_TEAM_BILLING_ENABLED &&
team.slug === null &&
(team.metadata as Prisma.JsonObject)?.requestedSlug && (
<Button
color="secondary"
className="ml-2"
type="button"
onClick={() => {
publishMutation.mutate({ teamId: team.id });
}}>
{t("team_publish")}
</Button>
)}
</SectionBottomActions>
</Form>
);
};
ProfileView.getLayout = getLayout;
export default ProfileView;

View File

@ -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,
};
};

View File

@ -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()))

View File

@ -163,9 +163,12 @@ export default function ImageUploader({
return (
<Dialog
onOpenChange={
(opened) => !opened && setFile(null) // unset file on close
}>
onOpenChange={(opened) => {
// unset file on close
if (!opened) {
setFile(null);
}
}}>
<DialogTrigger asChild>
<Button color={triggerButtonColor ?? "secondary"} type="button" className="py-1 text-sm">
{buttonMsg}
@ -201,10 +204,11 @@ export default function ImageUploader({
</div>
</div>
<DialogFooter className="relative">
<DialogClose color="minimal">{t("cancel")}</DialogClose>
<DialogClose color="primary" onClick={() => showCroppedImage(croppedAreaPixels)}>
{t("save")}
</DialogClose>
<DialogClose color="minimal">{t("cancel")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>