Files
calendar/apps/web/modules/bookings/components/BookingListContainer.tsx
T
Bailey PumfleetGitHubunknown <>Pedro CastroDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
7c66f33de2 chore: UX Fixes (#26643)
* Copy changes

* Move search bar inline with new button

* Get rid of no more results message

* Change hidden badge to (hidden)

* Remove Cal.ai badge from sidebar

* Add dropdown to create button when there is multiple options

* Fix delete dialog

* Saved filters updates

* More string fixes

* Switch members table to use names

* Fix member spacing

* Fix routing form identifier field

* Fix routing forms stuff

* Only show SMS hint on SMS options

* Make workflow delete button minimal

* Fix padding on workflow steps

* Remove min width on workflow title

* Fix delete workflow PR

* Fix org profile buttons

* Fix org profile screen partially scrolled down

* Improve logos & banner uploads

* Personal profile fixes

* Fix settings general view stuff

* Sentence case consistency

* Fix stuff I broke

* Fix fab

* Fix hidden translation string

* Fix text fields

* Make button small for solo users too

* fix: update E2E tests to match sentence case labels in routing forms

* fix: update tests to match sentence case label changes

- insights.e2e.ts: chart titles (14 strings)
- event-types.e2e.ts: Organizer phone number location
- EditLocationDialog.test.tsx: phone number labels

* fix: address Cubic AI review feedback (confidence 9+)

- Replace hardcoded text-gray-500 with text-muted in TextField.tsx hint section
- Replace text locator with data-testid in E2E test for location select

Co-Authored-By: unknown <>

* fix: update E2E tests for sentence case label changes

- Use data-testid selectors for location options (more reliable than text)
- Update field identifiers in routing-forms tests to match new labels
- Fix Long text selector in manage-booking-questions test

* fix: replace text locator with data-testid in manage-booking-questions E2E test

Replace fragile text="Long text" locator with resilient
page.getByTestId("select-option-textarea") selector per E2E best practices.

Addresses Cubic AI review feedback (confidence 9/10).

Co-Authored-By: unknown <>

* fix: use .last() for multiple location select items

---------

Co-authored-by: Pedro Castro <pedro@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-13 00:56:08 -05:00

349 lines
9.5 KiB
TypeScript

"use client";
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
} from "@tanstack/react-table";
import { useRouter } from "next/navigation";
import React, { useState, useMemo, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import {
useDataTable,
useDisplayedFilterCount,
} from "@calcom/features/data-table";
import { DataTableSegment, DataTableFilters } from "~/data-table/components";
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 { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ToggleGroup } from "@calcom/ui/components/form";
import { WipeMyCalActionButton } from "@calcom/web/components/apps/wipemycalother/wipeMyCalActionButton";
import { useBookingFilters } from "~/bookings/hooks/useBookingFilters";
import { useBookingListColumns } from "~/bookings/hooks/useBookingListColumns";
import { useBookingListData } from "~/bookings/hooks/useBookingListData";
import { useBookingStatusTab } from "~/bookings/hooks/useBookingStatusTab";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useListAutoSelector } from "~/bookings/hooks/useListAutoSelector";
import { useListNavigationCapabilities } from "~/bookings/hooks/useListNavigationCapabilities";
import {
BookingDetailsSheetStoreProvider,
useBookingDetailsSheetStore,
} from "../store/bookingDetailsSheetStore";
import type {
RowData,
BookingListingStatus,
BookingsGetOutput,
} from "../types";
import { BookingDetailsSheet } from "./BookingDetailsSheet";
import { BookingList } from "./BookingList";
import { ViewToggleButton } from "./ViewToggleButton";
interface FilterButtonProps {
table: ReturnType<typeof useReactTable<RowData>>;
displayedFilterCount: number;
setShowFilters: (value: boolean | ((prev: boolean) => boolean)) => void;
}
function FilterButton({
table,
displayedFilterCount,
setShowFilters,
}: FilterButtonProps) {
const { t } = useLocale();
if (displayedFilterCount === 0) {
return <DataTableFilters.AddFilterButton table={table} />;
}
return (
<Button
color="secondary"
StartIcon="list-filter"
className="h-full"
size="sm"
onClick={() => setShowFilters((value) => !value)}
>
{t("filter")}
<Badge variant="gray" className="ml-1">
{displayedFilterCount}
</Badge>
</Button>
);
}
interface BookingListContainerProps {
status: BookingListingStatus;
permissions: {
canReadOthersBookings: boolean;
};
bookingsV3Enabled: boolean;
bookingAuditEnabled: boolean;
}
interface BookingListInnerProps extends BookingListContainerProps {
data?: BookingsGetOutput;
isPending: boolean;
hasError: boolean;
errorMessage?: string;
totalRowCount?: number;
bookings: BookingsGetOutput["bookings"];
}
function BookingListInner({
status,
permissions,
bookings,
bookingsV3Enabled,
bookingAuditEnabled,
data,
isPending,
hasError,
errorMessage,
totalRowCount,
}: BookingListInnerProps) {
const { t } = useLocale();
const user = useMeQuery().data;
const setSelectedBookingUid = useBookingDetailsSheetStore(
(state) => state.setSelectedBookingUid
);
const router = useRouter();
const [showFilters, setShowFilters] = useState(true);
// Handle auto-selection for list view
useListAutoSelector(bookings);
const ErrorView = errorMessage ? (
<Alert
severity="error"
title={t("something_went_wrong")}
message={errorMessage}
/>
) : undefined;
const handleBookingClick = useCallback(
(bookingUid: string) => {
setSelectedBookingUid(bookingUid);
},
[setSelectedBookingUid]
);
const columns = useBookingListColumns({
user,
status,
canReadOthersBookings: permissions.canReadOthersBookings,
bookingsV3Enabled,
handleBookingClick,
});
const finalData = useBookingListData({
data,
status,
userTimeZone: user?.timeZone,
});
const getFacetedUniqueValues = useFacetedUniqueValues();
const displayedFilterCount = useDisplayedFilterCount();
const { currentTab, tabOptions } = useBookingStatusTab();
useEffect(() => {
if (displayedFilterCount === 0) {
// reset to true, so it shows filters as soon as any filter is applied
setShowFilters(true);
}
}, [displayedFilterCount]);
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,
});
const isEmpty = !data?.bookings || data.bookings.length === 0;
return (
<>
<div className="flex flex-wrap items-center gap-2">
{/* Desktop: full width on first row, Mobile: full width on first row with horizontal scroll */}
<div className="w-full md:w-auto">
<div className="overflow-x-auto md:overflow-visible">
<ToggleGroup
value={currentTab}
onValueChange={(value) => {
if (!value) return;
const selectedTab = tabOptions.find(
(tab) => tab.value === value
);
if (selectedTab?.href) {
router.push(selectedTab.href);
}
}}
options={tabOptions}
/>
</div>
</div>
{/* Desktop: second item on first row, Mobile: first item on second row */}
<FilterButton
table={table}
displayedFilterCount={displayedFilterCount}
setShowFilters={setShowFilters}
/>
{/* Desktop: auto-pushed to right via flex-grow spacer, Mobile: continue on second row */}
<div className="hidden grow md:block" />
<DataTableSegment.Select />
{bookingsV3Enabled && (
<ViewToggleButton bookingsV3Enabled={bookingsV3Enabled} />
)}
</div>
{displayedFilterCount > 0 && showFilters && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.AddFilterButton table={table} variant="minimal" />
{/* Desktop: auto-pushed to right via flex-grow spacer */}
<div className="hidden flex-grow md:block" />
<DataTableFilters.ClearFiltersButton />
<DataTableSegment.SaveButton />
</div>
)}
{status === "upcoming" && !isEmpty && (
<WipeMyCalActionButton
className="mt-4"
bookingStatus={status}
bookingsEmpty={isEmpty}
/>
)}
<div className="mt-4">
<BookingList
status={status}
table={table}
isPending={isPending}
totalRowCount={totalRowCount}
ErrorView={ErrorView}
hasError={hasError}
/>
</div>
{bookingsV3Enabled && (
<BookingDetailsSheet
userTimeZone={user?.timeZone}
userTimeFormat={
user?.timeFormat === null ? undefined : user?.timeFormat
}
userId={user?.id}
userEmail={user?.email}
bookingAuditEnabled={bookingAuditEnabled}
/>
)}
</>
);
}
export function BookingListContainer(props: BookingListContainerProps) {
const { limit, offset, setPageIndex } = useDataTable();
const {
eventTypeIds,
teamIds,
userIds,
dateRange,
attendeeName,
attendeeEmail,
bookingUid,
} = useBookingFilters();
// Build query input once - shared between query and prefetching
const queryInput = useMemo(
() => ({
limit,
offset,
filters: {
statuses: [props.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,
},
}),
[
limit,
offset,
props.status,
eventTypeIds,
teamIds,
userIds,
attendeeName,
attendeeEmail,
bookingUid,
dateRange,
]
);
const query = trpc.viewer.bookings.get.useQuery(queryInput, {
staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh
gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time
});
const bookings = useMemo(
() => query.data?.bookings ?? [],
[query.data?.bookings]
);
// Always call the hook and provide navigation capabilities
// The BookingDetailsSheet is only rendered when bookingsV3Enabled is true (see line 212)
const capabilities = useListNavigationCapabilities({
limit,
offset,
totalCount: query.data?.totalCount,
setPageIndex,
queryInput,
});
return (
<BookingDetailsSheetStoreProvider
bookings={bookings}
capabilities={capabilities}
>
<BookingListInner
{...props}
data={query.data}
isPending={query.isPending}
hasError={!!query.error}
errorMessage={query.error?.message}
totalRowCount={query.data?.totalCount}
bookings={bookings}
/>
</BookingDetailsSheetStoreProvider>
);
}