feat: implement filter segment (#20066)
* feat: add filter segment * add tests * add ui * provide tableIdentifier from context * fix components * update save button * try to fix router * add proper implementation * update icon * remove segmentId if invalid * fix tests * fix type errors * fix type errors * fix unit test * clean up * minor fixes * clean up types * avoid barrel imports * fix imports * minor improvements * fix type error * fix radio import * fix little issues * menu permission * update icon snapshot * fix: remember last used filter segment (#20261) * fix: remember last used filter segment * extract segments logic as a separate file * remove unnecessary query * apply feedback * deep comparison
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { filterSegmentsRouter } from "@calcom/trpc/server/routers/viewer/filterSegments/_router";
|
||||
|
||||
export default createNextApiHandler(filterSegmentsRouter);
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 239 KiB |
@@ -80,6 +80,9 @@
|
||||
<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H19a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H6.5a1 1 0 0 1 0-5H20" ></path>
|
||||
<circle cx="12" cy="8" r="2" ></circle>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-bookmark" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="bookmark">
|
||||
<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-building" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="building">
|
||||
<rect width="16" height="20" x="4" y="2" rx="2" ry="2" ></rect>
|
||||
<path d="M9 22v-4h6v4" ></path>
|
||||
@@ -418,6 +421,11 @@
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" ></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-list-filter" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="list-filter">
|
||||
<path d="M3 6h18" ></path>
|
||||
<path d="M7 12h10" ></path>
|
||||
<path d="M10 18h4" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-loader" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="loader">
|
||||
<path d="M12 2v4" ></path>
|
||||
<path d="m16.2 7.8 2.9-2.9" ></path>
|
||||
@@ -605,6 +613,10 @@
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ></rect>
|
||||
<path d="m9 12 2 2 4-4" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-square-pen" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="square-pen">
|
||||
<path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" ></path>
|
||||
<path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z" ></path>
|
||||
</symbol>
|
||||
<symbol class="lucide lucide-star" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="star">
|
||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" ></polygon>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 51 KiB |
@@ -3038,6 +3038,29 @@
|
||||
"tip_username_plus": "Tip: You can a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting",
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"segment": "Segment",
|
||||
"rename": "Rename",
|
||||
"rename_segment": "Rename Segment",
|
||||
"delete_segment": "Delete Segment",
|
||||
"duplicate_segment": "Duplicate Segment",
|
||||
"delete_segment_confirmation": "Are you sure you want to delete this segment? This action cannot be undone.",
|
||||
"filter_segment_updated": "Filter segment updated",
|
||||
"error_updating_filter_segment": "Error updating filter segment",
|
||||
"filter_segment_duplicated": "Filter segment duplicated",
|
||||
"error_duplicating_filter_segment": "Error duplicating filter segment",
|
||||
"filter_segment_deleted": "Filter segment deleted",
|
||||
"error_deleting_filter_segment": "Error deleting filter segment",
|
||||
"save_segment": "Save Segment",
|
||||
"segment_not_found": "Segment not found",
|
||||
"filter_segment_saved": "Filter segment saved",
|
||||
"no_segments": "No segments",
|
||||
"error_saving_filter_segment": "Error saving filter segment",
|
||||
"override_segment": "Save overrides to \"{{name}}\"",
|
||||
"create_new_segment": "Create new segment",
|
||||
"save_for_team": "Save for team",
|
||||
"select_team": "Select team",
|
||||
"please_select_team": "Please select a team",
|
||||
"personal": "Personal",
|
||||
"hide_team_profile_link": "Hide Team Profile Link",
|
||||
"hide_team_profile_link_description": "Hide the team profile link on booking pages",
|
||||
"display": "Display",
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import type { SortingState, OnChangeFn, VisibilityState } from "@tanstack/react-table";
|
||||
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState, parseAsArrayOf, parseAsJson, parseAsInteger } from "nuqs";
|
||||
import { createContext, useCallback, useRef, useEffect } from "react";
|
||||
import { z } from "zod";
|
||||
import { createContext, useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { type FilterValue, ZFilterValue, ZSorting, ZColumnVisibility } from "./lib/types";
|
||||
|
||||
const ZActiveFilter = z.object({
|
||||
f: z.string(),
|
||||
v: ZFilterValue.optional(),
|
||||
});
|
||||
|
||||
type ActiveFilter = z.infer<typeof ZActiveFilter>;
|
||||
import { useSegments } from "./lib/segments";
|
||||
import {
|
||||
type FilterValue,
|
||||
ZSorting,
|
||||
ZColumnVisibility,
|
||||
ZActiveFilter,
|
||||
ZColumnSizing,
|
||||
type FilterSegmentOutput,
|
||||
type ActiveFilters,
|
||||
} from "./lib/types";
|
||||
|
||||
export type DataTableContextType = {
|
||||
tableIdentifier: string;
|
||||
ctaContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
|
||||
activeFilters: ActiveFilter[];
|
||||
activeFilters: ActiveFilters;
|
||||
clearAll: (exclude?: string[]) => void;
|
||||
addFilter: (columnId: string) => void;
|
||||
updateFilter: (columnId: string, value: FilterValue) => void;
|
||||
@@ -29,6 +32,9 @@ export type DataTableContextType = {
|
||||
columnVisibility: VisibilityState;
|
||||
setColumnVisibility: OnChangeFn<VisibilityState>;
|
||||
|
||||
columnSizing: ColumnSizingState;
|
||||
setColumnSizing: OnChangeFn<ColumnSizingState>;
|
||||
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
setPageIndex: (pageIndex: number) => void;
|
||||
@@ -36,22 +42,31 @@ export type DataTableContextType = {
|
||||
|
||||
offset: number;
|
||||
limit: number;
|
||||
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
segmentId: number | undefined;
|
||||
setSegmentId: (id: number | null) => void;
|
||||
canSaveSegment: boolean;
|
||||
};
|
||||
|
||||
export const DataTableContext = createContext<DataTableContextType | null>(null);
|
||||
|
||||
const DEFAULT_ACTIVE_FILTERS: ActiveFilter[] = [];
|
||||
const DEFAULT_ACTIVE_FILTERS: ActiveFilters = [];
|
||||
const DEFAULT_SORTING: SortingState = [];
|
||||
const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {};
|
||||
const DEFAULT_COLUMN_SIZING: ColumnSizingState = {};
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
|
||||
interface DataTableProviderProps {
|
||||
tableIdentifier?: string;
|
||||
children: React.ReactNode;
|
||||
ctaContainerClassName?: string;
|
||||
defaultPageSize?: number;
|
||||
}
|
||||
|
||||
export function DataTableProvider({
|
||||
tableIdentifier: _tableIdentifier,
|
||||
children,
|
||||
defaultPageSize = DEFAULT_PAGE_SIZE,
|
||||
ctaContainerClassName,
|
||||
@@ -68,13 +83,25 @@ export function DataTableProvider({
|
||||
"cols",
|
||||
parseAsJson(ZColumnVisibility.parse).withDefault(DEFAULT_COLUMN_VISIBILITY)
|
||||
);
|
||||
|
||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>(
|
||||
"widths",
|
||||
parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING)
|
||||
);
|
||||
const [segmentId, setSegmentId] = useQueryState("segment", parseAsInteger.withDefault(-1));
|
||||
const [pageIndex, setPageIndex] = useQueryState("page", parseAsInteger.withDefault(0));
|
||||
const [pageSize, setPageSize] = useQueryState("size", parseAsInteger.withDefault(defaultPageSize));
|
||||
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
}
|
||||
|
||||
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 }]);
|
||||
}
|
||||
},
|
||||
@@ -86,7 +113,7 @@ export function DataTableProvider({
|
||||
setPageIndex(0);
|
||||
setActiveFilters((prev) => prev.filter((filter) => exclude?.includes(filter.f)));
|
||||
},
|
||||
[setActiveFilters]
|
||||
[setActiveFilters, setPageIndex]
|
||||
);
|
||||
|
||||
const updateFilter = useCallback(
|
||||
@@ -107,7 +134,7 @@ export function DataTableProvider({
|
||||
return newFilters;
|
||||
});
|
||||
},
|
||||
[setActiveFilters]
|
||||
[setActiveFilters, setPageIndex]
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
@@ -115,7 +142,7 @@ export function DataTableProvider({
|
||||
setPageIndex(0);
|
||||
setActiveFilters((prev) => prev.filter((filter) => filter.f !== columnId));
|
||||
},
|
||||
[setActiveFilters]
|
||||
[setActiveFilters, setPageIndex]
|
||||
);
|
||||
|
||||
const setPageSizeAndGoToFirstPage = useCallback(
|
||||
@@ -126,6 +153,24 @@ export function DataTableProvider({
|
||||
[setPageSize, setPageIndex]
|
||||
);
|
||||
|
||||
const { segments, selectedSegment, canSaveSegment, setSegmentIdAndSaveToLocalStorage } = useSegments({
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
});
|
||||
|
||||
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ctaContainerClassName) {
|
||||
@@ -137,6 +182,7 @@ export function DataTableProvider({
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
tableIdentifier,
|
||||
ctaContainerRef,
|
||||
activeFilters,
|
||||
addFilter,
|
||||
@@ -147,12 +193,19 @@ export function DataTableProvider({
|
||||
setSorting,
|
||||
columnVisibility,
|
||||
setColumnVisibility,
|
||||
columnSizing,
|
||||
setColumnSizing,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
setPageIndex,
|
||||
setPageSize: setPageSizeAndGoToFirstPage,
|
||||
limit: pageSize,
|
||||
offset: pageIndex * pageSize,
|
||||
segments,
|
||||
selectedSegment,
|
||||
segmentId: segmentId || undefined,
|
||||
setSegmentId: setSegmentIdAndSaveToLocalStorage,
|
||||
canSaveSegment,
|
||||
}}>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
|
||||
@@ -6,7 +6,6 @@ import type { Table as ReactTableType, Header, HeaderGroup } from "@tanstack/rea
|
||||
import { useVirtualizer, type Virtualizer, type VirtualItem } from "@tanstack/react-virtual";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import kebabCase from "lodash/kebabCase";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState, memo, useMemo } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -34,6 +33,7 @@ export type DataTablePropsFromWrapper<TData> = {
|
||||
testId?: string;
|
||||
bodyTestId?: string;
|
||||
children?: React.ReactNode;
|
||||
enableColumnResizing?: boolean;
|
||||
className?: string;
|
||||
containerClassName?: string;
|
||||
headerClassName?: string;
|
||||
@@ -45,7 +45,6 @@ export type DataTableProps<TData> = DataTablePropsFromWrapper<TData> & {
|
||||
onRowMouseclick?: (row: Row<TData>) => void;
|
||||
onScroll?: (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) => void;
|
||||
tableOverlay?: React.ReactNode;
|
||||
identifier?: string;
|
||||
enableColumnResizing?: boolean;
|
||||
};
|
||||
|
||||
@@ -57,7 +56,6 @@ export function DataTable<TData>({
|
||||
onRowMouseclick,
|
||||
onScroll,
|
||||
children,
|
||||
identifier: _identifier,
|
||||
enableColumnResizing,
|
||||
testId,
|
||||
bodyTestId,
|
||||
@@ -68,9 +66,6 @@ export function DataTable<TData>({
|
||||
paginationMode = "infinite",
|
||||
...rest
|
||||
}: DataTableProps<TData> & React.ComponentPropsWithoutRef<"div">) {
|
||||
const pathname = usePathname() as string | null;
|
||||
const identifier = _identifier ?? pathname ?? undefined;
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
@@ -102,10 +97,9 @@ export function DataTable<TData>({
|
||||
const columnSizingVars = useColumnSizingVars({ table });
|
||||
|
||||
usePersistentColumnResizing({
|
||||
enabled: Boolean(enableColumnResizing && identifier),
|
||||
enabled: Boolean(enableColumnResizing),
|
||||
table,
|
||||
tableContainerRef,
|
||||
identifier,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,10 +5,8 @@ import { forwardRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import type { ButtonProps } from "@calcom/ui/components/button";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
import { Button, type ButtonProps } from "@calcom/ui/components/button";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
export type ActionItem<TData> =
|
||||
| {
|
||||
|
||||
@@ -6,10 +6,9 @@ import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { ButtonProps } from "@calcom/ui/components/button";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Button, type ButtonProps } from "@calcom/ui/components/button";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
|
||||
import { useColumnFilters } from "../hooks";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { useDataTable } from "../../hooks/useDataTable";
|
||||
|
||||
|
||||
@@ -6,8 +6,7 @@ import { forwardRef, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import type { ButtonProps } from "@calcom/ui/components/button";
|
||||
import { Button, buttonClasses } from "@calcom/ui/components/button";
|
||||
import { type ButtonProps, Button, buttonClasses } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select, Form, NumberInput } from "@calcom/ui/components/form";
|
||||
import { Form, Select, NumberInput } from "@calcom/ui/components/form";
|
||||
|
||||
import { useFilterValue, useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
|
||||
@@ -4,9 +4,7 @@ import { useForm, Controller } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
import { Input } from "@calcom/ui/components/form";
|
||||
import { Form, Select, Input } from "@calcom/ui/components/form";
|
||||
|
||||
import { useFilterValue, useDataTable } from "../../hooks";
|
||||
import type { FilterableColumn } from "../../lib/types";
|
||||
|
||||
@@ -6,3 +6,5 @@ export { DataTable } from "./DataTable";
|
||||
export { DataTableSkeleton } from "./DataTableSkeleton";
|
||||
export { DataTableWrapper } from "./DataTableWrapper";
|
||||
export type { DataTableProps } from "./DataTable";
|
||||
export { FilterSegmentSelect } from "./segment/FilterSegmentSelect";
|
||||
export { SaveFilterSegmentButton } from "./segment/SaveFilterSegmentButton";
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
|
||||
export function DeleteSegmentDialog({
|
||||
segment,
|
||||
onClose,
|
||||
}: {
|
||||
segment: FilterSegmentOutput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const { segmentId, setSegmentId } = useDataTable();
|
||||
|
||||
const { mutate: deleteSegment, isPending } = trpc.viewer.filterSegments.delete.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_deleted"), "success");
|
||||
if (segmentId === id) {
|
||||
setSegmentId(null);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_deleting_filter_segment"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!segment) return;
|
||||
deleteSegment({ id: segment.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("delete_segment")} />
|
||||
<div className="mb-6">{t("delete_segment_confirmation")}</div>
|
||||
<DialogFooter>
|
||||
<Button color="secondary" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button color="destructive" onClick={handleDelete} loading={isPending}>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function DuplicateSegmentDialog({
|
||||
segment,
|
||||
onClose,
|
||||
}: {
|
||||
segment: FilterSegmentOutput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
const { setSegmentId } = useDataTable();
|
||||
const utils = trpc.useUtils();
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
|
||||
const { mutate: createSegment, isPending } = trpc.viewer.filterSegments.create.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_duplicated"), "success");
|
||||
setSegmentId(id);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_duplicating_filter_segment"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: FormValues) => {
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||
|
||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||
createSegment({
|
||||
...rest,
|
||||
teamId: teamId ?? 0,
|
||||
scope: "TEAM",
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
createSegment({
|
||||
...rest,
|
||||
scope: "USER",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("duplicate_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<DialogFooter>
|
||||
<Button color="minimal" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("duplicate")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState, useMemo } from "react";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuLabel,
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
|
||||
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
|
||||
import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||
|
||||
type SubmenuItem = {
|
||||
iconName: IconName;
|
||||
labelKey: string;
|
||||
onClick: (segment: FilterSegmentOutput) => void;
|
||||
isDestructive?: boolean;
|
||||
adminOnly: boolean;
|
||||
};
|
||||
|
||||
export function FilterSegmentSelect() {
|
||||
const { t } = useLocale();
|
||||
const { segments, selectedSegment, segmentId, setSegmentId } = useDataTable();
|
||||
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
|
||||
|
||||
const submenuItems: SubmenuItem[] = [
|
||||
{
|
||||
iconName: "square-pen",
|
||||
labelKey: "rename",
|
||||
onClick: (segment) => setSegmentToRename(segment),
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
iconName: "copy",
|
||||
labelKey: "duplicate",
|
||||
onClick: (segment) => setSegmentToDuplicate(segment),
|
||||
adminOnly: false,
|
||||
},
|
||||
{
|
||||
iconName: "trash-2",
|
||||
labelKey: "delete",
|
||||
onClick: (segment) => setSegmentToDelete(segment),
|
||||
isDestructive: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const segmentGroups = useMemo(() => {
|
||||
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name);
|
||||
|
||||
const personalSegments = segments?.filter((segment) => !segment.team) || [];
|
||||
const teamSegments =
|
||||
segments?.filter(
|
||||
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||
segment.team !== null
|
||||
) || [];
|
||||
|
||||
// Group team segments by team name
|
||||
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
|
||||
(acc, segment) => {
|
||||
const teamName = segment.team.name;
|
||||
if (!acc[teamName]) {
|
||||
acc[teamName] = [];
|
||||
}
|
||||
acc[teamName].push(segment);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return [
|
||||
...(personalSegments.length > 0
|
||||
? [
|
||||
{
|
||||
label: t("personal"),
|
||||
segments: personalSegments.sort(sortFn),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...Object.entries(teamSegmentsByTeam)
|
||||
.map(([teamName, segments]) => ({
|
||||
label: teamName,
|
||||
segments: segments.sort(sortFn),
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
}, [segments, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button color="secondary" StartIcon="list-filter" EndIcon="chevron-down">
|
||||
{selectedSegment?.name || t("segment")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="start" className="w-60">
|
||||
{segmentGroups.length === 0 && <p className="text-subtle px-3 py-1">{t("no_segments")}</p>}
|
||||
|
||||
{segmentGroups.map((group, index) => (
|
||||
<div key={index}>
|
||||
{group.label && (
|
||||
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
|
||||
)}
|
||||
{group.segments.map((segment) => (
|
||||
<DropdownItemWithSubmenu
|
||||
key={segment.id}
|
||||
submenuItems={submenuItems}
|
||||
segment={segment}
|
||||
onSelect={() => {
|
||||
if (segmentId === segment.id) {
|
||||
setSegmentId(null);
|
||||
} else {
|
||||
setSegmentId(segment.id);
|
||||
}
|
||||
}}>
|
||||
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
|
||||
<span className="ml-3">{segment.name}</span>
|
||||
</DropdownItemWithSubmenu>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
|
||||
{segmentToRename && (
|
||||
<RenameSegmentDialog segment={segmentToRename} onClose={() => setSegmentToRename(undefined)} />
|
||||
)}
|
||||
|
||||
{segmentToDuplicate && (
|
||||
<DuplicateSegmentDialog
|
||||
segment={segmentToDuplicate}
|
||||
onClose={() => setSegmentToDuplicate(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{segmentToDelete && (
|
||||
<DeleteSegmentDialog segment={segmentToDelete} onClose={() => setSegmentToDelete(undefined)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownItemWithSubmenu({
|
||||
onSelect,
|
||||
segment,
|
||||
submenuItems,
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
segment: FilterSegmentOutput;
|
||||
submenuItems: SubmenuItem[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
|
||||
// Filter submenu items based on segment type and user role
|
||||
const filteredSubmenuItems = submenuItems.filter((item) => {
|
||||
if (!segment.team) {
|
||||
// Personal segments: show all actions
|
||||
return true;
|
||||
}
|
||||
|
||||
// Team segments: show if not admin-only or if user is admin/owner
|
||||
return !item.adminOnly || isAdminOrOwner;
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
|
||||
<div className="flex items-center">
|
||||
{children}
|
||||
<div className="grow" />
|
||||
<Dropdown open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button StartIcon="ellipsis" variant="icon" color="minimal" className="rounded-full" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuContent align="start" side="right" sideOffset={8}>
|
||||
{filteredSubmenuItems.map((item, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<DropdownItem
|
||||
color={item.isDestructive ? "destructive" : undefined}
|
||||
StartIcon={item.iconName}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
item.onClick(segment);
|
||||
setIsOpen(false);
|
||||
}}>
|
||||
{t(item.labelKey)}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui/components/dialog";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export function RenameSegmentDialog({
|
||||
segment,
|
||||
onClose,
|
||||
}: {
|
||||
segment: FilterSegmentOutput;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: segment.name,
|
||||
},
|
||||
});
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { mutate: updateSegment, isPending } = trpc.viewer.filterSegments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_updated"), "success");
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_updating_filter_segment"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: FormValues) => {
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
if (segment.scope === "TEAM") {
|
||||
updateSegment({
|
||||
...segment,
|
||||
scope: "TEAM",
|
||||
teamId: segment.teamId ?? 0,
|
||||
name: data.name,
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateSegment({
|
||||
...segment,
|
||||
scope: "USER",
|
||||
name: data.name,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("rename_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<DialogFooter>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { type FilterSegmentScope } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "@calcom/ui/components/dialog";
|
||||
import { Form, Input, Label, Select, Switch } from "@calcom/ui/components/form";
|
||||
import { RadioGroup, RadioField } from "@calcom/ui/components/radio";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
scope: FilterSegmentScope;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
export function SaveFilterSegmentButton() {
|
||||
const { t } = useLocale();
|
||||
const utils = trpc.useUtils();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isTeamSegment, setIsTeamSegment] = useState(false);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<number>();
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
scope: "USER",
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
selectedSegment,
|
||||
canSaveSegment,
|
||||
setSegmentId,
|
||||
} = useDataTable();
|
||||
|
||||
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
|
||||
selectedSegment ? "update" : "create"
|
||||
);
|
||||
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
|
||||
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_saved"), "success");
|
||||
setSegmentId(id);
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_saving_filter_segment"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateSegment } = trpc.viewer.filterSegments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_updated"), "success");
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
showToast(t("error_updating_filter_segment"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (isTeamSegment && !selectedTeamId) {
|
||||
showToast(t("please_select_team"), "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const segmentData = {
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
perPage: 10,
|
||||
};
|
||||
|
||||
if (saveMode === "update") {
|
||||
if (!selectedSegment) {
|
||||
// theoretically this should never happen
|
||||
showToast(t("segment_not_found"), "error");
|
||||
return;
|
||||
}
|
||||
const scope = selectedSegment.scope;
|
||||
if (scope === "TEAM") {
|
||||
updateSegment({
|
||||
id: selectedSegment.id,
|
||||
scope,
|
||||
teamId: selectedSegment.teamId || 0,
|
||||
name: selectedSegment.name,
|
||||
...segmentData,
|
||||
});
|
||||
} else {
|
||||
updateSegment({
|
||||
id: selectedSegment.id,
|
||||
scope,
|
||||
name: selectedSegment.name,
|
||||
...segmentData,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (isTeamSegment) {
|
||||
createSegment({
|
||||
...segmentData,
|
||||
name: values.name,
|
||||
scope: "TEAM",
|
||||
teamId: selectedTeamId || 0,
|
||||
});
|
||||
} else {
|
||||
createSegment({
|
||||
...segmentData,
|
||||
name: values.name,
|
||||
scope: "USER",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
// Reset form state when dialog closes
|
||||
setIsTeamSegment(false);
|
||||
setSelectedTeamId(undefined);
|
||||
setSaveMode(selectedSegment ? "update" : "create");
|
||||
form.reset();
|
||||
}
|
||||
setIsOpen(open);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button StartIcon="bookmark" color="secondary" disabled={!canSaveSegment}>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader title={t("save_segment")} />
|
||||
<Form form={form} handleSubmit={onSubmit}>
|
||||
{selectedSegment ? (
|
||||
<div className="mb-4">
|
||||
<RadioGroup
|
||||
defaultValue="update"
|
||||
onValueChange={(value: string) => setSaveMode(value as "create" | "update")}
|
||||
className="space-y-2">
|
||||
<RadioField
|
||||
id="update_segment"
|
||||
label={t("override_segment", { name: selectedSegment.name })}
|
||||
value="update"
|
||||
/>
|
||||
<RadioField id="create_segment" label={t("create_new_segment")} value="create" />
|
||||
</RadioGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
{saveMode === "create" && (
|
||||
<div>
|
||||
<Label>{t("name")}</Label>
|
||||
<Input {...form.register("name")} required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdminOrOwner && saveMode === "create" && (
|
||||
<>
|
||||
<div className="-ml-2 mt-4">
|
||||
<Switch
|
||||
id="teamSegment"
|
||||
label={t("save_for_team")}
|
||||
checked={isTeamSegment}
|
||||
onCheckedChange={setIsTeamSegment}
|
||||
labelOnLeading
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isTeamSegment && teams && teams.length > 0 && (
|
||||
<div>
|
||||
<Select<{ value: string; label: string }>
|
||||
options={teams.map((team) => ({
|
||||
value: team.id.toString(),
|
||||
label: team.name,
|
||||
}))}
|
||||
onChange={(option) => setSelectedTeamId(parseInt(option?.value || "0"))}
|
||||
placeholder={t("select_team")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" color="minimal" onClick={() => setIsOpen(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button type="submit">{t("save")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,35 +3,16 @@
|
||||
import type { Header, Table, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import { useDebouncedWidth } from "../hooks";
|
||||
import { useDebouncedWidth, useDataTable } from "../hooks";
|
||||
|
||||
type UsePersistentColumnResizingProps<TData> = {
|
||||
enabled: boolean;
|
||||
table: Table<TData>;
|
||||
identifier?: string;
|
||||
tableContainerRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
function getLocalStorageKey(identifier: string) {
|
||||
return `data-table-column-sizing-${identifier}`;
|
||||
}
|
||||
|
||||
function loadColumnSizing(identifier: string): ColumnSizingState {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(getLocalStorageKey(identifier)) || "{}");
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveColumnSizing(identifier: string, columnSizing: ColumnSizingState) {
|
||||
localStorage.setItem(getLocalStorageKey(identifier), JSON.stringify(columnSizing));
|
||||
}
|
||||
|
||||
const debouncedSaveColumnSizing = debounce(saveColumnSizing, 1000);
|
||||
|
||||
function getAdjustedColumnSizing<TData>({
|
||||
headers,
|
||||
containerWidth,
|
||||
@@ -103,20 +84,21 @@ function getPartialColumnSizing(columnSizing: ColumnSizingState, columnsToExtrac
|
||||
export function usePersistentColumnResizing<TData>({
|
||||
enabled,
|
||||
table,
|
||||
identifier,
|
||||
tableContainerRef,
|
||||
}: UsePersistentColumnResizingProps<TData>) {
|
||||
const initialized = useRef(false);
|
||||
const columnSizing = useRef<ColumnSizingState>({});
|
||||
const initialColumnSizing = useRef<ColumnSizingState>({});
|
||||
const resizedColumns = useRef<Set<string>>(new Set());
|
||||
const { columnSizing: loadedColumnSizing, setColumnSizing: setColumnSizingToContext } = useDataTable();
|
||||
|
||||
const debouncedSaveColumnSizing = useMemo(
|
||||
() => debounce(setColumnSizingToContext, 1000),
|
||||
[setColumnSizingToContext]
|
||||
);
|
||||
|
||||
const adjustColumnSizing = useCallback(
|
||||
(updater: ColumnSizingState | ((old: ColumnSizingState) => ColumnSizingState)) => {
|
||||
// `!identifier` is checked already in the `useEffect` hook,
|
||||
// but TS doesn't know that, and this won't happen.
|
||||
if (!identifier) return;
|
||||
|
||||
table.setState((oldTableState) => {
|
||||
let newColumnSizing = typeof updater === "function" ? updater(oldTableState.columnSizing) : updater;
|
||||
newColumnSizing = getAdjustedColumnSizing({
|
||||
@@ -126,10 +108,7 @@ export function usePersistentColumnResizing<TData>({
|
||||
currentColumnSizing: newColumnSizing,
|
||||
resizedColumns: resizedColumns.current,
|
||||
});
|
||||
debouncedSaveColumnSizing(
|
||||
identifier,
|
||||
getPartialColumnSizing(newColumnSizing, resizedColumns.current)
|
||||
);
|
||||
debouncedSaveColumnSizing(getPartialColumnSizing(newColumnSizing, resizedColumns.current));
|
||||
columnSizing.current = newColumnSizing;
|
||||
|
||||
return {
|
||||
@@ -138,7 +117,7 @@ export function usePersistentColumnResizing<TData>({
|
||||
};
|
||||
});
|
||||
},
|
||||
[table, identifier, tableContainerRef]
|
||||
[table, tableContainerRef, debouncedSaveColumnSizing]
|
||||
);
|
||||
|
||||
const onColumnSizingChange = useCallback(
|
||||
@@ -149,13 +128,13 @@ export function usePersistentColumnResizing<TData>({
|
||||
}
|
||||
adjustColumnSizing(updater);
|
||||
},
|
||||
[identifier, table, adjustColumnSizing]
|
||||
[table, adjustColumnSizing]
|
||||
);
|
||||
|
||||
const debouncedContainerWidth = useDebouncedWidth(tableContainerRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !identifier || !initialized.current) return;
|
||||
if (!enabled || !initialized.current) return;
|
||||
const newColumnSizing = getAdjustedColumnSizing({
|
||||
headers: table.getFlatHeaders(),
|
||||
containerWidth: debouncedContainerWidth,
|
||||
@@ -172,11 +151,9 @@ export function usePersistentColumnResizing<TData>({
|
||||
}, [debouncedContainerWidth, table.getFlatHeaders().length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !identifier) return;
|
||||
if (!enabled) return;
|
||||
|
||||
// loadedColumnSizing is a partial object of explicitly resized columns.
|
||||
const loadedColumnSizing = loadColumnSizing(identifier);
|
||||
|
||||
// combine loaded sizes and the default sizes from TanStack Table
|
||||
initialColumnSizing.current = table.getFlatHeaders().reduce((acc, header) => {
|
||||
acc[header.id] = loadedColumnSizing[header.id] || header.getSize();
|
||||
@@ -197,5 +174,5 @@ export function usePersistentColumnResizing<TData>({
|
||||
}));
|
||||
|
||||
initialized.current = true;
|
||||
}, [enabled, identifier, table, onColumnSizingChange]);
|
||||
}, [enabled, table, onColumnSizingChange]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { SortingState, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { isEqual } from "lodash";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { type ActiveFilters, ZSegmentStorage } from "./types";
|
||||
|
||||
type UseSegmentsProps = {
|
||||
tableIdentifier: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting: SortingState;
|
||||
columnVisibility: VisibilityState;
|
||||
columnSizing: ColumnSizingState;
|
||||
pageSize: number;
|
||||
defaultPageSize: number;
|
||||
segmentId: number;
|
||||
setSegmentId: (segmentId: number | null) => void;
|
||||
setActiveFilters: (activeFilters: ActiveFilters) => void;
|
||||
setSorting: (sorting: SortingState) => void;
|
||||
setColumnVisibility: (columnVisibility: VisibilityState) => void;
|
||||
setColumnSizing: (columnSizing: ColumnSizingState) => void;
|
||||
setPageSize: (pageSize: number) => void;
|
||||
setPageIndex: (pageIndex: number) => void;
|
||||
};
|
||||
|
||||
export function useSegments({
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
}: UseSegmentsProps) {
|
||||
const { data: segments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery({
|
||||
tableIdentifier,
|
||||
});
|
||||
const selectedSegment = useMemo(
|
||||
() => segments?.find((segment) => segment.id === segmentId),
|
||||
[segments, segmentId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (segments && segmentId > 0 && !isFetchingSegments) {
|
||||
const segment = segments.find((segment) => segment.id === segmentId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
|
||||
|
||||
useEffect(() => {
|
||||
// this hook doesn't include segmentId in the dependency array
|
||||
// because we want to only run this once, when the component mounts
|
||||
if (segmentId === -1) {
|
||||
const segments = getSegmentsFromLocalStorage();
|
||||
if (segments[tableIdentifier]) {
|
||||
setSegmentId(segments[tableIdentifier].segmentId);
|
||||
}
|
||||
}
|
||||
}, [tableIdentifier, setSegmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
// segment is selected, so we apply the filters, sorting, etc. from the segment
|
||||
setActiveFilters(selectedSegment.activeFilters);
|
||||
setSorting(selectedSegment.sorting);
|
||||
setColumnVisibility(selectedSegment.columnVisibility);
|
||||
setColumnSizing(selectedSegment.columnSizing);
|
||||
setPageSize(selectedSegment.perPage);
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [
|
||||
selectedSegment,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
]);
|
||||
|
||||
const canSaveSegment = useMemo(() => {
|
||||
if (!selectedSegment) {
|
||||
// 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
|
||||
);
|
||||
} else {
|
||||
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
|
||||
return (
|
||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||
!isEqual(sorting, selectedSegment.sorting) ||
|
||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||
!isEqual(pageSize, selectedSegment.perPage)
|
||||
);
|
||||
}
|
||||
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, defaultPageSize]);
|
||||
|
||||
const setSegmentIdAndSaveToLocalStorage = useCallback(
|
||||
(segmentId: number | null) => {
|
||||
setSegmentId(segmentId);
|
||||
saveSegmentToLocalStorage({ tableIdentifier, segmentId });
|
||||
},
|
||||
[tableIdentifier, setSegmentId]
|
||||
);
|
||||
|
||||
return {
|
||||
segments: segments ?? [],
|
||||
selectedSegment,
|
||||
canSaveSegment,
|
||||
setSegmentIdAndSaveToLocalStorage,
|
||||
};
|
||||
}
|
||||
|
||||
const LOCAL_STORAGE_KEY = "data-table:segments";
|
||||
|
||||
function getSegmentsFromLocalStorage() {
|
||||
try {
|
||||
return ZSegmentStorage.parse(JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveSegmentToLocalStorage({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
}: {
|
||||
tableIdentifier: string;
|
||||
segmentId: number | null;
|
||||
}) {
|
||||
const segments = getSegmentsFromLocalStorage();
|
||||
if (segmentId) {
|
||||
segments[tableIdentifier] = { segmentId };
|
||||
} else {
|
||||
delete segments[tableIdentifier];
|
||||
}
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(segments));
|
||||
}
|
||||
@@ -211,6 +211,10 @@ export const ZSorting = z.object({
|
||||
desc: z.boolean(),
|
||||
}) satisfies z.ZodType<Sorting>;
|
||||
|
||||
export const ZSortingState = z.array(ZSorting);
|
||||
|
||||
export const ZColumnSizing = z.record(z.string(), z.number());
|
||||
|
||||
export const ZColumnVisibility = z.record(z.string(), z.boolean());
|
||||
|
||||
export type FacetedValue = {
|
||||
@@ -218,3 +222,47 @@ export type FacetedValue = {
|
||||
value: string | number;
|
||||
section?: string;
|
||||
};
|
||||
|
||||
export type ActiveFilter = {
|
||||
f: string;
|
||||
v?: FilterValue;
|
||||
};
|
||||
|
||||
export type ActiveFilters = ActiveFilter[];
|
||||
|
||||
export const ZActiveFilter = z.object({
|
||||
f: z.string(),
|
||||
v: ZFilterValue.optional(),
|
||||
}) satisfies z.ZodType<ActiveFilter>;
|
||||
|
||||
export const ZActiveFilters = ZActiveFilter.array();
|
||||
|
||||
export type FilterSegmentOutput = {
|
||||
id: number;
|
||||
name: string;
|
||||
tableIdentifier: string;
|
||||
scope: "USER" | "TEAM";
|
||||
activeFilters: ActiveFilters;
|
||||
sorting: SortingState;
|
||||
columnVisibility: Record<string, boolean>;
|
||||
columnSizing: Record<string, number>;
|
||||
perPage: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
userId: number;
|
||||
teamId: number | null;
|
||||
team: { id: number; name: string } | null;
|
||||
};
|
||||
|
||||
export type SegmentStorage = {
|
||||
[tableIdentifier: string]: {
|
||||
segmentId: number;
|
||||
};
|
||||
};
|
||||
|
||||
export const ZSegmentStorage = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
segmentId: z.number(),
|
||||
})
|
||||
) satisfies z.ZodType<SegmentStorage>;
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
useColumnFilters,
|
||||
ColumnFilterType,
|
||||
convertFacetedValuesToMap,
|
||||
SaveFilterSegmentButton,
|
||||
FilterSegmentSelect,
|
||||
useDataTable,
|
||||
CTA_CONTAINER_CLASS_NAME,
|
||||
} from "@calcom/features/data-table";
|
||||
@@ -532,12 +534,19 @@ function UserListTableContent() {
|
||||
ToolbarLeft={
|
||||
<>
|
||||
<DataTableToolbar.SearchBar table={table} onSearch={(value) => setDebouncedSearchTerm(value)} />
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} hideWhenFilterApplied />
|
||||
<DataTableFilters.ActiveFilters table={table} />
|
||||
<DataTableFilters.AddFilterButton table={table} variant="sm" showWhenFilterApplied />
|
||||
</>
|
||||
}
|
||||
ToolbarRight={<DataTableFilters.ClearFiltersButton />}>
|
||||
ToolbarRight={
|
||||
<>
|
||||
<DataTableFilters.ClearFiltersButton />
|
||||
<SaveFilterSegmentButton />
|
||||
<FilterSegmentSelect />
|
||||
</>
|
||||
}>
|
||||
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
|
||||
<DataTableSelectionBar.Root className="!bottom-[7.3rem] md:!bottom-32">
|
||||
<DynamicLink table={table} domain={domain} />
|
||||
@@ -589,7 +598,6 @@ function UserListTableContent() {
|
||||
data-testid="export-members-button">
|
||||
{t("download")}
|
||||
</DataTableToolbar.CTA>
|
||||
<DataTableFilters.ColumnVisibilityButton table={table} />
|
||||
{adminOrOwner && (
|
||||
<DataTableToolbar.CTA
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "FilterSegmentScope" AS ENUM ('USER', 'TEAM');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "FilterSegment" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"tableIdentifier" TEXT NOT NULL,
|
||||
"scope" "FilterSegmentScope" NOT NULL,
|
||||
"activeFilters" JSONB,
|
||||
"sorting" JSONB,
|
||||
"columnVisibility" JSONB,
|
||||
"columnSizing" JSONB,
|
||||
"perPage" INTEGER NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"teamId" INTEGER,
|
||||
|
||||
CONSTRAINT "FilterSegment_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FilterSegment_scope_userId_tableIdentifier_idx" ON "FilterSegment"("scope", "userId", "tableIdentifier");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "FilterSegment_scope_teamId_tableIdentifier_idx" ON "FilterSegment"("scope", "teamId", "tableIdentifier");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FilterSegment" ADD CONSTRAINT "FilterSegment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "FilterSegment" ADD CONSTRAINT "FilterSegment_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -381,6 +381,7 @@ model User {
|
||||
BookingInternalNote BookingInternalNote[]
|
||||
creationSource CreationSource?
|
||||
createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings")
|
||||
filterSegments FilterSegment[]
|
||||
|
||||
@@unique([email])
|
||||
@@unique([email, username])
|
||||
@@ -502,6 +503,7 @@ model Team {
|
||||
// it means that it is an organization managed by another organization.
|
||||
managedOrganization ManagedOrganization? @relation("ManagedOrganization")
|
||||
managedOrganizations ManagedOrganization[] @relation("ManagerOrganization")
|
||||
filterSegments FilterSegment[]
|
||||
|
||||
@@unique([slug, parentId])
|
||||
@@index([parentId])
|
||||
@@ -2006,6 +2008,38 @@ model InternalNotePreset {
|
||||
@@index([teamId])
|
||||
}
|
||||
|
||||
enum FilterSegmentScope {
|
||||
USER
|
||||
TEAM
|
||||
}
|
||||
|
||||
model FilterSegment {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
// Identifies which data table this segment belongs to (e.g. "organization_members", "team_members", "bookings", etc.)
|
||||
tableIdentifier String
|
||||
scope FilterSegmentScope
|
||||
// Filter configuration
|
||||
activeFilters Json?
|
||||
sorting Json?
|
||||
columnVisibility Json?
|
||||
columnSizing Json?
|
||||
perPage Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
// Creator of the segment
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
userId Int
|
||||
// Team scope - optional, only set when scope is TEAM
|
||||
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
|
||||
teamId Int?
|
||||
|
||||
// For user-scoped segments: scope + userId + tableIdentifier
|
||||
@@index([scope, userId, tableIdentifier])
|
||||
// For team-scoped segments: scope + teamId + tableIdentifier
|
||||
@@index([scope, teamId, tableIdentifier])
|
||||
}
|
||||
|
||||
model BookingInternalNote {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
|
||||
@@ -35,4 +35,5 @@ export const ENDPOINTS = [
|
||||
"attributes",
|
||||
"delegationCredential",
|
||||
"routingForms",
|
||||
"filterSegments",
|
||||
] as const;
|
||||
|
||||
@@ -19,6 +19,7 @@ import { delegationCredentialRouter } from "./delegationCredential/_router";
|
||||
import { deploymentSetupRouter } from "./deploymentSetup/_router";
|
||||
import { dsyncRouter } from "./dsync/_router";
|
||||
import { eventTypesRouter } from "./eventTypes/_router";
|
||||
import { filterSegmentsRouter } from "./filterSegments/_router";
|
||||
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||
import { highPerfRouter } from "./highPerf/_router";
|
||||
import { i18nRouter } from "./i18n/_router";
|
||||
@@ -60,6 +61,7 @@ export const viewerRouter = mergeRouters(
|
||||
i18n: i18nRouter,
|
||||
insights: insightsRouter,
|
||||
payments: paymentsRouter,
|
||||
filterSegments: filterSegmentsRouter,
|
||||
// NOTE: Add all app related routes in the bottom till the problem described in @calcom/app-store/trpc-routers.ts is solved.
|
||||
// After that there would just one merge call here for all the apps.
|
||||
appRoutingForms: app_RoutingForms,
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { createHandler } from "../create.handler";
|
||||
import { type TCreateFilterSegmentInputSchema } from "../create.schema";
|
||||
|
||||
describe("createHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
} as NonNullable<TrpcSessionUser>;
|
||||
|
||||
const baseInput = {
|
||||
tableIdentifier: "bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
};
|
||||
|
||||
it("should create a user-scoped filter segment", async () => {
|
||||
const input: TCreateFilterSegmentInputSchema = {
|
||||
...baseInput,
|
||||
scope: "USER",
|
||||
name: "My Bookings",
|
||||
};
|
||||
|
||||
const result = await createHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Bookings",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create a team-scoped filter segment for admin user", async () => {
|
||||
// Create team membership first
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TCreateFilterSegmentInputSchema = {
|
||||
...baseInput,
|
||||
scope: "TEAM",
|
||||
teamId: 1,
|
||||
name: "Team Bookings",
|
||||
};
|
||||
|
||||
const result = await createHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
teamId: 1,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when creating team segment without admin role", async () => {
|
||||
// Create team membership with MEMBER role
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: 1,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TCreateFilterSegmentInputSchema = {
|
||||
...baseInput,
|
||||
scope: "TEAM",
|
||||
teamId: 1,
|
||||
name: "Team Bookings",
|
||||
};
|
||||
|
||||
await expect(
|
||||
createHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
})
|
||||
).rejects.toThrow("You must be a team admin or owner to create team segments");
|
||||
});
|
||||
|
||||
it("should throw error when creating team segment without teamId", async () => {
|
||||
const invalidInput = {
|
||||
...baseInput,
|
||||
scope: "TEAM",
|
||||
name: "Team Bookings",
|
||||
};
|
||||
|
||||
await expect(
|
||||
createHandler({
|
||||
ctx: { user: mockUser },
|
||||
// @ts-expect-error - Testing invalid input where teamId is missing for TEAM scope
|
||||
input: invalidInput,
|
||||
})
|
||||
).rejects.toThrow("Team ID is required for team scope");
|
||||
});
|
||||
|
||||
it("should throw error when creating user segment with teamId", async () => {
|
||||
const invalidInput = {
|
||||
...baseInput,
|
||||
scope: "USER",
|
||||
name: "My Bookings",
|
||||
teamId: 1,
|
||||
};
|
||||
|
||||
await expect(
|
||||
createHandler({
|
||||
ctx: { user: mockUser },
|
||||
// @ts-expect-error - Testing invalid input where teamId is present for USER scope
|
||||
input: invalidInput,
|
||||
})
|
||||
).rejects.toThrow("Team ID is not allowed for user scope");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { deleteHandler } from "../delete.handler";
|
||||
import { type TDeleteFilterSegmentInputSchema } from "../delete.schema";
|
||||
|
||||
describe("deleteHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
} as NonNullable<TrpcSessionUser>;
|
||||
|
||||
it("should delete a user-scoped filter segment", async () => {
|
||||
// Create a user-scoped segment first
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TDeleteFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
};
|
||||
|
||||
const result = await deleteHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: segment.id,
|
||||
message: "Filter segment deleted successfully",
|
||||
});
|
||||
|
||||
// Verify segment is deleted
|
||||
const deletedSegment = await prismock.filterSegment.findUnique({
|
||||
where: { id: segment.id },
|
||||
});
|
||||
expect(deletedSegment).toBeNull();
|
||||
});
|
||||
|
||||
it("should delete a team-scoped filter segment for admin user", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a team-scoped segment
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TDeleteFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
};
|
||||
|
||||
const result = await deleteHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
id: segment.id,
|
||||
message: "Filter segment deleted successfully",
|
||||
});
|
||||
|
||||
// Verify segment is deleted
|
||||
const deletedSegment = await prismock.filterSegment.findUnique({
|
||||
where: { id: segment.id },
|
||||
});
|
||||
expect(deletedSegment).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw error when deleting non-existent segment", async () => {
|
||||
const input: TDeleteFilterSegmentInputSchema = {
|
||||
id: 999,
|
||||
};
|
||||
|
||||
await expect(
|
||||
deleteHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
})
|
||||
).rejects.toThrow("Filter segment not found or you don't have permission to delete it");
|
||||
});
|
||||
|
||||
it("should throw error when deleting team segment without admin role", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership with MEMBER role
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a team-scoped segment
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TDeleteFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
};
|
||||
|
||||
await expect(
|
||||
deleteHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
})
|
||||
).rejects.toThrow("Filter segment not found or you don't have permission to delete it");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { listHandler } from "../list.handler";
|
||||
import { type TListFilterSegmentsInputSchema } from "../list.schema";
|
||||
|
||||
describe("listHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
} as NonNullable<TrpcSessionUser>;
|
||||
|
||||
it("should return user-scoped filter segments", async () => {
|
||||
// Create user-scoped segments
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Other Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 20,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TListFilterSegmentsInputSchema = {
|
||||
tableIdentifier: "bookings",
|
||||
};
|
||||
|
||||
const result = await listHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "My Other Bookings",
|
||||
scope: "USER",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 20,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "My Bookings",
|
||||
scope: "USER",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should return team-scoped filter segments for team member", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// Create team-scoped segments
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Other Team Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TListFilterSegmentsInputSchema = {
|
||||
tableIdentifier: "bookings",
|
||||
};
|
||||
|
||||
const result = await listHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "Other Team Bookings",
|
||||
scope: "TEAM",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Team Bookings",
|
||||
scope: "TEAM",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should return both user-scoped and team-scoped segments", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// Create user-scoped segment
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
// Create team-scoped segment
|
||||
await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TListFilterSegmentsInputSchema = {
|
||||
tableIdentifier: "bookings",
|
||||
};
|
||||
|
||||
const result = await listHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "My Bookings",
|
||||
scope: "USER",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Team Bookings",
|
||||
scope: "TEAM",
|
||||
activeFilters: [{ f: "status" }],
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("should return empty array when no segments exist", async () => {
|
||||
const input: TListFilterSegmentsInputSchema = {
|
||||
tableIdentifier: "bookings",
|
||||
};
|
||||
|
||||
const result = await listHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import prismock from "../../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
|
||||
|
||||
import { updateHandler } from "../update.handler";
|
||||
import { type TUpdateFilterSegmentInputSchema } from "../update.schema";
|
||||
|
||||
describe("updateHandler", () => {
|
||||
const mockUser = {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
} as NonNullable<TrpcSessionUser>;
|
||||
|
||||
it("should update a user-scoped filter segment", async () => {
|
||||
// Create a user-scoped segment first
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
scope: "USER",
|
||||
tableIdentifier: "bookings",
|
||||
name: "My Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TUpdateFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
scope: "USER",
|
||||
name: "Updated Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 20,
|
||||
};
|
||||
|
||||
const result = await updateHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: segment.id,
|
||||
name: "Updated Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should update a team-scoped filter segment for admin user", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership first
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a team-scoped segment
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TUpdateFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
scope: "TEAM",
|
||||
teamId: team.id,
|
||||
name: "Updated Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
};
|
||||
|
||||
const result = await updateHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
id: segment.id,
|
||||
name: "Updated Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: false }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 15,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw error when updating non-existent segment", async () => {
|
||||
const input: TUpdateFilterSegmentInputSchema = {
|
||||
id: 999,
|
||||
scope: "USER",
|
||||
name: "Updated Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
})
|
||||
).rejects.toThrow("Filter segment not found or you don't have permission to update it");
|
||||
});
|
||||
|
||||
it("should throw error when updating team segment without admin role", async () => {
|
||||
// Create team first
|
||||
const team = await prismock.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: "test-team",
|
||||
},
|
||||
});
|
||||
|
||||
// Create team membership with MEMBER role
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
accepted: true,
|
||||
role: MembershipRole.MEMBER,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a team-scoped segment
|
||||
const segment = await prismock.filterSegment.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: team.id,
|
||||
scope: "TEAM",
|
||||
tableIdentifier: "bookings",
|
||||
name: "Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
},
|
||||
});
|
||||
|
||||
const input: TUpdateFilterSegmentInputSchema = {
|
||||
id: segment.id,
|
||||
scope: "TEAM",
|
||||
teamId: team.id,
|
||||
name: "Updated Team Bookings",
|
||||
activeFilters: { f: "status" },
|
||||
sorting: [{ id: "date", desc: true }],
|
||||
columnVisibility: {},
|
||||
columnSizing: {},
|
||||
perPage: 10,
|
||||
};
|
||||
|
||||
await expect(
|
||||
updateHandler({
|
||||
ctx: { user: mockUser },
|
||||
input,
|
||||
})
|
||||
).rejects.toThrow("Filter segment not found or you don't have permission to update it");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZCreateFilterSegmentInputSchema } from "./create.schema";
|
||||
import { ZDeleteFilterSegmentInputSchema } from "./delete.schema";
|
||||
import { ZListFilterSegmentsInputSchema } from "./list.schema";
|
||||
import { ZUpdateFilterSegmentInputSchema } from "./update.schema";
|
||||
|
||||
type FilterSegmentsRouterHandlerCache = {
|
||||
list?: typeof import("./list.handler").listHandler;
|
||||
create?: typeof import("./create.handler").createHandler;
|
||||
update?: typeof import("./update.handler").updateHandler;
|
||||
delete?: typeof import("./delete.handler").deleteHandler;
|
||||
};
|
||||
|
||||
const UNSTABLE_HANDLER_CACHE: FilterSegmentsRouterHandlerCache = {};
|
||||
|
||||
export const filterSegmentsRouter = router({
|
||||
list: authedProcedure.input(ZListFilterSegmentsInputSchema).query(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.list) {
|
||||
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.list) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.list({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
create: authedProcedure.input(ZCreateFilterSegmentInputSchema).mutation(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.create) {
|
||||
UNSTABLE_HANDLER_CACHE.create = await import("./create.handler").then((mod) => mod.createHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.create) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.create({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
update: authedProcedure.input(ZUpdateFilterSegmentInputSchema).mutation(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.update) {
|
||||
UNSTABLE_HANDLER_CACHE.update = await import("./update.handler").then((mod) => mod.updateHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.update) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.update({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
|
||||
delete: authedProcedure.input(ZDeleteFilterSegmentInputSchema).mutation(async ({ input, ctx }) => {
|
||||
if (!UNSTABLE_HANDLER_CACHE.delete) {
|
||||
UNSTABLE_HANDLER_CACHE.delete = await import("./delete.handler").then((mod) => mod.deleteHandler);
|
||||
}
|
||||
|
||||
// Unreachable code but required for type safety
|
||||
if (!UNSTABLE_HANDLER_CACHE.delete) {
|
||||
throw new Error("Failed to load handler");
|
||||
}
|
||||
|
||||
return UNSTABLE_HANDLER_CACHE.delete({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TCreateFilterSegmentInputSchema } from "./create.schema";
|
||||
|
||||
export const createHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TCreateFilterSegmentInputSchema;
|
||||
}) => {
|
||||
const { scope, teamId, ...filterData } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// If scope is TEAM, verify user has admin/owner permissions
|
||||
if (scope === "TEAM") {
|
||||
if (!teamId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Team ID is required for team scope",
|
||||
});
|
||||
}
|
||||
|
||||
const membership = await prisma.membership.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
teamId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be a team admin or owner to create team segments",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// For USER scope, ensure no teamId is provided
|
||||
if (scope === "USER" && teamId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Team ID is not allowed for user scope",
|
||||
});
|
||||
}
|
||||
|
||||
// Create the filter segment
|
||||
const filterSegment = await prisma.filterSegment.create({
|
||||
data: {
|
||||
...filterData,
|
||||
scope,
|
||||
...(scope === "TEAM" ? { teamId } : {}),
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return filterSegment;
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZActiveFilters, ZColumnSizing, ZColumnVisibility, ZSortingState } from "./types";
|
||||
|
||||
// Base schema for common fields
|
||||
const baseCreateSchema = {
|
||||
name: z.string().min(1),
|
||||
tableIdentifier: z.string().min(1),
|
||||
activeFilters: ZActiveFilters.optional(),
|
||||
sorting: ZSortingState.optional(),
|
||||
columnVisibility: ZColumnVisibility.optional(),
|
||||
columnSizing: ZColumnSizing.optional(),
|
||||
perPage: z.number().int().min(1),
|
||||
};
|
||||
|
||||
// Schema for team scope - requires teamId
|
||||
const teamCreateSchema = z.object({
|
||||
scope: z.literal("TEAM"),
|
||||
teamId: z.number().int().positive(),
|
||||
...baseCreateSchema,
|
||||
});
|
||||
|
||||
// Schema for user scope - no teamId allowed
|
||||
const userCreateSchema = z.object({
|
||||
scope: z.literal("USER"),
|
||||
teamId: z.undefined(),
|
||||
...baseCreateSchema,
|
||||
});
|
||||
|
||||
export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
|
||||
teamCreateSchema,
|
||||
userCreateSchema,
|
||||
]);
|
||||
|
||||
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TDeleteFilterSegmentInputSchema } from "./delete.schema";
|
||||
|
||||
export const deleteHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TDeleteFilterSegmentInputSchema;
|
||||
}) => {
|
||||
const { id } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// First, fetch the existing segment to check permissions
|
||||
const existingSegment = await prisma.filterSegment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
OR: [
|
||||
{
|
||||
scope: "TEAM",
|
||||
teamId: { not: null },
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
scope: "USER",
|
||||
userId,
|
||||
},
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSegment) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Filter segment not found or you don't have permission to delete it",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the filter segment
|
||||
await prisma.filterSegment.delete({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
message: "Filter segment deleted successfully",
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZDeleteFilterSegmentInputSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export type TDeleteFilterSegmentInputSchema = z.infer<typeof ZDeleteFilterSegmentInputSchema>;
|
||||
@@ -0,0 +1,80 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import type { TListFilterSegmentsInputSchema } from "./list.schema";
|
||||
import {
|
||||
ZActiveFilters,
|
||||
ZSortingState,
|
||||
ZColumnSizing,
|
||||
ZColumnVisibility,
|
||||
type FilterSegmentOutput,
|
||||
} from "./types";
|
||||
|
||||
export const listHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TListFilterSegmentsInputSchema;
|
||||
}) => {
|
||||
const { tableIdentifier } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// Get all teams that the user is a member of
|
||||
const userTeamIds = await prisma.membership
|
||||
.findMany({
|
||||
where: {
|
||||
userId,
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
},
|
||||
})
|
||||
.then((memberships) => memberships.map((m) => m.teamId));
|
||||
|
||||
// Fetch both user-scoped and team-scoped segments
|
||||
const segments = await prisma.filterSegment.findMany({
|
||||
where: {
|
||||
tableIdentifier,
|
||||
OR: [
|
||||
// User-scoped segments created by the current user
|
||||
{
|
||||
scope: "USER",
|
||||
userId,
|
||||
},
|
||||
// Team-scoped segments for teams the user belongs to
|
||||
{
|
||||
scope: "TEAM",
|
||||
teamId: {
|
||||
in: userTeamIds,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
include: {
|
||||
team: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ scope: "desc" }, // USER segments first, then TEAM segments
|
||||
{ createdAt: "desc" }, // Newest first within each scope
|
||||
],
|
||||
});
|
||||
|
||||
const parsedSegments: FilterSegmentOutput[] = segments.map((segment) => ({
|
||||
...segment,
|
||||
activeFilters: ZActiveFilters.catch([]).parse(segment.activeFilters),
|
||||
sorting: ZSortingState.catch([]).parse(segment.sorting),
|
||||
columnVisibility: ZColumnVisibility.catch({}).parse(segment.columnVisibility),
|
||||
columnSizing: ZColumnSizing.catch({}).parse(segment.columnSizing),
|
||||
}));
|
||||
|
||||
return parsedSegments;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZListFilterSegmentsInputSchema = z.object({
|
||||
tableIdentifier: z.string(),
|
||||
});
|
||||
|
||||
export type TListFilterSegmentsInputSchema = z.infer<typeof ZListFilterSegmentsInputSchema>;
|
||||
@@ -0,0 +1,9 @@
|
||||
export type { SortingState, ColumnSizingState, VisibilityState } from "@tanstack/react-table";
|
||||
export {
|
||||
type ActiveFilter,
|
||||
type FilterSegmentOutput,
|
||||
ZActiveFilters,
|
||||
ZSortingState,
|
||||
ZColumnSizing,
|
||||
ZColumnVisibility,
|
||||
} from "@calcom/features/data-table/lib/types";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import type { TUpdateFilterSegmentInputSchema } from "./update.schema";
|
||||
|
||||
export const updateHandler = async ({
|
||||
ctx,
|
||||
input,
|
||||
}: {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TUpdateFilterSegmentInputSchema;
|
||||
}) => {
|
||||
const { id, name, activeFilters, sorting, columnVisibility, columnSizing, perPage } = input;
|
||||
const userId = ctx.user.id;
|
||||
|
||||
// First, fetch the existing segment to check permissions
|
||||
const existingSegment = await prisma.filterSegment.findFirst({
|
||||
where: {
|
||||
id,
|
||||
...(input.scope === "TEAM"
|
||||
? {
|
||||
scope: "TEAM",
|
||||
teamId: input.teamId,
|
||||
team: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
accepted: true,
|
||||
role: {
|
||||
in: ["ADMIN", "OWNER"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
scope: "USER",
|
||||
userId,
|
||||
}),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existingSegment) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Filter segment not found or you don't have permission to update it",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the filter segment with only the allowed fields
|
||||
const updatedSegment = await prisma.filterSegment.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
perPage,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedSegment;
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { ZActiveFilters, ZColumnSizing, ZColumnVisibility, ZSortingState } from "./types";
|
||||
|
||||
// Base schema for common fields that can be updated
|
||||
const baseUpdateSchema = {
|
||||
name: z.string().min(1).optional(),
|
||||
tableIdentifier: z.string().min(1).optional(),
|
||||
activeFilters: ZActiveFilters.optional(),
|
||||
sorting: ZSortingState.optional(),
|
||||
columnVisibility: ZColumnVisibility.optional(),
|
||||
columnSizing: ZColumnSizing.optional(),
|
||||
perPage: z.number().int().min(1).optional(),
|
||||
};
|
||||
|
||||
// Schema for team scope updates
|
||||
const teamUpdateSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
scope: z.literal("TEAM"),
|
||||
teamId: z.number().int().positive(),
|
||||
...baseUpdateSchema,
|
||||
});
|
||||
|
||||
// Schema for user scope updates
|
||||
const userUpdateSchema = z.object({
|
||||
id: z.number().int().positive(),
|
||||
scope: z.literal("USER"),
|
||||
...baseUpdateSchema,
|
||||
});
|
||||
|
||||
export const ZUpdateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
|
||||
teamUpdateSchema,
|
||||
userUpdateSchema,
|
||||
]);
|
||||
|
||||
export type TUpdateFilterSegmentInputSchema = z.infer<typeof ZUpdateFilterSegmentInputSchema>;
|
||||
@@ -145,4 +145,7 @@ export const lucideIconList = new Set([
|
||||
"chevrons-up-down",
|
||||
"sliders-horizontal",
|
||||
"calendar-range",
|
||||
"bookmark",
|
||||
"square-pen",
|
||||
"list-filter",
|
||||
]);
|
||||
|
||||
@@ -19,6 +19,7 @@ export type IconName =
|
||||
| "book-open-check"
|
||||
| "book-open"
|
||||
| "book-user"
|
||||
| "bookmark"
|
||||
| "building"
|
||||
| "calendar-check-2"
|
||||
| "calendar-days"
|
||||
@@ -85,6 +86,7 @@ export type IconName =
|
||||
| "layout-dashboard"
|
||||
| "link-2"
|
||||
| "link"
|
||||
| "list-filter"
|
||||
| "loader"
|
||||
| "lock-open"
|
||||
| "lock"
|
||||
@@ -122,6 +124,7 @@ export type IconName =
|
||||
| "sparkles"
|
||||
| "split"
|
||||
| "square-check"
|
||||
| "square-pen"
|
||||
| "star"
|
||||
| "sun"
|
||||
| "sunrise"
|
||||
|
||||
@@ -4,6 +4,8 @@ import React from "react";
|
||||
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
export const Root = RadioGroupPrimitive.Root;
|
||||
|
||||
export const Group = (props: RadioGroupPrimitive.RadioGroupProps & { children: ReactNode }) => (
|
||||
<RadioGroupPrimitive.Root {...props}>{props.children}</RadioGroupPrimitive.Root>
|
||||
);
|
||||
|
||||
@@ -55,5 +55,6 @@ const RadioAreaGroup = ({
|
||||
|
||||
const Item = RadioArea;
|
||||
const Group = RadioAreaGroup;
|
||||
const Root = RadioGroupPrimitive.Root;
|
||||
|
||||
export { RadioArea, RadioAreaGroup, Item, Group };
|
||||
export { RadioArea, RadioAreaGroup, Item, Group, Root };
|
||||
|
||||
@@ -5,4 +5,5 @@ export {
|
||||
Label as RadioLabel,
|
||||
Radio,
|
||||
RadioField,
|
||||
Root,
|
||||
} from "./Radio";
|
||||
|
||||
Reference in New Issue
Block a user