* ui playground * add types to ui * push avatar ignore ts and eslint errors * feat: V3 UI avatar (#18997) * avatar playground * add overflow-hidden back for loading indicator * update lock * feat: v3/badge (#18999) * badge intro * fix text on small badge * add dark mode warning * feat: v3/button (#19014) * TOC * wip buttons * shadows * secondary * semantic colours * minimal buttons * update disabled states * checkbox * dropdown v3 * load icons * fix design sections * fix icon gap * fix * remove client component from rsc * use peer as avatar * gif * fix button gap - dropdown iocn * support start icon in dropdowns * add alert demo + v3 component + darken semantic text * base inputs and text field * wip input * input field addon/suffix * toast page * wip select * match designs for input and select rounded * fix * fix select isloading transform * fix multi icons transition rotation * fix group heading * range slider demo + range slider * range slider popover design * v3 dialog * fix slider thumb size * wrap in suspense for useSearchParam difference in next14+ * base of tabs + horizontal tabs * fix select sizing to match input 36px * fix dark mode hover on button * target domState class for light/dark so we work on portal elements * fix default bg on popover * lightmode toasts + eleveation token * empty screen * intro to split button concept * mass colour updates * intro to themes * remove i18n from label with name * bouncy buttons * feat: small select * nav item dumb component * full demo * settings demo * fix tabs * fix mt * fix dropdown feedback from loom * alert fixes mentioned in loom * push font-cal * move to fumadocs * fix avatar * colors table * add typeography * icon + spacing guide * add border radius and shadow tables * fix colours and border radius * remove prose undline via fumas fork * fix icon buttons sizes * button group component * fix horizontal tabs * tab fixes * switches * pagination component * show toasts components raw * sync with airtable * add use client * 10px on input again * 10px in select also * fix input height * fix files merge * fix loading state for icon buttons * fix button fab * Enable addon to now overflow to next line in DS * use cal font for dialog text * add avatar square variant * fix select padding and menu size * fix type errors * fix type errors * add switch small size and fix type errors * fix ref types on input * update web styles * remove addonFilled to fix type errors * push dialog fixes * fix checkbox display * remove prose from alert examples * remove prose from all our component examples * badge rounded-lg on lg size * update colors * force w/h on icon buttons instead of inheriting * remove border-focus * remove border-focus from tailwind config * update tokens to match new colours * add new border subtle tokens and add to toasts * fix lg button padding * fix and drastically improve rtl rests * multi select use badges * use badge design for select pills * fix avatar alignment * fix tests and remove redudant ones * fix tests and remove redudant ones * update atoms globals css * extract tokens.css * extract app.css * force flex for atoms * add @calcom/ui/css * revert ui playground styles abstraction * fix input focus states * fix focus states for buttons * feat: v3/app work (#19233) * globals.css for app + event types listing search * few app wide NITS * fix select foucs within styling * fix formbuilder switch styles * fix disabled state of nested inputs * workflow fixes * webhook form fixes * i18n on tabs info * booking filters + tab * fix filters spacing * make first item so we get correct border radius if not default * round radio group in create event types + make button small * profile page * update editor br * button nits in existing places in the app * fix timezone select and multilist in guest form * fix text area on public booking page * size small event types create * margin on routing filter * filters improvments * routing insights fixes * fix date filter range * ooo fixes * fix teams/members bulk actions * create team UI * i18n in useTabs instead of in tab component now * Add event type setup border to availability * fix bulk actions on org members table * onboarding fixes * fix input types with ref stripe comparision * i18n fixes for platform * update globals of atoms * fix form builder firstname/lastname switch + correct footer useage * fix select * Revert "add @calcom/ui/css" This reverts commit 0dd8f9012907a278cfede4a98f289cb0744c9244. * revert tokens css * Revert "extract app.css" This reverts commit 10b998604816ad3310c24b36a02a638dabc5be24. * linear nits * workflow dropdown * fix page theme heading sizes to match cards * fix stepper for team creation * adjust margin to FilterSearchField * fix ooo icon * fix booker and dark mode due to darkgray 50 * more dark mode fixes * add size prop --------- Co-authored-by: Eunjae Lee <hey@eunjae.dev> Co-authored-by: Morgan Vernay <morgan@cal.com> * remove ui-playground * fix grow missing from merge * add search icon data-test-id back to search * fix FormAction style * fix datatestid for apps and tabs * change selector for team invitation e2e * fix use data-testid from item instaed of using names * rename upper case O * fix unit tests data-testid * fix unit tests data-testid * more data-testid changes * add data-testid to apps tab * data-testid to installed appPage * fix embed tests * fix filter-popover-trigger * use text-error and use dataId for locator * more e2e improvment * pass in single testId as button is now duplicate * show field-error instead * add test to eventtype tabs * Add css vars to embed too\ * add scrollbar and change dialog overlay colour * fix type * use attribute instead of class for tab detection * await promise * use new tokens for sprite generation * add correct testing Ids to e2e instead of button:text * use testIds for creation of orgs * Webhook receiver wait for 10 seconds --------- Co-authored-by: Eunjae Lee <hey@eunjae.dev> Co-authored-by: Morgan Vernay <morgan@cal.com> Co-authored-by: Hariom <hariombalhara@gmail.com> Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
550 lines
18 KiB
TypeScript
550 lines
18 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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
|
import {
|
|
AddressInput,
|
|
Button,
|
|
CheckboxField,
|
|
EmailField,
|
|
Group,
|
|
Icon,
|
|
InfoBadge,
|
|
InputField,
|
|
Label,
|
|
PhoneInput,
|
|
RadioField,
|
|
Tooltip,
|
|
} from "@calcom/ui";
|
|
|
|
import { ComponentForField } from "./FormBuilderField";
|
|
import { propsTypes } from "./propsTypes";
|
|
import type { fieldSchema, FieldType, variantsConfigSchema } from "./schema";
|
|
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"
|
|
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"
|
|
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 <Widgets.TextWidget type="email" id={props.name} noLabel={true} {...props} />;
|
|
},
|
|
},
|
|
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">
|
|
<input
|
|
type="checkbox"
|
|
disabled={readOnly}
|
|
onChange={(e) => {
|
|
const newValue = value.filter((v) => v !== option.value);
|
|
if (e.target.checked) {
|
|
newValue.push(option.value);
|
|
}
|
|
setValue(newValue);
|
|
}}
|
|
className="border-default dark:border-default hover:bg-subtle checked:hover:bg-brand-default checked:bg-brand-default dark:checked:bg-brand-default dark:hover:bg-subtle dark:checked:hover:bg-brand-default h-4 w-4 cursor-pointer rounded transition ltr:mr-2 rtl:ml-2"
|
|
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 (
|
|
<Group
|
|
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`}
|
|
/>
|
|
))}
|
|
</>
|
|
</Group>
|
|
);
|
|
},
|
|
},
|
|
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" noLabel={true} {...props} />;
|
|
},
|
|
},
|
|
} as const;
|
|
// Should use `statisfies` to check if the `type` is from supported types. But satisfies doesn't work with Next.js config
|