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>
This commit is contained in:
Hariom Balhara
2024-10-31 12:53:34 -04:00
committed by GitHub
co-authored by CarinaWolli
parent 06f83e6d0d
commit ab65ce73c2
20 changed files with 1577 additions and 688 deletions
+1 -1
View File
@@ -798,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
routeId: route.id,
route,
});
}
@@ -2660,6 +2660,7 @@
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"matching_members": "Matching members",
"no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.",
"no_matching_members_will_fallback_to_all_assigned_members": "No matching members. It will fallback to using the team members assigned to the event type. Consider adding a fallback or correcting the logic of using_fallback_members",
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
"last_number_of_days": "last {{count}} days",
@@ -2707,6 +2708,17 @@
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
"using_fallback_members": "Using fallback members",
"chosen_route": "Chosen Route",
"attribute_logic_matched": "Attribute logic matched",
"attribute_logic_fallback_matched": "Attribute logic fallback matched",
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules": "All assigned members of the team event type. Consider adding some attribute rules.",
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback": "All assigned members of the team event type. Consider adding some attribute rules to fallback.",
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match": "All assigned members of the team event type. Consider tweaking fallback to have a match.",
"warning": "Warning",
"fallback_attribute_logic_description": "Fallback: If no Team Members match, use those that match the following criteria (matches all assigned team members of the event by default)",
"fallback_attribute_logic_warning": "Fallback warning",
"fallback_not_needed": "Not needed",
"confirm_reassign_unavailable": "Host unavailable",
"confirm_reassign_available": "Host available",
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",
+2 -1
View File
@@ -74,6 +74,7 @@
### V2.0
- [ ] Fallback for when no team member matches the criteria.
- Fallback will be attributes query builder that would match a different set of users. Though the booking will use the team members assigned to the event type, it might be better to be able to identify such a scenario and use a different set of users. It also makes it easy to identify when the fallback scenario happens.
- [x] Fallback will be attributes query builder that would match a different set of users. Though the booking will use the team members assigned to the event type, it might be better to be able to identify such a scenario and use a different set of users. It also makes it easy to identify when the fallback scenario happens.
- [ ] Mark if fallback was used by the router for a response.
- [ ] cal.routedTeamMembersIds query param - Could possible become a big payload and possibly break the URL limit. We could work on a short-lived row in a table that would hold that info and we pass the id of that row only in query param. handleNewBooking can then retrieve the routedTeamMembersIds from that short-lived row and delete the entry after successfully creating a booking.
- [ ] Better ability to test with contact owner from Routing Form Preview itself(if possible). Right now, we need to test the entire booking flow to verify that.
@@ -0,0 +1,297 @@
import { render, screen, fireEvent } from "@testing-library/react";
import type { Mock } from "vitest";
import { vi } from "vitest";
import { TestFormDialog } from "../components/SingleForm";
import { findMatchingRoute } from "../lib/processRoute";
vi.mock("../lib/processRoute", () => ({
findMatchingRoute: vi.fn(),
}));
function mockMatchingRoute(route: any) {
(findMatchingRoute as Mock<typeof findMatchingRoute>).mockReturnValue({
...route,
id: "matching-route-id",
});
}
function mockCustomPageMessageMatchingRoute() {
mockMatchingRoute({
action: {
type: "customPageMessage",
value: "Thank you for submitting!",
},
});
}
function mockEventTypeRedirectUrlMatchingRoute() {
mockMatchingRoute({
action: {
type: "eventTypeRedirectUrl",
value: "john/30min",
},
});
}
/**
* fixes the error due to Formbricks
*/
vi.mock("@calcom/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
}));
vi.mock("@calcom/features/shell/Shell", () => ({
ShellMain: vi.fn(),
}));
vi.mock("@calcom/lib/hooks/useApp", () => ({
default: vi.fn(),
}));
/**
* Avoids the error due to Formbricks
*/
vi.mock("../components/FormActions", () => ({
FormAction: vi.fn(),
FormActionsDropdown: vi.fn(),
FormActionsProvider: vi.fn(),
}));
vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
default: {},
}));
// Mock the necessary dependencies
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: vi.fn(() => ({ t: (key: string) => key })),
}));
let findTeamMembersMatchingAttributeLogicResponse: {
result: { email: string }[] | null;
checkedFallback: boolean;
mainWarnings?: string[] | null;
fallbackWarnings?: string[] | null;
} = {
result: null,
checkedFallback: false,
mainWarnings: null,
fallbackWarnings: null,
};
function resetFindTeamMembersMatchingAttributeLogicResponse() {
findTeamMembersMatchingAttributeLogicResponse = {
result: null,
checkedFallback: false,
mainWarnings: null,
fallbackWarnings: null,
};
}
function mockFindTeamMembersMatchingAttributeLogicResponse(
response: typeof findTeamMembersMatchingAttributeLogicResponse
) {
findTeamMembersMatchingAttributeLogicResponse = response;
}
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
appRoutingForms: {
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess(findTeamMembersMatchingAttributeLogicResponse);
}),
};
}),
},
},
},
},
}));
const mockTeamForm = {
id: "routing-form-id",
teamId: "test-team-id",
name: "Test Form",
description: "Test form description",
fields: [
{
id: "name",
identifier: "name",
type: "text",
label: "Name",
required: true,
},
],
routes: [
{
id: "non-matching-route-id",
isFallback: false,
action: {
type: "customPageMessage",
value: "Not matching",
},
},
{
id: "matching-route-id",
isFallback: false,
action: {
type: "customPageMessage",
value: "Thank you for submitting!",
},
},
{
id: "fallback-route",
isFallback: true,
action: {
type: "customPageMessage",
value: "Thank you for submitting!",
},
},
],
} as any;
describe("TestFormDialog", () => {
beforeEach(() => {
resetFindTeamMembersMatchingAttributeLogicResponse();
vi.clearAllMocks();
});
it("renders the dialog when open", () => {
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
expect(screen.getByText("test_routing_form")).toBeInTheDocument();
expect(screen.getByText("test_preview_description")).toBeInTheDocument();
});
it("doesn't render the dialog when closed", () => {
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={false} setIsTestPreviewOpen={() => {}} />);
expect(screen.queryByText("test_routing_form")).not.toBeInTheDocument();
});
it("renders form fields", () => {
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
expect(screen.getByTestId("form-field-name")).toBeInTheDocument();
});
describe("Team Form", () => {
it("submits the form and shows test results for Custom Page", async () => {
mockCustomPageMessageMatchingRoute();
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
expect(screen.getByText("route_to:")).toBeInTheDocument();
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Custom Page");
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("Thank you for submitting!");
});
it("submits the form and shows test results for Event Type", async () => {
mockEventTypeRedirectUrlMatchingRoute();
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
expect(screen.getByText("route_to:")).toBeInTheDocument();
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Event Redirect");
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("john/30min");
expect(screen.getByTestId("chosen-route")).toHaveTextContent("Route 2");
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("yes");
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("fallback_not_needed");
expect(screen.getByTestId("matching-members")).toHaveTextContent(
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules"
);
});
it("suggests to add fallback when matching members is empty and fallback is not checked", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: [],
checkedFallback: false,
});
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
expect(screen.getByText("route_to:")).toBeInTheDocument();
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Event Redirect");
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("john/30min");
expect(screen.getByTestId("chosen-route")).toHaveTextContent("Route 2");
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("yes");
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("fallback_not_needed");
expect(screen.getByTestId("matching-members")).toHaveTextContent(
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match"
);
});
it("shows warnings when there are warnings", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: null,
checkedFallback: false,
mainWarnings: ["Main-Error-1", "Main-Error-2"],
fallbackWarnings: ["Fallback-Error-1", "Fallback-Error-2"],
});
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
screen.logTestingPlaygroundURL();
const alerts = screen.getAllByTestId("alert");
expect(alerts).toHaveLength(2);
expect(alerts[0]).toHaveTextContent("Main-Error-1, Main-Error-2");
expect(alerts[1]).toHaveTextContent("Fallback-Error-1, Fallback-Error-2");
});
it("should not show warnings when there are no warnings", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: null,
checkedFallback: false,
mainWarnings: null,
fallbackWarnings: null,
});
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
screen.logTestingPlaygroundURL();
const alerts = screen.queryAllByTestId("alert");
expect(alerts).toHaveLength(0);
});
it("should show No in main and fallback matched", async () => {
mockEventTypeRedirectUrlMatchingRoute();
mockFindTeamMembersMatchingAttributeLogicResponse({
result: [],
checkedFallback: true,
mainWarnings: null,
fallbackWarnings: null,
});
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
fireEvent.click(screen.getByText("test_routing"));
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("no");
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("no");
expect(screen.getByTestId("matching-members")).toHaveTextContent(
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match"
);
});
});
it("closes the dialog when close button is clicked", () => {
const setIsTestPreviewOpen = vi.fn();
render(
<TestFormDialog
form={mockTeamForm}
isTestPreviewOpen={true}
setIsTestPreviewOpen={setIsTestPreviewOpen}
/>
);
fireEvent.click(screen.getByText("close"));
expect(setIsTestPreviewOpen).toHaveBeenCalledWith(false);
});
});
@@ -7,11 +7,13 @@ import { Controller, useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import AddMembersWithSwitch from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
import { ShellMain } from "@calcom/features/shell/Shell";
import cn from "@calcom/lib/classNames";
import useApp from "@calcom/lib/hooks/useApp";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc, TRPCClientError } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { Brand } from "@calcom/types/utils";
import {
Alert,
Badge,
@@ -229,33 +231,154 @@ type SingleFormComponentProps = {
appUrl: string;
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
}>;
enrichedWithUserProfileForm?: inferSSRProps<
enrichedWithUserProfileForm: inferSSRProps<
typeof getServerSidePropsForSingleFormView
>["enrichedWithUserProfileForm"];
};
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
type MembersMatchResultType = {
teamMembersMatchingAttributeLogic: { id: number; name: string | null; email: string }[] | null;
checkedFallback: boolean;
mainWarnings: string[] | null;
fallbackWarnings: string[] | null;
} | null;
const TeamMembersMatchResult = ({
membersMatchResult,
chosenRouteName,
}: {
membersMatchResult: MembersMatchResultType;
chosenRouteName: string;
}) => {
const { t } = useLocale();
if (!membersMatchResult) return null;
const hasMainWarnings = (membersMatchResult.mainWarnings?.length ?? 0) > 0;
const hasFallbackWarnings = (membersMatchResult.fallbackWarnings?.length ?? 0) > 0;
const renderFallbackLogicStatus = () => {
if (!membersMatchResult.checkedFallback) {
return t("fallback_not_needed");
} else if (
isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic) ||
membersMatchResult.teamMembersMatchingAttributeLogic.length > 0
) {
return t("yes");
} else {
return t("no");
}
};
const renderMainLogicStatus = () => {
return !membersMatchResult.checkedFallback ? t("yes") : t("no");
};
const renderMatchingMembers = () => {
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
if (membersMatchResult.checkedFallback) {
return (
<span className="font-semibold">
{t(
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback"
)}
</span>
);
}
return (
<span className="font-semibold">
{t("all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules")}
</span>
);
}
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic.map(
(member) => member.email
);
if (matchingMembers.length) {
return <span className="font-semibold">{matchingMembers.join(", ")}</span>;
}
return (
<span className="font-semibold">
{t("all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match")}
</span>
);
};
return (
<div className="text-default mt-2 space-y-2">
<div data-testid="chosen-route">
{t("chosen_route")}: <span className="font-semibold">{chosenRouteName}</span>
</div>
<div data-testid="attribute-logic-matched" className={cn(hasMainWarnings && "text-error")}>
{t("attribute_logic_matched")}: <span className="font-semibold">{renderMainLogicStatus()}</span>
{hasMainWarnings && (
<Alert className="mt-2" severity="warning" title={membersMatchResult.mainWarnings?.join(", ")} />
)}
</div>
<div data-testid="attribute-logic-fallback-matched" className={cn(hasFallbackWarnings && "text-error")}>
{t("attribute_logic_fallback_matched")}:{" "}
<span className="font-semibold">{renderFallbackLogicStatus()}</span>
{hasFallbackWarnings && (
<Alert
className="mt-2"
severity="warning"
title={membersMatchResult.fallbackWarnings?.join(", ")}
/>
)}
</div>
<div data-testid="matching-members">
{t("matching_members")}: {renderMatchingMembers()}
</div>
</div>
);
function isNoLogicFound(
teamMembersMatchingAttributeLogic: NonNullable<MembersMatchResultType>["teamMembersMatchingAttributeLogic"]
): teamMembersMatchingAttributeLogic is null {
return teamMembersMatchingAttributeLogic === null;
}
};
/**
* It has the the ongoing changes in the form along with enrichedWithUserProfileForm specific data.
* So, it can be used to test the form in the test preview dialog without saving the changes even.
*/
type UptoDateForm = Brand<
NonNullable<SingleFormComponentProps["enrichedWithUserProfileForm"]>,
"UptoDateForm"
>;
export const TestFormDialog = ({
form,
isTestPreviewOpen,
setIsTestPreviewOpen,
}: {
form: UptoDateForm;
isTestPreviewOpen: boolean;
setIsTestPreviewOpen: (value: boolean) => void;
}) => {
const { t } = useLocale();
const isTeamForm = !!form.teamId;
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [response, setResponse] = useState<FormResponse>({});
const [chosenRoute, setChosenRoute] = useState<NonRouterRoute | null>(null);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [eventTypeUrl, setEventTypeUrl] = useState("");
const searchParams = useCompatSearchParams();
const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState<
| {
id: number;
name: string | null;
email: string;
}[]
| null
>([]);
const isTeamForm = !!form.teamId;
const [membersMatchResult, setMembersMatchResult] = useState<MembersMatchResultType | null>(null);
const resetMembersMatchResult = () => {
setMembersMatchResult(null);
};
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data.result);
setMembersMatchResult({
teamMembersMatchingAttributeLogic: data.result,
checkedFallback: data.checkedFallback,
mainWarnings: data.mainWarnings,
fallbackWarnings: data.fallbackWarnings,
});
},
onError(e) {
if (e instanceof TRPCClientError) {
@@ -268,16 +391,13 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
function testRouting() {
const route = findMatchingRoute({ form, response });
if (route?.action?.type === "eventTypeRedirectUrl") {
setEventTypeUrl(
enrichedWithUserProfileForm
? getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form: enrichedWithUserProfileForm,
allURLSearchParams: new URLSearchParams(),
})
: ""
getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form,
allURLSearchParams: new URLSearchParams(),
})
);
}
@@ -288,12 +408,123 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
routeId: route.id,
route,
isPreview: true,
_enablePerf: searchParams.get("enablePerf") === "true",
});
}
const renderTestResult = () => {
if (!form.routes || !chosenRoute) return null;
const chosenRouteIndex = form.routes.findIndex((route) => route.id === chosenRoute.id);
const chosenRouteName = () => {
if (chosenRoute.isFallback) {
return t("fallback_route");
}
return `Route ${chosenRouteIndex + 1}`;
};
return (
<div className="bg-subtle text-default mt-5 rounded-md p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value !== chosenRoute.action.type) return null;
return (
<span key={page.value} data-testid="test-routing-result-type">
{page.label}
</span>
);
})}
:{" "}
{chosenRoute.action.type === "customPageMessage" ? (
<span className="text-default" data-testid="test-routing-result">
{chosenRoute.action.value}
</span>
) : chosenRoute.action.type === "externalRedirectUrl" ? (
<span className="text-default underline">
<a
target="_blank"
data-testid="test-routing-result"
href={
chosenRoute.action.value.includes("https://") ||
chosenRoute.action.value.includes("http://")
? chosenRoute.action.value
: `http://${chosenRoute.action.value}`
}
rel="noreferrer">
{chosenRoute.action.value}
</a>
</span>
) : (
<div className="flex flex-col space-y-2">
<span className="text-default underline">
<a target="_blank" href={eventTypeUrl} rel="noreferrer" data-testid="test-routing-result">
{chosenRoute.action.value}
</a>
</span>
{isTeamForm ? (
!findTeamMembersMatchingAttributeLogicMutation.isPending ? (
<div>
<TeamMembersMatchResult
chosenRouteName={chosenRouteName()}
membersMatchResult={membersMatchResult}
/>
</div>
) : (
<div>Loading...</div>
)
) : null}
</div>
)}
</div>
</div>
);
};
return (
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
resetMembersMatchResult();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>{renderTestResult()}</div>
<DialogFooter>
<DialogClose
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setChosenRoute(null);
setResponse({});
}}>
{t("close")}
</DialogClose>
<Button type="submit" data-testid="test-routing">
{t("test_routing")}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
);
};
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const hookForm = useFormContext<RoutingFormWithResponseCount>();
useEffect(() => {
@@ -341,99 +572,16 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
});
const connectedForms = form.connectedForms;
const testFormDialog = (() => {
const testResult = chosenRoute ? (
<div className="bg-subtle text-default mt-5 rounded-md p-3">
<div className="font-bold ">{t("route_to")}:</div>
<div className="mt-2">
{RoutingPages.map((page) => {
if (page.value !== chosenRoute.action.type) return null;
return (
<span key={page.value} data-testid="test-routing-result-type">
{page.label}
</span>
);
})}
:{" "}
{chosenRoute.action.type === "customPageMessage" ? (
<span className="text-default" data-testid="test-routing-result">
{chosenRoute.action.value}
</span>
) : chosenRoute.action.type === "externalRedirectUrl" ? (
<span className="text-default underline">
<a
target="_blank"
data-testid="test-routing-result"
href={
chosenRoute.action.value.includes("https://") ||
chosenRoute.action.value.includes("http://")
? chosenRoute.action.value
: `http://${chosenRoute.action.value}`
}
rel="noreferrer">
{chosenRoute.action.value}
</a>
</span>
) : (
<div className="flex flex-col space-y-2">
<span className="text-default underline">
<a target="_blank" href={eventTypeUrl} rel="noreferrer" data-testid="test-routing-result">
{chosenRoute.action.value}
</a>
</span>
{isTeamForm ? (
<div>
<span>{t("matching_members")}:</span>{" "}
{!findTeamMembersMatchingAttributeLogicMutation.isPending ? (
<div>
{teamMembersMatchingAttributeLogic?.map((member) => member.email).join(", ") ||
t("no_matching_members")}
</div>
) : (
<div>Loading...</div>
)}
</div>
) : null}
</div>
)}
</div>
</div>
) : null;
return (
<Dialog open={isTestPreviewOpen} onOpenChange={setIsTestPreviewOpen}>
<DialogContent enableOverflow>
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
<div>
<form
onSubmit={(e) => {
e.preventDefault();
testRouting();
}}>
<div className="px-1">
{form && <FormInputFields form={form} response={response} setResponse={setResponse} />}
</div>
<div>{testResult}</div>
<DialogFooter>
<DialogClose
color="secondary"
onClick={() => {
setIsTestPreviewOpen(false);
setChosenRoute(null);
setResponse({});
}}>
{t("close")}
</DialogClose>
<Button type="submit" data-testid="test-routing">
{t("test_routing")}
</Button>
</DialogFooter>
</form>
</div>
</DialogContent>
</Dialog>
);
})();
const uptoDateForm = {
...hookForm.getValues(),
routes: hookForm.watch("routes"),
user: enrichedWithUserProfileForm.user,
team: enrichedWithUserProfileForm.team,
nonOrgUsername: enrichedWithUserProfileForm.nonOrgUsername,
nonOrgTeamslug: enrichedWithUserProfileForm.nonOrgTeamslug,
userOrigin: enrichedWithUserProfileForm.userOrigin,
teamOrigin: enrichedWithUserProfileForm.teamOrigin,
} as UptoDateForm;
return (
<>
@@ -634,7 +782,11 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
</ShellMain>
</FormActionsProvider>
</Form>
{testFormDialog}
<TestFormDialog
form={uptoDateForm}
isTestPreviewOpen={isTestPreviewOpen}
setIsTestPreviewOpen={setIsTestPreviewOpen}
/>
</>
);
}
@@ -91,7 +91,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
const { user: u, ...formWithoutUser } = form;
const formWithoutProfilInfo = {
const formWithoutProfileInfo = {
...formWithoutUser,
team: form.team
? {
@@ -103,7 +103,7 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const formWithUserInfoProfil = {
const formWithUserInfoProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
@@ -111,9 +111,9 @@ export const getServerSidePropsForSingleFormView = async function getServerSideP
return {
props: {
trpcState: await ssr.dehydrate(),
form: await getSerializableForm({ form: formWithoutProfilInfo }),
form: await getSerializableForm({ form: formWithoutProfileInfo }),
enrichedWithUserProfileForm: await getSerializableForm({
form: enrichFormWithMigrationData(formWithUserInfoProfil),
form: enrichFormWithMigrationData(formWithUserInfoProfile),
}),
},
};
@@ -9,7 +9,7 @@ import { RaqbLogicResult } from "../../lib/evaluateRaqbLogic";
// import { EmailField } from "@calcom/ui";
import * as getAttributesModule from "../../lib/getAttributes";
import type { AttributesQueryValue, FormFieldsQueryValue } from "../../types/types";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../utils";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../findTeamMembersMatchingAttributeLogicOfRoute";
vi.mock("../../lib/getAttributes");
vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
@@ -182,39 +182,63 @@ function buildDefaultCustomPageRoute({
attributesQueryValue,
});
}
function buildScenarioWhereMainAttributeLogicFails() {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
const Option2OfAttribute1 = { id: "opt2", value: "Option 2", slug: "option-2" };
const Attribute1 = {
id: "attr1",
name: "Attribute 1",
type: "SINGLE_SELECT" as const,
slug: "attribute-1",
options: [Option1OfAttribute1, Option2OfAttribute1],
};
mockAttributesScenario({
attributes: [Attribute1],
teamMembersWithAttributeOptionValuePerAttribute: [
{ userId: 1, attributes: { [Attribute1.id]: Option1OfAttribute1.value } },
],
});
const failingAttributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option2OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
const matchingAttributesQueryValue = buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue;
return { failingAttributesQueryValue, matchingAttributesQueryValue };
}
describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("should return null if route is not found and troubleshooter should also be null by default", async () => {
const { teamMembersMatchingAttributeLogic: result, troubleshooter } =
await findTeamMembersMatchingAttributeLogicOfRoute({
form: { routes: [], fields: [] },
response: {},
routeId: "non-existent-route",
teamId: 1,
});
expect(result).toBeNull();
expect(troubleshooter).toBeNull();
});
it("should return null if the route does not have an attributesQueryValue set", async () => {
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
{
id: "test-route",
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
action: { type: RouteActionType.CustomPageMessage, value: "test" },
},
],
fields: [],
},
response: {},
routeId: "test-route",
route: {
id: "test-route",
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
action: { type: RouteActionType.CustomPageMessage, value: "test" },
},
teamId: 1,
});
@@ -251,20 +275,17 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result, troubleshooter } =
await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
{
id: "test-route",
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: attributesQueryValue,
},
],
fields: [],
},
response: {},
routeId: "test-route",
route: {
id: "test-route",
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: attributesQueryValue,
},
teamId: 1,
});
@@ -275,7 +296,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
},
]);
expect(troubleshooter).toBeNull();
expect(troubleshooter).toBeUndefined();
});
it("should return matching team members with a SINGLE_SELECT attribute when 'Value of Field' option is selected", async () => {
@@ -321,12 +342,6 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [
{
id: Field1Id,
@@ -342,7 +357,10 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
label: Option1OfAttribute1HumanReadableValue,
},
},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
});
@@ -403,16 +421,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
});
@@ -474,16 +489,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
});
@@ -548,16 +560,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result } = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
});
@@ -569,6 +578,100 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
]);
});
describe("Fallback", () => {
it("should return null when main attribute logic fails and no fallback is defined", async () => {
const { failingAttributesQueryValue } = buildScenarioWhereMainAttributeLogicFails();
const {
teamMembersMatchingAttributeLogic: result,
checkedFallback,
troubleshooter,
} = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
fields: [],
},
response: {},
route: {
id: "test-route",
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: failingAttributesQueryValue,
},
teamId: 1,
});
expect(result).toEqual(null);
// We checked the fallback, that is why we know it is not there
expect(checkedFallback).toEqual(true);
expect(troubleshooter).toBeUndefined();
});
it("should return matching members when main attribute logic fails and but fallback matches", async () => {
const { failingAttributesQueryValue, matchingAttributesQueryValue } =
buildScenarioWhereMainAttributeLogicFails();
const {
teamMembersMatchingAttributeLogic: result,
checkedFallback,
troubleshooter,
} = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
fields: [],
},
response: {},
route: {
id: "test-route",
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: matchingAttributesQueryValue,
},
teamId: 1,
});
expect(checkedFallback).toEqual(true);
expect(result).toEqual([
{
userId: 1,
result: RaqbLogicResult.MATCH,
},
]);
expect(troubleshooter).toBeUndefined();
});
it("should return 0 matching members when main attribute logic and fallback attribute logic fail", async () => {
const { failingAttributesQueryValue, matchingAttributesQueryValue } =
buildScenarioWhereMainAttributeLogicFails();
const {
teamMembersMatchingAttributeLogic: result,
checkedFallback,
troubleshooter,
} = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
fields: [],
},
response: {},
route: {
id: "test-route",
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
attributesQueryValue: failingAttributesQueryValue,
fallbackAttributesQueryValue: failingAttributesQueryValue,
},
teamId: 1,
});
expect(checkedFallback).toEqual(true);
expect(troubleshooter).toBeUndefined();
expect(result).toEqual([]);
});
});
describe("Error handling", () => {
it("should throw an error if the attribute type is not supported", async () => {
const Option1OfAttribute1 = { id: "opt1", value: "Option 1", slug: "option-1" };
@@ -592,30 +695,27 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
await expect(
findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: buildSelectTypeFieldQueryValue({
rules: [
{
raqbFieldId: Attribute1.id,
value: [Option1OfAttribute1.id],
operator: "select_equals",
},
],
}) as AttributesQueryValue,
}),
teamId: 1,
})
).rejects.toThrow("Unsupported attribute type");
});
it("should not throw error in live (non-preview) mode but should throw in preview mode", async () => {
it("should return warnings in preview and live mode", async () => {
const Option1OfAttribute1HumanReadableValue = "Option 1";
const Option1OfAttribute1 = {
@@ -663,34 +763,37 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
}) as AttributesQueryValue;
async function runInMode({ mode }: { mode: "preview" | "live" }) {
const { teamMembersMatchingAttributeLogic: result } =
await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
teamId: 1,
isPreview: mode === "preview" ? true : false,
});
const result = await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
fields: [],
},
response: {},
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
isPreview: mode === "preview" ? true : false,
});
return result;
}
await (async function liveMode() {
const result = await runInMode({ mode: "live" });
expect(result).toEqual([]);
// it will fallback to the fallback attribute logic which isn't defined and thus will return null
expect(result.teamMembersMatchingAttributeLogic).toEqual(null);
expect(result.mainAttributeLogicBuildingWarnings).toEqual([
"Value NON_EXISTING_OPTION_1 is not in list of values",
]);
})();
await (async function previewMode() {
expect(() => runInMode({ mode: "preview" })).rejects.toThrow(
/Value NON_EXISTING_OPTION_1 is not in list of values/
);
const result = await runInMode({ mode: "preview" });
// it will fallback to the fallback attribute logic which isn't defined and thus will return null
expect(result.teamMembersMatchingAttributeLogic).toEqual(null);
expect(result.mainAttributeLogicBuildingWarnings).toEqual([
"Value NON_EXISTING_OPTION_1 is not in list of values",
]);
})();
});
@@ -740,16 +843,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
const { teamMembersMatchingAttributeLogic: result } =
await findTeamMembersMatchingAttributeLogicOfRoute({
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
isPreview: mode === "preview" ? true : false,
});
@@ -792,16 +892,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
await findTeamMembersMatchingAttributeLogicOfRoute(
{
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
},
{
@@ -902,16 +999,13 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
await findTeamMembersMatchingAttributeLogicOfRoute(
{
form: {
routes: [
buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
],
fields: [],
},
response: {},
routeId: "test-route",
route: buildDefaultCustomPageRoute({
id: "test-route",
attributesQueryValue: attributesQueryValue,
}),
teamId: 1,
},
{
@@ -925,7 +1019,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
result: RaqbLogicResult.MATCH,
},
]);
expect(troubleshooter).not.toBeNull();
expect(troubleshooter).not.toBeUndefined();
});
});
});
@@ -0,0 +1,480 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import async from "async";
import type { ImmutableTree, JsonLogicResult, JsonTree } from "react-awesome-query-builder";
import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import { getFieldResponse } from "../trpc/utils";
import type { Attribute, AttributesQueryValue, Route } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { RaqbLogicResult } from "./evaluateRaqbLogic";
import { getTeamMembersWithAttributeOptionValuePerAttribute, getAttributesForTeam } from "./getAttributes";
import isRouter from "./isRouter";
import jsonLogic from "./jsonLogic";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
} = acrossQueryValueCompatiblity;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
type RunAttributeLogicData = {
attributesQueryValue: AttributesQueryValue | undefined;
attributesForTeam: Attribute[];
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
teamId: number;
response: FormResponse;
};
type RunAttributeLogicOptions = {
concurrency: number;
enablePerf: boolean;
isPreview: boolean;
enableTroubleshooter: boolean;
};
export const enum TroubleshooterCase {
EMPTY_QUERY_VALUE = "empty-query-value",
IS_A_ROUTER = "is-a-router",
NO_LOGIC_FOUND = "no-logic-found",
MATCH_RESULTS_READY = "match-results-ready",
MATCH_RESULTS_READY_WITH_FALLBACK = "match-results-ready-with-fallback",
MATCHES_ALL_MEMBERS = "matches-all-members",
}
/**
* Performance wrapper for async functions
*/
async function asyncPerf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
const start = performance.now();
const result = await fn();
const end = performance.now();
return [result, end - start];
}
/**
* Performance wrapper for sync functions
*/
function perf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
const start = performance.now();
const result = fn();
const end = performance.now();
return [result, end - start];
}
function getErrorsFromImmutableTree(tree: ImmutableTree) {
const validatedQueryValue = QbUtils.getTree(tree);
if (!raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue)) {
return [];
}
if (!validatedQueryValue.children1) {
return [];
}
const errors: string[][] = [];
Object.values(validatedQueryValue.children1).map((rule) => {
if (rule.type !== "rule") {
return;
}
const valueError = rule.properties.valueError;
if (valueError) {
// Sometimes there are null values in it.
errors.push(valueError.filter((value) => !!value));
}
});
return errors;
}
function getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig,
isPreview,
}: {
attributesQueryValue: JsonTree;
attributesQueryBuilderConfig: Config;
isPreview: boolean;
}) {
const state = {
tree: QbUtils.checkTree(
QbUtils.loadTree(attributesQueryValue),
// We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfig returns a Config. So, asserting it.
attributesQueryBuilderConfig as unknown as Config
),
config: attributesQueryBuilderConfig as unknown as Config,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
// Considering errors as warnings as we want to continue with the flow without throwing actual errors
// We expect fallback logic to take effect in case of errors in main logic
const warnings = getErrorsFromImmutableTree(state.tree).flat();
if (!logic) {
// If children1 is not empty, it means that some rules were added by use
if (attributesQueryValue.children1 && Object.keys(attributesQueryValue.children1).length > 0) {
// Possible reasons for this
// 1. The attribute option value used is not in the options list. Happens if 'Value of field' value is chosen and that field's response value doesn't exist in attribute options list.
return { logic, warnings: ["There is some error building the logic, please check the routes."] };
}
}
return { logic, warnings };
}
function buildTroubleshooterData({ type, data }: { type: TroubleshooterCase; data: Record<string, any> }) {
return {
troubleshooter: {
type,
data,
},
};
}
async function getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
attributeJsonLogic,
attributesQueryValue,
}: {
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
attributeJsonLogic: NonNullable<JsonLogicResult["logic"]>;
attributesQueryValue: AttributesQueryValue;
},
config: {
concurrency: number;
enableTroubleshooter: boolean;
}
) {
const { concurrency, enableTroubleshooter } = config;
const teamMembersMatchingAttributeLogicMap = new Map<number, RaqbLogicResult>();
const attributesDataPerUser = new Map<number, ReturnType<typeof getAttributes>>();
await async.mapLimit<TeamMemberWithAttributeOptionValuePerAttribute, Promise<void>>(
teamMembersWithAttributeOptionValuePerAttribute,
concurrency,
async (member: TeamMemberWithAttributeOptionValuePerAttribute) => {
const attributesData = getAttributes({
attributesData: member.attributes,
attributesQueryValue,
});
if (enableTroubleshooter) {
attributesDataPerUser.set(member.userId, attributesData);
}
const result = !!jsonLogic.apply(attributeJsonLogic as any, attributesData)
? RaqbLogicResult.MATCH
: RaqbLogicResult.NO_MATCH;
if (result !== RaqbLogicResult.MATCH) {
return;
}
teamMembersMatchingAttributeLogicMap.set(member.userId, result);
}
);
return {
teamMembersMatchingAttributeLogicMap,
attributesDataPerUser,
};
}
async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const { attributesQueryValue: _attributesQueryValue, attributesForTeam, form, teamId, response } = data;
const { concurrency, enablePerf, isPreview, enableTroubleshooter } = options;
const [attributesQueryValue, ttGetAttributesQueryValue] = pf(() =>
getAttributesQueryValue({
attributesQueryValue: _attributesQueryValue,
attributes: attributesForTeam,
response,
fields: form.fields,
getFieldResponse,
})
);
if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) {
return {
logicBuildingWarnings: null,
teamMembersMatchingAttributeLogic: null,
...buildTroubleshooterData({
type: TroubleshooterCase.EMPTY_QUERY_VALUE,
data: { attributesQueryValue },
}),
timeTaken: {
ttGetAttributesQueryValue,
},
};
}
const [attributesQueryBuilderConfig, ttGetAttributesQueryBuilderConfig] = pf(() =>
getAttributesQueryBuilderConfig({
form,
attributes: attributesForTeam,
attributesQueryValue,
})
);
const [
teamMembersWithAttributeOptionValuePerAttribute,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
] = await aPf(() => getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }));
const { logic, warnings: logicBuildingWarnings } = getJsonLogic({
attributesQueryValue: attributesQueryValue as JsonTree,
attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config,
isPreview: !!isPreview,
});
if (!logic) {
return {
teamMembersMatchingAttributeLogic: null,
logicBuildingWarnings: null,
timeTaken: {
ttGetAttributesQueryValue,
ttGetAttributesQueryBuilderConfig,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
},
...buildTroubleshooterData({
type: TroubleshooterCase.NO_LOGIC_FOUND,
data: {
attributesQueryValue,
attributesQueryBuilderConfig,
teamMembersWithAttributeOptionValuePerAttribute,
},
}),
};
}
const [
{ teamMembersMatchingAttributeLogicMap, attributesDataPerUser },
ttTeamMembersMatchingAttributeLogic,
] = await aPf(async () =>
getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
attributeJsonLogic: logic,
attributesQueryValue,
},
{
concurrency,
enableTroubleshooter,
}
)
);
const teamMembersMatchingAttributeLogic = Array.from(teamMembersMatchingAttributeLogicMap).map((item) => ({
userId: item[0],
result: item[1],
}));
return {
teamMembersMatchingAttributeLogic,
logicBuildingWarnings,
timeTaken: {
ttGetAttributesQueryBuilderConfig,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
ttTeamMembersMatchingAttributeLogic,
ttGetAttributesQueryValue,
},
...buildTroubleshooterData({
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
attributesDataPerUser,
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesForTeam,
},
}),
};
function pf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
if (!enablePerf) {
return [fn(), null];
}
return perf(fn);
}
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
async function runMainAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const { teamMembersMatchingAttributeLogic, ...rest } = await runAttributeLogic(data, options);
return {
teamMembersMatchingMainAttributeLogic: teamMembersMatchingAttributeLogic,
...rest,
};
}
async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const { teamMembersMatchingAttributeLogic, ...rest } = await runAttributeLogic(data, options);
return {
teamMembersMatchingFallbackLogic: teamMembersMatchingAttributeLogic,
...rest,
};
}
export async function findTeamMembersMatchingAttributeLogicOfRoute(
{
form,
response,
route,
teamId,
isPreview = false,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
response: FormResponse;
route: Route;
teamId: number;
isPreview?: boolean;
},
options: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
} = {}
) {
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options;
const checkedFallback = false;
if (isRouter(route)) {
return {
teamMembersMatchingAttributeLogic: null,
mainAttributeLogicBuildingWarnings: null,
fallbackAttributeLogicBuildingWarnings: null,
checkedFallback,
timeTaken: null,
...buildTroubleshooterData({
type: TroubleshooterCase.IS_A_ROUTER,
data: { route },
}),
};
}
const [attributesForTeam, getAttributesForTeamTimeTaken] = await aPf(
async () => await getAttributesForTeam({ teamId: teamId })
);
const runAttributeLogicOptions = {
concurrency,
enablePerf,
isPreview,
enableTroubleshooter,
};
const runAttributeLogicData = {
// Change it as per the main/fallback query
attributesQueryValue: null,
attributesForTeam,
form,
teamId,
response,
};
const {
teamMembersMatchingMainAttributeLogic,
timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: mainAttributeLogicBuildingWarnings,
} = await runMainAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: route.attributesQueryValue,
},
runAttributeLogicOptions
);
// It being null means that no logic was found and thus all members match. In such case, we don't fallback intentionally.
// This is the case when user added no rules so, he expects to match all members
if (!teamMembersMatchingMainAttributeLogic) {
return {
teamMembersMatchingAttributeLogic: null,
checkedFallback,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCHES_ALL_MEMBERS,
})
: null),
};
}
const noMatchingMembersFound = !teamMembersMatchingMainAttributeLogic.length;
if (noMatchingMembersFound) {
const {
teamMembersMatchingFallbackLogic,
timeTaken: teamMembersMatchingFallbackLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: fallbackAttributeLogicBuildingWarnings,
} = await runFallbackAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: route.fallbackAttributesQueryValue,
},
runAttributeLogicOptions
);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic,
checkedFallback: true,
fallbackAttributeLogicBuildingWarnings,
mainAttributeLogicBuildingWarnings,
timeTaken: {
...teamMembersMatchingFallbackLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY_WITH_FALLBACK,
})
: null),
};
}
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic,
checkedFallback,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
...troubleshooter.data,
attributesForTeam,
},
})
: null),
};
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
@@ -5,14 +5,14 @@ import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { AttributeType } from "@calcom/prisma/enums";
import type { AttributesQueryBuilderConfigWithRaqbFields } from "../lib/getQueryBuilderConfig";
import { getQueryBuilderConfigForAttributes } from "../lib/getQueryBuilderConfig";
import type { Attribute } from "../types/types";
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/trpc/raqbUtils"] });
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/lib/raqbUtils"] });
type GetFieldResponse = ({
field,
@@ -251,8 +251,6 @@ function getAttributesQueryValue({
return null;
}
type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
const attributesMap = attributes.reduce((acc, attribute) => {
acc[attribute.id] = attribute;
return acc;
@@ -465,12 +465,12 @@ const FormEdit = ({
};
export default function FormEditPage({
form,
appUrl,
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
return (
<SingleForm
form={form}
{...props}
appUrl={appUrl}
Page={({ hookForm, form }) => <FormEdit appUrl={appUrl} hookForm={hookForm} form={form} />}
/>
@@ -186,8 +186,7 @@ const Reporter = ({ form }: { form: inferSSRProps<typeof getServerSideProps>["fo
};
export default function ReporterWrapper({
form,
appUrl,
...props
}: inferSSRProps<typeof getServerSideProps> & { appUrl: string }) {
const [isClient, setIsClient] = useState(false);
@@ -200,8 +199,7 @@ export default function ReporterWrapper({
return (
<SingleForm
form={form}
appUrl={appUrl}
{...props}
Page={({ form }) => (
<div className="route-config bg-default fixed inset-0 w-full overflow-scroll pt-12 ltr:mr-2 rtl:ml-2 sm:pt-0">
{isClient && <Reporter form={form} />}
@@ -57,8 +57,11 @@ type AttributesQueryBuilderState = {
type LocalRouteWithRaqbStates = LocalRoute & {
formFieldsQueryBuilderState: FormFieldsQueryBuilderState;
attributesQueryBuilderState: AttributesQueryBuilderState | null;
fallbackAttributesQueryBuilderState: AttributesQueryBuilderState | null;
};
type EventTypesByGroup = RouterOutputs["viewer"]["eventTypes"]["getByViewer"];
type Form = inferSSRProps<typeof getServerSideProps>["form"];
type Route = LocalRouteWithRaqbStates | GlobalRoute;
@@ -136,10 +139,15 @@ const hasRules = (route: Route) => {
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
};
function getEmptyQueryValue() {
return { id: QbUtils.uuid(), type: "group" };
}
const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
const formFieldsQueryValue = { id: uuid, type: "group" } as FormFieldsQueryValue;
const attributesQueryValue = { id: uuid, type: "group" } as AttributesQueryValue;
const formFieldsQueryValue = getEmptyQueryValue() as FormFieldsQueryValue;
const attributesQueryValue = getEmptyQueryValue() as AttributesQueryValue;
const fallbackAttributesQueryValue = getEmptyQueryValue() as AttributesQueryValue;
return {
id: uuid,
@@ -150,6 +158,7 @@ const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
// It is actually formFieldsQueryValue
queryValue: formFieldsQueryValue,
attributesQueryValue: attributesQueryValue,
fallbackAttributesQueryValue: fallbackAttributesQueryValue,
};
};
@@ -158,7 +167,7 @@ const buildEventsData = ({
form,
route,
}: {
eventTypesByGroup: RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
eventTypesByGroup: EventTypesByGroup | undefined;
form: Form;
route: Route;
}) => {
@@ -218,6 +227,7 @@ const Route = ({
appUrl,
disabled = false,
fieldIdentifiers,
eventTypesByGroup,
}: {
form: Form;
route: Route;
@@ -232,15 +242,12 @@ const Route = ({
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
eventTypesByGroup: EventTypesByGroup;
}) => {
const { t } = useLocale();
const isTeamForm = form.teamId !== null;
const index = routes.indexOf(route);
const { data: eventTypesByGroup, isLoading } = trpc.viewer.eventTypes.getByViewer.useQuery({
forRoutingForms: true,
});
const { eventOptions, eventTypesMap } = buildEventsData({ eventTypesByGroup, form, route });
// /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG}
@@ -252,12 +259,10 @@ const Route = ({
const [customEventTypeSlug, setCustomEventTypeSlug] = useState<string>("");
useEffect(() => {
if (!isLoading) {
const isCustom =
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "");
}
}, [isLoading]);
const isCustom =
!isRouter(route) && !eventOptions.find((eventOption) => eventOption.value === route.action.value);
setCustomEventTypeSlug(isCustom && !isRouter(route) ? route.action.value.split("/").pop() ?? "" : "");
}, []);
useEnsureEventTypeIdInRedirectUrlAction({
route,
@@ -289,6 +294,18 @@ const Route = ({
});
};
const onChangeFallbackTeamMembersQuery = (
route: Route,
immutableTree: ImmutableTree,
config: AttributesQueryBuilderConfigWithRaqbFields
) => {
const jsonTree = QbUtils.getTree(immutableTree);
setRoute(route.id, {
fallbackAttributesQueryBuilderState: { tree: immutableTree, config: config },
fallbackAttributesQueryValue: jsonTree as AttributesQueryValue,
});
};
const renderBuilder = useCallback(
(props: BuilderProps) => (
<div className="query-builder-container">
@@ -361,7 +378,7 @@ const Route = ({
const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
<div>
<span className="text-emphasis flex w-full items-center text-sm">
For responses matching the following criteria(matches all by default)
For responses matching the following criteria (matches all by default)
</span>
<Query
{...(formFieldsQueryBuilderConfig as unknown as Config)}
@@ -375,7 +392,8 @@ const Route = ({
}}
renderBuilder={renderBuilder}
/>
<Divider className="mb-6 mt-6" />
<Divider className="mt-6" />
<Divider className="mb-6 " />
</div>
) : null;
@@ -383,7 +401,7 @@ const Route = ({
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
<div className="mt-4">
<span className="text-emphasis flex w-full items-center text-sm">
and use only the Team Members that match the following criteria(matches all by default)
and use only the Team Members that match the following criteria (matches all by default)
</span>
{isRoundRobinEventSelectedForRedirect ? (
@@ -412,6 +430,31 @@ const Route = ({
</div>
) : null;
const fallbackAttributesQueryBuilder =
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
<div className="mt-4">
<span className="text-emphasis flex w-full items-center text-sm">
{t("fallback_attribute_logic_description")}
</span>
<div className="mt-2">
{route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfig && (
<Query
{...(attributesQueryBuilderConfig as unknown as Config)}
value={route.fallbackAttributesQueryBuilderState.tree}
onChange={(immutableTree, attributesQueryBuilderConfig) => {
onChangeFallbackTeamMembersQuery(
route,
immutableTree,
attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields
);
}}
renderBuilder={renderBuilder}
/>
)}
</div>
</div>
) : null;
return (
<FormCard
className="mb-6"
@@ -547,6 +590,8 @@ const Route = ({
) : null}
</div>
{attributesQueryBuilder}
<Divider className="mb-6 mt-6" />
{fallbackAttributesQueryBuilder}
</div>
</div>
</div>
@@ -589,6 +634,14 @@ const deserializeRoute = ({
})
: null;
const fallbackAttributesQueryBuilderState =
route.fallbackAttributesQueryValue && attributesQueryBuilderConfig
? buildState({
queryValue: route.fallbackAttributesQueryValue,
config: attributesQueryBuilderConfig,
})
: null;
return {
...route,
formFieldsQueryBuilderState: buildState({
@@ -596,38 +649,38 @@ const deserializeRoute = ({
config: formFieldsQueryBuilderConfig,
}),
attributesQueryBuilderState,
fallbackAttributesQueryBuilderState,
};
};
const Routes = ({
form,
function useRoutes({
serializedRoutes,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
hookForm,
appUrl,
attributes,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
serializedRoutes: SerializableRoute[] | null | undefined;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
attributes: Attribute[] | null;
}) => {
const { routes: serializedRoutes } = hookForm.getValues();
const { t } = useLocale();
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(hookForm.getValues());
const attributesQueryBuilderConfig = attributes
? getQueryBuilderConfigForAttributes({
attributes: attributes,
form: hookForm.getValues(),
})
: null;
const [routes, setRoutes] = useState(() => {
}) {
const [routes, _setRoutes] = useState(() => {
const transformRoutes = () => {
const _routes = serializedRoutes || [getEmptyRoute()];
_routes.forEach((r) => {
if (isRouter(r)) return;
// Add default empty queries to existing routes otherwise they won't have 'Add Rule' button for those RAQB queries.
if (!r.queryValue?.id) {
r.queryValue = { id: QbUtils.uuid(), type: "group" } as LocalRoute["queryValue"];
r.queryValue = getEmptyQueryValue() as LocalRoute["queryValue"];
}
if (!r.attributesQueryValue) {
r.attributesQueryValue = getEmptyQueryValue() as LocalRoute["attributesQueryValue"];
}
if (!r.fallbackAttributesQueryValue) {
r.fallbackAttributesQueryValue = getEmptyQueryValue() as LocalRoute["fallbackAttributesQueryValue"];
}
});
return _routes;
@@ -643,6 +696,69 @@ const Routes = ({
});
});
const setRoutes: typeof _setRoutes = (newRoutes) => {
_setRoutes((routes) => {
if (typeof newRoutes === "function") {
const newRoutesValue = newRoutes(routes);
hookForm.setValue("routes", getRoutesToSave(newRoutesValue));
return newRoutesValue;
}
hookForm.setValue("routes", getRoutesToSave(newRoutes));
return newRoutes;
});
function getRoutesToSave(routes: Route[]) {
return routes.map((route) => {
if (isRouter(route)) {
return route;
}
return {
id: route.id,
attributeRoutingConfig: route.attributeRoutingConfig,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
attributesQueryValue: route.attributesQueryValue,
fallbackAttributesQueryValue: route.fallbackAttributesQueryValue,
};
});
}
};
return { routes, setRoutes };
}
const Routes = ({
form,
hookForm,
appUrl,
attributes,
eventTypesByGroup,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
appUrl: string;
attributes: Attribute[] | null;
eventTypesByGroup: EventTypesByGroup;
}) => {
const { routes: serializedRoutes } = hookForm.getValues();
const { t } = useLocale();
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(hookForm.getValues());
const attributesQueryBuilderConfig = attributes
? getQueryBuilderConfigForAttributes({
attributes: attributes,
form: hookForm.getValues(),
})
: null;
const { routes, setRoutes } = useRoutes({
serializedRoutes,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
hookForm,
});
const { data: allForms } = trpc.viewer.appRoutingForms.forms.useQuery();
const notHaveAttributesQuery = ({ form }: { form: SerializableForm<App_RoutingForms_Form> }) => {
@@ -784,22 +900,6 @@ const Routes = ({
});
};
const routesToSave = routes.map((route) => {
if (isRouter(route)) {
return route;
}
return {
id: route.id,
attributeRoutingConfig: route.attributeRoutingConfig,
action: route.action,
isFallback: route.isFallback,
queryValue: route.queryValue,
attributesQueryValue: route.attributesQueryValue,
};
});
hookForm.setValue("routes", routesToSave);
const fields = hookForm.getValues("fields");
const fieldIdentifiers = fields ? fields.map((field) => field.identifier ?? field.label) : [];
@@ -834,6 +934,7 @@ const Routes = ({
setRoute={setRoute}
setAttributeRoutingConfig={setAttributeRoutingConfig}
setRoutes={setRoutes}
eventTypesByGroup={eventTypesByGroup}
/>
);
})}
@@ -866,6 +967,13 @@ const Routes = ({
config: attributesQueryBuilderConfig,
})
: null,
fallbackAttributesQueryBuilderState:
attributesQueryBuilderConfig && newEmptyRoute.fallbackAttributesQueryValue
? buildState({
queryValue: newEmptyRoute.fallbackAttributesQueryValue,
config: attributesQueryBuilderConfig,
})
: null,
},
];
@@ -900,6 +1008,7 @@ const Routes = ({
appUrl={appUrl}
fieldIdentifiers={fieldIdentifiers}
setAttributeRoutingConfig={setAttributeRoutingConfig}
eventTypesByGroup={eventTypesByGroup}
/>
</div>
</div>
@@ -907,6 +1016,66 @@ const Routes = ({
);
};
function Page({
hookForm,
form,
appUrl,
}: {
form: RoutingFormWithResponseCount;
appUrl: string;
hookForm: UseFormReturn<RoutingFormWithResponseCount>;
}) {
const { t } = useLocale();
const values = hookForm.getValues();
const { data: attributes, isPending: isAttributesLoading } =
trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery(
{ teamId: values.teamId! },
{ enabled: !!values.teamId }
);
const { data: eventTypesByGroup, isLoading: areEventsLoading } =
trpc.viewer.eventTypes.getByViewer.useQuery({
forRoutingForms: true,
});
// If hookForm hasn't been initialized, don't render anything
// This is important here because some states get initialized which aren't reset when the hookForm is reset with the form values and they don't get the updated values
if (!hookForm.getValues().id) {
return null;
}
// Only team form needs attributes
if (values.teamId) {
if (isAttributesLoading) {
return <div>Loading...</div>;
}
if (!attributes) {
return <div>{t("something_went_wrong")}</div>;
}
}
if (areEventsLoading) {
return <div>Loading...</div>;
}
if (!eventTypesByGroup) {
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}
/>
</div>
);
}
export default function RouteBuilder({
form,
appUrl,
@@ -917,36 +1086,7 @@ export default function RouteBuilder({
form={form}
appUrl={appUrl}
enrichedWithUserProfileForm={enrichedWithUserProfileForm}
Page={function Page({ hookForm, form }) {
const { t } = useLocale();
const values = hookForm.getValues();
const { data: attributes, isPending: isAttributesLoading } =
trpc.viewer.appRoutingForms.getAttributesForTeam.useQuery(
{ teamId: values.teamId! },
{ enabled: !!values.teamId }
);
// If hookForm hasn't been initialized, don't render anything
// This is important here because some states get initialized which aren't reset when the hookForm is reset with the form values and they don't get the updated values
if (!hookForm.getValues().id) {
return null;
}
// Only team form needs attributes
if (values.teamId) {
if (isAttributesLoading) {
return <div>Loading...</div>;
}
if (!attributes) {
return <div>{t("something_went_wrong")}</div>;
}
}
return (
<div className="route-config">
<Routes hookForm={hookForm} appUrl={appUrl} form={form} attributes={attributes || null} />
</div>
);
}}
Page={Page}
/>
);
}
@@ -590,7 +590,9 @@ test.describe("Routing Forms", () => {
await page.click('[data-testid="test-preview"]');
await page.fill('[data-testid="form-field-short-text"]', "medium");
await page.click('[data-testid="test-routing"]');
await page.waitForSelector("text=No matching members.");
await page.waitForSelector("text=Attribute logic matched: No");
await page.waitForSelector("text=Attribute logic fallback matched: Yes");
await page.waitForSelector("text=All assigned members of the team event type. Consider adding some attribute rules to fallback.");
await page.click('[data-testid="dialog-rejection"]');
})();
});
@@ -7,9 +7,9 @@ import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@calcom/trpc/server";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "./findTeamMembersMatchingAttributeLogic.schema";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "./utils";
interface FindTeamMembersMatchingAttributeLogicHandlerOptions {
ctx: {
@@ -25,7 +25,7 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
input,
}: FindTeamMembersMatchingAttributeLogicHandlerOptions) => {
const { prisma, user } = ctx;
const { formId, response, routeId, isPreview, _enablePerf, _concurrency } = input;
const { formId, response, route, isPreview, _enablePerf, _concurrency } = input;
const form = await prisma.app_RoutingForms_Form.findFirst({
where: {
@@ -54,10 +54,13 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
timeTaken: teamMembersMatchingAttributeLogicTimeTaken,
troubleshooter,
checkedFallback,
mainAttributeLogicBuildingWarnings: mainWarnings,
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
} = await findTeamMembersMatchingAttributeLogicOfRoute(
{
response,
routeId,
route,
form: serializableForm,
teamId: form.teamId,
isPreview: !!isPreview,
@@ -73,6 +76,9 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
if (!matchingTeamMembersWithResult) {
return {
troubleshooter,
checkedFallback,
mainWarnings,
fallbackWarnings,
result: null,
};
}
@@ -88,6 +94,9 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
return {
troubleshooter,
checkedFallback,
mainWarnings,
fallbackWarnings,
result: matchingTeamMembers.map((user) => ({
id: user.id,
name: user.name,
@@ -96,16 +105,10 @@ export const findTeamMembersMatchingAttributeLogicHandler = async ({
};
};
function getServerTimingHeader(timeTaken: {
gAtr: number | null;
gQryCnfg: number | null;
gMbrWtAtr: number | null;
lgcFrMbrs: number | null;
gQryVal: number | null;
}) {
function getServerTimingHeader(timeTaken: Record<string, number | null | undefined>) {
const headerParts = Object.entries(timeTaken)
.map(([key, value]) => {
if (value !== null) {
if (value !== null && value !== undefined) {
return `${key};dur=${value}`;
}
return null;
@@ -1,9 +1,11 @@
import { z } from "zod";
import { zodNonRouterRoute } from "../zod";
export const ZFindTeamMembersMatchingAttributeLogicInputSchema = z.object({
formId: z.string(),
response: z.record(z.string(), z.any()),
routeId: z.string(),
route: zodNonRouterRoute,
isPreview: z.boolean().optional(),
_enablePerf: z.boolean().optional(),
_concurrency: z.number().optional(),
@@ -42,9 +42,9 @@ function throwIfInvalidQueryValueToBeSaved({
if (!parsedFormFieldsQueryValue.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Route ${routeIndex + 1} form fields: ${parsedFormFieldsQueryValue.error.errors
.map((err) => err.message)
.join(", ")}`,
message: `Route ${routeIndex + 1} form fields: ${getErrorMessageFromZodError(
parsedFormFieldsQueryValue.error
)}`,
});
}
@@ -52,12 +52,28 @@ function throwIfInvalidQueryValueToBeSaved({
if (!parsedAttributesQueryValue.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Route ${routeIndex + 1} attributes: ${parsedAttributesQueryValue.error.errors
.map((err) => err.message)
.join(", ")}`,
message: `Route ${routeIndex + 1} attributes: ${getErrorMessageFromZodError(
parsedAttributesQueryValue.error
)}`,
});
}
const parsedFallbackAttributesQueryValue = queryValueSaveValidationSchema.safeParse(
route.fallbackAttributesQueryValue
);
if (!parsedFallbackAttributesQueryValue.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Route ${routeIndex + 1} fallback attributes: ${getErrorMessageFromZodError(
parsedFallbackAttributesQueryValue.error
)}`,
});
}
});
function getErrorMessageFromZodError(zodError: Zod.ZodError) {
return zodError.errors.map((err) => err.message).join(", ");
}
}
export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOptions) => {
@@ -8,10 +8,11 @@ import type { PrismaClient } from "@calcom/prisma";
import { RoutingFormSettings } from "@calcom/prisma/zod-utils";
import { TRPCError } from "@calcom/trpc/server";
import { findTeamMembersMatchingAttributeLogicOfRoute } from "../lib/findTeamMembersMatchingAttributeLogicOfRoute";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { FormResponse } from "../types/types";
import type { TResponseInputSchema } from "./response.schema";
import { onFormSubmission, findTeamMembersMatchingAttributeLogicOfRoute } from "./utils";
import { onFormSubmission } from "./utils";
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/response.handler"] });
@@ -123,11 +124,19 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
userWithEmails = userEmails.map((userEmail) => userEmail.user.email);
}
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
if (!chosenRoute) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Chosen route not found",
});
}
const teamMembersMatchingAttributeLogicWithResult =
form.teamId && chosenRouteId
? await findTeamMembersMatchingAttributeLogicOfRoute({
response,
routeId: chosenRouteId,
route: chosenRoute,
form: serializableForm,
teamId: form.teamId,
})
@@ -145,14 +154,6 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
)
: null;
const chosenRoute = serializableFormWithFields.routes?.find((route) => route.id === chosenRouteId);
if (!chosenRoute) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Chosen route not found",
});
}
await onFormSubmission(
{ ...serializableFormWithFields, userWithEmails },
dbFormResponse.response as FormResponse,
+1 -318
View File
@@ -1,8 +1,4 @@
import type { App_RoutingForms_Form, User } from "@prisma/client";
import async from "async";
import type { ImmutableTree, JsonTree } from "react-awesome-query-builder";
import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import dayjs from "@calcom/dayjs";
import type { Tasker } from "@calcom/features/tasker/tasker";
@@ -13,16 +9,8 @@ import logger from "@calcom/lib/logger";
import { WebhookTriggerEvents } from "@calcom/prisma/client";
import type { Ensure } from "@calcom/types/utils";
import { RaqbLogicResult } from "../lib/evaluateRaqbLogic";
import {
getTeamMembersWithAttributeOptionValuePerAttribute,
getAttributesForTeam,
} from "../lib/getAttributes";
import isRouter from "../lib/isRouter";
import jsonLogic from "../lib/jsonLogic";
import type { SerializableField, OrderedResponses } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
let tasker: Tasker;
@@ -36,12 +24,6 @@ if (typeof window === "undefined") {
});
}
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
} = acrossQueryValueCompatiblity;
const moduleLogger = logger.getSubLogger({ prefix: ["routing-forms/trpc/utils"] });
type SelectFieldWebhookResponse = string | number | string[] | { label: string; id: string | null };
@@ -59,15 +41,11 @@ export type FORM_SUBMITTED_WEBHOOK_RESPONSES = Record<
}
>;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
function isOptionsField(field: Pick<SerializableField, "type" | "options">) {
return (field.type === "select" || field.type === "multiselect") && field.options;
}
function getFieldResponse({
export function getFieldResponse({
field,
fieldResponseValue,
}: {
@@ -114,301 +92,6 @@ function getFieldResponse({
};
}
/**
* Performance wrapper for async functions
*/
async function asyncPerf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
const start = performance.now();
const result = await fn();
const end = performance.now();
return [result, end - start];
}
/**
* Performance wrapper for sync functions
*/
function perf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
const start = performance.now();
const result = fn();
const end = performance.now();
return [result, end - start];
}
function getErrorsFromImmutableTree(tree: ImmutableTree) {
const validatedQueryValue = QbUtils.getTree(tree);
if (!raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue)) {
return [];
}
if (!validatedQueryValue.children1) {
return [];
}
const errors: string[][] = [];
Object.values(validatedQueryValue.children1).map((rule) => {
if (rule.type !== "rule") {
return;
}
const valueError = rule.properties.valueError;
if (valueError) {
// Sometimes there are null values in it.
errors.push(valueError.filter((value) => !!value));
}
});
return errors;
}
function getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig,
isPreview,
}: {
attributesQueryValue: JsonTree;
attributesQueryBuilderConfig: Config;
isPreview: boolean;
}) {
const state = {
tree: QbUtils.checkTree(
QbUtils.loadTree(attributesQueryValue),
// We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfig returns a Config. So, asserting it.
attributesQueryBuilderConfig as unknown as Config
),
config: attributesQueryBuilderConfig as unknown as Config,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
// We error only in preview mode to communicate any problem.
// In live mode, we don't error and instead prefer to let no members match which then causes all of the assignes of the team event to be used.
if (isPreview) {
const errors = getErrorsFromImmutableTree(state.tree).flat();
if (errors.length) {
throw new Error(errors.toString());
}
if (!logic) {
// Empty children1 is normal where it means that no rules are added by user.
if (attributesQueryValue.children1 && Object.keys(attributesQueryValue.children1).length > 0) {
// Possible reasons for this
// 1. The attribute option value used is not in the options list. Happens if 'Value of field' value is chosen and that field's response value doesn't exist in attribute options list.
throw new Error("There is some error building the logic, please check the routes.");
}
}
}
return logic;
}
export const enum TroubleshooterCase {
EMPTY_QUERY_VALUE = "empty-query-value",
IS_A_ROUTER = "is-a-router",
NO_LOGIC_FOUND = "no-logic-found",
MATCH_RESULTS_READY = "match-results-ready",
NO_ROUTE_FOUND = "no-route-found",
}
export async function findTeamMembersMatchingAttributeLogicOfRoute(
{
form,
response,
routeId,
teamId,
isPreview,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: FormResponse;
routeId: string;
teamId: number;
isPreview?: boolean;
},
config: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
} = {}
) {
const route = form.routes?.find((route) => route.id === routeId);
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter } = config;
if (!route) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: null,
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.NO_ROUTE_FOUND,
data: {
routeId,
},
}
: null,
};
}
if (isRouter(route)) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: null,
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.IS_A_ROUTER,
data: {
routeId,
},
}
: null,
};
}
const teamMembersMatchingAttributeLogicMap = new Map<number, RaqbLogicResult>();
const [attributesForTeam, getAttributesForTeamTimeTaken] = await aPf(
async () => await getAttributesForTeam({ teamId: teamId })
);
const [attributesQueryValue, getAttributesQueryValueTimeTaken] = pf(() =>
getAttributesQueryValue({
attributesQueryValue: route.attributesQueryValue,
attributes: attributesForTeam,
response,
fields: form.fields,
getFieldResponse,
})
);
if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryVal: getAttributesQueryValueTimeTaken,
gQryCnfg: null,
gMbrWtAtr: null,
lgcFrMbrs: null,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.EMPTY_QUERY_VALUE,
data: {
attributesQueryValue,
},
}
: null,
};
}
const [attributesQueryBuilderConfig, getAttributesQueryBuilderConfigTimeTaken] = pf(() =>
getAttributesQueryBuilderConfig({
form,
attributes: attributesForTeam,
attributesQueryValue,
})
);
const [
teamMembersWithAttributeOptionValuePerAttribute,
getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
] = await aPf(() => getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }));
const logic = getJsonLogic({
attributesQueryValue: attributesQueryValue as JsonTree,
attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config,
isPreview: !!isPreview,
});
if (!logic) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryCnfg: getAttributesQueryBuilderConfigTimeTaken,
gMbrWtAtr: getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
lgcFrMbrs: null,
gQryVal: getAttributesQueryValueTimeTaken,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.NO_LOGIC_FOUND,
data: {
attributesQueryValue,
attributesQueryBuilderConfig,
teamMembersWithAttributeOptionValuePerAttribute,
},
}
: null,
};
}
const attributesDataPerUser = new Map<number, ReturnType<typeof getAttributes>>();
const [_, teamMembersMatchingAttributeLogicTimeTaken] = await aPf(async () => {
return await async.mapLimit<TeamMemberWithAttributeOptionValuePerAttribute, Promise<void>>(
teamMembersWithAttributeOptionValuePerAttribute,
concurrency,
async (member: TeamMemberWithAttributeOptionValuePerAttribute) => {
const attributesData = getAttributes({
attributesData: member.attributes,
attributesQueryValue,
});
if (enableTroubleshooter) {
attributesDataPerUser.set(member.userId, attributesData);
}
const result = !!jsonLogic.apply(logic as any, attributesData)
? RaqbLogicResult.MATCH
: RaqbLogicResult.NO_MATCH;
if (result !== RaqbLogicResult.MATCH) {
return;
}
teamMembersMatchingAttributeLogicMap.set(member.userId, result);
}
);
});
return {
teamMembersMatchingAttributeLogic: Array.from(teamMembersMatchingAttributeLogicMap).map((item) => ({
userId: item[0],
result: item[1],
})),
timeTaken: {
gAtr: getAttributesForTeamTimeTaken,
gQryCnfg: getAttributesQueryBuilderConfigTimeTaken,
gMbrWtAtr: getTeamMembersWithAttributeOptionValuePerAttributeTimeTaken,
lgcFrMbrs: teamMembersMatchingAttributeLogicTimeTaken,
gQryVal: getAttributesQueryValueTimeTaken,
},
troubleshooter: enableTroubleshooter
? {
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
attributesDataPerUser,
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesForTeam,
},
}
: null,
};
function pf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
if (!enablePerf) {
return [fn(), null];
}
return perf(fn);
}
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
export async function onFormSubmission(
form: Ensure<
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
+10
View File
@@ -139,12 +139,22 @@ export const zodNonRouterRoute = z.object({
// TODO: It should be renamed to formFieldsQueryValue but it would take some effort
/**
* RAQB query value for form fields
* BRANDED to ensure we don't give it Attributes
*/
queryValue: queryValueSchema.brand<"formFieldsQueryValue">(),
/**
* RAQB query value for attributes. It is only applicable for Team Events as it is used to find matching team members
* BRANDED to ensure we don't give it Form Fields
*/
attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
/**
* RAQB query value for fallback of `attributesQueryValue`
* BRANDED to ensure we don't give it Form Fields, It needs Attributes
*/
fallbackAttributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
/**
* Whether the route is a fallback if no other routes match
*/
isFallback: z.boolean().optional(),
action: z.object({
type: routeActionTypeSchema,
+2 -2
View File
@@ -134,9 +134,9 @@ const workspaces = packagedEmbedTestsOnly
{
test: {
globals: true,
name: "@calcom/routing-forms/widgets",
name: "@calcom/routing-forms",
include: [
"packages/app-store/routing-forms/components/react-awesome-query-builder/widgets.test.tsx",
"packages/app-store/routing-forms/**/*.test.tsx",
],
environment: "jsdom",
setupFiles: ["packages/ui/components/test-setup.ts"],