Files
calendar/packages/features/form-builder/Components.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

560 lines
19 KiB
TypeScript

import { useEffect } from "react";
import type { z } from "zod";
import type {
SelectLikeComponentProps,
TextLikeComponentProps,
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
import Widgets from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
import PhoneInput from "@calcom/features/components/phone-input";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { fieldSchema, variantsConfigSchema, FieldType } from "@calcom/prisma/zod-utils";
import { AddressInput } from "@calcom/ui/components/address";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { Label, CheckboxField, EmailField, InputField, Checkbox } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { RadioGroup, RadioField } from "@calcom/ui/components/radio";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { ComponentForField } from "./FormBuilderField";
import { propsTypes } from "./propsTypes";
import { preprocessNameFieldDataWithVariant } from "./utils";
export const isValidValueProp: Record<Component["propsType"], (val: unknown) => boolean> = {
boolean: (val) => typeof val === "boolean",
multiselect: (val) => val instanceof Array && val.every((v) => typeof v === "string"),
objectiveWithInput: (val) => (typeof val === "object" && val !== null ? "value" in val : false),
select: (val) => typeof val === "string",
text: (val) => typeof val === "string",
textList: (val) => val instanceof Array && val.every((v) => typeof v === "string"),
variants: (val) => (typeof val === "object" && val !== null) || typeof val === "string",
};
type Component =
| {
propsType: "text";
factory: <TProps extends TextLikeComponentProps>(props: TProps) => JSX.Element;
}
| {
propsType: "textList";
factory: <TProps extends TextLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
}
| {
propsType: "select";
factory: <TProps extends SelectLikeComponentProps>(props: TProps) => JSX.Element;
}
| {
propsType: "boolean";
factory: <TProps extends TextLikeComponentProps<boolean>>(props: TProps) => JSX.Element;
}
| {
propsType: "multiselect";
factory: <TProps extends SelectLikeComponentProps<string[]>>(props: TProps) => JSX.Element;
}
| {
// Objective type question with option having a possible input
propsType: "objectiveWithInput";
factory: <
TProps extends SelectLikeComponentProps<{
value: string;
optionValue: string;
}> & {
optionsInputs: NonNullable<z.infer<typeof fieldSchema>["optionsInputs"]>;
value: { value: string; optionValue: string };
} & {
name?: string;
required?: boolean;
translatedDefaultLabel?: string;
}
>(
props: TProps
) => JSX.Element;
}
| {
propsType: "variants";
factory: <
TProps extends Omit<TextLikeComponentProps, "value" | "setValue"> & {
variant: string | undefined;
variants: z.infer<typeof variantsConfigSchema>["variants"];
value: Record<string, string> | string | undefined;
setValue: (value: string | Record<string, string>) => void;
}
>(
props: TProps
) => JSX.Element;
};
// TODO: Share FormBuilder components across react-query-awesome-builder(for Routing Forms) widgets.
// There are certain differences b/w two. Routing Forms expect label to be provided by the widget itself and FormBuilder adds label itself and expect no label to be added by component.
// Routing Form approach is better as it provides more flexibility to show the label in complex components. But that can't be done right now because labels are missing consistent asterisk required support across different components
export const Components: Record<FieldType, Component> = {
text: {
propsType: propsTypes.text,
factory: (props) => <Widgets.TextWidget id={props.name} noLabel={true} {...props} />,
},
textarea: {
propsType: propsTypes.textarea,
// TODO: Make rows configurable in the form builder
factory: (props) => <Widgets.TextAreaWidget id={props.name} rows={3} {...props} />,
},
number: {
propsType: propsTypes.number,
factory: (props) => <Widgets.NumberWidget id={props.name} noLabel={true} {...props} />,
},
name: {
propsType: propsTypes.name,
// Keep special "name" type field and later build split(FirstName and LastName) variant of it.
factory: (props) => {
const { variant: variantName = "fullName" } = props;
const onChange = (name: string, value: string) => {
let currentValue = props.value;
if (typeof currentValue !== "object") {
currentValue = {};
}
props.setValue({
...currentValue,
[name]: value,
});
};
if (!props.variants) {
throw new Error("'variants' is required for 'name' type of field");
}
if (variantName !== "firstAndLastName" && variantName !== "fullName") {
throw new Error(`Invalid variant name '${variantName}' for 'name' type of field`);
}
const value = preprocessNameFieldDataWithVariant(variantName, props.value);
if (variantName === "fullName") {
if (typeof value !== "string") {
throw new Error("Invalid value for 'fullName' variant");
}
const variant = props.variants[variantName];
const variantField = variant.fields[0];
return (
<InputField
name="name"
showAsteriskIndicator={true}
placeholder={variantField.placeholder}
label={variantField.label}
containerClassName="w-full"
readOnly={props.readOnly}
value={value}
required={variantField.required}
type="text"
autoComplete="name"
onChange={(e) => {
props.setValue(e.target.value);
}}
/>
);
}
const variant = props.variants[variantName];
if (typeof value !== "object") {
throw new Error("Invalid value for 'fullName' variant");
}
return (
<div className="flex space-x-4">
{variant.fields.map((variantField) => (
<InputField
// Because the container is flex(and thus margin is being computed towards container height), I need to explicitly ensure that margin-bottom for the input becomes 0, which is mb-2 otherwise
className="mb-0!"
showAsteriskIndicator={true}
key={variantField.name}
name={variantField.name}
readOnly={props.readOnly}
placeholder={variantField.placeholder}
label={variantField.label}
containerClassName={`w-full testid-${variantField.name}`}
value={value[variantField.name as keyof typeof value]}
required={variantField.required}
type="text"
autoComplete={
variantField.name === "firstName"
? "given-name"
: variantField.name === "lastName"
? "family-name"
: undefined
}
onChange={(e) => onChange(variantField.name, e.target.value)}
/>
))}
</div>
);
},
},
phone: {
propsType: propsTypes.phone,
factory: ({ setValue, readOnly, ...props }) => {
if (!props) {
return <div />;
}
return (
<PhoneInput
disabled={readOnly}
onChange={(val: string) => {
setValue(val);
}}
{...props}
/>
);
},
},
email: {
propsType: propsTypes.email,
factory: (props) => {
if (!props) {
return <div />;
}
return (
<InputField
type="email"
id={props.name}
noLabel={true}
autoComplete="email"
{...props}
onChange={(e) => props.setValue(e.target.value)}
/>
);
},
},
address: {
propsType: propsTypes.address,
factory: (props) => {
return (
<AddressInput
id={props.name}
onChange={(val) => {
props.setValue(val);
}}
{...props}
disabled={props.readOnly}
/>
);
},
},
multiemail: {
propsType: propsTypes.multiemail,
//TODO: Make it a ui component
factory: function MultiEmail({ value, readOnly, label, setValue, ...props }) {
const placeholder = props.placeholder;
const { t } = useLocale();
value = value || [];
return (
<>
{value.length ? (
<div>
<label htmlFor="guests" className="text-default mb-1 block text-sm font-medium">
{label}
</label>
<ul>
{value.map((field, index) => (
<li key={index}>
<EmailField
id={`${props.name}.${index}`}
disabled={readOnly}
value={value[index]}
onChange={(e) => {
value[index] = e.target.value.toLowerCase();
setValue(value);
}}
placeholder={placeholder}
label={<></>}
required
onClickAddon={() => {
value.splice(index, 1);
setValue(value);
}}
addOnSuffix={
!readOnly ? (
<Tooltip content="Remove email">
<button className="m-1" type="button">
<Icon name="x" width={12} className="text-default" />
</button>
</Tooltip>
) : null
}
/>
</li>
))}
</ul>
{!readOnly && (
<Button
data-testid="add-another-guest"
type="button"
color="minimal"
StartIcon="user-plus"
className="my-2.5"
onClick={() => {
value.push("");
setValue(value);
}}>
{t("add_another")}
</Button>
)}
</div>
) : (
<></>
)}
{!value.length && !readOnly && (
<Button
data-testid="add-guests"
color="minimal"
variant="button"
StartIcon="user-plus"
onClick={() => {
value.push("");
setValue(value);
}}
className="mr-auto h-fit whitespace-normal text-left">
<span className="flex-1">{label}</span>
</Button>
)}
</>
);
},
},
multiselect: {
propsType: propsTypes.multiselect,
factory: (props) => {
const newProps = {
...props,
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
};
return <Widgets.MultiSelectWidget id={props.name} {...newProps} />;
},
},
select: {
propsType: propsTypes.select,
factory: (props) => {
const newProps = {
...props,
listValues: props.options.map((o) => ({ title: o.label, value: o.value })),
};
return <Widgets.SelectWidget id={props.name} {...newProps} />;
},
},
checkbox: {
propsType: propsTypes.checkbox,
factory: ({ options, readOnly, setValue, value }) => {
value = value || [];
return (
<div>
{options.map((option, i) => {
return (
<label key={i} className="block">
<Checkbox
disabled={readOnly}
onCheckedChange={(checked) => {
const newValue = value.filter((v) => v !== option.value);
if (checked) {
newValue.push(option.value);
}
setValue(newValue);
}}
value={option.value}
checked={value.includes(option.value)}
/>
<span className="text-emphasis me-2 ms-2 text-sm">{option.label ?? ""}</span>
</label>
);
})}
</div>
);
},
},
radio: {
propsType: propsTypes.radio,
factory: ({ setValue, name, value, options, readOnly }) => {
return (
<RadioGroup
disabled={readOnly}
value={value}
onValueChange={(e) => {
setValue(e);
}}>
<>
{options.map((option, i) => (
<RadioField
label={option.label}
key={`option.${i}.radio`}
value={option.label}
id={`${name}.option.${i}.radio`}
/>
))}
</>
</RadioGroup>
);
},
},
radioInput: {
propsType: propsTypes.radioInput,
factory: function RadioInputWithLabel({
name,
label,
options,
optionsInputs,
value,
setValue,
readOnly,
translatedDefaultLabel,
}) {
useEffect(() => {
if (!value) {
setValue({
value: options[0]?.value,
optionValue: "",
});
}
}, [options, setValue, value]);
const { t } = useLocale();
const didUserProvideLabel = (
label: string | undefined,
translatedDefaultLabel: string | undefined
): label is string => {
return label && translatedDefaultLabel ? translatedDefaultLabel !== label : false;
};
const getCleanLabel = (label: string): string | JSX.Element => {
if (!label) {
return "";
}
return label.search(/^https?:\/\//) !== -1 ? (
<a href={label} target="_blank">
<span className="underline">{label}</span>
</a>
) : (
label
);
};
return (
<div>
<div>
<div className="mb-2">
{options.length > 1 ? (
options.map((option, i) => {
return (
<label key={i} className="mb-1 flex items-center">
<input
type="radio"
disabled={readOnly}
name={name}
className="bg-default after:bg-default border-emphasis focus:ring-brand-default hover:bg-subtle hover:after:bg-subtle dark:checked:after:bg-brand-accent flex h-4 w-4 cursor-pointer items-center justify-center text-(--cal-brand) transition after:h-[6px] after:w-[6px] after:rounded-full after:content-[''] after:hover:block focus:ring-2 focus:ring-offset-0 ltr:mr-2 rtl:ml-2 dark:checked:hover:text-(--cal-brand)"
value={option.value}
onChange={(e) => {
setValue({
value: e.target.value,
optionValue: "",
});
}}
checked={value?.value === option.value}
/>
<span className="text-emphasis me-2 ms-2 text-sm">
{option.value === "somewhereElse"
? t("somewhere_else")
: getCleanLabel(option.label) ?? ""}
</span>
<span>
{option.value === "phone" && (
<InfoBadge content={t("number_in_international_format")} />
)}
</span>
</label>
);
})
) : (
// Use the only option itself to determine if the field is required or not.
<>
<Label className="flex items-center">
{/* We still want to show the label of the field if it is changed by the user otherwise the best label would be the option label */}
{options[0].value === "somewhereElse"
? translatedDefaultLabel
: getCleanLabel(
didUserProvideLabel(label, translatedDefaultLabel) ? label : options[0].label
)}
{!readOnly && optionsInputs[options[0].value]?.required ? (
<span className="text-default -mb-2 ml-1 text-sm font-medium">*</span>
) : null}
{options[0].value === "phone" && (
<InfoBadge content={t("number_in_international_format")} />
)}
</Label>
</>
)}
</div>
</div>
{(() => {
const optionField = optionsInputs[value?.value];
if (!optionField) {
return null;
}
return (
<div>
<ComponentForField
readOnly={!!readOnly}
field={{
...optionField,
name: "optionField",
}}
value={value?.optionValue}
setValue={(val: string | undefined) => {
setValue({
value: value?.value,
optionValue: val || "",
});
}}
/>
</div>
);
})()}
</div>
);
},
},
boolean: {
propsType: propsTypes.boolean,
factory: ({ readOnly, name, label, value, setValue }) => {
return (
<div className="flex">
<CheckboxField
name={name}
onChange={(e) => {
if (e.target.checked) {
setValue(true);
} else {
setValue(false);
}
}}
placeholder=""
checked={value}
disabled={readOnly}
description=""
// Form Builder ensures that it would be safe HTML in here if the field type supports it. So, we can safely use label value in `descriptionAsSafeHtml`
descriptionAsSafeHtml={label ?? ""}
/>
</div>
);
},
},
url: {
propsType: propsTypes.url,
factory: (props) => {
return <Widgets.TextWidget type="url" autoComplete="url" noLabel={true} {...props} />;
},
},
} as const;
// Should use `satisfies` to check if the `type` is from supported types. But satisfies doesn't work with Next.js config