feat: firstAndLastName variant for name field (#8671)

* Add split full name variant

* Share propsType across fieldTypes and components

* Simplify Components

* Add assertions for required field indicator

* Fix test as name cant be used as a custom field right now

* Make it disabled during reschedule

* Fix UI issues in dark mode

* Support adding links in boolean checkbox

* Revert "Support adding links in boolean checkbox"

This reverts commit 31252f8a5f.

* Make sure getBookingFields isnt import client side

* PR feedback addressed from Carina

* DRY Code

* Fix Dialog

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
This commit is contained in:
Hariom Balhara 2023-07-20 10:33:50 +05:30 committed by GitHub
parent 47f65bb19f
commit f1c981fdcf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1767 additions and 758 deletions

View File

@ -17,7 +17,7 @@ import {
allowDisablingHostConfirmationEmails,
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import type { EditableSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { EditableSchema } from "@calcom/features/form-builder/schema";
import { BookerLayoutSelector } from "@calcom/features/settings/BookerLayoutSelector";
import { classNames } from "@calcom/lib";
import { APP_NAME, CAL_URL } from "@calcom/lib/constants";

View File

@ -23,9 +23,9 @@ import {
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { SystemField } from "@calcom/features/bookings/lib/SystemField";
import { getBookingWithResponses } from "@calcom/features/bookings/lib/get-booking";
import {
SystemField,
getBookingFieldsWithSystemFields,
SMS_REMINDER_NUMBER_FIELD,
} from "@calcom/features/bookings/lib/getBookingFields";

View File

@ -5,6 +5,7 @@ import { uuid } from "short-uuid";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
import { test } from "./lib/fixtures";
import { createHttpServer, waitFor, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
@ -40,6 +41,101 @@ test.describe("Manage Booking Questions", () => {
await runTestStepsCommonForTeamAndUserEventType(page, context, webhookReceiver);
});
test("Split 'Full name' into 'First name' and 'Last name'", async ({
page,
users,
context,
}, testInfo) => {
// Considering there are many steps in it, it would need more than default test timeout
test.setTimeout(testInfo.timeout * 3);
const user = await createAndLoginUserWithEventTypes({ page, users });
const webhookReceiver = await addWebhook(user);
await test.step("Go to first EventType Page ", async () => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
});
await test.step("Open the 'Name' field dialog", async () => {
await page.click('[href$="tabName=advanced"]');
await page.locator('[data-testid="field-name"] [data-testid="edit-field-action"]').click();
});
await test.step("Toggle on the variant toggle and save Event Type", async () => {
await page.click('[data-testid="variant-toggle"]');
await page.click("[data-testid=field-add-save]");
await saveEventType(page);
});
await test.step("Book a time slot with firstName and lastName provided separately", async () => {
await doOnFreshPreview(page, context, async (page) => {
await expectSystemFieldsToBeThereOnBookingPage({ page, isFirstAndLastNameVariant: true });
await bookTimeSlot({
page,
name: { firstName: "John", lastName: "Doe" },
email: "booker@example.com",
});
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
expect(await page.locator('[data-testid="attendee-name-John Doe"]').nth(0).textContent()).toBe(
"John Doe"
);
await expectWebhookToBeCalled(webhookReceiver, {
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
payload: {
attendees: [
{
// It would have full Name only
name: "John Doe",
email: "booker@example.com",
},
],
responses: {
name: {
label: "your_name",
value: {
firstName: "John",
lastName: "Doe",
},
},
email: {
label: "email_address",
value: "booker@example.com",
},
},
},
});
});
});
await test.step("Verify that we can prefill name and other fields correctly", async () => {
await doOnFreshPreview(page, context, async (page) => {
const url = page.url();
const prefillUrl = new URL(url);
prefillUrl.searchParams.append("name", "John Johny Janardan");
prefillUrl.searchParams.append("email", "john@example.com");
prefillUrl.searchParams.append("guests", "guest1@example.com");
prefillUrl.searchParams.append("guests", "guest2@example.com");
prefillUrl.searchParams.append("notes", "This is an additional note");
await page.goto(prefillUrl.toString());
await bookTimeSlot({ page, skipSubmission: true });
await expectSystemFieldsToBeThereOnBookingPage({
page,
isFirstAndLastNameVariant: true,
values: {
name: {
firstName: "John",
lastName: "Johny Janardan",
},
email: "john@example.com",
guests: ["guest1@example.com", "guest2@example.com"],
notes: "This is an additional note",
},
});
});
});
});
});
test.describe("For Team EventType", () => {
@ -91,12 +187,23 @@ async function runTestStepsCommonForTeamAndUserEventType(
}
) {
await page.click('[href$="tabName=advanced"]');
await test.step("Check that all the system questions are shown in the list", async () => {
await page.locator("[data-testid=field-name]").isVisible();
await page.locator("[data-testid=field-email]").isVisible();
await page.locator("[data-testid=field-notes]").isVisible();
await page.locator("[data-testid=field-guests]").isVisible();
await page.locator("[data-testid=field-rescheduleReason]").isVisible();
// It is conditional
// await page.locator("data-testid=field-location").isVisible();
});
await test.step("Add Question and see that it's shown on Booking Page at appropriate position", async () => {
await addQuestionAndSave({
page,
question: {
name: "how_are_you",
type: "Name",
type: "Address",
label: "How are you?",
placeholder: "I'm fine, thanks",
required: true,
@ -104,13 +211,13 @@ async function runTestStepsCommonForTeamAndUserEventType(
});
await doOnFreshPreview(page, context, async (page) => {
const allFieldsLocator = await expectSystemFieldsToBeThere(page);
const allFieldsLocator = await expectSystemFieldsToBeThereOnBookingPage({ page });
const userFieldLocator = allFieldsLocator.nth(5);
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 getLabelText(userFieldLocator)).toBe("How are you?");
await expect(userFieldLocator.locator("input[type=text]")).toBeVisible();
await expect(userFieldLocator.locator("input")).toBeVisible();
});
});
@ -217,7 +324,24 @@ async function runTestStepsCommonForTeamAndUserEventType(
});
}
async function expectSystemFieldsToBeThere(page: Page) {
async function expectSystemFieldsToBeThereOnBookingPage({
page,
isFirstAndLastNameVariant,
values,
}: {
page: Page;
isFirstAndLastNameVariant?: boolean;
values?: Partial<{
name: {
firstName?: string;
lastName?: string;
fullName?: string;
};
email: string;
notes: string;
guests: string[];
}>;
}) {
const allFieldsLocator = page.locator("[data-fob-field-name]:not(.hidden)");
const nameLocator = allFieldsLocator.nth(0);
const emailLocator = allFieldsLocator.nth(1);
@ -226,11 +350,44 @@ async function expectSystemFieldsToBeThere(page: Page) {
const additionalNotes = allFieldsLocator.nth(3);
const guestsLocator = allFieldsLocator.nth(4);
await expect(nameLocator.locator('[name="name"]')).toBeVisible();
await expect(emailLocator.locator('[name="email"]')).toBeVisible();
if (isFirstAndLastNameVariant) {
if (values?.name) {
await expect(nameLocator.locator('[name="firstName"]')).toHaveValue(values?.name?.firstName || "");
await expect(nameLocator.locator('[name="lastName"]')).toHaveValue(values?.name?.lastName || "");
expect(await nameLocator.locator(".testid-firstName > label").innerText()).toContain("*");
} else {
await expect(nameLocator.locator('[name="firstName"]')).toBeVisible();
await expect(nameLocator.locator('[name="lastName"]')).toBeVisible();
}
} else {
if (values?.name) {
await expect(nameLocator.locator('[name="name"]')).toHaveValue(values?.name?.fullName || "");
}
await expect(nameLocator.locator('[name="name"]')).toBeVisible();
expect(await nameLocator.locator("label").innerText()).toContain("*");
}
await expect(additionalNotes.locator('[name="notes"]')).toBeVisible();
await expect(guestsLocator.locator("button")).toBeVisible();
if (values?.email) {
await expect(emailLocator.locator('[name="email"]')).toHaveValue(values?.email || "");
} else {
await expect(emailLocator.locator('[name="email"]')).toBeVisible();
}
if (values?.notes) {
await expect(additionalNotes.locator('[name="notes"]')).toHaveValue(values?.notes);
} else {
await expect(additionalNotes.locator('[name="notes"]')).toBeVisible();
}
if (values?.guests) {
const allGuestsLocators = guestsLocator.locator('[type="email"]');
for (let i = 0; i < values.guests.length; i++) {
await expect(allGuestsLocators.nth(i)).toHaveValue(values.guests[i] || "");
}
await expect(guestsLocator.locator("[data-testid='add-another-guest']")).toBeVisible();
} else {
await expect(guestsLocator.locator("[data-testid='add-guests']")).toBeVisible();
}
return allFieldsLocator;
}
@ -238,11 +395,33 @@ async function expectSystemFieldsToBeThere(page: Page) {
// Verify webhook is sent with the correct data, DB is correct (including metadata)
//TODO: Verify that prefill works
async function bookTimeSlot({ page, name, email }: { page: Page; name: string; email: string }) {
// --- fill form
await page.fill('[name="name"]', name);
await page.fill('[name="email"]', email);
await page.press('[name="email"]', "Enter");
async function bookTimeSlot({
page,
name,
email,
skipSubmission = false,
}: {
page: Page;
name?: string | { firstName: string; lastName?: string };
email?: string;
skipSubmission?: boolean;
}) {
if (name) {
if (typeof name === "string") {
await page.fill('[name="name"]', name);
} else {
await page.fill('[name="firstName"]', name.firstName);
if (name.lastName) {
await page.fill('[name="lastName"]', name.lastName);
}
}
}
if (email) {
await page.fill('[name="email"]', email);
}
if (!skipSubmission) {
await page.press('[name="email"]', "Enter");
}
}
/**
@ -424,3 +603,22 @@ async function addWebhook(
return webhookReceiver;
}
async function expectWebhookToBeCalled(
webhookReceiver: Awaited<ReturnType<typeof addWebhook>>,
expectedBody: {
triggerEvent: WebhookTriggerEvents;
payload: Omit<Partial<CalendarEvent>, "attendees"> & {
attendees: Partial<CalendarEvent["attendees"][number]>[];
};
}
) {
await waitFor(() => {
expect(webhookReceiver.requestList.length).toBe(1);
});
const [request] = webhookReceiver.requestList;
const body = request.body;
expect(body).toMatchObject(expectedBody);
}

View File

@ -1870,6 +1870,9 @@
"open_dialog_with_element_click": "Open your Cal dialog when someone clicks an element.",
"need_help_embedding": "Need help? See our guides for embedding Cal on Wix, Squarespace, or WordPress, check our common questions, or explore advanced embed options.",
"book_my_cal": "Book my Cal",
"first_name": "First name",
"last_name": "Last name",
"first_last_name": "First name, Last name",
"invite_as": "Invite as",
"form_updated_successfully": "Form updated successfully.",
"disable_attendees_confirmation_emails": "Disable default confirmation emails for attendees",

View File

@ -23,6 +23,7 @@ import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/
import getBookingResponsesSchema, {
getBookingResponsesPartialSchema,
} from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getFullName } from "@calcom/features/form-builder/utils";
import { bookingSuccessRedirect } from "@calcom/lib/bookingSuccessRedirect";
import { MINUTES_TO_BOOK } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@ -199,12 +200,13 @@ export const BookEventForm = ({ onCancel }: BookEventFormProps) => {
const createBookingMutation = useMutation(createBooking, {
onSuccess: async (responseData) => {
const { uid, paymentUid } = responseData;
const fullName = getFullName(bookingForm.getValues("responses.name"));
if (paymentUid) {
return await router.push(
createPaymentLink({
paymentUid,
date: timeslot,
name: bookingForm.getValues("responses.name"),
name: fullName,
email: bookingForm.getValues("responses.email"),
absolute: false,
})

View File

@ -2,11 +2,11 @@ import { useFormContext } from "react-hook-form";
import type { LocationObject } from "@calcom/app-store/locations";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder";
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { SystemField } from "../../../lib/getBookingFields";
import { SystemField } from "../../../lib/SystemField";
export const BookingFields = ({
fields,
@ -26,6 +26,7 @@ export const BookingFields = ({
return (
// TODO: It might make sense to extract this logic into BookingFields config, that would allow to quickly configure system fields and their editability in fresh booking and reschedule booking view
// The logic here intends to make modifications to booking fields based on the way we want to specifically show Booking Form
<div>
{fields.map((field, index) => {
// During reschedule by default all system fields are readOnly. Make them editable on case by case basis.
@ -33,7 +34,6 @@ export const BookingFields = ({
let readOnly =
(field.editable === "system" || field.editable === "system-but-optional") && !!rescheduleUid;
let noLabel = false;
let hidden = !!field.hidden;
const fieldViews = field.views;
@ -88,28 +88,10 @@ export const BookingFields = ({
field.options = options.filter(
(location): location is NonNullable<(typeof options)[number]> => !!location
);
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
if (field.options.length === 1) {
if (field.optionsInputs[field.options[0].value]) {
noLabel = true;
} else {
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
hidden = true;
}
}
}
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
return (
<FormBuilderField
className="mb-4"
field={{ ...field, label, placeholder, hidden }}
readOnly={readOnly}
key={index}
/>
<FormBuilderField className="mb-4" field={{ ...field, hidden }} readOnly={readOnly} key={index} />
);
})}
</div>

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const SystemField = z.enum([
"name",
"email",
"location",
"notes",
"guests",
"rescheduleReason",
"smsReminderNumber",
]);

View File

@ -1,15 +1,21 @@
import type { EventTypeCustomInput, EventType, Prisma, Workflow } from "@prisma/client";
import { z } from "zod";
import type { z } from "zod";
import slugify from "@calcom/lib/slugify";
import { EventTypeCustomInputType } from "@calcom/prisma/enums";
import {
BookingFieldType,
BookingFieldTypeEnum,
customInputSchema,
eventTypeBookingFields,
EventTypeMetaDataSchema,
} from "@calcom/prisma/zod-utils";
type Fields = z.infer<typeof eventTypeBookingFields>;
if (typeof window !== "undefined") {
console.warn("This file should not be imported on the client side");
}
export const SMS_REMINDER_NUMBER_FIELD = "smsReminderNumber";
/**
@ -42,18 +48,6 @@ export const getSmsReminderNumberSource = ({
editUrl: `/workflows/${workflowId}`,
});
type Fields = z.infer<typeof eventTypeBookingFields>;
export const SystemField = z.enum([
"name",
"email",
"location",
"notes",
"guests",
"rescheduleReason",
"smsReminderNumber",
]);
/**
* This fn is the key to ensure on the fly mapping of customInputs to bookingFields and ensuring that all the systems fields are present and correctly ordered in bookingFields
*/
@ -125,12 +119,12 @@ export const ensureBookingInputsHaveSystemFields = ({
// If bookingFields is set already, the migration is done.
const handleMigration = !bookingFields.length;
const CustomInputTypeToFieldType = {
[EventTypeCustomInputType.TEXT]: BookingFieldType.text,
[EventTypeCustomInputType.TEXTLONG]: BookingFieldType.textarea,
[EventTypeCustomInputType.NUMBER]: BookingFieldType.number,
[EventTypeCustomInputType.BOOL]: BookingFieldType.boolean,
[EventTypeCustomInputType.RADIO]: BookingFieldType.radio,
[EventTypeCustomInputType.PHONE]: BookingFieldType.phone,
[EventTypeCustomInputType.TEXT]: BookingFieldTypeEnum.text,
[EventTypeCustomInputType.TEXTLONG]: BookingFieldTypeEnum.textarea,
[EventTypeCustomInputType.NUMBER]: BookingFieldTypeEnum.number,
[EventTypeCustomInputType.BOOL]: BookingFieldTypeEnum.boolean,
[EventTypeCustomInputType.RADIO]: BookingFieldTypeEnum.radio,
[EventTypeCustomInputType.PHONE]: BookingFieldTypeEnum.phone,
};
const smsNumberSources = [] as NonNullable<(typeof bookingFields)[number]["sources"]>;
@ -151,10 +145,12 @@ export const ensureBookingInputsHaveSystemFields = ({
// These fields should be added before other user fields
const systemBeforeFields: typeof bookingFields = [
{
defaultLabel: "your_name",
type: "name",
// This is the `name` of the main field
name: "name",
editable: "system",
// This Label is used in Email only as of now.
defaultLabel: "your_name",
required: true,
sources: [
{
@ -332,7 +328,6 @@ export const ensureBookingInputsHaveSystemFields = ({
};
}
}
bookingFields = bookingFields.concat(missingSystemAfterFields);
return eventTypeBookingFields.brand<"HAS_SYSTEM_FIELDS">().parse(bookingFields);

View File

@ -1,7 +1,8 @@
import { isValidPhoneNumber } from "libphonenumber-js";
import z from "zod";
import type { ALL_VIEWS } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { ALL_VIEWS } from "@calcom/features/form-builder/schema";
import { fieldTypesSchemaMap, dbReadResponseSchema } from "@calcom/features/form-builder/schema";
import type { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { bookingResponses, emailSchemaRefinement } from "@calcom/prisma/zod-utils";
@ -9,19 +10,8 @@ type EventType = Parameters<typeof preprocess>[0]["eventType"];
// eslint-disable-next-line @typescript-eslint/ban-types
type View = ALL_VIEWS | (string & {});
export const bookingResponse = z.union([
z.string(),
z.boolean(),
z.string().array(),
z.object({
optionValue: z.string(),
value: z.string(),
}),
// For variantsConfig case
z.record(z.string()),
]);
export const bookingResponsesDbSchema = z.record(bookingResponse);
export const bookingResponse = dbReadResponseSchema;
export const bookingResponsesDbSchema = z.record(dbReadResponseSchema);
const catchAllSchema = bookingResponsesDbSchema;
@ -82,6 +72,16 @@ function preprocess<T extends z.ZodType>({
// If the field is not applicable in the current view, then we don't need to do any processing
return;
}
const fieldTypeSchema = fieldTypesSchemaMap[field.type as keyof typeof fieldTypesSchemaMap];
// TODO: Move all the schemas along with their respective types to fieldTypeSchema, that would make schemas shared across Routing Forms builder and Booking Question Formm builder
if (fieldTypeSchema) {
newResponses[field.name] = fieldTypeSchema.preprocess({
response: value,
isPartialSchema,
field,
});
return newResponses;
}
if (field.type === "boolean") {
// Turn a boolean in string to a real boolean
newResponses[field.name] = value === "true" || value === true;
@ -149,6 +149,19 @@ function preprocess<T extends z.ZodType>({
return;
}
const fieldTypeSchema = fieldTypesSchemaMap[bookingField.type as keyof typeof fieldTypesSchemaMap];
if (fieldTypeSchema) {
fieldTypeSchema.superRefine({
response: value,
ctx,
m,
field: bookingField,
isPartialSchema,
});
return;
}
if (bookingField.type === "multiemail") {
const emailsParsed = emailSchema.array().safeParse(value);
if (!emailsParsed.success) {
@ -217,9 +230,9 @@ function preprocess<T extends z.ZodType>({
return;
}
if (
["address", "text", "select", "name", "number", "radio", "textarea"].includes(bookingField.type)
) {
// Use fieldTypeConfig.propsType to validate for propsType=="text" or propsType=="select" as in those cases, the response would be a string.
// If say we want to do special validation for 'address' that can be added to `fieldTypesSchemaMap`
if (["address", "text", "select", "number", "radio", "textarea"].includes(bookingField.type)) {
const schema = stringSchema;
if (!schema.safeParse(value).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });

View File

@ -1,6 +1,6 @@
import type z from "zod";
import { SystemField } from "@calcom/features/bookings/lib/getBookingFields";
import { SystemField } from "@calcom/features/bookings/lib/SystemField";
import type { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { getBookingWithResponses } from "@calcom/lib/getBooking";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@ -43,6 +43,7 @@ export const getCalEventResponses = ({
parsedBookingFields.forEach((field) => {
const label = field.label || field.defaultLabel;
if (!label) {
//TODO: This error must be thrown while saving event-type as well so that such an event-type can't be saved
throw new Error('Missing label for booking field "' + field.name + '"');
}

View File

@ -42,6 +42,7 @@ import { deleteScheduledEmailReminder } from "@calcom/features/ee/workflows/lib/
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { deleteScheduledSMSReminder } from "@calcom/features/ee/workflows/lib/reminders/smsReminderManager";
import { deleteScheduledWhatsappReminder } from "@calcom/features/ee/workflows/lib/reminders/whatsappReminderManager";
import { getFullName } from "@calcom/features/form-builder/utils";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { isPrismaObjOrUndefined, parseRecurringEvent } from "@calcom/lib";
@ -598,6 +599,7 @@ function getBookingData({
throw new Error("`responses` must not be nullish");
}
const responses = reqBody.responses;
const { userFieldsResponses: calEventUserFieldsResponses, responses: calEventResponses } =
getCalEventResponses({
bookingFields: eventType.bookingFields,
@ -722,6 +724,8 @@ async function handler(
eventType,
});
const fullName = getFullName(bookerName);
const tAttendees = await getTranslation(language ?? "en", "common");
const tGuests = await getTranslation("en", "common");
log.debug(`Booking eventType ${eventTypeId} started`);
@ -937,7 +941,7 @@ async function handler(
const invitee = [
{
email: bookerEmail,
name: bookerName,
name: fullName,
timeZone: reqBody.timeZone,
language: { translate: tAttendees, locale: language ?? "en" },
},
@ -990,7 +994,7 @@ async function handler(
const eventNameObject = {
//TODO: Can we have an unnamed attendee? If not, I would really like to throw an error here.
attendeeName: bookerName || "Nameless",
attendeeName: fullName || "Nameless",
eventType: eventType.title,
eventName: eventType.eventName,
// TODO: Can we have an unnamed organizer? If not, I would really like to throw an error here.

View File

@ -7,7 +7,6 @@ import type {
SelectLikeComponentProps,
} from "@calcom/app-store/routing-forms/components/react-awesome-query-builder/widgets";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { BookingFieldType } from "@calcom/prisma/zod-utils";
import {
PhoneInput,
AddressInput,
@ -17,12 +16,25 @@ import {
RadioField,
EmailField,
Tooltip,
InputField,
CheckboxField,
} from "@calcom/ui";
import { UserPlus, X } from "@calcom/ui/components/icon";
import { ComponentForField } from "./FormBuilder";
import type { fieldsSchema } from "./FormBuilderFieldsSchema";
import { ComponentForField } from "./FormBuilderField";
import { propsTypes } from "./propsTypes";
import type { FieldType, variantsConfigSchema, fieldSchema } 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 =
| {
@ -53,7 +65,7 @@ type Component =
value: string;
optionValue: string;
}> & {
optionsInputs: NonNullable<z.infer<typeof fieldsSchema>[number]["optionsInputs"]>;
optionsInputs: NonNullable<z.infer<typeof fieldSchema>["optionsInputs"]>;
value: { value: string; optionValue: string };
} & {
name?: string;
@ -62,32 +74,119 @@ type Component =
>(
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;
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<BookingFieldType, Component> = {
export const Components: Record<FieldType, Component> = {
text: {
propsType: "text",
propsType: propsTypes.text,
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
},
textarea: {
propsType: "text",
propsType: propsTypes.textarea,
// TODO: Make rows configurable in the form builder
factory: (props) => <Widgets.TextAreaWidget rows={3} {...props} />,
},
number: {
propsType: "text",
propsType: propsTypes.number,
factory: (props) => <Widgets.NumberWidget noLabel={true} {...props} />,
},
name: {
propsType: "text",
propsType: propsTypes.name,
// Keep special "name" type field and later build split(FirstName and LastName) variant of it.
factory: (props) => <Widgets.TextWidget noLabel={true} {...props} />,
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: "text",
propsType: propsTypes.phone,
factory: ({ setValue, readOnly, ...props }) => {
if (!props) {
return <div />;
@ -105,7 +204,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
email: {
propsType: "text",
propsType: propsTypes.email,
factory: (props) => {
if (!props) {
return <div />;
@ -114,7 +213,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
address: {
propsType: "text",
propsType: propsTypes.address,
factory: (props) => {
return (
<AddressInput
@ -127,7 +226,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
multiemail: {
propsType: "textList",
propsType: propsTypes.multiemail,
//TODO: Make it a ui component
factory: function MultiEmail({ value, readOnly, label, setValue, ...props }) {
const placeholder = props.placeholder;
@ -213,7 +312,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
multiselect: {
propsType: "multiselect",
propsType: propsTypes.multiselect,
factory: (props) => {
const newProps = {
...props,
@ -223,7 +322,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
select: {
propsType: "select",
propsType: propsTypes.select,
factory: (props) => {
const newProps = {
...props,
@ -233,7 +332,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
checkbox: {
propsType: "multiselect",
propsType: propsTypes.checkbox,
factory: ({ options, readOnly, setValue, value }) => {
value = value || [];
return (
@ -264,7 +363,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
radio: {
propsType: "select",
propsType: propsTypes.radio,
factory: ({ setValue, name, value, options }) => {
return (
<Group
@ -287,7 +386,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
radioInput: {
propsType: "objectiveWithInput",
propsType: propsTypes.radioInput,
factory: function RadioInputWithLabel({ name, options, optionsInputs, value, setValue, readOnly }) {
useEffect(() => {
if (!value) {
@ -366,7 +465,7 @@ export const Components: Record<BookingFieldType, Component> = {
},
},
boolean: {
propsType: "boolean",
propsType: propsTypes.boolean,
factory: ({ readOnly, label, value, setValue }) => {
return (
<div className="flex">

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,343 @@
import { ErrorMessage } from "@hookform/error-message";
import type { TFunction } from "next-i18next";
import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label } from "@calcom/ui";
import { Info } from "@calcom/ui/components/icon";
import { Components, isValidValueProp } from "./Components";
import { fieldTypesConfigMap } from "./fieldTypes";
import type { fieldsSchema } from "./schema";
import { getVariantsConfig } from "./utils";
type RhfForm = {
fields: z.infer<typeof fieldsSchema>;
};
type RhfFormFields = RhfForm["fields"];
type RhfFormField = RhfFormFields[number];
type ValueProps =
| {
value: string[];
setValue: (value: string[]) => void;
}
| {
value: string;
setValue: (value: string) => void;
}
| {
value: {
value: string;
optionValue: string;
};
setValue: (value: { value: string; optionValue: string }) => void;
}
| {
value: boolean;
setValue: (value: boolean) => void;
};
export const FormBuilderField = ({
field,
readOnly,
className,
}: {
field: RhfFormFields[number];
readOnly: boolean;
className: string;
}) => {
const { t } = useLocale();
const { control, formState } = useFormContext();
const { hidden, placeholder, label } = getAndUpdateNormalizedValues(field, t);
return (
<div data-fob-field-name={field.name} className={classNames(className, hidden ? "hidden" : "")}>
<Controller
control={control}
// Make it a variable
name={`responses.${field.name}`}
render={({ field: { value, onChange }, fieldState: { error } }) => {
return (
<div>
<ComponentForField
field={{ ...field, label, placeholder, hidden }}
value={value}
readOnly={readOnly}
setValue={(val: unknown) => {
onChange(val);
}}
/>
<ErrorMessage
name="responses"
errors={formState.errors}
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;
}
message = message.replace(/\{[^}]+\}(.*)/, "$1").trim();
if (hidden) {
console.error(`Error message for hidden field:${field.name} => ${message}`);
}
return (
<div
data-testid={`error-message-${field.name}`}
className="mt-2 flex items-center text-sm text-red-700 ">
<Info className="h-3 w-3 ltr:mr-2 rtl:ml-2" />
<p>{t(message || "invalid_input")}</p>
</div>
);
}}
/>
</div>
);
}}
/>
</div>
);
};
function assertUnreachable(arg: never) {
throw new Error(`Don't know how to handle ${JSON.stringify(arg)}`);
}
// TODO: Add consistent `label` support to all the components and then remove the usage of WithLabel.
// Label should be handled by each Component itself.
const WithLabel = ({
field,
children,
readOnly,
}: {
field: Partial<RhfFormField>;
readOnly: boolean;
children: React.ReactNode;
}) => {
return (
<div>
{/* multiemail doesnt show label initially. It is shown on clicking CTA */}
{/* boolean type doesn't have a label overall, the radio has it's own label */}
{/* 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">
<span>{field.label}</span>
<span className="text-emphasis ml-1 -mb-1 text-sm font-medium leading-none">
{!readOnly && field.required ? "*" : ""}
</span>
</Label>
</div>
)}
{children}
</div>
);
};
/**
* Ensures that `labels` and `placeholders`, wherever they are, are set properly. If direct values are not set, default values from fieldTypeConfig are used.
*/
function getAndUpdateNormalizedValues(field: RhfFormFields[number], t: TFunction) {
let noLabel = false;
let hidden = !!field.hidden;
if (field.type === "radioInput") {
const options = field.options;
// If we have only one option and it has an input, we don't show the field label because Option name acts as label.
// e.g. If it's just Attendee Phone Number option then we don't show `Location` label
if (options?.length === 1) {
if (!field.optionsInputs) {
throw new Error("radioInput must have optionsInputs");
}
if (field.optionsInputs[options[0].value]) {
noLabel = true;
} else {
// If there's only one option and it doesn't have an input, we don't show the field at all because it's visible in the left side bar
hidden = true;
}
}
}
const label = noLabel ? "" : field.label || t(field.defaultLabel || "");
const placeholder = field.placeholder || t(field.defaultPlaceholder || "");
if (field.variantsConfig?.variants) {
Object.entries(field.variantsConfig.variants).forEach(([variantName, variant]) => {
variant.fields.forEach((variantField) => {
const fieldTypeVariantsConfig = fieldTypesConfigMap[field.type]?.variantsConfig;
const defaultVariantFieldLabel =
fieldTypeVariantsConfig?.variants?.[variantName]?.fieldsMap[variantField.name]?.defaultLabel;
variantField.label = variantField.label || t(defaultVariantFieldLabel || "");
});
});
}
return { hidden, placeholder, label };
}
export const ComponentForField = ({
field,
value,
setValue,
readOnly,
}: {
field: Omit<RhfFormField, "editable" | "label"> & {
// Label is optional because radioInput doesn't have a label
label?: string;
};
readOnly: boolean;
} & ValueProps) => {
const fieldType = field.type || "text";
const componentConfig = Components[fieldType];
const isValueOfPropsType = (val: unknown, propsType: typeof componentConfig.propsType) => {
const isValid = isValidValueProp[propsType](val);
return isValid;
};
// If possible would have wanted `isValueOfPropsType` to narrow the type of `value` and `setValue` accordingly, but can't seem to do it.
// So, code following this uses type assertion to tell TypeScript that everything has been validated
if (value !== undefined && !isValueOfPropsType(value, componentConfig.propsType)) {
throw new Error(
`Value ${value} is not valid for type ${componentConfig.propsType} for field ${field.name}`
);
}
if (componentConfig.propsType === "text") {
return (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
placeholder={field.placeholder}
name={field.name}
label={field.label}
readOnly={readOnly}
value={value as string}
setValue={setValue as (arg: typeof value) => void}
/>
</WithLabel>
);
}
if (componentConfig.propsType === "boolean") {
return (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
name={field.name}
label={field.label}
readOnly={readOnly}
value={value as boolean}
setValue={setValue as (arg: typeof value) => void}
placeholder={field.placeholder}
/>
</WithLabel>
);
}
if (componentConfig.propsType === "textList") {
return (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
placeholder={field.placeholder}
name={field.name}
label={field.label}
readOnly={readOnly}
value={value as string[]}
setValue={setValue as (arg: typeof value) => void}
/>
</WithLabel>
);
}
if (componentConfig.propsType === "select") {
if (!field.options) {
throw new Error("Field options is not defined");
}
return (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
readOnly={readOnly}
value={value as string}
name={field.name}
placeholder={field.placeholder}
setValue={setValue as (arg: typeof value) => void}
options={field.options.map((o) => ({ ...o, title: o.label }))}
/>
</WithLabel>
);
}
if (componentConfig.propsType === "multiselect") {
if (!field.options) {
throw new Error("Field options is not defined");
}
return (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
placeholder={field.placeholder}
name={field.name}
readOnly={readOnly}
value={value as string[]}
setValue={setValue as (arg: typeof value) => void}
options={field.options.map((o) => ({ ...o, title: o.label }))}
/>
</WithLabel>
);
}
if (componentConfig.propsType === "objectiveWithInput") {
if (!field.options) {
throw new Error("Field options is not defined");
}
if (!field.optionsInputs) {
throw new Error("Field optionsInputs is not defined");
}
return field.options.length ? (
<WithLabel field={field} readOnly={readOnly}>
<componentConfig.factory
placeholder={field.placeholder}
readOnly={readOnly}
name={field.name}
value={value as { value: string; optionValue: string }}
setValue={setValue as (arg: typeof value) => void}
optionsInputs={field.optionsInputs}
options={field.options}
required={field.required}
/>
</WithLabel>
) : null;
}
if (componentConfig.propsType === "variants") {
const variantsConfig = getVariantsConfig(field);
if (!variantsConfig) {
return null;
}
return (
<componentConfig.factory
placeholder={field.placeholder}
readOnly={readOnly}
name={field.name}
variant={field.variant}
value={value as { value: string; optionValue: string }}
setValue={setValue as (arg: Record<string, string> | string) => void}
variants={variantsConfig.variants}
/>
);
}
assertUnreachable(componentConfig);
return null;
};

View File

@ -1,90 +0,0 @@
import { z } from "zod";
const fieldTypeEnum = z.enum([
"name",
"text",
"textarea",
"number",
"email",
"phone",
"address",
"multiemail",
"select",
"multiselect",
"checkbox",
"radio",
"radioInput",
"boolean",
]);
export const EditableSchema = z.enum([
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
"user", // Fully editable
"user-readonly", // All fields are readOnly.
]);
export type ALL_VIEWS = "ALL_VIEWS";
const fieldSchema = z.object({
name: z.string(),
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
label: z.string().optional(),
placeholder: z.string().optional(),
/**
* Supports translation
*/
defaultLabel: z.string().optional(),
defaultPlaceholder: z.string().optional(),
views: z
.object({
label: z.string(),
id: z.string(),
description: z.string().optional(),
})
.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({
// Support all types as needed
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
type: z.enum(["address", "phone", "text"]),
required: z.boolean().optional(),
placeholder: z.string().optional(),
})
)
.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(),
editable: EditableSchema.default("user").optional(),
sources: z
.array(
z.object({
// Unique ID for the `type`. If type is workflow, it's the workflow ID
id: z.string(),
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
label: z.string(),
editUrl: z.string().optional(),
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
fieldRequired: z.boolean().optional(),
})
)
.optional(),
});
export const fieldsSchema = z.array(fieldSchema);

View File

@ -0,0 +1,157 @@
import type z from "zod";
import { propsTypes } from "./propsTypes";
import type { FieldType, fieldTypeConfigSchema } from "./schema";
const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "propsType">> = {
// This won't be stored in DB. It allows UI to be configured from the codebase for all existing booking fields stored in DB as well
// Candidates for this are:
// - Anything that you want to show in App UI only.
// - Default values that are shown in UI that are supposed to be changed for existing bookingFields as well if user is using default values
name: {
label: "Name",
value: "name",
isTextType: true,
systemOnly: true,
variantsConfig: {
toggleLabel: 'Split "Full name" into "First name" and "Last name"',
defaultVariant: "fullName",
variants: {
firstAndLastName: {
label: "first_last_name",
fieldsMap: {
firstName: {
defaultLabel: "first_name",
canChangeRequirability: false,
},
lastName: {
defaultLabel: "last_name",
canChangeRequirability: true,
},
},
},
fullName: {
label: "your_name",
fieldsMap: {
fullName: {
defaultLabel: "your_name",
defaultPlaceholder: "example_name",
canChangeRequirability: false,
},
},
},
},
defaultValue: {
variants: {
firstAndLastName: {
// Configures variant fields
// This array form(in comparison to a generic component form) has the benefit that we can allow configuring placeholder, label, required etc. for each variant
// Doing this in a generic component form would require a lot of work in terms of supporting variables maybe that would be read by the component.
fields: [
{
// This name won't be configurable by user. User can always configure the main field name
name: "firstName",
type: "text",
required: true,
},
{
name: "lastName",
type: "text",
required: false,
},
],
},
fullName: {
fields: [
{
name: "fullName",
type: "text",
label: "Your Name",
required: true,
},
],
},
},
},
},
},
email: {
label: "Email",
value: "email",
isTextType: true,
},
phone: {
label: "Phone",
value: "phone",
isTextType: true,
},
address: {
label: "Address",
value: "address",
isTextType: true,
},
text: {
label: "Short Text",
value: "text",
isTextType: true,
},
number: {
label: "Number",
value: "number",
isTextType: true,
},
textarea: {
label: "Long Text",
value: "textarea",
isTextType: true,
},
select: {
label: "Select",
value: "select",
needsOptions: true,
isTextType: true,
},
multiselect: {
label: "MultiSelect",
value: "multiselect",
needsOptions: true,
isTextType: false,
},
multiemail: {
label: "Multiple Emails",
value: "multiemail",
isTextType: true,
},
radioInput: {
label: "Radio Input",
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",
value: "checkbox",
needsOptions: true,
isTextType: false,
},
radio: {
label: "Radio Group",
value: "radio",
needsOptions: true,
isTextType: false,
},
boolean: {
label: "Checkbox",
value: "boolean",
isTextType: false,
},
};
export const fieldTypesConfigMap = configMap as Record<FieldType, z.infer<typeof fieldTypeConfigSchema>>;
Object.entries(fieldTypesConfigMap).forEach(([fieldType, config]) => {
config.propsType = propsTypes[fieldType as keyof typeof fieldTypesConfigMap];
});

View File

@ -0,0 +1,16 @@
export const propsTypes = {
name: "variants",
email: "text",
phone: "text",
address: "text",
text: "text",
number: "text",
textarea: "text",
select: "select",
multiselect: "multiselect",
multiemail: "textList",
radioInput: "objectiveWithInput",
checkbox: "multiselect",
radio: "select",
boolean: "boolean",
} as const;

View File

@ -0,0 +1,331 @@
import { z } from "zod";
import { fieldTypesConfigMap } from "./fieldTypes";
import { getVariantsConfig, preprocessNameFieldDataWithVariant } from "./utils";
const fieldTypeEnum = z.enum([
"name",
"text",
"textarea",
"number",
"email",
"phone",
"address",
"multiemail",
"select",
"multiselect",
"checkbox",
"radio",
"radioInput",
"boolean",
]);
export type FieldType = z.infer<typeof fieldTypeEnum>;
export const EditableSchema = z.enum([
"system", // Can't be deleted, can't be hidden, name can't be edited, can't be marked optional
"system-but-optional", // Can't be deleted. Name can't be edited. But can be hidden or be marked optional
"system-but-hidden", // Can't be deleted, name can't be edited, will be shown
"user", // Fully editable
"user-readonly", // All fields are readOnly.
]);
const baseFieldSchema = z.object({
name: z.string(),
type: fieldTypeEnum,
// TODO: We should make at least one of `defaultPlaceholder` and `placeholder` required. Do the same for label.
label: z.string().optional(),
/**
* It is the default label that will be used when a new field is created.
* Note: It belongs in FieldsTypeConfig, so that changing defaultLabel in code can work for existing fields as well(for fields that are using the default label).
* Supports translation
*/
defaultLabel: z.string().optional(),
placeholder: z.string().optional(),
/**
* It is the default placeholder that will be used when a new field is created.
* Note: Same as defaultLabel, it belongs in FieldsTypeConfig
* Supports translation
*/
defaultPlaceholder: z.string().optional(),
required: z.boolean().default(false).optional(),
/**
* It is the list of options that is valid for a certain type of fields.
*
*/
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(),
/**
* For `radioInput` type of questions, it stores the input that is shown based on the user option selected.
* e.g. If user is given a list of locations and he selects "Phone", then he will be shown a phone input
*/
optionsInputs: z
.record(
z.object({
// Support all types as needed
// Must be a subset of `fieldTypeEnum`.TODO: Enforce it in TypeScript
type: z.enum(["address", "phone", "text"]),
required: z.boolean().optional(),
placeholder: z.string().optional(),
})
)
.optional(),
});
export const variantsConfigSchema = z.object({
variants: z.record(
z.object({
/**
* Variant Fields schema for a variant of the main field.
* It doesn't support non text fields as of now
**/
fields: baseFieldSchema
.omit({
defaultLabel: true,
defaultPlaceholder: true,
options: true,
getOptionsAt: true,
optionsInputs: true,
})
.array(),
})
),
});
export type ALL_VIEWS = "ALL_VIEWS";
// It is the config that is specific to a type and doesn't make sense in all fields individually. Any field with the type will automatically inherit this config.
// This allows making changes to the UI without having to make changes to the existing stored configs
export const fieldTypeConfigSchema = z
.object({
label: z.string(),
value: fieldTypeEnum,
isTextType: z.boolean().default(false).optional(),
systemOnly: z.boolean().default(false).optional(),
needsOptions: z.boolean().default(false).optional(),
propsType: z.enum([
"text",
"textList",
"select",
"multiselect",
"boolean",
"objectiveWithInput",
"variants",
]),
// It is the config that can tweak what an existing or a new field shows in the App UI or booker UI.
variantsConfig: z
.object({
/**
* This is the default variant that will be used when a new field is created.
*/
defaultVariant: z.string(),
/**
* Used only when there are 2 variants, so that UI can be simplified by showing a switch(with this label) instead of a Select
*/
toggleLabel: z.string().optional(),
variants: z.record(
z.object({
/**
* That's how the variant would be labelled in App UI. This label represents the field in booking questions' list
* Supports translation
*/
label: z.string(),
fieldsMap: z.record(
z.object({
/**
* Supports translation
*/
defaultLabel: z.string().optional(),
/**
* Supports translation
*/
defaultPlaceholder: z.string().optional(),
/**
* Decides if a variant field's required property can be changed or not
*/
canChangeRequirability: z.boolean().default(true).optional(),
})
),
})
),
/**
* This is the default configuration for the field.
*/
defaultValue: variantsConfigSchema.optional(),
})
.optional(),
})
.refine((data) => {
if (!data.variantsConfig) {
return;
}
const variantsConfig = data.variantsConfig;
if (!variantsConfig.variants[variantsConfig.defaultVariant]) {
throw new Error(`defaultVariant: ${variantsConfig.defaultVariant} is not in variants`);
}
return true;
});
/**
* Main field Schema
*/
export const fieldSchema = baseFieldSchema.merge(
z.object({
variant: z.string().optional(),
variantsConfig: variantsConfigSchema.optional(),
views: z
.object({
label: z.string(),
id: z.string(),
description: z.string().optional(),
})
.array()
.optional(),
/**
* It is used to hide fields such as location when there are less than two options
*/
hideWhenJustOneOption: z.boolean().default(false).optional(),
hidden: z.boolean().optional(),
editable: EditableSchema.default("user").optional(),
sources: z
.array(
z.object({
// Unique ID for the `type`. If type is workflow, it's the workflow ID
id: z.string(),
type: z.union([z.literal("user"), z.literal("system"), z.string()]),
label: z.string(),
editUrl: z.string().optional(),
// Mark if a field is required by this source or not. This allows us to set `field.required` based on all the sources' fieldRequired value
fieldRequired: z.boolean().optional(),
})
)
.optional(),
})
);
export const fieldsSchema = z.array(fieldSchema);
export const fieldTypesSchemaMap: Partial<
Record<
FieldType,
{
/**
* - preprocess the responses received through prefill query params
* - preprocess the values being filled in the booking form.
* - does not run for the responses received from DB
*/
preprocess: (data: {
field: z.infer<typeof fieldSchema>;
response: any;
isPartialSchema: boolean;
}) => unknown;
/**
* - Validates the response received through prefill query params
* - Validates the values being filled in the booking form.
* - does not run for the responses received from DB
*/
superRefine: (data: {
field: z.infer<typeof fieldSchema>;
response: any;
isPartialSchema: boolean;
ctx: z.RefinementCtx;
m: (key: string) => string;
}) => void;
}
>
> = {
name: {
preprocess: ({ response, field }) => {
const fieldTypeConfig = fieldTypesConfigMap[field.type];
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
let correctedVariant: "firstAndLastName" | "fullName";
if (!variantInResponse) {
throw new Error("`variant` must be there for the field with `variantsConfig`");
}
if (variantInResponse !== "firstAndLastName" && variantInResponse !== "fullName") {
correctedVariant = "fullName";
} else {
correctedVariant = variantInResponse;
}
return preprocessNameFieldDataWithVariant(correctedVariant, response);
},
superRefine: ({ field, response, isPartialSchema, ctx, m }) => {
const stringSchema = z.string();
const fieldTypeConfig = fieldTypesConfigMap[field.type];
const variantInResponse = field.variant || fieldTypeConfig?.variantsConfig?.defaultVariant;
if (!variantInResponse) {
throw new Error("`variant` must be there for the field with `variantsConfig`");
}
const variantsConfig = getVariantsConfig(field);
if (!variantsConfig) {
throw new Error("variantsConfig must be there for `name` field");
}
const fields =
variantsConfig.variants[variantInResponse as keyof typeof variantsConfig.variants].fields;
const variantSupportedFields = ["text"];
if (fields.length === 1) {
const field = fields[0];
if (variantSupportedFields.includes(field.type)) {
const schema = stringSchema;
if (!schema.safeParse(response).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
}
return;
} else {
throw new Error(`Unsupported field.type with variants: ${field.type}`);
}
}
fields.forEach((subField) => {
const schema = stringSchema;
if (!variantSupportedFields.includes(subField.type)) {
throw new Error(`Unsupported field.type with variants: ${subField.type}`);
}
const valueIdentified = response as unknown as Record<string, string>;
if (subField.required) {
if (!schema.safeParse(valueIdentified[subField.name]).success) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m("Invalid string") });
return;
}
if (!isPartialSchema && !valueIdentified[subField.name])
ctx.addIssue({ code: z.ZodIssueCode.custom, message: m(`error_required_field`) });
}
});
},
},
};
/**
* DB Read schema has no field type based validation because user might change the type of a field from Type1 to Type2 after some data has been collected with Type1.
* Parsing that type1 data with type2 schema will fail.
* So, we just validate that the response conforms to one of the field types' schema.
*/
export const dbReadResponseSchema = z.union([
z.string(),
z.boolean(),
z.string().array(),
z.object({
optionValue: z.string(),
value: z.string(),
}),
// For variantsConfig case
z.record(z.string()),
]);

View File

@ -0,0 +1,69 @@
import type z from "zod";
import { fieldTypesConfigMap } from "./fieldTypes";
import type { fieldSchema } from "./schema";
export const preprocessNameFieldDataWithVariant = (
variantName: "fullName" | "firstAndLastName",
value: string | Record<"firstName" | "lastName", string>
) => {
// We expect an object here, but if we get a string, then we will try to transform it into the appropriate object
if (variantName === "firstAndLastName") {
return getFirstAndLastName(value);
// We expect a string here, but if we get an object, then we will try to transform it into the appropriate string
} else {
return getFullName(value);
}
};
export const getFullName = (name: string | { firstName: string; lastName?: string }) => {
if (!name) {
return name;
}
let nameString = "";
if (typeof name === "string") {
nameString = name;
} else {
nameString = name.firstName + " " + name.lastName;
}
return nameString;
};
function getFirstAndLastName(value: string | Record<"firstName" | "lastName", string>) {
let newValue: Record<"firstName" | "lastName", string>;
if (typeof value === "string") {
try {
// Support name={"firstName": "John", "lastName": "Johny Janardan"} for prefilling
newValue = JSON.parse(value);
} catch (e) {
// Support name="John Johny Janardan" to be filled as firstName="John" and lastName="Johny Janardan"
const parts = value.split(" ").map((part) => part.trim());
const firstName = parts[0];
const lastName = parts.slice(1).join(" ");
// If the value is not a valid JSON, then we will just use the value as is as it can be the full name directly
newValue = { firstName, lastName };
}
} else {
newValue = value;
}
return newValue;
}
/**
* Get's the field's variantsConfig and if not available, then it will get the default variantsConfig from the fieldTypesConfigMap
*/
export const getVariantsConfig = (field: Pick<z.infer<typeof fieldSchema>, "variantsConfig" | "type">) => {
const fieldVariantsConfig = field.variantsConfig;
const fieldTypeConfig = fieldTypesConfigMap[field.type as keyof typeof fieldTypesConfigMap];
if (!fieldTypeConfig) throw new Error(`Invalid field.type ${field.type}}`);
const defaultVariantsConfig = fieldTypeConfig?.variantsConfig?.defaultValue;
const variantsConfig = fieldVariantsConfig || defaultVariantsConfig;
if (fieldTypeConfig.propsType === "variants" && !variantsConfig) {
throw new Error(`propsType variants must have variantsConfig`);
}
return variantsConfig;
};

View File

@ -15,11 +15,14 @@ import type {
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
import dayjs from "@calcom/dayjs";
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/FormBuilderFieldsSchema";
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
import { fieldsSchema as formBuilderFieldsSchema } from "@calcom/features/form-builder/schema";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { slugify } from "@calcom/lib/slugify";
import { EventTypeCustomInputType } from "@calcom/prisma/enums";
export const nonEmptyString = () => z.string().refine((value: string) => value.trim().length > 0);
// Let's not import 118kb just to get an enum
export enum Frequency {
YEARLY = 0,
@ -106,15 +109,24 @@ export const EventTypeMetaDataSchema = z
.nullable();
export const eventTypeBookingFields = formBuilderFieldsSchema;
export const BookingFieldType = eventTypeBookingFields.element.shape.type.Enum;
export type BookingFieldType = typeof BookingFieldType extends z.Values<infer T> ? T[number] : never;
export const BookingFieldTypeEnum = eventTypeBookingFields.element.shape.type.Enum;
export type BookingFieldType = FormBuilderFieldType;
// Validation of user added bookingFields' responses happen using `getBookingResponsesSchema` which requires `eventType`.
// So it is a dynamic validation and thus entire validation can't exist here
export const bookingResponses = z
.object({
email: z.string(),
name: z.string().refine((value: string) => value.trim().length > 0),
//TODO: Why don't we move name out of bookingResponses and let it be handled like user fields?
name: z.union([
nonEmptyString(),
z.object({
firstName: nonEmptyString(),
lastName: nonEmptyString()
.refine((value: string) => value.trim().length > 0)
.optional(),
}),
]),
guests: z.array(z.string()).optional(),
notes: z.string().optional(),
location: z

View File

@ -2,6 +2,7 @@ import { z } from "zod";
import { _DestinationCalendarModel, _EventTypeModel } from "@calcom/prisma/zod";
import { customInputSchema, EventTypeMetaDataSchema, stringOrNumber } from "@calcom/prisma/zod-utils";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
export const EventTypeUpdateInput = _EventTypeModel
/** Optional fields */
@ -39,6 +40,7 @@ export const EventTypeUpdateInput = _EventTypeModel
.partial()
.extend({
metadata: EventTypeMetaDataSchema.optional(),
bookingFields: eventTypeBookingFields.optional(),
})
.merge(
_EventTypeModel

View File

@ -52,6 +52,7 @@ type InputFieldProps = {
error?: string;
labelSrOnly?: boolean;
containerClassName?: string;
showAsteriskIndicator?: boolean;
t?: (key: string) => string;
} & React.ComponentProps<typeof Input> & {
labelProps?: React.ComponentProps<typeof Label>;
@ -106,6 +107,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
labelSrOnly,
containerClassName,
readOnly,
showAsteriskIndicator,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
t: __t,
...passThrough
@ -123,6 +125,9 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
{...labelProps}
className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}>
{label}
{showAsteriskIndicator && !readOnly && passThrough.required ? (
<span className="text-default ml-1 font-medium">*</span>
) : null}
{LockedIcon}
</Skeleton>
)}