feat: add booking actions into BookingDetailsSheet (#24912)

* move booking actions into a subfolder

* fix logic to render booking actions dropdown

* skip dropdown portal when rendered from detail sheet

* fix type error
This commit is contained in:
Eunjae Lee
2025-11-11 16:14:48 +00:00
committed by GitHub
parent 38f96d97d3
commit 86353191d2
10 changed files with 165 additions and 99 deletions
@@ -46,8 +46,11 @@ import { Tooltip } from "@calcom/ui/components/tooltip";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import { buildBookingLink } from "../../modules/bookings/lib/buildBookingLink";
import { BookingActionsDropdown } from "./BookingActionsDropdown";
import { useBookingActionsStoreContext, BookingActionsStoreProvider } from "./BookingActionsStoreProvider";
import { BookingActionsDropdown } from "./actions/BookingActionsDropdown";
import {
useBookingActionsStoreContext,
BookingActionsStoreProvider,
} from "./actions/BookingActionsStoreProvider";
import {
getPendingActions,
getCancelEventAction,
@@ -56,7 +59,7 @@ import {
shouldShowIndividualReportButton,
type BookingActionContext,
getReportAction,
} from "./bookingActions";
} from "./actions/bookingActions";
import type { BookingItemProps } from "./types";
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
@@ -496,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
<div className="flex w-full flex-col flex-wrap items-end justify-end space-x-2 space-y-2 py-4 pl-4 text-right text-sm font-medium ltr:pr-4 rtl:pl-4 sm:flex-row sm:flex-nowrap sm:items-start sm:space-y-0 sm:pl-0">
{shouldShowPendingActions(actionContext) && <TableActions actions={pendingActions} />}
<BookingActionsDropdown booking={booking} />
<BookingActionsDropdown booking={booking} context="booking-list-item" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
@@ -31,6 +31,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
getCancelEventAction,
@@ -40,13 +41,14 @@ import {
shouldShowEditActions,
type BookingActionContext,
} from "./bookingActions";
import type { BookingItemProps } from "./types";
interface BookingActionsDropdownProps {
booking: BookingItemProps;
context: "booking-list-item" | "booking-details-sheet";
size?: "xs" | "sm" | "base" | "lg";
}
export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps) {
export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -236,9 +238,11 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
const cancelEventAction = getCancelEventAction(actionContext);
const shouldShowEdit = shouldShowEditActions(actionContext);
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
...action,
disabled: !shouldShowEdit || action.disabled, // Disable all edit actions if shouldn't show edit actions
onClick:
action.id === "reschedule_request"
? () => setIsOpenRescheduleDialog(true)
@@ -494,99 +498,119 @@ export function BookingActionsDropdown({ booking }: BookingActionsDropdownProps)
</>
);
// Don't render dropdown if edit actions shouldn't be shown
if (!shouldShowEditActions(actionContext)) {
// Check if there are any available actions across all action groups
const hasAnyAvailableActions = () => {
// Check if any edit action is available
const hasAvailableEditAction = editEventActions.some((action) => !action.disabled);
// Check if any after event action is available
const hasAvailableAfterAction = afterEventActions.some((action) => !action.disabled);
// For booking-list-item context, only check edit and after event actions
if (context === "booking-list-item") {
return hasAvailableEditAction || hasAvailableAfterAction;
}
// For booking-details-sheet context, also check report and cancel actions
const isReportAvailable = !reportActionWithHandler.disabled;
const isCancelAvailable = !cancelEventAction.disabled;
return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable;
};
// Don't render dropdown if no actions are available
if (!hasAnyAvailableActions()) {
return dialogs;
}
const menuContent = (
<DropdownMenuContent>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuSeparator />
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
);
return (
<>
{dialogs}
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon="ellipsis"
data-testid="booking-actions-dropdown"
/>
<Dropdown modal={context === "booking-details-sheet" ? false : undefined}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button type="button" color="secondary" variant="icon" size={size} StartIcon="ellipsis" />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuLabel className="p-2">{t("edit_event")}</DropdownMenuLabel>
{editEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
disabled={action.disabled}
onClick={action.onClick}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="!my-1 bg-subtle" />
<DropdownMenuLabel className="p-2">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
<DropdownMenuItem className="rounded-lg" key={action.id} disabled={action.disabled}>
<DropdownItem
type="button"
color={action.color}
StartIcon={action.icon}
href={action.href}
onClick={action.onClick}
disabled={action.disabled}
data-bookingid={action.bookingId}
data-testid={action.id}
className={action.disabled ? "text-muted" : undefined}>
{action.label}
</DropdownItem>
</DropdownMenuItem>
))}
<>
<DropdownMenuSeparator className="!my-1 bg-subtle" />
<DropdownMenuItem
className="rounded-lg"
key={reportActionWithHandler.id}
disabled={reportActionWithHandler.disabled}>
<DropdownItem
type="button"
color={reportActionWithHandler.color}
StartIcon={reportActionWithHandler.icon}
onClick={reportActionWithHandler.onClick}
disabled={reportActionWithHandler.disabled}
data-testid={reportActionWithHandler.id}
className={reportActionWithHandler.disabled ? "text-muted" : undefined}>
{reportActionWithHandler.label}
</DropdownItem>
</DropdownMenuItem>
</>
<DropdownMenuSeparator className="!my-1 bg-subtle" />
<DropdownMenuItem
className="rounded-lg"
key={cancelEventAction.id}
disabled={cancelEventAction.disabled}>
<DropdownItem
type="button"
color={cancelEventAction.color}
StartIcon={cancelEventAction.icon}
href={cancelEventAction.disabled ? undefined : cancelEventAction.href}
onClick={cancelEventAction.onClick}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
{context === "booking-details-sheet" ? (
menuContent
) : (
<DropdownMenuPortal>{menuContent}</DropdownMenuPortal>
)}
</Dropdown>
</>
);
@@ -54,7 +54,7 @@ function createMockContext(overrides: Partial<BookingActionContext> = {}): Booki
locale: "en",
bookingId: 1,
noShow: false,
} as any,
},
],
user: {
id: 1,
@@ -1,7 +1,7 @@
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { ActionType } from "@calcom/ui/components/table";
import type { BookingItemProps } from "./types";
import type { BookingItemProps } from "../types";
export interface BookingActionContext {
booking: BookingItemProps;
+1 -1
View File
@@ -1,6 +1,6 @@
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
export type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
type BookingItem = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][number];
@@ -26,6 +26,9 @@ import {
SheetTitle,
} from "@calcom/ui/components/sheet";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import { buildBookingLink } from "../lib/buildBookingLink";
import type { BookingOutput } from "../types";
@@ -37,26 +40,43 @@ interface BookingDetailsSheetProps {
onClose: () => void;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
onPrevious?: () => void;
hasPrevious?: boolean;
onNext?: () => void;
hasNext?: boolean;
}
export function BookingDetailsSheet({
interface BookingDetailsSheetInnerProps extends Omit<BookingDetailsSheetProps, "booking"> {
booking: BookingOutput;
}
export function BookingDetailsSheet(props: BookingDetailsSheetProps) {
if (!props.booking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner {...props} booking={props.booking} />
</BookingActionsStoreProvider>
);
}
function BookingDetailsSheetInner({
booking,
isOpen,
onClose,
userTimeZone,
userTimeFormat,
userId,
userEmail,
onPrevious,
hasPrevious = false,
onNext,
hasNext = false,
}: BookingDetailsSheetProps) {
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
if (!booking) return null;
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
@@ -210,6 +230,22 @@ export function BookingDetailsSheet({
<Button color="secondary" size="sm" EndIcon="external-link" href={bookingLink} target="_blank">
{t("view")}
</Button>
<BookingActionsDropdown
context="booking-details-sheet"
size="sm"
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
/>
</div>
</SheetFooter>
</SheetContent>
@@ -473,6 +473,8 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
onClose={() => setSelectedBookingId(null)}
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
onPrevious={bookingNavigation.onPrevious}
hasPrevious={bookingNavigation.hasPrevious}
onNext={bookingNavigation.onNext}
@@ -3968,5 +3968,6 @@
"team_invite_subtitle": "Invite team members to collaborate and schedule together",
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
"view_booking_details": "View Booking Details",
"view": "View",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}