feat: Settings for dashboard theme that is separate from system (#13779)

* feat(db): add new col: user appTheme

* feat(event-types page): apply appTheme color

* add appTheme for trpc

* feat: mutate main page theme

* feat: add english translation for app theme update section

* modify keys in common (en)

* add woring

* Revert yarn.lock to its state before unintended changes

* feat(i18n): add back the i18n string (for dark/light mode switching)

* refactor: refactor ThemeLabel

* fix: add new user field "appTheme" to test files

* chore: modify TODO comment

* chore: update common.json (en) for "theme"

since we had appTheme now, I think it's better to update the wording here to avoid confusion

* fix: update button's data-testid to fix e2e test error

* chore: remove comment

* fix: fix brand-color-not-apply bug

* solve type error

* fix: fix type error

* fix: use correct storageKey for booker / dashboard

* fix: tidy up

* fix: skeleton

---------

Co-authored-by: swh00tw <a6140000@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
This commit is contained in:
authored and GitHub committed 2024-02-21 13:02:40 +00:00
1 parent fa9a099dd6
commit 51b2a15cb0
14 files changed
+126 -18

No files matched your search

+4
View File
@@ -74,6 +74,7 @@ export const schemaUserBaseBodyParams = User.pick({
timeZone: true,
weekStart: true,
theme: true,
appTheme: true,
defaultScheduleId: true,
locale: true,
hideBranding: true,
@@ -100,6 +101,7 @@ const schemaUserEditParams = z.object({
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
appTheme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
defaultScheduleId: z
.number()
@@ -122,6 +124,7 @@ const schemaUserCreateParams = z.object({
hideBranding: z.boolean().optional(),
timeZone: timeZone.optional(),
theme: z.nativeEnum(theme).optional().nullable(),
appTheme: z.nativeEnum(theme).optional().nullable(),
timeFormat: z.nativeEnum(timeFormat).optional(),
defaultScheduleId: z
.number()
@@ -159,6 +162,7 @@ export const schemaUserReadPublic = User.pick({
weekStart: true,
endTime: true,
bufferTime: true,
appTheme: true,
theme: true,
defaultScheduleId: true,
locale: true,
+1 -1
View File
@@ -113,7 +113,7 @@ const enum ThemeSupport {
// e.g. Login Page
None = "none",
// Entire App except Booking Pages
App = "systemOnly",
App = "userConfigured",
// Booking Pages(including Routing Forms)
Booking = "userConfigured",
}
+5 -3
View File
@@ -127,9 +127,9 @@ const enum ThemeSupport {
// e.g. Login Page
None = "none",
// Entire App except Booking Pages
App = "systemOnly",
App = "appConfigured",
// Booking Pages(including Routing Forms)
Booking = "userConfigured",
Booking = "bookingConfigured",
}
type CalcomThemeProps = PropsWithChildren<
@@ -144,8 +144,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
const embedNamespace = getEmbedNamespace(props.router.query);
const isEmbedMode = typeof embedNamespace === "string";
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
return (
<ThemeProvider {...getThemeProviderProps({ props, isEmbedMode, embedNamespace })}>
<ThemeProvider {...themeProviderProps}>
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
{typeof window !== "undefined" && !isEmbedMode && (
@@ -11,8 +11,10 @@ import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { APP_NAME } from "@calcom/lib/constants";
import { DEFAULT_LIGHT_BRAND_COLOR, DEFAULT_DARK_BRAND_COLOR } from "@calcom/lib/constants";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import useGetBrandingColours from "@calcom/lib/getBrandColours";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { validateBookerLayouts } from "@calcom/lib/validateBookerLayouts";
import type { userMetadata } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
@@ -29,6 +31,7 @@ import {
SkeletonText,
SettingsToggle,
UpgradeTeamsBadge,
useCalcomTheme,
} from "@calcom/ui";
import PageWrapper from "@components/PageWrapper";
@@ -41,10 +44,10 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
<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 className="[&>*]:bg-emphasis flex w-full items-center justify-center gap-x-2 [&>*]:animate-pulse">
<div className="h-32 flex-1 rounded-md p-5" />
<div className="h-32 flex-1 rounded-md p-5" />
<div className="h-32 flex-1 rounded-md p-5" />
</div>
<div className="flex justify-between">
<SkeletonText className="h-8 w-1/3" />
@@ -62,6 +65,26 @@ const SkeletonLoader = ({ title, description }: { title: string; description: st
);
};
const useBrandColors = (
currentTheme: string | null,
{
brandColor,
darkBrandColor,
}: {
brandColor?: string | null;
darkBrandColor?: string | null;
}
): void => {
const brandTheme = useGetBrandingColours({
lightVal: brandColor,
darkVal: darkBrandColor,
});
const selectedTheme = currentTheme ? brandTheme[currentTheme as "light" | "dark"] : {};
useCalcomTheme({
root: selectedTheme,
});
};
const AppearanceView = ({
user,
hasPaidPlan,
@@ -77,6 +100,22 @@ const AppearanceView = ({
user?.brandColor !== DEFAULT_LIGHT_BRAND_COLOR || user?.darkBrandColor !== DEFAULT_DARK_BRAND_COLOR
);
const [hideBrandingValue, setHideBrandingValue] = useState(user?.hideBranding ?? false);
useTheme(user?.appTheme);
useBrandColors(user?.appTheme ?? null, {
brandColor: user?.brandColor,
darkBrandColor: user?.darkBrandColor,
});
const userAppThemeFormMethods = useForm({
defaultValues: {
appTheme: user.appTheme,
},
});
const {
formState: { isSubmitting: isUserAppThemeSubmitting, isDirty: isUserAppThemeDirty },
reset: resetUserAppThemeReset,
} = userAppThemeFormMethods;
const userThemeFormMethods = useForm({
defaultValues: {
@@ -131,6 +170,7 @@ const AppearanceView = ({
resetBrandColorsThemeReset({ brandColor: data.brandColor, darkBrandColor: data.darkBrandColor });
resetBookerLayoutThemeReset({ metadata: data.metadata });
resetUserThemeReset({ theme: data.theme });
resetUserAppThemeReset({ appTheme: data.appTheme });
},
onError: (error) => {
if (error.message) {
@@ -147,6 +187,56 @@ const AppearanceView = ({
return (
<div>
<Meta title={t("appearance")} description={t("appearance_description")} borderInShellHeader={false} />
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
<div>
<p className="text-default text-base font-semibold">{t("app_theme")}</p>
<p className="text-default">{t("app_theme_applies_note")}</p>
</div>
</div>
<Form
form={userAppThemeFormMethods}
handleSubmit={(values) => {
mutation.mutate({
appTheme: values.appTheme ?? null,
});
}}>
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
<ThemeLabel
variant="system"
value={undefined}
label={t("theme_system")}
defaultChecked={user.appTheme === null}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
<ThemeLabel
variant="light"
value="light"
label={t("light")}
defaultChecked={user.appTheme === "light"}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
<ThemeLabel
variant="dark"
value="dark"
label={t("dark")}
defaultChecked={user.appTheme === "dark"}
register={userAppThemeFormMethods.register}
fieldName="appTheme"
/>
</div>
<SectionBottomActions className="mb-6" align="end">
<Button
disabled={isUserAppThemeSubmitting || !isUserAppThemeDirty}
type="submit"
data-testid="update-app-theme-btn"
color="primary">
{t("update")}
</Button>
</SectionBottomActions>
</Form>
<div className="border-subtle mt-6 flex items-center rounded-t-lg border p-6 text-sm">
<div>
<p className="text-default text-base font-semibold">{t("theme")}</p>
@@ -1473,8 +1473,10 @@
"route_to_the_right_person": "Route to the right person based on the answers to your form",
"form_description": "Create your form to route a booker",
"copy_link_to_form": "Copy link to form",
"theme": "Theme",
"theme": "Booking Page Theme",
"theme_applies_note": "This only applies to your public booking pages",
"app_theme": "Dashboard Theme",
"app_theme_applies_note": "This only applies to your logged in dashboard",
"theme_system": "System default",
"add_a_team": "Add a team",
"add_webhook_description": "Receive meeting data in real-time when something happens in {{appName}}",
@@ -37,6 +37,7 @@ export const getSampleUserInSession = function () {
disableImpersonation: false,
organizationId: null,
theme: "",
appTheme: "",
createdDate: new Date(),
trialEndsAt: new Date(),
completedOnboarding: false,
+6 -5
View File
@@ -4,23 +4,24 @@ interface ThemeLabelProps {
label: string;
defaultChecked?: boolean;
register: any;
fieldName?: string;
}
export default function ThemeLabel(props: ThemeLabelProps) {
const { variant, label, value, defaultChecked, register } = props;
const { variant, label, value, defaultChecked, register, fieldName = "theme" } = props;
return (
<label
className="relative mb-4 flex-1 cursor-pointer text-center last:mb-0 last:mr-0 sm:mb-0 sm:mr-4"
htmlFor={`theme-${variant}`}
data-testid={`theme-${variant}`}>
htmlFor={`${fieldName}-${variant}`}
data-testid={`${fieldName}-${variant}`}>
<input
className="peer absolute left-8 top-8"
type="radio"
value={value}
id={`theme-${variant}`}
id={`${fieldName}-${variant}`}
defaultChecked={defaultChecked}
{...register("theme")}
{...register(fieldName)}
/>
<div className="ring-inverted relative z-10 rounded-lg ring-offset-2 transition-all peer-checked:ring-2">
<img
+4 -4
View File
@@ -48,6 +48,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client";
import getBrandColours from "@calcom/lib/getBrandColours";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useTheme from "@calcom/lib/hooks/useTheme";
import { isKeyInObject } from "@calcom/lib/isKeyInObject";
import type { User } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
@@ -329,13 +330,14 @@ type LayoutProps = {
hideHeadingOnMobile?: boolean;
};
const useBrandColors = () => {
const useAppTheme = () => {
const { data: user } = useMeQuery();
const brandTheme = getBrandColours({
lightVal: user?.brandColor,
darkVal: user?.darkBrandColor,
});
useCalcomTheme(brandTheme);
useTheme(user?.appTheme);
};
const KBarWrapper = ({ children, withKBar = false }: { withKBar: boolean; children: React.ReactNode }) =>
@@ -361,9 +363,7 @@ export default function Shell(props: LayoutProps) {
// if a page is unauthed and isPublic is true, the redirect does not happen.
useRedirectToLoginIfUnauthenticated(props.isPublic);
useRedirectToOnboardingIfNeeded();
// System Theme is automatically supported using ThemeProvider. If we intend to use user theme throughout the app we need to uncomment this.
// useTheme(profile.theme);
useBrandColors();
useAppTheme();
return !props.isPublic ? (
<KBarWrapper withKBar>
+1
View File
@@ -234,6 +234,7 @@ export const buildUser = <T extends Partial<UserPayload>>(
selectedCalendars: [],
startTime: 0,
theme: null,
appTheme: null,
timeFormat: null,
trialEndsAt: null,
twoFactorEnabled: false,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "appTheme" TEXT;
+2
View File
@@ -221,7 +221,9 @@ model User {
// </DEPRECATED>
bufferTime Int @default(0)
hideBranding Boolean @default(false)
// TODO: should be renamed since it only affects the booking page
theme String?
appTheme String?
createdDate DateTime @default(now()) @map(name: "created")
trialEndsAt DateTime?
eventTypes EventType[] @relation("user_eventtype")
@@ -45,6 +45,7 @@ export async function getUserFromSession(ctx: TRPCContextInner, session: Maybe<S
defaultScheduleId: true,
bufferTime: true,
theme: true,
appTheme: true,
createdDate: true,
hideBranding: true,
twoFactorEnabled: true,
@@ -54,6 +54,7 @@ export const meHandler = async ({ ctx }: MeOptions) => {
bio: user.bio,
weekStart: user.weekStart,
theme: user.theme,
appTheme: user.appTheme,
hideBranding: user.hideBranding,
metadata: user.metadata,
defaultBookerLayouts: user.defaultBookerLayouts,
@@ -23,6 +23,7 @@ export const ZUpdateProfileInputSchema = z.object({
brandColor: z.string().optional(),
darkBrandColor: z.string().optional(),
theme: z.string().optional().nullable(),
appTheme: z.string().optional().nullable(),
completedOnboarding: z.boolean().optional(),
locale: z.string().optional(),
timeFormat: z.number().optional(),