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:
Eunjae Lee
2025-03-31 08:53:48 +00:00
committed by GitHub
parent c95118715a
commit f15d347804
45 changed files with 2348 additions and 79 deletions
@@ -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

+12
View File
@@ -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>
);
}
+14 -37
View File
@@ -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));
}
+48
View File
@@ -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;
+34
View File
@@ -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())
+1
View File
@@ -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"
+2
View File
@@ -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 };
+1
View File
@@ -5,4 +5,5 @@ export {
Label as RadioLabel,
Radio,
RadioField,
Root,
} from "./Radio";