From 88961a1c673ddfef5539bf1e306f3782f3f89d51 Mon Sep 17 00:00:00 2001 From: Eunjae Lee Date: Thu, 27 Mar 2025 14:36:47 +0100 Subject: [PATCH] fix: attribute filters on organization member list (#20380) * fix: attribute filters on organization member list * fix total count * add tests --------- Co-authored-by: Alex van Andel --- .../insights/server/routing-events.ts | 3 +- .../components/UserTable/UserListTable.tsx | 6 + .../organizations/listMembers.handler.test.ts | 137 ++++++++++++++++++ .../organizations/listMembers.handler.ts | 124 ++++++++-------- 4 files changed, 207 insertions(+), 63 deletions(-) create mode 100644 packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts diff --git a/packages/features/insights/server/routing-events.ts b/packages/features/insights/server/routing-events.ts index a2b8693dab..95906e70be 100644 --- a/packages/features/insights/server/routing-events.ts +++ b/packages/features/insights/server/routing-events.ts @@ -10,9 +10,8 @@ import { } from "@calcom/app-store/routing-forms/lib/FieldTypes"; import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod"; import dayjs from "@calcom/dayjs"; -import { ColumnFilterType } from "@calcom/features/data-table"; import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server"; -import type { TypedColumnFilter } from "@calcom/features/data-table/lib/types"; +import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types"; import type { RoutingFormResponsesInput, RoutingFormStatsInput, diff --git a/packages/features/users/components/UserTable/UserListTable.tsx b/packages/features/users/components/UserTable/UserListTable.tsx index 96cea2033f..196531bc30 100644 --- a/packages/features/users/components/UserTable/UserListTable.tsx +++ b/packages/features/users/components/UserTable/UserListTable.tsx @@ -305,6 +305,9 @@ function UserListTableContent() { accessorFn: (data) => data.role, header: "Role", size: 100, + meta: { + filter: { type: ColumnFilterType.MULTI_SELECT }, + }, cell: ({ row, table }) => { const { role, username } = row.original; return ( @@ -324,6 +327,9 @@ function UserListTableContent() { accessorFn: (data) => data.teams.map((team) => team.name), header: "Teams", size: 140, + meta: { + filter: { type: ColumnFilterType.MULTI_SELECT }, + }, cell: ({ row, table }) => { const { teams, accepted, email, username } = row.original; // TODO: Implement click to filter diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts new file mode 100644 index 0000000000..1cacc5d33d --- /dev/null +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it, vi } from "vitest"; + +import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types"; +import { prisma } from "@calcom/prisma"; + +import { listMembersHandler } from "./listMembers.handler"; + +const prismaMock = { + membership: { + findMany: vi.fn().mockResolvedValue([]), + count: vi.fn().mockResolvedValue(0), + }, + attributeOption: { + findMany: vi.fn().mockResolvedValue([ + { id: "1", value: "value1", isGroup: false }, + { id: "2", value: "value2", isGroup: false }, + ]), + }, +}; + +vi.spyOn(prisma.membership, "findMany").mockImplementation(prismaMock.membership.findMany); +vi.spyOn(prisma.membership, "count").mockImplementation(prismaMock.membership.count); +vi.spyOn(prisma.attributeOption, "findMany").mockImplementation(prismaMock.attributeOption.findMany); + +const ORGANIZATION_ID = 123; + +const mockUser = { + id: 1, + timeZone: "UTC", + locale: "en", + organizationId: ORGANIZATION_ID, + organization: { + id: ORGANIZATION_ID, + isOrgAdmin: true, + isPrivate: false, + }, + profiles: [ + { + id: 1, + userId: 1, + organizationId: ORGANIZATION_ID, + }, + ], +}; + +describe("listMembersHandler", () => { + it("should combine multiple attribute filters with AND logic", async () => { + const roleFilter: TypedColumnFilter = { + id: "role", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: ["ADMIN"], + }, + }; + + const teamFilter: TypedColumnFilter = { + id: "teams", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: ["team1"], + }, + }; + + const attributeFilter1: TypedColumnFilter = { + id: "1", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: ["value1"], + }, + }; + + const attributeFilter2: TypedColumnFilter = { + id: "2", + value: { + type: ColumnFilterType.MULTI_SELECT, + data: ["value2"], + }, + }; + + await listMembersHandler({ + ctx: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + user: mockUser as any, + }, + input: { + limit: 25, + offset: 0, + filters: [roleFilter, teamFilter, attributeFilter1, attributeFilter2], + }, + }); + + expect(prisma.membership.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + role: { + in: ["ADMIN"], + }, + teamId: ORGANIZATION_ID, + user: { + isPlatformManaged: false, + teams: { + some: { + team: { + name: { + in: ["team1"], + }, + }, + }, + }, + }, + AND: [ + { + AttributeToUser: { + some: { + attributeOption: { + attribute: { id: "1" }, + value: { in: ["value1"] }, + }, + }, + }, + }, + { + AttributeToUser: { + some: { + attributeOption: { + attribute: { id: "2" }, + value: { in: ["value2"] }, + }, + }, + }, + }, + ], + }), + }) + ); + }); +}); diff --git a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts index e62de55a70..bd28cf47e8 100644 --- a/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/listMembers.handler.ts @@ -1,5 +1,5 @@ import { makeWhereClause } from "@calcom/features/data-table/lib/server"; -import { ColumnFilterType } from "@calcom/features/data-table/lib/types"; +import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types"; import { UserRepository } from "@calcom/lib/server/repository/user"; import { prisma } from "@calcom/prisma"; import type { Prisma } from "@calcom/prisma/client"; @@ -77,18 +77,26 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { const { limit, offset } = input; - const getTotalMembers = await prisma.membership.count({ - where: { - user: { - ...getUserConditions(oAuthClientId), - }, - teamId: organizationId, - }, - }); + const roleFilter = filters.find((filter) => filter.id === "role") as + | TypedColumnFilter + | undefined; + const teamFilter = filters.find((filter) => filter.id === "teams") as + | TypedColumnFilter + | undefined; - let whereClause: Prisma.MembershipWhereInput = { + const whereClause: Prisma.MembershipWhereInput = { user: { ...getUserConditions(oAuthClientId), + ...(teamFilter && { + teams: { + some: { + team: makeWhereClause({ + columnName: "name", + filterValue: teamFilter.value, + }), + }, + }, + }), }, teamId: organizationId, ...(searchTerm && { @@ -99,65 +107,57 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { ], }, }), + ...(roleFilter && + makeWhereClause({ + columnName: "role", + filterValue: roleFilter.value, + })), }; - filters.forEach((filter) => { - switch (filter.id) { - case "role": - whereClause = { - ...whereClause, - ...makeWhereClause({ - columnName: "role", - filterValue: filter.value, - }), - }; - break; - case "teams": - whereClause.user = { - teams: { - some: { - team: makeWhereClause({ - columnName: "name", - filterValue: filter.value, - }), - }, - }, - }; - break; - // We assume that if the filter is not one of the above, it must be an attribute filter - default: - if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) { - const attributeOptionValues: string[] = []; - filter.value.data.forEach((filterValueItem) => { - attributeOptionValues.push(filterValueItem); - groupOptionsWithContainsOptionValues.forEach((groupOption) => { - if (groupOption.contains.find(({ value: containValue }) => containValue === filterValueItem)) { - attributeOptionValues.push(groupOption.value); - } - }); + const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters + .filter((filter) => filter.id !== "role" && filter.id !== "teams") + .map((filter) => { + if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) { + const attributeOptionValues: string[] = []; + filter.value.data.forEach((filterValueItem) => { + attributeOptionValues.push(filterValueItem); + groupOptionsWithContainsOptionValues.forEach((groupOption) => { + if (groupOption.contains.find(({ value: containValue }) => containValue === filterValueItem)) { + attributeOptionValues.push(groupOption.value); + } }); + }); - filter.value.data = attributeOptionValues; - } + filter.value.data = attributeOptionValues; + } - whereClause.AttributeToUser = { - some: { - attributeOption: { - attribute: { - id: filter.id, - }, - ...makeWhereClause({ - columnName: "value", - filterValue: filter.value, - }), + return { + some: { + attributeOption: { + attribute: { + id: filter.id, }, + ...makeWhereClause({ + columnName: "value", + filterValue: filter.value, + }), }, - }; - break; - } + }, + }; + }); + + // If we have attribute filters, add them to the where clause with AND + if (attributeFilters.length > 0) { + whereClause.AND = attributeFilters.map((filter) => ({ + AttributeToUser: filter, + })); + } + + const totalCountPromise = prisma.membership.count({ + where: whereClause, }); - const teamMembers = await prisma.membership.findMany({ + const teamMembersPromise = prisma.membership.findMany({ where: whereClause, select: { id: true, @@ -200,6 +200,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { }, }); + const [totalCount, teamMembers] = await Promise.all([totalCountPromise, teamMembersPromise]); + const members = await Promise.all( teamMembers?.map(async (membership) => { const user = await UserRepository.enrichUserWithItsProfile({ user: membership.user }); @@ -267,7 +269,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => { return { rows: members || [], meta: { - totalRowCount: getTotalMembers || 0, + totalRowCount: totalCount || 0, }, }; };