feat: bookings page redesign v3 with calendar view (#24664)

* bookings page redesign work in progress

fix: duplicate translation key

chore: use newly supported separator type

remove outdated BookingDetailsSheet

remove dropdown and related code

revert unncessary changes

* fix wrong rebase

* fix type error

* refactor: separate bookings columns into filter and display columns (#24959)

* refactor: separate bookings columns into filter and display columns

- Extract filter-only columns into shared filterColumns.ts
- Extract list display columns into listColumns.tsx
- Create BookingsListContainer for list view with both column sets
- Create BookingsCalendarContainer for calendar view with filter columns only
- Refactor bookings-view.tsx to use dynamic imports for containers
- Remove column/table creation logic from bookings-view.tsx

This ensures calendar view doesn't import list-specific UI components (AvatarGroup, Badge, etc.)

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

* fix: return null instead of false for separator rows in filter accessors

The filter accessor functions were returning false for separator rows instead of null,
which would pollute the multi-select filters with bogus 'false' values.

This fix ensures that separator rows return null so they are properly excluded from filters.

Addresses cubic AI reviewer feedback on PR #24959

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

* clean up filter column visibility

* feat: integrate booking calendar view with re-designed list (#24973)

* add toggle button

* remove the dateRange filter when switching from calendar to list view

* move "view" to the action dropdown

* add close button the details sheet

* move close button

* fix more button behavior

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix type error

* update the test case

* fix e2e util

* fix actions dropdown

* revert e2e tests

* fix type error on BookingActionsDropdown.tsx

* fix: include today's bookings in flatData for past status

Previously, flatData excluded groupedBookings.today, which caused
past bookings that happened today to not show up when status === 'past'.
This fix includes today's bookings in flatData for all statuses.

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

* improve attendee cell

* fix e2e tests

* change max

* fix e2e

* add reschedule requested message

* fix e2e

* update e2e

* remove flaky checks

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Rodrigo Ehlers
2025-11-13 16:20:48 +00:00
committed by GitHub
co-authored by eunjae@cal.com Devin AI
parent d7fc11df5e
commit 89230474a5
34 changed files with 1861 additions and 616 deletions
@@ -0,0 +1,62 @@
"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 !== "list") {
setView("list");
}
}, [isMobile, view, setView]);
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>
);
}
@@ -6,9 +6,7 @@ import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -16,6 +14,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 +54,12 @@ const Page = async ({ params }: PageProps) => {
canReadOthersBookings = teamIdsWithPermission.length > 0;
}
const featuresRepository = new FeaturesRepository(prisma);
const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"booking-calendar-view"
);
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<ShellMainAppDir heading={t("bookings")} CTA={<ViewToggleButton />}>
<BookingsList
status={parsed.data.status}
userId={session?.user?.id}
permissions={{ canReadOthersBookings }}
isCalendarViewEnabled={isCalendarViewEnabled}
/>
</ShellMainAppDir>
);
+68 -21
View File
@@ -1,36 +1,83 @@
import React from "react";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import classNames from "@calcom/ui/classNames";
import { SkeletonAvatar, 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">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
<div className="animate-pulse">
{/* Table rows with separator at the beginning */}
<div className="divide-subtle divide-y">
{/* Month separator skeleton */}
<div className="bg-muted rounded-t py-2">
<SkeletonItem isHeader={true} />
</div>
<div className="bg-muted">
<SkeletonText className="ml-2 mt-3 h-4 w-28 rounded" />
</div>
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</div>
</div>
);
}
export default SkeletonLoader;
function SkeletonItem() {
function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) {
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>
</div>
<div
className={classNames(
"grid grid-cols-[132px_130px_185px_150px_140px_280px] gap-6 px-2",
isHeader ? "py-2" : "py-2.5"
)}>
{/* Date column - 140px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-20")} />
</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>
{/* Time column - 140px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-12" : "w-28")} />
</div>
</li>
{/* Event column - 200px */}
<div className="flex items-center">
<SkeletonText className={classNames("h-4 rounded", isHeader ? "w-16" : "w-full")} />
</div>
{/* Who column - 160px, Avatar group */}
<div className="flex items-center -space-x-1">
{isHeader && <SkeletonText className="h-4 w-20 rounded" />}
{!isHeader && (
<>
<SkeletonAvatar className="h-6 w-6 rounded-full" />
<SkeletonAvatar className="h-6 w-6 rounded-full" />
<SkeletonAvatar className="h-6 w-6 rounded-full" />
</>
)}
</div>
{/* Team column - 140px */}
<div className="flex items-center">
<SkeletonText className="h-4 w-20 rounded-md" />
</div>
{/* Actions column - 280px */}
<div className="mr-2 flex items-center justify-end gap-2">
{isHeader && <SkeletonText className="h-4 w-16 rounded-md" />}
{!isHeader && (
<>
<SkeletonText className="h-4 w-16 rounded-md" />
<SkeletonText className="h-4 w-20 rounded-md" />
<SkeletonText className="h-4 w-8 rounded-md" />
</>
)}
</div>
</div>
);
}
@@ -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} />
{shouldShowRecurringCancelAction(actionContext) && <TableActions actions={[cancelEventAction]} />}
{shouldShowIndividualReportButton(actionContext) && (
<div className="flex items-center space-x-2">
@@ -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";
@@ -31,6 +31,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { buildBookingLink } from "../../../modules/bookings/lib/buildBookingLink";
import type { BookingItemProps } from "../types";
import { useBookingActionsStoreContext } from "./BookingActionsStoreProvider";
import {
@@ -39,16 +40,29 @@ 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;
}
export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) {
export function BookingActionsDropdown({
booking,
size = "base",
className,
usePortal = true,
}: BookingActionsDropdownProps) {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -164,6 +178,13 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
const isBookingFromRoutingForm = !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
// Build booking confirmation link
const bookingLink = buildBookingLink({
bookingUid: booking.uid,
allRemainingBookings: isRecurring,
email: booking.attendees?.[0]?.email,
});
const userEmail = booking.loggedInUser.userEmail;
const userSeat = booking.seatsReferences.find((seat) => !!userEmail && seat.attendee?.email === userEmail);
const isAttendee = !!userSeat;
@@ -211,6 +232,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 +253,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 +263,20 @@ export function BookingActionsDropdown({ booking, context, size = "base" }: Book
const cancelEventAction = getCancelEventAction(actionContext);
// Get pending actions (accept/reject)
const shouldShowPending = shouldShowPendingActions(actionContext);
const basePendingActions = shouldShowPending ? 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,150 @@ 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>
<DropdownMenuItem className="rounded-lg">
<DropdownItem
type="button"
StartIcon="external-link"
href={bookingLink}
target="_blank"
data-testid="view-booking"
onClick={(e) => e.stopPropagation()}>
{t("view")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
{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 space-x-3">
<div className="bg-subtle flex h-10 w-10 flex-shrink-0 justify-center rounded-full ">
<Icon name="clock" className="m-auto h-6 w-6" />
+9 -3
View File
@@ -2,7 +2,13 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
const [matches, setMatches] = useState(() => {
// Initialize with actual media query value on client
if (typeof window !== "undefined") {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const media = window.matchMedia(query);
@@ -10,8 +16,8 @@ const useMediaQuery = (query: string) => {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [matches, query]);
return matches;
@@ -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"],
},
},
},
}),
];
}
@@ -0,0 +1,542 @@
import { createColumnHelper } from "@tanstack/react-table";
import { useState } from "react";
import dayjs from "@calcom/dayjs";
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { trpc } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuPortal,
} from "@calcom/ui/components/dropdown";
import { showToast } from "@calcom/ui/components/toast";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider";
import type { BookingListingStatus } from "../../../components/booking/types";
import { JoinMeetingButton } from "../components/JoinMeetingButton";
import type { RowData } from "../types";
interface PendingActionHandlers {
onAccept: (bookingId: number, recurringEventId?: string | null) => void;
onReject: (bookingId: number, recurringEventId?: string | null) => void;
isLoading?: boolean;
}
interface BuildListDisplayColumnsParams {
t: (key: string) => string;
user?: {
id?: number;
email?: string;
timeZone?: string;
timeFormat?: number | null;
};
pendingActionHandlers: PendingActionHandlers;
}
// Maximum number of attendees to display before showing "..." button
const MAX_DISPLAYED_ATTENDEES = 3;
type AttendeeProps = {
name?: string;
email: string;
phoneNumber: string | null;
id: number;
noShow: boolean;
};
// Component for individual attendee avatar with dropdown
const IndividualAttendee = ({
attendee,
bookingUid,
isBookingInPast,
}: {
attendee: AttendeeProps;
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button className="focus:outline-none" data-testid="guest">
<Avatar
size="sm"
imageSrc={getPlaceholderAvatar(null, attendee.name)}
alt={attendee.name || attendee.email}
title={attendee.name || attendee.email}
className="border-subtle cursor-pointer hover:opacity-80"
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent className="w-48" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-emphasis px-3 py-2 text-sm font-medium">
{attendee.name || attendee.email}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{!isSmsCalEmail(attendee.email) && (
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${attendee.email}`}
onClick={() => {
setOpenDropdown(false);
}}>
<a href={`mailto:${attendee.email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={(e) => {
e.preventDefault();
const smsCalEmail = isSmsCalEmail(attendee.email);
copyToClipboard(smsCalEmail ? attendee.email : attendee.phoneNumber ?? "");
setOpenDropdown(false);
showToast(smsCalEmail ? t("email_copied") : t("phone_number_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</DropdownItem>
</DropdownMenuItem>
{isBookingInPast && (
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
onClick={(e) => {
e.preventDefault();
setOpenDropdown(false);
noShowMutation.mutate({
bookingUid,
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
});
}}
StartIcon={attendee.noShow ? "eye" : "eye-off"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</DropdownItem>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};
// Component for the "..." dropdown that shows all attendees with action buttons
const AllAttendeesDropdown = ({
attendees,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard } = useCopy();
const noShowMutation = trpc.viewer.loggedInViewerRouter.markNoShow.useMutation({
onSuccess: async (data) => {
showToast(data.message, "success");
await utils.viewer.bookings.invalidate();
},
onError: (err) => {
showToast(err.message, "error");
},
});
const handleEmail = (email: string) => {
window.location.href = `mailto:${email}`;
setOpenDropdown(false);
};
const handleCopy = (attendee: AttendeeProps) => {
const isSmsAttendee = isSmsCalEmail(attendee.email);
copyToClipboard(isSmsAttendee ? attendee.email : attendee.phoneNumber ?? "");
showToast(isSmsAttendee ? t("email_copied") : t("phone_number_copied"), "success");
};
const handleToggleNoShow = (attendee: AttendeeProps) => {
noShowMutation.mutate({
bookingUid,
attendees: [{ noShow: !attendee.noShow, email: attendee.email }],
});
};
const handleMarkAllAsNoShow = () => {
const allAttendees = attendees.map((attendee) => ({
noShow: true,
email: attendee.email,
}));
noShowMutation.mutate({
bookingUid,
attendees: allAttendees,
});
setOpenDropdown(false);
};
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<Button
size="sm"
color="minimal"
StartIcon="ellipsis"
className="px-1.5"
data-testid="more-guests"
onClick={(e) => {
e.stopPropagation();
}}></Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent className="w-80" onClick={(e) => e.stopPropagation()}>
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("attendees")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="max-h-96 overflow-y-auto">
{attendees.map((attendee) => (
<div key={attendee.id} className="hover:bg-muted flex items-center justify-between px-2 py-2">
<div className="flex-1 truncate text-sm">
<div className="font-medium">{attendee.name || attendee.email}</div>
{attendee.name && <div className="text-subtle text-xs">{attendee.email}</div>}
</div>
<div className="flex items-center gap-1">
{!isSmsCalEmail(attendee.email) && (
<Button
size="sm"
color="minimal"
StartIcon="mail"
className="px-1.5"
tooltip={t("email")}
onClick={(e) => {
e.stopPropagation();
handleEmail(attendee.email);
}}
/>
)}
<Button
size="sm"
color="minimal"
StartIcon="clipboard"
className="px-1.5"
tooltip={t("copy")}
onClick={(e) => {
e.stopPropagation();
handleCopy(attendee);
}}
/>
{isBookingInPast && (
<Button
size="sm"
color="minimal"
StartIcon={attendee.noShow ? "eye" : "eye-off"}
className="px-1.5"
data-testid={attendee.noShow ? "unmark-no-show" : "mark-no-show"}
tooltip={attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
onClick={(e) => {
e.stopPropagation();
handleToggleNoShow(attendee);
}}
/>
)}
</div>
</div>
))}
</div>
{isBookingInPast && (
<>
<DropdownMenuSeparator />
<div className="flex justify-end p-2">
<Button
size="sm"
color="secondary"
data-testid="update-no-show"
onClick={(e) => {
e.stopPropagation();
handleMarkAllAsNoShow();
}}>
{t("mark_as_no_show_title")}
</Button>
</div>
</>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
);
};
// Component that renders the attendees with appropriate dropdown
const AttendeeCell = ({
attendees,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
bookingUid: string;
isBookingInPast: boolean;
}) => {
// Show "..." button if there are more attendees than the max display count
const hasMoreThanMax = attendees.length > MAX_DISPLAYED_ATTENDEES;
const displayedAttendees = attendees.slice(0, MAX_DISPLAYED_ATTENDEES);
return (
<div className="flex items-center">
{displayedAttendees.map((attendee, idx) => (
<div key={attendee.id} className={idx > 0 ? "-ml-1" : ""}>
<IndividualAttendee attendee={attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
</div>
))}
{hasMoreThanMax && (
<AllAttendeesDropdown
attendees={attendees}
bookingUid={bookingUid}
isBookingInPast={isBookingInPast}
/>
)}
</div>
);
};
export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) {
const columnHelper = createColumnHelper<RowData>();
return [
columnHelper.display({
id: "date",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("date")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
return (
<div className="text-default text-sm font-medium">
{dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")}
</div>
);
},
}),
columnHelper.display({
id: "time",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("time")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const startTime = dayjs(row.booking.startTime).tz(user?.timeZone);
const endTime = dayjs(row.booking.endTime).tz(user?.timeZone);
return (
<div className="text-default text-sm font-medium">
{startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "}
{endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")}
</div>
);
},
}),
columnHelper.display({
id: "event",
minSize: 200,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("event")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
return (
<div className="text-emphasis flex-1 truncate text-sm font-medium" data-testid="title">
{row.booking.title}
</div>
);
},
}),
columnHelper.display({
id: "who",
size: 160,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("who")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const isBookingInPast = new Date(row.booking.endTime) < new Date();
const attendees = row.booking.attendees.map((attendee) => ({
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
phoneNumber: attendee.phoneNumber,
}));
return (
<AttendeeCell
attendees={attendees}
bookingUid={row.booking.uid}
isBookingInPast={isBookingInPast}
/>
);
},
}),
columnHelper.display({
id: "team",
size: 140,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => <span className="text-subtle text-sm font-medium">{t("team")}</span>,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
if (row.booking.eventType.team) {
return (
<Badge variant="gray" size="sm">
{row.booking.eventType.team.name}
</Badge>
);
}
return null;
},
}),
columnHelper.display({
id: "actions",
size: 280,
enableColumnFilter: true,
enableSorting: false,
enableHiding: false,
header: () => null,
cell: (props) => {
const row = props.row.original;
if (isSeparatorRow(row)) return null;
const booking = row.booking;
const isPending = booking.status === "PENDING";
const isUpcoming = new Date(booking.endTime) >= new Date();
const isCancelled = booking.status === "CANCELLED";
const isRejected = booking.status === "REJECTED";
const isAccepted = booking.status === "ACCEPTED";
// Determine if we should show pending actions
const shouldShowPendingActions = isPending && isUpcoming && !isCancelled;
// Determine which buttons to show based on payment status
const hasPayment = Array.isArray(booking.payment) && booking.payment.length > 0;
const isPaid = booking.paid;
const shouldShowAccept = shouldShowPendingActions && (!hasPayment || isPaid);
const shouldShowReject = shouldShowPendingActions;
// Show join meeting button only for upcoming accepted/confirmed bookings
const shouldShowJoinButton = isAccepted && isUpcoming && !isCancelled && !isRejected;
// Determine if this is a recurring booking for the label
const isRecurring = booking.recurringEventId !== null;
const isTabRecurring = row.type === "data" && row.recurringInfo !== undefined;
const isTabUnconfirmed = booking.status === "PENDING";
const showAllLabel = (isTabRecurring || isTabUnconfirmed) && isRecurring;
return (
<div className="flex w-full items-center justify-end gap-2">
{shouldShowReject && (
<Button
color="minimal"
size="sm"
StartIcon="ban"
disabled={pendingActionHandlers.isLoading}
data-testid="reject"
onClick={(e) => {
e.stopPropagation();
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
pendingActionHandlers.onReject(booking.id, recurringEventId);
}}>
{showAllLabel ? t("reject_all") : t("reject")}
</Button>
)}
{shouldShowAccept && (
<Button
color="secondary"
size="sm"
StartIcon="check"
disabled={pendingActionHandlers.isLoading}
data-testid="confirm"
onClick={(e) => {
e.stopPropagation();
const recurringEventId = showAllLabel ? booking.recurringEventId : null;
pendingActionHandlers.onAccept(booking.id, recurringEventId);
}}>
{showAllLabel ? t("confirm_all") : t("confirm")}
</Button>
)}
{shouldShowJoinButton && (
<JoinMeetingButton
size="sm"
location={booking.location}
metadata={booking.metadata}
bookingStatus={booking.status}
/>
)}
<BookingActionsStoreProvider>
<BookingActionsDropdown
booking={{
...booking,
listingStatus: booking.status.toLowerCase() as BookingListingStatus,
recurringInfo: row.type === "data" ? row.recurringInfo : undefined,
loggedInUser: {
userId: user?.id,
userTimeZone: user?.timeZone,
userTimeFormat: user?.timeFormat,
userEmail: user?.email,
},
isToday: row.type === "data" ? row.isToday : false,
}}
className="px-1.5"
size="sm"
/>
</BookingActionsStoreProvider>
</div>
);
},
}),
];
}
@@ -29,8 +29,8 @@ 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 type { BookingOutput } from "../types";
import { JoinMeetingButton } from "./JoinMeetingButton";
type BookingMetaData = z.infer<typeof bookingMetadataSchema>;
@@ -80,14 +80,6 @@ function BookingDetailsSheetInner({
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 +100,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
? {
@@ -183,6 +167,8 @@ function BookingDetailsSheetInner({
</p>
</div>
<RescheduleRequestMessage booking={booking} />
<WhoSection booking={booking} />
<WhereSection booking={booking} meta={bookingMetadata} />
@@ -203,49 +189,34 @@ 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={onClose}>
{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}
/>
</div>
</div>
</SheetFooter>
</SheetContent>
@@ -253,6 +224,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,62 @@
"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;
onOpenDetails: (bookingId: number) => void;
currentWeekStart: dayjs.Dayjs;
setCurrentWeekStart: (
value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null
) => Promise<URLSearchParams>;
bookings: BookingOutput[];
};
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,
onOpenDetails,
currentWeekStart,
setCurrentWeekStart,
bookings,
}: 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 (
@@ -103,6 +76,8 @@ export function BookingsCalendar({ table }: BookingsCalendarViewProps) {
bookings={bookings}
currentWeekStart={currentWeekStart}
onWeekStartChange={handleWeekStartChange}
isPending={isPending}
onOpenDetails={onOpenDetails}
/>
</>
);
@@ -0,0 +1,131 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { createParser, useQueryState } from "nuqs";
import { useCallback, 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 { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { RowData, BookingListingStatus } 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: RowData[];
isPending?: boolean;
}
export function BookingsCalendarContainer({
status,
permissions,
data,
isPending = false,
}: BookingsCalendarContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
const [currentWeekStart, setCurrentWeekStart] = useQueryState(
"weekStart",
weekStartParser.withDefault(dayjs().startOf("week"))
);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
return buildFilterColumns({ t, permissions, status });
}, [t, permissions, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data,
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 data
.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"))
);
});
}, [data, currentWeekStart]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
return (
<>
<BookingsCalendar
status={status}
table={table}
isPending={isPending}
onOpenDetails={onOpenDetails}
currentWeekStart={currentWeekStart}
setCurrentWeekStart={setCurrentWeekStart}
bookings={bookings}
/>
<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}
/>
</>
);
}
@@ -18,12 +18,16 @@ type BookingsCalendarViewProps = {
bookings: BookingOutput[];
currentWeekStart: dayjs.Dayjs;
onWeekStartChange: (weekStart: dayjs.Dayjs) => void;
isPending?: boolean;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsCalendarView({
bookings,
currentWeekStart,
onWeekStartChange,
isPending = false,
onOpenDetails,
}: BookingsCalendarViewProps) {
const { t } = useLocale();
const { timezone } = useTimePreferences();
@@ -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) {
onOpenDetails(bookingId);
}
}}
hideHeader
/>
</div>
@@ -1,8 +1,10 @@
"use client";
import type { Table as ReactTable } from "@tanstack/react-table";
import type { Row, Table as ReactTable } from "@tanstack/react-table";
import { useCallback } from "react";
import { DataTableWrapper, DataTableFilters, DataTableSegment } from "@calcom/features/data-table";
import { isSeparatorRow } from "@calcom/features/data-table/lib/separator";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
@@ -23,22 +25,49 @@ type BookingsListViewProps = {
table: ReactTable<RowData>;
isPending: boolean;
totalRowCount?: number;
onOpenDetails: (bookingId: number) => void;
};
export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) {
export function BookingsList({
status,
table,
isPending,
totalRowCount,
onOpenDetails,
}: BookingsListViewProps) {
const { t } = useLocale();
const handleRowClick = useCallback(
(row: Row<RowData>) => {
if (!isSeparatorRow(row.original)) {
onOpenDetails(row.original.booking.id);
}
},
[onOpenDetails]
);
return (
<DataTableWrapper
className="mb-6"
table={table}
testId={`${status}-bookings`}
bodyTestId="bookings"
headerClassName="hidden"
rowTestId={(row) => {
if (isSeparatorRow(row.original)) return undefined;
return "booking-item";
}}
rowDataAttributes={(row) => {
if (isSeparatorRow(row.original)) return undefined;
return {
"data-today": String(row.original.isToday),
};
}}
isPending={isPending}
totalRowCount={totalRowCount}
variant="compact"
variant="default"
paginationMode="standard"
onRowMouseclick={handleRowClick}
hideSeparatorsOnSort={true}
ToolbarLeft={
<>
<DataTableFilters.FilterBar table={table} />
@@ -0,0 +1,218 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table";
import { useCallback, useMemo, useState } from "react";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import { Button } from "@calcom/ui/components/button";
import { DialogContent, DialogFooter, DialogClose } from "@calcom/ui/components/dialog";
import { TextAreaField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns";
import { buildListDisplayColumns } from "../columns/listColumns";
import { useBookingCursor } from "../hooks/useBookingCursor";
import { useSelectedBookingId } from "../hooks/useSelectedBookingId";
import type { RowData, BookingListingStatus } from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingsList } from "./BookingsList";
interface BookingsListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
data: RowData[];
isPending: boolean;
totalRowCount?: number;
}
export function BookingsListContainer({
status,
permissions,
data,
isPending,
totalRowCount,
}: BookingsListContainerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const utils = trpc.useUtils();
const [selectedBookingId, setSelectedBookingId] = useSelectedBookingId();
// Filter out separator rows and extract bookings
const bookings = useMemo(() => {
return data
.filter((row): row is Extract<RowData, { type: "data" }> => row.type === "data")
.map((row) => row.booking);
}, [data]);
const selectedBooking = useMemo(() => {
if (!selectedBookingId) return null;
return bookings.find((booking) => booking.id === selectedBookingId) ?? null;
}, [selectedBookingId, bookings]);
const bookingNavigation = useBookingCursor({
bookings,
selectedBookingId,
setSelectedBookingId,
});
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [rejectionReason, setRejectionReason] = useState<string>("");
const [pendingRejection, setPendingRejection] = useState<{
bookingId: number;
recurringEventId?: string | null;
} | null>(null);
const confirmMutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
if (data?.status === "REJECTED") {
setRejectionDialogIsOpen(false);
setRejectionReason("");
setPendingRejection(null);
showToast(t("booking_rejection_success"), "success");
} else {
showToast(t("booking_confirmation_success"), "success");
}
utils.viewer.bookings.invalidate();
utils.viewer.me.bookingUnconfirmedCount.invalidate();
},
onError: () => {
showToast(t("booking_confirmation_failed"), "error");
utils.viewer.bookings.invalidate();
},
});
const handleAccept = useCallback(
(bookingId: number, recurringEventId?: string | null) => {
confirmMutation.mutate({
bookingId,
confirmed: true,
reason: "",
...(recurringEventId && { recurringEventId }),
});
},
[confirmMutation]
);
const handleReject = useCallback((bookingId: number, recurringEventId?: string | null) => {
setPendingRejection({ bookingId, recurringEventId });
setRejectionDialogIsOpen(true);
}, []);
const handleConfirmRejection = useCallback(() => {
if (!pendingRejection) return;
confirmMutation.mutate({
bookingId: pendingRejection.bookingId,
confirmed: false,
reason: rejectionReason,
...(pendingRejection.recurringEventId && { recurringEventId: pendingRejection.recurringEventId }),
});
}, [pendingRejection, rejectionReason, confirmMutation]);
const onOpenDetails = useCallback(
(bookingId: number) => {
setSelectedBookingId(bookingId);
},
[setSelectedBookingId]
);
const columns = useMemo(() => {
const filterCols = buildFilterColumns({ t, permissions, status });
const listCols = buildListDisplayColumns({
t,
user,
pendingActionHandlers: {
onAccept: handleAccept,
onReject: handleReject,
isLoading: confirmMutation.isPending,
},
});
return [...filterCols, ...listCols];
}, [t, permissions, status, user, handleAccept, handleReject, confirmMutation.isPending]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data,
columns,
initialState: {
columnVisibility: getFilterColumnVisibility(),
columnPinning: {
right: ["actions"],
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
const handleRejectionDialogChange = useCallback((open: boolean) => {
setRejectionDialogIsOpen(open);
if (!open) {
setRejectionReason("");
setPendingRejection(null);
}
}, []);
return (
<>
<Dialog open={rejectionDialogIsOpen} onOpenChange={handleRejectionDialogChange}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
<TextAreaField
name="rejectionReason"
label={
<>
{t("rejection_reason")}
<span className="text-subtle font-normal"> (Optional)</span>
</>
}
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
/>
</div>
<DialogFooter>
<DialogClose />
<Button
disabled={confirmMutation.isPending}
data-testid="rejection-confirm"
onClick={handleConfirmRejection}>
{t("rejection_confirmation")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<BookingsList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
onOpenDetails={onOpenDetails}
/>
<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}
/>
</>
);
}
@@ -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,10 @@
import { useQueryState } from "nuqs";
export function useSelectedBookingId() {
return useQueryState("selectedId", {
defaultValue: null,
parse: (value) => (value ? parseInt(value, 10) : null),
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";
+2 -3
View File
@@ -1,3 +1,4 @@
import type { SeparatorRow } from "@calcom/features/data-table/lib/separator";
import type { RouterOutputs } from "@calcom/trpc/react";
import type { validStatuses } from "~/bookings/lib/validStatuses";
@@ -18,8 +19,6 @@ export type RowData =
isToday: boolean;
recurringInfo?: RecurringInfo;
}
| {
type: "today" | "next";
};
| SeparatorRow;
export type BookingListingStatus = (typeof validStatuses)[number];
+120 -281
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,
@@ -27,24 +25,26 @@ 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";
const BookingsListContainer = dynamic(() =>
import("../components/BookingsListContainer").then((mod) => ({ default: mod.BookingsListContainer }))
);
const BookingsCalendarContainer = dynamic(() =>
import("../components/BookingsCalendarContainer").then((mod) => ({
default: mod.BookingsCalendarContainer,
}))
);
type BookingsProps = {
status: (typeof validStatuses)[number];
userId?: number;
permissions: {
canReadOthersBookings: boolean;
};
isCalendarViewEnabled: boolean;
};
function useSystemSegments(userId?: number) {
@@ -86,27 +86,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) {
const [_view] = useQueryState("view", viewParser.withDefault("list"));
function BookingsContent({ status, permissions }: BookingsProps) {
const [view] = useQueryState("view", viewParser.withDefault("list"));
// Force view to be "list" if calendar view is disabled
const view = isCalendarViewEnabled ? _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,161 +141,47 @@ 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 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,
},
},
});
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]);
{
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 flatData = useMemo<RowData[]>(() => {
const groupedBookings = useMemo(() => {
if (!query.data?.bookings) {
return { today: [], currentMonth: [], monthBuckets: {} };
}
const now = dayjs().tz(user?.timeZone);
const today = now.format("YYYY-MM-DD");
const currentMonthStart = now.startOf("month");
const currentMonthEnd = now.endOf("month");
const shownBookings: Record<string, BookingOutput[]> = {};
const filterBookings = (booking: BookingOutput) => {
if (status === "recurring" || status == "unconfirmed" || status === "cancelled") {
@@ -325,95 +196,86 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: 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) => ({
const todayBookings: RowData[] = [];
const currentMonthBookings: RowData[] = [];
const monthBuckets: Record<string, RowData[]> = {}; // Key format: "YYYY-MM"
query.data.bookings.filter(filterBookings).forEach((booking) => {
const bookingDate = dayjs(booking.startTime).tz(user?.timeZone);
const bookingDateStr = bookingDate.format("YYYY-MM-DD");
const monthKey = bookingDate.format("YYYY-MM");
const rowData: RowData = {
type: "data",
booking,
isToday: bookingDateStr === today,
recurringInfo: query.data?.recurringInfo.find(
(info) => info.recurringEventId === booking.recurringEventId
),
isToday: false,
})) || []
);
};
if (bookingDateStr === today) {
todayBookings.push(rowData);
} else if (bookingDate.isAfter(currentMonthStart) && bookingDate.isBefore(currentMonthEnd)) {
currentMonthBookings.push(rowData);
} else if (bookingDate.isAfter(currentMonthEnd)) {
if (!monthBuckets[monthKey]) {
monthBuckets[monthKey] = [];
}
monthBuckets[monthKey].push(rowData);
} else if (bookingDate.isBefore(currentMonthStart)) {
// Handle bookings from months before the current month
if (!monthBuckets[monthKey]) {
monthBuckets[monthKey] = [];
}
monthBuckets[monthKey].push(rowData);
}
});
return { today: todayBookings, currentMonth: currentMonthBookings, monthBuckets };
}, [query.data, status, user?.timeZone]);
const flatData = useMemo<RowData[]>(() => {
return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()];
}, [groupedBookings]);
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]);
return groupedBookings.today;
}, [groupedBookings]);
const finalData = useMemo<RowData[]>(() => {
if (status !== "upcoming") {
return flatData;
}
const merged: RowData[] = [];
if (bookingsToday.length > 0) {
merged.push({ type: "today" as const }, ...bookingsToday);
// Add Today section
if (groupedBookings.today.length > 0) {
merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today);
}
if (flatData.length > 0) {
merged.push({ type: "next" as const }, ...flatData);
// Add Current Month section (rest of this month, excluding today)
if (groupedBookings.currentMonth.length > 0) {
merged.push({ type: "separator", label: t("this_month") }, ...groupedBookings.currentMonth);
}
// Add individual month sections
const sortedMonthKeys = Object.keys(groupedBookings.monthBuckets).sort();
sortedMonthKeys.forEach((monthKey) => {
const bookings = groupedBookings.monthBuckets[monthKey];
if (bookings.length > 0) {
const monthLabel = dayjs(monthKey, "YYYY-MM").format("MMMM YYYY");
merged.push({ type: "separator", label: monthLabel }, ...bookings);
}
});
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,
},
},
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
}, [groupedBookings, status, t, flatData]);
const isPending = query.isPending;
const totalRowCount = query.data?.totalCount;
@@ -431,55 +293,32 @@ function BookingsContent({ status, permissions, isCalendarViewEnabled }: Booking
<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} />
</>
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
) : (
<>
{!!bookingsToday.length && status === "upcoming" && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
)}
{view === "list" ? (
<BookingsList
<BookingsListContainer
status={status}
table={table}
permissions={permissions}
data={finalData}
isPending={isPending}
totalRowCount={totalRowCount}
/>
) : (
<BookingsCalendar status={status} table={table} />
<BookingsCalendarContainer
status={status}
permissions={permissions}
data={finalData}
isPending={isPending}
/>
)}
</>
)}
</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>
);
}
+34 -34
View File
@@ -10,6 +10,8 @@ import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
import { setupManagedEvent } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Bookings", () => {
@@ -58,16 +60,14 @@ test.describe("Bookings", () => {
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
).toBeVisible();
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
).toBeVisible();
});
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
test("Cannot choose date range presets", async ({ page, users }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
@@ -91,7 +91,7 @@ test.describe("Bookings", () => {
test.describe("Past bookings", () => {
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
const secondUser = await users.create();
const _secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
@@ -112,15 +112,14 @@ test.describe("Bookings", () => {
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeHidden();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
await page.locator('[data-testid="mark-no-show"]').click();
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
// Wait for the dropdown to close before clicking again
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
@@ -142,7 +141,7 @@ test.describe("Bookings", () => {
});
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
const firstUser = await users.create();
const secondUser = await users.create();
const _secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
@@ -158,23 +157,33 @@ test.describe("Bookings", () => {
{ name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
await bookingWhereFirstUserIsOrganizerFixture.self();
await firstUser.apiLogin();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
// Click the ellipsis button to open the all attendees dropdown
const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]');
await moreGuests.click();
const firstGuestInMore = page.getByRole("menuitemcheckbox").nth(0);
await expect(firstGuestInMore).toBeChecked({ checked: false });
await firstGuestInMore.click();
await expect(firstGuestInMore).toBeChecked({ checked: true });
const updateNoShow = firstPastBooking.locator('[data-testid="update-no-show"]');
await updateNoShow.click();
// Find all the mark-no-show buttons in the dropdown (one for each attendee)
const noShowButtons = page.locator('[data-testid="mark-no-show"]');
// Verify the 3rd attendee (index 2) has mark-no-show button (not already marked)
const thirdAttendeeNoShowButton = noShowButtons.nth(2);
await expect(thirdAttendeeNoShowButton).toBeVisible();
// Click the 3rd attendee's mark-no-show button
await thirdAttendeeNoShowButton.click();
// Open dropdown again to verify the 3rd attendee now shows unmark-no-show
await moreGuests.click();
await expect(firstGuestInMore).toBeChecked({ checked: true });
const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]');
// The 3rd attendee should now have the unmark button visible
await expect(unmarkNoShowButtons.first()).toBeVisible();
});
test("Team admin/owner can mark first attendee as no-show", async ({
page,
@@ -199,18 +208,16 @@ test.describe("Bookings", () => {
});
const booking = await bookingFixture.self();
await adminUser.apiLogin();
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
const { webhookReceiver } = await webhooks.createTeamReceiver();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]');
const firstGuest = firstPastBooking.locator('[data-testid="guest"]').nth(0);
await firstGuest.click();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeVisible();
await page.locator('[data-testid="mark-no-show"]').click();
await firstGuest.click();
await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden();
// Wait for the dropdown to close before clicking again
await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
@@ -232,7 +239,7 @@ test.describe("Bookings", () => {
webhookReceiver.close();
});
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
test("Can choose date range presets", async ({ page, users }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
const bookingsGetResponse = page.waitForResponse((response) =>
@@ -374,22 +381,15 @@ test.describe("Bookings", () => {
//verify with the booking titles
const firstUpcomingBooking = bookingListItems.nth(0);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)
).toBeVisible();
await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible();
const secondUpcomingBooking = bookingListItems.nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${thirdUserAttendeeIndividualBooking!.title}`)
).toBeVisible();
const thirdUpcomingBooking = bookingListItems.nth(2);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)
).toBeVisible();
await expect(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible();
});
test("Does not show booking from another user from collective event type when a member is filtered", async ({
@@ -414,7 +414,7 @@ test.describe("Bookings", () => {
const { team } = await owner.getFirstTeamMembership();
const eventType = await owner.getFirstTeamEvent(team.id);
const { id: eventTypeId, title: teamEventTitle, slug: teamEventSlug } = eventType;
const { id: eventTypeId, title: _teamEventTitle, slug: _teamEventSlug } = eventType;
// remove myself from host of this event type
await prisma.host.delete({
+1 -3
View File
@@ -57,9 +57,7 @@ test.describe("Locale-specific pages must not 404", () => {
await user.apiLogin();
const response = await page.goto(`/${locale}/bookings/upcoming`);
expect(response?.status()).not.toBe(404);
await expect(
page.locator("text=See upcoming and past events booked through your event type links.")
).toBeVisible();
await expect(page.getByRole("heading", { name: "Bookings" })).toBeVisible();
});
test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => {
+13 -9
View File
@@ -41,7 +41,9 @@ test.describe("Reschedule Tests", async () => {
await page.fill('[data-testid="reschedule_reason"]', "I can't longer have it");
await page.locator('button[data-testid="send_request"]').click();
await expect(page.locator('[id="modal-title"]')).toBeHidden();
await expect(page.locator('[data-testid="reschedule-dialog"]')).toBeHidden();
await page.waitForTimeout(2000);
const updatedBooking = await booking.self();
@@ -58,7 +60,7 @@ test.describe("Reschedule Tests", async () => {
}) => {
const user = await users.create();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
const _booking = await bookings.create(user.id, user.username, user.eventTypes[0].id!, {
status: BookingStatus.ACCEPTED,
startTime: dayjs().subtract(2, "day").toDate(),
endTime: dayjs().subtract(2, "day").add(30, "minutes").toDate(),
@@ -130,8 +132,9 @@ test.describe("Reschedule Tests", async () => {
await user.apiLogin();
await page.goto("/bookings/cancelled");
const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1);
await expect(requestRescheduleSentElement).toBeVisible();
await page.locator('[data-testid="booking-item"]').nth(0).click();
await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible();
await booking.delete();
});
@@ -196,7 +199,7 @@ test.describe("Reschedule Tests", async () => {
},
},
});
const payment = await payments.create(booking.id);
const _payment = await payments.create(booking.id);
await page.goto(`/reschedule/${booking.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -226,7 +229,7 @@ test.describe("Reschedule Tests", async () => {
paid: true,
});
const payment = await payments.create(booking.id);
const _payment = await payments.create(booking.id);
await page.goto(`/reschedule/${booking?.uid}`);
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -330,7 +333,6 @@ test.describe("Reschedule Tests", async () => {
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
page,
users,
bookings,
browser,
}) => {
// eslint-disable-next-line playwright/no-skipped-test
@@ -339,11 +341,13 @@ test.describe("Reschedule Tests", async () => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
const confirmBooking = async (bookingId: number) => {
const confirmBooking = async (_bookingId: number) => {
const [authedContext, authedPage] = await user.apiLoginOnNewBrowser(browser);
await authedPage.goto("/bookings/upcoming");
await submitAndWaitForResponse(authedPage, "/api/trpc/bookings/confirm?batch=1", {
action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(),
action: async () => {
await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click();
},
});
await authedContext.close();
};
+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}}",
@@ -1711,7 +1712,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",
@@ -3885,7 +3889,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",
@@ -3975,7 +3979,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.",
@@ -4004,5 +4008,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={() => onRowMouseclick && onRowMouseclick(row)}
@@ -389,7 +409,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 (
@@ -428,7 +448,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>
);
@@ -22,6 +22,8 @@ type BaseDataTableWrapperProps<TData> = Omit<
LoaderView?: React.ReactNode;
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> & {
@@ -58,6 +60,8 @@ export function DataTableWrapper<TData>({
containerClassName,
headerClassName,
rowClassName,
rowTestId,
rowDataAttributes,
children,
tableContainerRef: externalRef,
paginationMode,
@@ -133,6 +137,8 @@ export function DataTableWrapper<TData>({
containerClassName={containerClassName}
headerClassName={headerClassName}
rowClassName={rowClassName}
rowTestId={rowTestId}
rowDataAttributes={rowDataAttributes}
paginationMode={paginationMode}
onRowMouseclick={onRowMouseclick}
hasWrapperContext={true}
+9 -3
View File
@@ -1,7 +1,13 @@
import { useState, useEffect } from "react";
const useMediaQuery = (query: string) => {
const [matches, setMatches] = useState(false);
const [matches, setMatches] = useState(() => {
// Initialize with actual media query value on client
if (typeof window !== "undefined") {
return window.matchMedia(query).matches;
}
return false;
});
useEffect(() => {
const media = window.matchMedia(query);
@@ -9,8 +15,8 @@ const useMediaQuery = (query: string) => {
setMatches(media.matches);
}
const listener = () => setMatches(media.matches);
window.addEventListener("resize", listener);
return () => window.removeEventListener("resize", listener);
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [matches, query]);
return matches;
@@ -13,6 +13,7 @@ export type AvatarGroupProps = {
className?: string;
truncateAfter?: number;
hideTruncatedAvatarsCount?: boolean;
onClick?: (e: React.MouseEvent) => void;
};
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
@@ -46,8 +47,10 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
<li
className={classNames(
"bg-inverted relative -mr-1 inline-flex justify-center overflow-hidden rounded-full",
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16"
)}>
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16",
props.onClick && "cursor-pointer hover:opacity-80"
)}
onClick={props.onClick}>
<span
className={classNames(
" text-inverted m-auto flex h-full w-full items-center justify-center text-center",
+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",