fix: ui and ux improvements in booking questions (#7734)
This commit is contained in:
@@ -11,7 +11,6 @@ import { z } from "zod";
|
||||
|
||||
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { createPaymentLink } from "@calcom/app-store/stripepayment/lib/client";
|
||||
import { getEventTypeAppData } from "@calcom/app-store/utils";
|
||||
import type { LocationObject } from "@calcom/core/location";
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
import getBookingResponsesSchema, {
|
||||
getBookingResponsesPartialSchema,
|
||||
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
|
||||
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilder";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
@@ -143,26 +143,14 @@ const BookingFields = ({
|
||||
}
|
||||
const optionsInputs = field.optionsInputs;
|
||||
|
||||
const options = locations.map((location) => {
|
||||
const eventLocation = getEventLocationType(location.type);
|
||||
const locationString = locationKeyToString(location);
|
||||
|
||||
if (typeof locationString !== "string" || !eventLocation) {
|
||||
// It's possible that location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
const type = eventLocation.type;
|
||||
const optionInput = optionsInputs[type as keyof typeof optionsInputs];
|
||||
// TODO: Instead of `getLocationOptionsForSelect` options should be retrieved from dataStore[field.getOptionsAt]. It would make it agnostic of the `name` of the field.
|
||||
const options = getLocationOptionsForSelect(locations, t);
|
||||
options.forEach((option) => {
|
||||
const optionInput = optionsInputs[option.value as keyof typeof optionsInputs];
|
||||
if (optionInput) {
|
||||
optionInput.placeholder = t(eventLocation?.attendeeInputPlaceholder || "");
|
||||
optionInput.placeholder = option.inputPlaceholder;
|
||||
}
|
||||
|
||||
return {
|
||||
label: t(locationString),
|
||||
value: type,
|
||||
};
|
||||
});
|
||||
|
||||
field.options = options.filter(
|
||||
(location): location is NonNullable<(typeof options)[number]> => !!location
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import type { EventNameObjectType } from "@calcom/core/event";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import getLocationsOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
|
||||
import DestinationCalendarSelector from "@calcom/features/calendars/DestinationCalendarSelector";
|
||||
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
|
||||
import { classNames } from "@calcom/lib";
|
||||
@@ -148,6 +149,11 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: getLocationsOptionsForSelect(eventType?.locations ?? [], t),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<hr className="border-subtle" />
|
||||
<RequiresConfirmationController
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Page, PlaywrightTestArgs } from "@playwright/test";
|
||||
import type { Locator, Page, PlaywrightTestArgs } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { WebhookTriggerEvents } from "@prisma/client";
|
||||
import type { createUsersFixture } from "playwright/fixtures/users";
|
||||
@@ -9,6 +9,10 @@ import prisma from "@calcom/prisma";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
|
||||
async function getLabelText(field: Locator) {
|
||||
return await field.locator("label").first().locator("span").first().innerText();
|
||||
}
|
||||
|
||||
test.describe("Manage Booking Questions", () => {
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
@@ -91,7 +95,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
|
||||
await expect(userFieldLocator.locator('[name="how_are_you"]')).toBeVisible();
|
||||
// There are 2 labels right now. Will be one in future. The second one is hidden
|
||||
expect(await userFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
expect(await getLabelText(userFieldLocator)).toBe("How are you?");
|
||||
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -140,7 +144,7 @@ async function runTestStepsCommonForTeamAndUserEventType(
|
||||
expect(
|
||||
await formBuilderFieldLocator.locator('[name="how_are_you"]').getAttribute("placeholder")
|
||||
).toBe("I'm fine, thanks");
|
||||
expect(await formBuilderFieldLocator.locator("label").nth(0).innerText()).toBe("How are you?");
|
||||
expect(await getLabelText(formBuilderFieldLocator)).toBe("How are you?");
|
||||
await formBuilderFieldLocator.locator('[name="how_are_you"]').fill("I am great!");
|
||||
await bookTimeSlot({ page, name: "Booker", email: "booker@example.com" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
@@ -1625,8 +1625,7 @@
|
||||
"not_enough_seats": "Not enough seats",
|
||||
"form_builder_field_already_exists": "A field with this name already exists",
|
||||
"form_builder_field_add_subtitle": "Customize the questions asked on the booking page",
|
||||
"form_builder_system_field_cant_delete": "This system field can't be removed.",
|
||||
"form_builder_system_field_cant_toggle": "This system field can't be toggled.",
|
||||
"show_on_booking_page":"Show on booking page",
|
||||
"get_started_zapier_templates": "Get started with Zapier templates",
|
||||
"team_is_unpublished": "{{team}} is unpublished",
|
||||
"team_is_unpublished_description": "This team link is currently not available. Please contact the team owner or ask them publish it.",
|
||||
|
||||
@@ -1614,8 +1614,6 @@
|
||||
"booking_confirmation_failed": "Échec de la confirmation de la réservation",
|
||||
"form_builder_field_already_exists": "Un champ avec ce nom existe déjà",
|
||||
"form_builder_field_add_subtitle": "Personnalisez les questions posées sur la page de réservation.",
|
||||
"form_builder_system_field_cant_delete": "Ce champ système ne peut pas être supprimé.",
|
||||
"form_builder_system_field_cant_toggle": "Ce champ système ne peut pas être activé.",
|
||||
"get_started_zapier_templates": "Démarrer avec les modèles Zapier",
|
||||
"team_member": "Membre d'équipe",
|
||||
"a_routing_form": "Un formulaire de routage",
|
||||
|
||||
@@ -1614,8 +1614,6 @@
|
||||
"booking_confirmation_failed": "אישור ההזמנה נכשל",
|
||||
"form_builder_field_already_exists": "שדה עם שם זה כבר קיים",
|
||||
"form_builder_field_add_subtitle": "התאמת השאלות שנשאלות בדף התזמון",
|
||||
"form_builder_system_field_cant_delete": "שדה מערכת זה לא ניתן להסרה.",
|
||||
"form_builder_system_field_cant_toggle": "שדה מערכת זה לא ניתן לשינוי.",
|
||||
"get_started_zapier_templates": "התחל עם תבניות Zapier",
|
||||
"team_is_unpublished": "צוות {{team}} אינו מפורסם",
|
||||
"team_is_unpublished_description": "קישור הצוות הזה אינו זמין כעת. אנא צור קשר עם הבעלים של הצוות או בקש מהם לפרסם אותו.",
|
||||
|
||||
@@ -1614,8 +1614,6 @@
|
||||
"booking_confirmation_failed": "Не удалось подтвердить бронирование",
|
||||
"form_builder_field_already_exists": "Поле с таким именем уже существует",
|
||||
"form_builder_field_add_subtitle": "Настроить вопросы, задаваемые на странице бронирования",
|
||||
"form_builder_system_field_cant_delete": "Это системное поле нельзя удалить.",
|
||||
"form_builder_system_field_cant_toggle": "Это системное поле нельзя переключить.",
|
||||
"get_started_zapier_templates": "Начать работу с шаблонами Zapier",
|
||||
"team_member": "Участник команды",
|
||||
"a_routing_form": "Форма маршрутизации",
|
||||
|
||||
@@ -191,9 +191,9 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
type: "radioInput",
|
||||
name: "location",
|
||||
editable: "system",
|
||||
hideWhenJustOneOption: true,
|
||||
required: false,
|
||||
// Populated on the fly from locations. I don't want to duplicate storing locations and instead would like to be able to refer to locations in eventType.
|
||||
// options: `eventType.locations`
|
||||
getOptionsAt: "locations",
|
||||
optionsInputs: {
|
||||
attendeeInPerson: {
|
||||
type: "address",
|
||||
|
||||
@@ -121,12 +121,13 @@ function preprocess<T extends z.ZodType>({
|
||||
const views = bookingField.views;
|
||||
const isFieldApplicableToCurrentView =
|
||||
currentView === "ALL_VIEWS" ? true : views ? views.find((view) => view.id === currentView) : true;
|
||||
let hidden = bookingField.hidden;
|
||||
const numOptions = bookingField.options?.length ?? 0;
|
||||
if (bookingField.hideWhenJustOneOption) {
|
||||
hidden = hidden || numOptions <= 1;
|
||||
}
|
||||
// If the field is hidden, then it can never be required
|
||||
const isRequired = bookingField.hidden
|
||||
? false
|
||||
: isFieldApplicableToCurrentView
|
||||
? bookingField.required
|
||||
: false;
|
||||
const isRequired = hidden ? false : isFieldApplicableToCurrentView ? bookingField.required : false;
|
||||
|
||||
if ((isPartialSchema || !isRequired) && value === undefined) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { locationKeyToString } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType } from "@calcom/app-store/locations";
|
||||
import type { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import notEmpty from "@calcom/lib/notEmpty";
|
||||
|
||||
export default function getLocationsOptionsForSelect(
|
||||
locations: LocationObject[],
|
||||
t: ReturnType<typeof useLocale>["t"]
|
||||
) {
|
||||
return locations
|
||||
.map((location) => {
|
||||
const eventLocation = getEventLocationType(location.type);
|
||||
const locationString = locationKeyToString(location);
|
||||
|
||||
if (typeof locationString !== "string" || !eventLocation) {
|
||||
// It's possible that location app got uninstalled
|
||||
return null;
|
||||
}
|
||||
const type = eventLocation.type;
|
||||
|
||||
return {
|
||||
label: t(locationString),
|
||||
value: type,
|
||||
inputPlaceholder: t(eventLocation?.attendeeInputPlaceholder || ""),
|
||||
};
|
||||
})
|
||||
.filter(notEmpty);
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type Component =
|
||||
value: { value: string; optionValue: string };
|
||||
} & {
|
||||
name?: string;
|
||||
required?: boolean;
|
||||
}
|
||||
>(
|
||||
props: TProps
|
||||
@@ -323,8 +324,14 @@ export const Components: Record<BookingFieldType, Component> = {
|
||||
})
|
||||
) : (
|
||||
// Show option itself as label because there is just one option
|
||||
// TODO: Support asterisk for required fields
|
||||
<Label>{options[0].label}</Label>
|
||||
<>
|
||||
<Label>
|
||||
{options[0].label}
|
||||
{!readOnly && optionsInputs[options[0].value]?.required ? (
|
||||
<span className="ml-1 mb-1 text-sm font-medium leading-none dark:text-white">*</span>
|
||||
) : null}
|
||||
</Label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,10 +349,10 @@ export const Components: Record<BookingFieldType, Component> = {
|
||||
name: "optionField",
|
||||
}}
|
||||
value={value?.optionValue}
|
||||
setValue={(val: string) => {
|
||||
setValue={(val: string | undefined) => {
|
||||
setValue({
|
||||
value: value?.value,
|
||||
optionValue: val,
|
||||
optionValue: val || "",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -44,11 +44,18 @@ export const FormBuilder = function FormBuilder({
|
||||
description,
|
||||
addFieldLabel,
|
||||
formProp,
|
||||
dataStore,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
description: string;
|
||||
addFieldLabel: string;
|
||||
/**
|
||||
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
|
||||
*/
|
||||
dataStore: {
|
||||
options: Record<string, { label: string; value: string; inputPlaceholder?: string }[]>;
|
||||
};
|
||||
}) {
|
||||
const FieldTypesMap: Record<
|
||||
string,
|
||||
@@ -112,6 +119,8 @@ export const FormBuilder = function FormBuilder({
|
||||
value: "radioInput",
|
||||
isTextType: false,
|
||||
systemOnly: true,
|
||||
// This is false currently because we don't want to show the options for Location field right now. It is the only field with type radioInput.
|
||||
// needsOptions: true,
|
||||
},
|
||||
checkbox: {
|
||||
label: "Checkbox Group",
|
||||
@@ -148,13 +157,14 @@ export const FormBuilder = function FormBuilder({
|
||||
function OptionsField({
|
||||
label = "Options",
|
||||
value,
|
||||
onChange,
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
onChange = () => {},
|
||||
className = "",
|
||||
readOnly = false,
|
||||
}: {
|
||||
label?: string;
|
||||
value: { label: string; value: string }[];
|
||||
onChange: (value: { label: string; value: string }[]) => void;
|
||||
onChange?: (value: { label: string; value: string }[]) => void;
|
||||
className?: string;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
@@ -257,6 +267,7 @@ export const FormBuilder = function FormBuilder({
|
||||
|
||||
const fieldType = FieldTypesMap[fieldForm.watch("type") || "text"];
|
||||
const isFieldEditMode = fieldDialog.fieldIndex !== -1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
@@ -264,9 +275,20 @@ export const FormBuilder = function FormBuilder({
|
||||
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">{description}</p>
|
||||
<ul className="border-default divide-subtle mt-2 divide-y-2 rounded-md border ">
|
||||
{fields.map((field, index) => {
|
||||
const options = field.options
|
||||
? field.options
|
||||
: field.getOptionsAt
|
||||
? dataStore.options[field.getOptionsAt as keyof typeof dataStore]
|
||||
: [];
|
||||
const numOptions = options?.length ?? 0;
|
||||
if (field.hideWhenJustOneOption && numOptions <= 1) {
|
||||
return null;
|
||||
}
|
||||
const fieldType = FieldTypesMap[field.type];
|
||||
|
||||
const isRequired = field.required;
|
||||
const isFieldEditableSystemButOptional = field.editable === "system-but-optional";
|
||||
const isFieldEditableSystem = field.editable === "system";
|
||||
const isUserField = !isFieldEditableSystem && !isFieldEditableSystemButOptional;
|
||||
|
||||
if (!fieldType) {
|
||||
throw new Error(`Invalid field type - ${field.type}`);
|
||||
@@ -286,7 +308,7 @@ export const FormBuilder = function FormBuilder({
|
||||
<li
|
||||
key={field.name}
|
||||
data-testid={`field-${field.name}`}
|
||||
className="group relative flex items-center justify-between p-4">
|
||||
className="hover:bg-muted group relative flex items-center justify-between p-4 ">
|
||||
{index >= 1 && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -305,15 +327,15 @@ export const FormBuilder = function FormBuilder({
|
||||
)}
|
||||
<div>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center">
|
||||
<div className="text-default text-sm font-semibold ltr:mr-1 rtl:ml-1">
|
||||
<div className="text-default text-sm font-semibold ltr:mr-2 rtl:ml-2">
|
||||
{field.label || t(field.defaultLabel || "")}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{field.hidden ? (
|
||||
// Hidden field can't be required, so we don't need to show the Optional badge
|
||||
<Badge variant="gray">{t("hidden")}</Badge>
|
||||
<Badge variant="grayWithoutHover">{t("hidden")}</Badge>
|
||||
) : (
|
||||
<Badge variant="gray">{isRequired ? t("required") : t("optional")}</Badge>
|
||||
<Badge variant="grayWithoutHover">{isRequired ? t("required") : t("optional")}</Badge>
|
||||
)}
|
||||
{Object.entries(groupedBySourceLabel).map(([sourceLabel, sources], key) => (
|
||||
// We don't know how to pluralize `sourceLabel` because it can be anything
|
||||
@@ -323,43 +345,43 @@ export const FormBuilder = function FormBuilder({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-subtle max-w-[280px] break-words py-1 text-sm sm:max-w-[500px]">
|
||||
<p className="text-subtle max-w-[280px] break-words pt-1 text-sm sm:max-w-[500px]">
|
||||
{fieldType.label}
|
||||
</p>
|
||||
</div>
|
||||
{field.editable !== "user-readonly" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
data-testid="toggle-field"
|
||||
disabled={field.editable === "system"}
|
||||
tooltip={field.editable === "system" ? t("form_builder_system_field_cant_toggle") : ""}
|
||||
checked={!field.hidden}
|
||||
onCheckedChange={(checked) => {
|
||||
update(index, { ...field, hidden: !checked });
|
||||
}}
|
||||
/>
|
||||
{!isFieldEditableSystem && (
|
||||
<Switch
|
||||
data-testid="toggle-field"
|
||||
disabled={isFieldEditableSystem}
|
||||
checked={!field.hidden}
|
||||
onCheckedChange={(checked) => {
|
||||
update(index, { ...field, hidden: !checked });
|
||||
}}
|
||||
classNames={{ container: "p-2 hover:bg-gray-100 rounded" }}
|
||||
tooltip={t("show_on_booking_page")}
|
||||
/>
|
||||
)}
|
||||
{isUserField && (
|
||||
<Button
|
||||
color="destructive"
|
||||
disabled={!isUserField}
|
||||
variant="icon"
|
||||
onClick={() => {
|
||||
removeField(index);
|
||||
}}
|
||||
StartIcon={FiTrash2}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
data-testid="edit-field-action"
|
||||
color="secondary"
|
||||
onClick={() => {
|
||||
editField(index, field);
|
||||
}}>
|
||||
Edit
|
||||
{t("edit")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
tooltip={
|
||||
field.editable === "system" || field.editable === "system-but-optional"
|
||||
? t("form_builder_system_field_cant_delete")
|
||||
: ""
|
||||
}
|
||||
disabled={field.editable === "system" || field.editable === "system-but-optional"}
|
||||
variant="icon"
|
||||
onClick={() => {
|
||||
removeField(index);
|
||||
}}
|
||||
StartIcon={FiTrash2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
@@ -434,7 +456,7 @@ export const FormBuilder = function FormBuilder({
|
||||
}}
|
||||
value={FieldTypesMap[fieldForm.getValues("type")]}
|
||||
options={FieldTypes.filter((f) => !f.systemOnly)}
|
||||
label="Input Type"
|
||||
label={t("input_type")}
|
||||
/>
|
||||
<InputField
|
||||
required
|
||||
@@ -444,7 +466,7 @@ export const FormBuilder = function FormBuilder({
|
||||
fieldForm.getValues("editable") === "system" ||
|
||||
fieldForm.getValues("editable") === "system-but-optional"
|
||||
}
|
||||
label="Name"
|
||||
label="Identifier"
|
||||
/>
|
||||
<InputField
|
||||
{...fieldForm.register("label")}
|
||||
@@ -462,8 +484,7 @@ export const FormBuilder = function FormBuilder({
|
||||
placeholder={t(fieldForm.getValues("defaultPlaceholder") || "")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{fieldType?.needsOptions ? (
|
||||
{fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? (
|
||||
<Controller
|
||||
name="options"
|
||||
render={({ field: { value, onChange } }) => {
|
||||
@@ -471,6 +492,14 @@ export const FormBuilder = function FormBuilder({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{/* TODO: Maybe we should show location options in readOnly mode in Booking Questions. Right now options are not shown in Manage Booking Questions UI for location Booking Question */}
|
||||
{/* {fieldForm.getValues("getOptionsAt") ? (
|
||||
<OptionsField
|
||||
readOnly={true}
|
||||
value={dataStore.options[fieldForm.getValues("getOptionsAt") as keyof typeof dataStore]}
|
||||
className="mt-6"
|
||||
/>
|
||||
) : null} */}
|
||||
<Controller
|
||||
name="required"
|
||||
control={fieldForm.control}
|
||||
@@ -489,7 +518,7 @@ export const FormBuilder = function FormBuilder({
|
||||
}}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<DialogClose color="secondary">Cancel</DialogClose>
|
||||
<DialogClose color="secondary">{t("cancel")}</DialogClose>
|
||||
<Button data-testid="field-add-save" type="submit">
|
||||
{isFieldEditMode ? t("save") : t("add")}
|
||||
</Button>
|
||||
@@ -520,10 +549,12 @@ const WithLabel = ({
|
||||
{/* Component itself managing it's label should remove these checks */}
|
||||
{field.type !== "boolean" && field.type !== "multiemail" && field.label && (
|
||||
<div className="mb-2 flex items-center">
|
||||
<Label className="!mb-0 flex items-center">{field.label}</Label>
|
||||
<span className="dark:text-inverted ml-1 -mb-1 text-sm font-medium leading-none">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
<Label className="!mb-0">
|
||||
<span>{field.label}</span>
|
||||
<span className="dark:text-inverted ml-1 -mb-1 text-sm font-medium leading-none">
|
||||
{!readOnly && field.required ? "*" : ""}
|
||||
</span>
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
@@ -587,7 +618,6 @@ export const ComponentForField = ({
|
||||
`Value ${value} is not valid for type ${componentConfig.propsType} for field ${field.name}`
|
||||
);
|
||||
}
|
||||
|
||||
if (componentConfig.propsType === "text") {
|
||||
return (
|
||||
<WithLabel field={field} readOnly={readOnly}>
|
||||
@@ -683,6 +713,7 @@ export const ComponentForField = ({
|
||||
setValue={setValue as (arg: typeof value) => void}
|
||||
optionsInputs={field.optionsInputs}
|
||||
options={field.options}
|
||||
required={field.required}
|
||||
/>
|
||||
</WithLabel>
|
||||
) : null;
|
||||
@@ -708,7 +739,7 @@ export const FormBuilderField = ({
|
||||
control={control}
|
||||
// Make it a variable
|
||||
name={`responses.${field.name}`}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => {
|
||||
return (
|
||||
<div>
|
||||
<ComponentForField
|
||||
@@ -722,10 +753,13 @@ export const FormBuilderField = ({
|
||||
<ErrorMessage
|
||||
name="responses"
|
||||
errors={formState.errors}
|
||||
render={({ message }) => {
|
||||
const name = message?.replace(/\{([^}]+)\}.*/, "$1");
|
||||
// Use the message targeted for it.
|
||||
if (name !== field.name) {
|
||||
render={({ message }: { message: string | undefined }) => {
|
||||
message = message || "";
|
||||
// If the error comes due to parsing the `responses` object(which can have error for any field), we need to identify the field that has the error from the message
|
||||
const name = message.replace(/\{([^}]+)\}.*/, "$1");
|
||||
const isResponsesErrorForThisField = name === field.name;
|
||||
// If the error comes for the specific property of responses(Possible for system fields), then also we would go ahead and show the error
|
||||
if (!isResponsesErrorForThisField && !error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -738,7 +772,7 @@ export const FormBuilderField = ({
|
||||
data-testid={`error-message-${field.name}`}
|
||||
className="mt-2 flex items-center text-sm text-red-700 ">
|
||||
<FiInfo className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
|
||||
<p>{t(message)}</p>
|
||||
<p>{t(message || "invalid_input")}</p>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -44,7 +44,14 @@ const fieldSchema = z.object({
|
||||
.array()
|
||||
.optional(),
|
||||
type: fieldTypeEnum,
|
||||
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
/**
|
||||
* This is an alternate way to specify options when the options are stored elsewhere. Form Builder expects options to be present at `dataStore[getOptionsAt]`
|
||||
* This allows keeping a single source of truth in DB.
|
||||
*/
|
||||
getOptionsAt: z.string().optional(),
|
||||
|
||||
optionsInputs: z
|
||||
.record(
|
||||
z.object({
|
||||
@@ -56,6 +63,10 @@ const fieldSchema = z.object({
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
/**
|
||||
* It is used to hide fields such as location when there are less than two options
|
||||
*/
|
||||
hideWhenJustOneOption: z.boolean().default(false).optional(),
|
||||
|
||||
required: z.boolean().default(false).optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
|
||||
@@ -18,6 +18,7 @@ const badgeStyles = cva("font-medium inline-flex items-center justify-center rou
|
||||
blue: "bg-info text-info",
|
||||
red: "bg-error text-error",
|
||||
error: "bg-error text-error",
|
||||
grayWithoutHover: "bg-gray-100 text-gray-800 dark:bg-darkgray-200 dark:text-darkgray-800",
|
||||
},
|
||||
size: {
|
||||
sm: "px-1 py-0.5 text-xs leading-3",
|
||||
|
||||
@@ -3,7 +3,7 @@ import * as Label from "@radix-ui/react-label";
|
||||
import * as PrimitiveSwitch from "@radix-ui/react-switch";
|
||||
import React from "react";
|
||||
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import cx from "@calcom/lib/classNames";
|
||||
|
||||
import { Tooltip } from "../../tooltip";
|
||||
|
||||
@@ -16,21 +16,27 @@ const Wrapper = ({ children, tooltip }: { tooltip?: string; children: React.Reac
|
||||
const Switch = (
|
||||
props: React.ComponentProps<typeof PrimitiveSwitch.Root> & {
|
||||
label?: string;
|
||||
thumbProps?: {
|
||||
className?: string;
|
||||
};
|
||||
fitToHeight?: boolean;
|
||||
tooltip?: string;
|
||||
classNames?: {
|
||||
container?: string;
|
||||
thumb?: string;
|
||||
};
|
||||
}
|
||||
) => {
|
||||
const { label, fitToHeight, ...primitiveProps } = props;
|
||||
const { label, fitToHeight, classNames, ...primitiveProps } = props;
|
||||
const id = useId();
|
||||
|
||||
return (
|
||||
<Wrapper tooltip={props.tooltip}>
|
||||
<div className={classNames("flex h-auto w-auto flex-row items-center", fitToHeight && "h-fit")}>
|
||||
<div
|
||||
className={cx(
|
||||
"flex h-auto w-auto flex-row items-center",
|
||||
fitToHeight && "h-fit",
|
||||
classNames?.container
|
||||
)}>
|
||||
<PrimitiveSwitch.Root
|
||||
className={classNames(
|
||||
className={cx(
|
||||
props.checked ? "bg-inverted" : "bg-emphasis",
|
||||
primitiveProps.disabled && "cursor-not-allowed",
|
||||
"focus:ring-brand-default h-5 w-[34px] rounded-full shadow-none",
|
||||
@@ -39,17 +45,17 @@ const Switch = (
|
||||
{...primitiveProps}>
|
||||
<PrimitiveSwitch.Thumb
|
||||
id={id}
|
||||
className={classNames(
|
||||
className={cx(
|
||||
"block h-[14px] w-[14px] rounded-full transition will-change-transform ltr:translate-x-[4px] rtl:-translate-x-[4px] ltr:[&[data-state='checked']]:translate-x-[17px] rtl:[&[data-state='checked']]:-translate-x-[17px]",
|
||||
props.checked ? "bg-default shadow-inner" : "bg-inverted",
|
||||
props.thumbProps?.className
|
||||
classNames?.thumb
|
||||
)}
|
||||
/>
|
||||
</PrimitiveSwitch.Root>
|
||||
{label && (
|
||||
<Label.Root
|
||||
htmlFor={id}
|
||||
className={classNames(
|
||||
className={cx(
|
||||
"text-emphasis align-text-top text-sm font-medium ltr:ml-2 rtl:mr-2",
|
||||
primitiveProps.disabled ? "cursor-not-allowed opacity-25" : "cursor-pointer "
|
||||
)}>
|
||||
|
||||
@@ -32,8 +32,10 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
||||
onValueChange(defaultValue);
|
||||
return null;
|
||||
}
|
||||
const commonClass =
|
||||
"w-full inline-flex items-center justify-center rounded py-[10px] px-4 text-sm font-medium leading-4";
|
||||
const commonClass = classNames(
|
||||
"w-full inline-flex items-center justify-center rounded py-[10px] px-4 text-sm font-medium leading-4",
|
||||
disabled && "cursor-not-allowed"
|
||||
);
|
||||
const selectedClass = classNames(commonClass, "bg-emphasis text-emphasis");
|
||||
const unselectedClass = classNames(commonClass, "text-default hover:bg-subtle hover:text-emphasis");
|
||||
return (
|
||||
@@ -53,6 +55,7 @@ export const BooleanToggleGroup = function BooleanToggleGroup({
|
||||
value="yes">
|
||||
Yes
|
||||
</ToggleGroupItemPrimitive>
|
||||
|
||||
<ToggleGroupItemPrimitive
|
||||
disabled={disabled}
|
||||
className={classNames(!boolean(yesNoValue) ? selectedClass : unselectedClass)}
|
||||
|
||||
Reference in New Issue
Block a user