fix: put booking details and calendar behind feature flag (#25175)

* Revert "fix: revert bookings redesign (#25172)"

This reverts commit 1f102bf3b4.

* add bookings-v3 feature flag

* put things behind a feature flag

* remove no longer needed test

* revert e2e tests

* put back description

* revert AvatarGroup

* apply feedback

* remove "view" booking action

* remove Alert (When the bookings query errors, this branch now renders only the alert and skips the data-table filter/segment controls. Those controls moved into BookingsList, so in error states users can no longer clear or tweak filters to recover from the failure, effectively trapping them behind the alert.)

* address feedback

* revert useMediaQuery
This commit is contained in:
Eunjae Lee
2025-11-19 16:27:13 +01:00
committed by GitHub
parent 7e4d9e26c6
commit dd7f108f08
32 changed files with 1557 additions and 699 deletions
@@ -0,0 +1,64 @@
"use client";
import { useQueryState } from "nuqs";
import { useEffect } from "react";
import { activeFiltersParser } from "@calcom/features/data-table/lib/parsers";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { ToggleGroup } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { viewParser, type BookingView } from "~/bookings/lib/viewParser";
export function ViewToggleButton() {
const { t } = useLocale();
const [view, setView] = useQueryState(
"view",
viewParser.withDefault("list").withOptions({ clearOnDefault: true })
);
const [, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const isMobile = !useMediaQuery("(min-width: 640px)");
useEffect(() => {
// Force list view on mobile
if (isMobile && view === "calendar") {
// Clear dateRange filter when forcing calendar to list view, same as manual toggle
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
setView("list");
}
}, [isMobile, view, setView, setActiveFilters]);
return (
<div className="hidden sm:block">
<ToggleGroup
value={view}
onValueChange={(value) => {
if (!value) return;
const newView = value as BookingView;
// When switching from calendar to list view, remove the dateRange filter
if (view === "calendar" && newView === "list") {
setActiveFilters((prev) => prev?.filter((filter) => filter.f !== "dateRange") ?? []);
}
setView(newView);
}}
options={[
{
value: "list",
label: "",
tooltip: t("list_view"),
iconLeft: <Icon name="menu" className="h-4 w-4" />,
},
{
value: "calendar",
label: "",
tooltip: t("calendar_view"),
iconLeft: <Icon name="calendar" className="h-4 w-4" />,
},
]}
/>
</div>
);
}
@@ -16,6 +16,8 @@ import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-view";
import { ViewToggleButton } from "./ViewToggleButton";
const querySchema = z.object({
status: z.enum(validStatuses),
});
@@ -54,18 +56,23 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const userProfile = session?.user?.profile;
const orgId = userProfile?.organizationId ?? session?.user.org?.id;
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"booking-calendar-view"
);
const bookingsV3Enabled = orgId
? await featuresRepository.checkIfTeamHasFeature(orgId, "bookings-v3")
: false;
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<ShellMainAppDir
heading={t("bookings")}
subtitle={t("bookings_description")}
CTA={bookingsV3Enabled ? <ViewToggleButton /> : null}>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
isCalendarViewEnabled={isCalendarViewEnabled}
bookingsV3Enabled={bookingsV3Enabled}
/>
</ShellMainAppDir>
);
@@ -499,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} context="booking-list-item" />
<BookingActionsDropdown booking={booking} context="list" />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
+44 -12
View File
@@ -5,9 +5,20 @@ import { SkeletonText } from "@calcom/ui/components/skeleton";
function SkeletonLoader() {
return (
<ul className="divide-subtle border-subtle bg-default animate-pulse divide-y rounded-md border sm:overflow-hidden">
{/* TODAY header */}
<li className="bg-muted flex items-center px-6 py-4">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
{/* NEXT header */}
<li className="bg-muted px-4 py-2">
<SkeletonText className="h-4 w-16" />
</li>
<SkeletonItem />
<SkeletonItem />
</ul>
);
}
@@ -16,20 +27,41 @@ export default SkeletonLoader;
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between px-4 py-4 sm:px-6">
<div className="flex-grow truncate text-sm">
<div className="flex">
<div className="flex flex-col space-y-2">
<SkeletonText className="h-5 w-16" />
<SkeletonText className="h-4 w-32" />
</div>
<li className="group flex w-full items-start justify-between px-4 py-4 sm:px-6">
{/* Left side - Date and time info */}
<div className="flex min-w-0 flex-shrink-0 flex-col gap-2 pr-4">
<SkeletonText className="h-4 w-24" />
<SkeletonText className="h-4 w-28" />
<div className="mt-1 flex items-center gap-2">
<SkeletonText className="h-4 w-4 rounded" />
<SkeletonText className="h-3 w-20" />
</div>
{/* Badges */}
<div className="mt-3 flex gap-2">
<SkeletonText className="h-5 w-14 rounded-md" />
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-6 w-16" />
<SkeletonText className="h-6 w-32" />
</div>
{/* Right side - Event details */}
<div className="flex min-w-0 flex-1 flex-col space-y-2">
{/* Event title */}
<SkeletonText className="h-5 w-36" />
{/* Event description */}
<SkeletonText className="h-4 w-48" />
{/* Attendees */}
<SkeletonText className="h-4 w-56" />
</div>
{/* Action buttons - only visible on larger screens */}
<div className="ml-4 hidden flex-shrink-0 gap-2 sm:flex">
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-20 rounded-md" />
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
{/* Mobile menu button */}
<div className="ml-4 flex flex-shrink-0 sm:hidden">
<SkeletonText className="h-9 w-9 rounded-md" />
</div>
</li>
);
@@ -15,9 +15,9 @@ import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { TextAreaField } from "@calcom/ui/components/form";
import type { ActionType } from "@calcom/ui/components/table";
@@ -39,16 +39,36 @@ import {
getAfterEventActions,
getReportAction,
shouldShowEditActions,
shouldShowPendingActions,
getPendingActions,
type BookingActionContext,
} from "./bookingActions";
interface BookingActionsDropdownProps {
booking: BookingItemProps;
context: "booking-list-item" | "booking-details-sheet";
size?: "xs" | "sm" | "base" | "lg";
className?: string;
/**
* Whether to use a portal for the dropdown menu.
* Set to false when rendering inside a Sheet/Dialog to keep the dropdown within the modal's stacking context.
* @default true
*/
usePortal?: boolean;
/**
* Context where the dropdown is being used.
* - "list": Used in booking list view (hides Confirm and Reject actions)
* - "details": Used in booking details view (shows all actions)
*/
context: "list" | "details";
}
export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) {
export function BookingActionsDropdown({
booking,
size = "base",
className,
usePortal = true,
context,
}: BookingActionsDropdownProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -211,6 +231,10 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
}
};
// Calculate showPendingPayment based on payment logic
const hasPayment = booking.payment.length > 0;
const showPendingPayment = hasPayment;
const actionContext: BookingActionContext = {
booking,
isUpcoming,
@@ -228,7 +252,7 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
isDisabledCancelling,
isDisabledRescheduling,
isCalVideoLocation,
showPendingPayment: false, // This will be calculated below
showPendingPayment,
isAttendee,
cardCharged,
attendeeList,
@@ -238,6 +262,21 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
const cancelEventAction = getCancelEventAction(actionContext);
// Get pending actions (accept/reject) - only for details context
const shouldShowPending = shouldShowPendingActions(actionContext);
const basePendingActions =
shouldShowPending && context === "details" ? getPendingActions(actionContext) : [];
const pendingActions: ActionType[] = basePendingActions.map((action) => ({
...action,
disabled: mutation.isPending,
onClick:
action.id === "confirm"
? () => bookingConfirm(true)
: action.id === "reject"
? () => setRejectionDialogIsOpen(true)
: undefined,
})) as ActionType[];
const shouldShowEdit = shouldShowEditActions(actionContext);
const baseEditEventActions = getEditEventActions(actionContext);
const editEventActions: ActionType[] = baseEditEventActions.map((action) => ({
@@ -500,22 +539,26 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
// Check if there are any available actions across all action groups
const hasAnyAvailableActions = () => {
// Check if any pending action is available
const hasAvailablePendingAction = pendingActions.some((action) => !action.disabled);
// 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
// Check report and cancel actions
const isReportAvailable = !reportActionWithHandler.disabled;
const isCancelAvailable = !cancelEventAction.disabled;
return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable;
return (
hasAvailablePendingAction ||
hasAvailableEditAction ||
hasAvailableAfterAction ||
isReportAvailable ||
isCancelAvailable
);
};
// Don't render dropdown if no actions are available
@@ -523,94 +566,138 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
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>
);
// Conditional portal wrapper to avoid portal when inside Sheet/Dialog
const ConditionalPortal = usePortal
? DropdownMenuPortal
: ({ children }: { children: React.ReactNode }) => <>{children}</>;
return (
<>
{dialogs}
<Dropdown modal={context === "booking-details-sheet" ? false : undefined}>
<Dropdown modal={false}>
<DropdownMenuTrigger asChild data-testid="booking-actions-dropdown">
<Button type="button" color="secondary" variant="icon" size={size} StartIcon="ellipsis" />
<Button
type="button"
color="secondary"
size={size}
StartIcon="ellipsis"
className={className}
// Prevent click from bubbling to parent row/container click handlers
onClick={(e) => e.stopPropagation()}
/>
</DropdownMenuTrigger>
{context === "booking-details-sheet" ? (
menuContent
) : (
<DropdownMenuPortal>{menuContent}</DropdownMenuPortal>
)}
<ConditionalPortal>
<DropdownMenuContent>
{pendingActions.length > 0 && (
<>
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("booking_response")}</DropdownMenuLabel>
{pendingActions.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={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
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("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={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
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={(e) => {
e.stopPropagation();
action.onClick?.(e);
}}
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={(e) => {
e.stopPropagation();
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={(e) => {
e.stopPropagation();
cancelEventAction.onClick?.(e);
}}
disabled={cancelEventAction.disabled}
data-bookingid={cancelEventAction.bookingId}
data-testid={cancelEventAction.id}
className={cancelEventAction.disabled ? "text-muted" : undefined}>
{cancelEventAction.label}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</ConditionalPortal>
</Dropdown>
</>
);
@@ -137,18 +137,18 @@ describe("Booking Actions", () => {
expect(actions).toHaveLength(2);
expect(actions[0]).toEqual({
id: "reject",
label: "reject",
icon: "ban",
disabled: false,
});
expect(actions[1]).toEqual({
id: "confirm",
bookingId: 1,
label: "confirm",
icon: "check",
disabled: false,
});
expect(actions[1]).toEqual({
id: "reject",
label: "reject",
icon: "ban",
disabled: false,
});
});
it("should return reject action only for non-pending booking", () => {
@@ -167,8 +167,8 @@ describe("Booking Actions", () => {
});
const actions = getPendingActions(context);
expect(actions[0].label).toBe("reject_all");
expect(actions[1].label).toBe("confirm_all");
expect(actions[0].label).toBe("confirm_all");
expect(actions[1].label).toBe("reject_all");
});
});
@@ -38,14 +38,7 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
const { booking, isPending, isTabRecurring, isTabUnconfirmed, isRecurring, showPendingPayment, t } =
context;
const actions: ActionType[] = [
{
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
icon: "ban",
disabled: false, // This would be controlled by mutation state in the component
},
];
const actions: ActionType[] = [];
// For bookings with payment, only confirm if the booking is paid for
// Original logic: (isPending && !paymentAppData.enabled) || (paymentAppData.enabled && !!paymentAppData.price && booking.paid)
@@ -59,6 +52,13 @@ export function getPendingActions(context: BookingActionContext): ActionType[] {
});
}
actions.push({
id: "reject",
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("reject_all") : t("reject"),
icon: "ban",
disabled: false, // This would be controlled by mutation state in the component
});
return actions;
}
@@ -36,7 +36,7 @@ export const RescheduleDialog = (props: IRescheduleDialog) => {
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow>
<DialogContent enableOverflow data-testid="reschedule-dialog">
<div className="flex flex-row md:space-x-3">
<div className="bg-subtle hidden h-10 w-10 flex-shrink-0 justify-center rounded-full md:flex">
<Icon name="clock" className="m-auto h-6 w-6" />
@@ -0,0 +1,133 @@
import type { VisibilityState } from "@tanstack/react-table";
import { createColumnHelper } from "@tanstack/react-table";
import { ColumnFilterType } from "@calcom/features/data-table";
import type { RowData } from "../types";
interface BuildFilterColumnsParams {
t: (key: string) => string;
permissions: {
canReadOthersBookings: boolean;
};
status: string;
}
/**
* IDs of filter columns that should be hidden in the UI
* These columns are used for filtering but not displayed
*/
const FILTER_COLUMN_IDS = [
"eventTypeId",
"teamId",
"userId",
"attendeeName",
"attendeeEmail",
"dateRange",
"bookingUid",
] as const;
/**
* Gets the column visibility state for filter columns
* All filter columns are hidden by default as they only provide filtering functionality
*/
export function getFilterColumnVisibility(): VisibilityState {
return Object.fromEntries(FILTER_COLUMN_IDS.map((id) => [id, false]));
}
export function buildFilterColumns({ t, permissions, status }: BuildFilterColumnsParams) {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.id : null), {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.eventType.team?.id ?? null : null), {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.user?.id ?? null : null), {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => (row.type === "data" ? row.booking.uid : null), {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
];
}
@@ -29,65 +29,91 @@ import {
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 {
useBookingDetailsSheetStore,
useBookingDetailsSheetStoreApi,
} from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
interface BookingDetailsSheetProps {
booking: BookingOutput | null;
isOpen: boolean;
onClose: () => void;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
onPrevious?: () => void;
hasPrevious?: boolean;
onNext?: () => void;
hasNext?: boolean;
}
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,
export function BookingDetailsSheet({
userTimeZone,
userTimeFormat,
userId,
userEmail,
}: BookingDetailsSheetProps) {
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
// Return null if no booking is selected (sheet is closed)
if (!booking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner
booking={booking}
userTimeZone={userTimeZone}
userTimeFormat={userTimeFormat}
userId={userId}
userEmail={userEmail}
/>
</BookingActionsStoreProvider>
);
}
interface BookingDetailsSheetInnerProps {
booking: BookingOutput;
userTimeZone?: string;
userTimeFormat?: number;
userId?: number;
userEmail?: string;
}
function BookingDetailsSheetInner({
booking,
userTimeZone,
userTimeFormat,
userId,
userEmail,
onPrevious,
hasPrevious = false,
onNext,
hasNext = false,
}: BookingDetailsSheetInnerProps) {
const { t } = useLocale();
// Get navigation state directly from the store
const hasNext = useBookingDetailsSheetStore((state) => state.hasNext());
const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious());
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleClose = () => {
setSelectedBookingId(null);
};
const storeApi = useBookingDetailsSheetStoreApi();
const handleNext = () => {
const nextId = storeApi.getState().getNextBookingId();
if (nextId !== null) {
setSelectedBookingId(nextId);
}
};
const handlePrevious = () => {
const prevId = storeApi.getState().getPreviousBookingId();
if (prevId !== null) {
setSelectedBookingId(prevId);
}
};
const startTime = dayjs(booking.startTime).tz(userTimeZone);
const endTime = dayjs(booking.endTime).tz(userTimeZone);
// Build booking confirmation link
const isRecurring = booking.recurringEventId !== null;
const bookingLink = buildBookingLink({
bookingUid: booking.uid,
allRemainingBookings: isRecurring,
email: booking.attendees?.[0]?.email,
});
const getStatusBadge = () => {
switch (booking.status) {
case "ACCEPTED":
@@ -108,14 +134,6 @@ function BookingDetailsSheetInner({
const parsedMetadata = bookingMetadataSchema.safeParse(booking.metadata ?? null);
const bookingMetadata = parsedMetadata.success ? parsedMetadata.data : null;
// Get conference link info for Join button
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
location: booking.location,
videoCallUrl: bookingMetadata?.videoCallUrl,
t,
bookingStatus: booking.status,
});
const recurringInfo =
booking.recurringEventId && booking.eventType?.recurringEvent
? {
@@ -131,7 +149,7 @@ function BookingDetailsSheetInner({
: [];
return (
<Sheet open={isOpen} onOpenChange={onClose}>
<Sheet open={true} onOpenChange={handleClose}>
<SheetContent className="overflow-y-auto">
<SheetHeader
showCloseButton={false}
@@ -144,7 +162,7 @@ function BookingDetailsSheetInner({
disabled={!hasPrevious}
onClick={(e) => {
e.preventDefault();
onPrevious?.();
handlePrevious();
}}
/>
<Button
@@ -154,7 +172,7 @@ function BookingDetailsSheetInner({
disabled={!hasNext}
onClick={(e) => {
e.preventDefault();
onNext?.();
handleNext();
}}
/>
</div>
@@ -183,6 +201,8 @@ function BookingDetailsSheetInner({
</p>
</div>
<RescheduleRequestMessage booking={booking} />
<WhoSection booking={booking} />
<WhereSection booking={booking} meta={bookingMetadata} />
@@ -203,49 +223,35 @@ function BookingDetailsSheetInner({
</SheetBody>
<SheetFooter className="bg-muted border-subtle -mx-4 -mb-4 border-t pt-0 sm:-mx-6 sm:-my-6">
<div className="flex w-full flex-row items-center justify-end gap-2 px-4 pb-4 pt-4">
{isLocationURL && locationToDisplay && (
<>
<Button
color="secondary"
size="sm"
href={locationToDisplay}
target="_blank"
className="flex items-center gap-2">
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 flex-shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
)}
{provider?.label
? t("join_event_location", { eventLocationType: provider.label })
: t("join_meeting")}
</Button>
<div className="border-subtle h-3 w-px border-r" />
</>
)}
<Button color="secondary" size="sm" EndIcon="external-link" href={bookingLink} target="_blank">
{t("view")}
<div className="flex w-full flex-row items-center justify-between gap-2 px-4 pb-4 pt-4">
<Button color="secondary" StartIcon="x" onClick={handleClose}>
{t("close")}
</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 className="flex gap-2">
<JoinMeetingButton
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: undefined,
loggedInUser: {
userId,
userTimeZone,
userTimeFormat: userTimeFormat ?? null,
userEmail,
},
isToday: false,
}}
usePortal={false}
context="details"
/>
</div>
</div>
</SheetFooter>
</SheetContent>
@@ -253,6 +259,20 @@ function BookingDetailsSheetInner({
);
}
function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
if (booking.status !== "CANCELLED" || !booking.rescheduled) {
return null;
}
return (
<Badge startIcon="send" size="md" variant="gray" data-testid="request_reschedule_sent">
{t("reschedule_request_sent")}
</Badge>
);
}
function WhoSection({ booking }: { booking: BookingOutput }) {
const { t } = useLocale();
return (
@@ -1,89 +1,64 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo, useCallback } from "react";
import { useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableFilters,
DataTableSegment,
useDataTable,
useFilterValue,
ZDateRangeFilterValue,
ColumnFilterType,
} from "@calcom/features/data-table";
import { CUSTOM_PRESET } from "@calcom/features/data-table/lib/dateRange";
import type { RowData, BookingListingStatus } from "../types";
import type { RowData, BookingListingStatus, BookingOutput } from "../types";
import { BookingsCalendarView } from "./BookingsCalendarView";
type BookingsCalendarViewProps = {
type BookingsCalendarProps = {
status: BookingListingStatus;
table: ReactTable<RowData>;
isPending?: boolean;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
) => Promise<URLSearchParams>;
bookings: BookingOutput[];
ErrorView?: React.ReactNode;
hasError?: boolean;
};
const COLUMN_IDS_TO_HIDE = ["dateRange"];
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
const { rows } = table.getRowModel();
export function BookingsCalendar({
table,
isPending = false,
currentWeekStart,
setCurrentWeekStart,
bookings,
ErrorView,
hasError,
}: BookingsCalendarProps) {
const { updateFilter } = useDataTable();
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const bookings = useMemo(() => {
return rows
.filter((row) => row.original.type === "data")
.map((row) => (row.original.type === "data" ? row.original.booking : null))
.filter((booking): booking is NonNullable<typeof booking> => booking !== null);
}, [rows]);
const handleWeekStartChange = useCallback(
(newWeekStart: dayjs.Dayjs) => {
setCurrentWeekStart(newWeekStart);
// Always set the date range to match the current week exactly
const startDate = newWeekStart.toDate();
const endDate = newWeekStart.add(6, "day").toDate();
if (!dateRange) {
return;
}
const rangeStart = dateRange.startDate ? new Date(dateRange.startDate) : null;
const rangeEnd = dateRange.endDate ? new Date(dateRange.endDate) : null;
const needsStartUpdate = !rangeStart || startDate < rangeStart;
const needsEndUpdate = !rangeEnd || endDate > rangeEnd;
if (!needsStartUpdate && !needsEndUpdate) {
return;
}
const newStartDate = needsStartUpdate ? startDate : rangeStart;
const newEndDate = needsEndUpdate ? endDate : rangeEnd;
updateFilter("dateRange", {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: newStartDate.toISOString(),
endDate: newEndDate.toISOString(),
startDate: startDate.toISOString(),
endDate: endDate.toISOString(),
preset: CUSTOM_PRESET.value,
},
});
},
[dateRange, updateFilter, setCurrentWeekStart]
[updateFilter, setCurrentWeekStart]
);
return (
@@ -99,11 +74,16 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
<DataTableSegment.Select />
</div>
</div>
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
/>
{hasError && ErrorView ? (
ErrorView
) : (
<BookingsCalendarView
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
/>
)}
</>
);
}
@@ -0,0 +1,162 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsCalendar } from "./BookingsCalendar";
const weekStartParser = createParser({
parse: (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed.startOf("week") : dayjs().startOf("week");
},
serialize: (value: dayjs.Dayjs) => value.format("YYYY-MM-DD"),
});
interface BookingsCalendarContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending?: boolean;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
export function BookingsCalendarContainer({
status,
permissions,
data,
isPending = false,
ErrorView,
hasError,
}: BookingsCalendarContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
/**
* Transform raw booking data into RowData format for the calendar view
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - Attaches recurring info and isToday flag to each booking
*/
const rowData = useMemo<RowData[]>(() => {
if (!data?.bookings) {
return [];
}
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
}
return true;
};
return data.bookings.filter(filterBookings).map((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const today = dayjs().tz(user?.timeZone).format("YYYY-MM-DD");
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
return {
type: "data" as const,
booking,
isToday: bookingDateStr === today,
recurringInfo: data.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
};
});
}, [data, status, user?.timeZone]);
const table = useReactTable<RowData>({
data: rowData,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
// Extract bookings from table data and filter by current week
const bookings = useMemo(() => {
const weekStart = currentWeekStart;
const weekEnd = currentWeekStart.add(6, "day");
return rowData
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking)
.filter((booking) => {
const bookingStart = dayjs(booking.startTime);
return (
(bookingStart.isAfter(weekStart) || bookingStart.isSame(weekStart, "day")) &&
bookingStart.isBefore(weekEnd.endOf("day"))
);
});
}, [rowData, currentWeekStart]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
ErrorView={ErrorView}
hasError={hasError}
/>
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -12,19 +12,23 @@ import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { Icon } from "@calcom/ui/components/icon";
import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore";
import type { BookingOutput } from "../types";
type BookingsCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
};
export function BookingsCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
isPending = false,
}: BookingsCalendarViewProps) {
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const { t } = useLocale();
const { timezone } = useTimePreferences();
const { resolvedTheme, forcedTheme } = useGetTheme();
@@ -44,8 +48,10 @@ export function BookingsCalendarView({
const startDate = useMemo(() => currentWeekStart.toDate(), [currentWeekStart]);
const endDate = useMemo(() => currentWeekStart.add(6, "day").toDate(), [currentWeekStart]);
// Intentionally only runs on mount to trigger the initial currentWeekStart
useEffect(() => {
onWeekStartChange(currentWeekStart);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const events = useMemo<CalendarEvent[]>(() => {
@@ -78,6 +84,7 @@ export function BookingsCalendarView({
options: {
status: booking.status,
...(eventTypeColor && { color: eventTypeColor }),
bookingId: booking.id,
},
};
});
@@ -109,6 +116,7 @@ export function BookingsCalendarView({
<div className="mx-4 mt-4 flex items-center justify-between py-1.5">
<div className="flex items-center gap-2">
<h2 className="text-xl font-semibold">{weekRange}</h2>
{isPending && <Icon name="refresh-cw" className="text-muted h-4 w-4 animate-spin" />}
</div>
<div className="flex items-center gap-2">
<Button color="secondary" onClick={goToToday} className="capitalize leading-4">
@@ -141,7 +149,12 @@ export function BookingsCalendarView({
showBackgroundPattern={false}
showBorder={false}
borderColor="subtle"
onEventClick={(_event) => {}}
onEventClick={(event) => {
const bookingId = event.options?.bookingId;
if (bookingId) {
setSelectedBookingId(bookingId);
}
}}
hideHeader
/>
</div>
@@ -23,9 +23,18 @@ type BookingsListViewProps = {
table: ReactTable<RowData>;
isPending: boolean;
totalRowCount?: number;
ErrorView?: React.ReactNode;
hasError?: boolean;
};
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
export function BookingsList({
status,
table,
isPending,
totalRowCount,
ErrorView,
hasError,
}: BookingsListViewProps) {
const { t } = useLocale();
return (
@@ -64,6 +73,8 @@ export function BookingsList({ status, table, isPending, totalRowCount }: Bookin
/>
</div>
}
ErrorView={ErrorView}
hasError={hasError}
/>
);
}
@@ -0,0 +1,346 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useMemo, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { ColumnFilterType } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import BookingListItem from "@calcom/web/components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type { RowData, BookingListingStatus, BookingsGetOutput, BookingOutput } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
interface BookingsListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data?: BookingsGetOutput;
isPending: boolean;
enableDetailsSheet: boolean;
totalRowCount?: number;
ErrorView?: React.ReactNode;
hasError?: boolean;
}
function BookingsListInner({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId);
const handleBookingClick = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
// Define table columns for filtering (hidden columns used for filter UI)
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
{...(enableDetailsSheet && { onClick: () => handleBookingClick(booking.id) })}
{...booking}
/>
);
} else if (props.row.original.type === "today") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("today")}
</p>
);
} else if (props.row.original.type === "next") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("next")}
</p>
);
}
},
}),
];
}, [user, status, t, permissions.canReadOthersBookings, enableDetailsSheet, handleBookingClick]);
/**
* Transform raw bookings into flat list (excluding today's bookings for "upcoming" status)
* - Deduplicates recurring bookings for recurring/unconfirmed/cancelled tabs
* - For "upcoming" status, filters out today's bookings (they're shown in separate "Today" section)
*/
const flatData = useMemo<RowData[]>(() => {
// For recurring/unconfirmed/cancelled tabs: track recurring series to show only one representative booking per series
// Key: recurringEventId, Value: array of all bookings in that series
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
// Deduplicate recurring bookings for specific status tabs
// This ensures we show only ONE booking per recurring series instead of all occurrences
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
// Non-recurring bookings are always shown
if (!booking.recurringEventId) {
return true;
}
// If we've already encountered this recurring series
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
// Store this occurrence but DON'T display it (return false to filter out)
shownBookings[booking.recurringEventId].push(booking);
return false;
}
// First occurrence of this recurring series - show it and start tracking
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
// For "upcoming" tab, exclude today's bookings (they're shown separately in the "Today" section)
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
return (
data?.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: false,
})) || []
);
}, [data, status, user?.timeZone]);
// Extract today's bookings for the "Today" section (only used in "upcoming" status)
const bookingsToday = useMemo<RowData[]>(() => {
return (data?.bookings ?? [])
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: data?.recurringInfo.find((info) => info.recurringEventId === booking.recurringEventId),
isToday: true,
}));
}, [data, user?.timeZone]);
// Combine data with section separators for "upcoming" tab
const finalData = useMemo<RowData[]>(() => {
// For other statuses, just return the flat list
if (status !== "upcoming") {
return flatData;
}
// For "upcoming" status, organize into "Today" and "Next" sections
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
}
return merged;
}, [bookingsToday, flatData, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
return (
<>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
{enableDetailsSheet && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={user?.timeFormat === null ? undefined : user?.timeFormat}
userId={user?.id}
userEmail={user?.email}
/>
)}
</>
);
}
export function BookingsListContainer({
status,
permissions,
data,
isPending,
enableDetailsSheet,
totalRowCount,
ErrorView,
hasError,
}: BookingsListContainerProps) {
// Extract bookings from data for BookingDetailsSheet
const bookings = useMemo(() => data?.bookings ?? [], [data]);
return (
<BookingDetailsSheetStoreProvider bookings={bookings}>
<BookingsListInner
status={status}
permissions={permissions}
data={data}
isPending={isPending}
enableDetailsSheet={enableDetailsSheet}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</BookingDetailsSheetStoreProvider>
);
}
@@ -0,0 +1,66 @@
"use client";
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 {
location: string | null;
metadata?: unknown;
bookingStatus: BookingStatus;
size?: "sm" | "base" | "lg";
color?: "primary" | "secondary" | "minimal" | "destructive";
className?: string;
onClick?: (e: React.MouseEvent) => void;
}
export function JoinMeetingButton({
location,
metadata,
bookingStatus,
size = "base",
color = "secondary",
className,
onClick,
}: JoinMeetingButtonProps) {
const { t } = useLocale();
const { isJoinable, locationToDisplay, provider } = useJoinableLocation({
location,
metadata,
bookingStatus,
t,
});
if (!isJoinable || !locationToDisplay) {
return null;
}
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
};
return (
<Button
color={color}
size={size}
href={locationToDisplay}
target="_blank"
rel="noopener noreferrer"
className={classNames("flex items-center gap-2", className)}
onClick={handleClick}>
{provider?.iconUrl && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={provider.iconUrl}
className="h-4 w-4 flex-shrink-0 rounded-sm"
alt={`${provider.label} logo`}
/>
)}
{provider?.label ? t("join_event_location", { eventLocationType: provider.label }) : t("join_meeting")}
</Button>
);
}
@@ -0,0 +1,46 @@
import type { TFunction } from "i18next";
import { useMemo } from "react";
import { useBookingLocation } from "@calcom/features/bookings/hooks";
import type { BookingStatus } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
interface UseJoinableLocationParams {
location: string | null;
metadata?: unknown;
bookingStatus: BookingStatus;
t: TFunction;
}
/**
* Custom hook to determine if a booking location is joinable (i.e., has a clickable URL).
* This hook is used to show/hide the join meeting button and related UI elements.
*
* @returns An object containing:
* - `isJoinable`: Whether the location is joinable (has a valid URL)
* - `locationToDisplay`: The location URL or text to display
* - `provider`: Provider information (label, iconUrl, etc.)
* - `isLocationURL`: Whether the location is a URL
*/
export function useJoinableLocation({ location, metadata, bookingStatus, t }: UseJoinableLocationParams) {
const bookingMetadata = useMemo(() => {
const parsedMetadata = bookingMetadataSchema.safeParse(metadata ?? null);
return parsedMetadata.success ? parsedMetadata.data : null;
}, [metadata]);
const { locationToDisplay, provider, isLocationURL } = useBookingLocation({
location,
videoCallUrl: bookingMetadata?.videoCallUrl,
t,
bookingStatus,
});
const isJoinable = isLocationURL && !!locationToDisplay;
return {
isJoinable,
locationToDisplay,
provider,
isLocationURL,
};
}
@@ -1,43 +1,37 @@
import { useMemo, useCallback } from "react";
import type { RowData } from "../types";
function isDataRow(row: RowData): row is Extract<RowData, { type: "data" }> {
return row.type === "data";
}
import type { BookingOutput } from "../types";
export function useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
}: {
bookings: RowData[];
bookings: BookingOutput[];
selectedBookingId: number | null;
setSelectedBookingId: (bookingId: number | null) => void;
}) {
const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]);
const currentIndex = useMemo(
() => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId),
[bookingRows, selectedBookingId]
() => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId),
[bookings, selectedBookingId]
);
const onPrevious = useCallback(() => {
if (currentIndex >= 1) {
setSelectedBookingId(bookingRows[currentIndex - 1].booking.id);
setSelectedBookingId(bookings[currentIndex - 1].id);
}
}, [bookingRows, currentIndex, setSelectedBookingId]);
}, [bookings, currentIndex, setSelectedBookingId]);
const onNext = useCallback(() => {
if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) {
setSelectedBookingId(bookingRows[currentIndex + 1].booking.id);
if (currentIndex >= 0 && currentIndex < bookings.length - 1) {
setSelectedBookingId(bookings[currentIndex + 1].id);
}
}, [bookingRows, currentIndex, setSelectedBookingId]);
}, [bookings, currentIndex, setSelectedBookingId]);
return {
onPrevious,
onNext,
hasPrevious: currentIndex > 0,
hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0,
hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0,
};
}
@@ -0,0 +1,14 @@
import { useQueryState } from "nuqs";
export function useSelectedBookingId() {
return useQueryState("selectedId", {
defaultValue: null,
parse: (value) => {
if (!value) return null;
const parsed = parseInt(value, 10);
return isNaN(parsed) ? null : parsed;
},
serialize: (value) => (value ? String(value) : ""),
clearOnDefault: true,
});
}
@@ -0,0 +1,11 @@
import { createParser } from "nuqs";
export const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: "list" | "calendar") => value,
});
export type BookingView = "list" | "calendar";
@@ -0,0 +1,144 @@
"use client";
import React from "react";
import { createStore, useStore } from "zustand";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { BookingOutput } from "../types";
interface BookingDetailsSheetStore {
// State
selectedBookingId: number | null;
bookings: BookingOutput[];
// Actions
setSelectedBookingId: (id: number | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
clearSelection: () => void;
// Computed getters (used via selectors)
getSelectedBooking: () => BookingOutput | null;
getNextBookingId: () => number | null;
getPreviousBookingId: () => number | null;
hasNext: () => boolean;
hasPrevious: () => boolean;
}
type BookingDetailsSheetStoreType = ReturnType<typeof createBookingDetailsSheetStore>;
const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => {
return createStore<BookingDetailsSheetStore>((set, get) => ({
// Initial state
selectedBookingId: null,
bookings: initialBookings,
// Actions
setSelectedBookingId: (id) => set({ selectedBookingId: id }),
setBookings: (bookings) => set({ bookings }),
clearSelection: () => set({ selectedBookingId: null }),
// Computed getters
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingId) return null;
return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null;
},
getNextBookingId: () => {
const state = get();
if (!state.selectedBookingId) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null;
return state.bookings[currentIndex + 1].id;
},
getPreviousBookingId: () => {
const state = get();
if (!state.selectedBookingId) return null;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
if (currentIndex <= 0) return null;
return state.bookings[currentIndex - 1].id;
},
hasNext: () => {
const state = get();
if (!state.selectedBookingId) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
return currentIndex >= 0 && currentIndex < state.bookings.length - 1;
},
hasPrevious: () => {
const state = get();
if (!state.selectedBookingId) return false;
const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId);
return currentIndex > 0;
},
}));
};
const BookingDetailsSheetStoreContext = React.createContext<BookingDetailsSheetStoreType | null>(null);
export function BookingDetailsSheetStoreProvider({
children,
bookings,
}: {
children: React.ReactNode;
bookings: BookingOutput[];
}) {
const [store] = React.useState(() => createBookingDetailsSheetStore(bookings));
const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId();
// Update bookings when they change
React.useEffect(() => {
store.getState().setBookings(bookings);
}, [bookings, store]);
// Sync Store → URL
React.useEffect(() => {
const unsubscribe = store.subscribe((state) => {
const storeId = state.selectedBookingId;
if (storeId !== selectedBookingIdFromUrl) {
setSelectedBookingIdToUrl(storeId);
}
});
return unsubscribe;
}, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]);
// Sync URL → Store
React.useEffect(() => {
const currentStoreId = store.getState().selectedBookingId;
if (currentStoreId !== selectedBookingIdFromUrl) {
store.getState().setSelectedBookingId(selectedBookingIdFromUrl);
}
}, [selectedBookingIdFromUrl, store]);
return (
<BookingDetailsSheetStoreContext.Provider value={store}>
{children}
</BookingDetailsSheetStoreContext.Provider>
);
}
export function useBookingDetailsSheetStore<T>(selector: (state: BookingDetailsSheetStore) => T): T {
const store = React.useContext(BookingDetailsSheetStoreContext);
if (!store) {
throw new Error("useBookingDetailsSheetStore must be used within BookingDetailsSheetStoreProvider");
}
return useStore(store, selector);
}
// For direct store access (needed for subscribe and getState)
export function useBookingDetailsSheetStoreApi() {
const store = React.useContext(BookingDetailsSheetStoreContext);
if (!store) {
throw new Error("useBookingDetailsSheetStoreApi must be used within BookingDetailsSheetStoreProvider");
}
return store;
}
+2
View File
@@ -2,6 +2,8 @@ import type { RouterOutputs } from "@calcom/trpc/react";
import type { validStatuses } from "~/bookings/lib/validStatuses";
export type BookingsGetOutput = RouterOutputs["viewer"]["bookings"]["get"];
export type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
export type RecurringInfo = {
+68 -333
View File
@@ -1,15 +1,13 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import dynamic from "next/dynamic";
import { useSearchParams, usePathname } from "next/navigation";
import { createParser, useQueryState } from "nuqs";
import { useQueryState } from "nuqs";
import { useMemo } from "react";
import dayjs from "@calcom/dayjs";
import {
DataTableProvider,
DataTableFilters,
DataTableSegment,
type SystemFilterSegment,
useDataTable,
ColumnFilterType,
@@ -21,22 +19,21 @@ import {
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Alert } from "@calcom/ui/components/alert";
import type { HorizontalTabItemProps } from "@calcom/ui/components/navigation";
import { HorizontalTabs } from "@calcom/ui/components/navigation";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import BookingListItem from "@components/booking/BookingListItem";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import type { validStatuses } from "~/bookings/lib/validStatuses";
import { viewParser } from "~/bookings/lib/viewParser";
import { BookingDetailsSheet } from "../components/BookingDetailsSheet";
import { BookingsCalendar } from "../components/BookingsCalendar";
import { BookingsList } from "../components/BookingsList";
import { useBookingCursor } from "../hooks/useBookingCursor";
import type { RowData, BookingOutput } from "../types";
import { BookingsListContainer } from "../components/BookingsListContainer";
const BookingsCalendarContainer = dynamic(() =>
import("../components/BookingsCalendarContainer").then((mod) => ({
default: mod.BookingsCalendarContainer,
}))
);
type BookingsProps = {
status: (typeof validStatuses)[number];
@@ -44,7 +41,7 @@ type BookingsProps = {
permissions: {
canReadOthersBookings: boolean;
};
isCalendarViewEnabled: boolean;
bookingsV3Enabled: boolean;
};
function useSystemSegments(userId?: number) {
@@ -86,27 +83,12 @@ export default function Bookings(props: BookingsProps) {
);
}
const viewParser = createParser({
parse: (value: string) => {
if (value === "calendar") return "calendar";
return "list";
},
serialize: (value: "list" | "calendar") => value,
});
function BookingsContent({ status, permissions, isCalendarViewEnabled }: BookingsProps) {
function BookingsContent({ status, permissions, bookingsV3Enabled }: BookingsProps) {
const [_view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = isCalendarViewEnabled ? _view : "list";
const view = bookingsV3Enabled ? _view : "list";
const { t } = useLocale();
const user = useMeQuery().data;
const searchParams = useSearchParams();
const [selectedBookingId, setSelectedBookingId] = useQueryState("selectedId", {
defaultValue: null,
parse: (value) => (value ? parseInt(value, 10) : null),
serialize: (value) => (value ? String(value) : ""),
clearOnDefault: true,
});
const tabs: HorizontalTabItemProps[] = useMemo(() => {
const queryString = searchParams?.toString() || "";
@@ -156,267 +138,43 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
const { limit, offset } = useDataTable();
const query = trpc.viewer.bookings.get.useQuery({
limit,
offset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
});
// Only apply pagination for list view, calendar view needs all bookings
const shouldPaginate = view === "list";
const queryLimit = shouldPaginate ? limit : 100; // Use max limit for calendar view
const queryOffset = shouldPaginate ? offset : 0; // Reset offset for calendar view
const columns = useMemo(() => {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.id, {
id: "eventTypeId",
header: t("event_type"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.eventType.team?.id, {
id: "teamId",
header: t("team"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("member"),
enableColumnFilter: permissions.canReadOthersBookings,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.uid, {
id: "bookingUid",
header: t("booking_uid"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
allowedOperators: ["equals"],
},
},
},
}),
columnHelper.display({
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
return (
<BookingListItem
key={booking.id}
isToday={isToday}
loggedInUser={{
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
}}
listingStatus={status}
recurringInfo={recurringInfo}
// uncomment this line to enable BookingDetailsSheet
// onClick={() => setSelectedBookingId(booking.id)}
{...booking}
/>
);
} else if (props.row.original.type === "today") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("today")}
</p>
);
} else if (props.row.original.type === "next") {
return (
<p className="text-subtle bg-subtle w-full py-4 pl-6 text-xs font-semibold uppercase leading-4">
{t("next")}
</p>
);
}
},
}),
];
}, [user, status, t, permissions.canReadOthersBookings]);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
const flatData = useMemo<RowData[]>(() => {
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
if (!booking.recurringEventId) {
return true;
}
if (
shownBookings[booking.recurringEventId] !== undefined &&
shownBookings[booking.recurringEventId].length > 0
) {
shownBookings[booking.recurringEventId].push(booking);
return false;
}
shownBookings[booking.recurringEventId] = [booking];
} else if (status === "upcoming") {
return (
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") !==
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
);
}
return true;
};
return (
query.data?.bookings.filter(filterBookings).map((booking) => ({
type: "data",
booking,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: false,
})) || []
);
}, [query.data, status, user?.timeZone]);
const bookingsToday = useMemo<RowData[]>(() => {
return (
query.data?.bookings
.filter(
(booking: BookingOutput) =>
dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") ===
dayjs().tz(user?.timeZone).format("YYYY-MM-DD")
)
.map((booking) => ({
type: "data" as const,
booking,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: true,
})) ?? []
);
}, [query.data, user?.timeZone]);
const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") {
return flatData;
}
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
}
return merged;
}, [bookingsToday, flatData, status]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
const dataRow = finalData.find(
(row): row is Extract<RowData, { type: "data" }> =>
row.type === "data" && row.booking.id === selectedBookingId
);
return dataRow?.booking ?? null;
}, [selectedBookingId, finalData]);
const bookingNavigation = useBookingCursor({
bookings: finalData,
selectedBookingId,
setSelectedBookingId,
});
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
attendeeName: false,
attendeeEmail: false,
dateRange: false,
bookingUid: false,
const query = trpc.viewer.bookings.get.useQuery(
{
limit: queryLimit,
offset: queryOffset,
filters: {
status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
afterStartDate: dateRange?.startDate
? dayjs(dateRange?.startDate).startOf("day").toISOString()
: undefined,
beforeEndDate: dateRange?.endDate ? dayjs(dateRange?.endDate).endOf("day").toISOString() : undefined,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
{
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
}
);
const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]);
const isPending = query.isPending;
const totalRowCount = query.data?.totalCount;
const hasError = !!query.error;
const errorView = query.error ? (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
) : undefined;
return (
<div className="flex flex-col">
@@ -430,56 +188,33 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
</div>
<main className="w-full">
<div className="flex w-full flex-col">
{query.status === "error" ? (
<>
<div className="grid w-full items-center gap-2 pb-4">
<div className="flex w-full flex-col gap-2">
<div className="flex w-full flex-wrap justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<DataTableFilters.FilterBar table={table} />
</div>
<div className="flex flex-wrap items-center gap-2">
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
<DataTableSegment.Select />
</div>
</div>
</div>
</div>
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
</>
) : (
<>
{!!bookingsToday.length && status === "upcoming" && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
{view === "list" ? (
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
/>
) : (
<BookingsCalendar status={status} table={table} />
)}
</>
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
{view === "list" && (
<BookingsListContainer
status={status}
permissions={permissions}
data={query.data}
isPending={isPending}
totalRowCount={totalRowCount}
enableDetailsSheet={bookingsV3Enabled}
ErrorView={errorView}
hasError={hasError}
/>
)}
{bookingsV3Enabled && view === "calendar" && (
<BookingsCalendarContainer
status={status}
permissions={permissions}
data={query.data}
isPending={isPending}
ErrorView={errorView}
hasError={hasError}
/>
)}
</div>
</main>
<BookingDetailsSheet
booking={selectedBooking}
isOpen={!!selectedBooking}
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}
hasNext={bookingNavigation.hasNext}
/>
</div>
);
}
+3 -65
View File
@@ -1,8 +1,9 @@
import { expect } from "@playwright/test";
import { MembershipRole } from "@calcom/prisma/enums";
import { applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
import { MembershipRole } from "@calcom/prisma/enums";
test.describe.configure({ mode: "parallel" });
@@ -107,69 +108,6 @@ test.describe("Booking Filters", () => {
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
});
test("Query params should be preserved when switching between bookings tabs in calendar view", async ({
page,
users,
}) => {
const owner = await users.create(
{ name: "Owner User" },
{
hasTeam: true,
isOrg: true,
teammates: [{ name: "Team Member 1" }, { name: "Team Member 2" }],
}
);
await owner.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/upcoming?view=calendar`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
await applySelectFilter(page, "userId", "Owner User");
await expect(page).toHaveURL(/.*userId.*/);
const urlWithFilters = page.url();
expect(urlWithFilters).toContain("userId");
const searchParams = new URL(urlWithFilters).searchParams;
const activeFilters = searchParams.get("activeFilters");
expect(activeFilters).toBeTruthy();
expect(searchParams.get("view")).toBe("calendar");
const pastBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("past-test").click();
await expect(page).toHaveURL(/\/bookings\/past/);
await pastBookingsGetResponse;
const pastUrl = page.url();
expect(pastUrl).toContain("/bookings/past");
expect(pastUrl).toContain("userId");
const pastSearchParams = new URL(pastUrl).searchParams;
expect(pastSearchParams.get("activeFilters")).toBe(activeFilters);
expect(pastSearchParams.get("view")).toBe("calendar");
const cancelledBookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.getByTestId("cancelled-test").click();
await expect(page).toHaveURL(/\/bookings\/cancelled/);
await cancelledBookingsGetResponse;
const cancelledUrl = page.url();
expect(cancelledUrl).toContain("/bookings/cancelled");
expect(cancelledUrl).toContain("userId");
const cancelledSearchParams = new URL(cancelledUrl).searchParams;
expect(cancelledSearchParams.get("activeFilters")).toBe(activeFilters);
expect(cancelledSearchParams.get("view")).toBe("calendar");
});
test("Query params should NOT be preserved when navigating from a non-bookings page", async ({
page,
users,
@@ -229,7 +167,7 @@ test.describe("Booking Filters", () => {
const eventTypeSearchParams = new URL(page.url()).searchParams;
expect(eventTypeSearchParams.get("activeFilters")).toBeFalsy();
});
test("Filter segments with removed team members should not cause stuck UI", async ({
page,
users,
+11 -3
View File
@@ -221,6 +221,7 @@
"manage_this_team": "Manage this team",
"team_info": "Team Info",
"join_meeting": "Join Meeting",
"join_platform": "Join {{platform}}",
"request_another_invitation_email": "If you prefer not to use {{toEmail}} as your {{appName}} email or already have a {{appName}} account, please request another invitation to that email.",
"you_have_been_invited": "You have been invited to join the team {{teamName}}",
"user_invited_you": "{{user}} invited you to join the {{entity}} {{team}} on {{appName}}",
@@ -1714,7 +1715,10 @@
"error_updating_password": "Error updating password",
"two_factor_auth": "Two factor authentication",
"recurring_event_tab_description": "Set up a repeating schedule",
"today": "today",
"today": "Today",
"this_month": "This month",
"time": "Time",
"event": "Event",
"appearance": "Appearance",
"my_account": "My account",
"general": "General",
@@ -3893,7 +3897,7 @@
"history": "History",
"no_audit_history": "No audit history available",
"remove_value_from_blocklist": "Remove {{value}} from blocklist?",
"remove_value_from_blocklist_description":"The action is irreversible",
"remove_value_from_blocklist_description": "The action is irreversible",
"delete_blocklist_entry_confirmation": "Are you sure you want to remove \"{{value}}\" from the blocklist? This action cannot be undone.",
"created_by": "Created By",
"blocked_by": "Blocked By",
@@ -3983,7 +3987,7 @@
"team_onboarding_details_subtitle": "Add your team's name and create a unique URL for your team",
"repeats_num_times": "Repeats {{count}} times",
"view_booking_details": "View Booking Details",
"view": "View",
"recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences",
"team_bio": "Team Bio",
"team_bio_placeholder": "Tell us about your team...",
"onboarding_invite_subtitle": "Connect your Google workspace, invite via email, upload a CSV file or copy the invite link and share it with your teammates to add them to your Team.",
@@ -4012,5 +4016,9 @@
"onboarding_browser_view_ask_question_description": "Ask a question",
"onboarding_browser_view_default_bio": "Add your bio here...",
"book_now": "Book now",
"view": "View",
"booking_response": "Booking Response",
"list_view": "List view",
"calendar_view": "Calendar view",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -13,6 +13,7 @@ export interface CalendarEvent {
allDay?: boolean;
color?: string;
className?: string;
bookingId?: number;
"data-test-id"?: string;
};
}
@@ -38,7 +38,9 @@ export type DataTablePropsFromWrapper<TData> = {
className?: string;
containerClassName?: string;
headerClassName?: string;
rowClassName?: string;
rowClassName?: string | ((row: Row<TData>) => string);
rowTestId?: string | ((row: Row<TData>) => string | undefined);
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
paginationMode?: "infinite" | "standard";
hasWrapperContext?: boolean;
hideSeparatorsOnSort?: boolean;
@@ -68,6 +70,8 @@ export function DataTable<TData>({
containerClassName,
headerClassName,
rowClassName,
rowTestId,
rowDataAttributes,
paginationMode = "infinite",
hasWrapperContext = false,
hideSeparatorsOnSort = true,
@@ -203,6 +207,8 @@ export function DataTable<TData>({
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
rowClassName={rowClassName}
rowTestId={rowTestId}
rowDataAttributes={rowDataAttributes}
hideSeparatorsOnSort={hideSeparatorsOnSort}
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
separatorClassName={separatorClassName}
@@ -219,6 +225,8 @@ export function DataTable<TData>({
onRowMouseclick={onRowMouseclick}
paginationMode={paginationMode}
rowClassName={rowClassName}
rowTestId={rowTestId}
rowDataAttributes={rowDataAttributes}
hideSeparatorsOnSort={hideSeparatorsOnSort}
hideSeparatorsOnFilter={hideSeparatorsOnFilter}
separatorClassName={separatorClassName}
@@ -244,6 +252,8 @@ const MemoizedTableBody = memo(
prev.onRowMouseclick === next.onRowMouseclick &&
prev.paginationMode === next.paginationMode &&
prev.rowClassName === next.rowClassName &&
prev.rowTestId === next.rowTestId &&
prev.rowDataAttributes === next.rowDataAttributes &&
prev.hideSeparatorsOnSort === next.hideSeparatorsOnSort &&
prev.hideSeparatorsOnFilter === next.hideSeparatorsOnFilter &&
prev.separatorClassName === next.separatorClassName &&
@@ -259,7 +269,9 @@ type DataTableBodyProps<TData> = {
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
paginationMode?: "infinite" | "standard";
rowClassName?: string;
rowClassName?: string | ((row: Row<TData>) => string);
rowTestId?: string | ((row: Row<TData>) => string | undefined);
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
hideSeparatorsOnSort?: boolean;
hideSeparatorsOnFilter?: boolean;
separatorClassName?: string;
@@ -294,6 +306,8 @@ function DataTableBody<TData>({
onRowMouseclick,
paginationMode,
rowClassName,
rowTestId,
rowDataAttributes,
hideSeparatorsOnSort = true,
hideSeparatorsOnFilter = false,
separatorClassName,
@@ -374,10 +388,16 @@ function DataTableBody<TData>({
);
}
const computedRowTestId = typeof rowTestId === "function" ? rowTestId(row) : rowTestId;
const computedRowClassName = typeof rowClassName === "function" ? rowClassName(row) : rowClassName;
const computedDataAttributes = rowDataAttributes?.(row);
return (
<TableRow
ref={virtualItem ? (node) => filteredRowVirtualizer.measureElement(node) : undefined}
key={row.id}
data-testid={computedRowTestId}
{...computedDataAttributes}
data-index={virtualItem?.index} // needed for dynamic row height measurement
data-state={row.getIsSelected() && "selected"}
onClick={(e) => {
@@ -396,7 +416,7 @@ function DataTableBody<TData>({
width: "100%",
}),
}}
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}>
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", computedRowClassName)}>
{row.getVisibleCells().map((cell) => {
const column = cell.column;
return (
@@ -435,7 +455,7 @@ const TableHeadLabel = <TData,>({ header }: { header: Header<TData, unknown> })
if (!canSort && !canHide) {
if (typeof header.column.columnDef.header === "string") {
return (
<div className="truncate px-2 py-1" title={header.column.columnDef.header}>
<div className="truncate" title={header.column.columnDef.header}>
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
</div>
);
@@ -20,8 +20,12 @@ type BaseDataTableWrapperProps<TData> = Omit<
ToolbarRight?: React.ReactNode;
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
ErrorView?: React.ReactNode;
hasError?: boolean;
tableContainerRef?: React.RefObject<HTMLDivElement>;
onRowMouseclick?: (row: Row<TData>) => void;
rowTestId?: string | ((row: Row<TData>) => string | undefined);
rowDataAttributes?: (row: Row<TData>) => Record<string, string> | undefined;
};
type InfinitePaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
@@ -54,10 +58,14 @@ export function DataTableWrapper<TData>({
ToolbarRight,
EmptyView,
LoaderView,
ErrorView,
hasError,
className,
containerClassName,
headerClassName,
rowClassName,
rowTestId,
rowDataAttributes,
children,
tableContainerRef: externalRef,
paginationMode,
@@ -97,8 +105,10 @@ export function DataTableWrapper<TData>({
}));
}, [table, sorting, columnFilters, columnVisibility, setSorting, setColumnVisibility]);
let view: "loader" | "empty" | "table" = "table";
if (isPending && LoaderView) {
let view: "loader" | "empty" | "error" | "table" = "table";
if (hasError && ErrorView) {
view = "error";
} else if (isPending && LoaderView) {
view = "loader";
} else if (table.getRowCount() === 0 && EmptyView) {
view = "empty";
@@ -118,6 +128,7 @@ export function DataTableWrapper<TData>({
{children}
</div>
)}
{view === "error" && ErrorView}
{view === "loader" && LoaderView}
{view === "empty" && EmptyView}
{view === "table" && (
@@ -133,6 +144,8 @@ export function DataTableWrapper<TData>({
containerClassName={containerClassName}
headerClassName={headerClassName}
rowClassName={rowClassName}
rowTestId={rowTestId}
rowDataAttributes={rowDataAttributes}
paginationMode={paginationMode}
onRowMouseclick={onRowMouseclick}
hasWrapperContext={true}
+1
View File
@@ -31,6 +31,7 @@ export type AppFlags = {
"onboarding-v3": boolean;
"booker-botid": boolean;
"booking-calendar-view": boolean;
"bookings-v3": boolean;
};
export type TeamFeatures = Record<keyof AppFlags, boolean>;
+1
View File
@@ -30,6 +30,7 @@ const initialData: AppFlags = {
"onboarding-v3": false,
"booker-botid": false,
"booking-calendar-view": false,
"bookings-v3": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
@@ -0,0 +1,9 @@
INSERT INTO
"Feature" (slug, enabled, description, "type")
VALUES
(
'bookings-v3',
false,
'Enable bookings redesign v3 for all users',
'EXPERIMENT'
) ON CONFLICT (slug) DO NOTHING;
+1 -1
View File
@@ -70,7 +70,7 @@ const SheetContent = React.forwardRef<
ref={forwardedRef}
className={classNames(
// base
"fixed inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:right-2 sm:max-w-lg sm:p-6",
"fixed inset-x-0 inset-y-4 mx-auto flex w-[95vw] flex-1 flex-col overflow-y-auto rounded-xl border p-4 shadow-lg focus:outline-none sm:inset-x-auto sm:right-2 sm:max-w-lg sm:p-6",
// "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm sm:p-6 p-4 shadow-lg rounded-xl border ",
// border color
"border-subtle",