Skip to content

Commit ab65ce7

Browse files
hariombalharaCarinaWolli
and
CarinaWolli
authored
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>
1 parent 06f83e6 commit ab65ce7

20 files changed

+1574
-685
lines changed

apps/web/components/dialog/RerouteDialog.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -798,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
798798
findTeamMembersMatchingAttributeLogicMutation.mutate({
799799
formId: form.id,
800800
response: currentResponse,
801-
routeId: route.id,
801+
route,
802802
});
803803
}
804804

apps/web/public/static/locales/en/common.json

+12
Original file line numberDiff line numberDiff line change
@@ -2660,6 +2660,7 @@
26602660
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
26612661
"matching_members": "Matching members",
26622662
"no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.",
2663+
"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",
26632664
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
26642665
"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.",
26652666
"last_number_of_days": "last {{count}} days",
@@ -2707,6 +2708,17 @@
27072708
"add_new_field": "Add new field",
27082709
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
27092710
"form_response_not_found": "Form response not found",
2711+
"using_fallback_members": "Using fallback members",
2712+
"chosen_route": "Chosen Route",
2713+
"attribute_logic_matched": "Attribute logic matched",
2714+
"attribute_logic_fallback_matched": "Attribute logic fallback matched",
2715+
"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.",
2716+
"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.",
2717+
"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.",
2718+
"warning": "Warning",
2719+
"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)",
2720+
"fallback_attribute_logic_warning": "Fallback warning",
2721+
"fallback_not_needed": "Not needed",
27102722
"confirm_reassign_unavailable": "Host unavailable",
27112723
"confirm_reassign_available": "Host available",
27122724
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",

packages/app-store/routing-forms/TODO.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474

7575
### V2.0
7676
- [ ] Fallback for when no team member matches the criteria.
77-
- 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.
77+
- [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.
78+
- [ ] Mark if fallback was used by the router for a response.
7879
- [ ] 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.
7980
- [ ] 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.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import type { Mock } from "vitest";
3+
import { vi } from "vitest";
4+
5+
import { TestFormDialog } from "../components/SingleForm";
6+
import { findMatchingRoute } from "../lib/processRoute";
7+
8+
vi.mock("../lib/processRoute", () => ({
9+
findMatchingRoute: vi.fn(),
10+
}));
11+
12+
function mockMatchingRoute(route: any) {
13+
(findMatchingRoute as Mock<typeof findMatchingRoute>).mockReturnValue({
14+
...route,
15+
id: "matching-route-id",
16+
});
17+
}
18+
19+
function mockCustomPageMessageMatchingRoute() {
20+
mockMatchingRoute({
21+
action: {
22+
type: "customPageMessage",
23+
value: "Thank you for submitting!",
24+
},
25+
});
26+
}
27+
28+
function mockEventTypeRedirectUrlMatchingRoute() {
29+
mockMatchingRoute({
30+
action: {
31+
type: "eventTypeRedirectUrl",
32+
value: "john/30min",
33+
},
34+
});
35+
}
36+
37+
/**
38+
* fixes the error due to Formbricks
39+
*/
40+
vi.mock("@calcom/ui", async (importOriginal) => ({
41+
...(await importOriginal<Record<string, unknown>>()),
42+
}));
43+
44+
vi.mock("@calcom/features/shell/Shell", () => ({
45+
ShellMain: vi.fn(),
46+
}));
47+
48+
vi.mock("@calcom/lib/hooks/useApp", () => ({
49+
default: vi.fn(),
50+
}));
51+
/**
52+
* Avoids the error due to Formbricks
53+
*/
54+
55+
vi.mock("../components/FormActions", () => ({
56+
FormAction: vi.fn(),
57+
FormActionsDropdown: vi.fn(),
58+
FormActionsProvider: vi.fn(),
59+
}));
60+
61+
vi.mock("../../components/react-awesome-query-builder/widgets", () => ({
62+
default: {},
63+
}));
64+
65+
// Mock the necessary dependencies
66+
vi.mock("@calcom/lib/hooks/useLocale", () => ({
67+
useLocale: vi.fn(() => ({ t: (key: string) => key })),
68+
}));
69+
70+
let findTeamMembersMatchingAttributeLogicResponse: {
71+
result: { email: string }[] | null;
72+
checkedFallback: boolean;
73+
mainWarnings?: string[] | null;
74+
fallbackWarnings?: string[] | null;
75+
} = {
76+
result: null,
77+
checkedFallback: false,
78+
mainWarnings: null,
79+
fallbackWarnings: null,
80+
};
81+
82+
function resetFindTeamMembersMatchingAttributeLogicResponse() {
83+
findTeamMembersMatchingAttributeLogicResponse = {
84+
result: null,
85+
checkedFallback: false,
86+
mainWarnings: null,
87+
fallbackWarnings: null,
88+
};
89+
}
90+
91+
function mockFindTeamMembersMatchingAttributeLogicResponse(
92+
response: typeof findTeamMembersMatchingAttributeLogicResponse
93+
) {
94+
findTeamMembersMatchingAttributeLogicResponse = response;
95+
}
96+
97+
vi.mock("@calcom/trpc/react", () => ({
98+
trpc: {
99+
viewer: {
100+
appRoutingForms: {
101+
findTeamMembersMatchingAttributeLogic: {
102+
useMutation: vi.fn(({ onSuccess }) => {
103+
return {
104+
mutate: vi.fn(() => {
105+
onSuccess(findTeamMembersMatchingAttributeLogicResponse);
106+
}),
107+
};
108+
}),
109+
},
110+
},
111+
},
112+
},
113+
}));
114+
115+
const mockTeamForm = {
116+
id: "routing-form-id",
117+
teamId: "test-team-id",
118+
name: "Test Form",
119+
description: "Test form description",
120+
fields: [
121+
{
122+
id: "name",
123+
identifier: "name",
124+
type: "text",
125+
label: "Name",
126+
required: true,
127+
},
128+
],
129+
routes: [
130+
{
131+
id: "non-matching-route-id",
132+
isFallback: false,
133+
action: {
134+
type: "customPageMessage",
135+
value: "Not matching",
136+
},
137+
},
138+
{
139+
id: "matching-route-id",
140+
isFallback: false,
141+
action: {
142+
type: "customPageMessage",
143+
value: "Thank you for submitting!",
144+
},
145+
},
146+
{
147+
id: "fallback-route",
148+
isFallback: true,
149+
action: {
150+
type: "customPageMessage",
151+
value: "Thank you for submitting!",
152+
},
153+
},
154+
],
155+
} as any;
156+
157+
describe("TestFormDialog", () => {
158+
beforeEach(() => {
159+
resetFindTeamMembersMatchingAttributeLogicResponse();
160+
vi.clearAllMocks();
161+
});
162+
163+
it("renders the dialog when open", () => {
164+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
165+
166+
expect(screen.getByText("test_routing_form")).toBeInTheDocument();
167+
expect(screen.getByText("test_preview_description")).toBeInTheDocument();
168+
});
169+
170+
it("doesn't render the dialog when closed", () => {
171+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={false} setIsTestPreviewOpen={() => {}} />);
172+
173+
expect(screen.queryByText("test_routing_form")).not.toBeInTheDocument();
174+
});
175+
176+
it("renders form fields", () => {
177+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
178+
179+
expect(screen.getByTestId("form-field-name")).toBeInTheDocument();
180+
});
181+
182+
describe("Team Form", () => {
183+
it("submits the form and shows test results for Custom Page", async () => {
184+
mockCustomPageMessageMatchingRoute();
185+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
186+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
187+
fireEvent.click(screen.getByText("test_routing"));
188+
189+
expect(screen.getByText("route_to:")).toBeInTheDocument();
190+
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Custom Page");
191+
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("Thank you for submitting!");
192+
});
193+
194+
it("submits the form and shows test results for Event Type", async () => {
195+
mockEventTypeRedirectUrlMatchingRoute();
196+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
197+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
198+
fireEvent.click(screen.getByText("test_routing"));
199+
expect(screen.getByText("route_to:")).toBeInTheDocument();
200+
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Event Redirect");
201+
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("john/30min");
202+
expect(screen.getByTestId("chosen-route")).toHaveTextContent("Route 2");
203+
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("yes");
204+
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("fallback_not_needed");
205+
expect(screen.getByTestId("matching-members")).toHaveTextContent(
206+
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules"
207+
);
208+
});
209+
210+
it("suggests to add fallback when matching members is empty and fallback is not checked", async () => {
211+
mockEventTypeRedirectUrlMatchingRoute();
212+
mockFindTeamMembersMatchingAttributeLogicResponse({
213+
result: [],
214+
checkedFallback: false,
215+
});
216+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
217+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
218+
fireEvent.click(screen.getByText("test_routing"));
219+
expect(screen.getByText("route_to:")).toBeInTheDocument();
220+
expect(screen.getByTestId("test-routing-result-type")).toHaveTextContent("Event Redirect");
221+
expect(screen.getByTestId("test-routing-result")).toHaveTextContent("john/30min");
222+
expect(screen.getByTestId("chosen-route")).toHaveTextContent("Route 2");
223+
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("yes");
224+
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("fallback_not_needed");
225+
expect(screen.getByTestId("matching-members")).toHaveTextContent(
226+
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match"
227+
);
228+
});
229+
230+
it("shows warnings when there are warnings", async () => {
231+
mockEventTypeRedirectUrlMatchingRoute();
232+
mockFindTeamMembersMatchingAttributeLogicResponse({
233+
result: null,
234+
checkedFallback: false,
235+
mainWarnings: ["Main-Error-1", "Main-Error-2"],
236+
fallbackWarnings: ["Fallback-Error-1", "Fallback-Error-2"],
237+
});
238+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
239+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
240+
fireEvent.click(screen.getByText("test_routing"));
241+
screen.logTestingPlaygroundURL();
242+
const alerts = screen.getAllByTestId("alert");
243+
expect(alerts).toHaveLength(2);
244+
expect(alerts[0]).toHaveTextContent("Main-Error-1, Main-Error-2");
245+
expect(alerts[1]).toHaveTextContent("Fallback-Error-1, Fallback-Error-2");
246+
});
247+
248+
it("should not show warnings when there are no warnings", async () => {
249+
mockEventTypeRedirectUrlMatchingRoute();
250+
mockFindTeamMembersMatchingAttributeLogicResponse({
251+
result: null,
252+
checkedFallback: false,
253+
mainWarnings: null,
254+
fallbackWarnings: null,
255+
});
256+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
257+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
258+
fireEvent.click(screen.getByText("test_routing"));
259+
screen.logTestingPlaygroundURL();
260+
const alerts = screen.queryAllByTestId("alert");
261+
expect(alerts).toHaveLength(0);
262+
});
263+
264+
it("should show No in main and fallback matched", async () => {
265+
mockEventTypeRedirectUrlMatchingRoute();
266+
mockFindTeamMembersMatchingAttributeLogicResponse({
267+
result: [],
268+
checkedFallback: true,
269+
mainWarnings: null,
270+
fallbackWarnings: null,
271+
});
272+
render(<TestFormDialog form={mockTeamForm} isTestPreviewOpen={true} setIsTestPreviewOpen={() => {}} />);
273+
fireEvent.change(screen.getByTestId("form-field-name"), { target: { value: "John Doe" } });
274+
fireEvent.click(screen.getByText("test_routing"));
275+
expect(screen.getByTestId("attribute-logic-matched")).toHaveTextContent("no");
276+
expect(screen.getByTestId("attribute-logic-fallback-matched")).toHaveTextContent("no");
277+
expect(screen.getByTestId("matching-members")).toHaveTextContent(
278+
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match"
279+
);
280+
});
281+
});
282+
283+
it("closes the dialog when close button is clicked", () => {
284+
const setIsTestPreviewOpen = vi.fn();
285+
render(
286+
<TestFormDialog
287+
form={mockTeamForm}
288+
isTestPreviewOpen={true}
289+
setIsTestPreviewOpen={setIsTestPreviewOpen}
290+
/>
291+
);
292+
293+
fireEvent.click(screen.getByText("close"));
294+
295+
expect(setIsTestPreviewOpen).toHaveBeenCalledWith(false);
296+
});
297+
});

0 commit comments

Comments
 (0)