Skip to content

Commit 4e529cb

Browse files
authored
fix: user table to load on scroll (#29593)
* fix: load more users on table scroll Signed-off-by: Chayan Das <daschayan8837@gmail.com> * refactor: move user listing logic to UserRepository Add a dedicated listUsers method with search and cursor pagination support. Add repository tests covering filtering and pagination. Ensure user listing metadata remains consistent with the returned results. Signed-off-by: Chayan Das <daschayan8837@gmail.com> --------- Signed-off-by: Chayan Das <daschayan8837@gmail.com>
1 parent 561cf88 commit 4e529cb

4 files changed

Lines changed: 250 additions & 75 deletions

File tree

apps/web/modules/users/components/UsersTable.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,16 +109,22 @@ export function UsersTable() {
109109
}, [fetchMoreOnBottomReached]);
110110

111111
return (
112-
<div>
112+
<div className="flex flex-col gap-3">
113113
<TextField
114114
placeholder="username or email"
115115
label={t("search")}
116116
onChange={(e) => setSearchTerm(e.target.value)}
117117
/>
118+
<p className="text-subtle text-sm">
119+
{isFetching && totalFetched === 0
120+
? t("loading")
121+
: `${t("showing_x_of_y", { x: totalFetched, y: totalRowCount })}`}
122+
</p>
123+
118124
<div
119125
className="border-subtle rounded-md border"
120126
ref={tableContainerRef}
121-
onScroll={() => fetchMoreOnBottomReached()}
127+
onScroll={() => fetchMoreOnBottomReached(tableContainerRef.current)}
122128
style={{
123129
height: "calc(100vh - 30vh)",
124130
overflow: "auto",

packages/features/users/repositories/UserRepository.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,158 @@ describe("UserRepository", () => {
112112
});
113113
});
114114
});
115+
describe("listUsers", () => {
116+
test("Should return all users matching search term with default pagination", async () => {
117+
await new UserRepository(prismock).create({
118+
username: "alice",
119+
email: "alice@example.com",
120+
organizationId: null,
121+
creationSource: CreationSource.WEBAPP,
122+
locked: false,
123+
});
124+
await new UserRepository(prismock).create({
125+
username: "bob",
126+
email: "bob@example.com",
127+
organizationId: null,
128+
creationSource: CreationSource.WEBAPP,
129+
locked: false,
130+
});
131+
const { users, total } = await new UserRepository(prismock).listUsers({
132+
searchTerm: null,
133+
cursor: null,
134+
limit: 10,
135+
});
136+
137+
expect(users).toHaveLength(2);
138+
expect(total).toEqual(2);
139+
});
140+
test("Should filter users by searchTerm matching username (case-insensitive)", async () => {
141+
await new UserRepository(prismock).create({
142+
username: "alice",
143+
email: "alice@example.com",
144+
organizationId: null,
145+
creationSource: CreationSource.WEBAPP,
146+
locked: false,
147+
});
148+
await new UserRepository(prismock).create({
149+
username: "bob",
150+
email: "bob@example.com",
151+
organizationId: null,
152+
creationSource: CreationSource.WEBAPP,
153+
locked: false,
154+
});
155+
156+
const { users, total } = await new UserRepository(prismock).listUsers({
157+
searchTerm: "BOB",
158+
cursor: null,
159+
limit: 10,
160+
});
161+
162+
expect(users).toHaveLength(1);
163+
expect(users[0]).toEqual(
164+
expect.objectContaining({
165+
username: "bob",
166+
})
167+
);
168+
const result = await new UserRepository(prismock).listUsers({
169+
searchTerm: "ALiCE",
170+
cursor: null,
171+
limit: 10,
172+
});
173+
174+
expect(result.users).toHaveLength(1);
175+
expect(result.users[0]).toEqual(
176+
expect.objectContaining({
177+
username: "alice",
178+
})
179+
);
180+
});
181+
182+
test("Should include both locked and unlocked users", async () => {
183+
await new UserRepository(prismock).create({
184+
username: "locked-user",
185+
email: "locked@example.com",
186+
organizationId: null,
187+
creationSource: CreationSource.WEBAPP,
188+
locked: true,
189+
});
190+
await new UserRepository(prismock).create({
191+
username: "unlocked-user",
192+
email: "unlocked@example.com",
193+
organizationId: null,
194+
creationSource: CreationSource.WEBAPP,
195+
locked: false,
196+
});
197+
198+
const { users, total } = await new UserRepository(prismock).listUsers({
199+
searchTerm: null,
200+
cursor: null,
201+
limit: 10,
202+
});
203+
204+
expect(total).toEqual(2);
205+
expect(users.map((u) => u.locked).sort()).toEqual([false, true]);
206+
});
207+
208+
test("Should respect limit by returning limit + 1 rows for cursor calculation", async () => {
209+
for (let i = 0; i < 5; i++) {
210+
await new UserRepository(prismock).create({
211+
username: `user${i}`,
212+
email: `user${i}@example.com`,
213+
organizationId: null,
214+
creationSource: CreationSource.WEBAPP,
215+
locked: false,
216+
});
217+
}
218+
219+
const { users, total } = await new UserRepository(prismock).listUsers({
220+
searchTerm: null,
221+
cursor: null,
222+
limit: 3,
223+
});
224+
// take = limit + 1, so up to 4 rows come back to let the caller detect "has more"
225+
expect(users.length).toBeLessThanOrEqual(4);
226+
expect(total).toEqual(5);
227+
});
228+
229+
test("Should return all users when limit is not passed (no cap)", async () => {
230+
for (let i = 0; i < 5; i++) {
231+
await new UserRepository(prismock).create({
232+
username: `nolimituser${i}`,
233+
email: `nolimituser${i}@example.com`,
234+
organizationId: null,
235+
creationSource: CreationSource.WEBAPP,
236+
locked: false,
237+
});
238+
}
239+
240+
const { users, total } = await new UserRepository(prismock).listUsers({
241+
searchTerm: null,
242+
cursor: null,
243+
limit: undefined,
244+
});
245+
246+
expect(users).toHaveLength(5);
247+
expect(total).toEqual(5);
248+
});
249+
250+
test("Should return empty array when no users match searchTerm", async () => {
251+
await new UserRepository(prismock).create({
252+
username: "alice",
253+
email: "alice@example.com",
254+
organizationId: null,
255+
creationSource: CreationSource.WEBAPP,
256+
locked: false,
257+
});
258+
259+
const { users, total } = await new UserRepository(prismock).listUsers({
260+
261+
searchTerm: "nonexistent-term-xyz",
262+
cursor: null,
263+
limit: 10,
264+
});
265+
266+
expect(users).toEqual([]);
267+
expect(total).toEqual(0);
268+
});
269+
});

packages/features/users/repositories/UserRepository.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1587,4 +1587,79 @@ export class UserRepository {
15871587
select: { id: true },
15881588
});
15891589
}
1590-
}
1590+
async listUsers({
1591+
searchTerm,
1592+
cursor,
1593+
limit,
1594+
}: {
1595+
searchTerm?: string | null;
1596+
cursor: number | null | undefined;
1597+
limit?: number | null;
1598+
}) {
1599+
const bothLockedAndUnlockedWhere: Prisma.UserWhereInput = {
1600+
OR: [{ locked: false }, { locked: true }],
1601+
};
1602+
const trimmedSearchTerm = searchTerm?.trim();
1603+
const searchFilters: Prisma.UserWhereInput = trimmedSearchTerm
1604+
? {
1605+
AND: [
1606+
// To bypass the excludeLockedUsersExtension
1607+
bothLockedAndUnlockedWhere,
1608+
{
1609+
OR: [
1610+
{ email: { contains: trimmedSearchTerm, mode: "insensitive" } },
1611+
{ username: { contains: trimmedSearchTerm, mode: "insensitive" } },
1612+
{
1613+
profiles: {
1614+
some: {
1615+
username: { contains: trimmedSearchTerm, mode: "insensitive" },
1616+
},
1617+
},
1618+
},
1619+
],
1620+
},
1621+
],
1622+
}
1623+
// To bypass the excludeLockedUsersExtension
1624+
: bothLockedAndUnlockedWhere;
1625+
1626+
const hasLimit = limit !== undefined && limit !== null;
1627+
const take = hasLimit ? limit + 1 : undefined; // +1 lets us detect "has more" for the cursor
1628+
1629+
const users = await this.prismaClient.user.findMany({
1630+
cursor: cursor ? { id: cursor } : undefined,
1631+
skip: cursor ? 1 : 0,
1632+
...(take !== undefined ? { take } : {}),
1633+
where: searchFilters,
1634+
orderBy: {
1635+
id: "asc",
1636+
},
1637+
select: {
1638+
id: true,
1639+
locked: true,
1640+
email: true,
1641+
username: true,
1642+
name: true,
1643+
timeZone: true,
1644+
role: true,
1645+
profiles: {
1646+
select: {
1647+
username: true,
1648+
},
1649+
},
1650+
},
1651+
});
1652+
1653+
if (!hasLimit) {
1654+
return { users, nextCursor: undefined, total: users.length };
1655+
}
1656+
1657+
const total = await this.prismaClient.user.count({
1658+
where: searchFilters,
1659+
});
1660+
const hasMore = users.length > limit;
1661+
const items = hasMore ? users.slice(0, limit) : users;
1662+
const nextCursor = hasMore ? items[items.length - 1].id : undefined;
1663+
return { users: items, nextCursor, total };
1664+
}
1665+
}

packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts

Lines changed: 11 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import { prisma } from "@calcom/prisma";
2-
import type { Prisma } from "@calcom/prisma/client";
1+
import { getUserRepository } from "@calcom/features/di/containers/UserRepository";
32

43
import type { TrpcSessionUser } from "../../../types";
54
import type { TListMembersSchema } from "./listPaginated.schema";
@@ -10,83 +9,23 @@ type GetOptions = {
109
};
1110
input: TListMembersSchema;
1211
};
13-
1412
const listPaginatedHandler = async ({ input }: GetOptions) => {
15-
const { cursor, limit, searchTerm } = input;
16-
17-
const getTotalUsers = await prisma.user.count();
13+
const userRepository = getUserRepository();
1814

19-
let searchFilters: Prisma.UserWhereInput = {};
20-
const bothLockedAndUnlockedWhere = { OR: [{ locked: false }, { locked: true }] };
21-
22-
if (searchTerm) {
23-
searchFilters = {
24-
// To bypass the excludeLockedUsersExtension
25-
AND: bothLockedAndUnlockedWhere,
26-
OR: [
27-
{
28-
email: {
29-
contains: searchTerm.toLowerCase(),
30-
},
31-
},
32-
{
33-
username: {
34-
contains: searchTerm.toLocaleLowerCase(),
35-
},
36-
},
37-
{
38-
profiles: {
39-
some: {
40-
username: {
41-
contains: searchTerm.toLowerCase(),
42-
},
43-
},
44-
},
45-
},
46-
],
47-
};
48-
} else {
49-
// To bypass the excludeLockedUsersExtension
50-
searchFilters = bothLockedAndUnlockedWhere;
51-
}
52-
53-
const users = await prisma.user.findMany({
54-
cursor: cursor ? { id: cursor } : undefined,
55-
take: limit + 1, // We take +1 as itll be used for the next cursor
56-
where: {
57-
...searchFilters,
58-
},
59-
orderBy: {
60-
id: "asc",
61-
},
62-
select: {
63-
id: true,
64-
locked: true,
65-
email: true,
66-
username: true,
67-
name: true,
68-
timeZone: true,
69-
role: true,
70-
profiles: {
71-
select: {
72-
username: true,
73-
},
74-
},
75-
},
76-
});
15+
const { cursor, limit, searchTerm } = input;
7716

78-
let nextCursor: typeof cursor | undefined = undefined;
79-
if (users && users.length > limit) {
80-
const nextItem = users.pop();
81-
nextCursor = nextItem?.id;
82-
}
17+
const { users, total, nextCursor } = await userRepository.listUsers({
18+
searchTerm,
19+
limit,
20+
cursor
21+
})
8322

8423
return {
85-
rows: users || [],
24+
rows: users,
8625
nextCursor,
8726
meta: {
88-
totalRowCount: getTotalUsers || 0,
89-
},
27+
totalRowCount: total
28+
}
9029
};
9130
};
9231

0 commit comments

Comments
 (0)