diff --git a/apps/web/pages/api/trpc/filterSegments/[trpc].ts b/apps/web/pages/api/trpc/filterSegments/[trpc].ts new file mode 100644 index 0000000000..c5117136db --- /dev/null +++ b/apps/web/pages/api/trpc/filterSegments/[trpc].ts @@ -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); diff --git a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png index 1111d7becc..495b51414f 100644 Binary files a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png and b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png differ diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 2a90f76591..76b7741690 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -80,6 +80,9 @@ + + + @@ -418,6 +421,11 @@ + + + + + @@ -605,6 +613,10 @@ + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index b209949ce0..1a633967f1 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/data-table/DataTableProvider.tsx b/packages/features/data-table/DataTableProvider.tsx index 458650dea0..10aff33f1a 100644 --- a/packages/features/data-table/DataTableProvider.tsx +++ b/packages/features/data-table/DataTableProvider.tsx @@ -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; +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; - 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; + columnSizing: ColumnSizingState; + setColumnSizing: OnChangeFn; + 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(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( + "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(null); useEffect(() => { if (ctaContainerClassName) { @@ -137,6 +182,7 @@ export function DataTableProvider({ return ( {children} diff --git a/packages/features/data-table/components/DataTable.tsx b/packages/features/data-table/components/DataTable.tsx index 34361c589e..ccf3322623 100644 --- a/packages/features/data-table/components/DataTable.tsx +++ b/packages/features/data-table/components/DataTable.tsx @@ -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 = { testId?: string; bodyTestId?: string; children?: React.ReactNode; + enableColumnResizing?: boolean; className?: string; containerClassName?: string; headerClassName?: string; @@ -45,7 +45,6 @@ export type DataTableProps = DataTablePropsFromWrapper & { onRowMouseclick?: (row: Row) => void; onScroll?: (e: Pick, "target">) => void; tableOverlay?: React.ReactNode; - identifier?: string; enableColumnResizing?: boolean; }; @@ -57,7 +56,6 @@ export function DataTable({ onRowMouseclick, onScroll, children, - identifier: _identifier, enableColumnResizing, testId, bodyTestId, @@ -68,9 +66,6 @@ export function DataTable({ paginationMode = "infinite", ...rest }: DataTableProps & 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({ const columnSizingVars = useColumnSizingVars({ table }); usePersistentColumnResizing({ - enabled: Boolean(enableColumnResizing && identifier), + enabled: Boolean(enableColumnResizing), table, tableContainerRef, - identifier, }); return ( diff --git a/packages/features/data-table/components/DataTableSelectionBar.tsx b/packages/features/data-table/components/DataTableSelectionBar.tsx index 9981f2b9b7..bc3e88579a 100644 --- a/packages/features/data-table/components/DataTableSelectionBar.tsx +++ b/packages/features/data-table/components/DataTableSelectionBar.tsx @@ -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 = | { diff --git a/packages/features/data-table/components/DataTableToolbar.tsx b/packages/features/data-table/components/DataTableToolbar.tsx index 8ccb6db72d..92f15f117f 100644 --- a/packages/features/data-table/components/DataTableToolbar.tsx +++ b/packages/features/data-table/components/DataTableToolbar.tsx @@ -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"; diff --git a/packages/features/data-table/components/filters/ClearFiltersButton.tsx b/packages/features/data-table/components/filters/ClearFiltersButton.tsx index 288702a235..32ecc81ffe 100644 --- a/packages/features/data-table/components/filters/ClearFiltersButton.tsx +++ b/packages/features/data-table/components/filters/ClearFiltersButton.tsx @@ -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"; diff --git a/packages/features/data-table/components/filters/ColumnVisibilityButton.tsx b/packages/features/data-table/components/filters/ColumnVisibilityButton.tsx index 3f81cfeddf..c42e198ca3 100644 --- a/packages/features/data-table/components/filters/ColumnVisibilityButton.tsx +++ b/packages/features/data-table/components/filters/ColumnVisibilityButton.tsx @@ -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, diff --git a/packages/features/data-table/components/filters/NumberFilterOptions.tsx b/packages/features/data-table/components/filters/NumberFilterOptions.tsx index 3fd7c9c1aa..62a70f39e8 100644 --- a/packages/features/data-table/components/filters/NumberFilterOptions.tsx +++ b/packages/features/data-table/components/filters/NumberFilterOptions.tsx @@ -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"; diff --git a/packages/features/data-table/components/filters/TextFilterOptions.tsx b/packages/features/data-table/components/filters/TextFilterOptions.tsx index c05ffc83e6..66f9c20f34 100644 --- a/packages/features/data-table/components/filters/TextFilterOptions.tsx +++ b/packages/features/data-table/components/filters/TextFilterOptions.tsx @@ -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"; diff --git a/packages/features/data-table/components/index.ts b/packages/features/data-table/components/index.ts index f8f693aa42..5a420fa0e3 100644 --- a/packages/features/data-table/components/index.ts +++ b/packages/features/data-table/components/index.ts @@ -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"; diff --git a/packages/features/data-table/components/segment/DeleteSegmentDialog.tsx b/packages/features/data-table/components/segment/DeleteSegmentDialog.tsx new file mode 100644 index 0000000000..ef709e26e0 --- /dev/null +++ b/packages/features/data-table/components/segment/DeleteSegmentDialog.tsx @@ -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 ( + { + if (!open) { + onClose(); + } + }}> + + + {t("delete_segment_confirmation")} + + + {t("cancel")} + + + {t("delete")} + + + + + ); +} diff --git a/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx b/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx new file mode 100644 index 0000000000..ce1518a463 --- /dev/null +++ b/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx @@ -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({ + 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 ( + { + if (!open) { + onClose(); + } + }}> + + + + + + + + {t("cancel")} + + + {t("duplicate")} + + + + + + + ); +} diff --git a/packages/features/data-table/components/segment/FilterSegmentSelect.tsx b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx new file mode 100644 index 0000000000..100a5a61b1 --- /dev/null +++ b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx @@ -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(); + const [segmentToDuplicate, setSegmentToDuplicate] = useState(); + const [segmentToDelete, setSegmentToDelete] = useState(); + + 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 } => + 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 ( + <> + + + + {selectedSegment?.name || t("segment")} + + + + + {segmentGroups.length === 0 && {t("no_segments")}} + + {segmentGroups.map((group, index) => ( + + {group.label && ( + {group.label} + )} + {group.segments.map((segment) => ( + { + if (segmentId === segment.id) { + setSegmentId(null); + } else { + setSegmentId(segment.id); + } + }}> + {segment.id === segmentId && } + {segment.name} + + ))} + + ))} + + + + + {segmentToRename && ( + setSegmentToRename(undefined)} /> + )} + + {segmentToDuplicate && ( + setSegmentToDuplicate(undefined)} + /> + )} + + {segmentToDelete && ( + 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 ( + + + {children} + + + + + + + + {filteredSubmenuItems.map((item, index) => ( + + { + event.preventDefault(); + item.onClick(segment); + setIsOpen(false); + }}> + {t(item.labelKey)} + + + ))} + + + + + + ); +} diff --git a/packages/features/data-table/components/segment/RenameSegmentDialog.tsx b/packages/features/data-table/components/segment/RenameSegmentDialog.tsx new file mode 100644 index 0000000000..19b6bbab3a --- /dev/null +++ b/packages/features/data-table/components/segment/RenameSegmentDialog.tsx @@ -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({ + 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 ( + { + if (!open) { + onClose(); + } + }}> + + + + + + + + {t("save")} + + + + + + + ); +} diff --git a/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx b/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx new file mode 100644 index 0000000000..f7bd0c4404 --- /dev/null +++ b/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx @@ -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(); + const session = useSession(); + const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role); + + const form = useForm({ + 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 ( + + + + {t("save")} + + + + + + {selectedSegment ? ( + + setSaveMode(value as "create" | "update")} + className="space-y-2"> + + + + + ) : null} + + + {saveMode === "create" && ( + + {t("name")} + + + )} + + {isAdminOrOwner && saveMode === "create" && ( + <> + + + + + {isTeamSegment && teams && teams.length > 0 && ( + + + options={teams.map((team) => ({ + value: team.id.toString(), + label: team.name, + }))} + onChange={(option) => setSelectedTeamId(parseInt(option?.value || "0"))} + placeholder={t("select_team")} + required + /> + + )} + > + )} + + + setIsOpen(false)}> + {t("cancel")} + + {t("save")} + + + + + + ); +} diff --git a/packages/features/data-table/lib/resizing.ts b/packages/features/data-table/lib/resizing.ts index 1a6c84980a..5a88bf5657 100644 --- a/packages/features/data-table/lib/resizing.ts +++ b/packages/features/data-table/lib/resizing.ts @@ -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 = { enabled: boolean; table: Table; - identifier?: string; tableContainerRef: React.RefObject; }; -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({ headers, containerWidth, @@ -103,20 +84,21 @@ function getPartialColumnSizing(columnSizing: ColumnSizingState, columnsToExtrac export function usePersistentColumnResizing({ enabled, table, - identifier, tableContainerRef, }: UsePersistentColumnResizingProps) { const initialized = useRef(false); const columnSizing = useRef({}); const initialColumnSizing = useRef({}); const resizedColumns = useRef>(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({ 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({ }; }); }, - [table, identifier, tableContainerRef] + [table, tableContainerRef, debouncedSaveColumnSizing] ); const onColumnSizingChange = useCallback( @@ -149,13 +128,13 @@ export function usePersistentColumnResizing({ } 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({ }, [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({ })); initialized.current = true; - }, [enabled, identifier, table, onColumnSizingChange]); + }, [enabled, table, onColumnSizingChange]); } diff --git a/packages/features/data-table/lib/segments.ts b/packages/features/data-table/lib/segments.ts new file mode 100644 index 0000000000..88876f5517 --- /dev/null +++ b/packages/features/data-table/lib/segments.ts @@ -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)); +} diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index b611e9e8b3..65737c42db 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -211,6 +211,10 @@ export const ZSorting = z.object({ desc: z.boolean(), }) satisfies z.ZodType; +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; + +export const ZActiveFilters = ZActiveFilter.array(); + +export type FilterSegmentOutput = { + id: number; + name: string; + tableIdentifier: string; + scope: "USER" | "TEAM"; + activeFilters: ActiveFilters; + sorting: SortingState; + columnVisibility: Record; + columnSizing: Record; + 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; diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 2e5149a268..f88f4e6954 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -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={ <> setDebouncedSearchTerm(value)} /> + > } - ToolbarRight={}> + ToolbarRight={ + <> + + + + > + }> {numberOfSelectedRows >= 2 && dynamicLinkVisible && ( @@ -589,7 +598,6 @@ function UserListTableContent() { data-testid="export-members-button"> {t("download")} - {adminOrOwner && ( { + const mockUser = { + id: 1, + name: "Test User", + } as NonNullable; + + 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"); + }); +}); diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts b/packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts new file mode 100644 index 0000000000..05c74c9b23 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/__tests__/delete.handler.test.ts @@ -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; + + 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"); + }); +}); diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts b/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts new file mode 100644 index 0000000000..e9ccb0f9d6 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts @@ -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; + + 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([]); + }); +}); diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts b/packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts new file mode 100644 index 0000000000..7267def8ed --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/__tests__/update.handler.test.ts @@ -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; + + 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"); + }); +}); diff --git a/packages/trpc/server/routers/viewer/filterSegments/_router.tsx b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx new file mode 100644 index 0000000000..394155da1d --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx @@ -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, + }); + }), +}); diff --git a/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts new file mode 100644 index 0000000000..353eb22180 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/create.handler.ts @@ -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; + }; + 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; +}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts new file mode 100644 index 0000000000..92c44efe3f --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/create.schema.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts new file mode 100644 index 0000000000..f91b85cf26 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/delete.handler.ts @@ -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; + }; + 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", + }; +}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts new file mode 100644 index 0000000000..d1c2faefb1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/delete.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZDeleteFilterSegmentInputSchema = z.object({ + id: z.number().int().positive(), +}); + +export type TDeleteFilterSegmentInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts new file mode 100644 index 0000000000..40c3557157 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts @@ -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; + }; + 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; +}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts new file mode 100644 index 0000000000..a3068a4f88 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/list.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZListFilterSegmentsInputSchema = z.object({ + tableIdentifier: z.string(), +}); + +export type TListFilterSegmentsInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/types.ts b/packages/trpc/server/routers/viewer/filterSegments/types.ts new file mode 100644 index 0000000000..d5e5b042f8 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/types.ts @@ -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"; diff --git a/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts new file mode 100644 index 0000000000..ef5c7561b1 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/update.handler.ts @@ -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; + }; + 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; +}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts new file mode 100644 index 0000000000..6a9ea2bc9b --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/update.schema.ts @@ -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; diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs index e882a12cf4..51c3428629 100644 --- a/packages/ui/components/icon/icon-list.mjs +++ b/packages/ui/components/icon/icon-list.mjs @@ -145,4 +145,7 @@ export const lucideIconList = new Set([ "chevrons-up-down", "sliders-horizontal", "calendar-range", + "bookmark", + "square-pen", + "list-filter", ]); diff --git a/packages/ui/components/icon/icon-names.ts b/packages/ui/components/icon/icon-names.ts index 791e2f8fe5..ff6139851c 100644 --- a/packages/ui/components/icon/icon-names.ts +++ b/packages/ui/components/icon/icon-names.ts @@ -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" diff --git a/packages/ui/components/radio/Radio.tsx b/packages/ui/components/radio/Radio.tsx index cc1805b7ff..a2e0fd717f 100644 --- a/packages/ui/components/radio/Radio.tsx +++ b/packages/ui/components/radio/Radio.tsx @@ -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 }) => ( {props.children} ); diff --git a/packages/ui/components/radio/RadioAreaGroup.tsx b/packages/ui/components/radio/RadioAreaGroup.tsx index ee0a789893..50a8201e15 100644 --- a/packages/ui/components/radio/RadioAreaGroup.tsx +++ b/packages/ui/components/radio/RadioAreaGroup.tsx @@ -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 }; diff --git a/packages/ui/components/radio/index.ts b/packages/ui/components/radio/index.ts index fede74aea9..7338fdf69e 100644 --- a/packages/ui/components/radio/index.ts +++ b/packages/ui/components/radio/index.ts @@ -5,4 +5,5 @@ export { Label as RadioLabel, Radio, RadioField, + Root, } from "./Radio";
{t("no_segments")}