Files
calendar/packages/app-store/routing-forms/lib/raqbUtils.ts
T
ab65ce73c2 feat: Support Attribute Logic fallback (#17290)
* Add fallback

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

* With fallback in picture, we dont throw error in preview now instead we capture errors and show them gracefully

* Get attribute logic preview working without saving Fixes CAL-4582

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 12:53:34 -04:00

335 lines
12 KiB
TypeScript

import type { App_RoutingForms_Form } from "@prisma/client";
import type { JsonGroup, JsonItem, JsonRule, JsonTree } from "react-awesome-query-builder";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { AttributeType } from "@calcom/prisma/enums";
import type { Attribute, AttributesQueryValue } from "../types/types";
import type { LocalRoute } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import type { SerializableField } from "../types/types";
import type { AttributesQueryBuilderConfigWithRaqbFields } from "./getQueryBuilderConfig";
import { getQueryBuilderConfigForAttributes } from "./getQueryBuilderConfig";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] });
type GetFieldResponse = ({
field,
fieldResponseValue,
}: {
fieldResponseValue: FormResponse[keyof FormResponse]["value"];
field: Pick<SerializableField, "type" | "options">;
}) => { value: string | number | string[] };
function ensureArray(value: string | string[]) {
return typeof value === "string" ? [value] : value;
}
// We connect Form Field value and Attribute value using the labels lowercased
function compatibleForAttributeAndFormFieldMatch<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";
},
isARule: function isARule(rule: JsonItem): rule is JsonRule {
return rule.type === "rule";
},
getValueTypeFromAttributesQueryValueForRaqbField:
function getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId,
}: {
attributesQueryValue: JsonTree;
raqbFieldId: string;
}) {
if (!raqbQueryValueUtils.isQueryValueARuleGroup(attributesQueryValue)) {
return null;
}
if (!attributesQueryValue.children1) {
return null;
}
let raqbFieldValueType = null;
Object.values(attributesQueryValue.children1).reduce((acc, rule) => {
if (!raqbQueryValueUtils.isARule(rule)) {
return acc;
}
const ruleProperties = rule?.properties;
if (!ruleProperties) {
return acc;
}
if (ruleProperties.field === raqbFieldId) {
raqbFieldValueType = ruleProperties.valueType?.[0];
}
return acc;
}, null);
return raqbFieldValueType;
},
isQueryValueEmpty: function isQueryValueEmpty(queryValue: JsonTree | null): queryValue is null {
if (!queryValue) {
return true;
}
return !queryValue.children1;
},
};
/**
* 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"),
compatibleForAttributeAndFormFieldMatch(attributeOption.value)
);
});
return queryValueString;
};
/**
* Replace {field:<fieldId>} with the field label(compatible to be matched with attribute value)
*/
const replaceFieldTemplateVariableWithOptionLabel = ({
queryValueString,
fields,
response,
getFieldResponse,
}: {
queryValueString: string;
fields: SerializableField[] | undefined;
response: FormResponse;
getFieldResponse: GetFieldResponse;
}) => {
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 { value: fieldValue } = getFieldResponse({ field, fieldResponseValue });
moduleLogger.debug("matchingOptionLabel", safeStringify({ fieldValue, response, fieldId }));
if (fieldValue instanceof Array && fieldValue.length > 1) {
throw new Error("Array value not supported with 'Value of field'");
}
return fieldValue ? compatibleForAttributeAndFormFieldMatch(fieldValue.toString()) : match;
});
};
/**
* Utilities to handle compatiblity when attribute's type changes
*/
const attributeChangeCompatibility = {
/**
* FIXME: It isn't able to handle a case where for SINGLE_SELECT attribute, the queryValue->valueType is ["multiselect"]. It happens for select_any_in operator
* So, don't use it till that is fixed.
*/
getRaqbFieldTypeCompatibleWithQueryValue: function getRaqbFieldTypeCompatibleWithQueryValue({
attributesQueryValue,
raqbField,
raqbFieldId,
}: {
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
raqbField: AttributesQueryBuilderConfigWithRaqbFields["fields"][string];
raqbFieldId: string;
}) {
const fieldTypeFromAttributesQueryValue =
raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId,
});
if (raqbField.type === "select" && fieldTypeFromAttributesQueryValue === "multiselect") {
// One could argue why not use fieldTypeFromAttributesQueryValue directly. This could potentially cause issues, so we do this only when we think it makes sense.
moduleLogger.warn(
`Switching field type from ${raqbField.type} to multiselect as the queryValue expects it to be so`
);
return "multiselect";
}
return raqbField.type;
},
/**
* Ensure the attribute value if of type same as the valueType in queryValue
* FIXME: It isn't able to handle a case where for SINGLE_SELECT attribute, the queryValue->valueType is ["multiselect"]. It happens for select_any_in operator
* So, don't use it till that is fixed.
*/
ensureAttributeValueToBeOfRaqbFieldValueType: function ensureAttributeValueToBeOfRaqbFieldValueType({
attributeValue,
attributesQueryValue,
attributeId,
}: {
attributeValue: string | string[];
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
attributeId: string;
}) {
const fieldTypeFromAttributesQueryValue =
raqbQueryValueUtils.getValueTypeFromAttributesQueryValueForRaqbField({
attributesQueryValue,
raqbFieldId: attributeId,
});
if (fieldTypeFromAttributesQueryValue === "multiselect") {
return ensureArray(attributeValue);
}
return attributeValue;
},
};
function getAttributesData({
attributesData,
attributesQueryValue,
}: {
attributesData: Record<
string,
{
value: string | string[];
type: Attribute["type"];
}
>;
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
return Object.entries(attributesData).reduce((acc, [attributeId, { value, type: attributeType }]) => {
const compatibleValueForAttributeAndFormFieldMatching = compatibleForAttributeAndFormFieldMatch(value);
// We do this to ensure that correct jsonLogic is generated for an existing route even if the attribute's type changes
// acc[attributeId] = attributeChangeCompatibility.ensureAttributeValueToBeOfRaqbFieldValueType({
// attributeValue: compatibleValueForAttributeAndFormFieldMatching,
// attributesQueryValue,
// attributeId,
// });
// Right now we can't trust ensureAttributeValueToBeOfRaqbFieldValueType to give us the correct value
acc[attributeId] =
// multiselect attribute's value must be an array as all the operators multiselect_some_in, multiselect_all_in and their respective not operators expect an array
// If we add an operator that doesn't expect an array, we need to somehow make it operator based.
attributeType === AttributeType.MULTI_SELECT
? ensureArray(compatibleValueForAttributeAndFormFieldMatching)
: compatibleValueForAttributeAndFormFieldMatching;
return acc;
}, {} as Record<string, string | string[]>);
}
function getAttributesQueryValue({
attributesQueryValue,
attributes,
response,
fields,
getFieldResponse,
}: {
attributesQueryValue: LocalRoute["attributesQueryValue"];
attributes: Attribute[];
response: FormResponse;
fields: SerializableField[] | undefined;
getFieldResponse: GetFieldResponse;
}) {
if (!attributesQueryValue) {
return null;
}
const attributesMap = attributes.reduce((acc, attribute) => {
acc[attribute.id] = attribute;
return acc;
}, {} as Record<string, Attribute>);
const attributesQueryValueCompatibleForMatchingWithFormField: AttributesQueryValue = JSON.parse(
replaceFieldTemplateVariableWithOptionLabel({
queryValueString: replaceAttributeOptionIdsWithOptionLabel({
queryValueString: JSON.stringify(attributesQueryValue),
attributes,
}),
fields,
response,
getFieldResponse,
})
);
return attributesQueryValueCompatibleForMatchingWithFormField;
}
function getAttributesQueryBuilderConfig({
form,
attributes,
attributesQueryValue,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
attributes: Attribute[];
attributesQueryValue: NonNullable<LocalRoute["attributesQueryValue"]>;
}) {
const attributesQueryBuilderConfig = getQueryBuilderConfigForAttributes({
attributes,
form,
});
const attributesQueryBuilderConfigFieldsWithCompatibleListValues = Object.fromEntries(
Object.entries(attributesQueryBuilderConfig.fields).map(([raqbFieldId, raqbField]) => {
// const raqbFieldType = attributeChangeCompatibility.getRaqbFieldTypeCompatibleWithQueryValue({
// attributesQueryValue,
// raqbField,
// raqbFieldId,
// });
// Right now we can't trust getRaqbFieldTypeCompatibleWithQueryValue to give us the correct type
const raqbFieldType = raqbField.type;
return [
raqbFieldId,
{
...raqbField,
type: raqbFieldType,
fieldSettings: {
...raqbField.fieldSettings,
listValues: raqbField.fieldSettings.listValues?.map((option) => {
return {
...option,
// Use the title(which is the attributeOption.value) as the value of the raqb field so that it can be compatible for matching with the form field value
value: compatibleForAttributeAndFormFieldMatch(option.title),
};
}),
},
},
];
})
);
const attributesQueryBuilderConfigWithCompatibleListValues = {
...attributesQueryBuilderConfig,
fields: attributesQueryBuilderConfigFieldsWithCompatibleListValues,
};
return attributesQueryBuilderConfigWithCompatibleListValues;
}
/**
* Utilities to establish compatibility between formFieldQueryValue and attributeQueryValue
*/
export const acrossQueryValueCompatiblity = {
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
getAttributesData,
};