feat: setting to enable weights on attribute in route (#18592)

* add UI to Routes

* add attributeIdForWeights to route json

* fixes and clean up

* clean up onChangeAttributeIdForWeights

* fix type errors

* fix attribute weights in getLuckyUser

* adjust tests

* fixes for attribute rule changes

* fix type error

* Keep weighted attributes logic outside Route (#18974)

Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>

* use findUnique

* fix test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmgmail.com>
This commit is contained in:
authored and GitHub committed 2025-01-30 11:44:42 -05:00
1 parent a0f6e50d5b
commit 8d2dc0bbe5
8 files changed
+222 -46

No files matched your search

@@ -2931,6 +2931,9 @@
"choose_an_option": "Choose an option",
"enter_option_value": "Enter option value",
"remove_option": "Remove option",
"use_attribute_weights": "Use Attribute weights",
"if_enabled_ignore_event_type_weights": "If enabled, all weights set within the event type will be ignored",
"attribute_for_weights": "Attribute for weights",
"managed_users": "Managed Users",
"managed_users_description": "See all the managed users created by your OAuth client",
"select_oAuth_client": "Select Oauth Client",
@@ -5,6 +5,14 @@ import { FieldTypes, RoutingFormFieldType } from "./FieldTypes";
import { AttributesInitialConfig, FormFieldsInitialConfig } from "./InitialConfig";
import { getUIOptionsForSelect } from "./selectOptions";
export const isDynamicOperandField = (value: string) => {
return /{field:.*?}/.test(value);
};
const buildDynamicOperandFieldVariable = (fieldId: string) => {
return `{field:${fieldId}}`;
};
type RaqbConfigFields = Record<
string,
{
@@ -135,7 +143,7 @@ export function getQueryBuilderConfigForAttributes({
const valueOfFieldOptions = (() => {
const formFieldsOptions = dynamicOperandFields.map((field) => ({
title: `Value of field '${field.label}'`,
value: `{field:${field.id}}`,
value: buildDynamicOperandFieldVariable(field.id),
}));
return formFieldsOptions;
})();
@@ -12,7 +12,7 @@ import type { UseFormReturn } from "react-hook-form";
import Shell from "@calcom/features/shell/Shell";
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { buildEmptyQueryValue } from "@calcom/lib/raqb/raqbUtils";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/client";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -26,6 +26,7 @@ import {
TextField,
Badge,
Divider,
SettingsToggle,
} from "@calcom/ui";
import { routingFormAppComponents } from "../../appComponents";
@@ -46,6 +47,7 @@ import {
getQueryBuilderConfigForAttributes,
type FormFieldsQueryBuilderConfigWithRaqbFields,
type AttributesQueryBuilderConfigWithRaqbFields,
isDynamicOperandField,
} from "../../lib/getQueryBuilderConfig";
import isRouter from "../../lib/isRouter";
import type { SerializableForm } from "../../types/types";
@@ -146,6 +148,7 @@ const buildEventsData = ({
value: string;
eventTypeId: number;
eventTypeAppMetadata?: Record<string, any>;
isRRWeightsEnabled: boolean;
}[] = [];
const eventTypesMap = new Map<
number,
@@ -191,6 +194,7 @@ const buildEventsData = ({
value: uniqueSlug,
eventTypeId: eventType.id,
eventTypeAppMetadata,
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
});
});
});
@@ -198,6 +202,126 @@ const buildEventsData = ({
return { eventOptions, eventTypesMap };
};
const isValidAttributeIdForWeights = ({
attributeIdForWeights,
jsonTree,
}: {
attributeIdForWeights: string;
jsonTree: JsonTree;
}) => {
if (!attributeIdForWeights || !jsonTree.children1) {
return false;
}
return Object.values(jsonTree.children1).some((rule) => {
if (rule.type !== "rule" || rule?.properties?.field !== attributeIdForWeights) {
return false;
}
const values = rule.properties.value.flat();
return values.length === 1 && values.some((value: string) => isDynamicOperandField(value));
});
};
const WeightedAttributesSelector = ({
attributes,
route,
eventTypeRedirectUrlSelectedOption,
setRoute,
}: {
attributes?: Attribute[];
route: EditFormRoute;
eventTypeRedirectUrlSelectedOption: { isRRWeightsEnabled: boolean } | undefined;
setRoute: SetRoute;
}) => {
const [attributeIdForWeights, setAttributeIdForWeights] = useState(
"attributeIdForWeights" in route ? route.attributeIdForWeights : undefined
);
const { t } = useLocale();
if (isRouter(route)) {
return null;
}
let attributesWithWeightsEnabled: Attribute[] = [];
if (eventTypeRedirectUrlSelectedOption?.isRRWeightsEnabled) {
const validatedQueryValue = route.attributesQueryBuilderState?.tree
? QbUtils.getTree(route.attributesQueryBuilderState.tree)
: null;
if (
validatedQueryValue &&
raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue) &&
validatedQueryValue.children1
) {
const attributeIds = Object.values(validatedQueryValue.children1).map((rule) => {
if (rule.type === "rule" && rule?.properties?.field) {
if (
rule.properties.value.flat().length == 1 &&
rule.properties.value.flat().some((value) => isDynamicOperandField(value))
) {
return rule.properties.field;
}
}
});
attributesWithWeightsEnabled = attributes
? attributes.filter(
(attribute) =>
attribute.isWeightsEnabled && attributeIds.find((attributeId) => attributeId === attribute.id)
)
: [];
}
}
const onChangeAttributeIdForWeights = (
route: EditFormRoute & { attributeIdForWeights?: string },
attributeIdForWeights?: string
) => {
setRoute(route.id, {
attributeIdForWeights,
});
};
return attributesWithWeightsEnabled.length > 0 ? (
<div className="mt-8">
<SettingsToggle
title={t("use_attribute_weights")}
description={t("if_enabled_ignore_event_type_weights")}
checked={!!attributeIdForWeights}
onCheckedChange={(checked) => {
const attributeId = checked ? attributesWithWeightsEnabled[0].id : undefined;
setAttributeIdForWeights(attributeId);
onChangeAttributeIdForWeights(route, attributeId);
}}
/>
{!!attributeIdForWeights ? (
<SelectField
containerClassName="mb-6 mt-4 data-testid-select-router"
label={t("attribute_for_weights")}
options={attributesWithWeightsEnabled.map((attribute) => {
return { value: attribute.id, label: attribute.name };
})}
value={{
value: attributeIdForWeights,
label: attributesWithWeightsEnabled.find((attribute) => attribute.id === attributeIdForWeights)
?.name,
}}
onChange={(option) => {
if (option) {
setAttributeIdForWeights(option.value);
onChangeAttributeIdForWeights(route, option.value);
}
}}
/>
) : (
<></>
)}
</div>
) : null;
};
const Route = ({
form,
route,
@@ -213,6 +337,7 @@ const Route = ({
disabled = false,
fieldIdentifiers,
eventTypesByGroup,
attributes,
}: {
form: Form;
route: EditFormRoute;
@@ -228,12 +353,13 @@ const Route = ({
appUrl: string;
disabled?: boolean;
eventTypesByGroup: EventTypesByGroup;
attributes?: Attribute[];
}) => {
const { t } = useLocale();
const isTeamForm = form.teamId !== null;
const index = routes.indexOf(route);
const { eventOptions, eventTypesMap } = buildEventsData({ eventTypesByGroup, form, route });
const { eventOptions } = buildEventsData({ eventTypesByGroup, form, route });
// /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG}
const eventTypePrefix =
@@ -267,15 +393,30 @@ const Route = ({
});
};
const setAttributeIdForWeights = (attributeIdForWeights: string | undefined) => {
setRoute(route.id, {
attributeIdForWeights,
});
};
const onChangeTeamMembersQuery = (
route: EditFormRoute,
immutableTree: ImmutableTree,
config: AttributesQueryBuilderConfigWithRaqbFields
) => {
const jsonTree = QbUtils.getTree(immutableTree);
const attributeIdForWeights = isRouter(route) ? null : route.attributeIdForWeights;
const _isValidAttributeIdForWeights =
attributeIdForWeights && isValidAttributeIdForWeights({ attributeIdForWeights, jsonTree });
if (attributeIdForWeights && !_isValidAttributeIdForWeights) {
setAttributeIdForWeights(undefined);
}
setRoute(route.id, {
attributesQueryBuilderState: { tree: immutableTree, config: config },
attributesQueryValue: jsonTree as AttributesQueryValue,
attributeIdForWeights: _isValidAttributeIdForWeights ? attributeIdForWeights : undefined,
});
};
@@ -336,7 +477,9 @@ const Route = ({
const shouldShowFormFieldsQueryBuilder = (route.isFallback && hasRules(route)) || !route.isFallback;
const eventTypeRedirectUrlOptions =
eventOptions.length !== 0
? [{ label: t("custom"), value: "custom", eventTypeId: 0 }].concat(eventOptions)
? [{ label: t("custom"), value: "custom", eventTypeId: 0, isRRWeightsEnabled: false }].concat(
eventOptions
)
: [];
const eventTypeRedirectUrlSelectedOption =
@@ -347,6 +490,7 @@ const Route = ({
label: t("custom"),
value: "custom",
eventTypeId: 0,
isRRWeightsEnabled: false,
}
: undefined;
@@ -590,6 +734,12 @@ const Route = ({
) : null}
</div>
{attributesQueryBuilder}
<WeightedAttributesSelector
attributes={attributes}
route={route}
eventTypeRedirectUrlSelectedOption={eventTypeRedirectUrlSelectedOption}
setRoute={setRoute}
/>
<Divider className="mb-6 mt-6" />
{fallbackAttributesQueryBuilder}
</div>
@@ -721,6 +871,7 @@ function useRoutes({
queryValue: route.queryValue,
attributesQueryValue: route.attributesQueryValue,
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
attributeIdForWeights: route.attributeIdForWeights,
};
});
}
@@ -739,7 +890,7 @@ const Routes = ({
form: inferSSRProps<typeof getServerSideProps>["form"];
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
attributes: Attribute[] | null;
attributes?: Attribute[];
eventTypesByGroup: EventTypesByGroup;
}) => {
const { routes: serializedRoutes } = hookForm.getValues();
@@ -914,6 +1065,7 @@ const Routes = ({
form={form}
appUrl={appUrl}
key={route.id}
attributes={attributes}
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
route={route}
@@ -1063,7 +1215,6 @@ function Page({
console.error("Events not available");
return <div>{t("something_went_wrong")}</div>;
}
return (
<div className="route-config">
<Routes
@@ -1071,7 +1222,7 @@ function Page({
appUrl={appUrl}
eventTypesByGroup={eventTypesByGroup}
form={form}
attributes={attributes || null}
attributes={attributes}
/>
</div>
);
+2
View File
@@ -70,6 +70,7 @@ export type Attribute = {
slug: string;
type: AttributeType;
id: string;
isWeightsEnabled?: boolean;
options: {
id: string;
value: string;
@@ -97,6 +98,7 @@ export type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
fallbackAttributesQueryBuilderState: AttributesQueryBuilderState | null;
attributeIdForWeights?: string;
};
export type EditFormRoute = LocalRouteWithRaqbStates | GlobalRoute;
+1
View File
@@ -71,6 +71,7 @@ export const attributeRoutingConfigSchema = z
export const zodNonRouterRoute = z.object({
id: z.string(),
name: z.string().optional(),
attributeIdForWeights: z.string().optional(),
attributeRoutingConfig: attributeRoutingConfigSchema,
// TODO: It should be renamed to formFieldsQueryValue but it would take some effort
+4 -2
View File
@@ -872,6 +872,7 @@ describe("attribute weights and virtual queues", () => {
{
//chosen route
id: routeId,
attributeIdForWeights: attributeId,
action: { type: "eventTypeRedirectUrl", value: "team/team1/team1-event-1", eventTypeId: 29 },
queryValue: { id: "a98ab8a9-4567-489a-bcde-f1932649bb8b", type: "group" },
attributesQueryValue: {
@@ -920,7 +921,7 @@ describe("attribute weights and virtual queues", () => {
chosenRouteId: routeId,
};
prismaMock.attribute.findFirst.mockResolvedValue({
prismaMock.attribute.findUnique.mockResolvedValue({
name: "Headquaters",
id: attributeId,
type: AttributeType.SINGLE_SELECT,
@@ -1040,6 +1041,7 @@ describe("attribute weights and virtual queues", () => {
id: routeId,
action: { type: "eventTypeRedirectUrl", value: "team/team1/team1-event-1", eventTypeId: 29 },
queryValue: { id: "a98ab8a9-4567-489a-bcde-f1932649bb8b", type: "group" },
attributeIdForWeights: attributeId,
attributesQueryValue: {
id: "b8ab8ba9-0123-4456-b89a-b1932649bb8b",
type: "group",
@@ -1141,7 +1143,7 @@ describe("attribute weights and virtual queues", () => {
},
]);
prismaMock.attribute.findFirst.mockResolvedValue({
prismaMock.attribute.findUnique.mockResolvedValue({
name: "Company Size",
id: attributeId,
type: AttributeType.SINGLE_SELECT,
+45 -37
View File
@@ -868,53 +868,61 @@ export async function prepareQueuesAndAttributesData<T extends PartialUser>({
const organizationId = eventType.team?.parentId;
log.debug("prepareQueuesAndAttributesData", safeStringify({ routingFormResponse, organizationId }));
if (routingFormResponse && organizationId) {
const attributeWithEnabledWeights = await prisma.attribute.findFirst({
where: {
teamId: organizationId,
isWeightsEnabled: true,
},
select: {
id: true,
name: true,
slug: true,
type: true,
options: {
select: {
id: true,
value: true,
slug: true,
assignedUsers: {
select: {
member: {
select: {
userId: true,
const routingForm = routingFormResponse?.form;
const routes = zodRoutes.parse(routingForm.routes);
const chosenRoute = routes?.find((route) => route.id === routingFormResponse.chosenRouteId);
if (chosenRoute && "attributeIdForWeights" in chosenRoute) {
const attributeIdForWeights = chosenRoute.attributeIdForWeights;
const attributeWithEnabledWeights = await prisma.attribute.findUnique({
where: {
id: attributeIdForWeights,
teamId: organizationId,
isWeightsEnabled: true,
},
select: {
id: true,
name: true,
slug: true,
type: true,
options: {
select: {
id: true,
value: true,
slug: true,
assignedUsers: {
select: {
member: {
select: {
userId: true,
},
},
weight: true,
},
weight: true,
},
},
},
},
},
});
});
if (attributeWithEnabledWeights) {
// Virtual queues are defined by the attribute that has weights and is used with 'Value of field ...'
const queueAndAtributeWeightData = await getQueueAndAttributeWeightData(
allRRHosts,
routingFormResponse,
attributeWithEnabledWeights
);
log.debug(`attributeWithEnabledWeights ${safeStringify(attributeWithEnabledWeights)}`);
if (queueAndAtributeWeightData?.averageWeightsHosts && queueAndAtributeWeightData?.virtualQueuesData) {
attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts;
virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData;
if (attributeWithEnabledWeights) {
// Virtual queues are defined by the attribute that is used for weights
const queueAndAtributeWeightData = await getQueueAndAttributeWeightData(
allRRHosts,
routingFormResponse,
attributeWithEnabledWeights
);
if (
queueAndAtributeWeightData?.averageWeightsHosts &&
queueAndAtributeWeightData?.virtualQueuesData
) {
attributeWeights = queueAndAtributeWeightData?.averageWeightsHosts;
virtualQueuesData = queueAndAtributeWeightData?.virtualQueuesData;
}
}
}
}
return { attributeWeights, virtualQueuesData };
}
@@ -272,6 +272,7 @@ async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId
id: true,
name: true,
type: true,
isWeightsEnabled: true,
options: {
select: {
id: true,