feat: Custom Fields OR Add-Ons with a fee associated (#19997)

* feat: Addons to bookings

* Add tests

* Add tests

* Fix price field not shown immediately after enabling payment for an event

* Add support for all currencies in addons and fix type errors

* Address review comments

* Add tests to cover all input types

* Address cubic-dev-ai comments

* Remove openapi.json from git tracking

* Revert changes

* Revert unrelated linter/formatter changes

* Address cubic-dev-ai comments

* Addres cubic-dev-ai comment

* Update to use test id instead of hardcoded label

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Address coderabbits review comments

* Fix failing unit test

* some fixes...

* Fix tests

* Fix import

* Move handlePayment test file to appropriate location

* Fix test

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
This commit is contained in:
Anwar Sadath
2025-09-16 21:33:23 +09:00
committed by GitHub
co-authored by coderabbitai[bot] Omar López amrit Kartik Saini Anik Dhabal Babu Devanshu Sharma Benny Joo Peer Richelsen Volnei Munhoz
parent 65448c017f
commit 55e6682832
14 changed files with 822 additions and 40 deletions
@@ -141,7 +141,7 @@ const PaymentPage: FC<PaymentPageProps> = (props) => {
<div className="col-span-2 mb-6 font-semibold">
<Price
currency={paymentAppData.currency}
price={paymentAppData.price}
price={props.payment?.amount ?? paymentAppData.price}
displayAlternateSymbol={false}
/>
</div>
@@ -32,3 +32,27 @@ export const convertFromSmallestToPresentableCurrencyUnit = (amount: number, cur
}
return amount / 100;
};
export const getCurrencySymbol = (currencyCode: string): string => {
try {
const formatter = Intl.NumberFormat("en", { style: "currency", currency: currencyCode });
// formatToParts(1) breaks down the formatted number (1) into its constituent parts
// like currency symbol, decimal separator, etc. We use 1 as it's a simple number
// that will show the currency symbol clearly.
// For example, since we are formatting the number 1 with USD currency, formatToParts(1) would return an array like:
// [
// { type: "currency", value: "$" },
// { type: "integer", value: "1" },
// { type: "decimal", value: "." },
// { type: "fraction", value: "00" }
// ]
const parts = formatter.formatToParts(1);
const currencyPart = parts.find((part) => part.type === "currency");
return currencyPart?.value || "$";
} catch {
// Ideally we would not reach here, but if for some reason we reach here, we return
// $ as default currency
console.warn(`Failed to get currency symbol for ${currencyCode}, falling back to $`);
return "$";
}
};
@@ -89,6 +89,11 @@ export const BookEventForm = ({
return eventType?.price > 0 && !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0;
}, [eventType]);
const paymentCurrency = useMemo(() => {
if (!eventType) return "USD";
return getPaymentAppData(eventType)?.currency || "USD";
}, [eventType]);
if (eventQuery.isError) return <Alert severity="warning" message={t("error_booking_event")} />;
if (eventQuery.isPending || !eventQuery.data) return <FormSkeleton />;
if (!timeslot)
@@ -129,6 +134,8 @@ export const BookEventForm = ({
locations={eventType.locations}
rescheduleUid={rescheduleUid || undefined}
bookingData={bookingData}
isPaidEvent={isPaidEvent}
paymentCurrency={paymentCurrency}
/>
{errors.hasFormErrors || errors.hasDataErrors ? (
<div data-testid="booking-fail">
@@ -6,30 +6,81 @@ import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import getLocationOptionsForSelect from "@calcom/features/bookings/lib/getLocationOptionsForSelect";
import { FormBuilderField } from "@calcom/features/form-builder/FormBuilderField";
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
import { fieldsThatSupportLabelAsSafeHtml } from "@calcom/features/form-builder/fieldsThatSupportLabelAsSafeHtml";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { RouterOutputs } from "@calcom/trpc/react";
import { SystemField } from "../../../lib/SystemField";
type Fields = NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
export const BookingFields = ({
fields,
locations,
rescheduleUid,
isDynamicGroupBooking,
bookingData,
isPaidEvent,
paymentCurrency = "USD",
}: {
fields: NonNullable<RouterOutputs["viewer"]["public"]["event"]>["bookingFields"];
fields: Fields;
locations: LocationObject[];
rescheduleUid?: string;
bookingData?: GetBookingType | null;
isDynamicGroupBooking: boolean;
isPaidEvent?: boolean;
paymentCurrency?: string;
}) => {
const { t } = useLocale();
const { t, i18n } = useLocale();
const { watch, setValue } = useFormContext();
const locationResponse = watch("responses.location");
const currentView = rescheduleUid ? "reschedule" : "";
const isInstantMeeting = useBookerStore((state) => state.isInstantMeeting);
const getPriceFormattedLabel = (label: string, price: number) =>
`${label} (${Intl.NumberFormat(i18n.language, {
style: "currency",
currency: paymentCurrency,
}).format(price)})`;
const getFieldWithDirectPricing = (field: Fields[number]) => {
if (!fieldTypesConfigMap[field.type]?.supportsPricing || !field.label || !field.price) {
return field;
}
const price = typeof field.price === "string" ? parseFloat(field.price) : field.price;
const label = getPriceFormattedLabel(field.label, price);
return {
...field,
label,
...(fieldsThatSupportLabelAsSafeHtml.includes(field.type) && field.labelAsSafeHtml
? { labelAsSafeHtml: markdownToSafeHTML(label) }
: { labelAsSafeHtml: undefined }),
};
};
const getFieldWithOptionLevelPrices = (field: Fields[number]) => {
if (!fieldTypesConfigMap[field.type]?.optionsSupportPricing || !field.options) return field;
return {
...field,
options: field.options.map((opt) => {
const option = opt as { value: string; label: string; price?: number };
const optionPrice = option.price;
// Only add price to label if there's a price
if (!optionPrice) return option;
return {
...option,
label: getPriceFormattedLabel(option.label, optionPrice),
};
}),
};
};
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
@@ -134,8 +185,28 @@ export const BookingFields = ({
});
}
// Add price display for custom inputs with prices
let fieldWithPrice = field;
if (isPaidEvent) {
// Handle number fields and boolean (single checkbox) fields
if (fieldTypesConfigMap[field.type]?.supportsPricing) {
fieldWithPrice = getFieldWithDirectPricing(field);
}
// Handle fields with option-level prices (select, multiselect, and checkbox group)
if (fieldTypesConfigMap[field.type]?.optionsSupportPricing) {
fieldWithPrice = getFieldWithOptionLevelPrices(fieldWithPrice);
}
}
return (
<FormBuilderField className="mb-4" field={{ ...field, hidden }} readOnly={readOnly} key={index} />
<FormBuilderField
className="mb-4"
field={{ ...fieldWithPrice, hidden }}
readOnly={readOnly}
key={index}
/>
);
})}
</div>
@@ -18,7 +18,7 @@ import {
EventTypeMetaDataSchema,
} from "@calcom/prisma/zod-utils";
type Fields = z.infer<typeof eventTypeBookingFields>;
export type Fields = z.infer<typeof eventTypeBookingFields>;
if (typeof window !== "undefined" && !process.env.INTEGRATION_TEST_MODE) {
// This file imports some costly dependencies, so we want to make sure it's not imported on the client side.
@@ -2102,6 +2102,8 @@ async function handler(
bookerEmail,
bookerPhoneNumber,
isDryRun,
bookingFields: eventType.bookingFields,
locale: language,
});
const subscriberOptionsPaymentInitiated: GetSubscriberOptions = {
userId: triggerForUser ? organizerUser.id : null,
@@ -0,0 +1,300 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { handlePayment } from "./handlePayment";
vi.mock("@calcom/app-store/zod-utils", () => ({
eventTypeMetaDataSchemaWithTypedApps: {
parse: vi.fn((data) => data),
},
}));
vi.mock("@calcom/app-store/payment.services.generated", () => ({
PaymentServiceMap: {
stripepayment: Promise.resolve({
PaymentService: class MockPaymentService {
async create(paymentData: { amount: number; currency: string }) {
return {
...paymentData,
id: "mock-payment-id",
externalId: "mock-external-id",
success: true,
};
}
async collectCard(paymentData: { amount: number; currency: string }) {
return {
...paymentData,
id: "mock-payment-id",
externalId: "mock-external-id",
success: true,
};
}
async afterPayment() {
return true;
}
},
}),
},
}));
describe("handlePayment", () => {
const mockBooking = {
id: 123,
userId: 456,
startTime: { toISOString: () => "2025-03-12T10:00:00Z" },
uid: "test-uid",
user: {
email: "user@example.com",
name: "Test User",
timeZone: "UTC",
username: "testuser",
},
};
const mockEventType = {
title: "Test Event",
metadata: {
apps: {
stripe: {
enabled: true,
price: 1000,
currency: "USD",
paymentOption: "ON_BOOKING",
},
},
},
};
const mockPaymentCredentials = {
key: {},
appId: "stripe" as const,
app: {
dirName: "stripepayment",
categories: ["payment"],
},
};
const mockEvent = {
type: "testEvent",
title: "Test Event",
startTime: "2025-03-12T10:00:00Z",
endTime: "2025-03-12T11:00:00Z",
organizer: {
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "UTC",
language: { locale: "en" },
},
attendees: [],
responses: {},
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should handle payment on dry run", async () => {
const result = await handlePayment({
evt: mockEvent,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields: [],
isDryRun: true,
});
expect(result).toBeNull();
});
it("should calculate total amount with no addons", async () => {
const result = await handlePayment({
evt: mockEvent,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields: [],
});
expect(result?.amount).toBe(1000);
expect(result?.currency).toBe("USD");
});
it("should calculate total amount with number field addon", async () => {
const mockEventWithResponse = {
...mockEvent,
responses: {
quantity: { value: "2" },
},
};
const bookingFields = [
{
name: "quantity",
type: "number" as const,
price: 5,
required: false,
},
];
const result = await handlePayment({
evt: mockEventWithResponse,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields,
});
expect(result?.amount).toBe(2000); // Base 1000 cents + (2 * $5 * 100 cents)
});
it("should calculate total amount with boolean field addon", async () => {
const mockEventWithResponse = {
...mockEvent,
responses: {
extraService: { value: true },
},
};
const bookingFields = [
{
name: "extraService",
type: "boolean" as const,
price: 15,
required: false,
},
];
const result = await handlePayment({
evt: mockEventWithResponse,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields,
});
expect(result?.amount).toBe(2500); // Base 1000 cents + ($15 * 100 cents)
});
it("should calculate total amount with select field addon", async () => {
const mockEventWithResponse = {
...mockEvent,
responses: {
package: { value: "premium" },
},
};
const bookingFields = [
{
name: "package",
type: "select" as const,
required: false,
options: [
{ value: "basic", label: "Basic Package", price: 10 },
{ value: "premium", label: "Premium Package", price: 20 },
],
},
];
const result = await handlePayment({
evt: mockEventWithResponse,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields,
});
expect(result?.amount).toBe(3000); // Base 1000 cents + ($20 * 100 cents)
});
it("should calculate total amount with multiple addons", async () => {
const mockEventWithResponse = {
...mockEvent,
responses: {
quantity: { value: "2" },
extraService: { value: true },
package: { value: "premium" },
},
};
const bookingFields = [
{
name: "quantity",
type: "number" as const,
price: 5,
required: false,
},
{
name: "extraService",
type: "boolean" as const,
price: 15,
required: false,
},
{
name: "package",
type: "select" as const,
required: false,
options: [
{ value: "basic", label: "Basic Package", price: 10 },
{ value: "premium", label: "Premium Package", price: 20 },
],
},
];
const result = await handlePayment({
evt: mockEventWithResponse,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields,
});
expect(result?.amount).toBe(5500); // Base 1000 cents + (2 * $5 * 100 cents) + ($15 * 100 cents) + ($20 * 100 cents)
});
it("should calculate total amount with radio field addon using locale", async () => {
const mockEventWithResponse = {
...mockEvent,
responses: {
mealChoice: { value: "premium ($20.00)" }, // Formatted with price
},
};
const bookingFields = [
{
name: "mealChoice",
type: "radio" as const,
required: false,
options: [
{ value: "basic", label: "Basic Meal", price: 10 },
{ value: "premium", label: "Premium Meal", price: 20 },
],
},
];
const result = await handlePayment({
evt: mockEventWithResponse,
selectedEventType: mockEventType,
paymentAppCredentials: mockPaymentCredentials,
booking: mockBooking,
bookerName: "John Doe",
bookerEmail: "john@example.com",
bookingFields,
locale: "en",
});
expect(result?.amount).toBe(3000); // Base 1000 cents + ($20 * 100 cents)
});
});
+102 -8
View File
@@ -2,6 +2,9 @@ import { PaymentServiceMap } from "@calcom/app-store/payment.services.generated"
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
import type { AppCategories, Prisma, EventType } from "@calcom/prisma/client";
import { convertToSmallestCurrencyUnit } from "@calcom/app-store/_utils/payments/currencyConversions";
import type { Fields } from "@calcom/features/bookings/lib/getBookingFields";
import { fieldTypesConfigMap } from "@calcom/features/form-builder/fieldTypes";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { IAbstractPaymentService } from "@calcom/types/PaymentService";
@@ -17,6 +20,8 @@ const handlePayment = async ({
bookerEmail,
bookerPhoneNumber,
isDryRun = false,
bookingFields,
locale = "en",
}: {
evt: CalendarEvent;
selectedEventType: Pick<EventType, "metadata" | "title">;
@@ -39,6 +44,8 @@ const handlePayment = async ({
bookerEmail: string;
bookerPhoneNumber?: string | null;
isDryRun?: boolean;
bookingFields?: Fields;
locale?: string;
}) => {
if (isDryRun) return null;
const key = paymentAppCredentials?.app?.dirName;
@@ -60,14 +67,104 @@ const handlePayment = async ({
const apps = eventTypeMetaDataSchemaWithTypedApps.parse(selectedEventType?.metadata)?.apps;
const paymentOption = apps?.[paymentAppCredentials.appId].paymentOption || "ON_BOOKING";
const paymentCurrency = apps?.[paymentAppCredentials.appId].currency;
// Ensure we have a valid currency - fallback to USD if undefined
const currency = paymentCurrency || "USD";
let totalAmount = apps?.[paymentAppCredentials.appId].price || 0;
if ((bookingFields || [])?.length > 0) {
let addonsPrice = 0;
// Process each booking field
(bookingFields || []).forEach((field) => {
// Skip fields that don't support pricing
if (
!fieldTypesConfigMap[field.type]?.supportsPricing &&
!fieldTypesConfigMap[field.type]?.optionsSupportPricing
) {
return;
}
// Type assertion for price-related fields
const typedInput = field as { price?: number; options?: { value: string; price?: number }[] };
const response = evt.responses?.[field.name];
// Skip if no response for this field
if (!response) return;
switch (field.type) {
case "number": {
// Multiply the numeric value by the field's price
const parsedValue = Number((response.value ?? "").toString().trim());
const safeValue = Number.isFinite(parsedValue) ? parsedValue : 0;
addonsPrice += safeValue * (typedInput.price || 0);
break;
}
case "boolean": {
// Add price if boolean field is true
if (response.value) {
addonsPrice += typedInput.price || 0;
}
break;
}
case "select":
case "radio": {
// For select and radio, find the selected option and add its price
const selectedValue = response.value;
let selectedOption;
if (field.type === "radio") {
// For radio, the value coming is the label itself (formatted with price)
selectedOption = typedInput.options?.find((opt) => {
const formattedValue = `${opt.value} (${Intl.NumberFormat(locale, {
style: "currency",
currency: currency,
}).format(opt.price || 0)})`;
return formattedValue === selectedValue;
});
} else {
// For select, match by direct value
selectedOption = typedInput.options?.find((opt) => opt.value === selectedValue);
}
addonsPrice += selectedOption?.price || 0;
break;
}
case "checkbox":
case "multiselect": {
// For checkbox and multiselect, add prices of all selected options
const responseValue = response.value;
const selectedValues = Array.isArray(responseValue)
? responseValue
: responseValue
? [responseValue]
: [];
selectedValues.forEach((value) => {
const option = typedInput.options?.find((opt) => opt.value === value);
addonsPrice += option?.price || 0;
});
break;
}
}
});
totalAmount += convertToSmallestCurrencyUnit(addonsPrice, currency);
}
const paymentPriceAndCurrency = {
amount: totalAmount,
currency: currency,
};
let paymentData;
if (paymentOption === "HOLD") {
paymentData = await paymentInstance.collectCard(
{
amount: apps?.[paymentAppCredentials.appId].price,
currency: apps?.[paymentAppCredentials.appId].currency,
},
paymentPriceAndCurrency,
booking.id,
paymentOption,
bookerEmail,
@@ -75,10 +172,7 @@ const handlePayment = async ({
);
} else {
paymentData = await paymentInstance.create(
{
amount: apps?.[paymentAppCredentials.appId].price,
currency: apps?.[paymentAppCredentials.appId].currency,
},
paymentPriceAndCurrency,
booking.id,
booking.userId,
booking.user?.username ?? null,
@@ -42,6 +42,7 @@ import {
} from "@calcom/lib/constants";
import { generateHashedLink } from "@calcom/lib/generateHashedLink";
import { checkWCAGContrastColor } from "@calcom/lib/getBrandColours";
import { getPaymentAppData } from "@calcom/app-store/_utils/payments/getPaymentAppData";
import { extractHostTimezone } from "@calcom/lib/hashedLinksUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Prisma } from "@calcom/prisma/client";
@@ -569,6 +570,21 @@ export const EventAdvancedTab = ({
userEmail = removePlatformClientIdFromEmail(userEmail, platformContext.clientId);
}
const metadata = formMethods.watch("metadata");
const paymentAppData = useMemo(() => {
const _eventType = {
price: 0,
currency: "",
metadata,
};
return getPaymentAppData(_eventType);
}, [metadata]);
const isPaidEvent = useMemo(
() => !Number.isNaN(paymentAppData.price) && paymentAppData.price > 0,
[paymentAppData]
);
const TimezoneSelect = useMemo(() => {
return isPlatform ? PlatformTimzoneSelect : WebTimezoneSelect;
}, [isPlatform]);
@@ -628,6 +644,8 @@ export const EventAdvancedTab = ({
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
return field.name === "location" ? true : field.required;
}}
showPriceField={isPaidEvent}
paymentCurrency={paymentAppData?.currency || "usd"}
/>
</div>
</div>
@@ -167,4 +167,108 @@ describe("FormBuilder", () => {
expect(pageObject.dialog.isFieldShowingAsRequired({ dialog })).toBe(true);
});
});
describe("Addon Fields Tests", () => {
beforeEach(() => {
renderComponent({
formBuilderProps: {
...mockProps,
showPriceField: true,
},
formDefaultValues: {},
});
});
it("Should add number field with price", async () => {
const identifier = "quantity-addon";
const price = 10;
await verifier.verifyFieldAddition({
fieldType: "number",
identifier,
label: "Quantity",
price,
});
pageObject.openEditFieldDialog({ identifier });
await expectScenario.toHavePriceField({ identifier, price });
});
it("Should add boolean field with price", async () => {
const identifier = "extra-service-addon";
const price = 15;
await verifier.verifyFieldAddition({
fieldType: "boolean",
identifier,
label: "Extra Service",
price,
});
pageObject.openEditFieldDialog({ identifier });
await expectScenario.toHavePriceField({ identifier, price });
});
it("Should add select field with price and options", async () => {
const identifier = "meal-selection";
await verifier.verifyFieldAddition({
fieldType: "select",
identifier,
label: "Meal Selection",
options: [
{ label: "Vegetarian", value: "veg", price: 20 },
{ label: "Non-Vegetarian", value: "non-veg", price: 25 },
],
});
verifier.verifyOptionPrices({ identifier, prices: [20, 25] });
});
it("Should add multiselect field with price and options", async () => {
const identifier = "add-ons";
await verifier.verifyFieldAddition({
fieldType: "multiselect",
identifier,
label: "Add-ons",
options: [
{ label: "WiFi", value: "wifi", price: 10 },
{ label: "Breakfast", value: "breakfast", price: 15 },
{ label: "Parking", value: "parking", price: 20 },
],
});
verifier.verifyOptionPrices({ identifier, prices: [10, 15, 20] });
});
it("Should add checkbox group field with price and options", async () => {
const identifier = "amenities";
await verifier.verifyFieldAddition({
fieldType: "checkbox",
identifier,
label: "Amenities",
options: [
{ label: "Pool Access", value: "pool", price: 15 },
{ label: "Gym Access", value: "gym", price: 20 },
{ label: "Spa Access", value: "spa", price: 25 },
],
});
verifier.verifyOptionPrices({ identifier, prices: [15, 20, 25] });
});
it("Should add radio group field with price and options", async () => {
const identifier = "room-type";
await verifier.verifyFieldAddition({
fieldType: "radio",
identifier,
label: "Room Type",
options: [
{ label: "Standard", value: "standard", price: 50 },
{ label: "Deluxe", value: "deluxe", price: 75 },
{ label: "Suite", value: "suite", price: 100 },
],
});
verifier.verifyOptionPrices({ identifier, prices: [50, 75, 100] });
});
});
});
+90 -26
View File
@@ -5,6 +5,7 @@ import { Controller, useFieldArray, useForm, useFormContext } from "react-hook-f
import type { z } from "zod";
import { ZodError } from "zod";
import { getCurrencySymbol } from "@calcom/app-store/_utils/payments/currencyConversions";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
@@ -79,6 +80,8 @@ export const FormBuilder = function FormBuilder({
LockedIcon,
dataStore,
shouldConsiderRequired,
showPriceField,
paymentCurrency = "USD",
showPhoneAndEmailToggle = false,
}: {
formProp: string;
@@ -97,6 +100,8 @@ export const FormBuilder = function FormBuilder({
* e.g. Location field has a default value at backend so API can send no location but formBuilder in UI doesn't allow it.
*/
shouldConsiderRequired?: (field: RhfFormField) => boolean | undefined;
showPriceField?: boolean;
paymentCurrency?: string;
}) {
// 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.
@@ -402,6 +407,8 @@ export const FormBuilder = function FormBuilder({
});
}}
shouldConsiderRequired={shouldConsiderRequired}
showPriceField={showPriceField}
paymentCurrency={paymentCurrency}
/>
)}
</div>
@@ -415,13 +422,19 @@ function Options({
onChange = () => {},
className = "",
readOnly = false,
showPrice = false,
paymentCurrency,
}: {
label?: string;
value: { label: string; value: string }[];
onChange?: (value: { label: string; value: string }[]) => void;
value: { label: string; value: string; price?: number }[];
onChange?: (value: { label: string; value: string; price?: number }[]) => void;
className?: string;
readOnly?: boolean;
showPrice?: boolean;
paymentCurrency: string;
}) {
const { t } = useLocale();
const [animationRef] = useAutoAnimate<HTMLUListElement>();
if (!value) {
onChange([
@@ -438,26 +451,52 @@ function Options({
return (
<div className={className}>
<Label>{label}</Label>
<div className="bg-muted rounded-md p-4">
<div className="bg-muted rounded-md p-4" data-testid="options-container">
<ul ref={animationRef} className="flex flex-col gap-1">
{value?.map((option, index) => (
<li key={index}>
<div className="flex items-center">
<Input
required
value={option.label}
onChange={(e) => {
// 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}`}
/>
<div className="flex items-center gap-2">
<div className="flex-grow">
<Input
required
value={option.label}
onChange={(e) => {
// Right now we use label of the option as the value of the option. It allows use 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.
const newOptions = [...(value || [])];
newOptions.splice(index, 1, {
label: e.target.value,
value: e.target.value.trim(),
});
onChange(newOptions);
}}
readOnly={readOnly}
placeholder={t("enter_option", { index: index + 1 })}
/>
</div>
{showPrice && (
<div className="w-24">
<InputField
type="number"
min={0}
step="0.01"
value={option.price}
onChange={(e) => {
const val = e.target.value;
const numValue = val === "" ? undefined : Number(val);
const updatedOptions = [...(value || [])];
updatedOptions[index] = {
...option,
price: numValue,
};
onChange(updatedOptions);
}}
readOnly={readOnly}
placeholder="0"
addOnLeading={getCurrencySymbol(paymentCurrency)}
/>
</div>
)}
{value.length > 2 && !readOnly && (
<Button
type="button"
@@ -466,10 +505,8 @@ function Options({
color="minimal"
StartIcon="x"
onClick={() => {
if (!value) {
return;
}
const newOptions = [...value];
if (!value) return;
const newOptions = [...(value || [])];
newOptions.splice(index, 1);
onChange(newOptions);
}}
@@ -482,9 +519,11 @@ function Options({
{!readOnly && (
<Button
color="minimal"
data-testid="add-option"
onClick={() => {
value.push({ label: "", value: "" });
onChange(value);
const newOptions = [...(value || [])];
newOptions.push({ label: "", value: "", price: 0 });
onChange(newOptions);
}}
StartIcon="plus">
Add an Option
@@ -521,11 +560,15 @@ function FieldEditDialog({
onOpenChange,
handleSubmit,
shouldConsiderRequired,
showPriceField,
paymentCurrency,
}: {
dialog: { isOpen: boolean; fieldIndex: number; data: RhfFormField | null };
onOpenChange: (isOpen: boolean) => void;
handleSubmit: SubmitHandler<RhfFormField>;
shouldConsiderRequired?: (field: RhfFormField) => boolean | undefined;
showPriceField?: boolean;
paymentCurrency: string;
}) {
const { t } = useLocale();
const fieldForm = useForm<RhfFormField>({
@@ -633,7 +676,15 @@ function FieldEditDialog({
<Controller
name="options"
render={({ field: { value, onChange } }) => {
return <Options onChange={onChange} value={value} className="mt-6" />;
return (
<Options
onChange={onChange}
value={value}
className="mt-6"
showPrice={showPriceField && fieldType.optionsSupportPricing}
paymentCurrency={paymentCurrency}
/>
);
}}
/>
) : null}
@@ -684,6 +735,19 @@ function FieldEditDialog({
/>
)}
{/* Add price field only for fields that support pricing */}
{showPriceField && fieldType.supportsPricing && (
<InputField
{...fieldForm.register("price")}
containerClassName="mt-6"
label={t("price")}
type="number"
min={0}
placeholder="0"
addOnLeading={getCurrencySymbol(paymentCurrency)}
/>
)}
<div className="mt-6">
<Controller
name="required"
@@ -99,6 +99,7 @@ const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "
label: "Number",
value: "number",
isTextType: true,
supportsPricing: true,
},
textarea: {
label: "Long Text",
@@ -114,12 +115,14 @@ const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "
value: "select",
needsOptions: true,
isTextType: true,
optionsSupportPricing: true,
},
multiselect: {
label: "MultiSelect",
value: "multiselect",
needsOptions: true,
isTextType: false,
optionsSupportPricing: true,
},
multiemail: {
label: "Multiple Emails",
@@ -140,17 +143,20 @@ const configMap: Record<FieldType, Omit<z.infer<typeof fieldTypeConfigSchema>, "
value: "checkbox",
needsOptions: true,
isTextType: false,
optionsSupportPricing: true,
},
radio: {
label: "Radio Group",
value: "radio",
needsOptions: true,
isTextType: false,
optionsSupportPricing: true,
},
boolean: {
label: "Checkbox",
value: "boolean",
isTextType: false,
supportsPricing: true,
},
url: {
label: "URL",
+13 -1
View File
@@ -82,7 +82,15 @@ const baseFieldSchema = z.object({
* 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(),
options: z
.array(
z.object({
label: z.string(),
value: z.string(),
price: z.coerce.number().min(0).optional(),
})
)
.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.
@@ -124,6 +132,8 @@ const baseFieldSchema = z.object({
excludeEmails: excludeOrRequireEmailSchema.optional(),
// Emails that need to be required
requireEmails: excludeOrRequireEmailSchema.optional(),
// Price associated with the field which works like addons which users can add to the booking
price: z.coerce.number().min(0).optional(),
});
export const variantsConfigSchema = z.object({
@@ -162,6 +172,8 @@ export const fieldTypeConfigSchema = z
maxLength: z.number(),
})
.optional(),
supportsPricing: z.boolean().default(false).optional(),
optionsSupportPricing: z.boolean().default(false).optional(),
propsType: z.enum([
"text",
"textList",
@@ -9,6 +9,8 @@ export interface FieldProps {
fieldType: string;
identifier: string;
label: string;
price?: number;
options?: { label: string; value: string; price?: number }[];
}
type FormBuilderProps = React.ComponentProps<typeof FormBuilder>;
@@ -22,6 +24,7 @@ export const mockProps: FormBuilderProps = {
LockedIcon: false,
dataStore: { options: {} },
};
export const getLocationBookingField = () => {
const bookingFields = getBookingFieldsWithSystemFields({
bookingFields: [],
@@ -163,6 +166,46 @@ export const pageObject = {
fireEvent.change(dialog.getAllByRole("textbox")[1], { target: { value: label } });
}
},
fillInPrice: ({ dialog, price }: { dialog: TestingLibraryElement; price: number }) => {
// The price input is rendered with an Input component with a $ suffix
const priceInput = dialog.getByRole("spinbutton");
fireEvent.change(priceInput, { target: { value: price.toString() } });
},
addOption: ({ dialog }: { dialog: TestingLibraryElement }) => {
fireEvent.click(dialog.getByTestId("add-option"));
},
fillInOption: async ({
dialog,
index,
label,
price,
}: {
dialog: TestingLibraryElement;
index: number;
label: string;
price?: number;
}) => {
pageObject.dialog.addOption({ dialog });
const optionInputs = dialog.getByTestId("options-container");
await waitFor(() => {
const listItems = optionInputs.querySelectorAll("li");
expect(listItems.length).toBeGreaterThan(index);
const optionInput = within(listItems[index]).getByRole("textbox");
expect(optionInput).toBeInTheDocument();
fireEvent.change(optionInput, { target: { value: label } });
});
if (price !== undefined) {
await waitFor(() => {
const listItems = optionInputs.querySelectorAll("li");
expect(listItems.length).toBeGreaterThan(index);
const priceInput = within(listItems[index]).getByRole("spinbutton");
expect(priceInput).toBeInTheDocument();
fireEvent.change(priceInput, { target: { value: price.toString() } });
});
}
},
close: ({ dialog }: { dialog: TestingLibraryElement }) => {
fireEvent.click(dialog.getByTestId("dialog-rejection"));
},
@@ -199,6 +242,23 @@ export const verifier = {
pageObject.dialog.selectFieldType({ dialog, fieldType: props.fieldType });
pageObject.dialog.fillInFieldIdentifier({ dialog, identifier: props.identifier });
pageObject.dialog.fillInFieldLabel({ dialog, label: props.label, fieldType: props.fieldType });
if (props.price !== undefined) {
pageObject.dialog.fillInPrice({ dialog, price: props.price });
}
if (props.options) {
for (let index = 0; index < props.options.length; index++) {
const option = props.options[index];
await pageObject.dialog.fillInOption({
dialog,
index,
label: option.label,
price: option.price,
});
}
}
pageObject.dialog.saveField({ dialog: getEditDialogForm() });
await waitFor(() => {
@@ -239,6 +299,16 @@ export const verifier = {
expect(screen.getByTestId("optional")).toBeInTheDocument();
});
},
verifyOptionPrices: ({ identifier, prices }: { identifier: string; prices: number[] }) => {
const dialog = pageObject.openEditFieldDialog({ identifier });
const optionsContainer = dialog.getByTestId("options-container");
const optionPriceInputs = within(optionsContainer).getAllByRole("spinbutton");
expect(optionPriceInputs).toHaveLength(prices.length);
prices.forEach((price, index) => {
expect(optionPriceInputs[index]).toHaveValue(price);
});
},
};
export const expectScenario = {
@@ -279,4 +349,14 @@ export const expectScenario = {
const field = getFieldInTheList({ identifier });
expect(field.getByText("required")).not.toBeNull();
},
toHavePriceField: async ({ identifier, price }: { identifier: string; price: number }) => {
const dialog = pageObject.openEditFieldDialog({ identifier });
await waitFor(() => {
// Find the spinbutton input with $ suffix
const priceInput = dialog.getByRole("spinbutton");
expect(priceInput).toBeInTheDocument();
// Verify the price value is set
expect(priceInput).toHaveValue(price);
});
},
};