From 1f102bf3b49b82cfc3b6c3dfd5a39775658539fd Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 14 Nov 2025 18:24:31 +0100 Subject: [PATCH] fix: revert bookings redesign (#25172) * Revert "refactor: extract logic as bookingDetailsSheetStore (#25129)" This reverts commit 7a0b8a9eba4fac69fd361e34b3ab08c4c5b9c793. * Revert "feat: bookings page redesign v3 with calendar view (#24664)" This reverts commit 89230474a5a4e5aecaf212450c233c143643d3a4. --- .../bookings/[status]/ViewToggleButton.tsx | 62 -- .../(main-nav)/bookings/[status]/page.tsx | 12 +- ...ookingListItem.tsx => BookingListItem.tsx} | 2 +- .../web/components/booking/SkeletonLoader.tsx | 89 +-- .../actions/BookingActionsDropdown.tsx | 285 +++------ .../booking/actions/bookingActions.test.ts | 16 +- .../booking/actions/bookingActions.ts | 16 +- .../components/dialog/RescheduleDialog.tsx | 2 +- apps/web/lib/hooks/useMediaQuery.ts | 12 +- .../modules/bookings/columns/filterColumns.ts | 133 ----- .../modules/bookings/columns/listColumns.tsx | 542 ------------------ .../components/BookingDetailsSheet.tsx | 181 +++--- .../bookings/components/BookingsCalendar.tsx | 70 ++- .../components/BookingsCalendarContainer.tsx | 103 ---- .../components/BookingsCalendarView.tsx | 15 +- .../bookings/components/BookingsList.tsx | 30 +- .../components/BookingsListContainer.tsx | 184 ------ .../bookings/components/JoinMeetingButton.tsx | 66 --- .../components/useJoinableLocation.tsx | 46 -- .../bookings/hooks/useBookingCursor.ts | 26 +- .../bookings/hooks/useSelectedBookingId.ts | 10 - apps/web/modules/bookings/lib/viewParser.ts | 11 - .../store/bookingDetailsSheetStore.tsx | 144 ----- apps/web/modules/bookings/types.ts | 5 +- .../modules/bookings/views/bookings-view.tsx | 401 +++++++++---- apps/web/playwright/bookings-list.e2e.ts | 68 +-- apps/web/playwright/i18n-routing.e2e.ts | 4 +- apps/web/playwright/reschedule.e2e.ts | 22 +- apps/web/public/static/locales/en/common.json | 14 +- .../calendars/weeklyview/types/events.ts | 1 - .../data-table/components/DataTable.tsx | 28 +- .../components/DataTableWrapper.tsx | 6 - packages/lib/hooks/useMediaQuery.ts | 12 +- packages/ui/components/avatar/AvatarGroup.tsx | 7 +- packages/ui/components/sheet/Sheet.tsx | 2 +- 35 files changed, 637 insertions(+), 1990 deletions(-) delete mode 100644 apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx rename apps/web/components/booking/{_BookingListItem.tsx => BookingListItem.tsx} (99%) delete mode 100644 apps/web/modules/bookings/columns/filterColumns.ts delete mode 100644 apps/web/modules/bookings/columns/listColumns.tsx delete mode 100644 apps/web/modules/bookings/components/BookingsCalendarContainer.tsx delete mode 100644 apps/web/modules/bookings/components/BookingsListContainer.tsx delete mode 100644 apps/web/modules/bookings/components/JoinMeetingButton.tsx delete mode 100644 apps/web/modules/bookings/components/useJoinableLocation.tsx delete mode 100644 apps/web/modules/bookings/hooks/useSelectedBookingId.ts delete mode 100644 apps/web/modules/bookings/lib/viewParser.ts delete mode 100644 apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx deleted file mode 100644 index 4f6874fe9c..0000000000 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/ViewToggleButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"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 ( -
- { - 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: , - }, - { - value: "calendar", - label: "", - tooltip: t("calendar_view"), - iconLeft: , - }, - ]} - /> -
- ); -} diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 9e27631fbf..f749bfe150 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -6,7 +6,9 @@ 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"; @@ -14,8 +16,6 @@ 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,12 +54,18 @@ const Page = async ({ params }: PageProps) => { canReadOthersBookings = teamIdsWithPermission.length > 0; } + const featuresRepository = new FeaturesRepository(prisma); + const isCalendarViewEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( + "booking-calendar-view" + ); + return ( - }> + ); diff --git a/apps/web/components/booking/_BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx similarity index 99% rename from apps/web/components/booking/_BookingListItem.tsx rename to apps/web/components/booking/BookingListItem.tsx index 3feb3a4b29..c869c1d3ba 100644 --- a/apps/web/components/booking/_BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -499,7 +499,7 @@ function BookingListItem(booking: BookingItemProps) {
{shouldShowPendingActions(actionContext) && } - + {shouldShowRecurringCancelAction(actionContext) && } {shouldShowIndividualReportButton(actionContext) && (
diff --git a/apps/web/components/booking/SkeletonLoader.tsx b/apps/web/components/booking/SkeletonLoader.tsx index fd60bafaef..419a0ecf03 100644 --- a/apps/web/components/booking/SkeletonLoader.tsx +++ b/apps/web/components/booking/SkeletonLoader.tsx @@ -1,83 +1,36 @@ import React from "react"; -import classNames from "@calcom/ui/classNames"; -import { SkeletonAvatar, SkeletonText } from "@calcom/ui/components/skeleton"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; function SkeletonLoader() { return ( -
- {/* Table rows with separator at the beginning */} -
- {/* Month separator skeleton */} -
- -
-
- -
- - - - - - -
-
+
    + + + +
); } export default SkeletonLoader; -function SkeletonItem({ isHeader = false }: { isHeader?: boolean }) { +function SkeletonItem() { return ( -
- {/* Date column - 140px */} -
- +
  • +
    +
    +
    + + +
    +
    - - {/* Time column - 140px */} -
    - +
    +
    + + +
    - - {/* Event column - 200px */} -
    - -
    - - {/* Who column - 160px, Avatar group */} -
    - {isHeader && } - {!isHeader && ( - <> - - - - - )} -
    - - {/* Team column - 140px */} -
    - -
    - - {/* Actions column - 280px */} -
    - {isHeader && } - {!isHeader && ( - <> - - - - - )} -
    -
    +
  • ); } diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 66cc5b5ea7..13c04b8915 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -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,7 +31,6 @@ 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 { @@ -40,29 +39,16 @@ 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, - size = "base", - className, - usePortal = true, -}: BookingActionsDropdownProps) { +export function BookingActionsDropdown({ booking, context, size = "base" }: BookingActionsDropdownProps) { const { t } = useLocale(); const utils = trpc.useUtils(); @@ -178,13 +164,6 @@ export function BookingActionsDropdown({ 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; @@ -232,10 +211,6 @@ export function BookingActionsDropdown({ } }; - // Calculate showPendingPayment based on payment logic - const hasPayment = booking.payment.length > 0; - const showPendingPayment = hasPayment; - const actionContext: BookingActionContext = { booking, isUpcoming, @@ -253,7 +228,7 @@ export function BookingActionsDropdown({ isDisabledCancelling, isDisabledRescheduling, isCalVideoLocation, - showPendingPayment, + showPendingPayment: false, // This will be calculated below isAttendee, cardCharged, attendeeList, @@ -263,20 +238,6 @@ export function BookingActionsDropdown({ 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) => ({ @@ -539,26 +500,22 @@ export function BookingActionsDropdown({ // 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); - // Check report and cancel actions + // For booking-list-item context, only check edit and after event actions + if (context === "booking-list-item") { + return hasAvailableEditAction || hasAvailableAfterAction; + } + + // For booking-details-sheet context, also check report and cancel actions const isReportAvailable = !reportActionWithHandler.disabled; const isCancelAvailable = !cancelEventAction.disabled; - return ( - hasAvailablePendingAction || - hasAvailableEditAction || - hasAvailableAfterAction || - isReportAvailable || - isCancelAvailable - ); + return hasAvailableEditAction || hasAvailableAfterAction || isReportAvailable || isCancelAvailable; }; // Don't render dropdown if no actions are available @@ -566,150 +523,94 @@ export function BookingActionsDropdown({ return dialogs; } - // Conditional portal wrapper to avoid portal when inside Sheet/Dialog - const ConditionalPortal = usePortal - ? DropdownMenuPortal - : ({ children }: { children: React.ReactNode }) => <>{children}; + const menuContent = ( + + {t("edit_event")} + {editEventActions.map((action) => ( + + + {action.label} + + + ))} + + {t("after_event")} + {afterEventActions.map((action) => ( + + + {action.label} + + + ))} + <> + + + + {reportActionWithHandler.label} + + + + + + + {cancelEventAction.label} + + + + ); return ( <> {dialogs} - + - - - - e.stopPropagation()}> - - {attendee.name || attendee.email} - - - {!isSmsCalEmail(attendee.email) && ( - - { - setOpenDropdown(false); - }}> - {t("email")} - - - )} - - - { - 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")} - - - - {isBookingInPast && ( - - { - 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")} - - - )} - - - - ); -}; - -// 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 ( - - - - - - e.stopPropagation()}> - {t("attendees")} - -
    - {attendees.map((attendee) => ( -
    -
    -
    {attendee.name || attendee.email}
    - {attendee.name &&
    {attendee.email}
    } -
    -
    - {!isSmsCalEmail(attendee.email) && ( -
    -
    - ))} -
    - {isBookingInPast && ( - <> - -
    - -
    - - )} -
    -
    -
    - ); -}; - -// 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 ( -
    - {displayedAttendees.map((attendee, idx) => ( -
    0 ? "-ml-1" : ""}> - -
    - ))} - {hasMoreThanMax && ( - - )} -
    - ); -}; - -export function buildListDisplayColumns({ t, user, pendingActionHandlers }: BuildListDisplayColumnsParams) { - const columnHelper = createColumnHelper(); - - return [ - columnHelper.display({ - id: "date", - size: 140, - enableColumnFilter: true, - enableSorting: false, - enableHiding: false, - header: () => {t("date")}, - cell: (props) => { - const row = props.row.original; - if (isSeparatorRow(row)) return null; - - return ( -
    - {dayjs(row.booking.startTime).tz(user?.timeZone).format("ddd, DD MMM")} -
    - ); - }, - }), - columnHelper.display({ - id: "time", - size: 140, - enableColumnFilter: true, - enableSorting: false, - enableHiding: false, - header: () => {t("time")}, - 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 ( -
    - {startTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -{" "} - {endTime.format(user?.timeFormat === 12 ? "h:mma" : "HH:mm")} -
    - ); - }, - }), - columnHelper.display({ - id: "event", - minSize: 200, - enableColumnFilter: true, - enableSorting: false, - enableHiding: false, - header: () => {t("event")}, - cell: (props) => { - const row = props.row.original; - if (isSeparatorRow(row)) return null; - - return ( -
    - {row.booking.title} -
    - ); - }, - }), - columnHelper.display({ - id: "who", - size: 160, - enableColumnFilter: true, - enableSorting: false, - enableHiding: false, - header: () => {t("who")}, - 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 ( - - ); - }, - }), - columnHelper.display({ - id: "team", - size: 140, - enableColumnFilter: true, - enableSorting: false, - enableHiding: false, - header: () => {t("team")}, - cell: (props) => { - const row = props.row.original; - if (isSeparatorRow(row)) return null; - - if (row.booking.eventType.team) { - return ( - - {row.booking.eventType.team.name} - - ); - } - 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 ( -
    - {shouldShowReject && ( - - )} - {shouldShowAccept && ( - - )} - {shouldShowJoinButton && ( - - )} - - - -
    - ); - }, - }), - ]; -} diff --git a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx index 519220c3e1..4b7e347228 100644 --- a/apps/web/modules/bookings/components/BookingDetailsSheet.tsx +++ b/apps/web/modules/bookings/components/BookingDetailsSheet.tsx @@ -29,91 +29,65 @@ import { import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown"; import { BookingActionsStoreProvider } from "../../../components/booking/actions/BookingActionsStoreProvider"; import type { BookingListingStatus } from "../../../components/booking/types"; -import { - useBookingDetailsSheetStore, - useBookingDetailsSheetStoreApi, -} from "../store/bookingDetailsSheetStore"; +import { buildBookingLink } from "../lib/buildBookingLink"; import type { BookingOutput } from "../types"; -import { JoinMeetingButton } from "./JoinMeetingButton"; type BookingMetaData = z.infer; interface BookingDetailsSheetProps { + booking: BookingOutput | null; + isOpen: boolean; + onClose: () => void; userTimeZone?: string; userTimeFormat?: number; userId?: number; userEmail?: string; + onPrevious?: () => void; + hasPrevious?: boolean; + onNext?: () => void; + hasNext?: boolean; } -export function BookingDetailsSheet({ - userTimeZone, - userTimeFormat, - userId, - userEmail, -}: BookingDetailsSheetProps) { - const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking()); +interface BookingDetailsSheetInnerProps extends Omit { + booking: BookingOutput; +} - // Return null if no booking is selected (sheet is closed) - if (!booking) return null; +export function BookingDetailsSheet(props: BookingDetailsSheetProps) { + if (!props.booking) return null; return ( - + ); } -interface BookingDetailsSheetInnerProps { - booking: BookingOutput; - userTimeZone?: string; - userTimeFormat?: number; - userId?: number; - userEmail?: string; -} - function BookingDetailsSheetInner({ booking, + isOpen, + onClose, userTimeZone, userTimeFormat, userId, userEmail, + onPrevious, + hasPrevious = false, + onNext, + hasNext = false, }: BookingDetailsSheetInnerProps) { const { t } = useLocale(); - // Get navigation state directly from the store - const hasNext = useBookingDetailsSheetStore((state) => state.hasNext()); - const hasPrevious = useBookingDetailsSheetStore((state) => state.hasPrevious()); - const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); - - const handleClose = () => { - setSelectedBookingId(null); - }; - - const storeApi = useBookingDetailsSheetStoreApi(); - - const handleNext = () => { - const nextId = storeApi.getState().getNextBookingId(); - if (nextId !== null) { - setSelectedBookingId(nextId); - } - }; - - const handlePrevious = () => { - const prevId = storeApi.getState().getPreviousBookingId(); - if (prevId !== null) { - setSelectedBookingId(prevId); - } - }; - const startTime = dayjs(booking.startTime).tz(userTimeZone); const endTime = dayjs(booking.endTime).tz(userTimeZone); + // Build booking confirmation link + const isRecurring = booking.recurringEventId !== null; + const bookingLink = buildBookingLink({ + bookingUid: booking.uid, + allRemainingBookings: isRecurring, + email: booking.attendees?.[0]?.email, + }); + const getStatusBadge = () => { switch (booking.status) { case "ACCEPTED": @@ -134,6 +108,14 @@ 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 ? { @@ -149,7 +131,7 @@ function BookingDetailsSheetInner({ : []; return ( - + { e.preventDefault(); - handlePrevious(); + onPrevious?.(); }} />
    @@ -201,8 +183,6 @@ function BookingDetailsSheetInner({

    - - @@ -223,34 +203,49 @@ function BookingDetailsSheetInner({ -
    - +
    + + )} + - -
    - - - -
    +
    @@ -258,20 +253,6 @@ function BookingDetailsSheetInner({ ); } -function RescheduleRequestMessage({ booking }: { booking: BookingOutput }) { - const { t } = useLocale(); - - if (booking.status !== "CANCELLED" || !booking.rescheduled) { - return null; - } - - return ( - - {t("reschedule_request_sent")} - - ); -} - function WhoSection({ booking }: { booking: BookingOutput }) { const { t } = useLocale(); return ( diff --git a/apps/web/modules/bookings/components/BookingsCalendar.tsx b/apps/web/modules/bookings/components/BookingsCalendar.tsx index fd6f60b8eb..8c7c68bb57 100644 --- a/apps/web/modules/bookings/components/BookingsCalendar.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendar.tsx @@ -1,60 +1,89 @@ "use client"; import type { Table as ReactTable } from "@tanstack/react-table"; -import { useCallback } from "react"; +import { createParser, useQueryState } from "nuqs"; +import { useMemo, 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, BookingOutput } from "../types"; +import type { RowData, BookingListingStatus } from "../types"; import { BookingsCalendarView } from "./BookingsCalendarView"; -type BookingsCalendarProps = { +type BookingsCalendarViewProps = { status: BookingListingStatus; table: ReactTable; - isPending?: boolean; - currentWeekStart: dayjs.Dayjs; - setCurrentWeekStart: ( - value: dayjs.Dayjs | ((old: dayjs.Dayjs) => dayjs.Dayjs | null) | null - ) => Promise; - bookings: BookingOutput[]; }; const COLUMN_IDS_TO_HIDE = ["dateRange"]; -export function BookingsCalendar({ - table, - isPending = false, - currentWeekStart, - setCurrentWeekStart, - bookings, -}: BookingsCalendarProps) { +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(); 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 => 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: startDate.toISOString(), - endDate: endDate.toISOString(), + startDate: newStartDate.toISOString(), + endDate: newEndDate.toISOString(), preset: CUSTOM_PRESET.value, }, }); }, - [updateFilter, setCurrentWeekStart] + [dateRange, updateFilter, setCurrentWeekStart] ); return ( @@ -74,7 +103,6 @@ export function BookingsCalendar({ bookings={bookings} currentWeekStart={currentWeekStart} onWeekStartChange={handleWeekStartChange} - isPending={isPending} /> ); diff --git a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx b/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx deleted file mode 100644 index 40a0a63d1d..0000000000 --- a/apps/web/modules/bookings/components/BookingsCalendarContainer.tsx +++ /dev/null @@ -1,103 +0,0 @@ -"use client"; - -import { useReactTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table"; -import { createParser, useQueryState } from "nuqs"; -import { useMemo } from "react"; - -import dayjs from "@calcom/dayjs"; -import { useLocale } from "@calcom/lib/hooks/useLocale"; -import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; - -import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues"; - -import { buildFilterColumns, getFilterColumnVisibility } from "../columns/filterColumns"; -import { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore"; -import type { RowData, BookingListingStatus } 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 [currentWeekStart, setCurrentWeekStart] = useQueryState( - "weekStart", - weekStartParser.withDefault(dayjs().startOf("week")) - ); - - const columns = useMemo(() => { - return buildFilterColumns({ t, permissions, status }); - }, [t, permissions, status]); - - const getFacetedUniqueValues = useFacetedUniqueValues(); - - const table = useReactTable({ - 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 => 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]); - - return ( - - - - - - ); -} diff --git a/apps/web/modules/bookings/components/BookingsCalendarView.tsx b/apps/web/modules/bookings/components/BookingsCalendarView.tsx index 0a91023521..6a24a62f72 100644 --- a/apps/web/modules/bookings/components/BookingsCalendarView.tsx +++ b/apps/web/modules/bookings/components/BookingsCalendarView.tsx @@ -12,23 +12,19 @@ import { Button } from "@calcom/ui/components/button"; import { ButtonGroup } from "@calcom/ui/components/buttonGroup"; import { Icon } from "@calcom/ui/components/icon"; -import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { BookingOutput } from "../types"; type BookingsCalendarViewProps = { bookings: BookingOutput[]; currentWeekStart: dayjs.Dayjs; onWeekStartChange: (weekStart: dayjs.Dayjs) => void; - isPending?: boolean; }; export function BookingsCalendarView({ bookings, currentWeekStart, onWeekStartChange, - isPending = false, }: BookingsCalendarViewProps) { - const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); const { t } = useLocale(); const { timezone } = useTimePreferences(); const { resolvedTheme, forcedTheme } = useGetTheme(); @@ -48,10 +44,8 @@ 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(() => { @@ -84,7 +78,6 @@ export function BookingsCalendarView({ options: { status: booking.status, ...(eventTypeColor && { color: eventTypeColor }), - bookingId: booking.id, }, }; }); @@ -116,7 +109,6 @@ export function BookingsCalendarView({

    {weekRange}

    - {isPending && }
    diff --git a/apps/web/modules/bookings/components/BookingsList.tsx b/apps/web/modules/bookings/components/BookingsList.tsx index 69577f0811..ae4ad90067 100644 --- a/apps/web/modules/bookings/components/BookingsList.tsx +++ b/apps/web/modules/bookings/components/BookingsList.tsx @@ -1,16 +1,13 @@ "use client"; -import type { Row, Table as ReactTable } from "@tanstack/react-table"; -import { useCallback } from "react"; +import type { Table as ReactTable } from "@tanstack/react-table"; 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"; import SkeletonLoader from "@components/booking/SkeletonLoader"; -import { useBookingDetailsSheetStore } from "../store/bookingDetailsSheetStore"; import type { RowData, BookingListingStatus } from "../types"; const descriptionByStatus: Record = { @@ -30,16 +27,6 @@ type BookingsListViewProps = { export function BookingsList({ status, table, isPending, totalRowCount }: BookingsListViewProps) { const { t } = useLocale(); - const setSelectedBookingId = useBookingDetailsSheetStore((state) => state.setSelectedBookingId); - - const handleRowClick = useCallback( - (row: Row) => { - if (!isSeparatorRow(row.original)) { - setSelectedBookingId(row.original.booking.id); - } - }, - [setSelectedBookingId] - ); return ( { - if (isSeparatorRow(row.original)) return undefined; - return "booking-item"; - }} - rowDataAttributes={(row) => { - if (isSeparatorRow(row.original)) return undefined; - return { - "data-today": String(row.original.isToday), - }; - }} + headerClassName="hidden" isPending={isPending} totalRowCount={totalRowCount} - variant="default" + variant="compact" paginationMode="standard" - onRowMouseclick={handleRowClick} - hideSeparatorsOnSort={true} ToolbarLeft={ <> diff --git a/apps/web/modules/bookings/components/BookingsListContainer.tsx b/apps/web/modules/bookings/components/BookingsListContainer.tsx deleted file mode 100644 index 60153d02c9..0000000000 --- a/apps/web/modules/bookings/components/BookingsListContainer.tsx +++ /dev/null @@ -1,184 +0,0 @@ -"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 { BookingDetailsSheetStoreProvider } from "../store/bookingDetailsSheetStore"; -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(); - - // Filter out separator rows and extract bookings - const bookings = useMemo(() => { - return data - .filter((row): row is Extract => row.type === "data") - .map((row) => row.booking); - }, [data]); - - const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false); - const [rejectionReason, setRejectionReason] = useState(""); - 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 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({ - 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 ( - - - -
    - - {t("rejection_reason")} - (Optional) - - } - value={rejectionReason} - onChange={(e) => setRejectionReason(e.target.value)} - /> -
    - - - - - -
    -
    - - - - -
    - ); -} diff --git a/apps/web/modules/bookings/components/JoinMeetingButton.tsx b/apps/web/modules/bookings/components/JoinMeetingButton.tsx deleted file mode 100644 index 8ff4d4ed29..0000000000 --- a/apps/web/modules/bookings/components/JoinMeetingButton.tsx +++ /dev/null @@ -1,66 +0,0 @@ -"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 ( - - ); -} diff --git a/apps/web/modules/bookings/components/useJoinableLocation.tsx b/apps/web/modules/bookings/components/useJoinableLocation.tsx deleted file mode 100644 index 82993c2bec..0000000000 --- a/apps/web/modules/bookings/components/useJoinableLocation.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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, - }; -} diff --git a/apps/web/modules/bookings/hooks/useBookingCursor.ts b/apps/web/modules/bookings/hooks/useBookingCursor.ts index 96dbfa80bd..a271898c8b 100644 --- a/apps/web/modules/bookings/hooks/useBookingCursor.ts +++ b/apps/web/modules/bookings/hooks/useBookingCursor.ts @@ -1,37 +1,43 @@ import { useMemo, useCallback } from "react"; -import type { BookingOutput } from "../types"; +import type { RowData } from "../types"; + +function isDataRow(row: RowData): row is Extract { + return row.type === "data"; +} export function useBookingCursor({ bookings, selectedBookingId, setSelectedBookingId, }: { - bookings: BookingOutput[]; + bookings: RowData[]; selectedBookingId: number | null; setSelectedBookingId: (bookingId: number | null) => void; }) { + const bookingRows = useMemo(() => bookings.filter(isDataRow), [bookings]); + const currentIndex = useMemo( - () => bookings.findIndex((booking) => selectedBookingId && booking.id === selectedBookingId), - [bookings, selectedBookingId] + () => bookingRows.findIndex((row) => selectedBookingId && row.booking.id === selectedBookingId), + [bookingRows, selectedBookingId] ); const onPrevious = useCallback(() => { if (currentIndex >= 1) { - setSelectedBookingId(bookings[currentIndex - 1].id); + setSelectedBookingId(bookingRows[currentIndex - 1].booking.id); } - }, [bookings, currentIndex, setSelectedBookingId]); + }, [bookingRows, currentIndex, setSelectedBookingId]); const onNext = useCallback(() => { - if (currentIndex >= 0 && currentIndex < bookings.length - 1) { - setSelectedBookingId(bookings[currentIndex + 1].id); + if (currentIndex >= 0 && currentIndex < bookingRows.length - 1) { + setSelectedBookingId(bookingRows[currentIndex + 1].booking.id); } - }, [bookings, currentIndex, setSelectedBookingId]); + }, [bookingRows, currentIndex, setSelectedBookingId]); return { onPrevious, onNext, hasPrevious: currentIndex > 0, - hasNext: currentIndex < bookings.length - 1 && currentIndex >= 0, + hasNext: currentIndex < bookingRows.length - 1 && currentIndex >= 0, }; } diff --git a/apps/web/modules/bookings/hooks/useSelectedBookingId.ts b/apps/web/modules/bookings/hooks/useSelectedBookingId.ts deleted file mode 100644 index 45e2379b41..0000000000 --- a/apps/web/modules/bookings/hooks/useSelectedBookingId.ts +++ /dev/null @@ -1,10 +0,0 @@ -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, - }); -} diff --git a/apps/web/modules/bookings/lib/viewParser.ts b/apps/web/modules/bookings/lib/viewParser.ts deleted file mode 100644 index b6b2569f0e..0000000000 --- a/apps/web/modules/bookings/lib/viewParser.ts +++ /dev/null @@ -1,11 +0,0 @@ -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"; diff --git a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx b/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx deleted file mode 100644 index 531c83ae1a..0000000000 --- a/apps/web/modules/bookings/store/bookingDetailsSheetStore.tsx +++ /dev/null @@ -1,144 +0,0 @@ -"use client"; - -import React from "react"; -import { createStore, useStore } from "zustand"; - -import { useSelectedBookingId } from "../hooks/useSelectedBookingId"; -import type { BookingOutput } from "../types"; - -interface BookingDetailsSheetStore { - // State - selectedBookingId: number | null; - bookings: BookingOutput[]; - - // Actions - setSelectedBookingId: (id: number | null) => void; - setBookings: (bookings: BookingOutput[]) => void; - clearSelection: () => void; - - // Computed getters (used via selectors) - getSelectedBooking: () => BookingOutput | null; - getNextBookingId: () => number | null; - getPreviousBookingId: () => number | null; - hasNext: () => boolean; - hasPrevious: () => boolean; -} - -type BookingDetailsSheetStoreType = ReturnType; - -const createBookingDetailsSheetStore = (initialBookings: BookingOutput[] = []) => { - return createStore((set, get) => ({ - // Initial state - selectedBookingId: null, - bookings: initialBookings, - - // Actions - setSelectedBookingId: (id) => set({ selectedBookingId: id }), - setBookings: (bookings) => set({ bookings }), - clearSelection: () => set({ selectedBookingId: null }), - - // Computed getters - getSelectedBooking: () => { - const state = get(); - if (!state.selectedBookingId) return null; - return state.bookings.find((booking) => booking.id === state.selectedBookingId) ?? null; - }, - - getNextBookingId: () => { - const state = get(); - if (!state.selectedBookingId) return null; - - const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); - if (currentIndex === -1 || currentIndex >= state.bookings.length - 1) return null; - - return state.bookings[currentIndex + 1].id; - }, - - getPreviousBookingId: () => { - const state = get(); - if (!state.selectedBookingId) return null; - - const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); - if (currentIndex <= 0) return null; - - return state.bookings[currentIndex - 1].id; - }, - - hasNext: () => { - const state = get(); - if (!state.selectedBookingId) return false; - - const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); - return currentIndex >= 0 && currentIndex < state.bookings.length - 1; - }, - - hasPrevious: () => { - const state = get(); - if (!state.selectedBookingId) return false; - - const currentIndex = state.bookings.findIndex((booking) => booking.id === state.selectedBookingId); - return currentIndex > 0; - }, - })); -}; - -const BookingDetailsSheetStoreContext = React.createContext(null); - -export function BookingDetailsSheetStoreProvider({ - children, - bookings, -}: { - children: React.ReactNode; - bookings: BookingOutput[]; -}) { - const [store] = React.useState(() => createBookingDetailsSheetStore(bookings)); - const [selectedBookingIdFromUrl, setSelectedBookingIdToUrl] = useSelectedBookingId(); - - // Update bookings when they change - React.useEffect(() => { - store.getState().setBookings(bookings); - }, [bookings, store]); - - // Sync Store → URL - React.useEffect(() => { - const unsubscribe = store.subscribe((state) => { - const storeId = state.selectedBookingId; - if (storeId !== selectedBookingIdFromUrl) { - setSelectedBookingIdToUrl(storeId); - } - }); - - return unsubscribe; - }, [selectedBookingIdFromUrl, setSelectedBookingIdToUrl, store]); - - // Sync URL → Store - React.useEffect(() => { - const currentStoreId = store.getState().selectedBookingId; - if (currentStoreId !== selectedBookingIdFromUrl) { - store.getState().setSelectedBookingId(selectedBookingIdFromUrl); - } - }, [selectedBookingIdFromUrl, store]); - - return ( - - {children} - - ); -} - -export function useBookingDetailsSheetStore(selector: (state: BookingDetailsSheetStore) => T): T { - const store = React.useContext(BookingDetailsSheetStoreContext); - if (!store) { - throw new Error("useBookingDetailsSheetStore must be used within BookingDetailsSheetStoreProvider"); - } - return useStore(store, selector); -} - -// For direct store access (needed for subscribe and getState) -export function useBookingDetailsSheetStoreApi() { - const store = React.useContext(BookingDetailsSheetStoreContext); - if (!store) { - throw new Error("useBookingDetailsSheetStoreApi must be used within BookingDetailsSheetStoreProvider"); - } - return store; -} diff --git a/apps/web/modules/bookings/types.ts b/apps/web/modules/bookings/types.ts index 4aefb9ef0f..68bcaa935a 100644 --- a/apps/web/modules/bookings/types.ts +++ b/apps/web/modules/bookings/types.ts @@ -1,4 +1,3 @@ -import type { SeparatorRow } from "@calcom/features/data-table/lib/separator"; import type { RouterOutputs } from "@calcom/trpc/react"; import type { validStatuses } from "~/bookings/lib/validStatuses"; @@ -19,6 +18,8 @@ export type RowData = isToday: boolean; recurringInfo?: RecurringInfo; } - | SeparatorRow; + | { + type: "today" | "next"; + }; export type BookingListingStatus = (typeof validStatuses)[number]; diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 6eff0f978a..48678a0f79 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -1,13 +1,15 @@ "use client"; -import dynamic from "next/dynamic"; +import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; import { useSearchParams, usePathname } from "next/navigation"; -import { useQueryState } from "nuqs"; +import { createParser, useQueryState } from "nuqs"; import { useMemo } from "react"; import dayjs from "@calcom/dayjs"; import { DataTableProvider, + DataTableFilters, + DataTableSegment, type SystemFilterSegment, useDataTable, ColumnFilterType, @@ -25,26 +27,24 @@ 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,12 +86,27 @@ export default function Bookings(props: BookingsProps) { ); } -function BookingsContent({ status, permissions }: BookingsProps) { - const [view] = useQueryState("view", viewParser.withDefault("list")); +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")); // 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() || ""; @@ -141,47 +156,161 @@ function BookingsContent({ status, permissions }: BookingsProps) { const { limit, offset } = useDataTable(); - // 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 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, }, - { - staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh - gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time - } - ); + }); + + const columns = useMemo(() => { + const columnHelper = createColumnHelper(); + + 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 ( + setSelectedBookingId(booking.id)} + {...booking} + /> + ); + } else if (props.row.original.type === "today") { + return ( +

    + {t("today")} +

    + ); + } else if (props.row.original.type === "next") { + return ( +

    + {t("next")} +

    + ); + } + }, + }), + ]; + }, [user, status, t, permissions.canReadOthersBookings]); const isEmpty = useMemo(() => !query.data?.bookings.length, [query.data]); - 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 flatData = useMemo(() => { const shownBookings: Record = {}; const filterBookings = (booking: BookingOutput) => { if (status === "recurring" || status == "unconfirmed" || status === "cancelled") { @@ -196,86 +325,95 @@ function BookingsContent({ status, permissions }: BookingsProps) { 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; }; - const todayBookings: RowData[] = []; - const currentMonthBookings: RowData[] = []; - const monthBuckets: Record = {}; // 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 = { + return ( + query.data?.bookings.filter(filterBookings).map((booking) => ({ type: "data", booking, - isToday: bookingDateStr === today, recurringInfo: query.data?.recurringInfo.find( (info) => info.recurringEventId === booking.recurringEventId ), - }; - - 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 }; + isToday: false, + })) || [] + ); }, [query.data, status, user?.timeZone]); - const flatData = useMemo(() => { - return [...groupedBookings.today, ...groupedBookings.currentMonth, ...Object.values(groupedBookings.monthBuckets).flat()]; - }, [groupedBookings]); - const bookingsToday = useMemo(() => { - return groupedBookings.today; - }, [groupedBookings]); + return ( + query.data?.bookings + .filter( + (booking: BookingOutput) => + dayjs(booking.startTime).tz(user?.timeZone).format("YYYY-MM-DD") === + dayjs().tz(user?.timeZone).format("YYYY-MM-DD") + ) + .map((booking) => ({ + type: "data" as const, + booking, + recurringInfo: query.data?.recurringInfo.find( + (info) => info.recurringEventId === booking.recurringEventId + ), + isToday: true, + })) ?? [] + ); + }, [query.data, user?.timeZone]); const finalData = useMemo(() => { if (status !== "upcoming") { return flatData; } - const merged: RowData[] = []; - - // Add Today section - if (groupedBookings.today.length > 0) { - merged.push({ type: "separator", label: t("today") }, ...groupedBookings.today); + if (bookingsToday.length > 0) { + merged.push({ type: "today" as const }, ...bookingsToday); } - - // 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); + if (flatData.length > 0) { + merged.push({ type: "next" as const }, ...flatData); } - - // 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; - }, [groupedBookings, status, t, flatData]); + }, [bookingsToday, flatData, status]); + + const selectedBooking = useMemo(() => { + if (!selectedBookingId) return null; + const dataRow = finalData.find( + (row): row is Extract => + 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({ + data: finalData, + columns, + initialState: { + columnVisibility: { + eventTypeId: false, + teamId: false, + userId: false, + attendeeName: false, + attendeeEmail: false, + dateRange: false, + bookingUid: false, + }, + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedUniqueValues, + }); const isPending = query.isPending; const totalRowCount = query.data?.totalCount; @@ -293,32 +431,55 @@ function BookingsContent({ status, permissions }: BookingsProps) {
    {query.status === "error" ? ( - + <> +
    +
    +
    +
    + +
    +
    + + + +
    +
    +
    +
    + + ) : ( <> {!!bookingsToday.length && status === "upcoming" && ( )} {view === "list" ? ( - ) : ( - + )} )}
    + 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} + />
    ); } diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 42d61134fc..cf23c6c111 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -10,8 +10,6 @@ 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", () => { @@ -60,14 +58,16 @@ 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 }) => { + test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => { 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,14 +112,15 @@ 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(); - - // Wait for the dropdown to close before clicking again - await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible(); + await firstGuest.click(); + await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible(); + await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden(); await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; const body = request.body; @@ -141,7 +142,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", @@ -157,33 +158,23 @@ test.describe("Bookings", () => { { name: "Fourth", email: "fourth@cal.com", timeZone: "Europe/Berlin" }, ], }); - await bookingWhereFirstUserIsOrganizerFixture.self(); + const bookingWhereFirstUserIsOrganizer = 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); - - // Click the ellipsis button to open the all attendees dropdown + const titleAndAttendees = firstPastBooking.locator('[data-testid="title-and-attendees"]'); const moreGuests = firstPastBooking.locator('[data-testid="more-guests"]'); await moreGuests.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 + 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(); await moreGuests.click(); - const unmarkNoShowButtons = page.locator('[data-testid="unmark-no-show"]'); - - // The 3rd attendee should now have the unmark button visible - await expect(unmarkNoShowButtons.first()).toBeVisible(); + await expect(firstGuestInMore).toBeChecked({ checked: true }); }); test("Team admin/owner can mark first attendee as no-show", async ({ page, @@ -208,16 +199,18 @@ test.describe("Bookings", () => { }); const booking = await bookingFixture.self(); await adminUser.apiLogin(); - const { webhookReceiver } = await webhooks.createTeamReceiver(); + const { webhookReceiver, teamId } = 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(); - // Wait for the dropdown to close before clicking again - await expect(page.getByText("first@cal.com marked as no-show")).toBeVisible(); + await firstGuest.click(); + await expect(page.locator('[data-testid="unmark-no-show"]')).toBeVisible(); + await expect(page.locator('[data-testid="mark-no-show"]')).toBeHidden(); await webhookReceiver.waitForRequestCount(1); const [request] = webhookReceiver.requestList; const body = request.body; @@ -239,7 +232,7 @@ test.describe("Bookings", () => { webhookReceiver.close(); }); - test("Can choose date range presets", async ({ page, users }) => { + test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => { const firstUser = await users.create(); await firstUser.apiLogin(); const bookingsGetResponse = page.waitForResponse((response) => @@ -381,15 +374,22 @@ test.describe("Bookings", () => { //verify with the booking titles const firstUpcomingBooking = bookingListItems.nth(0); - await expect(firstUpcomingBooking.locator(`text=${thirdUserOrganizerBooking!.title}`)).toBeVisible(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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(thirdUpcomingBooking.locator(`text=${thirdUserAttendeeTeamEvent!.title}`)).toBeVisible(); + await expect( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + 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({ diff --git a/apps/web/playwright/i18n-routing.e2e.ts b/apps/web/playwright/i18n-routing.e2e.ts index 92463ba123..7944d5f8be 100644 --- a/apps/web/playwright/i18n-routing.e2e.ts +++ b/apps/web/playwright/i18n-routing.e2e.ts @@ -57,7 +57,9 @@ 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.getByRole("heading", { name: "Bookings" })).toBeVisible(); + await expect( + page.locator("text=See upcoming and past events booked through your event type links.") + ).toBeVisible(); }); test(`/${locale}/teams page shouldn't 404`, async ({ page, users }) => { diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index 87a55bd1f2..2dc45dba99 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -41,9 +41,7 @@ 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('[data-testid="reschedule-dialog"]')).toBeHidden(); - - await page.waitForTimeout(2000); + await expect(page.locator('[id="modal-title"]')).toBeHidden(); const updatedBooking = await booking.self(); @@ -60,7 +58,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(), @@ -132,9 +130,8 @@ test.describe("Reschedule Tests", async () => { await user.apiLogin(); await page.goto("/bookings/cancelled"); - await page.locator('[data-testid="booking-item"]').nth(0).click(); - - await expect(page.locator('[data-testid="request_reschedule_sent"]')).toBeVisible(); + const requestRescheduleSentElement = page.locator('[data-testid="request_reschedule_sent"]').nth(1); + await expect(requestRescheduleSentElement).toBeVisible(); await booking.delete(); }); @@ -199,7 +196,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); @@ -229,7 +226,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); @@ -333,6 +330,7 @@ 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 @@ -341,13 +339,11 @@ 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: async () => { - await authedPage.locator('[data-testid="booking-item"] [data-testid="confirm"]').nth(0).click(); - }, + action: () => authedPage.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`).click(), }); await authedContext.close(); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d22d8ab729..5d1434841f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -221,7 +221,6 @@ "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}}", @@ -1712,10 +1711,7 @@ "error_updating_password": "Error updating password", "two_factor_auth": "Two factor authentication", "recurring_event_tab_description": "Set up a repeating schedule", - "today": "Today", - "this_month": "This month", - "time": "Time", - "event": "Event", + "today": "today", "appearance": "Appearance", "my_account": "My account", "general": "General", @@ -3891,7 +3887,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", @@ -3981,7 +3977,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", - "recurring_booking_description": "Recurring {{frequency}} event with {{count}} occurrences", + "view": "View", "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.", @@ -4010,9 +4006,5 @@ "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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/calendars/weeklyview/types/events.ts b/packages/features/calendars/weeklyview/types/events.ts index b8d68914d1..5803d0a46d 100644 --- a/packages/features/calendars/weeklyview/types/events.ts +++ b/packages/features/calendars/weeklyview/types/events.ts @@ -13,7 +13,6 @@ export interface CalendarEvent { allDay?: boolean; color?: string; className?: string; - bookingId?: number; "data-test-id"?: string; }; } diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 98680df8dd..c6e25ffc24 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -38,9 +38,7 @@ export type DataTablePropsFromWrapper = { className?: string; containerClassName?: string; headerClassName?: string; - rowClassName?: string | ((row: Row) => string); - rowTestId?: string | ((row: Row) => string | undefined); - rowDataAttributes?: (row: Row) => Record | undefined; + rowClassName?: string; paginationMode?: "infinite" | "standard"; hasWrapperContext?: boolean; hideSeparatorsOnSort?: boolean; @@ -70,8 +68,6 @@ export function DataTable({ containerClassName, headerClassName, rowClassName, - rowTestId, - rowDataAttributes, paginationMode = "infinite", hasWrapperContext = false, hideSeparatorsOnSort = true, @@ -207,8 +203,6 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} - rowTestId={rowTestId} - rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -225,8 +219,6 @@ export function DataTable({ onRowMouseclick={onRowMouseclick} paginationMode={paginationMode} rowClassName={rowClassName} - rowTestId={rowTestId} - rowDataAttributes={rowDataAttributes} hideSeparatorsOnSort={hideSeparatorsOnSort} hideSeparatorsOnFilter={hideSeparatorsOnFilter} separatorClassName={separatorClassName} @@ -252,8 +244,6 @@ 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 && @@ -269,9 +259,7 @@ type DataTableBodyProps = { isPending?: boolean; onRowMouseclick?: (row: Row) => void; paginationMode?: "infinite" | "standard"; - rowClassName?: string | ((row: Row) => string); - rowTestId?: string | ((row: Row) => string | undefined); - rowDataAttributes?: (row: Row) => Record | undefined; + rowClassName?: string; hideSeparatorsOnSort?: boolean; hideSeparatorsOnFilter?: boolean; separatorClassName?: string; @@ -306,8 +294,6 @@ function DataTableBody({ onRowMouseclick, paginationMode, rowClassName, - rowTestId, - rowDataAttributes, hideSeparatorsOnSort = true, hideSeparatorsOnFilter = false, separatorClassName, @@ -388,16 +374,10 @@ function DataTableBody({ ); } - const computedRowTestId = typeof rowTestId === "function" ? rowTestId(row) : rowTestId; - const computedRowClassName = typeof rowClassName === "function" ? rowClassName(row) : rowClassName; - const computedDataAttributes = rowDataAttributes?.(row); - return ( filteredRowVirtualizer.measureElement(node) : undefined} key={row.id} - data-testid={computedRowTestId} - {...computedDataAttributes} data-index={virtualItem?.index} // needed for dynamic row height measurement data-state={row.getIsSelected() && "selected"} onClick={(e) => { @@ -416,7 +396,7 @@ function DataTableBody({ width: "100%", }), }} - className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", computedRowClassName)}> + className={classNames(onRowMouseclick && "hover:cursor-pointer", "group", rowClassName)}> {row.getVisibleCells().map((cell) => { const column = cell.column; return ( @@ -455,7 +435,7 @@ const TableHeadLabel = ({ header }: { header: Header }) if (!canSort && !canHide) { if (typeof header.column.columnDef.header === "string") { return ( -
    +
    {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
    ); diff --git a/packages/features/data-table/components/DataTableWrapper.tsx b/packages/features/data-table/components/DataTableWrapper.tsx index aef5aa5143..46337cd147 100644 --- a/packages/features/data-table/components/DataTableWrapper.tsx +++ b/packages/features/data-table/components/DataTableWrapper.tsx @@ -22,8 +22,6 @@ type BaseDataTableWrapperProps = Omit< LoaderView?: React.ReactNode; tableContainerRef?: React.RefObject; onRowMouseclick?: (row: Row) => void; - rowTestId?: string | ((row: Row) => string | undefined); - rowDataAttributes?: (row: Row) => Record | undefined; }; type InfinitePaginationProps = BaseDataTableWrapperProps & { @@ -60,8 +58,6 @@ export function DataTableWrapper({ containerClassName, headerClassName, rowClassName, - rowTestId, - rowDataAttributes, children, tableContainerRef: externalRef, paginationMode, @@ -137,8 +133,6 @@ export function DataTableWrapper({ containerClassName={containerClassName} headerClassName={headerClassName} rowClassName={rowClassName} - rowTestId={rowTestId} - rowDataAttributes={rowDataAttributes} paginationMode={paginationMode} onRowMouseclick={onRowMouseclick} hasWrapperContext={true} diff --git a/packages/lib/hooks/useMediaQuery.ts b/packages/lib/hooks/useMediaQuery.ts index badad29d30..9d06c75005 100644 --- a/packages/lib/hooks/useMediaQuery.ts +++ b/packages/lib/hooks/useMediaQuery.ts @@ -1,13 +1,7 @@ import { useState, useEffect } from "react"; const useMediaQuery = (query: string) => { - const [matches, setMatches] = useState(() => { - // Initialize with actual media query value on client - if (typeof window !== "undefined") { - return window.matchMedia(query).matches; - } - return false; - }); + const [matches, setMatches] = useState(false); useEffect(() => { const media = window.matchMedia(query); @@ -15,8 +9,8 @@ const useMediaQuery = (query: string) => { setMatches(media.matches); } const listener = () => setMatches(media.matches); - media.addEventListener("change", listener); - return () => media.removeEventListener("change", listener); + window.addEventListener("resize", listener); + return () => window.removeEventListener("resize", listener); }, [matches, query]); return matches; diff --git a/packages/ui/components/avatar/AvatarGroup.tsx b/packages/ui/components/avatar/AvatarGroup.tsx index 6d331717e2..ae8c37f748 100644 --- a/packages/ui/components/avatar/AvatarGroup.tsx +++ b/packages/ui/components/avatar/AvatarGroup.tsx @@ -13,7 +13,6 @@ export type AvatarGroupProps = { className?: string; truncateAfter?: number; hideTruncatedAvatarsCount?: boolean; - onClick?: (e: React.MouseEvent) => void; }; export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) { @@ -47,10 +46,8 @@ export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
  • + props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16" + )}>