From ab65ce73c2ffe39f719f0dc602aca7f946699a70 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 31 Oct 2024 22:23:34 +0530 Subject: [PATCH] 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 --- apps/web/components/dialog/RerouteDialog.tsx | 2 +- apps/web/public/static/locales/en/common.json | 12 + packages/app-store/routing-forms/TODO.md | 3 +- .../__tests__/TestFormDialog.test.tsx | 297 +++++++++++ .../routing-forms/components/SingleForm.tsx | 388 +++++++++----- .../getServerSidePropsSingleForm.ts | 8 +- ...bersMatchingAttributeLogicOfRoute.test.ts} | 334 +++++++----- ...eamMembersMatchingAttributeLogicOfRoute.ts | 480 ++++++++++++++++++ .../routing-forms/{trpc => lib}/raqbUtils.ts | 10 +- .../pages/form-edit/[...appPages].tsx | 4 +- .../pages/reporting/[...appPages].tsx | 6 +- .../pages/route-builder/[...appPages].tsx | 306 ++++++++--- .../playwright/tests/basic.e2e.ts | 4 +- ...amMembersMatchingAttributeLogic.handler.ts | 25 +- ...eamMembersMatchingAttributeLogic.schema.ts | 4 +- .../trpc/formMutation.handler.ts | 28 +- .../routing-forms/trpc/response.handler.ts | 21 +- .../app-store/routing-forms/trpc/utils.ts | 319 +----------- packages/app-store/routing-forms/zod.ts | 10 + vitest.workspace.ts | 4 +- 20 files changed, 1577 insertions(+), 688 deletions(-) create mode 100644 packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx rename packages/app-store/routing-forms/{trpc/__tests__/utils.test.ts => lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts} (76%) create mode 100644 packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts rename packages/app-store/routing-forms/{trpc => lib}/raqbUtils.ts (97%) diff --git a/apps/web/components/dialog/RerouteDialog.tsx b/apps/web/components/dialog/RerouteDialog.tsx index 6f358c5e70..2ea7104ec0 100644 --- a/apps/web/components/dialog/RerouteDialog.tsx +++ b/apps/web/components/dialog/RerouteDialog.tsx @@ -798,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({ findTeamMembersMatchingAttributeLogicMutation.mutate({ formId: form.id, response: currentResponse, - routeId: route.id, + route, }); } diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 4d8639b0e1..f4527d2b68 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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?", diff --git a/packages/app-store/routing-forms/TODO.md b/packages/app-store/routing-forms/TODO.md index 94505be4db..d0540e8b46 100644 --- a/packages/app-store/routing-forms/TODO.md +++ b/packages/app-store/routing-forms/TODO.md @@ -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. \ No newline at end of file diff --git a/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx new file mode 100644 index 0000000000..729d8af16e --- /dev/null +++ b/packages/app-store/routing-forms/__tests__/TestFormDialog.test.tsx @@ -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).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>()), +})); + +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( {}} />); + + expect(screen.getByText("test_routing_form")).toBeInTheDocument(); + expect(screen.getByText("test_preview_description")).toBeInTheDocument(); + }); + + it("doesn't render the dialog when closed", () => { + render( {}} />); + + expect(screen.queryByText("test_routing_form")).not.toBeInTheDocument(); + }); + + it("renders form fields", () => { + render( {}} />); + + expect(screen.getByTestId("form-field-name")).toBeInTheDocument(); + }); + + describe("Team Form", () => { + it("submits the form and shows test results for Custom Page", async () => { + mockCustomPageMessageMatchingRoute(); + render( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( {}} />); + 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( + + ); + + fireEvent.click(screen.getByText("close")); + + expect(setIsTestPreviewOpen).toHaveBeenCalledWith(false); + }); +}); diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index aa6454e568..8dcc3b1433 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -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; }>; - 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 ( + + {t( + "all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback" + )} + + ); + } + return ( + + {t("all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules")} + + ); + } + + const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic.map( + (member) => member.email + ); + + if (matchingMembers.length) { + return {matchingMembers.join(", ")}; + } + + return ( + + {t("all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match")} + + ); + }; + + return ( +
+
+ {t("chosen_route")}: {chosenRouteName} +
+
+ {t("attribute_logic_matched")}: {renderMainLogicStatus()} + {hasMainWarnings && ( + + )} +
+
+ {t("attribute_logic_fallback_matched")}:{" "} + {renderFallbackLogicStatus()} + {hasFallbackWarnings && ( + + )} +
+
+ {t("matching_members")}: {renderMatchingMembers()} +
+
+ ); + + function isNoLogicFound( + teamMembersMatchingAttributeLogic: NonNullable["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, + "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({}); const [chosenRoute, setChosenRoute] = useState(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(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 ( +
+
{t("route_to")}:
+
+ {RoutingPages.map((page) => { + if (page.value !== chosenRoute.action.type) return null; + return ( + + {page.label} + + ); + })} + :{" "} + {chosenRoute.action.type === "customPageMessage" ? ( + + {chosenRoute.action.value} + + ) : chosenRoute.action.type === "externalRedirectUrl" ? ( + + + {chosenRoute.action.value} + + + ) : ( +
+ + + {chosenRoute.action.value} + + + {isTeamForm ? ( + !findTeamMembersMatchingAttributeLogicMutation.isPending ? ( +
+ +
+ ) : ( +
Loading...
+ ) + ) : null} +
+ )} +
+
+ ); + }; + + return ( + + + +
+
{ + e.preventDefault(); + resetMembersMatchResult(); + testRouting(); + }}> +
+ {form && } +
+
{renderTestResult()}
+ + { + setIsTestPreviewOpen(false); + setChosenRoute(null); + setResponse({}); + }}> + {t("close")} + + + +
+
+
+
+ ); +}; + +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(); useEffect(() => { @@ -341,99 +572,16 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF }); const connectedForms = form.connectedForms; - const testFormDialog = (() => { - const testResult = chosenRoute ? ( -
-
{t("route_to")}:
-
- {RoutingPages.map((page) => { - if (page.value !== chosenRoute.action.type) return null; - return ( - - {page.label} - - ); - })} - :{" "} - {chosenRoute.action.type === "customPageMessage" ? ( - - {chosenRoute.action.value} - - ) : chosenRoute.action.type === "externalRedirectUrl" ? ( - - - {chosenRoute.action.value} - - - ) : ( -
- - - {chosenRoute.action.value} - - - {isTeamForm ? ( -
- {t("matching_members")}:{" "} - {!findTeamMembersMatchingAttributeLogicMutation.isPending ? ( -
- {teamMembersMatchingAttributeLogic?.map((member) => member.email).join(", ") || - t("no_matching_members")} -
- ) : ( -
Loading...
- )} -
- ) : null} -
- )} -
-
- ) : null; - - return ( - - - -
-
{ - e.preventDefault(); - testRouting(); - }}> -
- {form && } -
-
{testResult}
- - { - setIsTestPreviewOpen(false); - setChosenRoute(null); - setResponse({}); - }}> - {t("close")} - - - -
-
-
-
- ); - })(); + 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 - {testFormDialog} + ); } diff --git a/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts b/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts index 5842b1ba45..c9f0e92d97 100644 --- a/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts +++ b/packages/app-store/routing-forms/components/getServerSidePropsSingleForm.ts @@ -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), }), }, }; diff --git a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts b/packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts similarity index 76% rename from packages/app-store/routing-forms/trpc/__tests__/utils.test.ts rename to packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts index f3ac82a2ff..092097b153 100644 --- a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts +++ b/packages/app-store/routing-forms/lib/__tests__/findTeamMembersMatchingAttributeLogicOfRoute.test.ts @@ -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(); }); }); }); diff --git a/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts b/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts new file mode 100644 index 0000000000..8bc4ea5b95 --- /dev/null +++ b/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts @@ -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 +>[number]; + +type RunAttributeLogicData = { + attributesQueryValue: AttributesQueryValue | undefined; + attributesForTeam: Attribute[]; + form: Pick, "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(fn: () => Promise): 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(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 }) { + return { + troubleshooter: { + type, + data, + }, + }; +} + +async function getLogicResultForAllMembers( + { + teamMembersWithAttributeOptionValuePerAttribute, + attributeJsonLogic, + attributesQueryValue, + }: { + teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[]; + attributeJsonLogic: NonNullable; + attributesQueryValue: AttributesQueryValue; + }, + config: { + concurrency: number; + enableTroubleshooter: boolean; + } +) { + const { concurrency, enableTroubleshooter } = config; + const teamMembersMatchingAttributeLogicMap = new Map(); + const attributesDataPerUser = new Map>(); + + await async.mapLimit>( + 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(fn: () => ReturnValue): [ReturnValue, number | null] { + if (!enablePerf) { + return [fn(), null]; + } + return perf(fn); + } + async function aPf(fn: () => Promise): 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, "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(fn: () => Promise): Promise<[ReturnValue, number | null]> { + if (!enablePerf) { + return [await fn(), null]; + } + return asyncPerf(fn); + } +} diff --git a/packages/app-store/routing-forms/trpc/raqbUtils.ts b/packages/app-store/routing-forms/lib/raqbUtils.ts similarity index 97% rename from packages/app-store/routing-forms/trpc/raqbUtils.ts rename to packages/app-store/routing-forms/lib/raqbUtils.ts index 84b3f7bccf..6b9d6e8705 100644 --- a/packages/app-store/routing-forms/trpc/raqbUtils.ts +++ b/packages/app-store/routing-forms/lib/raqbUtils.ts @@ -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; - const attributesMap = attributes.reduce((acc, attribute) => { acc[attribute.id] = attribute; return acc; diff --git a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx index fef8f317cb..206b713a86 100644 --- a/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/form-edit/[...appPages].tsx @@ -465,12 +465,12 @@ const FormEdit = ({ }; export default function FormEditPage({ - form, appUrl, + ...props }: inferSSRProps & { appUrl: string }) { return ( } /> diff --git a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx index e9839be567..db721cccc3 100644 --- a/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/reporting/[...appPages].tsx @@ -186,8 +186,7 @@ const Reporter = ({ form }: { form: inferSSRProps["fo }; export default function ReporterWrapper({ - form, - appUrl, + ...props }: inferSSRProps & { appUrl: string }) { const [isClient, setIsClient] = useState(false); @@ -200,8 +199,7 @@ export default function ReporterWrapper({ return ( (
{isClient && } diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 99d8eb87b5..2d90b01cfc 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -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["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 => { 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 => { // 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(""); 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) => (
@@ -361,7 +378,7 @@ const Route = ({ const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
- For responses matching the following criteria(matches all by default) + For responses matching the following criteria (matches all by default) - + +
) : null; @@ -383,7 +401,7 @@ const Route = ({ route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
- 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) {isRoundRobinEventSelectedForRedirect ? ( @@ -412,6 +430,31 @@ const Route = ({
) : null; + const fallbackAttributesQueryBuilder = + route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? ( +
+ + {t("fallback_attribute_logic_description")} + +
+ {route.fallbackAttributesQueryBuilderState && attributesQueryBuilderConfig && ( + { + onChangeFallbackTeamMembersQuery( + route, + immutableTree, + attributesQueryBuilderConfig as unknown as AttributesQueryBuilderConfigWithRaqbFields + ); + }} + renderBuilder={renderBuilder} + /> + )} +
+
+ ) : null; + return ( {attributesQueryBuilder} + + {fallbackAttributesQueryBuilder}
@@ -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["form"]; + serializedRoutes: SerializableRoute[] | null | undefined; + formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; + attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; hookForm: UseFormReturn; - 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["form"]; + hookForm: UseFormReturn; + 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 }) => { @@ -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} /> @@ -907,6 +1016,66 @@ const Routes = ({ ); }; +function Page({ + hookForm, + form, + appUrl, +}: { + form: RoutingFormWithResponseCount; + appUrl: string; + hookForm: UseFormReturn; +}) { + 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
Loading...
; + } + if (!attributes) { + return
{t("something_went_wrong")}
; + } + } + + if (areEventsLoading) { + return
Loading...
; + } + + if (!eventTypesByGroup) { + console.error("Events not available"); + return
{t("something_went_wrong")}
; + } + + return ( +
+ +
+ ); +} + 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
Loading...
; - } - if (!attributes) { - return
{t("something_went_wrong")}
; - } - } - - return ( -
- -
- ); - }} + Page={Page} /> ); } diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index de235a3c28..436b63a7ca 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -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"]'); })(); }); diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts index 18933e4615..0608ba3b1b 100644 --- a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts +++ b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts @@ -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) { const headerParts = Object.entries(timeTaken) .map(([key, value]) => { - if (value !== null) { + if (value !== null && value !== undefined) { return `${key};dur=${value}`; } return null; diff --git a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts index 8e34f55ddb..6c4f16d21b 100644 --- a/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts +++ b/packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts @@ -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(), diff --git a/packages/app-store/routing-forms/trpc/formMutation.handler.ts b/packages/app-store/routing-forms/trpc/formMutation.handler.ts index 1f6ae9c57b..84350165fb 100644 --- a/packages/app-store/routing-forms/trpc/formMutation.handler.ts +++ b/packages/app-store/routing-forms/trpc/formMutation.handler.ts @@ -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) => { diff --git a/packages/app-store/routing-forms/trpc/response.handler.ts b/packages/app-store/routing-forms/trpc/response.handler.ts index 013c9d3132..d7182ff416 100644 --- a/packages/app-store/routing-forms/trpc/response.handler.ts +++ b/packages/app-store/routing-forms/trpc/response.handler.ts @@ -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, diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index 4cb162aa6d..948d2aeab6 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -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 ->[number]; - function isOptionsField(field: Pick) { 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(fn: () => Promise): 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(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, "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(); - - 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>(); - - const [_, teamMembersMatchingAttributeLogicTimeTaken] = await aPf(async () => { - return await async.mapLimit>( - 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(fn: () => ReturnValue): [ReturnValue, number | null] { - if (!enablePerf) { - return [fn(), null]; - } - return perf(fn); - } - - async function aPf(fn: () => Promise): Promise<[ReturnValue, number | null]> { - if (!enablePerf) { - return [await fn(), null]; - } - return asyncPerf(fn); - } -} - export async function onFormSubmission( form: Ensure< SerializableForm & { user: Pick; userWithEmails?: string[] }, diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index 95e2522026..dea6bcbdf4 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -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, diff --git a/vitest.workspace.ts b/vitest.workspace.ts index 4c12aa59e9..67a81cc365 100644 --- a/vitest.workspace.ts +++ b/vitest.workspace.ts @@ -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"],