feat: move filter segment selection from localStorage to database (#21523)

This commit is contained in:
devin-ai-integration[bot] authored and GitHub committed 2025-05-30 12:56:49 +00:00
1 parent 1f52ed7290
commit b3c822abf7
14 files changed
+373 -73

No files matched your search

+4
View File
@@ -172,3 +172,7 @@ export async function listSegments(page: Page): Promise<string[]> {
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 });
}
+152
View File
@@ -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,
@@ -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<string | undefined>(undefined);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
@@ -93,7 +95,10 @@ export function DataTableProvider({
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("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,
}
);
@@ -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));
}
@@ -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 = {
@@ -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;
}
}
@@ -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;
+38 -20
View File
@@ -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())
@@ -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,
});
});
});
@@ -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,
});
}),
});
@@ -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,
};
};
@@ -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<TrpcSessionUser>;
};
input: TSetFilterSegmentPreferenceInputSchema;
}) => {
const { tableIdentifier, segmentId } = input;
const userId = ctx.user.id;
return await FilterSegmentRepository.setPreference({
userId,
tableIdentifier,
segmentId,
});
};
@@ -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<typeof ZSetFilterSegmentPreferenceInputSchema>;
@@ -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;
};