feat: add filter segment validation with useActiveFiltersValidator hook and refactor DataTableProvider (#27208)
* feat: add activeFilters validator to DataTableProvider for filter segment validation - Add validateActiveFilters prop to DataTableProvider to filter out inaccessible values - Create useActiveFiltersValidator hook in bookings module to validate filter values - Integrate validator in bookings-view.tsx to validate userId, eventTypeId, teamId filters - Add comprehensive tests for the filter validation logic This provides a scalable solution for handling stale filter segment data by validating filters at the caller level using already-fetched accessible resource IDs. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: add type guard for eventTypeIds to ensure number[] type Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: delay segment filter application until validator is ready - Change useActiveFiltersValidator to return 'loading' state while data is fetching - Add pending segment handling in DataTableProvider to delay filter application - Expose isValidatorPending in context for consumers to disable queries - Update BookingListContainer to disable bookings query while validator is pending This prevents both errors from invalid filters and flash from fetching without filters. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: extract useSegmentManagement hook from DataTableProvider - Extract segment management logic into dedicated useSegmentManagement hook - Reduces DataTableProvider from ~500 lines to ~330 lines - Use NuqsSetter type to match nuqs useQueryState setter signatures - Maintains all existing functionality and type safety Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: export ActiveFiltersValidator types from data-table lib/types Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: import ActiveFiltersValidatorState from lib/types instead of hook Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * Revert "refactor: extract useSegmentManagement hook from DataTableProvider" This reverts commit dd588ac4cfb1d709c71d1f621994a7e8f3118f33. * refactor: split DataTableProvider into fine-grained context providers - Create DataTableStateContext for raw nuqs state management - Create DataTableSegmentContext for segment management - Create DataTableFiltersContext for filter manipulation - DataTableProvider now composes all providers with a bridge component - Maintains backward compatibility via combined DataTableContext Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: apply segment filters when navigating back to page with existing URL state When navigating back to a page with a segment already selected in the URL, the segment was being marked as selected but its filters were not being applied. This caused non-deterministic behavior where the filter segment would sometimes not take effect. The fix ensures that when segmentIdRaw exists in the URL and segments are fetched, we also call applySegmentFilters() (with validator timing support) instead of just setting the selectedSegment state. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: simplify filter validator to only handle multi-select These filters are always multi-select, so remove unnecessary single-select handling code and tests. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: cancel debounced search term on unmount Cancel the debounced search term on unmount/dependency change to avoid queued updates firing after the provider unmounts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
10 files changed
+1170
-350
No files matched your search
@@ -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]);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<DataTableProvider tableIdentifier={pathname} useSegments={useSegments} systemSegments={systemSegments}>
|
||||
<DataTableProvider
|
||||
tableIdentifier={pathname}
|
||||
useSegments={useSegments}
|
||||
systemSegments={systemSegments}
|
||||
validateActiveFilters={validateActiveFilters}>
|
||||
<BookingsContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
|
||||
@@ -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<DataTableContextType>(
|
||||
() => ({
|
||||
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 <DataTableContext.Provider value={value}>{children}</DataTableContext.Provider>;
|
||||
}
|
||||
|
||||
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<string | undefined>(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<VisibilityState>(
|
||||
"cols",
|
||||
columnVisibilityParser
|
||||
);
|
||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("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<CombinedFilterSegment | undefined>(
|
||||
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<HTMLDivElement>(ctaContainerClassName);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
tableIdentifier,
|
||||
ctaContainerRef,
|
||||
filterToOpen,
|
||||
activeFilters,
|
||||
addFilter,
|
||||
clearAll,
|
||||
updateFilter,
|
||||
removeFilter,
|
||||
sorting,
|
||||
setSorting,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnSizing,
|
||||
setColumnSizing,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
setPageIndex: setPageIndexWrapper,
|
||||
setPageSize: setPageSizeAndGoToFirstPage,
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
segments,
|
||||
selectedSegment,
|
||||
segmentId: segmentIdObject,
|
||||
setSegmentId,
|
||||
canSaveSegment,
|
||||
isSegmentEnabled,
|
||||
searchTerm,
|
||||
setSearchTerm: setDebouncedSearchTerm,
|
||||
timeZone,
|
||||
}}>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
<DataTableStateProvider
|
||||
tableIdentifier={tableIdentifier}
|
||||
defaultPageSize={defaultPageSize}
|
||||
ctaContainerClassName={ctaContainerClassName}
|
||||
timeZone={timeZone}
|
||||
preferredSegmentId={preferredSegmentId}
|
||||
validateActiveFilters={validateActiveFilters}>
|
||||
<DataTableSegmentProvider
|
||||
useSegments={useSegments}
|
||||
segments={providedSegments}
|
||||
systemSegments={systemSegments}>
|
||||
<DataTableFiltersProvider>
|
||||
<DataTableContextBridge>{children}</DataTableContextBridge>
|
||||
</DataTableFiltersProvider>
|
||||
</DataTableSegmentProvider>
|
||||
</DataTableStateProvider>
|
||||
);
|
||||
}
|
||||
@@ -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<DataTableFiltersContextType | null>(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 <DataTableFiltersContext.Provider value={value}>{children}</DataTableFiltersContext.Provider>;
|
||||
}
|
||||
@@ -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<DataTableSegmentContextType | null>(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<CombinedFilterSegment | undefined>(
|
||||
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 <DataTableSegmentContext.Provider value={value}>{children}</DataTableSegmentContext.Provider>;
|
||||
}
|
||||
@@ -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<HTMLDivElement>;
|
||||
filterToOpen: React.MutableRefObject<string | undefined>;
|
||||
|
||||
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<typeof useQueryState<ActiveFilters>>[1];
|
||||
|
||||
sorting: SortingState;
|
||||
setSorting: OnChangeFn<SortingState>;
|
||||
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: OnChangeFn<VisibilityState>;
|
||||
|
||||
columnSizing: ColumnSizingState;
|
||||
setColumnSizing: OnChangeFn<ColumnSizingState>;
|
||||
|
||||
segmentIdRaw: string | null;
|
||||
setSegmentIdRaw: (segmentId: string | null) => void;
|
||||
|
||||
defaultPageSize: number;
|
||||
timeZone?: string;
|
||||
|
||||
validateActiveFilters?: ActiveFiltersValidatorState;
|
||||
};
|
||||
|
||||
export const DataTableStateContext = createContext<DataTableStateContextType | null>(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<string | undefined>(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<VisibilityState>(
|
||||
"cols",
|
||||
columnVisibilityParser
|
||||
);
|
||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
|
||||
|
||||
const initialSegmentId = useMemo(
|
||||
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
|
||||
[preferredSegmentId]
|
||||
);
|
||||
const [segmentIdRaw, setSegmentIdRaw] = useQueryState(
|
||||
"segment",
|
||||
initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
|
||||
);
|
||||
|
||||
const ctaContainerRef = useElementByClassName<HTMLDivElement>(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 <DataTableStateContext.Provider value={value}>{children}</DataTableStateContext.Provider>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user