diff --git a/apps/web/modules/bookings/components/BookingListContainer.tsx b/apps/web/modules/bookings/components/BookingListContainer.tsx index 1827588399..38c77c1d79 100644 --- a/apps/web/modules/bookings/components/BookingListContainer.tsx +++ b/apps/web/modules/bookings/components/BookingListContainer.tsx @@ -230,7 +230,7 @@ function BookingListInner({ } export function BookingListContainer(props: BookingListContainerProps) { - const { limit, offset, setPageIndex } = useDataTable(); + const { limit, offset, setPageIndex, isValidatorPending } = useDataTable(); const { eventTypeIds, teamIds, userIds, dateRange, attendeeName, attendeeEmail, bookingUid } = useBookingFilters(); @@ -270,6 +270,7 @@ export function BookingListContainer(props: BookingListContainerProps) { const query = trpc.viewer.bookings.get.useQuery(queryInput, { staleTime: 5 * 60 * 1000, // 5 minutes - data is considered fresh gcTime: 30 * 60 * 1000, // 30 minutes - cache retention time + enabled: !isValidatorPending, // Wait for validator to be ready before fetching }); const bookings = useMemo(() => query.data?.bookings ?? [], [query.data?.bookings]); diff --git a/apps/web/modules/bookings/hooks/useActiveFiltersValidator.test.ts b/apps/web/modules/bookings/hooks/useActiveFiltersValidator.test.ts new file mode 100644 index 0000000000..272dd5e390 --- /dev/null +++ b/apps/web/modules/bookings/hooks/useActiveFiltersValidator.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it } from "vitest"; + +import { ColumnFilterType } from "@calcom/features/data-table"; +import type { ActiveFilters } from "@calcom/features/data-table"; + +import { createActiveFiltersValidator, type AccessibleResources } from "./useActiveFiltersValidator"; + +describe("createActiveFiltersValidator", () => { + const defaultAccessibleResources: AccessibleResources = { + userIds: [1, 2, 3], + eventTypeIds: [10, 20, 30], + teamIds: [100, 200, 300], + }; + + describe("userId filter validation", () => { + it("keeps valid user IDs in multi-select filter", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "userId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [1, 2, 999], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(1); + expect(result[0].f).toBe("userId"); + expect(result[0].v).toEqual({ + type: ColumnFilterType.MULTI_SELECT, + data: [1, 2], + }); + }); + + it("removes filter when all user IDs are invalid in multi-select", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "userId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [999, 888], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(0); + }); + }); + + describe("eventTypeId filter validation", () => { + it("keeps valid event type IDs in multi-select filter", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "eventTypeId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [10, 20, 999], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(1); + expect(result[0].f).toBe("eventTypeId"); + expect(result[0].v).toEqual({ + type: ColumnFilterType.MULTI_SELECT, + data: [10, 20], + }); + }); + + it("removes filter when all event type IDs are invalid in multi-select", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "eventTypeId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [999, 888], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(0); + }); + }); + + describe("teamId filter validation", () => { + it("keeps valid team IDs in multi-select filter", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "teamId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [100, 200, 999], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(1); + expect(result[0].f).toBe("teamId"); + expect(result[0].v).toEqual({ + type: ColumnFilterType.MULTI_SELECT, + data: [100, 200], + }); + }); + + it("removes filter when all team IDs are invalid in multi-select", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "teamId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [999, 888], + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(0); + }); + }); + + describe("other filters", () => { + it("preserves filters that are not userId, eventTypeId, or teamId", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "status", + v: { + type: ColumnFilterType.SINGLE_SELECT, + data: "confirmed", + }, + }, + { + f: "attendeeName", + v: { + type: ColumnFilterType.TEXT, + data: { + operator: "contains", + operand: "John", + }, + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(2); + expect(result[0].f).toBe("status"); + expect(result[1].f).toBe("attendeeName"); + }); + + it("preserves filters without a value", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "userId", + v: undefined, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(1); + expect(result[0].f).toBe("userId"); + expect(result[0].v).toBeUndefined(); + }); + }); + + describe("mixed filters", () => { + it("validates multiple filters correctly", () => { + const validator = createActiveFiltersValidator(defaultAccessibleResources); + const filters: ActiveFilters = [ + { + f: "userId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [1, 999], + }, + }, + { + f: "eventTypeId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [888], + }, + }, + { + f: "teamId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [100, 999], + }, + }, + { + f: "status", + v: { + type: ColumnFilterType.SINGLE_SELECT, + data: "confirmed", + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(3); + expect(result[0].f).toBe("userId"); + expect(result[0].v).toEqual({ + type: ColumnFilterType.MULTI_SELECT, + data: [1], + }); + expect(result[1].f).toBe("teamId"); + expect(result[1].v).toEqual({ + type: ColumnFilterType.MULTI_SELECT, + data: [100], + }); + expect(result[2].f).toBe("status"); + }); + }); + + describe("empty accessible resources", () => { + it("removes all ID-based filters when no resources are accessible", () => { + const validator = createActiveFiltersValidator({ + userIds: [], + eventTypeIds: [], + teamIds: [], + }); + const filters: ActiveFilters = [ + { + f: "userId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [1, 2], + }, + }, + { + f: "eventTypeId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [10], + }, + }, + { + f: "status", + v: { + type: ColumnFilterType.SINGLE_SELECT, + data: "confirmed", + }, + }, + ]; + + const result = validator(filters); + + expect(result).toHaveLength(1); + expect(result[0].f).toBe("status"); + }); + }); +}); diff --git a/apps/web/modules/bookings/hooks/useActiveFiltersValidator.ts b/apps/web/modules/bookings/hooks/useActiveFiltersValidator.ts new file mode 100644 index 0000000000..76c6abbc9d --- /dev/null +++ b/apps/web/modules/bookings/hooks/useActiveFiltersValidator.ts @@ -0,0 +1,105 @@ +import type { ActiveFilter, ActiveFilters, ActiveFiltersValidator } from "@calcom/features/data-table"; +import { ColumnFilterType } from "@calcom/features/data-table"; +import { trpc } from "@calcom/trpc/react"; +import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; +import { useCallback, useMemo } from "react"; + +import { useEventTypes } from "./useEventTypes"; + +interface UseActiveFiltersValidatorOptions { + canReadOthersBookings: boolean; +} + +export interface AccessibleResources { + userIds: number[]; + eventTypeIds: number[]; + teamIds: number[]; +} + +export function createActiveFiltersValidator(accessibleResources: AccessibleResources) { + const { userIds, eventTypeIds, teamIds } = accessibleResources; + + return function validateActiveFilters(filters: ActiveFilters): ActiveFilters { + return filters + .map((filter): ActiveFilter | null => { + if (filter.f === "userId" && filter.v && filter.v.type === ColumnFilterType.MULTI_SELECT) { + const validIds = filter.v.data.filter((id) => userIds.includes(id as number)); + if (validIds.length === 0) { + return null; + } + return { ...filter, v: { ...filter.v, data: validIds } }; + } + + if (filter.f === "eventTypeId" && filter.v && filter.v.type === ColumnFilterType.MULTI_SELECT) { + const validIds = filter.v.data.filter((id) => eventTypeIds.includes(id as number)); + if (validIds.length === 0) { + return null; + } + return { ...filter, v: { ...filter.v, data: validIds } }; + } + + if (filter.f === "teamId" && filter.v && filter.v.type === ColumnFilterType.MULTI_SELECT) { + const validIds = filter.v.data.filter((id) => teamIds.includes(id as number)); + if (validIds.length === 0) { + return null; + } + return { ...filter, v: { ...filter.v, data: validIds } }; + } + + return filter; + }) + .filter((f): f is ActiveFilter => f !== null); + }; +} + +export type ActiveFiltersValidatorState = ActiveFiltersValidator | "loading" | undefined; + +export function useActiveFiltersValidator({ + canReadOthersBookings, +}: UseActiveFiltersValidatorOptions): ActiveFiltersValidatorState { + const eventTypes = useEventTypes(); + const { data: teams } = trpc.viewer.teams.list.useQuery(); + const { data: members } = trpc.viewer.teams.listSimpleMembers.useQuery(undefined, { + enabled: canReadOthersBookings, + }); + const { data: currentUser } = useMeQuery(); + + const accessibleUserIds = useMemo(() => { + if (!canReadOthersBookings) { + return currentUser ? [currentUser.id] : []; + } + return members?.map((m) => m.id) ?? []; + }, [canReadOthersBookings, currentUser, members]); + + const accessibleEventTypeIds = useMemo(() => { + return eventTypes?.map((et) => et.value).filter((v): v is number => typeof v === "number") ?? []; + }, [eventTypes]); + + const accessibleTeamIds = useMemo(() => { + return teams?.map((t) => t.id) ?? []; + }, [teams]); + + const isDataLoaded = useMemo(() => { + if (!canReadOthersBookings) { + return currentUser !== undefined && eventTypes !== undefined && teams !== undefined; + } + return members !== undefined && eventTypes !== undefined && teams !== undefined; + }, [canReadOthersBookings, currentUser, members, eventTypes, teams]); + + const validateActiveFilters = useCallback( + (filters: ActiveFilters): ActiveFilters => { + return createActiveFiltersValidator({ + userIds: accessibleUserIds, + eventTypeIds: accessibleEventTypeIds, + teamIds: accessibleTeamIds, + })(filters); + }, + [accessibleUserIds, accessibleEventTypeIds, accessibleTeamIds] + ); + + if (!isDataLoaded) { + return "loading"; + } + + return validateActiveFilters; +} diff --git a/apps/web/modules/bookings/views/bookings-view.tsx b/apps/web/modules/bookings/views/bookings-view.tsx index 4c7546f1f4..f2102c9417 100644 --- a/apps/web/modules/bookings/views/bookings-view.tsx +++ b/apps/web/modules/bookings/views/bookings-view.tsx @@ -10,6 +10,7 @@ import { usePathname } from "next/navigation"; import { useMemo } from "react"; import { useFeatureOptInBanner } from "../../feature-opt-in/hooks/useFeatureOptInBanner"; import { BookingListContainer } from "../components/BookingListContainer"; +import { useActiveFiltersValidator } from "../hooks/useActiveFiltersValidator"; import { useBookingsView } from "../hooks/useBookingsView"; import type { validStatuses } from "../lib/validStatuses"; @@ -60,9 +61,16 @@ function useSystemSegments(userId?: number) { export default function Bookings(props: BookingsProps) { const pathname = usePathname(); const systemSegments = useSystemSegments(props.userId); + const validateActiveFilters = useActiveFiltersValidator({ + canReadOthersBookings: props.permissions.canReadOthersBookings, + }); if (!pathname) return null; return ( - + ); diff --git a/packages/features/data-table/DataTableProvider.tsx b/packages/features/data-table/DataTableProvider.tsx index e5848ce785..027bea5b79 100644 --- a/packages/features/data-table/DataTableProvider.tsx +++ b/packages/features/data-table/DataTableProvider.tsx @@ -1,36 +1,29 @@ "use client"; import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; -import debounce from "lodash/debounce"; -import isEqual from "lodash/isEqual"; -import { useQueryState } from "nuqs"; -import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react"; +import { createContext, useMemo } from "react"; -import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName"; - -import { useSegmentsNoop } from "./hooks/useSegmentsNoop"; import { - activeFiltersParser, - sortingParser, - columnVisibilityParser, - columnSizingParser, - segmentIdParser, - pageIndexParser, - pageSizeParser, - searchTermParser, - DEFAULT_PAGE_SIZE, -} from "./lib/parsers"; -import { - type FilterValue, - type FilterSegmentOutput, - type SystemFilterSegment, - type CombinedFilterSegment, - type SegmentIdentifier, - type ActiveFilters, - type UseSegments, - SYSTEM_SEGMENT_PREFIX, + DataTableStateProvider, + DataTableSegmentProvider, + DataTableFiltersProvider, + useDataTableState, + useDataTableSegment, + useDataTableFilters, +} from "./contexts"; +import type { ActiveFiltersValidatorState } from "./contexts"; +import type { + FilterValue, + FilterSegmentOutput, + SystemFilterSegment, + CombinedFilterSegment, + SegmentIdentifier, + ActiveFilters, + UseSegments, } from "./lib/types"; -import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils"; + +export type { ActiveFiltersValidatorState }; +export type { ActiveFiltersValidator } from "./contexts"; export type DataTableContextType = { tableIdentifier: string; @@ -70,6 +63,8 @@ export type DataTableContextType = { searchTerm: string; setSearchTerm: (searchTerm: string | null) => void; + isValidatorPending: boolean; + timeZone?: string; }; @@ -85,337 +80,91 @@ interface DataTableProviderProps { timeZone?: string; preferredSegmentId?: SegmentIdentifier | null; systemSegments?: SystemFilterSegment[]; + validateActiveFilters?: ActiveFiltersValidatorState; +} + +function DataTableContextBridge({ children }: { children: React.ReactNode }) { + const state = useDataTableState(); + const segment = useDataTableSegment(); + const filters = useDataTableFilters(); + + const value = useMemo( + () => ({ + tableIdentifier: state.tableIdentifier, + ctaContainerRef: state.ctaContainerRef, + filterToOpen: state.filterToOpen, + + activeFilters: filters.activeFilters, + addFilter: filters.addFilter, + updateFilter: filters.updateFilter, + removeFilter: filters.removeFilter, + clearAll: filters.clearAll, + + sorting: state.sorting, + setSorting: state.setSorting, + + columnVisibility: state.columnVisibility, + setColumnVisibility: state.setColumnVisibility, + + columnSizing: state.columnSizing, + setColumnSizing: state.setColumnSizing, + + pageIndex: filters.pageIndex, + pageSize: filters.pageSize, + setPageIndex: filters.setPageIndex, + setPageSize: filters.setPageSize, + + offset: filters.offset, + limit: filters.limit, + + segments: segment.segments, + selectedSegment: segment.selectedSegment, + segmentId: segment.segmentId, + setSegmentId: segment.setSegmentId, + canSaveSegment: segment.canSaveSegment, + isSegmentEnabled: segment.isSegmentEnabled, + + searchTerm: filters.searchTerm, + setSearchTerm: filters.setSearchTerm, + + isValidatorPending: segment.isValidatorPending, + + timeZone: state.timeZone, + }), + [state, segment, filters] + ); + + return {children}; } export function DataTableProvider({ tableIdentifier, children, - useSegments = useSegmentsNoop, - defaultPageSize = DEFAULT_PAGE_SIZE, - ctaContainerClassName = CTA_CONTAINER_CLASS_NAME, + useSegments, + defaultPageSize, + ctaContainerClassName, segments: providedSegments, timeZone, preferredSegmentId, systemSegments, + validateActiveFilters, }: DataTableProviderProps) { - if (!tableIdentifier.trim()) { - throw new Error("tableIdentifier is required and cannot be empty"); - } - - const filterToOpen = useRef(undefined); - const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); - const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); - const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); - const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); - const [sorting, setSorting] = useQueryState("sorting", sortingParser); - const [columnVisibility, setColumnVisibility] = useQueryState( - "cols", - columnVisibilityParser - ); - const [columnSizing, setColumnSizing] = useQueryState("widths", columnSizingParser); - const initialSegmentId = useMemo( - () => (preferredSegmentId ? String(preferredSegmentId.id) : null), - [preferredSegmentId] - ); - const [segmentId, _setSegmentId] = useQueryState( - "segment", - initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser - ); - const { - segments, - preferredSegmentId: fetchedPreferredSegmentId, - setPreference: setSegmentPreference, - isSegmentEnabled, - isSuccess: isSegmentFetchedSuccessfully, - } = useSegments({ - tableIdentifier, - providedSegments, - systemSegments, - }); - - const findSelectedSegment = useCallback( - (segmentId: string) => { - return segments.find((segment) => { - if ( - segment.type === "system" && - segmentId && - segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) && - segment.id === segmentId - ) { - return true; - } else if (segment.type === "user") { - const segmentIdNumber = parseInt(segmentId, 10); - return segment.id === segmentIdNumber; - } - }); - }, - [segments] - ); - - const segmentIdObject = useMemo(() => { - if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) { - return { - id: segmentId, - type: "system" as const, - }; - } else { - const segmentIdNumber = parseInt(segmentId, 10); - if (!Number.isNaN(segmentIdNumber)) { - return { - id: segmentIdNumber, - type: "user" as const, - }; - } - } - return null; - }, [segmentId]); - - const [selectedSegment, setSelectedSegment] = useState( - findSelectedSegment(segmentId) - ); - - const setSegmentId = useCallback( - (segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => { - if (!segmentId) { - _setSegmentId(null); - setSelectedSegment(undefined); - setSegmentPreference({ - tableIdentifier, - segmentId: null, - }); - return; - } - - const segment = providedSegment || findSelectedSegment(String(segmentId.id)); - if (!segment) { - // If segmentId is invalid (or not found), clear the segmentId from the query params, - // but we still keep all the other states like activeFilters, etc. - // This is useful when someone shares a URL that is inaccessible to someone else. - _setSegmentId(null); - setSelectedSegment(undefined); - setSegmentPreference({ - tableIdentifier, - segmentId: null, - }); - return; - } - - _setSegmentId(String(segmentId.id)); - setSelectedSegment(segment); - setSegmentPreference({ - tableIdentifier, - segmentId, - }); - - // apply the segment - setActiveFilters(segment.activeFilters); - if (segment.sorting) { - setSorting(segment.sorting); - } - if (segment.columnVisibility) { - setColumnVisibility(segment.columnVisibility); - } - if (segment.columnSizing) { - setColumnSizing(segment.columnSizing); - } - if (segment.perPage !== undefined) { - setPageSize(segment.perPage); - } - if (segment.searchTerm !== undefined) { - setSearchTerm(segment.searchTerm); - } - setPageIndex(0); - }, - [ - _setSegmentId, - setSelectedSegment, - setSegmentPreference, - tableIdentifier, - findSelectedSegment, - setActiveFilters, - setSorting, - setColumnVisibility, - setColumnSizing, - setPageSize, - setSearchTerm, - setPageIndex, - ] - ); - - useEffect(() => { - if (!isSegmentFetchedSuccessfully) { - return; - } - // If the preferred segment id has been fetched - // and no segment id has been selected yet, - // then we set it. - if (fetchedPreferredSegmentId && !segmentId) { - setSegmentId(fetchedPreferredSegmentId); - } else if (segmentId) { - setSelectedSegment(findSelectedSegment(segmentId)); - } - // We intentionally have only `isSegmentFetchedSuccessfully` - // in the dependency array. - }, [isSegmentFetchedSuccessfully]); - - const clearSystemSegmentSelectionIfExists = useCallback(() => { - if (selectedSegment?.type === "system") { - setSegmentId(null); - } - }, [selectedSegment, setSegmentId]); - - const setDebouncedSearchTerm = useMemo( - () => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500), - [setSearchTerm] - ); - - const addFilter = useCallback( - (columnId: string) => { - if (!activeFilters?.some((filter) => filter.f === columnId)) { - // do not reset the page to 0 here, - // because we don't have the filter value yet (`v: undefined`) - setActiveFilters([...activeFilters, { f: columnId, v: undefined }]); - clearSystemSegmentSelectionIfExists(); - } - }, - [activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists] - ); - - const setPageIndexWrapper = useCallback( - (newPageIndex: number | null) => setPageIndex(newPageIndex || null), - [setPageIndex] - ); - - const updateFilter = useCallback( - (columnId: string, value: FilterValue) => { - setPageIndex(null); - setActiveFilters((prev) => { - let added = false; - const newFilters = prev.map((item) => { - if (item.f === columnId) { - added = true; - return { ...item, v: value }; - } - return item; - }); - if (!added) { - newFilters.push({ f: columnId, v: value }); - } - return newFilters; - }); - clearSystemSegmentSelectionIfExists(); - }, - [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] - ); - - const removeFilter = useCallback( - (columnId: string) => { - setPageIndex(null); - setActiveFilters((prev) => { - const remainingFilters = prev.filter((filter) => filter.f !== columnId); - return remainingFilters.length === 0 ? null : remainingFilters; - }); - clearSystemSegmentSelectionIfExists(); - }, - [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] - ); - - const setPageSizeAndGoToFirstPage = useCallback( - (newPageSize: number | null) => { - setPageSize(newPageSize === defaultPageSize ? null : newPageSize); - setPageIndex(null); - clearSystemSegmentSelectionIfExists(); - }, - [setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists] - ); - - const clearAll = useCallback( - (exclude?: string[]) => { - setSegmentId(null); - setPageIndex(null); - setActiveFilters((prev) => { - const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f)); - return remainingFilters.length === 0 ? null : remainingFilters; - }); - }, - [setActiveFilters, setPageIndex, setSegmentId] - ); - - // Check if current state differs from selected segment - const hasStateChanged = useMemo(() => { - if (!selectedSegment) return false; - - return ( - !isEqual(activeFilters, selectedSegment.activeFilters) || - !isEqual(sorting, selectedSegment.sorting) || - !isEqual(columnVisibility, selectedSegment.columnVisibility) || - !isEqual(columnSizing, selectedSegment.columnSizing) || - !isEqual(pageSize, selectedSegment.perPage) || - !isEqual(searchTerm || "", selectedSegment.searchTerm || "") - ); - }, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]); - - const canSaveSegment = useMemo(() => { - if (selectedSegment) { - return hasStateChanged; - } - // if no segment is selected, we can save the segment if there are any active filters, sorting, etc. - return ( - activeFilters.length > 0 || - sorting.length > 0 || - Object.keys(columnVisibility).length > 0 || - Object.keys(columnSizing).length > 0 || - pageSize !== defaultPageSize || - searchTerm?.length > 0 - ); - }, [ - selectedSegment, - activeFilters, - sorting, - columnVisibility, - columnSizing, - pageSize, - searchTerm, - defaultPageSize, - hasStateChanged, - ]); - - const ctaContainerRef = useElementByClassName(ctaContainerClassName); - return ( - - {children} - + + + + {children} + + + ); } diff --git a/packages/features/data-table/contexts/DataTableFiltersContext.tsx b/packages/features/data-table/contexts/DataTableFiltersContext.tsx new file mode 100644 index 0000000000..5695bd4717 --- /dev/null +++ b/packages/features/data-table/contexts/DataTableFiltersContext.tsx @@ -0,0 +1,173 @@ +"use client"; + +import debounce from "lodash/debounce"; +import { createContext, useContext, useCallback, useMemo, useEffect } from "react"; + +import type { FilterValue, ActiveFilters } from "../lib/types"; +import { useDataTableSegment } from "./DataTableSegmentContext"; +import { useDataTableState } from "./DataTableStateContext"; + +export type DataTableFiltersContextType = { + activeFilters: ActiveFilters; + addFilter: (columnId: string) => void; + updateFilter: (columnId: string, value: FilterValue) => void; + removeFilter: (columnId: string) => void; + clearAll: (exclude?: string[]) => void; + + searchTerm: string; + setSearchTerm: (searchTerm: string | null) => void; + + pageIndex: number; + pageSize: number; + setPageIndex: (pageIndex: number | null) => void; + setPageSize: (pageSize: number | null) => void; + offset: number; + limit: number; +}; + +export const DataTableFiltersContext = createContext(null); + +export function useDataTableFilters() { + const context = useContext(DataTableFiltersContext); + if (!context) { + throw new Error("useDataTableFilters must be used within a DataTableFiltersProvider"); + } + return context; +} + +interface DataTableFiltersProviderProps { + children: React.ReactNode; +} + +export function DataTableFiltersProvider({ children }: DataTableFiltersProviderProps) { + const { + activeFilters, + setActiveFilters, + pageIndex, + setPageIndex, + pageSize, + setPageSize, + searchTerm, + setSearchTerm, + defaultPageSize, + } = useDataTableState(); + + const { setSegmentId, clearSystemSegmentSelectionIfExists } = useDataTableSegment(); + + const setDebouncedSearchTerm = useMemo( + () => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500), + [setSearchTerm] + ); + + useEffect(() => { + return () => { + setDebouncedSearchTerm.cancel(); + }; + }, [setDebouncedSearchTerm]); + + const addFilter = useCallback( + (columnId: string) => { + if (!activeFilters?.some((filter) => filter.f === columnId)) { + setActiveFilters([...activeFilters, { f: columnId, v: undefined }]); + clearSystemSegmentSelectionIfExists(); + } + }, + [activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists] + ); + + const setPageIndexWrapper = useCallback( + (newPageIndex: number | null) => setPageIndex(newPageIndex || null), + [setPageIndex] + ); + + const updateFilter = useCallback( + (columnId: string, value: FilterValue) => { + setPageIndex(null); + setActiveFilters((prev) => { + const filters = prev ?? []; + let added = false; + const newFilters = filters.map((item) => { + if (item.f === columnId) { + added = true; + return { ...item, v: value }; + } + return item; + }); + if (!added) { + newFilters.push({ f: columnId, v: value }); + } + return newFilters; + }); + clearSystemSegmentSelectionIfExists(); + }, + [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] + ); + + const removeFilter = useCallback( + (columnId: string) => { + setPageIndex(null); + setActiveFilters((prev) => { + const filters = prev ?? []; + const remainingFilters = filters.filter((filter) => filter.f !== columnId); + return remainingFilters.length === 0 ? null : remainingFilters; + }); + clearSystemSegmentSelectionIfExists(); + }, + [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] + ); + + const setPageSizeAndGoToFirstPage = useCallback( + (newPageSize: number | null) => { + setPageSize(newPageSize === defaultPageSize ? null : newPageSize); + setPageIndex(null); + clearSystemSegmentSelectionIfExists(); + }, + [setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists] + ); + + const clearAll = useCallback( + (exclude?: string[]) => { + setSegmentId(null); + setPageIndex(null); + setActiveFilters((prev) => { + const filters = prev ?? []; + const remainingFilters = filters.filter((filter) => exclude?.includes(filter.f)); + return remainingFilters.length === 0 ? null : remainingFilters; + }); + }, + [setActiveFilters, setPageIndex, setSegmentId] + ); + + const value = useMemo( + () => ({ + activeFilters, + addFilter, + updateFilter, + removeFilter, + clearAll, + searchTerm, + setSearchTerm: setDebouncedSearchTerm, + pageIndex, + pageSize, + setPageIndex: setPageIndexWrapper, + setPageSize: setPageSizeAndGoToFirstPage, + offset: pageIndex * pageSize, + limit: pageSize, + }), + [ + activeFilters, + addFilter, + updateFilter, + removeFilter, + clearAll, + searchTerm, + setDebouncedSearchTerm, + pageIndex, + pageSize, + setPageIndexWrapper, + setPageSizeAndGoToFirstPage, + ] + ); + + return {children}; +} diff --git a/packages/features/data-table/contexts/DataTableSegmentContext.tsx b/packages/features/data-table/contexts/DataTableSegmentContext.tsx new file mode 100644 index 0000000000..b11e77f13d --- /dev/null +++ b/packages/features/data-table/contexts/DataTableSegmentContext.tsx @@ -0,0 +1,319 @@ +"use client"; + +import { createContext, useContext, useState, useCallback, useEffect, useRef, useMemo } from "react"; + +import { useSegmentsNoop } from "../hooks/useSegmentsNoop"; +import type { + FilterSegmentOutput, + SystemFilterSegment, + CombinedFilterSegment, + SegmentIdentifier, + UseSegments, +} from "../lib/types"; +import { SYSTEM_SEGMENT_PREFIX } from "../lib/types"; +import { useDataTableState } from "./DataTableStateContext"; + +export type DataTableSegmentContextType = { + segments: CombinedFilterSegment[]; + selectedSegment: CombinedFilterSegment | undefined; + segmentId: SegmentIdentifier | null; + setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void; + isSegmentEnabled: boolean; + canSaveSegment: boolean; + isValidatorPending: boolean; + clearSystemSegmentSelectionIfExists: () => void; +}; + +export const DataTableSegmentContext = createContext(null); + +export function useDataTableSegment() { + const context = useContext(DataTableSegmentContext); + if (!context) { + throw new Error("useDataTableSegment must be used within a DataTableSegmentProvider"); + } + return context; +} + +interface DataTableSegmentProviderProps { + children: React.ReactNode; + useSegments?: UseSegments; + segments?: FilterSegmentOutput[]; + systemSegments?: SystemFilterSegment[]; +} + +export function DataTableSegmentProvider({ + children, + useSegments = useSegmentsNoop, + segments: providedSegments, + systemSegments, +}: DataTableSegmentProviderProps) { + const { + tableIdentifier, + activeFilters, + setActiveFilters, + sorting, + setSorting, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + pageSize, + setPageSize, + searchTerm, + setSearchTerm, + setPageIndex, + segmentIdRaw, + setSegmentIdRaw, + defaultPageSize, + validateActiveFilters, + } = useDataTableState(); + + const { + segments, + preferredSegmentId: fetchedPreferredSegmentId, + setPreference: setSegmentPreference, + isSegmentEnabled, + isSuccess: isSegmentFetchedSuccessfully, + } = useSegments({ + tableIdentifier, + providedSegments, + systemSegments, + }); + + const findSelectedSegment = useCallback( + (segmentId: string) => { + return segments.find((segment) => { + if ( + segment.type === "system" && + segmentId && + segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) && + segment.id === segmentId + ) { + return true; + } else if (segment.type === "user") { + const segmentIdNumber = parseInt(segmentId, 10); + return segment.id === segmentIdNumber; + } + }); + }, + [segments] + ); + + const segmentIdObject = useMemo(() => { + if (segmentIdRaw && segmentIdRaw.startsWith(SYSTEM_SEGMENT_PREFIX)) { + return { + id: segmentIdRaw, + type: "system" as const, + }; + } else if (segmentIdRaw) { + const segmentIdNumber = parseInt(segmentIdRaw, 10); + if (!Number.isNaN(segmentIdNumber)) { + return { + id: segmentIdNumber, + type: "user" as const, + }; + } + } + return null; + }, [segmentIdRaw]); + + const [selectedSegment, setSelectedSegment] = useState( + segmentIdRaw ? findSelectedSegment(segmentIdRaw) : undefined + ); + + const pendingSegmentRef = useRef<{ + segmentId: SegmentIdentifier; + segment: CombinedFilterSegment; + } | null>(null); + + const isValidatorReady = validateActiveFilters !== "loading"; + + const applySegmentFilters = useCallback( + (segment: CombinedFilterSegment) => { + const filtersToApply = + typeof validateActiveFilters === "function" + ? validateActiveFilters(segment.activeFilters) + : segment.activeFilters; + setActiveFilters(filtersToApply); + if (segment.sorting) { + setSorting(segment.sorting); + } + if (segment.columnVisibility) { + setColumnVisibility(segment.columnVisibility); + } + if (segment.columnSizing) { + setColumnSizing(segment.columnSizing); + } + if (segment.perPage !== undefined) { + setPageSize(segment.perPage); + } + if (segment.searchTerm !== undefined) { + setSearchTerm(segment.searchTerm); + } + setPageIndex(0); + }, + [ + validateActiveFilters, + setActiveFilters, + setSorting, + setColumnVisibility, + setColumnSizing, + setPageSize, + setSearchTerm, + setPageIndex, + ] + ); + + const setSegmentId = useCallback( + (segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => { + if (!segmentId) { + pendingSegmentRef.current = null; + setSegmentIdRaw(null); + setSelectedSegment(undefined); + setSegmentPreference({ + tableIdentifier, + segmentId: null, + }); + return; + } + + const segment = providedSegment || findSelectedSegment(String(segmentId.id)); + if (!segment) { + pendingSegmentRef.current = null; + setSegmentIdRaw(null); + setSelectedSegment(undefined); + setSegmentPreference({ + tableIdentifier, + segmentId: null, + }); + return; + } + + setSegmentIdRaw(String(segmentId.id)); + setSelectedSegment(segment); + setSegmentPreference({ + tableIdentifier, + segmentId, + }); + + if (validateActiveFilters === "loading") { + pendingSegmentRef.current = { segmentId, segment }; + return; + } + + pendingSegmentRef.current = null; + applySegmentFilters(segment); + }, + [ + setSegmentIdRaw, + setSegmentPreference, + tableIdentifier, + findSelectedSegment, + validateActiveFilters, + applySegmentFilters, + ] + ); + + useEffect(() => { + if (!isValidatorReady || !pendingSegmentRef.current) { + return; + } + const { segment } = pendingSegmentRef.current; + pendingSegmentRef.current = null; + applySegmentFilters(segment); + }, [isValidatorReady, applySegmentFilters]); + + useEffect(() => { + if (!isSegmentFetchedSuccessfully) { + return; + } + if (fetchedPreferredSegmentId && !segmentIdRaw) { + setSegmentId(fetchedPreferredSegmentId); + } else if (segmentIdRaw) { + const segment = findSelectedSegment(segmentIdRaw); + setSelectedSegment(segment); + if (segment) { + if (validateActiveFilters === "loading") { + pendingSegmentRef.current = { + segmentId: segmentIdObject!, + segment, + }; + } else { + applySegmentFilters(segment); + } + } + } + }, [isSegmentFetchedSuccessfully]); + + const clearSystemSegmentSelectionIfExists = useCallback(() => { + if (selectedSegment?.type === "system") { + setSegmentId(null); + } + }, [selectedSegment, setSegmentId]); + + const hasStateChanged = useMemo(() => { + if (!selectedSegment) return false; + + const isEqual = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + + return ( + !isEqual(activeFilters, selectedSegment.activeFilters) || + !isEqual(sorting, selectedSegment.sorting) || + !isEqual(columnVisibility, selectedSegment.columnVisibility) || + !isEqual(columnSizing, selectedSegment.columnSizing) || + !isEqual(pageSize, selectedSegment.perPage) || + !isEqual(searchTerm || "", selectedSegment.searchTerm || "") + ); + }, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]); + + const canSaveSegment = useMemo(() => { + if (selectedSegment) { + return hasStateChanged; + } + return ( + activeFilters.length > 0 || + sorting.length > 0 || + Object.keys(columnVisibility).length > 0 || + Object.keys(columnSizing).length > 0 || + pageSize !== defaultPageSize || + (searchTerm?.length ?? 0) > 0 + ); + }, [ + selectedSegment, + activeFilters, + sorting, + columnVisibility, + columnSizing, + pageSize, + searchTerm, + defaultPageSize, + hasStateChanged, + ]); + + const isValidatorPending = validateActiveFilters === "loading" && pendingSegmentRef.current !== null; + + const value = useMemo( + () => ({ + segments, + selectedSegment, + segmentId: segmentIdObject, + setSegmentId, + isSegmentEnabled, + canSaveSegment, + isValidatorPending, + clearSystemSegmentSelectionIfExists, + }), + [ + segments, + selectedSegment, + segmentIdObject, + setSegmentId, + isSegmentEnabled, + canSaveSegment, + isValidatorPending, + clearSystemSegmentSelectionIfExists, + ] + ); + + return {children}; +} diff --git a/packages/features/data-table/contexts/DataTableStateContext.tsx b/packages/features/data-table/contexts/DataTableStateContext.tsx new file mode 100644 index 0000000000..e441298b47 --- /dev/null +++ b/packages/features/data-table/contexts/DataTableStateContext.tsx @@ -0,0 +1,169 @@ +"use client"; + +import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; +import { useQueryState } from "nuqs"; +import { createContext, useContext, useRef, useMemo } from "react"; + +import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName"; + +import { + activeFiltersParser, + sortingParser, + columnVisibilityParser, + columnSizingParser, + segmentIdParser, + pageIndexParser, + pageSizeParser, + searchTermParser, + DEFAULT_PAGE_SIZE, +} from "../lib/parsers"; +import type { ActiveFilters, SegmentIdentifier } from "../lib/types"; +import { CTA_CONTAINER_CLASS_NAME } from "../lib/utils"; + +export type ActiveFiltersValidator = (filters: ActiveFilters) => ActiveFilters; + +export type ActiveFiltersValidatorState = ActiveFiltersValidator | "loading" | undefined; + +export type DataTableStateContextType = { + tableIdentifier: string; + ctaContainerRef: React.RefObject; + filterToOpen: React.MutableRefObject; + + pageIndex: number; + setPageIndex: (pageIndex: number | null) => void; + + pageSize: number; + setPageSize: (pageSize: number | null) => void; + + searchTerm: string; + setSearchTerm: (searchTerm: string | null) => void; + + activeFilters: ActiveFilters; + setActiveFilters: ReturnType>[1]; + + sorting: SortingState; + setSorting: OnChangeFn; + + columnVisibility: VisibilityState; + setColumnVisibility: OnChangeFn; + + columnSizing: ColumnSizingState; + setColumnSizing: OnChangeFn; + + segmentIdRaw: string | null; + setSegmentIdRaw: (segmentId: string | null) => void; + + defaultPageSize: number; + timeZone?: string; + + validateActiveFilters?: ActiveFiltersValidatorState; +}; + +export const DataTableStateContext = createContext(null); + +export function useDataTableState() { + const context = useContext(DataTableStateContext); + if (!context) { + throw new Error("useDataTableState must be used within a DataTableStateProvider"); + } + return context; +} + +interface DataTableStateProviderProps { + tableIdentifier: string; + children: React.ReactNode; + defaultPageSize?: number; + ctaContainerClassName?: string; + timeZone?: string; + preferredSegmentId?: SegmentIdentifier | null; + validateActiveFilters?: ActiveFiltersValidatorState; +} + +export function DataTableStateProvider({ + tableIdentifier, + children, + defaultPageSize = DEFAULT_PAGE_SIZE, + ctaContainerClassName = CTA_CONTAINER_CLASS_NAME, + timeZone, + preferredSegmentId, + validateActiveFilters, +}: DataTableStateProviderProps) { + if (!tableIdentifier.trim()) { + throw new Error("tableIdentifier is required and cannot be empty"); + } + + const filterToOpen = useRef(undefined); + const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); + const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); + const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); + const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); + const [sorting, setSorting] = useQueryState("sorting", sortingParser); + const [columnVisibility, setColumnVisibility] = useQueryState( + "cols", + columnVisibilityParser + ); + const [columnSizing, setColumnSizing] = useQueryState("widths", columnSizingParser); + + const initialSegmentId = useMemo( + () => (preferredSegmentId ? String(preferredSegmentId.id) : null), + [preferredSegmentId] + ); + const [segmentIdRaw, setSegmentIdRaw] = useQueryState( + "segment", + initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser + ); + + const ctaContainerRef = useElementByClassName(ctaContainerClassName); + + const value = useMemo( + () => ({ + tableIdentifier, + ctaContainerRef, + filterToOpen, + pageIndex, + setPageIndex, + pageSize, + setPageSize, + searchTerm, + setSearchTerm, + activeFilters, + setActiveFilters, + sorting, + setSorting, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + segmentIdRaw, + setSegmentIdRaw, + defaultPageSize, + timeZone, + validateActiveFilters, + }), + [ + tableIdentifier, + ctaContainerRef, + pageIndex, + setPageIndex, + pageSize, + setPageSize, + searchTerm, + setSearchTerm, + activeFilters, + setActiveFilters, + sorting, + setSorting, + columnVisibility, + setColumnVisibility, + columnSizing, + setColumnSizing, + segmentIdRaw, + setSegmentIdRaw, + defaultPageSize, + timeZone, + validateActiveFilters, + ] + ); + + return {children}; +} diff --git a/packages/features/data-table/contexts/index.ts b/packages/features/data-table/contexts/index.ts new file mode 100644 index 0000000000..48924dda79 --- /dev/null +++ b/packages/features/data-table/contexts/index.ts @@ -0,0 +1,22 @@ +export { + DataTableStateContext, + DataTableStateProvider, + useDataTableState, + type DataTableStateContextType, + type ActiveFiltersValidator, + type ActiveFiltersValidatorState, +} from "./DataTableStateContext"; + +export { + DataTableSegmentContext, + DataTableSegmentProvider, + useDataTableSegment, + type DataTableSegmentContextType, +} from "./DataTableSegmentContext"; + +export { + DataTableFiltersContext, + DataTableFiltersProvider, + useDataTableFilters, + type DataTableFiltersContextType, +} from "./DataTableFiltersContext"; diff --git a/packages/features/data-table/index.ts b/packages/features/data-table/index.ts index 5682b6aad1..56e0c09ada 100644 --- a/packages/features/data-table/index.ts +++ b/packages/features/data-table/index.ts @@ -3,5 +3,6 @@ export * from "./lib/utils"; export * from "./lib/serializers"; export * from "./lib/separator"; export * from "./DataTableProvider"; +export * from "./contexts"; export * from "./hooks/useColumnResizing"; export * from "./hooks";