From 016a9660571b68c7466c76f26cd06f10f8b03fa1 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Wed, 30 Jul 2025 20:37:16 +0530 Subject: [PATCH] feat: Support an array response for a field when used as `Value of Field` (#22740) * Passing tests and fixed * self review addressed adn more tests --- ...dTeamMembersMatchingAttributeLogic.test.ts | 815 +++++--- packages/lib/raqb/raqbUtils.test.ts | 1688 +++++++++++++++- packages/lib/raqb/raqbUtils.ts | 128 +- packages/lib/raqb/resolveQueryValue.test.ts | 1745 +++++++++++++++++ packages/lib/raqb/resolveQueryValue.ts | 267 +++ packages/lib/raqb/utils.ts | 9 + packages/lib/raqb/zod.ts | 34 + 7 files changed, 4271 insertions(+), 415 deletions(-) create mode 100644 packages/lib/raqb/resolveQueryValue.test.ts create mode 100644 packages/lib/raqb/resolveQueryValue.ts create mode 100644 packages/lib/raqb/utils.ts diff --git a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts index 667a638945..72a71ae5e9 100644 --- a/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts +++ b/packages/lib/raqb/findTeamMembersMatchingAttributeLogic.test.ts @@ -47,6 +47,7 @@ function mockAttributesScenario({ ...attribute, options: attribute.options.map((option) => ({ ...option, + attributeId: attribute.id, ...commonOptionsProps, })), })), @@ -289,35 +290,176 @@ describe("findTeamMembersMatchingAttributeLogic", () => { ); }); - it("should return matching team members with a SINGLE_SELECT attribute when a static option is selected and troubleshooter should be null by default", async () => { - const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" }; - const Attribute1 = { - id: "attr1", - name: "Attribute 1", - type: "SINGLE_SELECT" as const, - slug: "attribute-1", - options: [Option1OfAttribute1], - }; + describe("with a SINGLE_SELECT attribute", () => { + it("when a static option is selected, troubleshooter should be null by default", async () => { + const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" }; + const Attribute1 = { + id: "attr1", + name: "Attribute 1", + type: "SINGLE_SELECT" as const, + slug: "attribute-1", + options: [Option1OfAttribute1], + }; - mockAttributesScenario({ - attributes: [Attribute1], - teamMembersWithAttributeOptionValuePerAttribute: [ - { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, - ], + mockAttributesScenario({ + attributes: [Attribute1], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: Attribute1.id, + value: [Option1OfAttribute1.id], + operator: "select_equals", + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result, troubleshooter } = + await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [], + response: {}, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + expect(result).toEqual([ + { + userId: 1, + result: RaqbLogicResult.MATCH, + }, + ]); + + expect(troubleshooter).toBeUndefined(); }); - const attributesQueryValue = buildSelectTypeFieldQueryValue({ - rules: [ - { - raqbFieldId: Attribute1.id, - value: [Option1OfAttribute1.id], - operator: "select_equals", - }, - ], - }) as AttributesQueryValue; + describe("with `Value of field`", () => { + it("when 'Value of Field' option is selected and ", async () => { + const Option1OfAttribute1HumanReadableValue = "Option 1"; + const Option1OfField1HumanReadableValue = Option1OfAttribute1HumanReadableValue; + const Field1Id = "field-1"; - const { teamMembersMatchingAttributeLogic: result, troubleshooter } = - await findTeamMembersMatchingAttributeLogic({ + const Option1OfAttribute1 = { + id: "attr-1-opt-1", + value: Option1OfAttribute1HumanReadableValue, + slug: "option-1", + }; + + const Option1OfField1 = { + id: "field-1-opt-1", + label: Option1OfField1HumanReadableValue, + }; + + const Attribute1 = { + id: "attr1", + name: "Attribute 1", + type: "SINGLE_SELECT" as const, + slug: "attribute-1", + options: [Option1OfAttribute1], + }; + + mockAttributesScenario({ + attributes: [Attribute1], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: Attribute1.id, + value: [`{field:${Field1Id}}`], + operator: "select_equals", + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [ + { + id: Field1Id, + type: RoutingFormFieldType.SINGLE_SELECT, + label: "Field 1", + options: [Option1OfField1], + }, + ], + response: { + [Field1Id]: { + value: Option1OfAttribute1HumanReadableValue, + label: Option1OfAttribute1HumanReadableValue, + }, + }, + }, + attributesQueryValue: attributesQueryValue, + teamId: 1, + orgId, + }); + + expect(result).toEqual([ + { + userId: 1, + result: RaqbLogicResult.MATCH, + }, + ]); + }); + }); + + it("when 'Any in'(select_any_in) option is selected", 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: "SINGLE_SELECT" as const, + slug: "attribute-1", + options: [Option1OfAttribute1, Option2OfAttribute1, Option3OfAttribute1], + }; + + mockAttributesScenario({ + attributes: [Attribute1], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: Attribute1.id, + value: [[Option1OfAttribute1.id, Option2OfAttribute1.id]], + operator: "select_any_in", + valueType: ["multiselect"], + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ dynamicFieldValueOperands: { fields: [], response: {}, @@ -327,285 +469,328 @@ describe("findTeamMembersMatchingAttributeLogic", () => { orgId, }); - expect(result).toEqual([ - { - userId: 1, - result: RaqbLogicResult.MATCH, - }, - ]); - - expect(troubleshooter).toBeUndefined(); - }); - - it("should return matching team members with a SINGLE_SELECT attribute when 'Value of Field' option is selected", async () => { - const Option1OfAttribute1HumanReadableValue = "Option 1"; - const Option1OfField1HumanReadableValue = Option1OfAttribute1HumanReadableValue; - const Field1Id = "field-1"; - - const Option1OfAttribute1 = { - id: "attr-1-opt-1", - value: Option1OfAttribute1HumanReadableValue, - slug: "option-1", - }; - - const Option1OfField1 = { - id: "field-1-opt-1", - label: Option1OfField1HumanReadableValue, - }; - - const Attribute1 = { - id: "attr1", - name: "Attribute 1", - type: "SINGLE_SELECT" as const, - slug: "attribute-1", - options: [Option1OfAttribute1], - }; - - mockAttributesScenario({ - attributes: [Attribute1], - teamMembersWithAttributeOptionValuePerAttribute: [ - { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, - ], - }); - - const attributesQueryValue = buildSelectTypeFieldQueryValue({ - rules: [ - { - raqbFieldId: Attribute1.id, - value: [`{field:${Field1Id}}`], - operator: "select_equals", - }, - ], - }) as AttributesQueryValue; - - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ - dynamicFieldValueOperands: { - fields: [ - { - id: Field1Id, - type: RoutingFormFieldType.SINGLE_SELECT, - label: "Field 1", - options: [Option1OfField1], - }, - ], - response: { - [Field1Id]: { - value: Option1OfAttribute1HumanReadableValue, - label: Option1OfAttribute1HumanReadableValue, - }, - }, - }, - attributesQueryValue: attributesQueryValue, - teamId: 1, - orgId, - }); - - expect(result).toEqual([ - { - userId: 1, - result: RaqbLogicResult.MATCH, - }, - ]); - }); - - 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 = { - 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: "SINGLE_SELECT" as const, - slug: "attribute-1", - options: [Option1OfAttribute1, Option2OfAttribute1, Option3OfAttribute1], - }; - - mockAttributesScenario({ - attributes: [Attribute1], - teamMembersWithAttributeOptionValuePerAttribute: [ - { userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } }, - ], - }); - - const attributesQueryValue = buildSelectTypeFieldQueryValue({ - rules: [ - { - raqbFieldId: Attribute1.id, - value: [[Option1OfAttribute1.id, Option2OfAttribute1.id]], - operator: "select_any_in", - valueType: ["multiselect"], - }, - ], - }) as AttributesQueryValue; - - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ - dynamicFieldValueOperands: { - fields: [], - response: {}, - }, - attributesQueryValue, - teamId: 1, - orgId, - }); - - 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 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 findTeamMembersMatchingAttributeLogic({ - dynamicFieldValueOperands: { - fields: [], - response: {}, - }, - attributesQueryValue, - teamId: 1, - orgId, - }); - - 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: [ + expect(result).toEqual([ { userId: 1, - // user 1 has two options selected for the attribute - attributes: { [Attribute1.id]: [Option2OfAttribute1.value, Option1OfAttribute1.value] }, + result: RaqbLogicResult.MATCH, }, - ], + ]); }); + }); - const attributesQueryValue = buildSelectTypeFieldQueryValue({ - rules: [ + describe("with a MULTI_SELECT attribute", () => { + it("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 findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [], + response: {}, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + expect(result).toEqual([ { - raqbFieldId: Attribute1.id, - value: [[Option1OfAttribute1.id, Option2OfAttribute1.id]], - operator: "multiselect_some_in", - valueType: ["multiselect"], + userId: 1, + result: RaqbLogicResult.MATCH, }, - ], - }) as AttributesQueryValue; - - const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ - dynamicFieldValueOperands: { - fields: [], - response: {}, - }, - attributesQueryValue, - teamId: 1, - orgId, + ]); }); - expect(result).toEqual([ - { - userId: 1, - result: RaqbLogicResult.MATCH, - }, - ]); + it("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 findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [], + response: {}, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + expect(result).toEqual([ + { + userId: 1, + result: RaqbLogicResult.MATCH, + }, + ]); + }); + + describe("with `Value of field`", () => { + it("when 'Any in'(multiselect_some_in) option is selected and more than one option is used in attribute and more than one option is selected in the field", async () => { + const Field1Id = "location-field"; + const DelhiOption = { + id: "delhi-opt", + label: "Delhi", + }; + const HaryanaOption = { + id: "haryana-opt", + label: "Haryana", + }; + const MumbaiOption = { + id: "mumbai-opt", + label: "Mumbai", + }; + + const HeadquartersAttribute = { + id: "headquarters-attr", + name: "Headquarters", + type: "MULTI_SELECT" as const, + slug: "headquarters", + options: [ + { id: "delhi-hq", value: "Delhi", slug: "delhi" }, + { id: "haryana-hq", value: "Haryana", slug: "haryana" }, + { id: "mumbai-hq", value: "Mumbai", slug: "mumbai" }, + { id: "chennai-hq", value: "Chennai", slug: "chennai" }, + ], + }; + + mockAttributesScenario({ + attributes: [HeadquartersAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + // User 1: headquarters in Delhi and Haryana + { userId: 1, attributes: { [HeadquartersAttribute.id]: ["Delhi", "Haryana"] } }, + // User 2: headquarters only in Mumbai + { userId: 2, attributes: { [HeadquartersAttribute.id]: ["Mumbai"] } }, + // User 3: headquarters in Chennai + { userId: 3, attributes: { [HeadquartersAttribute.id]: ["Chennai"] } }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: HeadquartersAttribute.id, + value: [[`{field:${Field1Id}}`]], + operator: "multiselect_some_in", + valueType: ["multiselect"], + }, + ], + }) as AttributesQueryValue; + + // Test case 1: Booker selects Delhi and Mumbai + const { teamMembersMatchingAttributeLogic: result1 } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [ + { + id: Field1Id, + type: RoutingFormFieldType.MULTI_SELECT, + label: "Location", + options: [DelhiOption, HaryanaOption, MumbaiOption], + }, + ], + response: { + [Field1Id]: { label: "Location", value: [DelhiOption.id, MumbaiOption.id] }, // Booker selected Delhi and Mumbai + }, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // Should match users 1 and 2 (Delhi matches user 1, Mumbai matches user 2) + expect(result1).toEqual( + expect.arrayContaining([ + { userId: 1, result: RaqbLogicResult.MATCH }, + { userId: 2, result: RaqbLogicResult.MATCH }, + ]) + ); + expect(result1).not.toContainEqual({ userId: 3, result: RaqbLogicResult.MATCH }); + + // Test case 2: Booker selects only Chennai + const { teamMembersMatchingAttributeLogic: result2 } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [ + { + id: Field1Id, + type: RoutingFormFieldType.MULTI_SELECT, + label: "Location", + options: [DelhiOption, HaryanaOption, MumbaiOption, { id: "chennai-opt", label: "Chennai" }], + }, + ], + response: { + [Field1Id]: { label: "Location", value: ["chennai-opt"] }, // Booker selected only Chennai + }, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // Should match only user 3 + expect(result2).toEqual([{ userId: 3, result: RaqbLogicResult.MATCH }]); + }); + }); + + it("should handle field template mixed with regular values in multiselect array", async () => { + const LocationFieldId = "location-field"; + const FixedValueId = "fixed-location-id"; + + const LocationAttribute = { + id: "location-attr", + name: "Service Locations", + type: "MULTI_SELECT" as const, + slug: "service-locations", + options: [ + { id: "delhi-loc", value: "Delhi", slug: "delhi" }, + { id: "mumbai-loc", value: "Mumbai", slug: "mumbai" }, + { id: FixedValueId, value: "Chennai", slug: "chennai" }, + ], + }; + + mockAttributesScenario({ + attributes: [LocationAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + // User 1: services Delhi and Chennai + { userId: 1, attributes: { [LocationAttribute.id]: ["Delhi", "Chennai"] } }, + // User 2: services only Mumbai + { userId: 2, attributes: { [LocationAttribute.id]: ["Mumbai"] } }, + ], + }); + + // This simulates a complex rule: Location must include field value OR Chennai (fixed) + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: LocationAttribute.id, + value: [[`{field:${LocationFieldId}}`, "Chennai"]], // Mixed: field template + fixed value + operator: "multiselect_some_in", + valueType: ["multiselect"], + }, + ], + }) as AttributesQueryValue; + + // When booker selects Mumbai + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [ + { + id: LocationFieldId, + type: RoutingFormFieldType.MULTI_SELECT, + label: "Preferred Location", + options: [ + { id: "delhi-opt", label: "Delhi" }, + { id: "mumbai-opt", label: "Mumbai" }, + ], + }, + ], + response: { + [LocationFieldId]: { value: ["Mumbai"], label: "Mumbai" }, // Booker selected Mumbai + }, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // Should match both users: + // User 1: matches because they service Chennai (the fixed value) + // User 2: matches because they service Mumbai (the field value) + expect(result).toEqual( + expect.arrayContaining([ + { userId: 1, result: RaqbLogicResult.MATCH }, + { userId: 2, result: RaqbLogicResult.MATCH }, + ]) + ); + }); }); describe("Fallback", () => { @@ -1025,4 +1210,44 @@ describe("findTeamMembersMatchingAttributeLogic", () => { expect(troubleshooter).not.toBeUndefined(); }); }); + + it("should handle non-existent option IDs gracefully", async () => { + const LocationAttribute = { + id: "location-attr", + name: "Location", + type: "SINGLE_SELECT" as const, + slug: "location", + options: [{ id: "ny-opt", value: "New York", slug: "new-york" }], + }; + + mockAttributesScenario({ + attributes: [LocationAttribute], + teamMembersWithAttributeOptionValuePerAttribute: [ + { userId: 1, attributes: { [LocationAttribute.id]: "New York" } }, + ], + }); + + const attributesQueryValue = buildSelectTypeFieldQueryValue({ + rules: [ + { + raqbFieldId: LocationAttribute.id, + value: ["non-existent-id"], // Non-existent option ID + operator: "select_equals", + }, + ], + }) as AttributesQueryValue; + + const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogic({ + dynamicFieldValueOperands: { + fields: [], + response: {}, + }, + attributesQueryValue, + teamId: 1, + orgId, + }); + + // Should not match anyone as the option ID doesn't exist + expect(result).toEqual([]); + }); }); diff --git a/packages/lib/raqb/raqbUtils.test.ts b/packages/lib/raqb/raqbUtils.test.ts index d6ecb5c9cf..98d4c5005d 100644 --- a/packages/lib/raqb/raqbUtils.test.ts +++ b/packages/lib/raqb/raqbUtils.test.ts @@ -1,6 +1,165 @@ -import { expect, describe, it } from "vitest"; +import { expect, describe, it, vi } from "vitest"; -import { getValueOfAttributeOption } from "./raqbUtils"; +import type { AttributesQueryValue, RaqbChild } from "@calcom/lib/raqb/types"; +import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; +import { AttributeType } from "@calcom/prisma/enums"; +import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; + +import { getValueOfAttributeOption, acrossQueryValueCompatiblity } from "./raqbUtils"; + +const { resolveQueryValue } = acrossQueryValueCompatiblity; + +// Mock the getFieldResponseValueAsLabel +vi.mock("@calcom/app-store/routing-forms/lib/getFieldResponseValueAsLabel", () => ({ + getFieldResponseValueAsLabel: ({ fieldResponseValue }: { field: any; fieldResponseValue: any }) => { + // For testing, just return the value as-is + return fieldResponseValue; + }, +})); + +// Test Data Builders for AttributesQueryValue +const createQueryValueRule = (overrides?: Partial): RaqbChild => ({ + type: "rule", + properties: { + field: "default-field", + operator: "select_equals", + value: ["default-value"], + valueSrc: ["value"], + valueError: [null], + valueType: ["select"], + }, + ...overrides, +}); + +const createAttributesQueryValue = (overrides?: { + id?: string; + type?: "group" | "switch_group"; + children1?: Record; + properties?: any; +}): AttributesQueryValue => ({ + id: overrides?.id || "test-id", + type: overrides?.type || "group", + children1: overrides?.children1 || {}, + properties: overrides?.properties, +}); + +const createQueryValueWithRule = ({ + ruleId, + field, + operator, + value, + valueSrc = ["value"], + valueError = [null], + valueType = ["select"], +}: { + ruleId: string; + field: string; + operator: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; +}): AttributesQueryValue => { + return createAttributesQueryValue({ + children1: { + [ruleId]: createQueryValueRule({ + type: "rule", + properties: { + field, + operator, + value, + valueSrc, + valueError, + valueType, + }, + }), + }, + }); +}; + +const createComplexQueryValue = ({ + id, + rules, +}: { + id?: string; + rules: Array<{ + ruleId: string; + field: string; + operator: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; + }>; +}): AttributesQueryValue => { + const children1: Record = {}; + + rules.forEach((rule) => { + children1[rule.ruleId] = createQueryValueRule({ + type: "rule", + properties: { + field: rule.field, + operator: rule.operator, + value: rule.value, + valueSrc: rule.valueSrc || ["value"], + valueError: rule.valueError || [null], + valueType: rule.valueType || ["select"], + }, + }); + }); + + return createAttributesQueryValue({ + id, + children1, + }); +}; + +const createNestedGroupQueryValue = ({ + groups, +}: { + groups: Array<{ + groupId: string; + rules: Array<{ + ruleId: string; + field: string; + operator?: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; + }>; + }>; +}): AttributesQueryValue => { + const children1: Record = {}; + + groups.forEach((group) => { + const groupChildren: Record = {}; + + group.rules.forEach((rule) => { + groupChildren[rule.ruleId] = createQueryValueRule({ + type: "rule", + properties: { + field: rule.field, + operator: rule.operator || "select_equals", + value: rule.value, + valueSrc: rule.valueSrc || ["value"], + valueError: rule.valueError || [null], + valueType: rule.valueType || ["select"], + }, + }); + }); + + children1[group.groupId] = { + type: "group", + children1: groupChildren, + } as RaqbChild; + }); + + return createAttributesQueryValue({ + type: "group", + children1, + }); +}; describe("getValueOfAttributeOption", () => { it("should return non-array value for non-array input - It is a requirement for RAQB to not unnecessarily transform single value to an array of one item", () => { @@ -88,3 +247,1528 @@ describe("getValueOfAttributeOption", () => { expect(result).toEqual([]); }); }); + +describe("resolveQueryValue", () => { + const mockFields = [ + { id: "location", type: RoutingFormFieldType.MULTI_SELECT, label: "Location", options: [] }, + { id: "city", type: RoutingFormFieldType.SINGLE_SELECT, label: "City", options: [] }, + ]; + + // Mock attributes for testing + const mockAttributes: Attribute[] = [ + { + id: "attr-1", + name: "Location", + slug: "location", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "opt-1", value: "New York", slug: "new-york" }, + { id: "opt-2", value: "London", slug: "london" }, + ], + }, + { + id: "attr-2", + name: "City", + slug: "city", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt-3", value: "Mumbai", slug: "mumbai" }, + { id: "opt-4", value: "Delhi", slug: "delhi" }, + ], + }, + ]; + + describe("attribute option ID replacement", () => { + it("should replace attribute option IDs with lowercase values", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr-1", + operator: "select_equals", + value: ["new york"], // Should be lowercase + }), + }, + }, + }) + ); + }); + + it("should replace multiple attribute option IDs in the same value array", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "opt-2", "opt-3", "opt-4"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr-1", + operator: "multiselect_some_in", + value: [["new york", "london", "mumbai", "delhi"]], + }), + }, + }, + }) + ); + }); + + it("should handle attribute option IDs in nested rules", () => { + const queryValue = createComplexQueryValue({ + rules: [ + { + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }, + { + ruleId: "rule2", + field: "attr-2", + operator: "select_equals", + value: ["opt-3"], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["new york"], + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + value: ["mumbai"], + }), + }, + }, + }) + ); + }); + + it("should replace attribute option IDs before processing field templates", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Paris"], label: "Paris" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "paris"]], // opt-1 replaced with "new york", field template with "paris" + }), + }, + }, + }) + ); + }); + + it("should handle attribute option IDs with special characters", () => { + const specialAttributes: Attribute[] = [ + { + id: "attr-special", + name: "Special", + slug: "special", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt-special-1", value: "São Paulo", slug: "sao-paulo" }, + { id: "opt-special-2", value: "Zürich", slug: "zurich" }, + ], + }, + ]; + + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-special", + operator: "select_equals", + value: ["opt-special-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: specialAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["são paulo"], // Should preserve special characters but be lowercase + }), + }, + }, + }) + ); + }); + + it("should not replace IDs that don't match any attribute options", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["non-existent-id", "opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["non-existent-id", "new york"], // Only opt-1 should be replaced + }), + }, + }, + }) + ); + }); + + it("should handle empty attributes array", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: [], // No attributes + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["opt-1"], // Should remain unchanged + }), + }, + }, + }) + ); + }); + + it("should handle attributes with no options", () => { + const attributesWithoutOptions: Attribute[] = [ + { + id: "attr-no-options", + name: "No Options", + slug: "no-options", + type: AttributeType.TEXT, + options: [], // Empty options array + }, + ]; + + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-no-options", + operator: "text_equals", + value: ["some-value"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: attributesWithoutOptions, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["some-value"], // Should remain unchanged + }), + }, + }, + }) + ); + }); + + it("should replace option IDs in complex nested group structures", () => { + const queryValue = createNestedGroupQueryValue({ + groups: [ + { + groupId: "group1", + rules: [ + { + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "opt-2"]], + valueType: ["multiselect"], + }, + { + ruleId: "rule2", + field: "attr-2", + operator: "select_equals", + value: ["opt-3"], + }, + ], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + group1: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "london"]], + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + value: ["mumbai"], + }), + }, + }, + }, + }, + }) + ); + }); + + it("should handle option IDs mixed with field templates in double-nested arrays", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "{field:city}", "opt-2", "{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Tokyo", label: "Tokyo" }, + location: { value: ["Berlin", "Paris"], label: "Berlin, Paris" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "tokyo", "london", "berlin", "paris"]], + }), + }, + }, + }) + ); + }); + }); + + it("should handle simple field template replacement with single value", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "a8a89bba", + field: "city", + operator: "select_equals", + value: ["{field:city}"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { label: "Mumbai", value: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + a8a89bba: { + type: "rule", + properties: expect.objectContaining({ + field: "city", + operator: "select_equals", + value: ["mumbai"], + }), + }, + }, + }) + ); + }); + + it("should handle field template in double-nested array for multiselect", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { label: "Delhi", value: "Delhi" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "location", + operator: "multiselect_some_in", + value: [["delhi"]], + }), + }, + }, + }) + ); + }); + + it("should handle mixed array with field template and regular values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}", "Chennai"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: "Delhi", label: "Delhi" }, // Single value, not array + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["delhi", "Chennai"]], // Single value replaced + }, + }, + }, + }) + ); + }); + + it("should handle multiple field templates in the same array", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "multifield", + operator: "multiselect_some_in", + value: [["{field:location}", "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "multifield", + operator: "multiselect_some_in", + value: [["delhi", "mumbai"]], + }, + }, + }, + }) + ); + }); + + it("should handle field template with no response value", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "select_equals", + value: ["{field:location}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: {}, // No response for location field + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "select_equals", + value: ["{field:location}"], // Should remain unchanged + }, + }, + }, + }) + ); + }); + + it("should handle field template with unknown field", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "unknown", + operator: "select_equals", + value: ["{field:unknown}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + unknown: { value: "test", label: "test" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "unknown", + operator: "select_equals", + value: ["{field:unknown}"], // Should remain unchanged + }, + }, + }, + }) + ); + }); + + it("should handle complex nested structure", () => { + const queryValue = createAttributesQueryValue({ + id: "test", + type: "group", + children1: { + rule1: createQueryValueRule({ + type: "rule", + properties: { + field: "attr1", + operator: "multiselect_some_in", + value: [["{field:location}", "Fixed-Value"]], + valueType: ["multiselect"], + }, + }), + }, + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Haryana"], label: "Delhi, Haryana" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + id: "test", + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr1", + value: [["delhi", "haryana", "Fixed-Value"]], + operator: "multiselect_some_in", + }), + }, + }, + }) + ); + }); + + it("should handle empty array values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: [], label: "" }, // Empty array + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [[]], // Should be empty array + }, + }, + }, + }) + ); + }); + + it("should handle no dynamicFieldValueOperands", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: ["{field:location}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual(queryValue); // Should return unchanged + }); + + it("should preserve case for non-template values in mixed arrays", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}", "Chennai", "MUMBAI"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["delhi", "Chennai", "MUMBAI"]], // Only field template value is lowercased + }, + }, + }, + }) + ); + }); + + it("should handle deeply nested field templates", () => { + const queryValue = createNestedGroupQueryValue({ + groups: [ + { + groupId: "group1", + rules: [ + { + ruleId: "rule1", + field: "location", + value: [["{field:location}", "Fixed1"]], + valueType: ["multiselect"], + }, + { + ruleId: "rule2", + field: "city", + value: [["{field:city}", "Fixed2"]], + valueType: ["multiselect"], + }, + ], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + city: { value: "Chennai", label: "Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + type: "group", + children1: { + group1: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "location", + value: [["delhi", "mumbai", "Fixed1"]], + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + field: "city", + value: [["chennai", "Fixed2"]], + }), + }, + }, + }, + }, + }) + ); + }); + + it("should handle field template with array containing special characters", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["New York", "São Paulo", "Zürich"], label: "New York, São Paulo, Zürich" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["new york", "são paulo", "zürich"]], + }, + }, + }, + }) + ); + }); + + it("should handle field template in arrays at different nesting levels", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], // Single nested + }, + }, + rule2: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], // Double nested + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [[["{field:city}"]]], // Triple nested + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["mumbai"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "location", + value: [["delhi"]], + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [[["mumbai"]]], + }, + }, + }, + }) + ); + }); + + it("should handle null and undefined values gracefully", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "mixed", + value: [["{field:location}", null, "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + city: { value: null as any, label: "" }, // null value + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "mixed", + value: [["delhi", null, "{field:city}"]], // city remains as template due to null value + }, + }, + }, + }) + ); + }); + + it("should handle numeric values from fields", () => { + const numericFields = [ + { id: "age", type: RoutingFormFieldType.NUMBER, label: "Age", options: [] }, + { id: "count", type: RoutingFormFieldType.NUMBER, label: "Count", options: [] }, + ]; + + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "numbers", + value: [["{field:age}", "{field:count}", 42]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: numericFields, + response: { + age: { value: 25, label: "25" }, + count: { value: 100, label: "100" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "numbers", + value: [["25", "100", 42]], + }, + }, + }, + }) + ); + }); + + it("should handle very large arrays efficiently", () => { + const largeArray = new Array(100).fill("item"); + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", ...largeArray]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result.children1?.rule1.properties?.value[0]).toHaveLength(102); // 2 from field + 100 items + expect(result.children1?.rule1.properties?.value[0].slice(0, 2)).toEqual(["delhi", "mumbai"]); + }); + + it("should handle edge case with empty string field values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["", " ", "Delhi"], label: ", , Delhi" }, // Empty and whitespace strings + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["", " ", "delhi"]], + }, + }, + }, + }) + ); + }); + + it("should handle field templates with hyphens and underscores in field names", () => { + const specialFields = [ + { id: "field-with-dashes", type: RoutingFormFieldType.TEXT, label: "Dashed Field", options: [] }, + { + id: "field_with_underscores", + type: RoutingFormFieldType.TEXT, + label: "Underscored Field", + options: [], + }, + ]; + + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "special", + value: ["{field:field-with-dashes}", "{field:field_with_underscores}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: specialFields, + response: { + "field-with-dashes": { value: "Value2", label: "Value2" }, + field_with_underscores: { value: "Value3", label: "Value3" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "special", + value: ["value2", "value3"], + }, + }, + }, + }) + ); + }); + + it("should handle mixed field templates and fixed values in complex scenarios", () => { + const queryValue = { + children1: { + group1: { + type: "rule", + properties: { + field: "mixed1", + value: [["{field:location}", "Fixed1", "{field:city}"]], + }, + }, + group2: { + type: "rule", + properties: { + field: "mixed2", + value: [["Fixed2", "{field:location}"]], + }, + }, + group3: { + type: "rule", + properties: { + field: "mixed3", + value: [["{field:city}", "{field:location}", "Fixed3", "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + city: { value: "Chennai", label: "Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + group1: { + type: "rule", + properties: { + field: "mixed1", + value: [["delhi", "mumbai", "Fixed1", "chennai"]], + }, + }, + group2: { + type: "rule", + properties: { + field: "mixed2", + value: [["Fixed2", "delhi", "mumbai"]], + }, + }, + group3: { + type: "rule", + properties: { + field: "mixed3", + value: [["chennai", "delhi", "mumbai", "Fixed3", "chennai"]], + }, + }, + }, + }) + ); + }); + + it("should handle field templates in different JSON value types", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: "{field:city}", + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + rule4: { + type: "rule", + properties: { + field: "mixed", + customProp: "{field:city}", + nested: { + value: ["{field:location}"], + }, + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + city: { value: "Chennai", label: "Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: "chennai", + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["chennai"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "location", + value: [["delhi", "mumbai"]], + }, + }, + rule4: { + type: "rule", + properties: { + field: "mixed", + customProp: "chennai", + nested: { + value: ["delhi", "mumbai"], + }, + }, + }, + }, + }) + ); + }); + + it("should handle empty response object", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", "Fixed"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: {}, // Empty response + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", "Fixed"]], // Template unchanged + }, + }, + }, + }) + ); + }); + + it("should handle response with empty field value object", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: {} as any, // Empty object, no value property + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], // Template unchanged + }, + }, + }, + }) + ); + }); + + it("should handle multiple occurrences of the same field template", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [["{field:city}", "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["mumbai"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["mumbai"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [["mumbai", "mumbai"]], + }, + }, + }, + }) + ); + }); + + it("should handle invalid query structure gracefully", () => { + // Test with an invalid query structure + const invalidQueryValue = null as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue: invalidQueryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toBeNull(); + }); + + it("should handle valid JSON that causes processing errors gracefully", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "nonexistent", + value: ["{field:nonexistent}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + customField: "{field:city}", + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + // Non-existent field should remain as-is, existing field should be resolved + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "nonexistent", + value: ["{field:nonexistent}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + customField: "mumbai", + }, + }, + }, + }) + ); + }); +}); diff --git a/packages/lib/raqb/raqbUtils.ts b/packages/lib/raqb/raqbUtils.ts index 5b72019717..d6fdbfd620 100644 --- a/packages/lib/raqb/raqbUtils.ts +++ b/packages/lib/raqb/raqbUtils.ts @@ -4,9 +4,7 @@ import { Utils as QbUtils } from "react-awesome-query-builder"; import { getQueryBuilderConfigForAttributes } from "@calcom/app-store/routing-forms/lib/getQueryBuilderConfig"; import type { LocalRoute } from "@calcom/app-store/routing-forms/types/types"; -import logger from "@calcom/lib/logger"; -import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types"; -import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import type { dynamicFieldValueOperands } from "@calcom/lib/raqb/types"; import { safeStringify } from "@calcom/lib/safeStringify"; import type { AttributeOptionValueWithType, @@ -15,61 +13,13 @@ import type { } from "@calcom/lib/service/attribute/server/getAttributes"; import { AttributeType } from "@calcom/prisma/enums"; -const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] }); - -function getFieldResponseValueAsLabel({ - field, - fieldResponseValue, -}: { - fieldResponseValue: dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"]; - field: { - type: string; - options?: { - id: string | null; - label: string; - }[]; - }; -}) { - if (!field.options) { - return fieldResponseValue; - } - - const valueArray = fieldResponseValue instanceof Array ? fieldResponseValue : [fieldResponseValue]; - - const chosenOptions = valueArray.map((idOrLabel) => { - const foundOptionById = field.options?.find((option) => { - return option.id === idOrLabel; - }); - if (foundOptionById) { - return { - label: foundOptionById.label, - id: foundOptionById.id, - }; - } else { - return { - label: idOrLabel.toString(), - id: null, - }; - } - }); - - return chosenOptions.map((option) => option.label); -} +import { resolveQueryValue } from "./resolveQueryValue"; +import { caseInsensitive } from "./utils"; function ensureArray(value: string | string[]) { return typeof value === "string" ? [value] : value; } -function caseInsensitive( - stringOrStringArray: T -): T extends string[] ? string[] : string { - return ( - stringOrStringArray instanceof Array - ? stringOrStringArray.map((string) => string.toLowerCase()) - : stringOrStringArray.toLowerCase() - ) as T extends string[] ? string[] : string; -} - export const raqbQueryValueUtils = { isQueryValueARuleGroup: function isQueryValueARuleGroup(queryValue: JsonTree): queryValue is JsonGroup { return queryValue.type === "group"; @@ -144,61 +94,6 @@ export const buildStateFromQueryValue = ({ }; }; -/** - * Replace attribute option Ids with the attribute option label(compatible to be matched with form field value) - */ -const replaceAttributeOptionIdsWithOptionLabel = ({ - queryValueString, - attributes, -}: { - queryValueString: string; - attributes: Attribute[]; -}) => { - const allAttributesOptions = attributes.map((attribute) => attribute.options).flat(); - // Because all attribute option Ids are unique, we can reliably identify them along any number of attribute options of different attributes - allAttributesOptions.forEach((attributeOption) => { - const attributeOptionId = attributeOption.id; - queryValueString = queryValueString.replace( - new RegExp(`${attributeOptionId}`, "g"), - caseInsensitive(attributeOption.value) - ); - }); - return queryValueString; -}; - -/** - * Replace {field:} with the field label(compatible to be matched with attribute value) - */ -const replaceFieldTemplateVariableWithOptionLabel = ({ - queryValueString, - dynamicFieldValueOperands, -}: { - queryValueString: string; - dynamicFieldValueOperands?: dynamicFieldValueOperands; -}) => { - if (!dynamicFieldValueOperands) { - return queryValueString; - } - const { fields, response } = dynamicFieldValueOperands; - return queryValueString.replace(/{field:([\w-]+)}/g, (match, fieldId: string) => { - const field = fields?.find((f) => f.id === fieldId); - if (!field) { - moduleLogger.debug("field not found", safeStringify({ fieldId })); - return match; - } - const fieldResponseValue = response[fieldId]?.value; - if (!fieldResponseValue) { - return match; - } - const responseValueAsLabel = getFieldResponseValueAsLabel({ field, fieldResponseValue }); - moduleLogger.debug("matchingOptionLabel", safeStringify({ responseValueAsLabel, response, fieldId })); - if (responseValueAsLabel instanceof Array && responseValueAsLabel.length > 1) { - throw new Error("Array value not supported with 'Value of field'"); - } - return responseValueAsLabel ? caseInsensitive(responseValueAsLabel.toString()) : match; - }); -}; - export function getValueOfAttributeOption( attributeOptions: | Pick @@ -262,17 +157,13 @@ function getAttributesQueryValue({ return null; } - const attributesQueryValueCompatibleForMatchingWithFormField: AttributesQueryValue = JSON.parse( - replaceFieldTemplateVariableWithOptionLabel({ - queryValueString: replaceAttributeOptionIdsWithOptionLabel({ - queryValueString: JSON.stringify(attributesQueryValue), - attributes, - }), - dynamicFieldValueOperands, - }) - ); + const resolvedQueryValue = resolveQueryValue({ + queryValue: attributesQueryValue, + attributes, + dynamicFieldValueOperands, + }); - return attributesQueryValueCompatibleForMatchingWithFormField; + return resolvedQueryValue; } /** @@ -329,4 +220,5 @@ export const acrossQueryValueCompatiblity = { getAttributesQueryBuilderConfigHavingListofLabels, getAttributesQueryValue, getAttributesData, + resolveQueryValue, }; diff --git a/packages/lib/raqb/resolveQueryValue.test.ts b/packages/lib/raqb/resolveQueryValue.test.ts new file mode 100644 index 0000000000..d34e2a7030 --- /dev/null +++ b/packages/lib/raqb/resolveQueryValue.test.ts @@ -0,0 +1,1745 @@ +import { expect, describe, it } from "vitest"; + +import type { AttributesQueryValue, RaqbChild } from "@calcom/lib/raqb/types"; +import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; +import { AttributeType } from "@calcom/prisma/enums"; +import { RoutingFormFieldType } from "@calcom/routing-forms/lib/FieldTypes"; + +import { resolveQueryValue } from "./resolveQueryValue"; + +// Test Data Builders for AttributesQueryValue +const createQueryValueRule = (overrides?: Partial): RaqbChild => ({ + type: "rule", + properties: { + field: "default-field", + operator: "select_equals", + value: ["default-value"], + valueSrc: ["value"], + valueError: [null], + valueType: ["select"], + }, + ...overrides, +}); + +const createAttributesQueryValue = (overrides?: { + id?: string; + type?: "group" | "switch_group"; + children1?: Record; + properties?: any; +}): AttributesQueryValue => ({ + id: overrides?.id || "test-id", + type: overrides?.type || "group", + children1: overrides?.children1 || {}, + properties: overrides?.properties, +}); + +const createQueryValueWithRule = ({ + ruleId, + field, + operator, + value, + valueSrc = ["value"], + valueError = [null], + valueType = ["select"], +}: { + ruleId: string; + field: string; + operator: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; +}): AttributesQueryValue => { + return createAttributesQueryValue({ + children1: { + [ruleId]: createQueryValueRule({ + type: "rule", + properties: { + field, + operator, + value, + valueSrc, + valueError, + valueType, + }, + }), + }, + }); +}; + +const createComplexQueryValue = ({ + id, + rules, +}: { + id?: string; + rules: Array<{ + ruleId: string; + field: string; + operator: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; + }>; +}): AttributesQueryValue => { + const children1: Record = {}; + + rules.forEach((rule) => { + children1[rule.ruleId] = createQueryValueRule({ + type: "rule", + properties: { + field: rule.field, + operator: rule.operator, + value: rule.value, + valueSrc: rule.valueSrc || ["value"], + valueError: rule.valueError || [null], + valueType: rule.valueType || ["select"], + }, + }); + }); + + return createAttributesQueryValue({ + id, + children1, + }); +}; + +const createNestedGroupQueryValue = ({ + groups, +}: { + groups: Array<{ + groupId: string; + rules: Array<{ + ruleId: string; + field: string; + operator?: string; + value: any[]; + valueSrc?: string[]; + valueError?: (string | null)[]; + valueType?: string[]; + }>; + }>; +}): AttributesQueryValue => { + const children1: Record = {}; + + groups.forEach((group) => { + const groupChildren: Record = {}; + + group.rules.forEach((rule) => { + groupChildren[rule.ruleId] = createQueryValueRule({ + type: "rule", + properties: { + field: rule.field, + operator: rule.operator || "select_equals", + value: rule.value, + valueSrc: rule.valueSrc || ["value"], + valueError: rule.valueError || [null], + valueType: rule.valueType || ["select"], + }, + }); + }); + + children1[group.groupId] = { + type: "group", + children1: groupChildren, + } as RaqbChild; + }); + + return createAttributesQueryValue({ + type: "group", + children1, + }); +}; + +describe("resolveQueryValue", () => { + const mockFields = [ + { + id: "location", + type: RoutingFormFieldType.MULTI_SELECT, + label: "Location", + options: [ + { id: "delhi", label: "Delhi" }, + { id: "mumbai", label: "Mumbai" }, + { id: "paris", label: "Paris" }, + { id: "berlin", label: "Berlin" }, + { id: "new-york", label: "New York" }, + { id: "amsterdam", label: "Amsterdam" }, + { id: "haryana", label: "Haryana" }, + { id: "tokyo", label: "Tokyo" }, + { id: "chennai", label: "Chennai" }, + { id: "sao-paulo", label: "São Paulo" }, + { id: "zurich", label: "Zürich" }, + ], + }, + { + id: "city", + type: RoutingFormFieldType.SINGLE_SELECT, + label: "City", + options: [ + { id: "mumbai", label: "Mumbai" }, + { id: "delhi", label: "Delhi" }, + { id: "chennai", label: "Chennai" }, + { id: "tokyo", label: "Tokyo" }, + ], + }, + ]; + + // Mock attributes for testing + const mockAttributes: Attribute[] = [ + { + id: "attr-1", + name: "Location", + slug: "location", + type: AttributeType.MULTI_SELECT, + options: [ + { id: "opt-1", value: "New York", slug: "new-york" }, + { id: "opt-2", value: "London", slug: "london" }, + ], + }, + { + id: "attr-2", + name: "City", + slug: "city", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt-3", value: "Mumbai", slug: "mumbai" }, + { id: "opt-4", value: "Delhi", slug: "delhi" }, + ], + }, + ]; + + describe("attribute option ID to label conversion for field matching", () => { + /** + * These tests verify that attribute option IDs are replaced with their lowercase labels + * to enable comparison with form field values that are connected through "Value of Field". + * Labels are converted to lowercase to ensure case-insensitive matching between + * attribute options and field values. + */ + it("converts single option ID to lowercase label", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr-1", + operator: "select_equals", + value: ["new york"], // opt-1 -> "New York" -> "new york" for field matching + }), + }, + }, + }) + ); + }); + + it("converts multiple option IDs to labels in multiselect", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "opt-2", "opt-3", "opt-4"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr-1", + operator: "multiselect_some_in", + value: [["new york", "london", "mumbai", "delhi"]], // All IDs converted to lowercase labels + }), + }, + }, + }) + ); + }); + + it("converts option IDs across multiple rules", () => { + const queryValue = createComplexQueryValue({ + rules: [ + { + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }, + { + ruleId: "rule2", + field: "attr-2", + operator: "select_equals", + value: ["opt-3"], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["new york"], // opt-1 converted to label + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + value: ["mumbai"], // opt-3 converted to label + }), + }, + }, + }) + ); + }); + + it("converts option IDs before field template resolution", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Paris"], label: "Paris" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "paris"]], // Option ID converted first, then field template resolved + }), + }, + }, + }) + ); + }); + + it("preserves special chars in labels while lowercasing", () => { + const specialAttributes: Attribute[] = [ + { + id: "attr-special", + name: "Special", + slug: "special", + type: AttributeType.SINGLE_SELECT, + options: [ + { id: "opt-special-1", value: "São Paulo", slug: "sao-paulo" }, + { id: "opt-special-2", value: "Zürich", slug: "zurich" }, + ], + }, + ]; + + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-special", + operator: "select_equals", + value: ["opt-special-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: specialAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["são paulo"], // Special chars preserved, only case changed for matching + }), + }, + }, + }) + ); + }); + + it("keeps non-matching IDs unchanged", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["non-existent-id", "opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["non-existent-id", "new york"], // Unknown IDs kept, valid ones converted + }), + }, + }, + }) + ); + }); + + it("handles missing attributes gracefully", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "select_equals", + value: ["opt-1"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: [], // No attributes + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["opt-1"], // No attributes to resolve against + }), + }, + }, + }) + ); + }); + + it("handles attributes without options", () => { + const attributesWithoutOptions: Attribute[] = [ + { + id: "attr-no-options", + name: "No Options", + slug: "no-options", + type: AttributeType.TEXT, + options: [], // Empty options array + }, + ]; + + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-no-options", + operator: "text_equals", + value: ["some-value"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: attributesWithoutOptions, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: ["some-value"], // TEXT attributes don't have options to convert + }), + }, + }, + }) + ); + }); + + it("converts IDs in nested group structures", () => { + const queryValue = createNestedGroupQueryValue({ + groups: [ + { + groupId: "group1", + rules: [ + { + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "opt-2"]], + valueType: ["multiselect"], + }, + { + ruleId: "rule2", + field: "attr-2", + operator: "select_equals", + value: ["opt-3"], + }, + ], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + group1: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "london"]], // opt-1, opt-2 converted to labels + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + value: ["mumbai"], // opt-3 converted to label + }), + }, + }, + }, + }, + }) + ); + }); + + it("converts IDs alongside field templates in arrays", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "attr-1", + operator: "multiselect_some_in", + value: [["opt-1", "{field:city}", "opt-2", "{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Tokyo", label: "Tokyo" }, + location: { value: ["Berlin", "Paris"], label: "Berlin, Paris" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + value: [["new york", "tokyo", "london", "berlin", "paris"]], // opt-1->"new york", opt-2->"london", field templates resolved + }), + }, + }, + }) + ); + }); + }); + + it("should handle simple field template replacement with single value", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "a8a89bba", + field: "city", + operator: "select_equals", + value: ["{field:city}"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { label: "Mumbai", value: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + a8a89bba: { + type: "rule", + properties: expect.objectContaining({ + field: "city", + operator: "select_equals", + value: ["mumbai"], + }), + }, + }, + }) + ); + }); + + it("should handle field template in double-nested array for multiselect", () => { + const queryValue = createQueryValueWithRule({ + ruleId: "rule1", + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}"]], + valueType: ["multiselect"], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { label: "Delhi", value: "Delhi" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "location", + operator: "multiselect_some_in", + value: [["delhi"]], + }), + }, + }, + }) + ); + }); + + it("should handle mixed array with field template and regular values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}", "Chennai"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: "Delhi", label: "Delhi" }, // Single value, not array + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["delhi", "Chennai"]], // Single value replaced + }, + }, + }, + }) + ); + }); + + it("should handle multiple field templates in the same array", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "multifield", + operator: "multiselect_some_in", + value: [["{field:location}", "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "multifield", + operator: "multiselect_some_in", + value: [["delhi", "mumbai"]], + }, + }, + }, + }) + ); + }); + + it("should handle field template with no response value", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "select_equals", + value: ["{field:location}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: {}, // No response for location field + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "select_equals", + value: ["{field:location}"], // Should remain unchanged + }, + }, + }, + }) + ); + }); + + it("should handle field template with unknown field", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "unknown", + operator: "select_equals", + value: ["{field:unknown}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + unknown: { value: "test", label: "test" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "unknown", + operator: "select_equals", + value: ["{field:unknown}"], // Should remain unchanged + }, + }, + }, + }) + ); + }); + + it("should handle complex nested structure", () => { + const queryValue = createAttributesQueryValue({ + id: "test", + type: "group", + children1: { + rule1: createQueryValueRule({ + type: "rule", + properties: { + field: "attr1", + operator: "multiselect_some_in", + value: [["{field:location}", "Fixed-Value"]], + valueType: ["multiselect"], + }, + }), + }, + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Haryana"], label: "Delhi, Haryana" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + id: "test", + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "attr1", + value: [["delhi", "haryana", "Fixed-Value"]], + operator: "multiselect_some_in", + }), + }, + }, + }) + ); + }); + + it("should handle empty array values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: [], label: "" }, // Empty array + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [[]], // Should be empty array + }, + }, + }, + }) + ); + }); + + it("should handle no dynamicFieldValueOperands", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: ["{field:location}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: undefined, + attributes: mockAttributes, + }); + + expect(result).toEqual(queryValue); // Should return unchanged + }); + + it("should preserve case for non-template values in mixed arrays", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["{field:location}", "Chennai", "MUMBAI"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + operator: "multiselect_some_in", + value: [["delhi", "Chennai", "MUMBAI"]], // Only field template value is lowercased + }, + }, + }, + }) + ); + }); + + it("should handle deeply nested field templates", () => { + const queryValue = createNestedGroupQueryValue({ + groups: [ + { + groupId: "group1", + rules: [ + { + ruleId: "rule1", + field: "location", + value: [["{field:location}", "Fixed1"]], + valueType: ["multiselect"], + }, + { + ruleId: "rule2", + field: "city", + value: [["{field:city}", "Fixed2"]], + valueType: ["multiselect"], + }, + ], + }, + ], + }); + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + city: { value: "Chennai", label: "Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + type: "group", + children1: { + group1: { + type: "group", + children1: { + rule1: { + type: "rule", + properties: expect.objectContaining({ + field: "location", + value: [["delhi", "mumbai", "Fixed1"]], + }), + }, + rule2: { + type: "rule", + properties: expect.objectContaining({ + field: "city", + value: [["chennai", "Fixed2"]], + }), + }, + }, + }, + }, + }) + ); + }); + + it("should handle field template with array containing special characters", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["New York", "São Paulo", "Zürich"], label: "New York, São Paulo, Zürich" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["new york", "são paulo", "zürich"]], + }, + }, + }, + }) + ); + }); + + it("should handle null and undefined values gracefully", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "mixed", + value: [["{field:location}", null, "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi"], label: "Delhi" }, + city: { value: null as any, label: "" }, // null value + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "mixed", + value: [["delhi", null, "{field:city}"]], // city remains as template due to null value + }, + }, + }, + }) + ); + }); + + it("should handle numeric values from fields", () => { + const numericFields = [ + { id: "age", type: RoutingFormFieldType.NUMBER, label: "Age", options: [] }, + { id: "count", type: RoutingFormFieldType.NUMBER, label: "Count", options: [] }, + ]; + + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "numbers", + value: [["{field:age}", "{field:count}", 42]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: numericFields, + response: { + age: { value: 25, label: "25" }, + count: { value: 100, label: "100" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "numbers", + value: [["25", "100", 42]], + }, + }, + }, + }) + ); + }); + + it("should handle very large arrays efficiently", () => { + const largeArray = new Array(100).fill("item"); + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", ...largeArray]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result.children1?.rule1.properties?.value[0]).toHaveLength(102); // 2 from field + 100 items + expect(result.children1?.rule1.properties?.value[0].slice(0, 2)).toEqual(["delhi", "mumbai"]); + }); + + it("should handle edge case with empty string field values", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["", " ", "Delhi"], label: ", , Delhi" }, // Empty and whitespace strings + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["", " ", "delhi"]], + }, + }, + }, + }) + ); + }); + + it("should handle field templates with hyphens and underscores in field names", () => { + const specialFields = [ + { id: "field-with-dashes", type: RoutingFormFieldType.TEXT, label: "Dashed Field", options: [] }, + { + id: "field_with_underscores", + type: RoutingFormFieldType.TEXT, + label: "Underscored Field", + options: [], + }, + ]; + + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "special", + value: ["{field:field-with-dashes}", "{field:field_with_underscores}"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: specialFields, + response: { + "field-with-dashes": { value: "Value2", label: "Value2" }, + field_with_underscores: { value: "Value3", label: "Value3" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "special", + value: ["value2", "value3"], + }, + }, + }, + }) + ); + }); + + it("should handle field templates in different JSON value types", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: "{field:city}", + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + rule4: { + type: "rule", + properties: { + field: "mixed", + customProp: "{field:city}", + nested: { + value: ["{field:location}"], + }, + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: { value: ["Delhi", "Mumbai"], label: "Delhi, Mumbai" }, + city: { value: "Chennai", label: "Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: "chennai", + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["chennai"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "location", + value: [["delhi", "mumbai"]], + }, + }, + rule4: { + type: "rule", + properties: { + field: "mixed", + customProp: "chennai", + nested: { + value: ["delhi", "mumbai"], + }, + }, + }, + }, + }) + ); + }); + + it("should handle empty response object", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", "Fixed"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: {}, // Empty response + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}", "Fixed"]], // Template unchanged + }, + }, + }, + }) + ); + }); + + it("should handle response with empty field value object", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + location: {} as any, // Empty object, no value property + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "location", + value: [["{field:location}"]], // Template unchanged + }, + }, + }, + }) + ); + }); + + it("should handle multiple occurrences of the same field template", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["{field:city}"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [["{field:city}", "{field:city}"]], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "city", + value: ["mumbai"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + value: ["mumbai"], + }, + }, + rule3: { + type: "rule", + properties: { + field: "city", + value: [["mumbai", "mumbai"]], + }, + }, + }, + }) + ); + }); + + it("should handle invalid query structure gracefully", () => { + // Test with an invalid query structure + const invalidQueryValue = null as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue: invalidQueryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + expect(result).toBeNull(); + }); + + it("should handle valid JSON that causes processing errors gracefully", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "nonexistent", + value: ["{field:nonexistent}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + customField: "{field:city}", + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + // Non-existent field should remain as-is, existing field should be resolved + expect(result).toEqual( + expect.objectContaining({ + children1: { + rule1: { + type: "rule", + properties: { + field: "nonexistent", + value: ["{field:nonexistent}"], + }, + }, + rule2: { + type: "rule", + properties: { + field: "city", + customField: "mumbai", + }, + }, + }, + }) + ); + }); + + it("should handle field templates in deeply nested object structures", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + field: "complex", + metadata: { + level1: { + value: "{field:city}", + level2: { + items: ["{field:location}"], + level3: { + nested: [["{field:city}"]], + static: "preserved", + }, + }, + }, + }, + value: ["test"], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + location: { value: ["Delhi", "Chennai"], label: "Delhi, Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + // Verify all nested properties are preserved with templates resolved + const props = result.children1?.rule1.properties as any; + expect(props.metadata.level1.level2.level3.static).toBe("preserved"); + expect(props.metadata.level1.value).toBe("mumbai"); + expect(props.metadata.level1.level2.items).toEqual(["delhi", "chennai"]); + expect(props.metadata.level1.level2.level3.nested).toEqual([["mumbai"]]); + expect(props.value).toEqual(["test"]); + }); + + it("should handle extremely deep object nesting without stack overflow", () => { + let deepObj: any = { value: "{field:city}" }; + for (let i = 0; i < 100; i++) { + deepObj = { nested: deepObj, level: i }; + } + + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: deepObj, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + }, + }, + attributes: mockAttributes, + }); + + // Navigate to deepest level to verify it was processed + let current = result.children1?.rule1.properties as any; + for (let i = 99; i >= 0; i--) { + expect(current.level).toBe(i); + current = current.nested; + } + expect(current.value).toBe("mumbai"); + }); + + it("should preserve all JavaScript primitive types in nested structures", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + stringField: "{field:city}", + numberField: 42, + bigintField: 9007199254740991, + booleanField: true, + nullField: null, + nested: { + array: ["{field:location}", 123, false, null], + decimal: 3.14159, + negative: -100, + zero: 0, + emptyString: "", + specialChars: "!@#$%^&*()", + }, + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + location: { value: ["Delhi"], label: "Delhi" }, + }, + }, + attributes: mockAttributes, + }); + + const props = result.children1?.rule1.properties as any; + expect(props.stringField).toBe("mumbai"); + expect(props.numberField).toBe(42); + expect(props.bigintField).toBe(9007199254740991); + expect(props.booleanField).toBe(true); + expect(props.nullField).toBeNull(); + expect(props.nested.array).toEqual(["delhi", 123, false, null]); + expect(props.nested.decimal).toBe(3.14159); + expect(props.nested.negative).toBe(-100); + expect(props.nested.zero).toBe(0); + expect(props.nested.emptyString).toBe(""); + expect(props.nested.specialChars).toBe("!@#$%^&*()"); + }); + + it("should process field templates within objects inside arrays", () => { + const queryValue = { + children1: { + rule1: { + type: "rule", + properties: { + value: [ + { id: 1, city: "{field:city}" }, + { id: 2, locations: ["{field:location}"] }, + { id: 3, nested: { value: [["{field:city}"]] } }, + ], + }, + }, + }, + } as unknown as AttributesQueryValue; + + const result = resolveQueryValue({ + queryValue, + dynamicFieldValueOperands: { + fields: mockFields, + response: { + city: { value: "Mumbai", label: "Mumbai" }, + location: { value: ["Delhi", "Chennai"], label: "Delhi, Chennai" }, + }, + }, + attributes: mockAttributes, + }); + + const props = result.children1?.rule1.properties as any; + expect(props.value[0]).toEqual({ id: 1, city: "mumbai" }); + expect(props.value[1]).toEqual({ + id: 2, + locations: ["delhi", "chennai"], + }); + expect(props.value[2]).toEqual({ + id: 3, + nested: { value: [["mumbai"]] }, + }); + }); +}); diff --git a/packages/lib/raqb/resolveQueryValue.ts b/packages/lib/raqb/resolveQueryValue.ts new file mode 100644 index 0000000000..a6c9240cb7 --- /dev/null +++ b/packages/lib/raqb/resolveQueryValue.ts @@ -0,0 +1,267 @@ +import logger from "@calcom/lib/logger"; +import type { dynamicFieldValueOperands, dynamicFieldValueOperandsResponse } from "@calcom/lib/raqb/types"; +import type { AttributesQueryValue } from "@calcom/lib/raqb/types"; +import type { Attribute } from "@calcom/lib/service/attribute/server/getAttributes"; + +import { caseInsensitive } from "./utils"; + +// Type for JSON values that can be in the query +type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + +type MinimalField = { + type: string; + options?: { + id: string | null; + label: string; + }[]; +}; + +type FieldResponseValue = dynamicFieldValueOperandsResponse[keyof dynamicFieldValueOperandsResponse]["value"]; + +const moduleLogger = logger.getSubLogger({ prefix: ["raqb/resolveQueryValue"] }); + +/** + * Replace "Attribute Options' Ids" with "Attribute Options' Labels". It makes the query value compatible to be matched with 'Value of Field' operand + */ +const replaceAttributeOptionIdsWithOptionLabel = ({ + queryValue, + attributes, +}: { + queryValue: AttributesQueryValue; + attributes: Attribute[]; +}) => { + const queryValueString = JSON.stringify(queryValue); + let queryValueWithLabels = queryValueString; + const allAttributesOptions = attributes.map((attribute) => attribute.options).flat(); + // Because all attribute option Ids are unique, we can reliably identify them along any number of attribute options of different attributes + allAttributesOptions.forEach((attributeOption) => { + const attributeOptionId = attributeOption.id; + queryValueWithLabels = queryValueWithLabels.replace( + new RegExp(`${attributeOptionId}`, "g"), + caseInsensitive(attributeOption.value) + ); + }); + return JSON.parse(queryValueWithLabels) as AttributesQueryValue; +}; + +function getFieldResponseValueAsLabel({ + field, + fieldResponseValue, +}: { + fieldResponseValue: FieldResponseValue; + field: MinimalField; +}) { + const nonNumberFieldResponseValue = + typeof fieldResponseValue === "number" ? fieldResponseValue.toString() : fieldResponseValue; + + if (!field.options) { + return nonNumberFieldResponseValue; + } + + if (nonNumberFieldResponseValue instanceof Array) { + return nonNumberFieldResponseValue.map(transformValue); + } + + return transformValue(nonNumberFieldResponseValue); + + function transformValue(idOrLabel: string) { + const foundOptionById = field.options?.find((option) => { + return option.id === idOrLabel; + }); + if (foundOptionById) { + return foundOptionById.label; + } else { + return idOrLabel; + } + } +} + +/** + * Resolves query values by: + * 1. Converting attribute option IDs to their lowercase labels + * 2. Replacing field template placeholders (e.g., "{field:location}") with actual field values + * + * Important: When a field template resolves to an array value, the array items are flattened + * into the parent array. For example: + * - Input: [["{{field:location}}", "Delhi"]] where location = ["New York", "Amsterdam"] + * - Output: [["new york", "amsterdam", "Delhi"]] (flattened into single array) + * @param queryValue - JSON string containing RAQB query with field template placeholders like {field:fieldId} + * @param dynamicFieldValueOperands - Optional object containing fields metadata and user's form field responses + * @param dynamicFieldValueOperands.fields - Array of field definitions with id, type, label, etc. + * @param dynamicFieldValueOperands.response - Object mapping field IDs to their response values + * + * @example + * ```ts + * resolveQueryValue({ + * queryValue: { + * "id": "9b8ba888-0123-4456-b89a-b1984020d699", + * "type": "group", + * "children1": { + * "a8a89bba-89ab-4cde-b012-31984020ebaa": { + * "type": "rule", + * "id": "a8a89bba-89ab-4cde-b012-31984020ebaa", + * "properties": { + * "field": "88be6104-d350-411d-aa0f-092b2deda257", + * "operator": "multiselect_some_in", + * "value": [ + * [ + * "{field:0bf77a89-2bd0-4df7-9648-758014ba3189}", // 'Value of field' is stored like this and we call it a 'field template' + * "899c846d-7c02-43dc-9057-c3f8c118d41f" // A regular attribute option id + * ] + * ], + * "valueSrc": [ + * "value" + * ], + * "valueError": [ + * null + * ], + * "valueType": [ + * "multiselect" + * ] + * } + * } + * } + * }, + * dynamicFieldValueOperands: { + * fields: [ + * { id: "location", type: "MULTI_SELECT", label: "Location" } + * ], + * response: { + * location: { value: ["Delhi", "Mumbai"] } + * } + * } + * }) + * // Returns: '{"value": [["delhi", "mumbai"]], "operator": "multiselect_some_in"}' + * ``` + */ +export const resolveQueryValue = ({ + queryValue, + dynamicFieldValueOperands, + attributes, +}: { + queryValue: AttributesQueryValue; + dynamicFieldValueOperands?: dynamicFieldValueOperands; + attributes: Attribute[]; +}) => { + const queryValueWithLabels = replaceAttributeOptionIdsWithOptionLabel({ + queryValue, + attributes, + }); + + if (!dynamicFieldValueOperands) { + return queryValueWithLabels; + } + const { fields, response } = dynamicFieldValueOperands; + + const isFieldTemplate = (str: string): boolean => { + const trimmedStr = str.trim(); + return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}"); + }; + + // Extract field ID from template string + const extractFieldId = (template: string): string | null => { + const trimmedTemplate = template.trim(); + if (!isFieldTemplate(trimmedTemplate)) return null; + // Remove {field: and } to get the ID + return trimmedTemplate.slice(7, -1); + }; + + // Resolve a field ID to its actual value + const resolveFieldId = (fieldId: string): string | string[] | null => { + const field = fields.find((f) => f.id === fieldId); + if (!field || !response[fieldId]?.value) { + // Maybe invalid fieldId + return null; + } + + const fieldResponseValue = response[fieldId].value; + return getFieldResponseValueAsLabel({ field, fieldResponseValue }); + }; + + // Process a string value that might be a field template and thus could be an array + const processStringValue = (value: string): string | string[] => { + const fieldId = extractFieldId(value); + if (!fieldId) { + // Regular non-field template string + return value; + } + + const resolvedValue = resolveFieldId(fieldId); + if (!resolvedValue) { + // We keep the field template look-alike as it is + return value; + } + + return caseInsensitive(resolvedValue); + }; + + // Process an array that might contain field templates. We keep it generic instead of typing it as QueryValue to handle any property where the queryValue can have a field template + // Note: When a field template resolves to an array, its items are flattened into the parent array + const processArray = (arr: JsonValue[]): JsonValue[] => { + const hasFieldTemplate = arr.some((item) => typeof item === "string" && isFieldTemplate(item)); + + // Short circuit if there are no field templates + if (!hasFieldTemplate) { + return arr.map((item) => processAnyValue(item)); + } + + const result: JsonValue[] = []; + for (const item of arr) { + if (typeof item === "string" && isFieldTemplate(item)) { + const processed = processStringValue(item); + if (Array.isArray(processed)) { + // Flattening behavior: Array values from field templates are spread into parent array + // Example: ["{field:location}", "Delhi"] where location = ["New York", "Amsterdam"] + // Result: ["New York", "Amsterdam", "Delhi"] (items flattened) + // This is intentional to merge field values at the same array level + result.push(...processed); + } else { + // [{field:location}, Delhi] -> becomes [New York, Delhi]. New York was the response for the field `location` + result.push(processed); + } + } else { + result.push(processAnyValue(item)); + } + } + return result; + }; + + // Process any value recursively + const processAnyValue = (value: JsonValue): JsonValue => { + if (value instanceof Array) { + return processArray(value); + } + + if (typeof value === "object" && value !== null) { + const result: { [key: string]: JsonValue } = {}; + for (const key in value) { + result[key] = processAnyValue(value[key]); + } + return result; + } + + if (typeof value === "string") { + const processedValue = processStringValue(value); + return processedValue; + } + + return value; + }; + + try { + // Even though it recursively processes the queryValue, the object(see example in `resolveQueryValue` fn signature) isn't that complex and thus the performance is not an issue + const processed = processAnyValue(queryValueWithLabels) as AttributesQueryValue; + return processed; + } catch (e) { + moduleLogger.error("Failed to resolve field templates", { + error: e, + queryValue: JSON.stringify(queryValueWithLabels), + fieldsCount: fields?.length || 0, + responseKeys: Object.keys(response || {}), + dynamicFieldValueOperandsProvided: !!dynamicFieldValueOperands, + }); + throw new Error( + `Failed to resolve field templates in query value: ${e instanceof Error ? e.message : String(e)}` + ); + } +}; diff --git a/packages/lib/raqb/utils.ts b/packages/lib/raqb/utils.ts new file mode 100644 index 0000000000..6a6438871f --- /dev/null +++ b/packages/lib/raqb/utils.ts @@ -0,0 +1,9 @@ +export function caseInsensitive( + stringOrStringArray: T +): T extends string[] ? string[] : string { + return ( + stringOrStringArray instanceof Array + ? stringOrStringArray.map((string) => string.toLowerCase()) + : stringOrStringArray.toLowerCase() + ) as T extends string[] ? string[] : string; +} \ No newline at end of file diff --git a/packages/lib/raqb/zod.ts b/packages/lib/raqb/zod.ts index 28fd0eef7d..ade954aba6 100644 --- a/packages/lib/raqb/zod.ts +++ b/packages/lib/raqb/zod.ts @@ -83,6 +83,40 @@ const raqbChildren1Schema = z } }); }); + +/** + * @example + * ```json + * { + "id": "9b8ba888-0123-4456-b89a-b1984020d699", + "type": "group", + "children1": { + "a8a89bba-89ab-4cde-b012-31984020ebaa": { + "type": "rule", + "properties": { + "field": "88be6104-d350-411d-aa0f-092b2deda257", + "operator": "multiselect_some_in", + "value": [ + [ + "{field:0bf77a89-2bd0-4df7-9648-758014ba3189}", + "899c846d-7c02-43dc-9057-c3f8c118d41f" + ] + ], + "valueSrc": [ + "value" + ], + "valueError": [ + null + ], + "valueType": [ + "multiselect" + ] + } + } + } + } + * ``` + */ export const raqbQueryValueSchema = z.union([ z.object({ id: z.string().optional(),