Skip to content

Commit

Permalink
feat: setting to enable weights on attribute in route (calcom#18592)
Browse files Browse the repository at this point in the history
* 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 (calcom#18974)

Co-authored-by: Hariom Balhara <[email protected]>

* use findUnique

* fix test

---------

Co-authored-by: CarinaWolli <[email protected]>
Co-authored-by: Udit Takkar <[email protected]>
Co-authored-by: Hariom Balhara <[email protected]>
Co-authored-by: Hariom Balhara <[email protected]>
  • Loading branch information
5 people authored Jan 30, 2025
1 parent a0f6e50 commit 8d2dc0b
Show file tree
Hide file tree
Showing 8 changed files with 222 additions and 46 deletions.
3 changes: 3 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
{
Expand Down Expand Up @@ -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;
})();
Expand Down
163 changes: 157 additions & 6 deletions packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -26,6 +26,7 @@ import {
TextField,
Badge,
Divider,
SettingsToggle,
} from "@calcom/ui";

import { routingFormAppComponents } from "../../appComponents";
Expand All @@ -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";
Expand Down Expand Up @@ -146,6 +148,7 @@ const buildEventsData = ({
value: string;
eventTypeId: number;
eventTypeAppMetadata?: Record<string, any>;
isRRWeightsEnabled: boolean;
}[] = [];
const eventTypesMap = new Map<
number,
Expand Down Expand Up @@ -191,13 +194,134 @@ const buildEventsData = ({
value: uniqueSlug,
eventTypeId: eventType.id,
eventTypeAppMetadata,
isRRWeightsEnabled: eventType.isRRWeightsEnabled,
});
});
});

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,
Expand All @@ -213,6 +337,7 @@ const Route = ({
disabled = false,
fieldIdentifiers,
eventTypesByGroup,
attributes,
}: {
form: Form;
route: EditFormRoute;
Expand All @@ -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 =
Expand Down Expand Up @@ -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,
});
};

Expand Down Expand Up @@ -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 =
Expand All @@ -347,6 +490,7 @@ const Route = ({
label: t("custom"),
value: "custom",
eventTypeId: 0,
isRRWeightsEnabled: false,
}
: undefined;

Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -721,6 +871,7 @@ function useRoutes({
queryValue: route.queryValue,
attributesQueryValue: route.attributesQueryValue,
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
attributeIdForWeights: route.attributeIdForWeights,
};
});
}
Expand All @@ -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();
Expand Down Expand Up @@ -914,6 +1065,7 @@ const Routes = ({
form={form}
appUrl={appUrl}
key={route.id}
attributes={attributes}
formFieldsQueryBuilderConfig={formFieldsQueryBuilderConfig}
attributesQueryBuilderConfig={attributesQueryBuilderConfig}
route={route}
Expand Down Expand Up @@ -1063,15 +1215,14 @@ function Page({
console.error("Events not available");
return <div>{t("something_went_wrong")}</div>;
}

return (
<div className="route-config">
<Routes
hookForm={hookForm}
appUrl={appUrl}
eventTypesByGroup={eventTypesByGroup}
form={form}
attributes={attributes || null}
attributes={attributes}
/>
</div>
);
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/routing-forms/types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export type Attribute = {
slug: string;
type: AttributeType;
id: string;
isWeightsEnabled?: boolean;
options: {
id: string;
value: string;
Expand Down Expand Up @@ -97,6 +98,7 @@ export type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
fallbackAttributesQueryBuilderState: AttributesQueryBuilderState | null;
attributeIdForWeights?: string;
};

export type EditFormRoute = LocalRouteWithRaqbStates | GlobalRoute;
1 change: 1 addition & 0 deletions packages/app-store/routing-forms/zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions packages/lib/server/getLuckyUser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 8d2dc0b

Please sign in to comment.