From 4e529cbad1e8ab3959cf1f62404bf6e9c6853701 Mon Sep 17 00:00:00 2001 From: Chayan Das <145531182+ChayanDass@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:39:12 +0530 Subject: [PATCH] fix: user table to load on scroll (#29593) * fix: load more users on table scroll Signed-off-by: Chayan Das * 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 --------- Signed-off-by: Chayan Das --- .../modules/users/components/UsersTable.tsx | 10 +- .../users/repositories/UserRepository.test.ts | 155 ++++++++++++++++++ .../users/repositories/UserRepository.ts | 77 ++++++++- .../viewer/admin/listPaginated.handler.ts | 83 ++-------- 4 files changed, 250 insertions(+), 75 deletions(-) diff --git a/apps/web/modules/users/components/UsersTable.tsx b/apps/web/modules/users/components/UsersTable.tsx index 7dab265e0e..e15e3652a2 100644 --- a/apps/web/modules/users/components/UsersTable.tsx +++ b/apps/web/modules/users/components/UsersTable.tsx @@ -109,16 +109,22 @@ export function UsersTable() { }, [fetchMoreOnBottomReached]); return ( -
+
setSearchTerm(e.target.value)} /> +

+ {isFetching && totalFetched === 0 + ? t("loading") + : `${t("showing_x_of_y", { x: totalFetched, y: totalRowCount })}`} +

+
fetchMoreOnBottomReached()} + onScroll={() => fetchMoreOnBottomReached(tableContainerRef.current)} style={{ height: "calc(100vh - 30vh)", overflow: "auto", diff --git a/packages/features/users/repositories/UserRepository.test.ts b/packages/features/users/repositories/UserRepository.test.ts index 44e7001c96..eb870190b1 100644 --- a/packages/features/users/repositories/UserRepository.test.ts +++ b/packages/features/users/repositories/UserRepository.test.ts @@ -112,3 +112,158 @@ describe("UserRepository", () => { }); }); }); +describe("listUsers", () => { + test("Should return all users matching search term with default pagination", async () => { + await new UserRepository(prismock).create({ + username: "alice", + email: "alice@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + await new UserRepository(prismock).create({ + username: "bob", + email: "bob@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + const { users, total } = await new UserRepository(prismock).listUsers({ + searchTerm: null, + cursor: null, + limit: 10, + }); + + expect(users).toHaveLength(2); + expect(total).toEqual(2); + }); + test("Should filter users by searchTerm matching username (case-insensitive)", async () => { + await new UserRepository(prismock).create({ + username: "alice", + email: "alice@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + await new UserRepository(prismock).create({ + username: "bob", + email: "bob@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + + const { users, total } = await new UserRepository(prismock).listUsers({ + searchTerm: "BOB", + cursor: null, + limit: 10, + }); + + expect(users).toHaveLength(1); + expect(users[0]).toEqual( + expect.objectContaining({ + username: "bob", + }) + ); + const result = await new UserRepository(prismock).listUsers({ + searchTerm: "ALiCE", + cursor: null, + limit: 10, + }); + + expect(result.users).toHaveLength(1); + expect(result.users[0]).toEqual( + expect.objectContaining({ + username: "alice", + }) + ); + }); + + test("Should include both locked and unlocked users", async () => { + await new UserRepository(prismock).create({ + username: "locked-user", + email: "locked@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: true, + }); + await new UserRepository(prismock).create({ + username: "unlocked-user", + email: "unlocked@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + + const { users, total } = await new UserRepository(prismock).listUsers({ + searchTerm: null, + cursor: null, + limit: 10, + }); + + expect(total).toEqual(2); + expect(users.map((u) => u.locked).sort()).toEqual([false, true]); + }); + + test("Should respect limit by returning limit + 1 rows for cursor calculation", async () => { + for (let i = 0; i < 5; i++) { + await new UserRepository(prismock).create({ + username: `user${i}`, + email: `user${i}@example.com`, + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + } + + const { users, total } = await new UserRepository(prismock).listUsers({ + searchTerm: null, + cursor: null, + limit: 3, + }); + // take = limit + 1, so up to 4 rows come back to let the caller detect "has more" + expect(users.length).toBeLessThanOrEqual(4); + expect(total).toEqual(5); + }); + + test("Should return all users when limit is not passed (no cap)", async () => { + for (let i = 0; i < 5; i++) { + await new UserRepository(prismock).create({ + username: `nolimituser${i}`, + email: `nolimituser${i}@example.com`, + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + } + + const { users, total } = await new UserRepository(prismock).listUsers({ + searchTerm: null, + cursor: null, + limit: undefined, + }); + + expect(users).toHaveLength(5); + expect(total).toEqual(5); + }); + + test("Should return empty array when no users match searchTerm", async () => { + await new UserRepository(prismock).create({ + username: "alice", + email: "alice@example.com", + organizationId: null, + creationSource: CreationSource.WEBAPP, + locked: false, + }); + + const { users, total } = await new UserRepository(prismock).listUsers({ + + searchTerm: "nonexistent-term-xyz", + cursor: null, + limit: 10, + }); + + expect(users).toEqual([]); + expect(total).toEqual(0); + }); +}); \ No newline at end of file diff --git a/packages/features/users/repositories/UserRepository.ts b/packages/features/users/repositories/UserRepository.ts index d5c26676d8..9074c3650f 100644 --- a/packages/features/users/repositories/UserRepository.ts +++ b/packages/features/users/repositories/UserRepository.ts @@ -1587,4 +1587,79 @@ export class UserRepository { select: { id: true }, }); } -} + async listUsers({ + searchTerm, + cursor, + limit, + }: { + searchTerm?: string | null; + cursor: number | null | undefined; + limit?: number | null; + }) { + const bothLockedAndUnlockedWhere: Prisma.UserWhereInput = { + OR: [{ locked: false }, { locked: true }], + }; + const trimmedSearchTerm = searchTerm?.trim(); + const searchFilters: Prisma.UserWhereInput = trimmedSearchTerm + ? { + AND: [ + // To bypass the excludeLockedUsersExtension + bothLockedAndUnlockedWhere, + { + OR: [ + { email: { contains: trimmedSearchTerm, mode: "insensitive" } }, + { username: { contains: trimmedSearchTerm, mode: "insensitive" } }, + { + profiles: { + some: { + username: { contains: trimmedSearchTerm, mode: "insensitive" }, + }, + }, + }, + ], + }, + ], + } + // To bypass the excludeLockedUsersExtension + : bothLockedAndUnlockedWhere; + + const hasLimit = limit !== undefined && limit !== null; + const take = hasLimit ? limit + 1 : undefined; // +1 lets us detect "has more" for the cursor + + const users = await this.prismaClient.user.findMany({ + cursor: cursor ? { id: cursor } : undefined, + skip: cursor ? 1 : 0, + ...(take !== undefined ? { take } : {}), + where: searchFilters, + orderBy: { + id: "asc", + }, + select: { + id: true, + locked: true, + email: true, + username: true, + name: true, + timeZone: true, + role: true, + profiles: { + select: { + username: true, + }, + }, + }, + }); + + if (!hasLimit) { + return { users, nextCursor: undefined, total: users.length }; + } + + const total = await this.prismaClient.user.count({ + where: searchFilters, + }); + const hasMore = users.length > limit; + const items = hasMore ? users.slice(0, limit) : users; + const nextCursor = hasMore ? items[items.length - 1].id : undefined; + return { users: items, nextCursor, total }; + } +} \ No newline at end of file diff --git a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts index 2529b942cd..3124530c80 100644 --- a/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts +++ b/packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts @@ -1,5 +1,4 @@ -import { prisma } from "@calcom/prisma"; -import type { Prisma } from "@calcom/prisma/client"; +import { getUserRepository } from "@calcom/features/di/containers/UserRepository"; import type { TrpcSessionUser } from "../../../types"; import type { TListMembersSchema } from "./listPaginated.schema"; @@ -10,83 +9,23 @@ type GetOptions = { }; input: TListMembersSchema; }; - const listPaginatedHandler = async ({ input }: GetOptions) => { + const userRepository = getUserRepository(); + const { cursor, limit, searchTerm } = input; - const getTotalUsers = await prisma.user.count(); - - let searchFilters: Prisma.UserWhereInput = {}; - const bothLockedAndUnlockedWhere = { OR: [{ locked: false }, { locked: true }] }; - - if (searchTerm) { - searchFilters = { - // To bypass the excludeLockedUsersExtension - AND: bothLockedAndUnlockedWhere, - OR: [ - { - email: { - contains: searchTerm.toLowerCase(), - }, - }, - { - username: { - contains: searchTerm.toLocaleLowerCase(), - }, - }, - { - profiles: { - some: { - username: { - contains: searchTerm.toLowerCase(), - }, - }, - }, - }, - ], - }; - } else { - // To bypass the excludeLockedUsersExtension - searchFilters = bothLockedAndUnlockedWhere; - } - - const users = await prisma.user.findMany({ - cursor: cursor ? { id: cursor } : undefined, - take: limit + 1, // We take +1 as itll be used for the next cursor - where: { - ...searchFilters, - }, - orderBy: { - id: "asc", - }, - select: { - id: true, - locked: true, - email: true, - username: true, - name: true, - timeZone: true, - role: true, - profiles: { - select: { - username: true, - }, - }, - }, - }); - - let nextCursor: typeof cursor | undefined = undefined; - if (users && users.length > limit) { - const nextItem = users.pop(); - nextCursor = nextItem?.id; - } + const { users, total, nextCursor } = await userRepository.listUsers({ + searchTerm, + limit, + cursor + }) return { - rows: users || [], + rows: users, nextCursor, meta: { - totalRowCount: getTotalUsers || 0, - }, + totalRowCount: total + } }; };