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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* 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 <hey@eunjae.dev>

* refactor: rename segment types from default/custom to system/user for better semantics

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: update unit test to expect 'user' type instead of 'custom' in discriminated union

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: complete type renaming from default/custom to system/user across all component files

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: update segment ID prefix from default- to system- to match type naming

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* 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>
This commit is contained in:
Eunjae Lee
2025-08-22 10:18:18 +03:00
committed by GitHub
co-authored by Devin AI
parent 30b0012e6a
commit 2266301469
23 changed files with 829 additions and 355 deletions
@@ -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 (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} />
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
</ShellMainAppDir>
);
};
@@ -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<BookingListingStatus, string> = {
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 (
<DataTableProvider useSegments={useSegments}>
<DataTableProvider
useSegments={useSegments}
systemSegments={systemSegments}
tableIdentifier={pathname || undefined}>
<BookingsContent {...props} />
</DataTableProvider>
);
+2 -1
View File
@@ -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 (
<DataTableProvider timeZone={timeZone}>
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
<InsightsOrgTeamsProvider>
<InsightsPageContent />
</InsightsOrgTeamsProvider>
+69
View File
@@ -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<string[]> {
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");
}
+3 -17
View File
@@ -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 }) => {
+120
View File
@@ -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();
});
});
});
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -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<string | undefined>(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<VisibilityState>(
@@ -99,34 +118,172 @@ export function DataTableProvider({
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("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<CombinedFilterSegment | undefined>(
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<HTMLDivElement | null>(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<HTMLDivElement>(ctaContainerClassName);
return (
<DataTableContext.Provider
@@ -243,8 +412,8 @@ export function DataTableProvider({
offset: pageIndex * pageSize,
segments,
selectedSegment,
segmentId: segmentId || undefined,
setSegmentId: setAndPersistSegmentId,
segmentId: segmentIdObject,
setSegmentId,
canSaveSegment,
isSegmentEnabled,
searchTerm,
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
onSuccess: ({ id }) => {
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();
@@ -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({
<DialogHeader title={t("duplicate_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} />
<TextField
required
data-testid="duplicate-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter>
<Button color="minimal" onClick={onClose}>
{t("cancel")}
@@ -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<FilterSegmentOutput | undefined>();
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
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<FilterSegmentOutput["team"]> } =>
segment.team !== null
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
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 && (
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
)}
{group.segments.map((segment) => (
<DropdownItemWithSubmenu
key={segment.id}
submenuItems={submenuItems}
segment={segment}
onSelect={() => {
if (segmentId === segment.id) {
setSegmentId(null);
} else {
setSegmentId(segment.id);
}
}}>
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
))}
{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 (
<DropdownItemWithSubmenu
key={segment.id}
submenuItems={items}
segment={segment}
onSelect={() => {
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 && (
<Icon name="check" className="ml-3 h-4 w-4" />
)}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
);
})}
</div>
))}
</DropdownMenuContent>
@@ -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 (
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
@@ -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) => (
<DropdownMenuItem key={index}>
<DropdownItem
color={item.isDestructive ? "destructive" : undefined}
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
<DialogHeader title={t("rename_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} />
<TextField
required
data-testid="rename-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter>
<Button type="submit" loading={isPending}>
{t("save")}
@@ -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() {
<DialogContent data-testid="save-filter-segment-dialog">
<DialogHeader title={t("save_segment")} />
<Form form={form} handleSubmit={onSubmit}>
{selectedSegment ? (
{selectedSegment && selectedSegment.type === "user" ? (
<div className="mb-4">
<RadioGroup
defaultValue="update"
+32 -128
View File
@@ -1,35 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { isEqual } from "lodash";
import { useCallback, useMemo, useEffect } from "react";
import { useMemo } from "react";
import { trpc } from "@calcom/trpc/react";
import { recalculateDateRange } from "../lib/dateRange";
import { type UseSegments } from "../lib/types";
import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
import { isDateRangeFilterValue } from "../lib/utils";
export const useSegments: UseSegments = ({
tableIdentifier,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
segmentId,
setSegmentId,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
segments: providedSegments,
preferredSegmentId,
}) => {
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,
};
};
@@ -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,
};
};
+1 -1
View File
@@ -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("");
+38 -26
View File
@@ -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<string, boolean>;
columnSizing?: Record<string, number>;
perPage?: number;
searchTerm?: string | null;
type: "system";
};
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
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;
};
@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
export function useElementByClassName<T extends HTMLElement = HTMLDivElement>(
className?: string
): React.RefObject<T> {
const elementRef = useRef<T | null>(null);
useEffect(() => {
if (className) {
const element = document.getElementsByClassName(className)[0] as T;
elementRef.current = element || null;
} else {
elementRef.current = null;
}
}, [className]);
return elementRef;
}
@@ -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");
});
+93 -14
View File
@@ -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<FilterSegment>;
}): Promise<FilterSegmentOutput>;
update({
userId,
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
input: TUpdateFilterSegmentInputSchema;
}): Promise<FilterSegment>;
}): Promise<FilterSegmentOutput>;
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
tableIdentifier: string;
segmentId: number | null;
segmentId: SegmentIdentifier | null;
}): Promise<UserFilterSegmentPreference | null>;
}
@@ -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,
},
});
@@ -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<typeof ZCreateFilterSegmentInputSchema>;
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<typeof ZSetFilterSegmentPreferenceInputSchema>;
@@ -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);
+7 -7
View File
@@ -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])