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:
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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user