Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/web/modules/users/components/UsersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
155 changes: 155 additions & 0 deletions packages/features/users/repositories/UserRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
77 changes: 76 additions & 1 deletion packages/features/users/repositories/UserRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
}
83 changes: 11 additions & 72 deletions packages/trpc/server/routers/viewer/admin/listPaginated.handler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,83 +9,23 @@ type GetOptions = {
};
input: TListMembersSchema;
};

const listPaginatedHandler = async ({ input }: GetOptions) => {
const { cursor, limit, searchTerm } = input;

const getTotalUsers = await prisma.user.count();
const userRepository = getUserRepository();

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({
Comment thread
ChayanDass marked this conversation as resolved.
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,
},
},
},
});
const { cursor, limit, searchTerm } = input;

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
}
};
};

Expand Down
Loading