From 22663014697b33a5b34bff240bd929d325aaf13b Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Fri, 22 Aug 2025 09:18:18 +0200 Subject: [PATCH] feat: implement system filter segments for data tables (#22939) * feat: implement default filter segments for data tables - Add defaultSegmentId column to UserFilterSegmentPreference table - Support mixed segment ID types (number for user segments, string for default segments) - Add DefaultFilterSegment and CombinedFilterSegment types - Update useSegments hook to handle default segments with date range recalculation - Modify FilterSegmentSelect to group and display default segments separately - Add default segments to bookings view (My Bookings, Upcoming Bookings) - Prevent editing/deleting of default segments in SaveFilterSegmentButton - Update DataTableProvider to support defaultSegments prop - Update parsers and repository to handle mixed segment ID types Implements frontend-only default segments as specified in the requirements. Co-Authored-By: eunjae@cal.com * chore: update ESLint dependencies to resolve configuration issues - Update eslint-config-next and @typescript-eslint packages to latest versions - Fix dependency compatibility issues that were blocking pre-commit hooks Co-Authored-By: eunjae@cal.com * revert eslint change * some changes * refactor: implement discriminated union for segment types - Update SegmentIdentifier to use discriminated union with 'custom' vs 'default' types - Refactor setSegmentId to accept object parameters: { id: string; type: 'default' } | { id: number; type: 'custom' } - Update type definitions with DefaultFilterSegment, CustomFilterSegment, and CombinedFilterSegment - Modify useSegments hook to handle new segment type structure - Update FilterSegmentSelect component to work with discriminated unions - Refactor database preference handling to store segment type alongside ID - Add proper type safety throughout the data flow - Remove description property from DefaultFilterSegment type Co-Authored-By: eunjae@cal.com * fix: update unit test to expect discriminated union format for preferredSegmentId The test was expecting preferredSegmentId to be a number, but after the discriminated union refactor it now returns { id: number, type: 'custom' } for custom segments. Updated the assertion to use toEqual() for deep object comparison. Co-Authored-By: eunjae@cal.com * fix: correct SegmentIdentifier type to exclude undefined - Remove null from SegmentIdentifier type definition since null/undefined indicate absence of identifier - Update DataTableProvider and useSegments to handle SegmentIdentifier | null properly - Fix type safety while maintaining discriminated union functionality - Ensure setAndPersistSegmentId accepts null values for clearing segments Co-Authored-By: eunjae@cal.com * remove unused segment * a little update * feat: add default- prefix to default segment IDs for clearer type identification - Update 'my_bookings' to 'default-my_bookings' - Update 'upcoming-bookings' to 'default-upcoming-bookings' - Makes it easier to verify segment type by looking at the ID string - Maintains all existing discriminated union functionality Co-Authored-By: eunjae@cal.com * refactor: rename segment types from default/custom to system/user for better semantics Co-Authored-By: eunjae@cal.com * fix: update unit test to expect 'user' type instead of 'custom' in discriminated union Co-Authored-By: eunjae@cal.com * fix: complete type renaming from default/custom to system/user across all component files Co-Authored-By: eunjae@cal.com * refactor: update segment ID prefix from default- to system- to match type naming Co-Authored-By: eunjae@cal.com * remove unused segment * some fixes * revert schema change * rename defaultSegmentId to systemSegmentId * renaming * fix save button * remove as any * clean up prisma migrations * type fixes * many fixes * remove icon property * fix infinite rendering * fix race condition * re-visiting useSegments implementation WIP * extract useElementByClassName * add e2e tests * fix a bug that the created segment was not selected automatically * add useSegments to /insights and fix e2e test * fix type error * fix type error * apply feedback --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../(main-nav)/bookings/[status]/page.tsx | 8 +- .../bookings/views/bookings-listing-view.tsx | 39 ++- apps/web/modules/insights/insights-view.tsx | 3 +- apps/web/playwright/filter-helpers.ts | 69 +++++ apps/web/playwright/insights.e2e.ts | 20 +- apps/web/playwright/system-segments.e2e.ts | 120 ++++++++ apps/web/public/static/locales/en/common.json | 1 + .../features/data-table/DataTableProvider.tsx | 281 ++++++++++++++---- .../segment/DeleteSegmentDialog.tsx | 2 +- .../segment/DuplicateSegmentDialog.tsx | 42 ++- .../segment/FilterSegmentSelect.tsx | 173 +++++++---- .../segment/RenameSegmentDialog.tsx | 8 +- .../segment/SaveFilterSegmentButton.tsx | 19 +- .../features/data-table/hooks/useSegments.ts | 160 ++-------- .../data-table/hooks/useSegmentsNoop.ts | 6 +- packages/features/data-table/lib/parsers.ts | 2 +- packages/features/data-table/lib/types.ts | 64 ++-- packages/lib/hooks/useElementByClassName.ts | 18 ++ .../__tests__/filterSegments/get.test.ts | 2 +- .../lib/server/repository/filterSegment.ts | 107 ++++++- .../server/repository/filterSegment.type.ts | 13 +- .../migration.sql | 13 + packages/prisma/schema.prisma | 14 +- 23 files changed, 829 insertions(+), 355 deletions(-) create mode 100644 apps/web/playwright/system-segments.e2e.ts create mode 100644 packages/lib/hooks/useElementByClassName.ts create mode 100644 packages/prisma/migrations/20250812084523_add_system_segment_id/migration.sql 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({
- +
))} @@ -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) => (
- +