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