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 <me@alexvanandel.com>
This commit is contained in:
Eunjae LeeandAlex van Andel authored and GitHub committed 2025-03-27 14:36:47 +01:00
1 parent cd952dc93c
commit 88961a1c67
4 files changed
+207 -63

No files matched your search

@@ -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,
@@ -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
@@ -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<ColumnFilterType.MULTI_SELECT> = {
id: "role",
value: {
type: ColumnFilterType.MULTI_SELECT,
data: ["ADMIN"],
},
};
const teamFilter: TypedColumnFilter<ColumnFilterType.MULTI_SELECT> = {
id: "teams",
value: {
type: ColumnFilterType.MULTI_SELECT,
data: ["team1"],
},
};
const attributeFilter1: TypedColumnFilter<ColumnFilterType.MULTI_SELECT> = {
id: "1",
value: {
type: ColumnFilterType.MULTI_SELECT,
data: ["value1"],
},
};
const attributeFilter2: TypedColumnFilter<ColumnFilterType.MULTI_SELECT> = {
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"] },
},
},
},
},
],
}),
})
);
});
});
@@ -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<ColumnFilterType.MULTI_SELECT>
| undefined;
const teamFilter = filters.find((filter) => filter.id === "teams") as
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
| 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,
},
};
};