diff --git a/apps/web/components/eventtype/EventAdvancedTab.tsx b/apps/web/components/eventtype/EventAdvancedTab.tsx index b2ed894e9d..4d13b31352 100644 --- a/apps/web/components/eventtype/EventAdvancedTab.tsx +++ b/apps/web/components/eventtype/EventAdvancedTab.tsx @@ -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"; diff --git a/apps/web/pages/booking/[uid].tsx b/apps/web/pages/booking/[uid].tsx index 3f8d7dba9a..970fc3ca53 100644 --- a/apps/web/pages/booking/[uid].tsx +++ b/apps/web/pages/booking/[uid].tsx @@ -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"; diff --git a/apps/web/playwright/manage-booking-questions.e2e.ts b/apps/web/playwright/manage-booking-questions.e2e.ts index 69bf61d9ef..7685b91661 100644 --- a/apps/web/playwright/manage-booking-questions.e2e.ts +++ b/apps/web/playwright/manage-booking-questions.e2e.ts @@ -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>, + expectedBody: { + triggerEvent: WebhookTriggerEvents; + payload: Omit, "attendees"> & { + attendees: Partial[]; + }; + } +) { + await waitFor(() => { + expect(webhookReceiver.requestList.length).toBe(1); + }); + const [request] = webhookReceiver.requestList; + + const body = request.body; + + expect(body).toMatchObject(expectedBody); +} diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 6807d258f8..3504d723bb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx index f97d2376c2..fdf969deba 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookEventForm.tsx @@ -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, }) diff --git a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx index 49bacfd1bc..d301891770 100644 --- a/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx +++ b/packages/features/bookings/Booker/components/BookEventForm/BookingFields.tsx @@ -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
{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 ( -
diff --git a/packages/features/bookings/lib/SystemField.ts b/packages/features/bookings/lib/SystemField.ts new file mode 100644 index 0000000000..af940a8e2e --- /dev/null +++ b/packages/features/bookings/lib/SystemField.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const SystemField = z.enum([ + "name", + "email", + "location", + "notes", + "guests", + "rescheduleReason", + "smsReminderNumber", +]); diff --git a/packages/features/bookings/lib/getBookingFields.ts b/packages/features/bookings/lib/getBookingFields.ts index 450f4ecccd..ffc72da8d3 100644 --- a/packages/features/bookings/lib/getBookingFields.ts +++ b/packages/features/bookings/lib/getBookingFields.ts @@ -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; + +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; - -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); diff --git a/packages/features/bookings/lib/getBookingResponsesSchema.ts b/packages/features/bookings/lib/getBookingResponsesSchema.ts index dd61982c77..75231f6095 100644 --- a/packages/features/bookings/lib/getBookingResponsesSchema.ts +++ b/packages/features/bookings/lib/getBookingResponsesSchema.ts @@ -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[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({ // 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({ 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({ 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") }); diff --git a/packages/features/bookings/lib/getCalEventResponses.ts b/packages/features/bookings/lib/getCalEventResponses.ts index 022f46162a..176ae333b7 100644 --- a/packages/features/bookings/lib/getCalEventResponses.ts +++ b/packages/features/bookings/lib/getCalEventResponses.ts @@ -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 + '"'); } diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index f73c5ac747..08eae428e4 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -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. diff --git a/packages/features/form-builder/Components.tsx b/packages/features/form-builder/Components.tsx index eae8b68cf9..0adc3f7cc0 100644 --- a/packages/features/form-builder/Components.tsx +++ b/packages/features/form-builder/Components.tsx @@ -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 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[number]["optionsInputs"]>; + optionsInputs: NonNullable["optionsInputs"]>; value: { value: string; optionValue: string }; } & { name?: string; @@ -62,32 +74,119 @@ type Component = >( props: TProps ) => JSX.Element; + } + | { + propsType: "variants"; + factory: < + TProps extends Omit & { + variant: string | undefined; + variants: z.infer["variants"]; + value: Record | string; + setValue: (value: string | Record) => 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 = { +export const Components: Record = { text: { - propsType: "text", + propsType: propsTypes.text, factory: (props) => , }, textarea: { - propsType: "text", + propsType: propsTypes.textarea, // TODO: Make rows configurable in the form builder factory: (props) => , }, number: { - propsType: "text", + propsType: propsTypes.number, factory: (props) => , }, name: { - propsType: "text", + propsType: propsTypes.name, // Keep special "name" type field and later build split(FirstName and LastName) variant of it. - factory: (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 ( + { + props.setValue(e.target.value); + }} + /> + ); + } + + const variant = props.variants[variantName]; + + if (typeof value !== "object") { + throw new Error("Invalid value for 'fullName' variant"); + } + + return ( +
+ {variant.fields.map((variantField) => ( + onChange(variantField.name, e.target.value)} + /> + ))} +
+ ); + }, }, phone: { - propsType: "text", + propsType: propsTypes.phone, factory: ({ setValue, readOnly, ...props }) => { if (!props) { return
; @@ -105,7 +204,7 @@ export const Components: Record = { }, }, email: { - propsType: "text", + propsType: propsTypes.email, factory: (props) => { if (!props) { return
; @@ -114,7 +213,7 @@ export const Components: Record = { }, }, address: { - propsType: "text", + propsType: propsTypes.address, factory: (props) => { return ( = { }, }, 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 = { }, }, multiselect: { - propsType: "multiselect", + propsType: propsTypes.multiselect, factory: (props) => { const newProps = { ...props, @@ -223,7 +322,7 @@ export const Components: Record = { }, }, select: { - propsType: "select", + propsType: propsTypes.select, factory: (props) => { const newProps = { ...props, @@ -233,7 +332,7 @@ export const Components: Record = { }, }, checkbox: { - propsType: "multiselect", + propsType: propsTypes.checkbox, factory: ({ options, readOnly, setValue, value }) => { value = value || []; return ( @@ -264,7 +363,7 @@ export const Components: Record = { }, }, radio: { - propsType: "select", + propsType: propsTypes.radio, factory: ({ setValue, name, value, options }) => { return ( = { }, }, 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 = { }, }, boolean: { - propsType: "boolean", + propsType: propsTypes.boolean, factory: ({ readOnly, label, value, setValue }) => { return (
diff --git a/packages/features/form-builder/FormBuilder.tsx b/packages/features/form-builder/FormBuilder.tsx index 8dc9fe2df2..cdf60ae772 100644 --- a/packages/features/form-builder/FormBuilder.tsx +++ b/packages/features/form-builder/FormBuilder.tsx @@ -1,7 +1,7 @@ import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { ErrorMessage } from "@hookform/error-message"; -import { useState } from "react"; -import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-form"; +import { useState, useEffect } from "react"; +import { Controller, useFieldArray, useFormContext, useForm } from "react-hook-form"; +import type { UseFormReturn, SubmitHandler } from "react-hook-form"; import type { z } from "zod"; import { classNames } from "@calcom/lib"; @@ -19,19 +19,22 @@ import { BooleanToggleGroupField, SelectField, InputField, - showToast, + Input, Switch, + showToast, } from "@calcom/ui"; -import { ArrowDown, ArrowUp, X, Plus, Trash2, Info } from "@calcom/ui/components/icon"; +import { ArrowDown, ArrowUp, X, Plus, Trash2 } from "@calcom/ui/components/icon"; -import { Components } from "./Components"; -import type { fieldsSchema } from "./FormBuilderFieldsSchema"; +import { fieldTypesConfigMap } from "./fieldTypes"; +import type { fieldsSchema } from "./schema"; +import { getVariantsConfig } from "./utils"; type RhfForm = { fields: z.infer; }; type RhfFormFields = RhfForm["fields"]; + type RhfFormField = RhfFormFields[number]; /** @@ -60,213 +63,37 @@ export const FormBuilder = function FormBuilder({ options: Record; }; }) { - const FieldTypesMap: Record< - string, - { - value: RhfForm["fields"][number]["type"]; - label: string; - needsOptions?: boolean; - systemOnly?: boolean; - isTextType?: boolean; - } - > = { - name: { - label: "Name", - value: "name", - isTextType: true, - }, - email: { - label: "Email", - value: "email", - isTextType: true, - }, - phone: { - label: "Phone", - value: "phone", - 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, - }, - }; - const FieldTypes = Object.values(FieldTypesMap); - // I would have liked to give Form Builder it's own Form but nested Forms aren't something that browsers support. // So, this would reuse the same Form as the parent form. const fieldsForm = useFormContext(); const { t } = useLocale(); - const fieldForm = useForm(); + const { fields, swap, remove, update, append } = useFieldArray({ control: fieldsForm.control, // HACK: It allows any property name to be used for instead of `fields` property name name: formProp as unknown as "fields", }); - function OptionsField({ - label = "Options", - value, - // 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; - className?: string; - readOnly?: boolean; - }) { - const [animationRef] = useAutoAnimate(); - if (!value) { - onChange([ - { - label: "Option 1", - value: "Option 1", - }, - { - label: "Option 2", - value: "Option 2", - }, - ]); - } - - const crossButton = (index: number) => { - return ( - - )} -
-
- ); - } const [fieldDialog, setFieldDialog] = useState({ isOpen: false, fieldIndex: -1, + data: {} as RhfFormField | null, }); const addField = () => { - fieldForm.reset({}); setFieldDialog({ isOpen: true, fieldIndex: -1, + data: null, }); }; const editField = (index: number, data: RhfFormField) => { - fieldForm.reset(data); setFieldDialog({ isOpen: true, fieldIndex: index, + data, }); }; @@ -274,9 +101,6 @@ export const FormBuilder = function FormBuilder({ remove(index); }; - const fieldType = FieldTypesMap[fieldForm.watch("type") || "text"]; - const isFieldEditMode = fieldDialog.fieldIndex !== -1; - return (
@@ -296,7 +120,7 @@ export const FormBuilder = function FormBuilder({ if (field.hideWhenJustOneOption && numOptions <= 1) { return null; } - const fieldType = FieldTypesMap[field.type]; + const fieldType = fieldTypesConfigMap[field.type]; const isRequired = field.required; const isFieldEditableSystemButOptional = field.editable === "system-but-optional"; const isFieldEditableSystemButHidden = field.editable === "system-but-hidden"; @@ -347,7 +171,7 @@ export const FormBuilder = function FormBuilder({
- {field.label || t(field.defaultLabel || "")} +
{field.hidden ? ( @@ -418,122 +242,418 @@ export const FormBuilder = function FormBuilder({ )}
- - setFieldDialog({ - isOpen, - fieldIndex: -1, - }) - }> - + {/* Move this Dialog in another component and it would take with it fieldForm */} + {fieldDialog.isOpen && ( + + setFieldDialog({ + isOpen, + fieldIndex: -1, + data: null, + }) + } + handleSubmit={(data: Parameters>[0]) => { + const type = data.type || "text"; + const isNewField = !fieldDialog.data; + if (isNewField && fields.some((f) => f.name === data.name)) { + showToast(t("form_builder_field_already_exists"), "error"); + return; + } + if (fieldDialog.data) { + update(fieldDialog.fieldIndex, data); + } else { + const field: RhfFormField = { + ...data, + type, + sources: [ + { + label: "User", + type: "user", + id: "user", + fieldRequired: data.required, + }, + ], + }; + field.editable = field.editable || "user"; + append(field); + } + setFieldDialog({ + isOpen: false, + fieldIndex: -1, + data: null, + }); + }} + /> + )} +
+ ); +}; + +function Options({ + label = "Options", + value, + // 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; + className?: string; + readOnly?: boolean; +}) { + const [animationRef] = useAutoAnimate(); + if (!value) { + onChange([ + { + label: "Option 1", + value: "Option 1", + }, + { + label: "Option 2", + value: "Option 2", + }, + ]); + } + return ( +
+ +
+
    + {value?.map((option, index) => ( +
  • +
    + { + // Right now we use label of the option as the value of the option. It allows us to not separately lookup the optionId to know the optionValue + // It has the same drawback that if the label is changed, the value of the option will change. It is not a big deal for now. + value.splice(index, 1, { + label: e.target.value, + value: e.target.value.trim(), + }); + onChange(value); + }} + readOnly={readOnly} + placeholder={`Enter Option ${index + 1}`} + /> + {value.length > 2 && !readOnly && ( +
    +
  • + ))} +
+ {!readOnly && ( + + )} +
+
+ ); +} + +function FieldEditDialog({ + dialog, + onOpenChange, + handleSubmit, +}: { + dialog: { isOpen: boolean; fieldIndex: number; data: RhfFormField | null }; + onOpenChange: (isOpen: boolean) => void; + handleSubmit: SubmitHandler; +}) { + const { t } = useLocale(); + const fieldForm = useForm({ + defaultValues: dialog.data || {}, + // resolver: zodResolver(fieldSchema), + }); + useEffect(() => { + if (!fieldForm.getValues("type")) { + return; + } + + const variantsConfig = getVariantsConfig({ + type: fieldForm.getValues("type"), + variantsConfig: fieldForm.getValues("variantsConfig"), + }); + + // We need to set the variantsConfig in the RHF instead of using a derived value because RHF won't have the variantConfig for the variant that's not rendered yet. + fieldForm.setValue("variantsConfig", variantsConfig); + }, [fieldForm]); + const isFieldEditMode = !!dialog.data; + const fieldType = fieldTypesConfigMap[fieldForm.watch("type") || "text"]; + + const variantsConfig = fieldForm.watch("variantsConfig"); + + const fieldTypes = Object.values(fieldTypesConfigMap); + + return ( + + +
- { - const type = data.type || "text"; - const isNewField = fieldDialog.fieldIndex == -1; - if (isNewField && fields.some((f) => f.name === data.name)) { - showToast(t("form_builder_field_already_exists"), "error"); + { + const value = e?.value; + if (!value) { return; } - if (fieldDialog.fieldIndex !== -1) { - update(fieldDialog.fieldIndex, data); - } else { - const field: RhfFormField = { - ...data, - type, - sources: [ - { - label: "User", - type: "user", - id: "user", - fieldRequired: data.required, - }, - ], - }; - field.editable = field.editable || "user"; - append(field); - } - setFieldDialog({ - isOpen: false, - fieldIndex: -1, - }); - }}> - { - const value = e?.value; - if (!value) { - return; - } - fieldForm.setValue("type", value); - }} - value={FieldTypesMap[fieldForm.getValues("type")]} - options={FieldTypes.filter((f) => !f.systemOnly)} - label={t("input_type")} - classNames={{ - menuList: () => "min-h-[22.25rem] ", - }} - /> + fieldForm.setValue("type", value); + }} + value={fieldTypesConfigMap[fieldForm.getValues("type")]} + options={fieldTypes.filter((f) => !f.systemOnly)} + label={t("input_type")} + classNames={{ + menuList: () => "min-h-[27.25rem]", + }} + /> + {(() => { + if (!variantsConfig) { + return ( + <> + + + {fieldType?.isTextType ? ( + + ) : null} + {fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? ( + { + return ; + }} + /> + ) : null} + { + return ( + { + onChange(val); + }} + label={t("required")} + /> + ); + }} + /> + + ); + } + + if (!fieldType.isTextType) { + throw new Error("Variants are currently supported only with text type"); + } + + return ; + })()} +
+ + + {t("cancel")} + + +
+
+
+ ); +} + +/** + * Shows the label of the field, taking into account the current variant selected + */ +function FieldLabel({ field }: { field: RhfFormField }) { + const { t } = useLocale(); + const fieldTypeConfig = fieldTypesConfigMap[field.type]; + const fieldTypeConfigVariantsConfig = fieldTypeConfig?.variantsConfig; + const fieldTypeConfigVariants = fieldTypeConfigVariantsConfig?.variants; + const variantsConfig = field.variantsConfig; + const defaultVariant = fieldTypeConfigVariantsConfig?.defaultVariant; + if (!fieldTypeConfigVariants || !variantsConfig) { + return {field.label || t(field.defaultLabel || "")}; + } + const variant = field.variant || defaultVariant; + if (!variant) { + throw new Error( + "Field has `variantsConfig` but no `defaultVariant`" + JSON.stringify(fieldTypeConfigVariantsConfig) + ); + } + return {t(fieldTypeConfigVariants[variant as keyof typeof fieldTypeConfigVariants].label)}; +} + +function VariantSelector() { + // Implement a Variant selector for cases when there are more than 2 variants + return null; +} + +function VariantFields({ + fieldForm, + variantsConfig, +}: { + fieldForm: UseFormReturn; + variantsConfig: RhfFormField["variantsConfig"]; +}) { + const { t } = useLocale(); + if (!variantsConfig) { + throw new Error("VariantFields component needs variantsConfig"); + } + const fieldTypeConfigVariantsConfig = fieldTypesConfigMap[fieldForm.getValues("type")]?.variantsConfig; + + if (!fieldTypeConfigVariantsConfig) { + throw new Error("Coniguration Issue: FieldType doesn't have `variantsConfig`"); + } + + const variantToggleLabel = t(fieldTypeConfigVariantsConfig.toggleLabel || ""); + + const defaultVariant = fieldTypeConfigVariantsConfig.defaultVariant; + + const variantNames = Object.keys(variantsConfig.variants); + const otherVariants = variantNames.filter((v) => v !== defaultVariant); + if (otherVariants.length > 1 && variantToggleLabel) { + throw new Error("More than one other variant. Remove toggleLabel "); + } + const otherVariant = otherVariants[0]; + const variantName = fieldForm.watch("variant") || defaultVariant; + const variantFields = variantsConfig.variants[variantName as keyof typeof variantsConfig].fields; + /** + * A variant that has just one field can be shown in a simpler way in UI. + */ + const isSimpleVariant = variantFields.length === 1; + const isDefaultVariant = variantName === defaultVariant; + const supportsVariantToggle = variantNames.length === 2; + return ( + <> + {supportsVariantToggle ? ( + { + fieldForm.setValue("variant", checked ? otherVariant : defaultVariant); + }} + classNames={{ container: "p-2 mt-2 sm:hover:bg-muted rounded" }} + tooltip={t("Toggle Variant")} + /> + ) : ( + + )} + + + +
    + {variantFields.map((f, index) => { + const rhfVariantFieldPrefix = `variantsConfig.variants.${variantName}.fields.${index}` as const; + const fieldTypeConfigVariants = + fieldTypeConfigVariantsConfig.variants[ + variantName as keyof typeof fieldTypeConfigVariantsConfig.variants + ]; + const appUiFieldConfig = + fieldTypeConfigVariants.fieldsMap[f.name as keyof typeof fieldTypeConfigVariants.fieldsMap]; + return ( +
  • + {!isSimpleVariant && ( + + )} - - {fieldType?.isTextType ? ( - - ) : null} - {fieldType?.needsOptions && !fieldForm.getValues("getOptionsAt") ? ( - { - return ; - }} - /> - ) : 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") ? ( - - ) : null} */} + + { + render={({ field: { onChange } }) => { return ( { onChange(val); }} @@ -542,274 +662,10 @@ export const FormBuilder = function FormBuilder({ ); }} /> - -
- - {t("cancel")} - - - - -
- ); -}; - -// 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; - readOnly: boolean; - children: React.ReactNode; -}) => { - return ( -
- {/* 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 && ( -
- -
- )} - {children} -
- ); -}; - -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 ComponentForField = ({ - field, - value, - setValue, - readOnly, -}: { - field: Omit & { - // 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 propsTypeConditionMap = { - boolean: typeof val === "boolean", - multiselect: val instanceof Array && val.every((v) => typeof v === "string"), - objectiveWithInput: typeof val === "object" && val !== null ? "value" in val : false, - select: typeof val === "string", - text: typeof val === "string", - textList: val instanceof Array && val.every((v) => typeof v === "string"), - } as const; - if (!propsTypeConditionMap[propsType]) throw new Error(`Unknown propsType ${propsType}`); - return propsTypeConditionMap[propsType]; - }; - - // 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 ( - - void} - /> - - ); - } - - if (componentConfig.propsType === "boolean") { - return ( - - void} - placeholder={field.placeholder} - /> - - ); - } - - if (componentConfig.propsType === "textList") { - return ( - - void} - /> - - ); - } - - if (componentConfig.propsType === "select") { - if (!field.options) { - throw new Error("Field options is not defined"); - } - - return ( - - void} - options={field.options.map((o) => ({ ...o, title: o.label }))} - /> - - ); - } - - if (componentConfig.propsType === "multiselect") { - if (!field.options) { - throw new Error("Field options is not defined"); - } - return ( - - void} - options={field.options.map((o) => ({ ...o, title: o.label }))} - /> - - ); - } - - 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 ? ( - - void} - optionsInputs={field.optionsInputs} - options={field.options} - required={field.required} - /> - - ) : null; - } - - throw new Error(`Field ${field.name} does not have a valid propsType`); -}; - -export const FormBuilderField = ({ - field, - readOnly, - className, -}: { - field: RhfFormFields[number]; - readOnly: boolean; - className: string; -}) => { - const { t } = useLocale(); - const { control, formState } = useFormContext(); - return ( -
- { - return ( -
- { - onChange(val); - }} - /> - { - 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 (field.hidden) { - console.error(`Error message for hidden field:${field.name} => ${message}`); - } - return ( -
- -

{t(message || "invalid_input")}

-
- ); - }} - /> -
+ ); - }} - /> -
+ })} + + ); -}; +} diff --git a/packages/features/form-builder/FormBuilderField.tsx b/packages/features/form-builder/FormBuilderField.tsx new file mode 100644 index 0000000000..aff18f16a2 --- /dev/null +++ b/packages/features/form-builder/FormBuilderField.tsx @@ -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; +}; + +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 ( + + ); +}; + +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; + readOnly: boolean; + children: React.ReactNode; +}) => { + return ( +
+ {/* 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 && ( +
+ +
+ )} + {children} +
+ ); +}; + +/** + * 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 & { + // 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 ( + + void} + /> + + ); + } + + if (componentConfig.propsType === "boolean") { + return ( + + void} + placeholder={field.placeholder} + /> + + ); + } + + if (componentConfig.propsType === "textList") { + return ( + + void} + /> + + ); + } + + if (componentConfig.propsType === "select") { + if (!field.options) { + throw new Error("Field options is not defined"); + } + + return ( + + void} + options={field.options.map((o) => ({ ...o, title: o.label }))} + /> + + ); + } + + if (componentConfig.propsType === "multiselect") { + if (!field.options) { + throw new Error("Field options is not defined"); + } + return ( + + void} + options={field.options.map((o) => ({ ...o, title: o.label }))} + /> + + ); + } + + 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 ? ( + + void} + optionsInputs={field.optionsInputs} + options={field.options} + required={field.required} + /> + + ) : null; + } + + if (componentConfig.propsType === "variants") { + const variantsConfig = getVariantsConfig(field); + if (!variantsConfig) { + return null; + } + + return ( + | string) => void} + variants={variantsConfig.variants} + /> + ); + } + + assertUnreachable(componentConfig); + return null; +}; diff --git a/packages/features/form-builder/FormBuilderFieldsSchema.ts b/packages/features/form-builder/FormBuilderFieldsSchema.ts deleted file mode 100644 index fa50092ca6..0000000000 --- a/packages/features/form-builder/FormBuilderFieldsSchema.ts +++ /dev/null @@ -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); diff --git a/packages/features/form-builder/fieldTypes.ts b/packages/features/form-builder/fieldTypes.ts new file mode 100644 index 0000000000..1fb4809be1 --- /dev/null +++ b/packages/features/form-builder/fieldTypes.ts @@ -0,0 +1,157 @@ +import type z from "zod"; + +import { propsTypes } from "./propsTypes"; +import type { FieldType, fieldTypeConfigSchema } from "./schema"; + +const configMap: Record, "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>; + +Object.entries(fieldTypesConfigMap).forEach(([fieldType, config]) => { + config.propsType = propsTypes[fieldType as keyof typeof fieldTypesConfigMap]; +}); diff --git a/packages/features/form-builder/propsTypes.ts b/packages/features/form-builder/propsTypes.ts new file mode 100644 index 0000000000..8bfcb0bc20 --- /dev/null +++ b/packages/features/form-builder/propsTypes.ts @@ -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; diff --git a/packages/features/form-builder/schema.ts b/packages/features/form-builder/schema.ts new file mode 100644 index 0000000000..16b5778e76 --- /dev/null +++ b/packages/features/form-builder/schema.ts @@ -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; + +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; + 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; + 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; + 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()), +]); diff --git a/packages/features/form-builder/utils.ts b/packages/features/form-builder/utils.ts new file mode 100644 index 0000000000..c64053c822 --- /dev/null +++ b/packages/features/form-builder/utils.ts @@ -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, "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; +}; diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index fd2c990f73..49f431dbd5 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -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 ? 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 diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 65b34f009f..eeec8f8919 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -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 diff --git a/packages/ui/components/form/inputs/Input.tsx b/packages/ui/components/form/inputs/Input.tsx index 0b2f3e2073..af66eb1ff7 100644 --- a/packages/ui/components/form/inputs/Input.tsx +++ b/packages/ui/components/form/inputs/Input.tsx @@ -52,6 +52,7 @@ type InputFieldProps = { error?: string; labelSrOnly?: boolean; containerClassName?: string; + showAsteriskIndicator?: boolean; t?: (key: string) => string; } & React.ComponentProps & { labelProps?: React.ComponentProps; @@ -106,6 +107,7 @@ export const InputField = forwardRef(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(function {...labelProps} className={classNames(labelClassName, labelSrOnly && "sr-only", props.error && "text-error")}> {label} + {showAsteriskIndicator && !readOnly && passThrough.required ? ( + * + ) : null} {LockedIcon} )}