Skip to content

Commit

Permalink
fix: multiselect "Any in" operator for a user with just one option as…
Browse files Browse the repository at this point in the history
…signed for the attribute (#17200)
  • Loading branch information
hariombalhara authored Oct 20, 2024
1 parent c5677f1 commit 5f0cc80
Show file tree
Hide file tree
Showing 6 changed files with 205 additions and 19 deletions.
5 changes: 4 additions & 1 deletion packages/app-store/routing-forms/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,13 @@ describe("Query Builder Config", () => {
assertCommonStructure(AttributesBaseConfig);
});

it("should support multiselect_some_in operator for multiselect", () => {
it("should support multiselect_some_in and multiselect_not_some_in operators for multiselect", () => {
expect(AttributesBaseConfig.types.multiselect.widgets.multiselect.operators).toContain(
"multiselect_some_in"
);
expect(AttributesBaseConfig.types.multiselect.widgets.multiselect.operators).toContain(
"multiselect_not_some_in"
);

// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,10 @@ const operators: Operators = {
};
},
},
multiselect_not_some_in: {
label: "Not any in",
reversedOp: "multiselect_some_in",
},
multiselect_equals: {
label: "All in",
reversedOp: "multiselect_not_equals",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,9 @@ function getTypes(configFor: ConfigFor) {
const multiSelectOperators = BasicConfig.types.multiselect.widgets.multiselect.operators || [];

if (configFor === ConfigFor.Attributes) {
// Attributes don't need reporting at the moment. So, we can support multiselect_some_in operator for attributes.
// Attributes don't need reporting at the moment. So, we can support multiselect_some_in and multiselect_not_some_in operators for attributes.
// We could probably use them in FormFields later once they are supported through Prisma query as well
multiSelectOperators.push("multiselect_some_in");
multiSelectOperators.push("multiselect_some_in", "multiselect_not_some_in");
}

const types: Types = {
Expand Down
27 changes: 19 additions & 8 deletions packages/app-store/routing-forms/lib/getAttributes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,12 @@ export async function getAttributesForTeam({ teamId }: { teamId: number }) {
}

type AttributeId = string;
type AttributeOptionValue = string | string[];
type AttributeOptionValueWithType = {
type: Attribute["type"];
value: string | string[];
};

type UserId = number;

export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamId }: { teamId: number }) {
const attributesToUser = await getAttributeToUserWithMembershipAndAttributesForTeam({ teamId });
Expand All @@ -121,19 +126,25 @@ export async function getTeamMembersWithAttributeOptionValuePerAttribute({ teamI
}

const attributes = acc[userId].attributes;
const attributeValue = attributes[attribute.id];
const attributeValue = attributes[attribute.id]?.value;
if (attributeValue instanceof Array) {
// Push to existing array
// Value already exists, so push to it
attributeValue.push(value);
} else if (attributeValue) {
// Make it an array
attributes[attribute.id] = [attributeValue, value];
// Value already exists, so push to it and also make it an array before pushing
attributes[attribute.id] = {
type: attribute.type,
value: [attributeValue, value],
};
} else {
// Set it as a string
attributes[attribute.id] = value;
// Set the first value
attributes[attribute.id] = {
type: attribute.type,
value,
};
}
return acc;
}, {} as Record<number, { userId: number; attributes: Record<AttributeId, AttributeOptionValue> }>);
}, {} as Record<UserId, { userId: UserId; attributes: Record<AttributeId, AttributeOptionValueWithType> }>);

return Object.values(teamMembers);
}
166 changes: 161 additions & 5 deletions packages/app-store/routing-forms/trpc/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,24 @@ function mockAttributesScenario({
teamMembersWithAttributeOptionValuePerAttribute,
}: {
attributes: Awaited<ReturnType<typeof getAttributesModule.getAttributesForTeam>>;
teamMembersWithAttributeOptionValuePerAttribute: Awaited<
ReturnType<typeof getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute>
>;
teamMembersWithAttributeOptionValuePerAttribute: {
userId: number;
attributes: Record<string, string | string[]>;
}[];
}) {
vi.mocked(getAttributesModule.getAttributesForTeam).mockResolvedValue(attributes);
vi.mocked(getAttributesModule.getTeamMembersWithAttributeOptionValuePerAttribute).mockResolvedValue(
teamMembersWithAttributeOptionValuePerAttribute
teamMembersWithAttributeOptionValuePerAttribute.map((member) => ({
...member,
attributes: Object.fromEntries(
Object.entries(member.attributes).map(([attributeId, value]) => {
return [
attributeId,
{ value, type: attributes.find((attribute) => attribute.id === attributeId)?.type! },
];
})
),
}))
);
}

Expand Down Expand Up @@ -342,7 +353,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
]);
});

it("should return matching team members with a SINGLE_SELECT attribute when 'Any in' option is selected", async () => {
it("should return matching team members with a SINGLE_SELECT attribute when 'Any in'(select_any_in) option is selected", async () => {
const Option1OfAttribute1HumanReadableValue = "Option 1";

const Option1OfAttribute1 = {
Expand Down Expand Up @@ -412,6 +423,151 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
]);
});

it("should return matching team members with a MULTI_SELECT attribute when 'Any in'(multiselect_some_in) option is selected and just one option is used in attribute for the user", async () => {
const Option1OfAttribute1HumanReadableValue = "Option 1";

const Option1OfAttribute1 = {
id: "attr-1-opt-1",
value: Option1OfAttribute1HumanReadableValue,
slug: "option-1",
};

const Option2OfAttribute1 = {
id: "attr-1-opt-2",
value: "Option 2",
slug: "option-2",
};

const Option3OfAttribute1 = {
id: "attr-1-opt-3",
value: "Option 3",
slug: "option-3",
};

const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "MULTI_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1, Option2OfAttribute1, Option3OfAttribute1],
};

mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
// user 1 has only one option selected for the attribute
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});

const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [[Option1OfAttribute1.id, Option2OfAttribute1.id]],
operator: "multiselect_some_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;

const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
});

expect(result).toEqual([
{
userId: 1,
result: RaqbLogicResult.MATCH,
},
]);
});

it("should return matching team members with a MULTI_SELECT attribute when 'Any in'(multiselect_some_in) option is selected and more than one option is used in attribute for the user", async () => {
const Option1OfAttribute1HumanReadableValue = "Option 1";

const Option1OfAttribute1 = {
id: "attr-1-opt-1",
value: Option1OfAttribute1HumanReadableValue,
slug: "option-1",
};

const Option2OfAttribute1 = {
id: "attr-1-opt-2",
value: "Option 2",
slug: "option-2",
};

const Option3OfAttribute1 = {
id: "attr-1-opt-3",
value: "Option 3",
slug: "option-3",
};

const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "MULTI_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1, Option2OfAttribute1, Option3OfAttribute1],
};

mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{
userId: 1,
// user 1 has two options selected for the attribute
attributes: { [Attribute1.id]: [Option2OfAttribute1.value, Option1OfAttribute1.value] },
},
],
});

const attributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [[Option1OfAttribute1.id, Option2OfAttribute1.id]],
operator: "multiselect_some_in",
valueType: ["multiselect"],
},
],
}) as AttributesQueryValue;

const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
});

expect(result).toEqual([
{
userId: 1,
result: RaqbLogicResult.MATCH,
},
]);
});

describe("Error handling", () => {
it("should throw an error if the attribute type is not supported", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
Expand Down
18 changes: 15 additions & 3 deletions packages/app-store/routing-forms/trpc/raqbUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { JsonGroup, JsonItem, JsonRule, JsonTree } from "react-awesome-quer

import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { AttributeType } from "@calcom/prisma/enums";

import type { AttributesQueryBuilderConfigWithRaqbFields } from "../lib/getQueryBuilderConfig";
import { getQueryBuilderConfigForAttributes } from "../lib/getQueryBuilderConfig";
Expand Down Expand Up @@ -202,10 +203,16 @@ function getAttributesData({
attributesData,
attributesQueryValue,
}: {
attributesData: Record<string, string | string[]>;
attributesData: Record<
string,
{
value: string | string[];
type: Attribute["type"];
}
>;
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
return Object.entries(attributesData).reduce((acc, [attributeId, value]) => {
return Object.entries(attributesData).reduce((acc, [attributeId, { value, type: attributeType }]) => {
const compatibleValueForAttributeAndFormFieldMatching = compatibleForAttributeAndFormFieldMatch(value);

// We do this to ensure that correct jsonLogic is generated for an existing route even if the attribute's type changes
Expand All @@ -216,7 +223,12 @@ function getAttributesData({
// });

// Right now we can't trust ensureAttributeValueToBeOfRaqbFieldValueType to give us the correct value
acc[attributeId] = compatibleValueForAttributeAndFormFieldMatching;
acc[attributeId] =
// multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array
// If we add an operator that doesn't expect an array, we need to somehow make it operator based.
attributeType === AttributeType.MULTI_SELECT
? ensureArray(compatibleValueForAttributeAndFormFieldMatching)
: compatibleValueForAttributeAndFormFieldMatching;

return acc;
}, {} as Record<string, string | string[]>);
Expand Down

0 comments on commit 5f0cc80

Please sign in to comment.