feat: add option to hide duration selector in booking page for multiple durations (#27033)

* feat: add option to hide duration selector in booking page for multiple durations

- Add 'hideDurationSelectorInBookingPage' field to event type metadata schema
- Add checkbox under Default Duration select in event type settings
- Modify Duration component to hide selector when setting is enabled
- URL params can still set duration when selector is hidden
- Add i18n translation for the new checkbox label
- Add e2e tests for the new functionality

Co-Authored-By: ali@cal.com <ali@cal.com>

* fix: rename checkbox label to 'Hide duration selector'

Co-Authored-By: ali@cal.com <ali@cal.com>

* fix: replace text locator with data-testid in E2E test

Replace `text=Multiple duration` locator with `page.getByTestId("event-types").locator('a[title="Multiple duration"]')` to follow E2E test best practices.

Co-Authored-By: unknown <>

* fix: add data-testid to hide duration selector checkbox for E2E tests

Co-Authored-By: ali@cal.com <ali@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: ali@cal.com <ali@cal.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot]
2026-01-21 10:39:36 +02:00
committed by GitHub
co-authored by ali@cal.com <ali@cal.com> ali@cal.com <ali@cal.com> unknown <> ali@cal.com <ali@cal.com> Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> ali@cal.com <ali@cal.com> Syed Ali Shahbaz
parent 4162b5be55
commit cb567a91c5
5 changed files with 145 additions and 1 deletions
@@ -93,6 +93,13 @@ export const EventDuration = ({
return <>{getDurationFormatted(event.length, t)}</>;
const durations = event?.metadata?.multipleDuration || [15, 30, 60, 90];
const hideDurationSelector = event?.metadata?.hideDurationSelectorInBookingPage;
// When duration selector is hidden, show only the selected/default duration as text
// URL params can still set the duration, but the user cannot change it via UI
if (hideDurationSelector) {
return <>{getDurationFormatted(selectedDuration || event.length, t)}</>;
}
return selectedDuration ? (
<div className="border-default relative mr-5 flex flex-row items-center justify-between rounded-md border">
@@ -15,7 +15,7 @@ import { slugify } from "@calcom/lib/slugify";
import turndown from "@calcom/lib/turndownService";
import classNames from "@calcom/ui/classNames";
import { Editor } from "@calcom/ui/components/editor";
import { Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
import { CheckboxField, Label, Select, SettingsToggle, TextAreaField, TextField } from "@calcom/ui/components/form";
import { Skeleton } from "@calcom/ui/components/skeleton";
import Locations from "@calcom/web/modules/event-types/components/locations/Locations";
import { useState } from "react";
@@ -276,6 +276,21 @@ export const EventSetupTab = (
}}
/>
</div>
<div className="mt-4">
<Controller
name="metadata.hideDurationSelectorInBookingPage"
control={formMethods.control}
render={({ field: { value, onChange } }) => (
<CheckboxField
data-testid="hide-duration-selector-checkbox"
checked={value ?? false}
onChange={(e) => onChange(e.target.checked)}
description={t("hide_duration_selector_in_booking_page")}
disabled={lengthLockedProps.disabled}
/>
)}
/>
</div>
</div>
) : (
<TextField
@@ -0,0 +1,120 @@
import { expect } from "@playwright/test";
import { test } from "./lib/fixtures";
import { bookTimeSlot, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
test.afterEach(({ users }) => users.deleteAll());
test.describe("Hide duration selector in booking page", () => {
test("duration selector is hidden when setting is enabled but URL params still work", async ({
page,
users,
}) => {
const user = await users.create();
await user.apiLogin();
// Get the multiple duration event type
const eventType = user.eventTypes.find((e) => e.slug === "multiple-duration");
expect(eventType).toBeDefined();
await test.step("enable hide duration selector setting", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.getByTestId("event-types").locator('a[title="Multiple duration"]').click();
await page.waitForSelector('[data-testid="event-title"]');
// Find and check the hide duration selector checkbox
const checkbox = page.getByTestId("hide-duration-selector-checkbox");
await checkbox.check();
// Save the event type
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypesHeavy/update?batch=1");
});
await test.step("verify duration selector is hidden on booking page", async () => {
await page.goto(`/${user.username}/multiple-duration`);
// Wait for the page to load
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" });
// The duration selector buttons should NOT be visible
await expect(page.getByTestId("multiple-choice-30mins")).not.toBeVisible();
await expect(page.getByTestId("multiple-choice-60mins")).not.toBeVisible();
await expect(page.getByTestId("multiple-choice-90mins")).not.toBeVisible();
});
await test.step("verify URL param can still set duration when selector is hidden", async () => {
// Navigate with duration param
await page.goto(`/${user.username}/multiple-duration?duration=60`);
// Wait for the page to load
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" });
// The duration selector should still be hidden
await expect(page.getByTestId("multiple-choice-60mins")).not.toBeVisible();
// Select a time slot and book
await selectFirstAvailableTimeSlotNextMonth(page);
await page.fill('[name="name"]', "Test User");
await page.fill('[name="email"]', "test@example.com");
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
// Verify the booking was made with the correct duration (60 mins = 1 hour)
// The booking title or details should reflect the 60 min duration
});
});
test("duration selector is visible when setting is disabled", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
// The default multiple-duration event type should have the selector visible
await page.goto(`/${user.username}/multiple-duration`);
// Wait for the page to load
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" });
// The duration selector buttons should be visible
await expect(page.getByTestId("multiple-choice-30mins")).toBeVisible();
await expect(page.getByTestId("multiple-choice-60mins")).toBeVisible();
await expect(page.getByTestId("multiple-choice-90mins")).toBeVisible();
});
test("default duration is shown when selector is hidden and no URL param", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
// Get the multiple duration event type
const eventType = user.eventTypes.find((e) => e.slug === "multiple-duration");
expect(eventType).toBeDefined();
await test.step("enable hide duration selector setting", async () => {
await page.goto("/event-types");
await page.waitForSelector('[data-testid="event-types"]');
await page.getByTestId("event-types").locator('a[title="Multiple duration"]').click();
await page.waitForSelector('[data-testid="event-title"]');
// Find and check the hide duration selector checkbox
const checkbox = page.getByTestId("hide-duration-selector-checkbox");
await checkbox.check();
// Save the event type
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypesHeavy/update?batch=1");
});
await test.step("verify default duration is displayed", async () => {
await page.goto(`/${user.username}/multiple-duration`);
// Wait for the page to load
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).waitFor({ state: "visible" });
// The duration text should be visible (default is 30 mins)
// Since the selector is hidden, we should see the duration as text
await expect(page.locator('[data-testid="event-meta"]')).toContainText("30");
});
});
});
@@ -881,6 +881,7 @@
"available_durations": "Available durations",
"default_duration": "Default duration",
"default_duration_no_options": "Please choose available durations first",
"hide_duration_selector_in_booking_page": "Hide duration selector",
"multiple_duration_mins": "{{count}} $t(minute_timeUnit)",
"multiple_duration_timeUnit": "{{count}} $t({{unit}}_timeUnit)",
"multiple_duration_timeUnit_short": "{{count}}$t({{unit}}_short)",
+1
View File
@@ -218,6 +218,7 @@ const _eventTypeMetaDataSchemaWithoutApps = z.object({
smartContractAddress: z.string().optional(),
blockchainId: z.number().optional(),
multipleDuration: z.number().array().optional(),
hideDurationSelectorInBookingPage: z.boolean().optional(),
giphyThankYouPage: z.string().optional(),
additionalNotesRequired: z.boolean().optional(),
disableSuccessPage: z.boolean().optional(),