From 8d2dc0bbe5796caf13ef5a37f9b0034e594991c3 Mon Sep 17 00:00:00 2001 From: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com> Date: Thu, 30 Jan 2025 11:44:42 -0500 Subject: [PATCH] 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 * use findUnique * fix test --------- Co-authored-by: CarinaWolli Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Co-authored-by: Hariom Balhara Co-authored-by: Hariom Balhara --- apps/web/public/static/locales/en/common.json | 3 + .../lib/getQueryBuilderConfig.ts | 10 +- .../pages/route-builder/[...appPages].tsx | 163 +++++++++++++++++- .../app-store/routing-forms/types/types.d.ts | 2 + packages/app-store/routing-forms/zod.ts | 1 + packages/lib/server/getLuckyUser.test.ts | 6 +- packages/lib/server/getLuckyUser.ts | 82 +++++---- .../service/attribute/server/getAttributes.ts | 1 + 8 files changed, 222 insertions(+), 46 deletions(-) diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 1fcdde96b1c53d..7f2a6fc1e67adf 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts index a6384d617cb4af..84b5ac76a62a6f 100644 --- a/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts +++ b/packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts @@ -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; })(); diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 75b1983f2b4f16..24885325e47b9b 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -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; + 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 ? ( +
+ { + const attributeId = checked ? attributesWithWeightsEnabled[0].id : undefined; + setAttributeIdForWeights(attributeId); + onChangeAttributeIdForWeights(route, attributeId); + }} + /> + {!!attributeIdForWeights ? ( + { + 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); + } + }} + /> + ) : ( + <> + )} +
+ ) : 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} {attributesQueryBuilder} + {fallbackAttributesQueryBuilder} @@ -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["form"]; hookForm: UseFormReturn; 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
{t("something_went_wrong")}
; } - return (
); diff --git a/packages/app-store/routing-forms/types/types.d.ts b/packages/app-store/routing-forms/types/types.d.ts index 2d6f2a5c04813c..2e65d1520fe1dd 100644 --- a/packages/app-store/routing-forms/types/types.d.ts +++ b/packages/app-store/routing-forms/types/types.d.ts @@ -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; diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 5402e6ba68b079..60145f30581837 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -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 diff --git a/packages/lib/server/getLuckyUser.test.ts b/packages/lib/server/getLuckyUser.test.ts index f2d56d212b612b..c181dab688f7a6 100644 --- a/packages/lib/server/getLuckyUser.test.ts +++ b/packages/lib/server/getLuckyUser.test.ts @@ -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, diff --git a/packages/lib/server/getLuckyUser.ts b/packages/lib/server/getLuckyUser.ts index f71aa7dae570a1..1b353bac4bee2e 100644 --- a/packages/lib/server/getLuckyUser.ts +++ b/packages/lib/server/getLuckyUser.ts @@ -868,53 +868,61 @@ export async function prepareQueuesAndAttributesData({ 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 }; } diff --git a/packages/lib/service/attribute/server/getAttributes.ts b/packages/lib/service/attribute/server/getAttributes.ts index 2e98132adab7e8..678dc0e17cf033 100644 --- a/packages/lib/service/attribute/server/getAttributes.ts +++ b/packages/lib/service/attribute/server/getAttributes.ts @@ -272,6 +272,7 @@ async function getAttributesAssignedToMembersOfTeam({ teamId, userId }: { teamId id: true, name: true, type: true, + isWeightsEnabled: true, options: { select: { id: true,