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
This commit is contained in:
Hariom Balhara
2025-07-30 15:07:16 +00:00
committed by GitHub
parent dc967cffbe
commit 016a966057
7 changed files with 4271 additions and 415 deletions
@@ -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([]);
});
});
File diff suppressed because it is too large Load Diff
+10 -118
View File
@@ -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<T extends string | string[]>(
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:<fieldId>} 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<AttributeOptionValue, "isGroup" | "contains" | "value">
@@ -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,
};
File diff suppressed because it is too large Load Diff
+267
View File
@@ -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)}`
);
}
};
+9
View File
@@ -0,0 +1,9 @@
export function caseInsensitive<T extends string | string[]>(
stringOrStringArray: T
): T extends string[] ? string[] : string {
return (
stringOrStringArray instanceof Array
? stringOrStringArray.map((string) => string.toLowerCase())
: stringOrStringArray.toLowerCase()
) as T extends string[] ? string[] : string;
}
+34
View File
@@ -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(),