* mv * wip * wip * wip * wip * plural * fix: resolve TypeScript type errors for attribute refactoring - Add TransformedAttributeOption type to handle transformed contains field - Update imports to use routing-forms Attribute type where needed - Fix getValueOfAttributeOption to use TransformedAttributeOption type - Update AttributeOptionValueWithType to use TransformedAttributeOption - Fix pre-existing lint warnings (any -> unknown, proper type casting) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix * cleanup * cleanup * fix * fix * fix * fix * fix * fix: add explicit type annotation to reduce callback in getAttributes.ts Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: use RouterOutputs from @calcom/trpc/react for consistent type inference - Update AppPage.tsx to use RouterOutputs from @calcom/trpc/react instead of inferRouterOutputs<AppRouter> - Update MultiDisconnectIntegration.tsx to use the same RouterOutputs type source - Add eslint-disable comments for pre-existing warnings (useEffect deps, img elements) Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: explicitly type attribute property in AttributeOptionAssignment The attribute property from Pick<AttributeOption, 'attribute'> was typed as 'unknown' because Prisma relations don't have explicit types when picked. This explicitly defines the attribute shape with id, type, and isLocked properties that are used in assignValueToUser.ts. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: remove unused assignedUsers from AttributeOptionAssignment Pick The assignedUsers property was not being used in the code but was required by the Pick type, causing a type mismatch when the actual data from the database query didn't include it. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix: update import path for AttributeOptionAssignment and BulkAttributeAssigner types The types.d.ts file was moved from packages/lib/service/attribute/ to packages/app-store/routing-forms/types/. Updated the import path in utils.ts to point to the new location. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
267 lines
9.2 KiB
TypeScript
267 lines
9.2 KiB
TypeScript
import type { Attribute } 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 { caseInsensitive } from "@calcom/lib/raqb/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)}`
|
|
);
|
|
}
|
|
};
|