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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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])
|
||||
|
||||
Reference in New Issue
Block a user