Files
calendar/apps/web/playwright/booking-sheet-keyboard.e2e.ts
T
Peer RichelsenGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Dhairyashileunjae@cal.com <hey@eunjae.dev>Dhairyashil Shinde
60b6086221 feat: add keyboard shortcuts and tooltips to booking slideover buttons (#27428)
* feat: add keyboard shortcuts and tooltips to booking slideover buttons

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

* fix: remove duplicate useBookingLocation import and fix import ordering

- Removed duplicate import of useBookingLocation from non-existent
  @calcom/web/modules/bookings/hooks/useBookingLocation path
- Fixed import ordering to satisfy biome organizeImports rules
- Removed unnecessary code comment
- Original feature by @PeerRich via Devin AI

Co-Authored-By: unknown <>

* use same style arrows for both button and remove bydefault focus from them

* fix join button tooltip hover

* fix: disable keyboard shortcuts when overlays/dialogs are open on BookingDetailsSheet

Co-Authored-By: unknown <>

* fix: use focus-based detection instead of selector-based overlay detection for keyboard shortcuts

Co-Authored-By: unknown <>

* fix: allow keyboard shortcuts when focus is on sheet ancestors

Co-Authored-By: unknown <>

* fix: use capture phase for keyboard handler to prevent Enter from activating focused buttons

Co-Authored-By: unknown <>

* fix: allow shortcuts when focus is on page elements outside any Radix portal

Co-Authored-By: unknown <>

* fix: handle calendar event clicks in onInteractOutside to prevent sheet close/reopen

Co-Authored-By: unknown <>

* fix: stop arrow key propagation to prevent Radix dropdown from opening during booking navigation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: always stop arrow key propagation when sheet is active, even at first/last booking

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: extract keyboard handler into testable utility with tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: resolve type errors in keyboard handler config and test mocks

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* test: add e2e tests for booking sheet keyboard shortcuts

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: replace text= locators with data-testid selectors in e2e tests

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* made 3 fixes: Fix 1 — Stabilize handleNext/handlePrevious/handleClose with useCallback
In BookingDetailsSheet.tsx, all three handler functions were plain arrow functions recreated on every render, causing the useEffect to tear down and re-attach the document keydown listener unnecessarily. Wrapped all three in useCallback with proper dependency arrays (the Zustand store functions they call).
Fix 2 — data-booking-list-item verified (no change needed)
Confirmed that data-booking-list-item is rendered on BookingListItem.tsx and data-booking-calendar-event is rendered on Event.tsx. The onInteractOutside handler in the final merged state correctly checks both selectors. No code change required.
Fix 3 — Removed dead code from JoinMeetingButton
Reverted JoinMeetingButton back to a plain function component:
Removed forwardRef wrapping (no caller passes a ref)
Removed showTooltip prop (unused — tooltip is handled by the parent BookingDetailsSheet)
Removed ref prop from the inner Button
Removed unused Tod forwardRef imports

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Dhairyashil <dhairyashil10101010@gmail.com>
Co-authored-by: eunjae@cal.com <hey@eunjae.dev>
Co-authored-by: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com>
2026-02-19 22:32:43 +05:30

254 lines
7.1 KiB
TypeScript

import { BookingStatus } from "@calcom/prisma/enums";
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
async function enableBookingsV3(prisma: Fixtures["prisma"]) {
const existing = await prisma.feature.findUnique({ where: { slug: "bookings-v3" } });
await prisma.feature.upsert({
where: { slug: "bookings-v3" },
update: { enabled: true },
create: { slug: "bookings-v3", enabled: true, type: "OPERATIONAL" },
});
return existing;
}
async function restoreBookingsV3(prisma: Fixtures["prisma"], existing: { enabled: boolean } | null) {
if (existing) {
await prisma.feature.update({
where: { slug: "bookings-v3" },
data: { enabled: existing.enabled },
});
} else {
await prisma.feature.deleteMany({ where: { slug: "bookings-v3" } });
}
}
async function createBooking({
bookingsFixture,
organizer,
organizerEventType,
attendees,
relativeDate = 0,
durationMins = 30,
title,
}: {
bookingsFixture: Fixtures["bookings"];
organizer: { id: number; username: string | null };
organizerEventType: { id: number };
attendees: { name: string; email: string; timeZone: string }[];
relativeDate?: number;
durationMins?: number;
title: string;
}) {
const DAY_MS = 24 * 60 * 60 * 1000;
const bookingDurationMs = durationMins * 60 * 1000;
const startTime = new Date(Date.now() + relativeDate * DAY_MS);
const endTime = new Date(Date.now() + relativeDate * DAY_MS + bookingDurationMs);
return await bookingsFixture.create(organizer.id, organizer.username, organizerEventType.id, {
title,
status: BookingStatus.ACCEPTED,
startTime,
endTime,
attendees: {
createMany: {
data: [...attendees],
},
},
});
}
async function setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
bookingCount = 3,
}: {
page: Page;
users: Fixtures["users"];
bookings: Fixtures["bookings"];
prisma: Fixtures["prisma"];
bookingCount?: number;
}) {
const existingFlag = await enableBookingsV3(prisma);
const user = await users.create();
const fixtures = [];
for (let i = 0; i < bookingCount; i++) {
const fixture = await createBooking({
title: `Booking ${i + 1}`,
bookingsFixture: bookings,
relativeDate: i + 1,
organizer: user,
organizerEventType: user.eventTypes[0],
attendees: [{ name: `Attendee ${i + 1}`, email: `attendee${i + 1}@example.com`, timeZone: "Europe/Berlin" }],
});
fixtures.push(fixture);
}
await user.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto("/bookings/upcoming", { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
const firstBookingItem = page.locator(`[data-booking-uid="${fixtures[0].uid}"]`);
await expect(firstBookingItem).toBeVisible();
await firstBookingItem.locator('[role="button"]').first().click();
const sheet = page.locator('[role="dialog"]');
await expect(sheet).toBeVisible();
return { user, fixtures, existingFlag, sheet };
}
test.describe("Booking sheet keyboard shortcuts", () => {
test("ArrowDown navigates to the next booking", async ({ page, users, bookings, prisma }) => {
const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
});
try {
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");
await page.keyboard.press("ArrowDown");
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2");
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
test("ArrowUp navigates to the previous booking", async ({ page, users, bookings, prisma }) => {
const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
});
try {
await page.keyboard.press("ArrowDown");
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2");
await page.keyboard.press("ArrowUp");
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
test("ArrowDown at last booking does NOT open the actions dropdown", async ({
page,
users,
bookings,
prisma,
}) => {
const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
bookingCount: 2,
});
try {
await page.keyboard.press("ArrowDown");
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2");
await page.keyboard.press("ArrowDown");
await page.waitForTimeout(500);
const dropdownContent = page.locator('[data-radix-popper-content-wrapper]');
await expect(dropdownContent).toBeHidden();
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 2");
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
test("ArrowUp at first booking does NOT open the actions dropdown", async ({
page,
users,
bookings,
prisma,
}) => {
const { existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
});
try {
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");
await page.keyboard.press("ArrowUp");
await page.waitForTimeout(500);
const dropdownContent = page.locator('[data-radix-popper-content-wrapper]');
await expect(dropdownContent).toBeHidden();
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
test("Escape closes the sheet", async ({ page, users, bookings, prisma }) => {
const { existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
});
try {
await expect(sheet).toBeVisible();
await page.keyboard.press("Escape");
await expect(sheet).toBeHidden();
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
test("Rapid arrow key presses navigate correctly without triggering dropdown", async ({
page,
users,
bookings,
prisma,
}) => {
const { fixtures, existingFlag, sheet } = await setupBookingsAndOpenSheet({
page,
users,
bookings,
prisma,
});
try {
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 1");
await page.keyboard.press("ArrowDown");
await page.keyboard.press("ArrowDown");
await expect(sheet.getByTestId("booking-sheet-title")).toHaveText("Booking 3");
const dropdownContent = page.locator('[data-radix-popper-content-wrapper]');
await expect(dropdownContent).toBeHidden();
} finally {
await restoreBookingsV3(prisma, existingFlag);
}
});
});