Files
calendar/packages/lib/raqb/resolveQueryValue.ts
T
Hariom BalharaandGitHub 016a966057 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
2025-07-30 15:07:16 +00:00

268 lines
9.2 KiB
TypeScript

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)}`
);
}
};