* 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>
254 lines
7.1 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|