Files
calendar/packages/features/settings/BookerLayoutSelector.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

257 lines
9.7 KiB
TypeScript

import * as RadioGroup from "@radix-ui/react-radio-group";
import Link from "next/link";
import { useCallback, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
import { bookerLayoutOptions, type BookerLayoutSettings } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Label } from "@calcom/ui/components/form";
import { CheckboxField } from "@calcom/ui/components/form";
import SectionBottomActions from "./SectionBottomActions";
type BookerLayoutSelectorProps = {
title?: string;
description?: string;
name?: string;
/**
* If this boolean is set, it will show the user settings if the event does not have any settings (is null).
* In that case it also will NOT register itself in the form, so that way when submitting the form, the
* values won't be overridden. Because as long as the event's value is null, it will fallback to the user's
* settings.
*/
fallbackToUserSettings?: boolean;
/**
* isDark boolean should be passed in when the user selected 'dark mode' in the theme settings in profile/appearance.
* So it's not based on the user's system settings, but on the user's preference for the booker.
* This boolean is then used to show a dark version of the layout image. It's only easthetic, no functionality is attached
* to this boolean.
*/
isDark?: boolean;
isLoading?: boolean;
isDisabled?: boolean;
isOuterBorder?: boolean;
user?: Partial<Pick<RouterOutputs["viewer"]["me"]["get"], "defaultBookerLayouts">>;
isUserLoading?: boolean;
};
const defaultFieldName = "metadata.bookerLayouts";
export const BookerLayoutSelector = ({
title,
description,
name,
fallbackToUserSettings,
isDark,
isDisabled = false,
isOuterBorder = false,
isLoading = false,
user,
isUserLoading,
}: BookerLayoutSelectorProps) => {
const { control, getValues } = useFormContext();
const { t } = useLocale();
// Only fallback if event current does not have any settings, and the fallbackToUserSettings boolean is set.
const shouldShowUserSettings = (fallbackToUserSettings && !getValues(name || defaultFieldName)) || false;
return (
<div className={classNames(isOuterBorder && "border-subtle rounded-lg border p-6")}>
<div className={classNames(isOuterBorder ? "pb-5" : "border-subtle rounded-t-xl border p-6")}>
<Label className={classNames("mb-1 font-semibold", isOuterBorder ? "text-sm" : "text-base")}>
{title ? title : t("layout")}
</Label>
<p className="text-subtle max-w-full wrap-break-word text-sm leading-tight">
{description ? description : t("bookerlayout_description")}
</p>
</div>
<Controller
// If the event does not have any settings, we don't want to register this field in the form.
// That way the settings won't get saved into the event on save, but remain null. Thus keep using
// the global user's settings.
control={shouldShowUserSettings ? undefined : control}
name={name || defaultFieldName}
render={({ field: { value, onChange } }) => (
<>
<BookerLayoutFields
showUserSettings={shouldShowUserSettings}
settings={value}
onChange={onChange}
isDark={isDark}
isOuterBorder={isOuterBorder}
user={user}
isUserLoading={isUserLoading}
/>
{!isOuterBorder && (
<SectionBottomActions align="end">
<Button loading={isLoading} type="submit" disabled={isDisabled} color="primary">
{t("update")}
</Button>
</SectionBottomActions>
)}
</>
)}
/>
</div>
);
};
type BookerLayoutFieldsProps = {
settings: BookerLayoutSettings;
onChange: (settings: BookerLayoutSettings) => void;
showUserSettings: boolean;
isDark?: boolean;
isOuterBorder?: boolean;
user?: Partial<Pick<RouterOutputs["viewer"]["me"]["get"], "defaultBookerLayouts">>;
isUserLoading?: boolean;
};
type BookerLayoutState = { [key in BookerLayouts]: boolean };
const BookerLayoutFields = ({
settings,
onChange,
showUserSettings,
isDark,
isOuterBorder,
user,
isUserLoading,
}: BookerLayoutFieldsProps) => {
const { t } = useLocale();
const [isOverridingSettings, setIsOverridingSettings] = useState(false);
const disableFields = showUserSettings && !isOverridingSettings;
const shownSettings = disableFields ? user?.defaultBookerLayouts : settings;
const defaultLayout = shownSettings?.defaultLayout || BookerLayouts.MONTH_VIEW;
// Converts the settings array into a boolean object, which can be used as form values.
const toggleValues: BookerLayoutState = bookerLayoutOptions.reduce((layouts, layout) => {
layouts[layout] = !shownSettings?.enabledLayouts
? defaultBookerLayoutSettings.enabledLayouts.indexOf(layout) > -1
: shownSettings.enabledLayouts.indexOf(layout) > -1;
return layouts;
}, {} as BookerLayoutState);
const onLayoutToggleChange = useCallback(
(changedLayout: BookerLayouts, checked: boolean) => {
const newEnabledLayouts = Object.keys(toggleValues).filter((layout) => {
if (changedLayout === layout) return checked === true;
return toggleValues[layout as BookerLayouts] === true;
}) as BookerLayouts[];
const isDefaultLayoutToggledOff = newEnabledLayouts.indexOf(defaultLayout) === -1;
const firstEnabledLayout = newEnabledLayouts[0];
onChange({
enabledLayouts: newEnabledLayouts,
// If default layout is toggled off, we set the default layout to the first enabled layout
// if there's none enabled, we set it to month view.
defaultLayout: isDefaultLayoutToggledOff
? firstEnabledLayout || BookerLayouts.MONTH_VIEW
: defaultLayout,
});
},
[defaultLayout, onChange, toggleValues]
);
const onDefaultLayoutChange = useCallback(
(newDefaultLayout: BookerLayouts) => {
onChange({
enabledLayouts: Object.keys(toggleValues).filter(
(layout) => toggleValues[layout as BookerLayouts] === true
) as BookerLayouts[],
defaultLayout: newDefaultLayout,
});
},
[toggleValues, onChange]
);
const onOverrideSettings = () => {
setIsOverridingSettings(true);
// Sent default layout settings to form, otherwise it would still have 'null' as it's value.
if (user?.defaultBookerLayouts) onChange(user.defaultBookerLayouts);
};
return (
<div className={classNames("stack-y-5", !isOuterBorder && "border-subtle border-x px-6 py-8")}>
<div
className={classNames(
"flex flex-col gap-5 transition-opacity sm:flex-row sm:gap-3",
disableFields && "pointer-events-none opacity-40",
disableFields && isUserLoading && "animate-pulse"
)}>
{bookerLayoutOptions.map((layout) => (
<div className="w-full" key={layout}>
<label>
<img
className="mb-3 w-full max-w-none cursor-pointer"
src={`/bookerlayout_${layout}${isDark ? "_dark" : ""}.svg`}
alt="Layout preview"
/>
<CheckboxField
value={layout}
name={`bookerlayout_${layout}`}
description={t(`bookerlayout_${layout}`)}
checked={toggleValues[layout]}
onChange={(ev) => onLayoutToggleChange(layout, ev.target.checked)}
/>
</label>
</div>
))}
</div>
<div
hidden={Object.values(toggleValues).filter((value) => value === true).length <= 1}
className={classNames(
"transition-opacity",
disableFields && "pointer-events-none opacity-40",
disableFields && isUserLoading && "animate-pulse"
)}>
<Label>{t("bookerlayout_default_title")}</Label>
<RadioGroup.Root
key={defaultLayout}
className="border-subtle flex w-full gap-2 rounded-md border p-1"
defaultValue={defaultLayout}
onValueChange={(layout: BookerLayouts) => onDefaultLayoutChange(layout)}>
{bookerLayoutOptions.map((layout) => (
<RadioGroup.Item
className="aria-checked:bg-emphasis hover:[&:not(:disabled)]:bg-subtle focus:[&:not(:disabled)]:bg-subtle w-full rounded-[4px] p-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
disabled={toggleValues[layout] === false}
key={layout}
value={layout}>
{t(`bookerlayout_${layout}`)}
<RadioGroup.Indicator />
</RadioGroup.Item>
))}
</RadioGroup.Root>
</div>
{disableFields && (
<p className="text-sm">
<ServerTrans
t={t}
i18nKey="bookerlayout_override_global_settings"
components={[
<Link
key="appearance-link"
target="_blank"
href="/settings/my-account/appearance"
className="underline">
Appearance
</Link>,
<Button
key="override-button"
onClick={onOverrideSettings}
color="minimal"
className="h-fit p-0 font-normal underline hover:bg-transparent focus-visible:bg-transparent">
Override
</Button>,
]}
/>
</p>
)}
</div>
);
};