2266301469
* feat: implement default filter segments for data tables - Add defaultSegmentId column to UserFilterSegmentPreference table - Support mixed segment ID types (number for user segments, string for default segments) - Add DefaultFilterSegment and CombinedFilterSegment types - Update useSegments hook to handle default segments with date range recalculation - Modify FilterSegmentSelect to group and display default segments separately - Add default segments to bookings view (My Bookings, Upcoming Bookings) - Prevent editing/deleting of default segments in SaveFilterSegmentButton - Update DataTableProvider to support defaultSegments prop - Update parsers and repository to handle mixed segment ID types Implements frontend-only default segments as specified in the requirements. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * chore: update ESLint dependencies to resolve configuration issues - Update eslint-config-next and @typescript-eslint packages to latest versions - Fix dependency compatibility issues that were blocking pre-commit hooks Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * revert eslint change * some changes * refactor: implement discriminated union for segment types - Update SegmentIdentifier to use discriminated union with 'custom' vs 'default' types - Refactor setSegmentId to accept object parameters: { id: string; type: 'default' } | { id: number; type: 'custom' } - Update type definitions with DefaultFilterSegment, CustomFilterSegment, and CombinedFilterSegment - Modify useSegments hook to handle new segment type structure - Update FilterSegmentSelect component to work with discriminated unions - Refactor database preference handling to store segment type alongside ID - Add proper type safety throughout the data flow - Remove description property from DefaultFilterSegment type Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: update unit test to expect discriminated union format for preferredSegmentId The test was expecting preferredSegmentId to be a number, but after the discriminated union refactor it now returns { id: number, type: 'custom' } for custom segments. Updated the assertion to use toEqual() for deep object comparison. Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: correct SegmentIdentifier type to exclude undefined - Remove null from SegmentIdentifier type definition since null/undefined indicate absence of identifier - Update DataTableProvider and useSegments to handle SegmentIdentifier | null properly - Fix type safety while maintaining discriminated union functionality - Ensure setAndPersistSegmentId accepts null values for clearing segments Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * remove unused segment * a little update * feat: add default- prefix to default segment IDs for clearer type identification - Update 'my_bookings' to 'default-my_bookings' - Update 'upcoming-bookings' to 'default-upcoming-bookings' - Makes it easier to verify segment type by looking at the ID string - Maintains all existing discriminated union functionality Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: rename segment types from default/custom to system/user for better semantics Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: update unit test to expect 'user' type instead of 'custom' in discriminated union Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * fix: complete type renaming from default/custom to system/user across all component files Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * refactor: update segment ID prefix from default- to system- to match type naming Co-Authored-By: eunjae@cal.com <hey@eunjae.dev> * remove unused segment * some fixes * revert schema change * rename defaultSegmentId to systemSegmentId * renaming * fix save button * remove as any * clean up prisma migrations * type fixes * many fixes * remove icon property * fix infinite rendering * fix race condition * re-visiting useSegments implementation WIP * extract useElementByClassName * add e2e tests * fix a bug that the created segment was not selected automatically * add useSegments to /insights and fix e2e test * fix type error * fix type error * apply feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
427 lines
13 KiB
TypeScript
427 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import debounce from "lodash/debounce";
|
|
// eslint-disable-next-line no-restricted-imports
|
|
import isEqual from "lodash/isEqual";
|
|
import { usePathname } from "next/navigation";
|
|
import { useQueryState } from "nuqs";
|
|
import { useState, createContext, useCallback, useEffect, useRef, 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,
|
|
} from "./lib/types";
|
|
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
|
|
|
|
export type DataTableContextType = {
|
|
tableIdentifier: string;
|
|
ctaContainerRef: React.RefObject<HTMLDivElement>;
|
|
filterToOpen: React.MutableRefObject<string | undefined>;
|
|
|
|
activeFilters: ActiveFilters;
|
|
clearAll: (exclude?: string[]) => void;
|
|
addFilter: (columnId: string) => void;
|
|
updateFilter: (columnId: string, value: FilterValue) => void;
|
|
removeFilter: (columnId: string) => void;
|
|
|
|
sorting: SortingState;
|
|
setSorting: OnChangeFn<SortingState>;
|
|
|
|
columnVisibility: VisibilityState;
|
|
setColumnVisibility: OnChangeFn<VisibilityState>;
|
|
|
|
columnSizing: ColumnSizingState;
|
|
setColumnSizing: OnChangeFn<ColumnSizingState>;
|
|
|
|
pageIndex: number;
|
|
pageSize: number;
|
|
setPageIndex: (pageIndex: number | null) => void;
|
|
setPageSize: (pageSize: number | null) => void;
|
|
|
|
offset: number;
|
|
limit: number;
|
|
|
|
segments: CombinedFilterSegment[];
|
|
selectedSegment: CombinedFilterSegment | undefined;
|
|
segmentId: SegmentIdentifier | null;
|
|
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
|
|
canSaveSegment: boolean;
|
|
isSegmentEnabled: boolean;
|
|
|
|
searchTerm: string;
|
|
setSearchTerm: (searchTerm: string | null) => void;
|
|
|
|
timeZone?: string;
|
|
};
|
|
|
|
export const DataTableContext = createContext<DataTableContextType | null>(null);
|
|
|
|
interface DataTableProviderProps {
|
|
useSegments?: UseSegments;
|
|
tableIdentifier?: string;
|
|
children: React.ReactNode;
|
|
ctaContainerClassName?: string;
|
|
defaultPageSize?: number;
|
|
segments?: FilterSegmentOutput[];
|
|
timeZone?: string;
|
|
preferredSegmentId?: SegmentIdentifier | null;
|
|
systemSegments?: SystemFilterSegment[];
|
|
}
|
|
|
|
export function DataTableProvider({
|
|
tableIdentifier: _tableIdentifier,
|
|
children,
|
|
useSegments = useSegmentsNoop,
|
|
defaultPageSize = DEFAULT_PAGE_SIZE,
|
|
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
|
|
segments: providedSegments,
|
|
timeZone,
|
|
preferredSegmentId,
|
|
systemSegments,
|
|
}: DataTableProviderProps) {
|
|
const pathname = usePathname() as string | null;
|
|
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
|
if (!tableIdentifier) {
|
|
throw new Error("tableIdentifier is required");
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|