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>
This commit is contained in:
co-authored by
Devin AI
Dhairyashil
eunjae@cal.com
Dhairyashil Shinde
parent
5d65df9c05
commit
60b6086221
@@ -30,11 +30,12 @@ import {
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@calcom/ui/components/sheet";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
|
||||
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
|
||||
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { z } from "zod";
|
||||
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
|
||||
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
|
||||
@@ -44,6 +45,10 @@ import type { BookingListingStatus } from "../../../components/booking/types";
|
||||
import { usePaymentStatus } from "../hooks/usePaymentStatus";
|
||||
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
|
||||
import type { BookingOutput } from "../types";
|
||||
import {
|
||||
checkSheetActive,
|
||||
createBookingSheetKeydownHandler,
|
||||
} from "../lib/bookingSheetKeyboardHandler";
|
||||
import { JoinMeetingButton } from "./JoinMeetingButton";
|
||||
|
||||
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
|
||||
@@ -103,10 +108,9 @@ interface BookingDetailsSheetInnerProps {
|
||||
}
|
||||
|
||||
function useActiveSegment(bookingAuditEnabled: boolean) {
|
||||
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore((state) => [
|
||||
state.activeSegment,
|
||||
state.setActiveSegment,
|
||||
]);
|
||||
const [activeSegment, setActiveSegmentInStore] = useBookingDetailsSheetStore(
|
||||
(state) => [state.activeSegment, state.setActiveSegment]
|
||||
);
|
||||
|
||||
const getDerivedActiveSegment = ({
|
||||
activeSegment,
|
||||
@@ -121,10 +125,15 @@ function useActiveSegment(bookingAuditEnabled: boolean) {
|
||||
return activeSegment ?? "info";
|
||||
};
|
||||
|
||||
const derivedActiveSegment = getDerivedActiveSegment({ activeSegment, bookingAuditEnabled });
|
||||
const derivedActiveSegment = getDerivedActiveSegment({
|
||||
activeSegment,
|
||||
bookingAuditEnabled,
|
||||
});
|
||||
|
||||
const setDerivedActiveSegment = (segment: "info" | "history") => {
|
||||
setActiveSegmentInStore(getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled }));
|
||||
setActiveSegmentInStore(
|
||||
getDerivedActiveSegment({ activeSegment: segment, bookingAuditEnabled })
|
||||
);
|
||||
};
|
||||
|
||||
return [derivedActiveSegment, setDerivedActiveSegment] as const;
|
||||
@@ -139,16 +148,18 @@ function BookingDetailsSheetInner({
|
||||
bookingAuditEnabled = false,
|
||||
}: BookingDetailsSheetInnerProps) {
|
||||
const { t } = useLocale();
|
||||
const [activeSegment, setActiveSegment] = useActiveSegment(bookingAuditEnabled);
|
||||
const [activeSegment, setActiveSegment] =
|
||||
useActiveSegment(bookingAuditEnabled);
|
||||
|
||||
// Fetch additional booking details for reschedule information
|
||||
const { data: bookingDetails } = trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
const { data: bookingDetails } =
|
||||
trpc.viewer.bookings.getBookingDetails.useQuery(
|
||||
{ uid: booking.uid },
|
||||
{
|
||||
// Keep data fresh but don't refetch too aggressively
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
// Get navigation state from the store in a single selector
|
||||
const navigation = useBookingDetailsSheetStore((state) => {
|
||||
@@ -163,23 +174,57 @@ function BookingDetailsSheetInner({
|
||||
isTransitioning: state.isTransitioning,
|
||||
setSelectedBookingUid: state.setSelectedBookingUid,
|
||||
setActiveSegment: state.setActiveSegment,
|
||||
canGoNext: hasNextInArray || (isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev: hasPreviousInArray || (isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
canGoNext:
|
||||
hasNextInArray ||
|
||||
(isLastInArray && state.capabilities?.canNavigateToNextPeriod()),
|
||||
canGoPrev:
|
||||
hasPreviousInArray ||
|
||||
(isFirstInArray && state.capabilities?.canNavigateToPreviousPeriod()),
|
||||
};
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
const handleClose = useCallback(() => {
|
||||
navigation.setSelectedBookingUid(null);
|
||||
navigation.setActiveSegment(null);
|
||||
};
|
||||
}, [navigation.setSelectedBookingUid, navigation.setActiveSegment]);
|
||||
|
||||
const handleNext = () => {
|
||||
const handleNext = useCallback(() => {
|
||||
navigation.navigateNext();
|
||||
};
|
||||
}, [navigation.navigateNext]);
|
||||
|
||||
const handlePrevious = () => {
|
||||
const handlePrevious = useCallback(() => {
|
||||
navigation.navigatePrevious();
|
||||
};
|
||||
}, [navigation.navigatePrevious]);
|
||||
|
||||
const joinButtonWrapperRef = useRef<HTMLDivElement>(null);
|
||||
const sheetContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isSheetActive = useCallback(
|
||||
() => checkSheetActive(sheetContentRef.current, document.activeElement),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = createBookingSheetKeydownHandler({
|
||||
isSheetActive,
|
||||
canGoPrev: navigation.canGoPrev,
|
||||
canGoNext: navigation.canGoNext,
|
||||
isTransitioning: navigation.isTransitioning,
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
getJoinLink: () => joinButtonWrapperRef.current?.querySelector("a"),
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown, true);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown, true);
|
||||
}, [
|
||||
navigation.canGoPrev,
|
||||
navigation.canGoNext,
|
||||
navigation.isTransitioning,
|
||||
handleNext,
|
||||
handlePrevious,
|
||||
isSheetActive,
|
||||
]);
|
||||
|
||||
const startTime = dayjs(booking.startTime).tz(userTimeZone);
|
||||
const endTime = dayjs(booking.endTime).tz(userTimeZone);
|
||||
@@ -208,7 +253,9 @@ function BookingDetailsSheetInner({
|
||||
|
||||
const isPending = booking.status === BookingStatus.PENDING;
|
||||
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
|
||||
const parsedMetadata = bookingMetadataSchema.safeParse(
|
||||
booking.metadata ?? null
|
||||
);
|
||||
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
|
||||
|
||||
const recurringInfo =
|
||||
@@ -232,6 +279,7 @@ function BookingDetailsSheetInner({
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={handleClose} modal={false}>
|
||||
<SheetContent
|
||||
ref={sheetContentRef}
|
||||
className="overflow-y-auto pb-0 sm:pb-0"
|
||||
hideOverlay
|
||||
onInteractOutside={(e) => {
|
||||
@@ -254,38 +302,65 @@ function BookingDetailsSheetInner({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("previous_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-up"
|
||||
tabIndex={-1}
|
||||
disabled={!navigation.canGoPrev || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handlePrevious();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("next_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="chevron-down"
|
||||
tabIndex={-1}
|
||||
disabled={!navigation.canGoNext || navigation.isTransitioning}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleNext();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("close_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="icon"
|
||||
size="sm"
|
||||
color="secondary"
|
||||
StartIcon="x"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
@@ -293,9 +368,9 @@ function BookingDetailsSheetInner({
|
||||
<SheetBody className="-mt-3">
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex flex-col gap-1">
|
||||
<SheetTitle className="text-emphasis flex items-center gap-3 text-xl font-semibold">
|
||||
<div className="bg-emphasis w-0.5 shrink-0 self-stretch rounded-lg"></div>
|
||||
<span>{booking.title}</span>
|
||||
<SheetTitle className="flex items-center gap-3 font-semibold text-emphasis text-xl">
|
||||
<div className="w-0.5 shrink-0 self-stretch rounded-lg bg-emphasis"></div>
|
||||
<span data-testid="booking-sheet-title">{booking.title}</span>
|
||||
</SheetTitle>
|
||||
</div>
|
||||
|
||||
@@ -337,7 +412,12 @@ function BookingDetailsSheetInner({
|
||||
|
||||
<AssignmentReasonSection booking={booking} />
|
||||
|
||||
{booking.payment?.[0] && <PaymentSection booking={booking} payment={booking.payment[0]} />}
|
||||
{booking.payment?.[0] && (
|
||||
<PaymentSection
|
||||
booking={booking}
|
||||
payment={booking.payment[0]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SlotsSection booking={booking} />
|
||||
|
||||
@@ -377,18 +457,29 @@ function BookingDetailsSheetInner({
|
||||
</>
|
||||
) : (
|
||||
!booking.rescheduled && (
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{t("join_shortcut")}</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div ref={joinButtonWrapperRef}>
|
||||
<JoinMeetingButton
|
||||
location={booking.location}
|
||||
metadata={booking.metadata}
|
||||
bookingStatus={booking.status}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
)}
|
||||
|
||||
<BookingActionsDropdown
|
||||
booking={{
|
||||
...booking,
|
||||
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
|
||||
listingStatus:
|
||||
booking.status.toLowerCase() as BookingListingStatus,
|
||||
recurringInfo: undefined,
|
||||
loggedInUser: {
|
||||
userId,
|
||||
@@ -426,7 +517,7 @@ function WhenSection({
|
||||
return (
|
||||
<Section title={t("when")}>
|
||||
{previousBooking?.startTime && previousBooking?.endTime && (
|
||||
<div className="text-default flex flex-col text-sm line-through opacity-60">
|
||||
<div className="flex flex-col text-default text-sm line-through opacity-60">
|
||||
<DisplayTimestamp
|
||||
startTime={previousBooking.startTime}
|
||||
endTime={previousBooking.endTime}
|
||||
@@ -436,10 +527,15 @@ function WhenSection({
|
||||
)}
|
||||
<div
|
||||
className={classNames(
|
||||
"text-emphasis flex flex-col text-sm font-medium",
|
||||
"flex flex-col font-medium text-emphasis text-sm",
|
||||
rescheduled && "line-through"
|
||||
)}>
|
||||
<DisplayTimestamp startTime={startTime} endTime={endTime} timeZone={timeZone} />
|
||||
)}
|
||||
>
|
||||
<DisplayTimestamp
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
timeZone={timeZone}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
@@ -485,13 +581,16 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
imageSrc={
|
||||
booking.user.avatarUrl
|
||||
? getUserAvatarUrl(booking.user)
|
||||
: getPlaceholderAvatar(null, booking.user.name || booking.user.email)
|
||||
: getPlaceholderAvatar(
|
||||
null,
|
||||
booking.user.name || booking.user.email
|
||||
)
|
||||
}
|
||||
alt={booking.user.name || booking.user.email || ""}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-emphasis truncate text-sm leading-[1.2]">
|
||||
<p className="truncate text-emphasis text-sm leading-[1.2]">
|
||||
{booking.eventType?.hideOrganizerEmail
|
||||
? booking.user.name || t("organizer")
|
||||
: booking.user.name || booking.user.email}
|
||||
@@ -501,14 +600,20 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
</Badge>
|
||||
</div>
|
||||
{!booking.eventType?.hideOrganizerEmail && (
|
||||
<p className="text-default truncate text-sm leading-[1.2]">{booking.user.email}</p>
|
||||
<p className="truncate text-default text-sm leading-[1.2]">
|
||||
{booking.user.email}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{booking.attendees.map((attendee, idx) => {
|
||||
const name = attendee.user?.name || attendee.name || attendee.user?.email || attendee.email;
|
||||
const name =
|
||||
attendee.user?.name ||
|
||||
attendee.name ||
|
||||
attendee.user?.email ||
|
||||
attendee.email;
|
||||
return (
|
||||
<div key={idx} className="flex items-center gap-4">
|
||||
<Avatar
|
||||
@@ -535,7 +640,13 @@ function WhoSection({ booking }: { booking: BookingOutput }) {
|
||||
);
|
||||
}
|
||||
|
||||
function WhereSection({ booking, meta }: { booking: BookingOutput; meta: BookingMetaData | null }) {
|
||||
function WhereSection({
|
||||
booking,
|
||||
meta,
|
||||
}: {
|
||||
booking: BookingOutput;
|
||||
meta: BookingMetaData | null;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
|
||||
@@ -573,12 +684,15 @@ function WhereSection({ booking, meta }: { booking: BookingOutput; meta: Booking
|
||||
/>
|
||||
)}
|
||||
<div className="flex min-w-0 items-baseline gap-1">
|
||||
<span className="text-emphasis shrink-0 font-medium">{provider?.label}:</span>
|
||||
<span className="shrink-0 font-medium text-emphasis">
|
||||
{provider?.label}:
|
||||
</span>
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-blue-600 hover:underline">
|
||||
className="truncate text-blue-600 hover:underline"
|
||||
>
|
||||
{locationToDisplay}
|
||||
</a>
|
||||
</div>
|
||||
@@ -600,7 +714,7 @@ function RecurringInfoSection({
|
||||
|
||||
return (
|
||||
<Section title={t("recurring_event")}>
|
||||
<p className="text-emphasis text-sm font-medium">
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{getEveryFreqFor({
|
||||
t,
|
||||
recurringEvent: recurringInfo.recurringEvent,
|
||||
@@ -614,19 +728,26 @@ function RecurringInfoSection({
|
||||
function AssignmentReasonSection({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!booking.assignmentReasonSortedByCreatedAt || booking.assignmentReasonSortedByCreatedAt.length === 0) {
|
||||
if (
|
||||
!booking.assignmentReasonSortedByCreatedAt ||
|
||||
booking.assignmentReasonSortedByCreatedAt.length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reason =
|
||||
booking.assignmentReasonSortedByCreatedAt[booking.assignmentReasonSortedByCreatedAt.length - 1];
|
||||
booking.assignmentReasonSortedByCreatedAt[
|
||||
booking.assignmentReasonSortedByCreatedAt.length - 1
|
||||
];
|
||||
if (!reason.reasonString) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("assignment_reason")}>
|
||||
<div className="text-emphasis text-sm font-medium">{reason.reasonString}</div>
|
||||
<div className="font-medium text-emphasis text-sm">
|
||||
{reason.reasonString}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -644,7 +765,9 @@ function PaymentSection({
|
||||
const parsedEventTypeMetadata = booking.eventType?.metadata
|
||||
? EventTypeMetaDataSchema.safeParse(booking.eventType.metadata)
|
||||
: null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success ? parsedEventTypeMetadata.data : null;
|
||||
const eventTypeMetadata = parsedEventTypeMetadata?.success
|
||||
? parsedEventTypeMetadata.data
|
||||
: null;
|
||||
|
||||
const refundPolicy = eventTypeMetadata?.apps?.stripe?.refundPolicy;
|
||||
const refundDaysCount = eventTypeMetadata?.apps?.stripe?.refundDaysCount;
|
||||
@@ -663,8 +786,10 @@ function PaymentSection({
|
||||
|
||||
return (
|
||||
<Section title={t("payment")}>
|
||||
<p className="text-emphasis text-sm font-medium">{formattedPrice}</p>
|
||||
{paymentStatusMessage && <p className="text-subtle text-xs">{paymentStatusMessage}</p>}
|
||||
<p className="font-medium text-emphasis text-sm">{formattedPrice}</p>
|
||||
{paymentStatusMessage && (
|
||||
<p className="text-subtle text-xs">{paymentStatusMessage}</p>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -685,7 +810,9 @@ function SlotsSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("slots")}>
|
||||
<p className="text-emphasis text-sm font-medium">{t("slots_taken", { takenSeats, totalSeats })}</p>
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{t("slots_taken", { takenSeats, totalSeats })}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -723,7 +850,11 @@ function CustomQuestionsSection({
|
||||
|
||||
return new Map<string, string>(
|
||||
bookingFields.map(
|
||||
(field) => [field.name, field.label || t(field.defaultLabel || field.name)] as [string, string]
|
||||
(field) =>
|
||||
[field.name, field.label || t(field.defaultLabel || field.name)] as [
|
||||
string,
|
||||
string
|
||||
]
|
||||
)
|
||||
);
|
||||
}, [bookingFields, t]);
|
||||
@@ -739,7 +870,9 @@ function CustomQuestionsSection({
|
||||
const title: string = fieldLabels.get(fieldNameStr) ?? fieldNameStr;
|
||||
return (
|
||||
<Section key={idx} title={title}>
|
||||
<p className="text-emphasis text-sm font-medium">{String(answer)}</p>
|
||||
<p className="font-medium text-emphasis text-sm">
|
||||
{String(answer)}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
@@ -762,7 +895,8 @@ function OldRescheduledBookingInfo({
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduledBy;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
@@ -771,7 +905,7 @@ function OldRescheduledBookingInfo({
|
||||
{rescheduledToBooking?.uid && (
|
||||
<Section title={t("rescheduled")}>
|
||||
<Link href={`/booking/${rescheduledToBooking.uid}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
<div className="flex items-center gap-1 text-default text-sm underline">
|
||||
{t("view_booking")}
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -780,17 +914,19 @@ function OldRescheduledBookingInfo({
|
||||
)}
|
||||
{rescheduledBy && (
|
||||
<Section title={t("rescheduled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -806,15 +942,18 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const rescheduledBy = booking.rescheduler;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section title={t("rescheduled_by")}>
|
||||
{rescheduledBy && <p className="text-emphasis text-sm font-medium">{rescheduledBy}</p>}
|
||||
{rescheduledBy && (
|
||||
<p className="font-medium text-emphasis text-sm">{rescheduledBy}</p>
|
||||
)}
|
||||
<Link href={`/booking/${booking.fromReschedule}`}>
|
||||
<div className="text-default flex items-center gap-1 text-sm underline">
|
||||
<div className="flex items-center gap-1 text-default text-sm underline">
|
||||
{t("original_booking")}
|
||||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -822,7 +961,9 @@ function NewRescheduledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
</Section>
|
||||
{cancellationReason && (
|
||||
<Section title={t("reschedule_reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -834,14 +975,17 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
const { t } = useLocale();
|
||||
|
||||
// Only show for cancelled/rejected bookings that were NOT rescheduled
|
||||
const isCancelled = booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
const isCancelled =
|
||||
booking.status === BookingStatus.CANCELLED ||
|
||||
booking.status === BookingStatus.REJECTED;
|
||||
const wasRescheduled = booking.rescheduled === true;
|
||||
|
||||
if (!isCancelled || wasRescheduled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cancellationReason = booking.cancellationReason || booking.rejectionReason;
|
||||
const cancellationReason =
|
||||
booking.cancellationReason || booking.rejectionReason;
|
||||
const cancelledBy = booking.cancelledBy;
|
||||
|
||||
if (!cancellationReason && !cancelledBy) {
|
||||
@@ -852,12 +996,14 @@ function CancelledBookingInfo({ booking }: { booking: BookingOutput }) {
|
||||
<>
|
||||
{cancelledBy && (
|
||||
<Section title={t("cancelled_by")}>
|
||||
<p className="text-emphasis text-sm font-medium">{cancelledBy}</p>
|
||||
<p className="font-medium text-emphasis text-sm">{cancelledBy}</p>
|
||||
</Section>
|
||||
)}
|
||||
{cancellationReason && (
|
||||
<Section title={t("reason")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{cancellationReason}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{cancellationReason}
|
||||
</p>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
@@ -873,7 +1019,9 @@ function AdditionalNotesSection({ booking }: { booking: BookingOutput }) {
|
||||
|
||||
return (
|
||||
<Section title={t("additional_notes")}>
|
||||
<p className="text-emphasis whitespace-pre-wrap text-sm font-medium">{booking.description}</p>
|
||||
<p className="whitespace-pre-wrap font-medium text-emphasis text-sm">
|
||||
{booking.description}
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -902,7 +1050,9 @@ function BookingHeaderBadges({
|
||||
{reasonTitle}
|
||||
</Badge>
|
||||
)}
|
||||
{booking.eventType.team && <Badge variant="gray">{booking.eventType.team.name}</Badge>}
|
||||
{booking.eventType.team && (
|
||||
<Badge variant="gray">{booking.eventType.team.name}</Badge>
|
||||
)}
|
||||
{booking.paid && !payment ? (
|
||||
<Badge variant="orange">{t("error_collecting_card")}</Badge>
|
||||
) : booking.paid ? (
|
||||
@@ -937,7 +1087,9 @@ function TrackingSection({
|
||||
return null;
|
||||
}
|
||||
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) => Boolean(value));
|
||||
const utmEntries = Object.entries(tracking).filter(([_, value]) =>
|
||||
Boolean(value)
|
||||
);
|
||||
|
||||
if (utmEntries.length === 0) {
|
||||
return null;
|
||||
@@ -949,7 +1101,9 @@ function TrackingSection({
|
||||
{utmEntries.map(([key, value]) => (
|
||||
<div key={key} className="mb-1 last:mb-0">
|
||||
<span className="font-medium">{key}</span>:{" "}
|
||||
<code className="bg-subtle text-default rounded px-1 py-0.5 font-mono text-xs">{value}</code>
|
||||
<code className="rounded bg-subtle px-1 py-0.5 font-mono text-default text-xs">
|
||||
{value}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -968,7 +1122,7 @@ function Section({
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames("flex flex-col gap-1", className)}>
|
||||
<h3 className="text-subtle text-xs font-medium">{title}</h3>
|
||||
<h3 className="font-medium text-subtle text-xs">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
|
||||
import { useJoinableLocation } from "./useJoinableLocation";
|
||||
|
||||
interface JoinMeetingButtonProps {
|
||||
@@ -54,9 +53,15 @@ export function JoinMeetingButton({
|
||||
onClick={handleClick}>
|
||||
{provider?.iconUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={provider.iconUrl} className="h-4 w-4 shrink-0 rounded-sm" alt={`${provider.label} logo`} />
|
||||
<img
|
||||
src={provider.iconUrl}
|
||||
className="h-4 w-4 shrink-0 rounded-sm"
|
||||
alt={`${provider.label} logo`}
|
||||
/>
|
||||
)}
|
||||
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
|
||||
{provider?.label
|
||||
? t("join_event_location", { eventLocationType: provider.label })
|
||||
: t("join_meeting")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
isEditableTarget,
|
||||
checkSheetActive,
|
||||
createBookingSheetKeydownHandler,
|
||||
type BookingSheetKeyboardConfig,
|
||||
} from "./bookingSheetKeyboardHandler";
|
||||
|
||||
function createMockKeyboardEvent(key: string, target?: EventTarget | null): KeyboardEvent {
|
||||
const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true });
|
||||
if (target) {
|
||||
Object.defineProperty(event, "target", { value: target });
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("isEditableTarget", () => {
|
||||
it("returns true for input elements", () => {
|
||||
const input = document.createElement("input");
|
||||
expect(isEditableTarget(input)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for textarea elements", () => {
|
||||
const textarea = document.createElement("textarea");
|
||||
expect(isEditableTarget(textarea)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for contenteditable elements", () => {
|
||||
const div = document.createElement("div");
|
||||
div.contentEditable = "true";
|
||||
expect(isEditableTarget(div)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for regular div elements", () => {
|
||||
const div = document.createElement("div");
|
||||
expect(isEditableTarget(div)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for button elements", () => {
|
||||
const button = document.createElement("button");
|
||||
expect(isEditableTarget(button)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null", () => {
|
||||
expect(isEditableTarget(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkSheetActive", () => {
|
||||
it("returns true when no element has focus", () => {
|
||||
expect(checkSheetActive(document.createElement("div"), null)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when document.body is focused", () => {
|
||||
expect(checkSheetActive(document.createElement("div"), document.body)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when sheetContent is null", () => {
|
||||
const focusedElement = document.createElement("button");
|
||||
document.body.appendChild(focusedElement);
|
||||
expect(checkSheetActive(null, focusedElement)).toBe(false);
|
||||
document.body.removeChild(focusedElement);
|
||||
});
|
||||
|
||||
it("returns true when focused element is inside sheetContent", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const button = document.createElement("button");
|
||||
sheetContent.appendChild(button);
|
||||
document.body.appendChild(sheetContent);
|
||||
expect(checkSheetActive(sheetContent, button)).toBe(true);
|
||||
document.body.removeChild(sheetContent);
|
||||
});
|
||||
|
||||
it("returns true when focused element is outside any Radix portal", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const outsideButton = document.createElement("button");
|
||||
document.body.appendChild(sheetContent);
|
||||
document.body.appendChild(outsideButton);
|
||||
expect(checkSheetActive(sheetContent, outsideButton)).toBe(true);
|
||||
document.body.removeChild(sheetContent);
|
||||
document.body.removeChild(outsideButton);
|
||||
});
|
||||
|
||||
it("returns false when focused element is inside a Radix portal (overlay)", () => {
|
||||
const sheetContent = document.createElement("div");
|
||||
const portal = document.createElement("div");
|
||||
portal.setAttribute("data-radix-portal", "");
|
||||
const dropdownButton = document.createElement("button");
|
||||
portal.appendChild(dropdownButton);
|
||||
document.body.appendChild(sheetContent);
|
||||
document.body.appendChild(portal);
|
||||
expect(checkSheetActive(sheetContent, dropdownButton)).toBe(false);
|
||||
document.body.removeChild(sheetContent);
|
||||
document.body.removeChild(portal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createBookingSheetKeydownHandler", () => {
|
||||
let handlePrevious: () => void;
|
||||
let handleNext: () => void;
|
||||
let mockJoinLink: HTMLAnchorElement;
|
||||
let config: BookingSheetKeyboardConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
handlePrevious = vi.fn();
|
||||
handleNext = vi.fn();
|
||||
mockJoinLink = document.createElement("a");
|
||||
mockJoinLink.click = vi.fn();
|
||||
|
||||
config = {
|
||||
isSheetActive: () => true,
|
||||
canGoPrev: true,
|
||||
canGoNext: true,
|
||||
isTransitioning: false,
|
||||
handlePrevious,
|
||||
handleNext,
|
||||
getJoinLink: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
describe("ArrowDown", () => {
|
||||
it("navigates to next when canGoNext is true", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when canGoNext is false", () => {
|
||||
config.canGoNext = false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when transitioning", () => {
|
||||
config.isTransitioning = true;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowDown");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArrowUp", () => {
|
||||
it("navigates to previous when canGoPrev is true", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when canGoPrev is false", () => {
|
||||
config.canGoPrev = false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops propagation but does NOT navigate when transitioning", () => {
|
||||
config.isTransitioning = true;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("ArrowUp");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Enter", () => {
|
||||
it("clicks join link when available", () => {
|
||||
config.getJoinLink = () => mockJoinLink;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Enter");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).toHaveBeenCalled();
|
||||
expect(mockJoinLink.click).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing when no join link is available", () => {
|
||||
config.getJoinLink = () => null;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Enter");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sheet inactive", () => {
|
||||
it("does not handle any keys when sheet is inactive", () => {
|
||||
config.isSheetActive = () => false;
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
|
||||
const downEvent = createMockKeyboardEvent("ArrowDown");
|
||||
const downSpy = vi.spyOn(downEvent, "stopPropagation");
|
||||
handler(downEvent);
|
||||
expect(downSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
|
||||
const upEvent = createMockKeyboardEvent("ArrowUp");
|
||||
const upSpy = vi.spyOn(upEvent, "stopPropagation");
|
||||
handler(upEvent);
|
||||
expect(upSpy).not.toHaveBeenCalled();
|
||||
expect(handlePrevious).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("editable targets", () => {
|
||||
it("ignores events from input elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const input = document.createElement("input");
|
||||
const event = createMockKeyboardEvent("ArrowDown", input);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores events from textarea elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const textarea = document.createElement("textarea");
|
||||
const event = createMockKeyboardEvent("ArrowDown", textarea);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores events from contenteditable elements", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const div = document.createElement("div");
|
||||
div.contentEditable = "true";
|
||||
const event = createMockKeyboardEvent("ArrowDown", div);
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(handleNext).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unhandled keys", () => {
|
||||
it("does not preventDefault or stopPropagation for unhandled keys", () => {
|
||||
const handler = createBookingSheetKeydownHandler(config);
|
||||
const event = createMockKeyboardEvent("Tab");
|
||||
const preventDefaultSpy = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagationSpy = vi.spyOn(event, "stopPropagation");
|
||||
|
||||
handler(event);
|
||||
|
||||
expect(preventDefaultSpy).not.toHaveBeenCalled();
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
export interface BookingSheetKeyboardConfig {
|
||||
isSheetActive: () => boolean;
|
||||
canGoPrev: boolean | undefined;
|
||||
canGoNext: boolean | undefined;
|
||||
isTransitioning: boolean;
|
||||
handlePrevious: () => void;
|
||||
handleNext: () => void;
|
||||
getJoinLink: () => HTMLAnchorElement | null | undefined;
|
||||
}
|
||||
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
||||
return true;
|
||||
}
|
||||
if (target instanceof HTMLElement) {
|
||||
return target.contentEditable === "true";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function checkSheetActive(
|
||||
sheetContent: HTMLElement | null,
|
||||
activeElement: Element | null
|
||||
): boolean {
|
||||
if (!activeElement || activeElement === document.body) {
|
||||
return true;
|
||||
}
|
||||
if (!sheetContent) return false;
|
||||
if (sheetContent.contains(activeElement)) {
|
||||
return true;
|
||||
}
|
||||
return !activeElement.closest("[data-radix-portal]");
|
||||
}
|
||||
|
||||
export function createBookingSheetKeydownHandler(config: BookingSheetKeyboardConfig) {
|
||||
return (e: KeyboardEvent) => {
|
||||
if (isEditableTarget(e.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.isSheetActive()) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowUp":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (config.canGoPrev && !config.isTransitioning) {
|
||||
config.handlePrevious();
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (config.canGoNext && !config.isTransitioning) {
|
||||
config.handleNext();
|
||||
}
|
||||
break;
|
||||
case "Enter": {
|
||||
const joinLink = config.getJoinLink();
|
||||
if (joinLink) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
joinLink.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1823,6 +1823,10 @@
|
||||
"navigate": "Navigate",
|
||||
"open": "Open",
|
||||
"close": "Close",
|
||||
"close_shortcut": "Close (ESC)",
|
||||
"previous_shortcut": "Previous (↑)",
|
||||
"next_shortcut": "Next (↓)",
|
||||
"join_shortcut": "Join (Enter)",
|
||||
"upgrade": "Upgrade",
|
||||
"buy_us_number_button": "Buy US number for {{priceInDollars}}$/month",
|
||||
"upgrade_to_access_recordings_title": "Upgrade to access recordings",
|
||||
|
||||
@@ -110,7 +110,8 @@ export function Event({
|
||||
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
|
||||
<Component
|
||||
data-booking-calendar-event="true"
|
||||
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
|
||||
onClick={() => onEventClick?.(event)}
|
||||
{...(options?.bookingUid ? { "data-booking-uid": options.bookingUid } : {})}
|
||||
className={classNames(
|
||||
eventClasses({
|
||||
status: options?.status,
|
||||
|
||||
Reference in New Issue
Block a user