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:
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
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user