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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user