diff --git a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx index 7a52112ab6..d50b09a5bf 100644 --- a/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx +++ b/apps/web/app/(use-page-wrapper)/(main-nav)/bookings/[status]/page.tsx @@ -1,9 +1,14 @@ import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir"; import type { PageProps } from "app/_types"; import { _generateMetadata, getTranslate } from "app/_utils"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { z } from "zod"; +import { getServerSession } from "@calcom/features/auth/lib/getServerSession"; + +import { buildLegacyRequest } from "@lib/buildLegacyCtx"; + import { validStatuses } from "~/bookings/lib/validStatuses"; import BookingsList from "~/bookings/views/bookings-listing-view"; @@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => { redirect("/bookings/upcoming"); } const t = await getTranslate(); + const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) }); return ( - + ); }; diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index fa75436ee8..3743af9daf 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -1,7 +1,7 @@ "use client"; import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; -import { useSearchParams } from "next/navigation"; +import { useSearchParams, usePathname } from "next/navigation"; import { useMemo, useRef } from "react"; import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components"; @@ -17,6 +17,7 @@ import { ZMultiSelectFilterValue, ZDateRangeFilterValue, ZTextFilterValue, + type SystemFilterSegment, } from "@calcom/features/data-table"; import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -55,11 +56,45 @@ const descriptionByStatus: Record = { type BookingsProps = { status: (typeof validStatuses)[number]; + userId?: number; }; +function useSystemSegments(userId?: number) { + const { t } = useLocale(); + + const systemSegments: SystemFilterSegment[] = useMemo(() => { + if (!userId) return []; + + return [ + { + id: "my_bookings", + name: t("my_bookings"), + type: "system", + activeFilters: [ + { + f: "userId", + v: { + type: ColumnFilterType.MULTI_SELECT, + data: [userId], + }, + }, + ], + perPage: 10, + }, + ]; + }, [userId, t]); + + return systemSegments; +} + export default function Bookings(props: BookingsProps) { + const pathname = usePathname(); + const systemSegments = useSystemSegments(props.userId); return ( - + ); diff --git a/apps/web/modules/insights/insights-view.tsx b/apps/web/modules/insights/insights-view.tsx index 68e7357d4e..2b5e1c3af2 100644 --- a/apps/web/modules/insights/insights-view.tsx +++ b/apps/web/modules/insights/insights-view.tsx @@ -7,6 +7,7 @@ import { ColumnFilterType, type FilterableColumn, } from "@calcom/features/data-table"; +import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; import { AverageEventDurationChart, BookingKPICards, @@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; export default function InsightsPage({ timeZone }: { timeZone: string }) { return ( - + diff --git a/apps/web/playwright/filter-helpers.ts b/apps/web/playwright/filter-helpers.ts index f3a744f351..987a1d4c6a 100644 --- a/apps/web/playwright/filter-helpers.ts +++ b/apps/web/playwright/filter-helpers.ts @@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str } export async function selectOptionValue(page: Page, columnId: string, value: string) { + await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor(); await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click(); } @@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise { export function locateSelectedSegmentName(page: Page, expectedName: string) { return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName }); } + +/** + * Check if a system segment is visible in the dropdown + */ +export async function expectSystemSegmentVisible(page: Page, segmentName: string) { + await page.getByTestId("filter-segment-select").click(); + await expect( + page.locator('[data-testid="filter-segment-select-content"]').getByText("Default") + ).toBeVisible(); + await expect( + page + .locator('[data-testid="filter-segment-select-content"] [role="menuitem"]') + .filter({ hasText: segmentName }) + ).toBeVisible(); + await page.keyboard.press("Escape"); +} + +/** + * Check that no segment is currently selected + */ +export async function expectSegmentCleared(page: Page) { + // Check that no segment is selected (button shows default text) + const segmentSelect = page.getByTestId("filter-segment-select"); + const buttonText = await segmentSelect.textContent(); + expect(buttonText?.trim()).toEqual("Segment"); +} + +/** + * Check that a specific segment is currently selected + */ +export async function expectSegmentSelected(page: Page, segmentName: string) { + await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible(); +} + +/** + * Duplicate an existing segment + */ +export async function duplicateSegment(page: Page, originalName: string, newName: string) { + await openSegmentSubmenu(page, originalName); + await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click(); + await page.getByTestId("duplicate-segment-name").fill(newName); + await page.getByRole("button", { name: "Duplicate" }).click(); + await expect(page.getByText("Filter segment duplicated")).toBeVisible(); + await page.keyboard.press("Escape"); +} + +/** + * Rename an existing segment + */ +export async function renameSegment(page: Page, originalName: string, newName: string) { + await openSegmentSubmenu(page, originalName); + await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click(); + await page.getByTestId("rename-segment-name").fill(newName); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByText("Filter segment updated")).toBeVisible(); + await page.keyboard.press("Escape"); +} + +/** + * Check if a segment group (like "Default" or "Personal") is visible + */ +export async function expectSegmentGroupVisible(page: Page, groupName: string) { + await page.getByTestId("filter-segment-select").click(); + await expect( + page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName) + ).toBeVisible(); + await page.keyboard.press("Escape"); +} diff --git a/apps/web/playwright/insights.e2e.ts b/apps/web/playwright/insights.e2e.ts index 6b3a169010..30366a1dcc 100644 --- a/apps/web/playwright/insights.e2e.ts +++ b/apps/web/playwright/insights.e2e.ts @@ -3,7 +3,7 @@ import { expect } from "@playwright/test"; import { randomString } from "@calcom/lib/random"; import prisma from "@calcom/prisma"; -import { addFilter, openFilter, clearFilters } from "./filter-helpers"; +import { clearFilters, applySelectFilter } from "./filter-helpers"; import { test } from "./lib/fixtures"; test.describe.configure({ mode: "parallel" }); @@ -202,25 +202,11 @@ test.describe("Insights", async () => { await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click(); await page.keyboard.press("Escape"); - // Choose User filter item from dropdown - await addFilter(page, "userId"); - - // Wait for the URL to include userId - await page.waitForURL((url) => url.toString().includes("userId")); - - // Click User filter to see a user list - await openFilter(page, "userId"); - - await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click(); - - await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click(); - - // press escape button to close the filter - await page.keyboard.press("Escape"); + await applySelectFilter(page, "userId", member.username || ""); await clearFilters(page); - await expect(page.url()).not.toContain("userId"); + await expect(page).not.toHaveURL(/[?&]userId=/); }); test("should test download button", async ({ page, users }) => { diff --git a/apps/web/playwright/system-segments.e2e.ts b/apps/web/playwright/system-segments.e2e.ts new file mode 100644 index 0000000000..c74914a230 --- /dev/null +++ b/apps/web/playwright/system-segments.e2e.ts @@ -0,0 +1,120 @@ +import { expect } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers"; +import { test } from "./lib/fixtures"; + +test.describe.configure({ mode: "parallel" }); + +test.afterEach(async ({ users }) => { + await users.deleteAll(); +}); + +/** + * Navigate to bookings page with specific status + */ +async function navigateToBookings(page: Page, status = "upcoming") { + // Wait for the bookings API response like existing tests do + const bookingsGetResponse = page.waitForResponse((response) => + /\/api\/trpc\/bookings\/get.*/.test(response.url()) + ); + await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" }); + await bookingsGetResponse; +} + +test.describe("System Segments", () => { + test.describe("Core Functionality", () => { + test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => { + const user1 = await users.create({ username: "user1" }); + + // Create some bookings for user (this would need booking setup) + // For now, we'll test the filter application + await user1.apiLogin(); + await navigateToBookings(page); + + await selectSegment(page, "My Bookings"); + await expectSegmentSelected(page, "My Bookings"); + + // Verify the userId filter is applied (check URL or filter indicators) + const url = page.url(); + expect(url).toContain("activeFilters"); + }); + + test("System segments show only Duplicate option in submenu", async ({ page, users }) => { + const user = await users.create(); + await user.apiLogin(); + + await navigateToBookings(page); + + await page.getByTestId("filter-segment-select").click(); + await page + .locator('[data-testid="filter-segment-select-content"] [role="menuitem"]') + .filter({ hasText: "My Bookings" }) + .locator('[data-testid="filter-segment-select-submenu-button"]') + .click(); + + const submenu = page.getByTestId("filter-segment-select-submenu-content"); + await expect(submenu.getByText("Duplicate")).toBeVisible(); + await expect(submenu.getByText("Rename")).toBeHidden(); + await expect(submenu.getByText("Delete")).toBeHidden(); + }); + }); + + test.describe("State Persistence", () => { + test("System segment selection persists across page visits", async ({ page, users }) => { + const owner = await users.create(undefined, { hasTeam: true }); + await owner.apiLogin(); + + // Visit bookings page and select system segment + await navigateToBookings(page); + await selectSegment(page, "My Bookings"); + await expectSegmentSelected(page, "My Bookings"); + + // Revisit the page - should preserve selection + await navigateToBookings(page); + await expectSegmentSelected(page, "My Bookings"); + }); + + test("Cleared system segment state persists (no default selection after manual clear)", async ({ + page, + users, + }) => { + const owner = await users.create(undefined, { hasTeam: true }); + await owner.apiLogin(); + + await navigateToBookings(page); + + // Select system segment + await selectSegment(page, "My Bookings"); + await expectSegmentSelected(page, "My Bookings"); + + // Unselect system segment + await selectSegment(page, "My Bookings"); + + // Revisit the page - should NOT have any segment selected + await navigateToBookings(page); + await expectSegmentCleared(page); + }); + }); + + test.describe("Auto-clear Behavior", () => { + test("Manual filter clears system segment selection", async ({ page, users }) => { + const user = await users.create(undefined, { hasTeam: true }); + await user.apiLogin(); + + await navigateToBookings(page); + + // Select system segment + await selectSegment(page, "My Bookings"); + await expectSegmentSelected(page, "My Bookings"); + + // Apply manual filter - this should clear system segment + await addFilter(page, "eventTypeId"); + await page.keyboard.press("Escape"); + await expectSegmentCleared(page); + + // Verify filter is still applied + await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible(); + }); + }); +}); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 7681a56ac6..e0fa9b3b2e 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -3472,6 +3472,7 @@ "webhook_metadata": "Metadata", "stats": "Stats", "booking_status": "Booking status", + "my_bookings": "My Bookings", "phone": "Phone", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/features/data-table/DataTableProvider.tsx b/packages/features/data-table/DataTableProvider.tsx index ef4c0f3b06..67ab4233dc 100644 --- a/packages/features/data-table/DataTableProvider.tsx +++ b/packages/features/data-table/DataTableProvider.tsx @@ -3,9 +3,13 @@ import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; // eslint-disable-next-line no-restricted-imports import debounce from "lodash/debounce"; +// eslint-disable-next-line no-restricted-imports +import isEqual from "lodash/isEqual"; import { usePathname } from "next/navigation"; import { useQueryState } from "nuqs"; -import { createContext, useCallback, useEffect, useRef, useMemo } from "react"; +import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react"; + +import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName"; import { useSegmentsNoop } from "./hooks/useSegmentsNoop"; import { @@ -22,8 +26,12 @@ import { import { type FilterValue, type FilterSegmentOutput, + type SystemFilterSegment, + type CombinedFilterSegment, + type SegmentIdentifier, type ActiveFilters, type UseSegments, + SYSTEM_SEGMENT_PREFIX, } from "./lib/types"; import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils"; @@ -55,10 +63,10 @@ export type DataTableContextType = { offset: number; limit: number; - segments: FilterSegmentOutput[]; - selectedSegment: FilterSegmentOutput | undefined; - segmentId: number | undefined; - setSegmentId: (id: number | null) => void; + segments: CombinedFilterSegment[]; + selectedSegment: CombinedFilterSegment | undefined; + segmentId: SegmentIdentifier | null; + setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void; canSaveSegment: boolean; isSegmentEnabled: boolean; @@ -78,7 +86,8 @@ interface DataTableProviderProps { defaultPageSize?: number; segments?: FilterSegmentOutput[]; timeZone?: string; - preferredSegmentId?: number | null; + preferredSegmentId?: SegmentIdentifier | null; + systemSegments?: SystemFilterSegment[]; } export function DataTableProvider({ @@ -90,8 +99,18 @@ export function DataTableProvider({ segments: providedSegments, timeZone, preferredSegmentId, + systemSegments, }: DataTableProviderProps) { + const pathname = usePathname() as string | null; + const tableIdentifier = _tableIdentifier ?? pathname ?? undefined; + if (!tableIdentifier) { + throw new Error("tableIdentifier is required"); + } + const filterToOpen = useRef(undefined); + const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); + const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); + const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); const [sorting, setSorting] = useQueryState("sorting", sortingParser); const [columnVisibility, setColumnVisibility] = useQueryState( @@ -99,34 +118,172 @@ export function DataTableProvider({ columnVisibilityParser ); const [columnSizing, setColumnSizing] = useQueryState("widths", columnSizingParser); - const [segmentId, setSegmentId] = useQueryState( - "segment", - segmentIdParser.withDefault(preferredSegmentId ?? -1) + const initialSegmentId = useMemo( + () => (preferredSegmentId ? String(preferredSegmentId.id) : null), + [preferredSegmentId] ); - const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); - const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); - const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); + const [segmentId, _setSegmentId] = useQueryState( + "segment", + initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser + ); + const { + segments, + preferredSegmentId: fetchedPreferredSegmentId, + setPreference: setSegmentPreference, + isSegmentEnabled, + isSuccess: isSegmentFetchedSuccessfully, + } = useSegments({ + tableIdentifier, + providedSegments, + systemSegments, + }); + + const findSelectedSegment = useCallback( + (segmentId: string) => { + return segments.find((segment) => { + if ( + segment.type === "system" && + segmentId && + segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) && + segment.id === segmentId + ) { + return true; + } else if (segment.type === "user") { + const segmentIdNumber = parseInt(segmentId, 10); + return segment.id === segmentIdNumber; + } + }); + }, + [segments] + ); + + const segmentIdObject = useMemo(() => { + if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) { + return { + id: segmentId, + type: "system" as const, + }; + } else { + const segmentIdNumber = parseInt(segmentId, 10); + if (!Number.isNaN(segmentIdNumber)) { + return { + id: segmentIdNumber, + type: "user" as const, + }; + } + } + return null; + }, [segmentId]); + + const [selectedSegment, setSelectedSegment] = useState( + findSelectedSegment(segmentId) + ); + + const setSegmentId = useCallback( + (segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => { + if (!segmentId) { + _setSegmentId(null); + setSelectedSegment(undefined); + setSegmentPreference({ + tableIdentifier, + segmentId: null, + }); + return; + } + + const segment = providedSegment || findSelectedSegment(String(segmentId.id)); + if (!segment) { + // If segmentId is invalid (or not found), clear the segmentId from the query params, + // but we still keep all the other states like activeFilters, etc. + // This is useful when someone shares a URL that is inaccessible to someone else. + _setSegmentId(null); + setSelectedSegment(undefined); + setSegmentPreference({ + tableIdentifier, + segmentId: null, + }); + return; + } + + _setSegmentId(String(segmentId.id)); + setSelectedSegment(segment); + setSegmentPreference({ + tableIdentifier, + segmentId, + }); + + // apply the segment + setActiveFilters(segment.activeFilters); + if (segment.sorting) { + setSorting(segment.sorting); + } + if (segment.columnVisibility) { + setColumnVisibility(segment.columnVisibility); + } + if (segment.columnSizing) { + setColumnSizing(segment.columnSizing); + } + if (segment.perPage !== undefined) { + setPageSize(segment.perPage); + } + if (segment.searchTerm !== undefined) { + setSearchTerm(segment.searchTerm); + } + setPageIndex(0); + }, + [ + _setSegmentId, + setSelectedSegment, + setSegmentPreference, + tableIdentifier, + findSelectedSegment, + setActiveFilters, + setSorting, + setColumnVisibility, + setColumnSizing, + setPageSize, + setSearchTerm, + setPageIndex, + ] + ); + + useEffect(() => { + if (!isSegmentFetchedSuccessfully) { + return; + } + // If the preferred segment id has been fetched + // and no segment id has been selected yet, + // then we set it. + if (fetchedPreferredSegmentId && !segmentId) { + setSegmentId(fetchedPreferredSegmentId); + } else if (segmentId) { + setSelectedSegment(findSelectedSegment(segmentId)); + } + // We intentionally have only `isSegmentFetchedSuccessfully` + // in the dependency array. + }, [isSegmentFetchedSuccessfully]); + + const clearSystemSegmentSelectionIfExists = useCallback(() => { + if (selectedSegment?.type === "system") { + setSegmentId(null); + } + }, [selectedSegment, setSegmentId]); const setDebouncedSearchTerm = useMemo( () => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500), [setSearchTerm] ); - const 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 }]); + clearSystemSegmentSelectionIfExists(); } }, - [activeFilters, setActiveFilters] + [activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists] ); const setPageIndexWrapper = useCallback( @@ -151,8 +308,9 @@ export function DataTableProvider({ } return newFilters; }); + clearSystemSegmentSelectionIfExists(); }, - [setActiveFilters, setPageIndex] + [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] ); const removeFilter = useCallback( @@ -162,61 +320,72 @@ export function DataTableProvider({ const remainingFilters = prev.filter((filter) => filter.f !== columnId); return remainingFilters.length === 0 ? null : remainingFilters; }); + clearSystemSegmentSelectionIfExists(); }, - [setActiveFilters, setPageIndex] + [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists] ); const setPageSizeAndGoToFirstPage = useCallback( (newPageSize: number | null) => { setPageSize(newPageSize === defaultPageSize ? null : newPageSize); setPageIndex(null); + clearSystemSegmentSelectionIfExists(); }, - [setPageSize, setPageIndex, defaultPageSize] - ); - - const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments( - { - tableIdentifier, - activeFilters, - sorting, - columnVisibility, - columnSizing, - pageSize, - searchTerm, - defaultPageSize, - segmentId, - setSegmentId, - setActiveFilters, - setSorting, - setColumnVisibility, - setColumnSizing, - setPageSize, - setPageIndex, - setSearchTerm, - segments: providedSegments, - preferredSegmentId, - } + [setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists] ); const clearAll = useCallback( (exclude?: string[]) => { - setAndPersistSegmentId(null); + setSegmentId(null); setPageIndex(null); setActiveFilters((prev) => { const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f)); return remainingFilters.length === 0 ? null : remainingFilters; }); }, - [setActiveFilters, setPageIndex, setAndPersistSegmentId] + [setActiveFilters, setPageIndex, setSegmentId] ); - const ctaContainerRef = useRef(null); - useEffect(() => { - if (ctaContainerClassName) { - const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement; - ctaContainerRef.current = element; + // Check if current state differs from selected segment + const hasStateChanged = useMemo(() => { + if (!selectedSegment) return false; + + return ( + !isEqual(activeFilters, selectedSegment.activeFilters) || + !isEqual(sorting, selectedSegment.sorting) || + !isEqual(columnVisibility, selectedSegment.columnVisibility) || + !isEqual(columnSizing, selectedSegment.columnSizing) || + !isEqual(pageSize, selectedSegment.perPage) || + !isEqual(searchTerm || "", selectedSegment.searchTerm || "") + ); + }, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]); + + const canSaveSegment = useMemo(() => { + if (selectedSegment) { + return hasStateChanged; } - }, [ctaContainerClassName]); + // if no segment is selected, we can save the segment if there are any active filters, sorting, etc. + return ( + activeFilters.length > 0 || + sorting.length > 0 || + Object.keys(columnVisibility).length > 0 || + Object.keys(columnSizing).length > 0 || + pageSize !== defaultPageSize || + searchTerm?.length > 0 + ); + }, [ + selectedSegment, + activeFilters, + sorting, + columnVisibility, + columnSizing, + pageSize, + searchTerm, + defaultPageSize, + hasStateChanged, + ]); + + const ctaContainerRef = useElementByClassName(ctaContainerClassName); return ( { utils.viewer.filterSegments.list.invalidate(); showToast(t("filter_segment_deleted"), "success"); - if (segmentId === id) { + if (segmentId && segmentId.type === "user" && segmentId.id === id) { setSegmentId(null); } onClose(); diff --git a/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx b/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx index ce1518a463..c238f2d5a5 100644 --- a/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx +++ b/packages/features/data-table/components/segment/DuplicateSegmentDialog.tsx @@ -10,7 +10,7 @@ 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"; +import type { CombinedFilterSegment } from "../../lib/types"; type FormValues = { name: string; @@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({ segment, onClose, }: { - segment: FilterSegmentOutput; + segment: CombinedFilterSegment; onClose: () => void; }) { const { t } = useLocale(); @@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({ onSuccess: ({ id }) => { utils.viewer.filterSegments.list.invalidate(); showToast(t("filter_segment_duplicated"), "success"); - setSegmentId(id); + setSegmentId({ id, type: "user" }); onClose(); }, onError: () => { @@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({ 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 { + if (segment.type === "user") { + const { type: _type, 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, + }); + } + } else if (segment.type === "system") { + const { type: _type, ...rest } = segment; createSegment({ ...rest, scope: "USER", @@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({ - + {t("cancel")} diff --git a/packages/features/data-table/components/segment/FilterSegmentSelect.tsx b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx index 0134f5ceca..04febcf51b 100644 --- a/packages/features/data-table/components/segment/FilterSegmentSelect.tsx +++ b/packages/features/data-table/components/segment/FilterSegmentSelect.tsx @@ -16,7 +16,12 @@ import { import { Icon, type IconName } from "@calcom/ui/components/icon"; import { useDataTable } from "../../hooks"; -import type { FilterSegmentOutput } from "../../lib/types"; +import type { + FilterSegmentOutput, + CombinedFilterSegment, + SystemFilterSegmentInternal, + UserFilterSegment, +} from "../../lib/types"; import { DeleteSegmentDialog } from "./DeleteSegmentDialog"; import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog"; import { RenameSegmentDialog } from "./RenameSegmentDialog"; @@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog"; type SubmenuItem = { iconName: IconName; labelKey: string; - onClick: (segment: FilterSegmentOutput) => void; + onClick: (segment: CombinedFilterSegment) => void; isDestructive?: boolean; adminOnly: boolean; + enabledForSystemSegment?: boolean; }; export function FilterSegmentSelect() { const { t } = useLocale(); + const session = useSession(); + const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role); const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable(); const [segmentToRename, setSegmentToRename] = useState(); - const [segmentToDuplicate, setSegmentToDuplicate] = useState(); + const [segmentToDuplicate, setSegmentToDuplicate] = useState(); const [segmentToDelete, setSegmentToDelete] = useState(); const submenuItems: SubmenuItem[] = [ { iconName: "square-pen", labelKey: "rename", - onClick: (segment) => setSegmentToRename(segment), + onClick: (segment) => { + if (segment.type === "system") { + setSegmentToRename(undefined); + } else { + const { type: _type, ...rest } = segment; + setSegmentToRename(rest); + } + }, adminOnly: true, }, { @@ -48,55 +63,79 @@ export function FilterSegmentSelect() { labelKey: "duplicate", onClick: (segment) => setSegmentToDuplicate(segment), adminOnly: false, + enabledForSystemSegment: true, }, { iconName: "trash-2", labelKey: "delete", - onClick: (segment) => setSegmentToDelete(segment), + onClick: (segment) => { + if (segment.type === "system") { + setSegmentToDelete(undefined); + } else { + const { type: _type, ...rest } = segment; + setSegmentToDelete(rest); + } + }, isDestructive: true, adminOnly: true, }, ]; const segmentGroups = useMemo(() => { - const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name); + const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name); - const personalSegments = segments?.filter((segment) => !segment.team) || []; + const systemSegments = + segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || []; + const personalSegments = + segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || []; const teamSegments = segments?.filter( - (segment): segment is FilterSegmentOutput & { team: NonNullable } => - segment.team !== null + (s): s is UserFilterSegment & { team: NonNullable } => + s.type === "user" && s.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; - }, - {} - ); + const groups = []; - return [ - ...(personalSegments.length > 0 - ? [ - { - label: t("personal"), - segments: personalSegments.sort(sortFn), - }, - ] - : []), + if (systemSegments.length > 0) { + groups.push({ + label: t("default"), + segments: systemSegments.sort(sortFn), + isSystem: true, + }); + } + + // Personal segments + if (personalSegments.length > 0) { + groups.push({ + label: t("personal"), + segments: personalSegments.sort(sortFn), + isSystem: false, + }); + } + + // Team segments (existing grouping logic) + const teamSegmentsByTeam = teamSegments.reduce<{ + [teamName: string]: UserFilterSegment[]; + }>((acc, segment) => { + const teamName = segment.team!.name; + if (!acc[teamName]) { + acc[teamName] = []; + } + acc[teamName].push(segment); + return acc; + }, {}); + + groups.push( ...Object.entries(teamSegmentsByTeam) .map(([teamName, segments]) => ({ label: teamName, segments: segments.sort(sortFn), + isSystem: false, })) - .sort((a, b) => a.label.localeCompare(b.label)), - ]; + .sort((a, b) => a.label.localeCompare(b.label)) + ); + + return groups; }, [segments, t]); if (!isSegmentEnabled) { @@ -128,22 +167,43 @@ export function FilterSegmentSelect() { {group.label && ( {group.label} )} - {group.segments.map((segment) => ( - { - if (segmentId === segment.id) { - setSegmentId(null); - } else { - setSegmentId(segment.id); - } - }}> - {segment.id === segmentId && } - {segment.name} - - ))} + {group.segments.map((segment) => { + let items: SubmenuItem[] = []; + if (segment.type === "system") { + items = submenuItems.filter((item) => item.enabledForSystemSegment); + } else if (segment.type === "user" && segment.scope === "USER") { + items = submenuItems; + } else if (segment.type === "user" && segment.scope === "TEAM") { + items = submenuItems.filter( + (item) => + // Team segments: show if not admin-only or if user is admin/owner + isAdminOrOwner || !item.adminOnly + ); + } + + return ( + { + if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) { + setSegmentId(null); + } else { + if (segment.type === "system") { + setSegmentId({ id: segment.id, type: "system" }); + } else { + setSegmentId({ id: segment.id, type: "user" }); + } + } + }}> + {segmentId && segmentId.type === segment.type && segmentId.id === segment.id && ( + + )} + {segment.name} + + ); + })} ))} @@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({ children, }: { onSelect: () => void; - segment: FilterSegmentOutput; + segment: CombinedFilterSegment; 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 ( @@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({ side="right" sideOffset={8} data-testid="filter-segment-select-submenu-content"> - {filteredSubmenuItems.map((item, index) => ( + {submenuItems.map((item, index) => ( - + {t("save")} diff --git a/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx b/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx index 5673f6011b..02afafdfc6 100644 --- a/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx +++ b/packages/features/data-table/components/segment/SaveFilterSegmentButton.tsx @@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() { } = useDataTable(); const [saveMode, setSaveMode] = useState<"create" | "update">(() => - selectedSegment ? "update" : "create" + selectedSegment && selectedSegment.type === "user" ? "update" : "create" ); // When the dialog is not open, @@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() { if (isOpen) { return; } - setSaveMode(selectedSegment ? "update" : "create"); + setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create"); }, [selectedSegment, isOpen]); const { data: teams } = trpc.viewer.teams.list.useQuery(); const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({ - onSuccess: ({ id }) => { + onSuccess: (segment) => { utils.viewer.filterSegments.list.invalidate(); showToast(t("filter_segment_saved"), "success"); - setSegmentId(id); + setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment }); setIsOpen(false); }, onError: () => { @@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() { searchTerm, }; - if (saveMode === "update") { - if (!selectedSegment) { - // theoretically this should never happen - showToast(t("segment_not_found"), "error"); - return; - } + if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") { const scope = selectedSegment.scope; if (scope === "TEAM") { updateSegment({ @@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() { // Reset form state when dialog closes setIsTeamSegment(false); setSelectedTeamId(undefined); - setSaveMode(selectedSegment ? "update" : "create"); + setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create"); form.reset(); } setIsOpen(open); @@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() { - {selectedSegment ? ( + {selectedSegment && selectedSegment.type === "user" ? ( { - const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery( +export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => { + const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery( { tableIdentifier, }, @@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({ enabled: !providedSegments, // Only fetch if segments are not provided } ); - const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation(); - // Recalculate date ranges based on the current timestamp - const segments = useMemo(() => { - const segmentsSource = providedSegments || rawSegments?.segments; - if (!segmentsSource) return []; + const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]); - return segmentsSource.map((segment) => ({ + const segments = useMemo(() => { + const userSegments = providedSegments || rawSegments?.segments || []; + + const processedSystemSegments = (systemSegments || []).map((segment) => ({ + perPage: 10, + tableIdentifier, ...segment, + id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`, + type: "system" as const, activeFilters: segment.activeFilters.map((filter) => { if (isDateRangeFilterValue(filter.v)) { return { @@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({ return filter; }), })); - }, [rawSegments, providedSegments]); - const selectedSegment = useMemo(() => { - return segments?.find((segment) => segment.id === segmentId); - }, [segments, segmentId]); + const processedUserSegments = userSegments.map((segment) => ({ + ...segment, + type: "user" as const, + activeFilters: segment.activeFilters.map((filter) => { + if (isDateRangeFilterValue(filter.v)) { + return { + ...filter, + v: recalculateDateRange(filter.v), + }; + } + return filter; + }), + })); - 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]); - - const memoizedPreferredSegmentId = useMemo( - () => preferredSegmentId ?? rawSegments?.preferredSegmentId, - [preferredSegmentId, rawSegments] - ); - - useEffect(() => { - if (memoizedPreferredSegmentId) { - setSegmentId(memoizedPreferredSegmentId); - } - }, [memoizedPreferredSegmentId, 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); - setSearchTerm(selectedSegment.searchTerm); - setPageIndex(0); - } - }, [ - selectedSegment, - setActiveFilters, - setSorting, - setColumnVisibility, - setColumnSizing, - setPageSize, - setPageIndex, - setSearchTerm, - ]); - - 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 || - searchTerm?.length > 0 - ); - } 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) || - !isEqual(searchTerm, selectedSegment.searchTerm) - ); - } - }, [ - selectedSegment, - activeFilters, - sorting, - columnVisibility, - columnSizing, - pageSize, - searchTerm, - defaultPageSize, - ]); - - const setAndPersistSegmentId = useCallback( - (segmentId: number | null) => { - setSegmentId(segmentId); - setPreference({ - tableIdentifier, - segmentId, - }); - }, - [tableIdentifier, setSegmentId, setPreference] - ); + return [...processedSystemSegments, ...processedUserSegments]; + }, [rawSegments, providedSegments, systemSegments, tableIdentifier]); return { - segments: segments ?? [], - selectedSegment, - canSaveSegment, - setAndPersistSegmentId, + segments, + preferredSegmentId, + isSuccess: Boolean(providedSegments) || isSuccess, + setPreference, isSegmentEnabled: true, }; }; diff --git a/packages/features/data-table/hooks/useSegmentsNoop.ts b/packages/features/data-table/hooks/useSegmentsNoop.ts index 0e2a659faa..a993588f23 100644 --- a/packages/features/data-table/hooks/useSegmentsNoop.ts +++ b/packages/features/data-table/hooks/useSegmentsNoop.ts @@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types"; export const useSegmentsNoop: UseSegments = ({}) => { return { segments: [], - selectedSegment: undefined, - canSaveSegment: false, - setAndPersistSegmentId: noop, + preferredSegmentId: null, + isSuccess: false, + setPreference: noop, isSegmentEnabled: false, }; }; diff --git a/packages/features/data-table/lib/parsers.ts b/packages/features/data-table/lib/parsers.ts index de90cbf4dd..6515c3de0b 100644 --- a/packages/features/data-table/lib/parsers.ts +++ b/packages/features/data-table/lib/parsers.ts @@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD DEFAULT_COLUMN_VISIBILITY ); export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING); -export const segmentIdParser = parseAsInteger.withDefault(-1); +export const segmentIdParser = parseAsString.withDefault(""); export const pageIndexParser = parseAsInteger.withDefault(0); export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE); export const searchTermParser = parseAsString.withDefault(""); diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index 050aad05d4..2589d7d661 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -1,10 +1,12 @@ -import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; +import type { SortingState, ColumnSort } from "@tanstack/react-table"; import { z } from "zod"; import type { IconName } from "@calcom/ui/components/icon"; export type { SortingState } from "@tanstack/react-table"; +export const SYSTEM_SEGMENT_PREFIX = "system_"; + export enum ColumnFilterType { SINGLE_SELECT = "ss", MULTI_SELECT = "ms", @@ -255,11 +257,31 @@ export type FilterSegmentOutput = { team: { id: number; name: string } | null; }; -export type FilterSegmentsListResponse = { - segments: FilterSegmentOutput[]; - preferredSegmentId: number | null; +export type SystemFilterSegment = { + id: string; + name: string; + activeFilters: ActiveFilters; + sorting?: SortingState; + columnVisibility?: Record; + columnSizing?: Record; + perPage?: number; + searchTerm?: string | null; + type: "system"; }; +export type SystemFilterSegmentInternal = Omit & { + tableIdentifier: string; + perPage: number; +}; + +export type UserFilterSegment = FilterSegmentOutput & { + type: "user"; +}; + +export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment; + +export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" }; + export type SegmentStorage = { [tableIdentifier: string]: { segmentId: number; @@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn; export type UseSegmentsProps = { tableIdentifier: string; - activeFilters: ActiveFilters; - sorting: SortingState; - columnVisibility: VisibilityState; - columnSizing: ColumnSizingState; - pageSize: number; - searchTerm: string; - 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; - setSearchTerm: (searchTerm: string | null) => void; - segments?: FilterSegmentOutput[]; - preferredSegmentId?: number | null; + providedSegments?: FilterSegmentOutput[]; + systemSegments?: SystemFilterSegment[]; }; export type UseSegmentsReturn = { - segments: FilterSegmentOutput[]; - selectedSegment: FilterSegmentOutput | undefined; - canSaveSegment: boolean; - setAndPersistSegmentId: (segmentId: number | null) => void; + segments: CombinedFilterSegment[]; + preferredSegmentId: SegmentIdentifier | null; + isSuccess: boolean; + setPreference: ({ + tableIdentifier, + segmentId, + }: { + tableIdentifier: string; + segmentId: SegmentIdentifier | null; + }) => void; isSegmentEnabled: boolean; }; diff --git a/packages/lib/hooks/useElementByClassName.ts b/packages/lib/hooks/useElementByClassName.ts new file mode 100644 index 0000000000..e3fa6f785b --- /dev/null +++ b/packages/lib/hooks/useElementByClassName.ts @@ -0,0 +1,18 @@ +import { useEffect, useRef } from "react"; + +export function useElementByClassName( + className?: string +): React.RefObject { + const elementRef = useRef(null); + + useEffect(() => { + if (className) { + const element = document.getElementsByClassName(className)[0] as T; + elementRef.current = element || null; + } else { + elementRef.current = null; + } + }, [className]); + + return elementRef; +} diff --git a/packages/lib/server/repository/__tests__/filterSegments/get.test.ts b/packages/lib/server/repository/__tests__/filterSegments/get.test.ts index f8cea5d471..202d370b99 100644 --- a/packages/lib/server/repository/__tests__/filterSegments/get.test.ts +++ b/packages/lib/server/repository/__tests__/filterSegments/get.test.ts @@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => { }); expect(result.segments).toHaveLength(1); - expect(result.preferredSegmentId).toBe(segment.id); + expect(result.preferredSegmentId).toEqual({ id: segment.id, type: "user" }); expect(result.segments[0].name).toBe("Important Contacts"); }); diff --git a/packages/lib/server/repository/filterSegment.ts b/packages/lib/server/repository/filterSegment.ts index 0dc0182bd8..9351c05b0e 100644 --- a/packages/lib/server/repository/filterSegment.ts +++ b/packages/lib/server/repository/filterSegment.ts @@ -1,4 +1,4 @@ -import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; +import type { FilterSegmentOutput, SegmentIdentifier } from "@calcom/features/data-table/lib/types"; import { ZActiveFilters, ZSortingState, @@ -6,14 +6,14 @@ import { ZColumnVisibility, } from "@calcom/features/data-table/lib/types"; import { prisma } from "@calcom/prisma"; -import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client"; +import type { UserFilterSegmentPreference } from "@calcom/prisma/client"; import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type"; export interface IFilterSegmentRepository { get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{ segments: FilterSegmentOutput[]; - preferredSegmentId: number | null; + preferredSegmentId: SegmentIdentifier | null; }>; create({ @@ -22,7 +22,7 @@ export interface IFilterSegmentRepository { }: { userId: number; input: TCreateFilterSegmentInputSchema; - }): Promise; + }): Promise; update({ userId, @@ -30,7 +30,7 @@ export interface IFilterSegmentRepository { }: { userId: number; input: TUpdateFilterSegmentInputSchema; - }): Promise; + }): Promise; delete({ userId, id }: { userId: number; id: number }): Promise; @@ -41,7 +41,7 @@ export interface IFilterSegmentRepository { }: { userId: number; tableIdentifier: string; - segmentId: number | null; + segmentId: SegmentIdentifier | null; }): Promise; } @@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { }, select: { segmentId: true, + systemSegmentId: true, }, }); + const preferredSegmentId = preference?.segmentId + ? { id: preference.segmentId, type: "user" as const } + : preference?.systemSegmentId + ? { id: preference.systemSegmentId, type: "system" as const } + : null; + return { segments: parsedSegments, - preferredSegmentId: preference?.segmentId || null, + preferredSegmentId, }; } @@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { ...(scope === "TEAM" ? { teamId } : {}), userId, }, + select: { + id: true, + name: true, + tableIdentifier: true, + scope: true, + activeFilters: true, + sorting: true, + columnVisibility: true, + columnSizing: true, + perPage: true, + searchTerm: true, + createdAt: true, + updatedAt: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, + }, }); - return filterSegment; + // Parse the segment with Zod schemas + const parsedSegment: FilterSegmentOutput = { + ...filterSegment, + activeFilters: ZActiveFilters.catch([]).parse(filterSegment.activeFilters), + sorting: ZSortingState.catch([]).parse(filterSegment.sorting), + columnVisibility: ZColumnVisibility.catch({}).parse(filterSegment.columnVisibility), + columnSizing: ZColumnSizing.catch({}).parse(filterSegment.columnSizing), + }; + + return parsedSegment; } async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) { @@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { perPage, searchTerm, }, + select: { + id: true, + name: true, + tableIdentifier: true, + scope: true, + activeFilters: true, + sorting: true, + columnVisibility: true, + columnSizing: true, + perPage: true, + searchTerm: true, + createdAt: true, + updatedAt: true, + userId: true, + teamId: true, + team: { + select: { + id: true, + name: true, + }, + }, + }, }); - return updatedSegment; + // Parse the segment with Zod schemas + const parsedSegment: FilterSegmentOutput = { + ...updatedSegment, + activeFilters: ZActiveFilters.catch([]).parse(updatedSegment.activeFilters), + sorting: ZSortingState.catch([]).parse(updatedSegment.sorting), + columnVisibility: ZColumnVisibility.catch({}).parse(updatedSegment.columnVisibility), + columnSizing: ZColumnSizing.catch({}).parse(updatedSegment.columnSizing), + }; + + return parsedSegment; } async delete({ userId, id }: { userId: number; id: number }) { @@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { }: { userId: number; tableIdentifier: string; - segmentId: number | null; + segmentId: SegmentIdentifier | null; }) { if (segmentId === null) { await prisma.userFilterSegmentPreference.deleteMany({ @@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { return null; } + const isSystemSegment = segmentId.type === "system"; + + const updateData = isSystemSegment + ? { + segmentId: null, + systemSegmentId: segmentId.id, + } + : { + segmentId: segmentId.id, + systemSegmentId: null, + }; + const preference = await prisma.userFilterSegmentPreference.upsert({ where: { userId_tableIdentifier: { @@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository { tableIdentifier, }, }, - update: { - segmentId, - }, + update: updateData, create: { userId, tableIdentifier, - segmentId, + ...updateData, }, }); diff --git a/packages/lib/server/repository/filterSegment.type.ts b/packages/lib/server/repository/filterSegment.type.ts index 56850b69f5..d98bf67a72 100644 --- a/packages/lib/server/repository/filterSegment.type.ts +++ b/packages/lib/server/repository/filterSegment.type.ts @@ -1,6 +1,5 @@ import { z } from "zod"; -import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; import { ZActiveFilters, ZSortingState, @@ -8,11 +7,6 @@ import { ZColumnVisibility, } from "@calcom/features/data-table/lib/types"; -export type FilterSegmentsListResponse = { - segments: FilterSegmentOutput[]; - preferredSegmentId: number | null; -}; - export const ZListFilterSegmentsInputSchema = z.object({ tableIdentifier: z.string(), }); @@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [ export type TCreateFilterSegmentInputSchema = z.infer; +const ZSegmentIdentifier = z.discriminatedUnion("type", [ + z.object({ id: z.string(), type: z.literal("system") }), + z.object({ id: z.number(), type: z.literal("user") }), +]); + export const ZSetFilterSegmentPreferenceInputSchema = z.object({ tableIdentifier: z.string(), - segmentId: z.number().nullable(), + segmentId: ZSegmentIdentifier.nullable(), }); export type TSetFilterSegmentPreferenceInputSchema = z.infer; diff --git a/packages/prisma/migrations/20250812084523_add_system_segment_id/migration.sql b/packages/prisma/migrations/20250812084523_add_system_segment_id/migration.sql new file mode 100644 index 0000000000..a7b50d3884 --- /dev/null +++ b/packages/prisma/migrations/20250812084523_add_system_segment_id/migration.sql @@ -0,0 +1,13 @@ +-- AlterTable +ALTER TABLE "UserFilterSegmentPreference" ADD COLUMN "systemSegmentId" TEXT, +ALTER COLUMN "segmentId" DROP NOT NULL; + +-- Add an XOR CHECK constraint between segmentId and systemSegmentId +ALTER TABLE "UserFilterSegmentPreference" + ADD CONSTRAINT "UserFilterSegmentPreference_segment_xor_system_chk" + CHECK ( + (("segmentId" IS NOT NULL AND "systemSegmentId" IS NULL) + OR ("segmentId" IS NULL AND "systemSegmentId" IS NOT NULL)) + ), + ADD CONSTRAINT "UserFilterSegmentPreference_systemSegmentId_nonempty_chk" + CHECK ("systemSegmentId" IS NULL OR length("systemSegmentId") > 0); diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4a37af4960..a905c2aee8 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -2356,15 +2356,15 @@ model FilterSegment { } model UserFilterSegmentPreference { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) userId Int tableIdentifier String - segmentId Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - segment FilterSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade) + segmentId Int? + systemSegmentId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + segment FilterSegment? @relation(fields: [segmentId], references: [id], onDelete: Cascade) @@unique([userId, tableIdentifier]) @@index([userId])