Skip to content

Commit b3c822a

Browse files
feat: move filter segment selection from localStorage to database (calcom#21523)
1 parent 1f52ed7 commit b3c822a

14 files changed

Lines changed: 373 additions & 73 deletions

File tree

apps/web/playwright/filter-helpers.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,7 @@ export async function listSegments(page: Page): Promise<string[]> {
172172
await page.keyboard.press("Escape");
173173
return segments;
174174
}
175+
176+
export function locateSelectedSegmentName(page: Page, expectedName: string) {
177+
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
178+
}

apps/web/playwright/filter-segment.e2e.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
listSegments,
1111
clearFilters,
1212
openSegmentSubmenu,
13+
locateSelectedSegmentName,
1314
getByTableColumnText,
1415
} from "./filter-helpers";
1516
import { test } from "./lib/fixtures";
@@ -126,6 +127,157 @@ test.describe("Filter Segment Functionality", () => {
126127
await deleteSegment(page, segmentName);
127128
});
128129

130+
test("Filter segment preferences persist in database across page reloads", async ({
131+
page,
132+
users,
133+
orgs,
134+
}) => {
135+
const orgOwner = await users.create(undefined, {
136+
hasTeam: true,
137+
isOrg: true,
138+
});
139+
const { team: org } = await orgOwner.getOrgMembership();
140+
141+
const memberUser = await users.create({
142+
roleInOrganization: MembershipRole.MEMBER,
143+
organizationId: org.id,
144+
username: "member-user",
145+
});
146+
147+
const adminUser = await users.create({
148+
roleInOrganization: MembershipRole.ADMIN,
149+
organizationId: org.id,
150+
username: "admin-user",
151+
});
152+
153+
await orgOwner.apiLogin();
154+
await page.goto(`/settings/organizations/${org.slug}/members`);
155+
156+
const dataTable = page.getByTestId("user-list-data-table");
157+
await expect(dataTable).toBeVisible();
158+
159+
await applySelectFilter(page, "role", "admin");
160+
const segmentName = "Admin Only";
161+
await createFilterSegment(page, segmentName);
162+
163+
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
164+
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
165+
166+
await page.goto(`/settings/organizations/${org.slug}/members`);
167+
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
168+
await expect(dataTable).toBeVisible();
169+
170+
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
171+
await expect(getByTableColumnText(page, "member", memberUser.email)).toBeHidden();
172+
173+
await deleteSegment(page, segmentName);
174+
});
175+
176+
test("Filter segment preferences persist across different browser sessions", async ({
177+
browser,
178+
page,
179+
users,
180+
orgs,
181+
}) => {
182+
const orgOwner = await users.create(undefined, {
183+
hasTeam: true,
184+
isOrg: true,
185+
});
186+
const { team: org } = await orgOwner.getOrgMembership();
187+
188+
const adminUser = await users.create({
189+
roleInOrganization: MembershipRole.ADMIN,
190+
organizationId: org.id,
191+
username: "admin-user-session",
192+
});
193+
194+
await orgOwner.apiLogin();
195+
await page.goto(`/settings/organizations/${org.slug}/members`);
196+
197+
await expect(page.getByTestId("user-list-data-table")).toBeVisible();
198+
await applySelectFilter(page, "role", "admin");
199+
const segmentName = "Cross Session Admins";
200+
await createFilterSegment(page, segmentName);
201+
202+
const [secondContext, secondPage] = await orgOwner.apiLoginOnNewBrowser(browser);
203+
await secondPage.goto(`/settings/organizations/${org.slug}/members`);
204+
205+
const dataTable = secondPage.getByTestId("user-list-data-table");
206+
await expect(dataTable).toBeVisible();
207+
await expect(locateSelectedSegmentName(secondPage, segmentName)).toBeVisible();
208+
await expect(getByTableColumnText(secondPage, "member", adminUser.email)).toBeVisible();
209+
210+
await deleteSegment(secondPage, segmentName);
211+
await secondContext.close();
212+
});
213+
214+
test("Team segment preferences persist in database", async ({ page, users, prisma }) => {
215+
const orgOwner = await users.create(undefined, {
216+
hasTeam: true,
217+
isOrg: true,
218+
hasSubteam: true,
219+
});
220+
const { team: org } = await orgOwner.getOrgMembership();
221+
const { team: subTeam } = await orgOwner.getFirstTeamMembership();
222+
223+
const adminUser = await users.create({
224+
roleInOrganization: MembershipRole.ADMIN,
225+
organizationId: org.id,
226+
username: "team-admin",
227+
});
228+
229+
await orgOwner.apiLogin();
230+
await page.goto(`/settings/organizations/${org.slug}/members`);
231+
232+
const dataTable = page.getByTestId("user-list-data-table");
233+
await expect(dataTable).toBeVisible();
234+
235+
await applySelectFilter(page, "role", "admin");
236+
const segmentName = "Team Admin";
237+
await createFilterSegment(page, segmentName, {
238+
teamScope: true,
239+
teamName: subTeam.name,
240+
});
241+
242+
await page.goto(`/settings/organizations/${org.slug}/members`);
243+
await expect(dataTable).toBeVisible();
244+
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
245+
246+
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
247+
248+
await deleteSegment(page, segmentName);
249+
});
250+
251+
test("Filter segment preferences are isolated per table identifier", async ({ page, users, orgs }) => {
252+
const orgOwner = await users.create(undefined, {
253+
hasTeam: true,
254+
isOrg: true,
255+
});
256+
const { team: org } = await orgOwner.getOrgMembership();
257+
258+
const adminUser = await users.create({
259+
roleInOrganization: MembershipRole.ADMIN,
260+
organizationId: org.id,
261+
username: "admin-table-isolation",
262+
});
263+
264+
await orgOwner.apiLogin();
265+
266+
await page.goto(`/settings/organizations/${org.slug}/members`);
267+
await expect(page.getByTestId("user-list-data-table")).toBeVisible();
268+
269+
await applySelectFilter(page, "role", "admin");
270+
const membersSegmentName = "Members Table Segment";
271+
await createFilterSegment(page, membersSegmentName);
272+
273+
await page.goto(`/settings/organizations/${org.slug}/members`);
274+
await expect(page.getByTestId("user-list-data-table")).toBeVisible();
275+
await expect(locateSelectedSegmentName(page, membersSegmentName)).toBeVisible();
276+
await expect(getByTableColumnText(page, "member", adminUser.email)).toBeVisible();
277+
278+
await deleteSegment(page, membersSegmentName);
279+
});
280+
129281
test("Admin can create and use team scope filter segments", async ({ page, users, prisma }) => {
130282
const orgOwner = await users.create(undefined, {
131283
hasTeam: true,

packages/features/data-table/DataTableProvider.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ interface DataTableProviderProps {
7575
ctaContainerClassName?: string;
7676
defaultPageSize?: number;
7777
segments?: FilterSegmentOutput[];
78+
preferredSegmentId?: number | null;
7879
}
7980

8081
export function DataTableProvider({
@@ -84,6 +85,7 @@ export function DataTableProvider({
8485
defaultPageSize = DEFAULT_PAGE_SIZE,
8586
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
8687
segments: providedSegments,
88+
preferredSegmentId,
8789
}: DataTableProviderProps) {
8890
const filterToOpen = useRef<string | undefined>(undefined);
8991
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
@@ -93,7 +95,10 @@ export function DataTableProvider({
9395
columnVisibilityParser
9496
);
9597
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
96-
const [segmentId, setSegmentId] = useQueryState("segment", segmentIdParser);
98+
const [segmentId, setSegmentId] = useQueryState(
99+
"segment",
100+
segmentIdParser.withDefault(preferredSegmentId ?? -1)
101+
);
97102
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
98103
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
99104
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
@@ -185,6 +190,7 @@ export function DataTableProvider({
185190
setPageIndex,
186191
setSearchTerm,
187192
segments: providedSegments,
193+
preferredSegmentId,
188194
}
189195
);
190196

packages/features/data-table/hooks/useSegments.ts

Lines changed: 21 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useCallback, useMemo, useEffect } from "react";
55
import { trpc } from "@calcom/trpc/react";
66

77
import { recalculateDateRange } from "../lib/dateRange";
8-
import { ZSegmentStorage, type UseSegments } from "../lib/types";
8+
import { type UseSegments } from "../lib/types";
99
import { isDateRangeFilterValue } from "../lib/utils";
1010

1111
export const useSegments: UseSegments = ({
@@ -27,6 +27,7 @@ export const useSegments: UseSegments = ({
2727
setPageIndex,
2828
setSearchTerm,
2929
segments: providedSegments,
30+
preferredSegmentId,
3031
}) => {
3132
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
3233
{
@@ -37,10 +38,11 @@ export const useSegments: UseSegments = ({
3738
}
3839
);
3940

41+
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
42+
4043
// Recalculate date ranges based on the current timestamp
4144
const segments = useMemo(() => {
42-
const segmentsSource = providedSegments || rawSegments;
43-
45+
const segmentsSource = providedSegments || rawSegments?.segments;
4446
if (!segmentsSource) return [];
4547

4648
return segmentsSource.map((segment) => ({
@@ -57,10 +59,9 @@ export const useSegments: UseSegments = ({
5759
}));
5860
}, [rawSegments, providedSegments]);
5961

60-
const selectedSegment = useMemo(
61-
() => segments?.find((segment) => segment.id === segmentId),
62-
[segments, segmentId]
63-
);
62+
const selectedSegment = useMemo(() => {
63+
return segments?.find((segment) => segment.id === segmentId);
64+
}, [segments, segmentId]);
6465

6566
useEffect(() => {
6667
if (segments && segmentId > 0 && !isFetchingSegments) {
@@ -74,16 +75,16 @@ export const useSegments: UseSegments = ({
7475
}
7576
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
7677

78+
const memoizedPreferredSegmentId = useMemo(
79+
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
80+
[preferredSegmentId, rawSegments]
81+
);
82+
7783
useEffect(() => {
78-
// this hook doesn't include segmentId in the dependency array
79-
// because we want to only run this once, when the component mounts
80-
if (segmentId === -1) {
81-
const segments = getSegmentsFromLocalStorage();
82-
if (segments[tableIdentifier]) {
83-
setSegmentId(segments[tableIdentifier].segmentId);
84-
}
84+
if (memoizedPreferredSegmentId) {
85+
setSegmentId(memoizedPreferredSegmentId);
8586
}
86-
}, [tableIdentifier, setSegmentId]);
87+
}, [memoizedPreferredSegmentId, setSegmentId]);
8788

8889
useEffect(() => {
8990
if (selectedSegment) {
@@ -143,9 +144,12 @@ export const useSegments: UseSegments = ({
143144
const setAndPersistSegmentId = useCallback(
144145
(segmentId: number | null) => {
145146
setSegmentId(segmentId);
146-
saveSegmentToLocalStorage({ tableIdentifier, segmentId });
147+
setPreference({
148+
tableIdentifier,
149+
segmentId,
150+
});
147151
},
148-
[tableIdentifier, setSegmentId]
152+
[tableIdentifier, setSegmentId, setPreference]
149153
);
150154

151155
return {
@@ -156,29 +160,3 @@ export const useSegments: UseSegments = ({
156160
isSegmentEnabled: true,
157161
};
158162
};
159-
160-
const LOCAL_STORAGE_KEY = "data-table:segments";
161-
162-
function getSegmentsFromLocalStorage() {
163-
try {
164-
return ZSegmentStorage.parse(JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY) ?? "{}"));
165-
} catch {
166-
return {};
167-
}
168-
}
169-
170-
function saveSegmentToLocalStorage({
171-
tableIdentifier,
172-
segmentId,
173-
}: {
174-
tableIdentifier: string;
175-
segmentId: number | null;
176-
}) {
177-
const segments = getSegmentsFromLocalStorage();
178-
if (segmentId) {
179-
segments[tableIdentifier] = { segmentId };
180-
} else {
181-
delete segments[tableIdentifier];
182-
}
183-
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(segments));
184-
}

packages/features/data-table/lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,11 @@ export type FilterSegmentOutput = {
255255
team: { id: number; name: string } | null;
256256
};
257257

258+
export type FilterSegmentsListResponse = {
259+
segments: FilterSegmentOutput[];
260+
preferredSegmentId: number | null;
261+
};
262+
258263
export type SegmentStorage = {
259264
[tableIdentifier: string]: {
260265
segmentId: number;
@@ -289,6 +294,7 @@ export type UseSegmentsProps = {
289294
setPageIndex: (pageIndex: number) => void;
290295
setSearchTerm: (searchTerm: string | null) => void;
291296
segments?: FilterSegmentOutput[];
297+
preferredSegmentId?: number | null;
292298
};
293299

294300
export type UseSegmentsReturn = {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { prisma } from "@calcom/prisma";
2+
3+
export class FilterSegmentRepository {
4+
static async setPreference({
5+
userId,
6+
tableIdentifier,
7+
segmentId,
8+
}: {
9+
userId: number;
10+
tableIdentifier: string;
11+
segmentId: number | null;
12+
}) {
13+
if (segmentId === null) {
14+
await prisma.userFilterSegmentPreference.deleteMany({
15+
where: {
16+
userId,
17+
tableIdentifier,
18+
},
19+
});
20+
return null;
21+
}
22+
23+
const preference = await prisma.userFilterSegmentPreference.upsert({
24+
where: {
25+
userId_tableIdentifier: {
26+
userId,
27+
tableIdentifier,
28+
},
29+
},
30+
update: {
31+
segmentId,
32+
},
33+
create: {
34+
userId,
35+
tableIdentifier,
36+
segmentId,
37+
},
38+
});
39+
40+
return preference;
41+
}
42+
}

0 commit comments

Comments
 (0)