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>
This commit is contained in:
Chayan Das authored and GitHub committed 2026-06-18 18:09:12 +00:00
1 parent 561cf889ab
commit 4e529cbad1
4 files changed
+250 -75

No files matched your search

@@ -109,16 +109,22 @@ export function UsersTable() {
}, [fetchMoreOnBottomReached]);
return (
<div>
<div className="flex flex-col gap-3">
<TextField
placeholder="username or email"
label={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<p className="text-subtle text-sm">
{isFetching && totalFetched === 0
? t("loading")
: `${t("showing_x_of_y", { x: totalFetched, y: totalRowCount })}`}
</p>
<div
className="border-subtle rounded-md border"
ref={tableContainerRef}
onScroll={() => fetchMoreOnBottomReached()}
onScroll={() => fetchMoreOnBottomReached(tableContainerRef.current)}
style={{
height: "calc(100vh - 30vh)",
overflow: "auto",
@@ -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);
});
});
@@ -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 };
}
}
@@ -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
}
};
};