fix: refactor filter UIs on /bookings (#19532)

* Reapply "fix: replace filter implementations on bookings (#19445)" (#19506)

This reverts commit f6af6e34bd.

* force custom date range selector for bookings except for Past tab

* fix e2e test

* add e2e tests

* fix e2e test
This commit is contained in:
Eunjae Lee
2025-02-26 09:47:20 +01:00
committed by GitHub
parent ca1f84c827
commit 3d3acba82a
19 changed files with 658 additions and 215 deletions
@@ -0,0 +1,74 @@
import { useSession } from "next-auth/react";
import { useMemo } from "react";
import type { FacetedValue } from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
export function useEventTypes() {
const { data: user } = useSession();
const { t } = useLocale();
const eventTypesQuery = trpc.viewer.eventTypes.listWithTeam.useQuery(undefined, {
enabled: !!user,
});
return useMemo(() => {
const eventTypes = eventTypesQuery.data || [];
// Separate individual and team event types
const individualEvents = eventTypes.filter((el) => !el.team);
// Group team events by team
const teamEventsMap = eventTypes.reduce((acc, event) => {
if (!event.team) return acc;
const teamId = event.team.id;
if (!acc[teamId]) {
acc[teamId] = {
teamName: event.team.name,
events: [],
};
}
acc[teamId].events.push(event);
return acc;
}, {} as Record<number, { teamName: string; events: typeof eventTypes }>);
// Create flat array with section markers
const flatArray: FacetedValue[] = [];
// Add individual events section if exists
if (individualEvents.length > 0) {
flatArray.push({
section: t("individual"),
label: individualEvents[0].title,
value: individualEvents[0].id,
});
flatArray.push(
...individualEvents.slice(1).map((event) => ({
label: event.title,
value: event.id,
}))
);
}
// Add each team's events as a separate section
Object.values(teamEventsMap).forEach(({ teamName, events }) => {
if (events.length > 0) {
flatArray.push({
section: `${teamName} Events`,
label: events[0].title,
value: events[0].id,
});
flatArray.push(
...events.slice(1).map((event) => ({
label: event.title,
value: event.id,
}))
);
}
});
return flatArray;
}, [eventTypesQuery, t]);
}
@@ -0,0 +1,39 @@
import type { Table } from "@tanstack/react-table";
import { useCallback } from "react";
import { convertFacetedValuesToMap, type FacetedValue } from "@calcom/features/data-table";
import { trpc } from "@calcom/trpc/react";
import { useEventTypes } from "./useEventTypes";
export function useFacetedUniqueValues() {
const eventTypes = useEventTypes();
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { data: members } = trpc.viewer.teams.listSimpleMembers.useQuery();
return useCallback(
(_: Table<any>, columnId: string) => (): Map<FacetedValue, number> => {
if (columnId === "eventTypeId") {
return convertFacetedValuesToMap(eventTypes || []);
} else if (columnId === "teamId") {
return convertFacetedValuesToMap(
(teams || []).map((team) => ({
label: team.name,
value: team.id,
}))
);
} else if (columnId === "userId") {
return convertFacetedValuesToMap(
(members || [])
.map((member) => ({
label: member.name,
value: member.id,
}))
.filter((option): option is { label: string; value: number } => Boolean(option.label))
);
}
return new Map<FacetedValue, number>();
},
[eventTypes, teams, members]
);
}
@@ -0,0 +1,39 @@
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { useCallback, useEffect, useRef } from "react";
// Dynamically adjusts DataTable height on mobile to prevent nested scrolling
// and ensure the table fits within the viewport without overflowing (hacky)
export function useProperHeightForMobile(ref: React.RefObject<HTMLDivElement>) {
const lastOffsetY = useRef<number>();
const lastWindowHeight = useRef<number>();
const BOTTOM_NAV_HEIGHT = 64;
const BUFFER = 32;
const updateHeight = useCallback(
debounce(() => {
if (!ref.current || window.innerWidth >= 640) return;
const rect = ref.current.getBoundingClientRect();
if (rect.top !== lastOffsetY.current || window.innerHeight !== lastWindowHeight.current) {
lastOffsetY.current = rect.top;
lastWindowHeight.current = window.innerHeight;
const height = window.innerHeight - lastOffsetY.current - BOTTOM_NAV_HEIGHT - BUFFER;
ref.current.style.height = `${height}px`;
}
}, 200),
[ref.current]
);
useEffect(() => {
const handleResize = () => {
updateHeight();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [updateHeight]);
updateHeight();
}
@@ -7,18 +7,19 @@ import {
getSortedRowModel,
createColumnHelper,
} from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { debounce } from "lodash";
import { useMemo, useState, useRef, useEffect, useCallback } from "react";
import type { z } from "zod";
import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
import dayjs from "@calcom/dayjs";
import { FilterToggle } from "@calcom/features/bookings/components/FilterToggle";
import { FiltersContainer } from "@calcom/features/bookings/components/FiltersContainer";
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import { DataTableProvider, DataTableWrapper } from "@calcom/features/data-table";
import {
DataTableProvider,
DataTableWrapper,
DataTableFilters,
ColumnFilterType,
useFilterValue,
ZMultiSelectFilterValue,
ZDateRangeFilterValue,
} from "@calcom/features/data-table";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
@@ -30,9 +31,11 @@ import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
import SkeletonLoader from "@components/booking/SkeletonLoader";
import { useFacetedUniqueValues } from "~/bookings/hooks/useFacetedUniqueValues";
import { useProperHeightForMobile } from "~/bookings/hooks/useProperHeightForMobile";
import type { validStatuses } from "~/bookings/lib/validStatuses";
type BookingListingStatus = z.infer<NonNullable<typeof filterQuerySchema>>["status"];
type BookingListingStatus = (typeof validStatuses)[number];
type BookingOutput = RouterOutputs["viewer"]["bookings"]["get"]["bookings"][0];
type RecurringInfo = {
@@ -70,7 +73,7 @@ const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
},
];
const descriptionByStatus: Record<NonNullable<BookingListingStatus>, string> = {
const descriptionByStatus: Record<BookingListingStatus, string> = {
upcoming: "upcoming_bookings",
recurring: "recurring_bookings",
past: "past_bookings",
@@ -102,24 +105,29 @@ type RowData =
};
function BookingsContent({ status }: BookingsProps) {
const { data: filterQuery } = useFilterQuery();
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const tableContainerRef = useRef<HTMLDivElement>(null);
useProperHeightForMobile(tableContainerRef);
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const userIds = useFilterValue("userId", ZMultiSelectFilterValue)?.data as number[] | undefined;
const dateRange = useFilterValue("dateRange", ZDateRangeFilterValue)?.data;
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 10,
filters: {
...filterQuery,
status: filterQuery.status ?? status,
status,
eventTypeIds,
teamIds,
userIds,
afterStartDate: dateRange?.startDate ?? undefined,
beforeEndDate: dateRange?.endDate ?? undefined,
},
},
{
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
@@ -128,8 +136,63 @@ function BookingsContent({ status }: BookingsProps) {
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,
filterFn: () => true,
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,
filterFn: () => true,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
id: "userId",
header: t("people"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
filterFn: () => true,
meta: {
filter: {
type: ColumnFilterType.MULTI_SELECT,
},
},
}),
columnHelper.accessor((row) => row, {
id: "dateRange",
header: t("date_range"),
enableColumnFilter: true,
enableSorting: false,
cell: () => null,
filterFn: () => true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
range: status === "past" ? "past" : "custom",
},
},
},
}),
columnHelper.display({
id: "custom-view",
id: "customView",
cell: (props) => {
if (props.row.original.type === "data") {
const { booking, recurringInfo, isToday } = props.row.original;
@@ -164,7 +227,7 @@ function BookingsContent({ status }: BookingsProps) {
},
}),
];
}, [user, status]);
}, [user, status, t]);
const isEmpty = useMemo(() => !query.data?.pages[0]?.bookings.length, [query.data]);
@@ -241,12 +304,23 @@ function BookingsContent({ status }: BookingsProps) {
return merged;
}, [bookingsToday, flatData, status]);
const getFacetedUniqueValues = useFacetedUniqueValues();
const table = useReactTable<RowData>({
data: finalData,
columns,
initialState: {
columnVisibility: {
eventTypeId: false,
teamId: false,
userId: false,
dateRange: false,
},
},
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues,
});
return (
@@ -258,16 +332,13 @@ function BookingsContent({ status }: BookingsProps) {
name: t(tab.name),
}))}
/>
<FilterToggle setIsFiltersVisible={setIsFiltersVisible} />
</div>
<FiltersContainer isFiltersVisible={isFiltersVisible} />
<main className="w-full">
<div className="flex w-full flex-col">
{query.status === "error" && (
<Alert severity="error" title={t("something_went_wrong")} message={query.error.message} />
)}
{(query.status === "pending" || query.isPaused) && <SkeletonLoader />}
{query.status === "success" && !isEmpty && (
{query.status !== "error" && (
<>
{!!bookingsToday.length && status === "upcoming" && (
<WipeMyCalActionButton bookingStatus={status} bookingsEmpty={isEmpty} />
@@ -278,64 +349,36 @@ function BookingsContent({ status }: BookingsProps) {
testId={`${status}-bookings`}
bodyTestId="bookings"
hideHeader={true}
isPending={query.isFetching && !flatData}
isPending={query.isPending}
hasNextPage={query.hasNextPage}
fetchNextPage={query.fetchNextPage}
isFetching={query.isFetching}
variant="compact"
ToolbarLeft={
<>
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ActiveFilters table={table} />
<DataTableFilters.ClearFiltersButton />
</>
}
LoaderView={<SkeletonLoader />}
EmptyView={
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon="calendar"
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
}
/>
</>
)}
{query.status === "success" && isEmpty && (
<div className="flex items-center justify-center pt-2 xl:pt-0">
<EmptyScreen
Icon="calendar"
headline={t("no_status_bookings_yet", { status: t(status).toLowerCase() })}
description={t("no_status_bookings_yet_description", {
status: t(status).toLowerCase(),
description: t(descriptionByStatus[status]),
})}
/>
</div>
)}
</div>
</main>
</div>
);
}
// Dynamically adjusts DataTable height on mobile to prevent nested scrolling
// and ensure the table fits within the viewport without overflowing (hacky)
function useProperHeightForMobile(ref: React.RefObject<HTMLDivElement>) {
const lastOffsetY = useRef<number>();
const lastWindowHeight = useRef<number>();
const BOTTOM_NAV_HEIGHT = 64;
const BUFFER = 32;
const updateHeight = useCallback(
debounce(() => {
if (!ref.current || window.innerWidth >= 640) return;
const rect = ref.current.getBoundingClientRect();
if (rect.top !== lastOffsetY.current || window.innerHeight !== lastWindowHeight.current) {
lastOffsetY.current = rect.top;
lastWindowHeight.current = window.innerHeight;
const height = window.innerHeight - lastOffsetY.current - BOTTOM_NAV_HEIGHT - BUFFER;
ref.current.style.height = `${height}px`;
}
}, 200),
[ref.current]
);
useEffect(() => {
const handleResize = () => {
updateHeight();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [updateHeight]);
updateHeight();
}
+50 -5
View File
@@ -65,6 +65,25 @@ test.describe("Bookings", () => {
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
).toBeVisible();
});
test("Cannot choose date range presets", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
await page.goto(`/bookings/upcoming`);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-m"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-y"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-t"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeHidden();
await expect(page.locator('[data-testid="date-range-calendar"]')).toBeVisible();
});
});
test.describe("Past bookings", () => {
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
@@ -209,6 +228,25 @@ test.describe("Bookings", () => {
// Close webhook receiver
webhookReceiver.close();
});
test("Can choose date range presets", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
await firstUser.apiLogin();
await page.goto(`/bookings/past`);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await page.locator('[data-testid="filter-popover-trigger-dateRange"]').click();
await expect(page.locator('[data-testid="date-range-options-c"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-w"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-m"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-y"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-t"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-options-tdy"]')).toBeVisible();
await expect(page.locator('[data-testid="date-range-calendar"]')).toBeHidden();
});
});
test("People filter includes bookings where filtered person is attendee", async ({
@@ -301,17 +339,24 @@ test.describe("Bookings", () => {
//admin login
//Select 'ThirdUser' in people filter
await firstUser.apiLogin();
await Promise.all([
page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())),
page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url())),
page.goto(`/bookings/upcoming?status=upcoming&userIds=${thirdUser.id}`),
]);
await page.goto(`/bookings/upcoming`);
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-userId"]').click();
await page.locator('[data-testid="filter-popover-trigger-userId"]').click();
await page
.locator(`[data-testid="multi-select-options-userId"] [role="option"]:has-text("${thirdUser.name}")`)
.click();
await page.waitForResponse((response) => /\/api\/trpc\/bookings\/get.*/.test(response.url()));
//expect only 3 bookings (out of 4 total) to be shown in list.
//where ThirdUser is either organizer or attendee
const upcomingBookingsTable = page.locator('[data-testid="upcoming-bookings"]');
const bookingListItems = upcomingBookingsTable.locator('[data-testid="booking-item"]');
const bookingListCount = await bookingListItems.count();
expect(bookingListCount).toBe(3);
//verify with the booking titles
@@ -265,7 +265,6 @@ function DataTableBody<TData>({
className={classNames(onRowMouseclick && "hover:cursor-pointer", "group")}>
{row.getVisibleCells().map((cell) => {
const column = cell.column;
const meta = column?.columnDef.meta;
return (
<TableCell
key={cell.id}
@@ -5,12 +5,12 @@ import { useEffect, useRef } from "react";
import {
DataTable,
DataTableToolbar,
DataTablePagination,
useFetchMoreOnBottomReached,
useDataTable,
useColumnFilters,
} from "@calcom/features/data-table";
import { classNames } from "@calcom/lib";
export type DataTableWrapperProps<TData, TValue> = {
testId?: string;
@@ -25,6 +25,8 @@ export type DataTableWrapperProps<TData, TValue> = {
totalDBRowCount?: number;
ToolbarLeft?: React.ReactNode;
ToolbarRight?: React.ReactNode;
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
className?: string;
containerClassName?: string;
children?: React.ReactNode;
@@ -44,6 +46,8 @@ export function DataTableWrapper<TData, TValue>({
hideHeader,
ToolbarLeft,
ToolbarRight,
EmptyView,
LoaderView,
className,
containerClassName,
children,
@@ -79,21 +83,17 @@ export function DataTableWrapper<TData, TValue>({
}));
}, [table, sorting, columnFilters, columnVisibility]);
let view: "loader" | "empty" | "table" = "table";
if (isPending && LoaderView) {
view = "loader";
} else if (table.getRowCount() === 0 && EmptyView) {
view = "empty";
}
return (
<DataTable
testId={testId}
bodyTestId={bodyTestId}
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
hideHeader={hideHeader}
variant={variant}
className={className}
containerClassName={containerClassName}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<>
{(ToolbarLeft || ToolbarRight || children) && (
<DataTableToolbar.Root>
<div className={classNames("grid w-full items-center gap-2 py-4", className)}>
<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">{ToolbarLeft}</div>
@@ -102,14 +102,30 @@ export function DataTableWrapper<TData, TValue>({
</div>
{children}
</DataTableToolbar.Root>
)}
{totalDBRowCount && (
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<DataTablePagination table={table} totalDbDataCount={totalDBRowCount} />
</div>
)}
</DataTable>
{view === "loader" && LoaderView}
{view === "empty" && EmptyView}
{view === "table" && (
<DataTable
testId={testId}
bodyTestId={bodyTestId}
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
hideHeader={hideHeader}
variant={variant}
className={className}
containerClassName={containerClassName}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
{totalDBRowCount && (
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<DataTablePagination table={table} totalDbDataCount={totalDBRowCount} />
</div>
)}
</DataTable>
)}
</>
);
}
@@ -5,6 +5,8 @@ import { type Table } from "@tanstack/react-table";
import { Fragment } from "react";
import { useDataTable, useFilterableColumns } from "../../hooks";
import { ColumnFilterType } from "../../lib/types";
import { DateRangeFilter } from "./DateRangeFilter";
import { FilterPopover } from "./FilterPopover";
// Add the new ActiveFilters component
@@ -21,7 +23,19 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
{activeFilters.map((filter) => {
const column = filterableColumns.find((col) => col.id === filter.f);
if (!column) return null;
return <FilterPopover key={column.id} column={column} />;
if (column.type === ColumnFilterType.DATE_RANGE) {
return (
<DateRangeFilter
key={column.id}
column={column}
options={column.dateRangeOptions}
showClearButton
/>
);
} else {
return <FilterPopover key={column.id} column={column} />;
}
})}
</>
);
@@ -62,7 +62,10 @@ function AddFilterButtonComponent<TData>(
<CommandList>
<CommandEmpty>{t("no_columns_found")}</CommandEmpty>
{filterableColumns.map((column) => {
const isVisible = table.getColumn(column.id)?.getIsVisible();
const showHiddenIndicator =
!table.getColumn(column.id)?.getIsVisible() &&
table.initialState.columnVisibility?.[column.id] !== false;
if (activeFilters?.some((filter) => filter.f === column.id)) return null;
return (
<CommandItem
@@ -71,7 +74,7 @@ function AddFilterButtonComponent<TData>(
className="flex items-center justify-between px-4 py-2"
data-testid={`add-filter-item-${column.id}`}>
<span>{startCase(column.title)}</span>
{!isVisible && <Icon name="eye-off" className="h-4 w-4 opacity-50" />}
{showHiddenIndicator && <Icon name="eye-off" className="h-4 w-4 opacity-50" />}
</CommandItem>
);
})}
@@ -1,6 +1,6 @@
import { format } from "date-fns";
import type { Dayjs } from "dayjs";
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { classNames } from "@calcom/lib";
@@ -15,6 +15,9 @@ import {
Command,
CommandList,
CommandItem,
CommandGroup,
CommandSeparator,
buttonClasses,
} from "@calcom/ui";
import { useDataTable, useFilterValue } from "../../hooks";
@@ -27,11 +30,13 @@ import {
getDefaultEndDate,
type PresetOption,
} from "../../lib/dateRange";
import type { FilterableColumn } from "../../lib/types";
import type { FilterableColumn, DateRangeFilterOptions } from "../../lib/types";
import { ZDateRangeFilterValue, ColumnFilterType } from "../../lib/types";
type DateRangeFilterProps = {
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
options?: DateRangeFilterOptions;
showClearButton?: boolean;
};
const getDateRangeFromPreset = (val: string | null) => {
@@ -72,44 +77,60 @@ const getDateRangeFromPreset = (val: string | null) => {
return { startDate, endDate, preset };
};
export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => {
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter } = useDataTable();
const { updateFilter, removeFilter } = useDataTable();
const range = options?.range ?? "past";
const forceCustom = range === "custom";
const forcePast = range === "past";
const { t } = useLocale();
const currentDate = dayjs();
const [startDate, setStartDate] = useState<Dayjs>(
filterValue?.data.startDate ? dayjs(filterValue.data.startDate) : getDefaultStartDate()
const [startDate, setStartDate] = useState<Dayjs | undefined>(
filterValue?.data.startDate ? dayjs(filterValue.data.startDate) : undefined
);
const [endDate, setEndDate] = useState<Dayjs | undefined>(
filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : getDefaultEndDate()
filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : undefined
);
const [selectedPreset, setSelectedPreset] = useState<PresetOption>(
forceCustom
? CUSTOM_PRESET
: filterValue?.data.preset
? PRESET_OPTIONS.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET
: DEFAULT_PRESET
);
const [selectedPreset, setSelectedPreset] = useState<PresetOption>(DEFAULT_PRESET);
const updateValues = ({
preset,
startDate,
endDate,
}: {
preset: PresetOption;
startDate: Dayjs;
endDate?: Dayjs;
}) => {
setSelectedPreset(preset);
setStartDate(startDate);
setEndDate(endDate);
const updateValues = useCallback(
({ preset, startDate, endDate }: { preset: PresetOption; startDate?: Dayjs; endDate?: Dayjs }) => {
setSelectedPreset(preset);
setStartDate(startDate);
setEndDate(endDate);
if (startDate && endDate) {
updateFilter(column.id, {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toDate().toISOString(),
endDate: endDate.toDate().toISOString(),
preset: preset.value,
},
if (startDate && endDate) {
updateFilter(column.id, {
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toDate().toISOString(),
endDate: endDate.toDate().toISOString(),
preset: preset.value,
},
});
}
},
[column.id]
);
useEffect(() => {
// initially apply the default value
// if the query param is not set yet
if (!filterValue && !forceCustom) {
updateValues({
preset: DEFAULT_PRESET,
startDate: getDefaultStartDate(),
endDate: getDefaultEndDate(),
});
}
};
}, [filterValue, forceCustom, updateValues]);
const updateDateRangeFromPreset = (val: string | null) => {
if (val === CUSTOM_PRESET_VALUE) {
@@ -144,19 +165,27 @@ export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
const isCustomPreset = selectedPreset.value === CUSTOM_PRESET_VALUE;
let customButtonLabel = t("date_range");
if (startDate && endDate) {
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ${format(
endDate.toDate(),
"LLL dd, y"
)}`;
} else if (startDate) {
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
}
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" className="items-center capitalize" EndIcon="chevron-down">
<Button
color="secondary"
className="items-center capitalize"
StartIcon="calendar-range"
EndIcon="chevron-down"
data-testid={`filter-popover-trigger-${column.id}`}>
{!isCustomPreset && <span>{t(selectedPreset.labelKey, selectedPreset.i18nOptions)}</span>}
{isCustomPreset &&
(endDate ? (
<span>
{format(startDate.toDate(), "LLL dd, y")} - {format(endDate.toDate(), "LLL dd, y")}
</span>
) : (
<span>{format(startDate.toDate(), "LLL dd, y")} - End</span>
))}
{isCustomPreset && <span>{customButtonLabel}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-fit p-0" align="end">
@@ -164,35 +193,66 @@ export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
<div className="border-subtle border-r">
<DateRangePicker
dates={{
startDate: startDate.toDate(),
startDate: startDate?.toDate(),
endDate: endDate?.toDate(),
}}
minDate={currentDate.subtract(2, "year").toDate()}
maxDate={currentDate.toDate()}
data-testid="date-range-calendar"
minDate={forcePast ? currentDate.subtract(2, "year").toDate() : null}
maxDate={forcePast ? currentDate.toDate() : undefined}
disabled={false}
onDatesChange={updateDateRangeFromPicker}
withoutPopover={true}
/>
{forceCustom && (
<div className="border-subtle border-t px-2 py-3">
<Button
color="secondary"
className="w-full justify-center"
onClick={() => removeFilter(column.id)}>
{t("clear")}
</Button>
</div>
)}
</div>
)}
<Command className={classNames("w-40", isCustomPreset && "rounded-b-none")}>
<CommandList>
{PRESET_OPTIONS.map((option) => (
<CommandItem
key={option.value}
className={classNames(
"cursor-pointer justify-between px-3 py-2",
selectedPreset.value === option.value && "bg-emphasis"
)}
onSelect={() => {
updateDateRangeFromPreset(option.value);
}}>
<span className="capitalize">{t(option.labelKey, option.i18nOptions)}</span>
{selectedPreset.value === option.value && <Icon name="check" />}
</CommandItem>
))}
</CommandList>
</Command>
{!forceCustom && (
<Command className={classNames("w-40", isCustomPreset && "rounded-b-none")}>
<CommandList>
{PRESET_OPTIONS.map((option) => (
<CommandItem
key={option.value}
data-testid={`date-range-options-${option.value}`}
className={classNames(
"cursor-pointer justify-between px-3 py-2",
selectedPreset.value === option.value && "bg-emphasis"
)}
onSelect={() => {
updateDateRangeFromPreset(option.value);
}}>
<span className="capitalize">{t(option.labelKey, option.i18nOptions)}</span>
{selectedPreset.value === option.value && <Icon name="check" />}
</CommandItem>
))}
</CommandList>
{showClearButton && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => {
removeFilter(column.id);
}}
className={classNames(
"w-full justify-center text-center",
buttonClasses({ color: "secondary" })
)}>
{t("clear")}
</CommandItem>
</CommandGroup>
</>
)}
</Command>
)}
</PopoverContent>
</Popover>
);
@@ -2,7 +2,6 @@
import type { FilterableColumn } from "../../lib/types";
import { ColumnFilterType } from "../../lib/types";
import { DateRangeFilter } from "./DateRangeFilter";
import { MultiSelectFilterOptions } from "./MultiSelectFilterOptions";
import { NumberFilterOptions } from "./NumberFilterOptions";
import { SingleSelectFilterOptions } from "./SingleSelectFilterOptions";
@@ -21,8 +20,6 @@ export function FilterOptions({ column }: FilterOptionsProps) {
return <SingleSelectFilterOptions column={column} />;
} else if (column.type === ColumnFilterType.NUMBER) {
return <NumberFilterOptions column={column} />;
} else if (column.type === ColumnFilterType.DATE_RANGE) {
return <DateRangeFilter column={column} />;
} else {
return null;
}
@@ -32,31 +32,42 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{column.options.map((option) => {
{column.options.map((option, index) => {
if (!option) return null;
const { label: optionLabel, value: optionValue } =
typeof option === "string" ? { label: option, value: option } : option;
const {
label: optionLabel,
value: optionValue,
section,
} = typeof option === "string" ? { label: option, value: option, section: undefined } : option;
return (
<CommandItem
key={optionValue}
onSelect={() => {
const newFilterValue = filterValue?.data.includes(optionValue)
? filterValue?.data.filter((value) => value !== optionValue)
: [...(filterValue?.data || []), optionValue];
updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data.includes(optionValue) ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data.includes(optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
<>
{section && index !== 0 && <hr className="border-subtle my-1" />}
{section && (
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{section}
</div>
)}
<CommandItem
key={optionValue}
onSelect={() => {
const newFilterValue = filterValue?.data.includes(optionValue)
? filterValue?.data.filter((value) => value !== optionValue)
: [...(filterValue?.data || []), optionValue];
updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data.includes(optionValue) ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data.includes(optionValue) && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
</>
);
})}
</CommandList>
@@ -32,28 +32,39 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP
<CommandInput placeholder={t("search")} />
<CommandList>
<CommandEmpty>{t("no_options_found")}</CommandEmpty>
{column.options.map((option) => {
{column.options.map((option, index) => {
if (!option) return null;
const { label: optionLabel, value: optionValue } =
typeof option === "string" ? { label: option, value: option } : option;
const {
label: optionLabel,
value: optionValue,
section,
} = typeof option === "string" ? { label: option, value: option, section: undefined } : option;
return (
<CommandItem
key={optionValue}
onSelect={() => {
updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data === optionValue ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data === optionValue && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
<>
{section && index !== 0 && <hr className="border-subtle my-1" />}
{section && (
<div className="text-subtle px-4 py-2 text-xs font-medium uppercase leading-none">
{section}
</div>
)}
<CommandItem
key={optionValue}
onSelect={() => {
updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue });
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
filterValue?.data === optionValue ? "bg-primary" : "opacity-50"
)}>
{filterValue?.data === optionValue && (
<Icon name="check" className="text-primary-foreground h-4 w-4" />
)}
</div>
{optionLabel}
</CommandItem>
</>
);
})}
</CommandList>
+17 -5
View File
@@ -116,11 +116,21 @@ export const ZFilterValue = z.union([
ZDateRangeFilterValue,
]);
export type ColumnFilterMeta = {
type?: ColumnFilterType;
icon?: IconName;
export type DateRangeFilterOptions = {
range: "past" | "custom";
};
export type ColumnFilterMeta =
| {
type: ColumnFilterType.DATE_RANGE;
icon?: IconName;
dateRangeOptions: DateRangeFilterOptions;
}
| {
type?: Exclude<ColumnFilterType, ColumnFilterType.DATE_RANGE>;
icon?: IconName;
};
export type FilterableColumn = {
id: string;
title: string;
@@ -128,11 +138,11 @@ export type FilterableColumn = {
} & (
| {
type: ColumnFilterType.SINGLE_SELECT;
options: Array<{ label: string; value: string | number }>;
options: FacetedValue[];
}
| {
type: ColumnFilterType.MULTI_SELECT;
options: Array<{ label: string; value: string | number }>;
options: FacetedValue[];
}
| {
type: ColumnFilterType.TEXT;
@@ -142,6 +152,7 @@ export type FilterableColumn = {
}
| {
type: ColumnFilterType.DATE_RANGE;
dateRangeOptions?: DateRangeFilterOptions;
}
);
@@ -186,4 +197,5 @@ export const ZColumnVisibility = z.record(z.string(), z.boolean());
export type FacetedValue = {
label: string;
value: string | number;
section?: string;
};
+3 -3
View File
@@ -151,7 +151,7 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) =>
export const convertFacetedValuesToMap = (array: FacetedValue[]) => {
return new Map<FacetedValue, number>(
array.map((option) => [{ label: option.label, value: option.value }, 1])
array.map((option) => [{ label: option.label, value: option.value, section: option.section }, 1])
);
};
@@ -161,8 +161,8 @@ export const convertMapToFacetedValues = (map: Map<FacetedValue, number> | undef
}
return Array.from(map.keys()).map((option) => {
if (typeof option === "string") {
return { label: option, value: option };
return { label: option, value: option, section: undefined };
}
return { label: option.label as string, value: option.value as string | number };
return { label: option.label as string, value: option.value as string | number, section: option.section };
});
};
@@ -56,7 +56,7 @@ export function RoutingFormResponsesTable() {
const { sorting } = useDataTable();
const { data, fetchNextPage, isFetching, hasNextPage, isLoading } =
const { data, fetchNextPage, isFetching, isPending, hasNextPage, isLoading } =
trpc.viewer.insights.routingFormResponses.useInfiniteQuery(
{
teamId,
@@ -113,7 +113,7 @@ export function RoutingFormResponsesTable() {
<div className="flex-1">
<DataTableWrapper
table={table}
isPending={isFetching && !data}
isPending={isPending}
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
@@ -123,6 +123,13 @@ export const viewerTeamsRouter = router({
const handler = await importHandler(namespaced("listMembers"), () => import("./listMembers.handler"));
return handler(opts);
}),
listSimpleMembers: authedProcedure.query(async (opts) => {
const handler = await importHandler(
namespaced("listSimpleMembers"),
() => import("./listSimpleMembers.handler")
);
return handler(opts);
}),
legacyListMembers: authedProcedure.input(ZLegacyListMembersInputSchema).query(async (opts) => {
const handler = await importHandler(
namespaced("legacyListMembers"),
@@ -0,0 +1,70 @@
/**
* Simplified version of legacyListMembers.handler.ts that returns basic member info.
* Used for filtering people on /bookings.
*/
import type { PrismaClient } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
type ListSimpleMembersOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
prisma: PrismaClient;
};
};
export const listSimpleMembers = async ({ ctx }: ListSimpleMembersOptions) => {
const { prisma } = ctx;
const { isOrgAdmin } = ctx.user.organization;
const hasPermsToView = !ctx.user.organization.isPrivate || isOrgAdmin;
if (!hasPermsToView) {
return [];
}
// query all teams the user is a member of
const teamsToQuery = (
await prisma.membership.findMany({
where: {
userId: ctx.user.id,
accepted: true,
},
select: { teamId: true },
})
).map((membership) => membership.teamId);
if (!teamsToQuery.length) {
return [];
}
// Fetch unique users through memberships
const members = (
await prisma.membership.findMany({
where: {
accepted: true,
teamId: { in: teamsToQuery },
},
select: {
id: true,
accepted: true,
user: {
select: {
id: true,
name: true,
username: true,
avatarUrl: true,
email: true,
},
},
},
distinct: ["userId"],
orderBy: [
{ userId: "asc" }, // First order by userId to ensure consistent ordering
{ id: "asc" }, // Then by id as secondary sort
],
})
).map((membership) => membership.user);
return members;
};
export default listSimpleMembers;
@@ -16,6 +16,7 @@ type DatePickerWithRangeProps = {
minDate?: Date | null;
maxDate?: Date;
withoutPopover?: boolean;
"data-testid"?: string;
};
export function DatePickerWithRange({
@@ -26,6 +27,7 @@ export function DatePickerWithRange({
onDatesChange,
disabled,
withoutPopover,
"data-testid": testId,
}: React.HTMLAttributes<HTMLDivElement> & DatePickerWithRangeProps) {
function handleDayClick(date: Date) {
if (dates?.endDate) {
@@ -50,6 +52,7 @@ export function DatePickerWithRange({
onDayClick={(day) => handleDayClick(day)}
numberOfMonths={1}
disabled={disabled}
data-testid={testId}
/>
);