diff --git a/apps/web/modules/bookings/hooks/useEventTypes.ts b/apps/web/modules/bookings/hooks/useEventTypes.ts new file mode 100644 index 0000000000..4b7c47e9bd --- /dev/null +++ b/apps/web/modules/bookings/hooks/useEventTypes.ts @@ -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); + + // 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]); +} diff --git a/apps/web/modules/bookings/hooks/useFacetedUniqueValues.ts b/apps/web/modules/bookings/hooks/useFacetedUniqueValues.ts new file mode 100644 index 0000000000..c490ec1521 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useFacetedUniqueValues.ts @@ -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, columnId: string) => (): Map => { + 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(); + }, + [eventTypes, teams, members] + ); +} diff --git a/apps/web/modules/bookings/hooks/useProperHeightForMobile.ts b/apps/web/modules/bookings/hooks/useProperHeightForMobile.ts new file mode 100644 index 0000000000..c302847bd8 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useProperHeightForMobile.ts @@ -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) { + const lastOffsetY = useRef(); + const lastWindowHeight = useRef(); + 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(); +} diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index d8b730aaff..8db8d40f1c 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -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>["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, string> = { +const descriptionByStatus: Record = { 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(false); const tableContainerRef = useRef(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(); 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({ 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), }))} /> - -
{query.status === "error" && ( )} - {(query.status === "pending" || query.isPaused) && } - {query.status === "success" && !isEmpty && ( + {query.status !== "error" && ( <> {!!bookingsToday.length && status === "upcoming" && ( @@ -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={ + <> + + + + + } + LoaderView={} + EmptyView={ +
+ +
+ } /> )} - {query.status === "success" && isEmpty && ( -
- -
- )}
); } - -// 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) { - const lastOffsetY = useRef(); - const lastWindowHeight = useRef(); - 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(); -} diff --git a/apps/web/playwright/bookings-list.e2e.ts b/apps/web/playwright/bookings-list.e2e.ts index 9ed6838b6d..4c132ff30c 100644 --- a/apps/web/playwright/bookings-list.e2e.ts +++ b/apps/web/playwright/bookings-list.e2e.ts @@ -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 diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 54f9fcdcc7..ef22c50506 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -265,7 +265,6 @@ function DataTableBody({ className={classNames(onRowMouseclick && "hover:cursor-pointer", "group")}> {row.getVisibleCells().map((cell) => { const column = cell.column; - const meta = column?.columnDef.meta; return ( = { testId?: string; @@ -25,6 +25,8 @@ export type DataTableWrapperProps = { 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({ hideHeader, ToolbarLeft, ToolbarRight, + EmptyView, + LoaderView, className, containerClassName, children, @@ -79,21 +83,17 @@ export function DataTableWrapper({ })); }, [table, sorting, columnFilters, columnVisibility]); + let view: "loader" | "empty" | "table" = "table"; + if (isPending && LoaderView) { + view = "loader"; + } else if (table.getRowCount() === 0 && EmptyView) { + view = "empty"; + } + return ( - fetchMoreOnBottomReached(e.target as HTMLDivElement)}> + <> {(ToolbarLeft || ToolbarRight || children) && ( - +
{ToolbarLeft}
@@ -102,14 +102,30 @@ export function DataTableWrapper({
{children} - - )} - - {totalDBRowCount && ( -
-
)} - + {view === "loader" && LoaderView} + {view === "empty" && EmptyView} + {view === "table" && ( + fetchMoreOnBottomReached(e.target as HTMLDivElement)}> + {totalDBRowCount && ( +
+ +
+ )} +
+ )} + ); } diff --git a/packages/features/data-table/components/filters/ActiveFilters.tsx b/packages/features/data-table/components/filters/ActiveFilters.tsx index 988618f04a..cb7781e3c4 100644 --- a/packages/features/data-table/components/filters/ActiveFilters.tsx +++ b/packages/features/data-table/components/filters/ActiveFilters.tsx @@ -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({ table }: ActiveFiltersProps) { {activeFilters.map((filter) => { const column = filterableColumns.find((col) => col.id === filter.f); if (!column) return null; - return ; + + if (column.type === ColumnFilterType.DATE_RANGE) { + return ( + + ); + } else { + return ; + } })} ); diff --git a/packages/features/data-table/components/filters/AddFilterButton.tsx b/packages/features/data-table/components/filters/AddFilterButton.tsx index 065d42792e..f9c7a00efe 100644 --- a/packages/features/data-table/components/filters/AddFilterButton.tsx +++ b/packages/features/data-table/components/filters/AddFilterButton.tsx @@ -62,7 +62,10 @@ function AddFilterButtonComponent( {t("no_columns_found")} {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 ( ( className="flex items-center justify-between px-4 py-2" data-testid={`add-filter-item-${column.id}`}> {startCase(column.title)} - {!isVisible && } + {showHiddenIndicator && } ); })} diff --git a/packages/features/data-table/components/filters/DateRangeFilter.tsx b/packages/features/data-table/components/filters/DateRangeFilter.tsx index 25919845f1..beffc2305d 100644 --- a/packages/features/data-table/components/filters/DateRangeFilter.tsx +++ b/packages/features/data-table/components/filters/DateRangeFilter.tsx @@ -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; + 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( - filterValue?.data.startDate ? dayjs(filterValue.data.startDate) : getDefaultStartDate() + const [startDate, setStartDate] = useState( + filterValue?.data.startDate ? dayjs(filterValue.data.startDate) : undefined ); const [endDate, setEndDate] = useState( - filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : getDefaultEndDate() + filterValue?.data.endDate ? dayjs(filterValue.data.endDate) : undefined + ); + const [selectedPreset, setSelectedPreset] = useState( + forceCustom + ? CUSTOM_PRESET + : filterValue?.data.preset + ? PRESET_OPTIONS.find((o) => o.value === filterValue.data.preset) ?? DEFAULT_PRESET + : DEFAULT_PRESET ); - const [selectedPreset, setSelectedPreset] = useState(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 ( - @@ -164,35 +193,66 @@ export const DateRangeFilter = ({ column }: DateRangeFilterProps) => {
+ {forceCustom && ( +
+ +
+ )}
)} - - - {PRESET_OPTIONS.map((option) => ( - { - updateDateRangeFromPreset(option.value); - }}> - {t(option.labelKey, option.i18nOptions)} - {selectedPreset.value === option.value && } - - ))} - - + {!forceCustom && ( + + + {PRESET_OPTIONS.map((option) => ( + { + updateDateRangeFromPreset(option.value); + }}> + {t(option.labelKey, option.i18nOptions)} + {selectedPreset.value === option.value && } + + ))} + + {showClearButton && ( + <> + + + { + removeFilter(column.id); + }} + className={classNames( + "w-full justify-center text-center", + buttonClasses({ color: "secondary" }) + )}> + {t("clear")} + + + + )} + + )}
); diff --git a/packages/features/data-table/components/filters/FilterOptions.tsx b/packages/features/data-table/components/filters/FilterOptions.tsx index f1f6312cee..598b130522 100644 --- a/packages/features/data-table/components/filters/FilterOptions.tsx +++ b/packages/features/data-table/components/filters/FilterOptions.tsx @@ -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 ; } else if (column.type === ColumnFilterType.NUMBER) { return ; - } else if (column.type === ColumnFilterType.DATE_RANGE) { - return ; } else { return null; } diff --git a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx index 8b179d6e6a..4f03bc9875 100644 --- a/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/MultiSelectFilterOptions.tsx @@ -32,31 +32,42 @@ export function MultiSelectFilterOptions({ column }: MultiSelectFilterOptionsPro {t("no_options_found")} - {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 ( - { - const newFilterValue = filterValue?.data.includes(optionValue) - ? filterValue?.data.filter((value) => value !== optionValue) - : [...(filterValue?.data || []), optionValue]; - updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue }); - }}> -
- {filterValue?.data.includes(optionValue) && ( - - )} -
- {optionLabel} -
+ <> + {section && index !== 0 &&
} + {section && ( +
+ {section} +
+ )} + { + const newFilterValue = filterValue?.data.includes(optionValue) + ? filterValue?.data.filter((value) => value !== optionValue) + : [...(filterValue?.data || []), optionValue]; + updateFilter(column.id, { type: ColumnFilterType.MULTI_SELECT, data: newFilterValue }); + }}> +
+ {filterValue?.data.includes(optionValue) && ( + + )} +
+ {optionLabel} +
+ ); })}
diff --git a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx index a8a9e6c4ac..936bc32213 100644 --- a/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx +++ b/packages/features/data-table/components/filters/SingleSelectFilterOptions.tsx @@ -32,28 +32,39 @@ export function SingleSelectFilterOptions({ column }: SingleSelectFilterOptionsP {t("no_options_found")} - {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 ( - { - updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue }); - }}> -
- {filterValue?.data === optionValue && ( - - )} -
- {optionLabel} -
+ <> + {section && index !== 0 &&
} + {section && ( +
+ {section} +
+ )} + { + updateFilter(column.id, { type: ColumnFilterType.SINGLE_SELECT, data: optionValue }); + }}> +
+ {filterValue?.data === optionValue && ( + + )} +
+ {optionLabel} +
+ ); })}
diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 82cf9cb0d7..0994ba279c 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -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; + 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; }; diff --git a/packages/features/data-table/lib/utils.ts b/packages/features/data-table/lib/utils.ts index 2b3cae53bc..e689d64046 100644 --- a/packages/features/data-table/lib/utils.ts +++ b/packages/features/data-table/lib/utils.ts @@ -151,7 +151,7 @@ export const dataTableFilter = (cellValue: unknown, filterValue: FilterValue) => export const convertFacetedValuesToMap = (array: FacetedValue[]) => { return new Map( - 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 | 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 }; }); }; diff --git a/packages/features/insights/components/RoutingFormResponsesTable.tsx b/packages/features/insights/components/RoutingFormResponsesTable.tsx index 21243eb4e4..b647360d45 100644 --- a/packages/features/insights/components/RoutingFormResponsesTable.tsx +++ b/packages/features/insights/components/RoutingFormResponsesTable.tsx @@ -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() {
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"), diff --git a/packages/trpc/server/routers/viewer/teams/listSimpleMembers.handler.ts b/packages/trpc/server/routers/viewer/teams/listSimpleMembers.handler.ts new file mode 100644 index 0000000000..cfa9a5b2b3 --- /dev/null +++ b/packages/trpc/server/routers/viewer/teams/listSimpleMembers.handler.ts @@ -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; + 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; diff --git a/packages/ui/components/form/date-range-picker/DateRangePicker.tsx b/packages/ui/components/form/date-range-picker/DateRangePicker.tsx index fa17538213..b933fd38c6 100644 --- a/packages/ui/components/form/date-range-picker/DateRangePicker.tsx +++ b/packages/ui/components/form/date-range-picker/DateRangePicker.tsx @@ -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 & 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} /> );