fix: multiselect "Any in" operator for a user with just one option assigned for the attribute (#17200)
This commit is contained in:
@@ -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
|
||||
|
||||
+4
@@ -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",
|
||||
|
||||
+2
-2
@@ -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 = {
|
||||
|
||||
@@ -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 });
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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! },
|
||||
];
|
||||
})
|
||||
),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
@@ -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" };
|
||||
|
||||
@@ -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";
|
||||
@@ -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
|
||||
@@ -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[]>);
|
||||
|
||||
Reference in New Issue
Block a user