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:
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
@@ -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)
|
||||
});
|
||||
});
|
||||
@@ -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] });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user