From b3c822abf750f91a5312f13e7642acb33fc82542 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 30 May 2025 12:56:49 +0000 Subject: [PATCH] feat: move filter segment selection from localStorage to database (#21523) --- apps/web/playwright/filter-helpers.ts | 4 + apps/web/playwright/filter-segment.e2e.ts | 152 ++++++++++++++++++ .../features/data-table/DataTableProvider.tsx | 8 +- .../features/data-table/hooks/useSegments.ts | 64 +++----- packages/features/data-table/lib/types.ts | 6 + .../lib/server/repository/filterSegment.ts | 42 +++++ .../migration.sql | 26 +++ packages/prisma/schema.prisma | 58 ++++--- .../__tests__/list.handler.test.ts | 19 ++- .../routers/viewer/filterSegments/_router.tsx | 12 ++ .../viewer/filterSegments/list.handler.ts | 17 +- .../filterSegments/preference.handler.ts | 23 +++ .../filterSegments/preference.schema.ts | 8 + .../routers/viewer/filterSegments/types.ts | 7 + 14 files changed, 373 insertions(+), 73 deletions(-) create mode 100644 packages/lib/server/repository/filterSegment.ts create mode 100644 packages/prisma/migrations/20250526143547_create_user_filter_segment_preference/migration.sql create mode 100644 packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts create mode 100644 packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts diff --git a/apps/web/playwright/filter-helpers.ts b/apps/web/playwright/filter-helpers.ts index 915d0c2712..f3a744f351 100644 --- a/apps/web/playwright/filter-helpers.ts +++ b/apps/web/playwright/filter-helpers.ts @@ -172,3 +172,7 @@ export async function listSegments(page: Page): Promise { await page.keyboard.press("Escape"); return segments; } + +export function locateSelectedSegmentName(page: Page, expectedName: string) { + return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName }); +} diff --git a/apps/web/playwright/filter-segment.e2e.ts b/apps/web/playwright/filter-segment.e2e.ts index 51e6d2d15e..b8ec89d43b 100644 --- a/apps/web/playwright/filter-segment.e2e.ts +++ b/apps/web/playwright/filter-segment.e2e.ts @@ -10,6 +10,7 @@ import { listSegments, clearFilters, openSegmentSubmenu, + locateSelectedSegmentName, getByTableColumnText, } from "./filter-helpers"; import { test } from "./lib/fixtures"; @@ -126,6 +127,157 @@ test.describe("Filter Segment Functionality", () => { await deleteSegment(page, segmentName); }); + test("Filter segment preferences persist in database across page reloads", async ({ + page, + users, + orgs, + }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + + const memberUser = await users.create({ + roleInOrganization: MembershipRole.MEMBER, + organizationId: org.id, + username: "member-user", + }); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + username: "admin-user", + }); + + await orgOwner.apiLogin(); + await page.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = page.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + + await applySelectFilter(page, "role", "admin"); + const segmentName = "Admin Only"; + await createFilterSegment(page, segmentName); + + await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible(); + await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible(); + await expect(dataTable).toBeVisible(); + + await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible(); + await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden(); + + await deleteSegment(page, segmentName); + }); + + test("Filter segment preferences persist across different browser sessions", async ({ + browser, + page, + users, + orgs, + }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + username: "admin-user-session", + }); + + await orgOwner.apiLogin(); + await page.goto(`/settings/organizations/${org.slug}/members`); + + await expect(page.getByTestId("user-list-data-table")).toBeVisible(); + await applySelectFilter(page, "role", "admin"); + const segmentName = "Cross Session Admins"; + await createFilterSegment(page, segmentName); + + const [secondContext, secondPage] = await orgOwner.apiLoginOnNewBrowser(browser); + await secondPage.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = secondPage.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + await expect(locateSelectedSegmentName(secondPage, segmentName)).toBeVisible(); + await expect(getByTableColumnText(secondPage, "member", adminUser.email)).toBeVisible(); + + await deleteSegment(secondPage, segmentName); + await secondContext.close(); + }); + + test("Team segment preferences persist in database", async ({ page, users, prisma }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + hasSubteam: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + const { team: subTeam } = await orgOwner.getFirstTeamMembership(); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + username: "team-admin", + }); + + await orgOwner.apiLogin(); + await page.goto(`/settings/organizations/${org.slug}/members`); + + const dataTable = page.getByTestId("user-list-data-table"); + await expect(dataTable).toBeVisible(); + + await applySelectFilter(page, "role", "admin"); + const segmentName = "Team Admin"; + await createFilterSegment(page, segmentName, { + teamScope: true, + teamName: subTeam.name, + }); + + await page.goto(`/settings/organizations/${org.slug}/members`); + await expect(dataTable).toBeVisible(); + await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible(); + + await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible(); + + await deleteSegment(page, segmentName); + }); + + test("Filter segment preferences are isolated per table identifier", async ({ page, users, orgs }) => { + const orgOwner = await users.create(undefined, { + hasTeam: true, + isOrg: true, + }); + const { team: org } = await orgOwner.getOrgMembership(); + + const adminUser = await users.create({ + roleInOrganization: MembershipRole.ADMIN, + organizationId: org.id, + username: "admin-table-isolation", + }); + + await orgOwner.apiLogin(); + + await page.goto(`/settings/organizations/${org.slug}/members`); + await expect(page.getByTestId("user-list-data-table")).toBeVisible(); + + await applySelectFilter(page, "role", "admin"); + const membersSegmentName = "Members Table Segment"; + await createFilterSegment(page, membersSegmentName); + + await page.goto(`/settings/organizations/${org.slug}/members`); + await expect(page.getByTestId("user-list-data-table")).toBeVisible(); + await expect(locateSelectedSegmentName(page, membersSegmentName)).toBeVisible(); + await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible(); + + await deleteSegment(page, membersSegmentName); + }); + test("Admin can create and use team scope filter segments", async ({ page, users, prisma }) => { const orgOwner = await users.create(undefined, { hasTeam: true, diff --git a/packages/features/data-table/DataTableProvider.tsx b/packages/features/data-table/DataTableProvider.tsx index e0c1840c6b..34f1528c5f 100644 --- a/packages/features/data-table/DataTableProvider.tsx +++ b/packages/features/data-table/DataTableProvider.tsx @@ -75,6 +75,7 @@ interface DataTableProviderProps { ctaContainerClassName?: string; defaultPageSize?: number; segments?: FilterSegmentOutput[]; + preferredSegmentId?: number | null; } export function DataTableProvider({ @@ -84,6 +85,7 @@ export function DataTableProvider({ defaultPageSize = DEFAULT_PAGE_SIZE, ctaContainerClassName = CTA_CONTAINER_CLASS_NAME, segments: providedSegments, + preferredSegmentId, }: DataTableProviderProps) { const filterToOpen = useRef(undefined); const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); @@ -93,7 +95,10 @@ export function DataTableProvider({ columnVisibilityParser ); const [columnSizing, setColumnSizing] = useQueryState("widths", columnSizingParser); - const [segmentId, setSegmentId] = useQueryState("segment", segmentIdParser); + const [segmentId, setSegmentId] = useQueryState( + "segment", + segmentIdParser.withDefault(preferredSegmentId ?? -1) + ); const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); @@ -185,6 +190,7 @@ export function DataTableProvider({ setPageIndex, setSearchTerm, segments: providedSegments, + preferredSegmentId, } ); diff --git a/packages/features/data-table/hooks/useSegments.ts b/packages/features/data-table/hooks/useSegments.ts index 2357538e4d..a0774caa56 100644 --- a/packages/features/data-table/hooks/useSegments.ts +++ b/packages/features/data-table/hooks/useSegments.ts @@ -5,7 +5,7 @@ import { useCallback, useMemo, useEffect } from "react"; import { trpc } from "@calcom/trpc/react"; import { recalculateDateRange } from "../lib/dateRange"; -import { ZSegmentStorage, type UseSegments } from "../lib/types"; +import { type UseSegments } from "../lib/types"; import { isDateRangeFilterValue } from "../lib/utils"; export const useSegments: UseSegments = ({ @@ -27,6 +27,7 @@ export const useSegments: UseSegments = ({ setPageIndex, setSearchTerm, segments: providedSegments, + preferredSegmentId, }) => { const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery( { @@ -37,10 +38,11 @@ export const useSegments: UseSegments = ({ } ); + const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation(); + // Recalculate date ranges based on the current timestamp const segments = useMemo(() => { - const segmentsSource = providedSegments || rawSegments; - + const segmentsSource = providedSegments || rawSegments?.segments; if (!segmentsSource) return []; return segmentsSource.map((segment) => ({ @@ -57,10 +59,9 @@ export const useSegments: UseSegments = ({ })); }, [rawSegments, providedSegments]); - const selectedSegment = useMemo( - () => segments?.find((segment) => segment.id === segmentId), - [segments, segmentId] - ); + const selectedSegment = useMemo(() => { + return segments?.find((segment) => segment.id === segmentId); + }, [segments, segmentId]); useEffect(() => { if (segments && segmentId > 0 && !isFetchingSegments) { @@ -74,16 +75,16 @@ export const useSegments: UseSegments = ({ } }, [segments, segmentId, setSegmentId, isFetchingSegments]); + const memoizedPreferredSegmentId = useMemo( + () => preferredSegmentId ?? rawSegments?.preferredSegmentId, + [preferredSegmentId, rawSegments] + ); + useEffect(() => { - // this hook doesn't include segmentId in the dependency array - // because we want to only run this once, when the component mounts - if (segmentId === -1) { - const segments = getSegmentsFromLocalStorage(); - if (segments[tableIdentifier]) { - setSegmentId(segments[tableIdentifier].segmentId); - } + if (memoizedPreferredSegmentId) { + setSegmentId(memoizedPreferredSegmentId); } - }, [tableIdentifier, setSegmentId]); + }, [memoizedPreferredSegmentId, setSegmentId]); useEffect(() => { if (selectedSegment) { @@ -143,9 +144,12 @@ export const useSegments: UseSegments = ({ const setAndPersistSegmentId = useCallback( (segmentId: number | null) => { setSegmentId(segmentId); - saveSegmentToLocalStorage({ tableIdentifier, segmentId }); + setPreference({ + tableIdentifier, + segmentId, + }); }, - [tableIdentifier, setSegmentId] + [tableIdentifier, setSegmentId, setPreference] ); return { @@ -156,29 +160,3 @@ export const useSegments: UseSegments = ({ isSegmentEnabled: true, }; }; - -const LOCAL_STORAGE_KEY = "data-table:segments"; - -function getSegmentsFromLocalStorage() { - try { - return ZSegmentStorage.parse(JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}")); - } catch { - return {}; - } -} - -function saveSegmentToLocalStorage({ - tableIdentifier, - segmentId, -}: { - tableIdentifier: string; - segmentId: number | null; -}) { - const segments = getSegmentsFromLocalStorage(); - if (segmentId) { - segments[tableIdentifier] = { segmentId }; - } else { - delete segments[tableIdentifier]; - } - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(segments)); -} diff --git a/packages/features/data-table/lib/types.ts b/packages/features/data-table/lib/types.ts index e4410b4014..050aad05d4 100644 --- a/packages/features/data-table/lib/types.ts +++ b/packages/features/data-table/lib/types.ts @@ -255,6 +255,11 @@ export type FilterSegmentOutput = { team: { id: number; name: string } | null; }; +export type FilterSegmentsListResponse = { + segments: FilterSegmentOutput[]; + preferredSegmentId: number | null; +}; + export type SegmentStorage = { [tableIdentifier: string]: { segmentId: number; @@ -289,6 +294,7 @@ export type UseSegmentsProps = { setPageIndex: (pageIndex: number) => void; setSearchTerm: (searchTerm: string | null) => void; segments?: FilterSegmentOutput[]; + preferredSegmentId?: number | null; }; export type UseSegmentsReturn = { diff --git a/packages/lib/server/repository/filterSegment.ts b/packages/lib/server/repository/filterSegment.ts new file mode 100644 index 0000000000..f5e0666c47 --- /dev/null +++ b/packages/lib/server/repository/filterSegment.ts @@ -0,0 +1,42 @@ +import { prisma } from "@calcom/prisma"; + +export class FilterSegmentRepository { + static async setPreference({ + userId, + tableIdentifier, + segmentId, + }: { + userId: number; + tableIdentifier: string; + segmentId: number | null; + }) { + if (segmentId === null) { + await prisma.userFilterSegmentPreference.deleteMany({ + where: { + userId, + tableIdentifier, + }, + }); + return null; + } + + const preference = await prisma.userFilterSegmentPreference.upsert({ + where: { + userId_tableIdentifier: { + userId, + tableIdentifier, + }, + }, + update: { + segmentId, + }, + create: { + userId, + tableIdentifier, + segmentId, + }, + }); + + return preference; + } +} diff --git a/packages/prisma/migrations/20250526143547_create_user_filter_segment_preference/migration.sql b/packages/prisma/migrations/20250526143547_create_user_filter_segment_preference/migration.sql new file mode 100644 index 0000000000..22de7cb09b --- /dev/null +++ b/packages/prisma/migrations/20250526143547_create_user_filter_segment_preference/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "UserFilterSegmentPreference" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "tableIdentifier" TEXT NOT NULL, + "segmentId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserFilterSegmentPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "UserFilterSegmentPreference_userId_idx" ON "UserFilterSegmentPreference"("userId"); + +-- CreateIndex +CREATE INDEX "UserFilterSegmentPreference_segmentId_idx" ON "UserFilterSegmentPreference"("segmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "UserFilterSegmentPreference_userId_tableIdentifier_key" ON "UserFilterSegmentPreference"("userId", "tableIdentifier"); + +-- AddForeignKey +ALTER TABLE "UserFilterSegmentPreference" ADD CONSTRAINT "UserFilterSegmentPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserFilterSegmentPreference" ADD CONSTRAINT "UserFilterSegmentPreference_segmentId_fkey" FOREIGN KEY ("segmentId") REFERENCES "FilterSegment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index e2d6e437eb..56aa60c42f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -377,35 +377,36 @@ model User { bookingRedirectsTo OutOfOfficeEntry[] @relation(name: "toUser") // Used to lock the user account - locked Boolean @default(false) + locked Boolean @default(false) platformOAuthClients PlatformOAuthClient[] AccessToken AccessToken[] RefreshToken RefreshToken[] PlatformAuthorizationToken PlatformAuthorizationToken[] profiles Profile[] movedToProfileId Int? - movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) + movedToProfile Profile? @relation("moved_to_profile", fields: [movedToProfileId], references: [id], onDelete: SetNull) secondaryEmails SecondaryEmail[] - isPlatformManaged Boolean @default(false) + isPlatformManaged Boolean @default(false) OutOfOfficeReasons OutOfOfficeReason[] - smsLockState SMSLockState @default(UNLOCKED) - smsLockReviewedByAdmin Boolean @default(false) + smsLockState SMSLockState @default(UNLOCKED) + smsLockReviewedByAdmin Boolean @default(false) NotificationsSubscriptions NotificationsSubscriptions[] referralLinkId String? features UserFeatures[] - reassignedBookings Booking[] @relation("reassignByUser") - createdAttributeToUsers AttributeToUser[] @relation("createdBy") - updatedAttributeToUsers AttributeToUser[] @relation("updatedBy") - createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations") - updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") - createdWatchlists Watchlist[] @relation("CreatedWatchlists") - updatedWatchlists Watchlist[] @relation("UpdatedWatchlists") + reassignedBookings Booking[] @relation("reassignByUser") + createdAttributeToUsers AttributeToUser[] @relation("createdBy") + updatedAttributeToUsers AttributeToUser[] @relation("updatedBy") + createdTranslations EventTypeTranslation[] @relation("CreatedEventTypeTranslations") + updatedTranslations EventTypeTranslation[] @relation("UpdatedEventTypeTranslations") + createdWatchlists Watchlist[] @relation("CreatedWatchlists") + updatedWatchlists Watchlist[] @relation("UpdatedWatchlists") BookingInternalNote BookingInternalNote[] creationSource CreationSource? - createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings") + createdOrganizationOnboardings OrganizationOnboarding[] @relation("CreatedOrganizationOnboardings") filterSegments FilterSegment[] + filterSegmentPreferences UserFilterSegmentPreference[] creditBalance CreditBalance? - whitelistWorkflows Boolean @default(false) + whitelistWorkflows Boolean @default(false) @@unique([email]) @@unique([email, username]) @@ -2231,7 +2232,7 @@ enum FilterSegmentScope { } model FilterSegment { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String // Identifies which data table this segment belongs to (e.g. "organization_members", "team_members", "bookings", etc.) tableIdentifier String @@ -2242,15 +2243,16 @@ model FilterSegment { columnVisibility Json? columnSizing Json? perPage Int - searchTerm String? @db.Text - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + searchTerm String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt // Creator of the segment - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) userId Int // Team scope - optional, only set when scope is TEAM - team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) + team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade) teamId Int? + userPreferences UserFilterSegmentPreference[] // For user-scoped segments: scope + userId + tableIdentifier @@index([scope, userId, tableIdentifier]) @@ -2258,6 +2260,22 @@ model FilterSegment { @@index([scope, teamId, tableIdentifier]) } +model UserFilterSegmentPreference { + 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) + + @@unique([userId, tableIdentifier]) + @@index([userId]) + @@index([segmentId]) +} + model BookingInternalNote { id Int @id @default(autoincrement()) diff --git a/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts b/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts index e9ccb0f9d6..bd164376af 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/__tests__/list.handler.test.ts @@ -3,7 +3,7 @@ import prismock from "../../../../../../../tests/libs/__mocks__/prisma"; import { describe, expect, it } from "vitest"; import { MembershipRole } from "@calcom/prisma/enums"; -import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; import { listHandler } from "../list.handler"; import { type TListFilterSegmentsInputSchema } from "../list.schema"; @@ -53,8 +53,8 @@ describe("listHandler", () => { input, }); - expect(result).toHaveLength(2); - expect(result).toEqual( + expect(result.segments).toHaveLength(2); + expect(result.segments).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "My Other Bookings", @@ -137,8 +137,8 @@ describe("listHandler", () => { input, }); - expect(result).toHaveLength(2); - expect(result).toEqual( + expect(result.segments).toHaveLength(2); + expect(result.segments).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "Other Team Bookings", @@ -221,8 +221,8 @@ describe("listHandler", () => { input, }); - expect(result).toHaveLength(2); - expect(result).toEqual( + expect(result.segments).toHaveLength(2); + expect(result.segments).toEqual( expect.arrayContaining([ expect.objectContaining({ name: "My Bookings", @@ -256,6 +256,9 @@ describe("listHandler", () => { input, }); - expect(result).toEqual([]); + expect(result).toEqual({ + segments: [], + preferredSegmentId: null, + }); }); }); diff --git a/packages/trpc/server/routers/viewer/filterSegments/_router.tsx b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx index 1cec011eb0..40e4070319 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/_router.tsx +++ b/packages/trpc/server/routers/viewer/filterSegments/_router.tsx @@ -3,6 +3,7 @@ import { router } from "../../../trpc"; import { ZCreateFilterSegmentInputSchema } from "./create.schema"; import { ZDeleteFilterSegmentInputSchema } from "./delete.schema"; import { ZListFilterSegmentsInputSchema } from "./list.schema"; +import { ZSetFilterSegmentPreferenceInputSchema } from "./preference.schema"; import { ZUpdateFilterSegmentInputSchema } from "./update.schema"; type FilterSegmentsRouterHandlerCache = { @@ -48,4 +49,15 @@ export const filterSegmentsRouter = router({ input, }); }), + + setPreference: authedProcedure + .input(ZSetFilterSegmentPreferenceInputSchema) + .mutation(async ({ input, ctx }) => { + const { setPreferenceHandler } = await import("./preference.handler"); + + return setPreferenceHandler({ + ctx, + input, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts index 40c3557157..d0829bcc54 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/list.handler.ts @@ -76,5 +76,20 @@ export const listHandler = async ({ columnSizing: ZColumnSizing.catch({}).parse(segment.columnSizing), })); - return parsedSegments; + const preference = await prisma.userFilterSegmentPreference.findUnique({ + where: { + userId_tableIdentifier: { + userId, + tableIdentifier, + }, + }, + select: { + segmentId: true, + }, + }); + + return { + segments: parsedSegments, + preferredSegmentId: preference?.segmentId || null, + }; }; diff --git a/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts b/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts new file mode 100644 index 0000000000..aa05986f52 --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/preference.handler.ts @@ -0,0 +1,23 @@ +import { FilterSegmentRepository } from "@calcom/lib/server/repository/filterSegment"; +import type { TrpcSessionUser } from "@calcom/trpc/server/types"; + +import type { TSetFilterSegmentPreferenceInputSchema } from "./preference.schema"; + +export const setPreferenceHandler = async ({ + ctx, + input, +}: { + ctx: { + user: NonNullable; + }; + input: TSetFilterSegmentPreferenceInputSchema; +}) => { + const { tableIdentifier, segmentId } = input; + const userId = ctx.user.id; + + return await FilterSegmentRepository.setPreference({ + userId, + tableIdentifier, + segmentId, + }); +}; diff --git a/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts b/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts new file mode 100644 index 0000000000..489323d74e --- /dev/null +++ b/packages/trpc/server/routers/viewer/filterSegments/preference.schema.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; + +export const ZSetFilterSegmentPreferenceInputSchema = z.object({ + tableIdentifier: z.string(), + segmentId: z.number().nullable(), +}); + +export type TSetFilterSegmentPreferenceInputSchema = z.infer; diff --git a/packages/trpc/server/routers/viewer/filterSegments/types.ts b/packages/trpc/server/routers/viewer/filterSegments/types.ts index d5e5b042f8..f1078ab8df 100644 --- a/packages/trpc/server/routers/viewer/filterSegments/types.ts +++ b/packages/trpc/server/routers/viewer/filterSegments/types.ts @@ -1,3 +1,5 @@ +import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; + export type { SortingState, ColumnSizingState, VisibilityState } from "@tanstack/react-table"; export { type ActiveFilter, @@ -7,3 +9,8 @@ export { ZColumnSizing, ZColumnVisibility, } from "@calcom/features/data-table/lib/types"; + +export type FilterSegmentsListResponse = { + segments: FilterSegmentOutput[]; + preferredSegmentId: number | null; +};