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:
co-authored by
CarinaWolli
parent
06f83e6d0d
commit
ab65ce73c2
@@ -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?",
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
+214
-120
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-6
@@ -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"]');
|
||||
})();
|
||||
});
|
||||
|
||||
+14
-11
@@ -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;
|
||||
|
||||
+3
-1
@@ -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,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[] },
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user