Skip to content

Commit

Permalink
Add getEventTypeRedirectUrl tests
Browse files Browse the repository at this point in the history
  • Loading branch information
hariombalhara committed Oct 9, 2024
1 parent be53242 commit 5135fdd
Show file tree
Hide file tree
Showing 13 changed files with 258 additions and 94 deletions.
7 changes: 6 additions & 1 deletion packages/app-store/routing-forms/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@
- [ ] getAttributes
- [ ] Querying Logic Test
- [ ] getRoutedUsers tests
- [ ] getAvailableSlots
- [ ] should use routedTeamMemberIds in availability query

### Documentation

### Documentation / Tooltip
- [ ] Document well that the option label in Routing Form Field and Attribute Option label must be same to connect them.
- Due to the connection requirement b/w Attribute Option and Field Option, we use label(lowercased) to match attributes instead of attribute slug
- [ ] Fixed hosts of the event will be included through attribute routing as well. They aren't tested for attribute routing logic.

### 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.
- [ ] 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.


## TODO - Attributes
Expand Down
10 changes: 0 additions & 10 deletions packages/app-store/routing-forms/TODO2.md

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from "vitest";

import { CAL_URL } from "@calcom/lib/constants";

import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";

console.log({ CAL_URL });
describe("getAbsoluteEventTypeRedirectUrl", () => {
const defaultForm = {
team: null,
nonOrgUsername: null,
nonOrgTeamslug: null,
userOrigin: "https://user.cal.com",
teamOrigin: "https://team.cal.com",
};

const defaultParams = {
eventTypeRedirectUrl: "user/event",
form: defaultForm,
allURLSearchParams: new URLSearchParams(),
isEmbed: false,
};

it("should return CAL_URL for non-migrated user", () => {
const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams,
eventTypeRedirectUrl: "user/event",
form: { ...defaultForm, nonOrgUsername: "user" },
});
expect(result).toBe(`${CAL_URL}/user/event?`);
});

it("should return user origin for migrated user", () => {
const result = getAbsoluteEventTypeRedirectUrl(defaultParams);
expect(result).toBe("https://user.cal.com/user/event?");
});

it("should return CAL_URL for non-migrated team", () => {
const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams,
eventTypeRedirectUrl: "team/team1/event",
form: { ...defaultForm, nonOrgTeamslug: "team1" },
});
expect(result).toBe(`${CAL_URL}/team/team1/event?`);
});

it("should return team origin for migrated team", () => {
const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams,
eventTypeRedirectUrl: "team/team1/event",
});
expect(result).toBe("https://team.cal.com/team/team1/event?");
});

it("should append URL search params", () => {
const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams,
allURLSearchParams: new URLSearchParams("foo=bar&baz=qux"),
});
expect(result).toBe("https://user.cal.com/user/event?foo=bar&baz=qux");
});

it("should append /embed for embedded views", () => {
const result = getAbsoluteEventTypeRedirectUrl({
...defaultParams,
allURLSearchParams: new URLSearchParams("foo=bar&baz=qux"),
isEmbed: true,
});
expect(result).toBe("https://user.cal.com/user/event/embed?foo=bar&baz=qux");
});

it.only("should throw an error if invalid team event redirect URL is provided", () => {
expect(() =>
getAbsoluteEventTypeRedirectUrl({
...defaultParams,
eventTypeRedirectUrl: "team/",
})
).toThrow("eventTypeRedirectUrl must have username or teamSlug");
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it, afterEach, vi } from "vitest";

import { jsonLogicToPrisma } from "../../jsonLogicToPrisma";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";

afterEach(() => {
vi.resetAllMocks();
Expand Down
2 changes: 2 additions & 0 deletions packages/app-store/routing-forms/getEventTypeRedirectUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ function getUserAndEventTypeSlug(eventTypeRedirectUrl: string) {
}

/**
* @param eventTypeRedirectUrl - The event path without a starting slash
*
* Handles the following cases
* 1. A team form where the team isn't a sub-team
* 1.1 A team form where team isn't a sub-team and the user is migrated. i.e. User has been migrated but not the team
Expand Down
135 changes: 85 additions & 50 deletions packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { App_RoutingForms_Form } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import {
Expand Down Expand Up @@ -57,8 +58,37 @@ type LocalRouteWithRaqbStates = LocalRoute & {
attributesQueryBuilderState: AttributesQueryBuilderState | null;
};

type Form = inferSSRProps<typeof getServerSideProps>["form"];

type Route = LocalRouteWithRaqbStates | GlobalRoute;

const RoundRobinContactOwnerOverrideSwitch = ({
route,
setAttributeRoutingConfig,
}: {
route: LocalRouteWithRaqbStates;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
}) => {
return (
<div className="mt-4 flex flex-col">
<Switch
label={
route.attributeRoutingConfig?.skipContactOwner
? "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)"
: "Contact owner will be the Round Robin host if available"
}
tooltip="Contact owner can only be used if the routed event has it enabled through Salesforce app"
checked={route.attributeRoutingConfig?.skipContactOwner ?? false}
onCheckedChange={(skipContactOwner) => {
setAttributeRoutingConfig(route.id, {
skipContactOwner,
});
}}
/>
</div>
);
};

type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
type FormFieldsQueryValue = LocalRoute["queryValue"];
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
Expand All @@ -85,43 +115,15 @@ const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
};
};

const Route = ({
const buildEventsData = ({
eventTypesByGroup,
form,
route,
routes,
setRoute,
setAttributeRoutingConfig,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
setRoutes,
moveUp,
moveDown,
appUrl,
disabled = false,
fieldIdentifiers,
}: {
form: inferSSRProps<typeof getServerSideProps>["form"];
eventTypesByGroup: RouterOutputs["viewer"]["eventTypes"]["getByViewer"] | undefined;
form: Form;
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
fieldIdentifiers: string[];
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
}) => {
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: { label: string; value: string; eventTypeId: number }[] = [];
const eventTypesMap = new Map<
number,
Expand Down Expand Up @@ -161,6 +163,48 @@ const Route = ({
});
});

return { eventOptions, eventTypesMap };
};

const Route = ({
form,
route,
routes,
setRoute,
setAttributeRoutingConfig,
formFieldsQueryBuilderConfig,
attributesQueryBuilderConfig,
setRoutes,
moveUp,
moveDown,
appUrl,
disabled = false,
fieldIdentifiers,
}: {
form: Form;
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
setRoutes: React.Dispatch<React.SetStateAction<Route[]>>;
fieldIdentifiers: string[];
moveUp?: { fn: () => void; check: () => boolean } | null;
moveDown?: { fn: () => void; check: () => boolean } | null;
appUrl: string;
disabled?: boolean;
}) => {
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}
const eventTypePrefix =
eventOptions.length !== 0
Expand Down Expand Up @@ -263,10 +307,13 @@ const Route = ({
}
: undefined;

const chosenEventType = eventTypeRedirectUrlSelectedOption?.eventTypeId
const chosenEventTypeForRedirect = eventTypeRedirectUrlSelectedOption?.eventTypeId
? eventTypesMap.get(eventTypeRedirectUrlSelectedOption.eventTypeId)
: null;

const isRoundRobinEventSelectedForRedirect =
chosenEventTypeForRedirect?.schedulingType === SchedulingType.ROUND_ROBIN;

const formFieldsQueryBuilder = shouldShowFormFieldsQueryBuilder ? (
<div>
<span className="text-emphasis flex w-full items-center text-sm">
Expand Down Expand Up @@ -295,23 +342,11 @@ const Route = ({
and use only the Team Members that match the following criteria(matches all by default)
</span>

{chosenEventType?.schedulingType === SchedulingType.ROUND_ROBIN ? (
<div className="mt-4 flex flex-col">
<Switch
label={
route.attributeRoutingConfig?.skipContactOwner
? "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)"
: "Contact owner will be the Round Robin host if available"
}
tooltip="Contact owner can only be used if the routed event has it enabled through Salesforce app"
checked={route.attributeRoutingConfig?.skipContactOwner ?? false}
onCheckedChange={(skipContactOwner) => {
setAttributeRoutingConfig(route.id, {
skipContactOwner,
});
}}
/>
</div>
{isRoundRobinEventSelectedForRedirect ? (
<RoundRobinContactOwnerOverrideSwitch
route={route}
setAttributeRoutingConfig={setAttributeRoutingConfig}
/>
) : null}

<div className="mt-2">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe("getUrlSearchParamsToForward", () => {
searchParams,
teamMembersMatchingAttributeLogic: null,
formResponseId: 1,
attributeRoutingConfig: null
attributeRoutingConfig: null,
});
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
});
Expand Down
25 changes: 25 additions & 0 deletions packages/core/event.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ describe("event tests", () => {
bookingFields: {
customField: "example custom field",
},
eventDuration: 15,
t: tFunc as TFunction,
});

Expand Down Expand Up @@ -387,6 +388,23 @@ describe("event tests", () => {
expect(result).toBe("event duration: 15 mins");
});

it("should support templating of routingFormResponses", () => {
const tFunc = vi.fn(() => "foo");

const result = event.getEventName({
attendeeName: "example attendee",
eventType: "example event type",
host: "example host",
eventName: "Event Title: {routingForm.companySize}",
routingFormResponses: {
companySize: "Large",
},
eventDuration: 15,
t: tFunc as TFunction,
});
expect(result).toBe("Event Title: Large");
});

describe("fn: validateCustomEventName", () => {
it("should be valid when no variables used", () => {
expect(event.validateCustomEventName("foo")).toBe(true);
Expand Down Expand Up @@ -424,5 +442,12 @@ describe("event tests", () => {
it("should return variable when invalid variable used", () => {
expect(event.validateCustomEventName("foo{nonsenseField}bar")).toBe("{nonsenseField}");
});

it("should support any random Routing form field variable ", () => {
// routingForm. namespace allows any random field to be used
expect(event.validateCustomEventName("foo{routingForm.randomField}bar")).toBe(true);
// Any other namespace should cause error - Error is identified by a string value that tells the template variable that is invalid
expect(event.validateCustomEventName("foo{abc.companySize}bar")).toBe("{abc.companySize}");
});
});
});
Loading

0 comments on commit 5135fdd

Please sign in to comment.