diff --git a/packages/app-store/routing-forms/__tests__/config.test.ts b/packages/app-store/routing-forms/__tests__/config.test.ts index 53acc5ae7b..8958ffe891 100644 --- a/packages/app-store/routing-forms/__tests__/config.test.ts +++ b/packages/app-store/routing-forms/__tests__/config.test.ts @@ -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 diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts index 5f1edef063..cbd8884192 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/BasicConfig.ts @@ -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", diff --git a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx index c826768aa2..33029af784 100644 --- a/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx +++ b/packages/app-store/routing-forms/components/react-awesome-query-builder/config/config.tsx @@ -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 = { diff --git a/packages/app-store/routing-forms/lib/getAttributes.ts b/packages/app-store/routing-forms/lib/getAttributes.ts index 50db6c5f79..f16f70b272 100644 --- a/packages/app-store/routing-forms/lib/getAttributes.ts +++ b/packages/app-store/routing-forms/lib/getAttributes.ts @@ -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 }>); + }, {} as Record }>); return Object.values(teamMembers); } diff --git a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts index b4d7d6c122..1a7c1aa130 100644 --- a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts +++ b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts @@ -22,13 +22,24 @@ function mockAttributesScenario({ teamMembersWithAttributeOptionValuePerAttribute, }: { attributes: Awaited>; - teamMembersWithAttributeOptionValuePerAttribute: Awaited< - ReturnType - >; + teamMembersWithAttributeOptionValuePerAttribute: { + userId: number; + attributes: Record; + }[]; }) { 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" }; diff --git a/packages/app-store/routing-forms/trpc/raqbUtils.ts b/packages/app-store/routing-forms/trpc/raqbUtils.ts index b1c25553cd..84b3f7bccf 100644 --- a/packages/app-store/routing-forms/trpc/raqbUtils.ts +++ b/packages/app-store/routing-forms/trpc/raqbUtils.ts @@ -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; + attributesData: Record< + string, + { + value: string | string[]; + type: Attribute["type"]; + } + >; attributesQueryValue: NonNullable; }) { - 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);