feat: Rerouting a booking with corrected responses (#17074)

* POC working with no TS error

* cleanup

* Fix query booking

* Simplify things

* Support rerouting to different event

* self-review fixes

* Some improvements

* add more tests

* Remove members relation query

* Udits feedback

* Dont show CTAs when non-eventTypeRedirectUrl action route is chosen

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
This commit is contained in:
Hariom Balhara
2024-10-18 17:11:50 -04:00
committed by GitHub
co-authored by Udit Takkar
parent 4f14775a4d
commit b8d4fccbab
33 changed files with 1990 additions and 98 deletions
@@ -20,6 +20,7 @@ import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import type { ActionType } from "@calcom/ui";
import {
Badge,
@@ -48,6 +49,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
@@ -65,9 +67,43 @@ type BookingItemProps = BookingItem & {
};
};
function BookingListItem(booking: BookingItemProps) {
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
type TeamEventBooking = Omit<ParsedBooking, "eventType"> & {
eventType: TeamEvent;
};
type ReroutableBooking = Ensure<TeamEventBooking, "routedFromRoutingFormReponse">;
function buildParsedBooking(booking: BookingItemProps) {
// The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence
const bookingEventType = booking.eventType.id
? (booking.eventType as Ensure<
typeof booking.eventType,
// It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here.
"id" | "length" | "title" | "slug" | "schedulingType" | "team"
>)
: null;
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
return {
...booking,
eventType: bookingEventType,
metadata: bookingMetadata,
};
}
const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => {
// We support only team bookings for now for rerouting
// Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now.
// Let's not support re-routing for a booking without an event-type for now.
// Such a booking has its event-type deleted and there might not be something to reroute to.
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
};
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const {
t,
i18n: { language },
@@ -107,7 +143,7 @@ function BookingListItem(booking: BookingItemProps) {
const paymentAppData = getPaymentAppData(booking.eventType);
const location = booking.location as ReturnType<typeof getEventLocationValue>;
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl;
const { resolvedTheme, forcedTheme } = useGetTheme();
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
@@ -191,6 +227,18 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenRescheduleDialog(true);
},
},
...(isBookingReroutable(parsedBooking)
? [
{
id: "reroute",
label: t("reroute"),
onClick: () => {
setRerouteDialogIsOpen(true);
},
icon: "waypoints" as const,
},
]
: []),
{
id: "change_location",
label: t("edit_location"),
@@ -275,6 +323,7 @@ function BookingListItem(booking: BookingItemProps) {
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
@@ -360,6 +409,7 @@ function BookingListItem(booking: BookingItemProps) {
phoneNumber: attendee.phoneNumber,
};
});
return (
<>
<RescheduleDialog
@@ -620,6 +670,13 @@ function BookingListItem(booking: BookingItemProps) {
)}
</td>
</tr>
{isBookingReroutable(parsedBooking) && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
</>
);
}
@@ -722,7 +779,7 @@ const FirstAttendee = ({
className=" hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name}
{user.name || user.email}
</a>
);
};
@@ -0,0 +1,910 @@
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useCallback } from "react";
import type { z } from "zod";
import FormInputFields, {
FormInputFieldsSkeleton,
} from "@calcom/app-store/routing-forms/components/FormInputFields";
import { getAbsoluteEventTypeRedirectUrl } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
import { getUrlSearchParamsToForwardForReroute } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { EventType, User, Team, Attendee, Booking as PrismaBooking } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui";
import { Button, Tooltip } from "@calcom/ui";
import { showToast } from "@calcom/ui/components/toast";
import useRouterQuery from "@calcom/web/lib/hooks/useRouterQuery";
const enum ReroutingStatusEnum {
REROUTING_NOT_INITIATED = "not_initiated",
REROUTING_IN_PROGRESS = "in_progress",
REROUTING_COMPLETE = "complete",
REROUTING_FAILED = "failed",
}
type ResponseWithForm = RouterOutputs["viewer"]["appRoutingForms"]["getResponseWithFormFields"];
type BookingToReroute = Pick<PrismaBooking, "metadata" | "responses" | "id" | "uid" | "title" | "status"> & {
routedFromRoutingFormReponse: {
id: number;
};
startTime: string;
metadata: z.infer<typeof bookingMetadataSchema>;
attendees: Pick<Attendee, "locale" | "timeZone" | "email" | "name">[];
eventType: Pick<EventType, "length" | "schedulingType" | "title" | "id" | "slug"> & {
team: Pick<Team, "slug"> | null;
};
user: Pick<User, "id" | "name" | "email"> | null;
};
type TeamEventTypeBookingToReroute = Omit<BookingToReroute, "eventType"> & {
user: NonNullable<BookingToReroute["user"]>;
eventType: Omit<BookingToReroute["eventType"], "team"> & {
team: Ensure<Pick<Team, "slug">, "slug">;
};
};
type RerouteDialogProps = {
isOpenDialog: boolean;
setIsOpenDialog: (isOpen: boolean) => void;
booking: BookingToReroute;
};
type ReroutingState =
| {
type: "reschedule_to_same_event_new_tab" | "reschedule_to_different_event_new_tab";
/**
* UID of the rescheduled booking
*/
newBooking: string | null;
/**
* window for the new tab that is opened for rerouting
*/
reschedulerWindow: Window;
error?: Error;
}
| {
type: "same_timeslot";
newBooking: string | null;
reschedulerWindow: null;
error?: Error;
};
type TeamMemberMatchingAttributeLogic = {
id: number;
name: string | null;
email: string;
};
// We can't let user change email identifier field as that would require contact owner information from salesforce which we don't support yet.
// Also, this isn't a requirement as well to allow correcting the email
const fieldsThatCannotBeCorrected = ["email"];
function getEventTypeUrlsForTheChosenRoute({
chosenRoute,
form,
reroutingFormResponses,
currentResponse,
teamMemberIdsMatchingAttributeLogic,
booking,
searchParams,
}: {
form: ResponseWithForm["form"];
currentResponse: FormResponse;
reroutingFormResponses: Record<string, Pick<FormResponse[keyof FormResponse], "value">>;
teamMemberIdsMatchingAttributeLogic: number[] | null;
chosenRoute: LocalRoute | null;
booking: TeamEventTypeBookingToReroute;
searchParams: URLSearchParams;
}) {
if (!chosenRoute) {
return null;
}
const formFields = form.fields || [];
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const allURLSearchParams = getUrlSearchParamsToForwardForReroute({
formResponse: currentResponse,
formResponseId: routedFromRoutingFormReponseId,
fields: formFields,
searchParams,
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
attributeRoutingConfig: chosenRoute.attributeRoutingConfig ?? null,
rescheduleUid: booking.uid,
reroutingFormResponses,
});
const eventFullSlug = substituteVariables(chosenRoute.action.value, currentResponse, formFields);
const eventBookingAbsoluteUrl = getAbsoluteEventTypeRedirectUrl({
form,
eventTypeRedirectUrl: eventFullSlug,
allURLSearchParams,
isEmbed: false,
});
return { eventFullSlug, eventBookingAbsoluteUrl };
}
function rescheduleInNewTab({ url }: { url: string }) {
const reschedulerWindow = window.open(url, "_blank");
if (!reschedulerWindow) {
throw new Error("Failed to open new tab");
}
return reschedulerWindow;
}
const getFullSlugForEvent = (
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "team" | "slug">
) => {
return `team/${eventType.team.slug}/${eventType.slug}`;
};
function isBookingTimeslotInPast(booking: BookingToReroute) {
return dayjs(booking.startTime).isBefore(dayjs());
}
const useReroutingState = ({ isOpenDialog }: Pick<RerouteDialogProps, "isOpenDialog">) => {
const [value, setValue] = useState<ReroutingState | null>(null);
const state = value;
// If dialog is closed but the rerouting window is still open, close it
if (!isOpenDialog && state?.reschedulerWindow) {
state.reschedulerWindow.close();
setValue(null);
}
useEffect(() => {
const checkInterval = setInterval(() => {
if (state?.reschedulerWindow?.closed) {
// Ensure that render happens again
setValue(null);
clearInterval(checkInterval);
}
}, 1);
return () => clearInterval(checkInterval);
}, [value, setValue]);
const status = (() => {
if (!value) return ReroutingStatusEnum.REROUTING_NOT_INITIATED;
if (value.error) return ReroutingStatusEnum.REROUTING_FAILED;
if (!!value.newBooking) return ReroutingStatusEnum.REROUTING_COMPLETE;
return ReroutingStatusEnum.REROUTING_IN_PROGRESS;
})();
return { value: state, setValue, status };
};
// const useUpdateIsReroutingQueryParam = ({ isOpenDialog }: { isOpenDialog: boolean }) => {
// const { setQuery: setIsReroutingQuery } = useRouterQuery("isRerouting");
// useEffect(() => {
// if (isOpenDialog) {
// setIsReroutingQuery("true");
// } else {
// setIsReroutingQuery(undefined);
// }
// }, [isOpenDialog]);
// };
const gotoBookingPage = ({
booking,
router,
}: {
booking: Pick<PrismaBooking, "uid">;
router: ReturnType<typeof useRouter>;
}) => {
// TODO: Do we need to send other params as well?
router.push(`/booking/${booking.uid}?cal.rerouting=true`);
};
const useEventListeners = ({ reroutingState }: { reroutingState: ReturnType<typeof useReroutingState> }) => {
const router = useRouter();
const { t } = useLocale();
const messageListener = useCallback(
(event: MessageEvent) => {
if (event.data.type !== "CAL:rescheduleBookingSuccessfulV2") {
return;
}
const calEventData = event.data.data;
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
if (!calEventData.uid) {
console.error("Booking UID is not there");
throw new Error(t("something_went_wrong"));
}
const newBookingId = calEventData.uid;
reroutingState.setValue((prev) => {
if (!prev) return null;
return { ...prev, newBooking: newBookingId };
});
gotoBookingPage({ router, booking: { uid: newBookingId } });
reroutingState.value?.reschedulerWindow?.close();
},
[reroutingState, reroutingState.value?.reschedulerWindow]
);
// Make sure to close the rescheduler window when this tab closes/reloads
const beforeUnloadListener = useCallback(() => {
reroutingState.value?.reschedulerWindow?.close();
reroutingState.setValue(null);
}, [reroutingState.value?.reschedulerWindow]);
// Ensure listeners are added once the component is mounted and removed once the component is unmounted
useEffect(() => {
window.addEventListener("message", messageListener);
window.addEventListener("beforeunload", beforeUnloadListener);
return () => {
window.removeEventListener("message", messageListener);
window.removeEventListener("beforeunload", beforeUnloadListener);
};
// messageListener and beforeUnloadListener are memoized using useCallback, so the reference remains stable
}, [messageListener, beforeUnloadListener]);
};
const NewRoutingManager = ({
chosenRoute,
booking,
form,
currentResponse,
teamMembersMatchingAttributeLogic,
reroutingState,
}: {
chosenRoute: LocalRoute;
booking: TeamEventTypeBookingToReroute;
form: ResponseWithForm["form"];
currentResponse: FormResponse;
teamMembersMatchingAttributeLogic: {
isPending: boolean;
data: TeamMemberMatchingAttributeLogic[] | null;
};
reroutingState: ReturnType<typeof useReroutingState>;
}) => {
const { t } = useLocale();
const router = useRouter();
const bookerUrl = useBookerUrl();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data?.map((member) => member.id) || null;
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const bookingEventType = booking.eventType;
// Provide a reason for rescheduling that can be customized by user in the future
// const rescheduleReason = "Rerouted";
const isRoundRobinScheduling = bookingEventType.schedulingType === SchedulingType.ROUND_ROBIN;
const reroutingFormResponses = Object.fromEntries(
Object.entries(currentResponse).map(([key, response]) => [key, { value: response.value }])
);
const chosenEventUrls = getEventTypeUrlsForTheChosenRoute({
chosenRoute,
form,
reroutingFormResponses,
teamMemberIdsMatchingAttributeLogic,
booking,
currentResponse,
searchParams: new URLSearchParams({
// rescheduleReason
}),
});
const chosenEventTypeId = chosenRoute.action.eventTypeId;
const enableChosenEventTypeQuery = !!chosenEventTypeId;
// TODO: Get bare minimum eventType details
const { data: chosenEventTypeData, isPending: isChosenEventTypePending } =
trpc.viewer.eventTypes.get.useQuery(
// enabled prop ensures that the query is not run if the chosenEventTypeId is not there
{ id: chosenEventTypeId! },
{
enabled: enableChosenEventTypeQuery,
}
);
// isPending of the query is true even if enabled=false and no request was sent. So, identify if the request is actually in progress
const isChosenEventTypeRequestInProgress = enableChosenEventTypeQuery ? isChosenEventTypePending : false;
const chosenEventType = chosenEventTypeData?.eventType;
const currentBookingEventFullSlug = getFullSlugForEvent(booking.eventType);
const isReroutingToDifferentEvent = currentBookingEventFullSlug !== chosenEventUrls?.eventFullSlug;
const createBookingMutation = useMutation({
mutationFn: createBooking,
onSuccess: (booking) => {
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
if (!booking.uid) {
console.error("Booking UID is not there");
throw new Error(t("something_went_wrong"));
}
const newBookingId = booking.uid;
reroutingState.setValue((prev) => {
if (!prev) return null;
return {
...prev,
newBooking: newBookingId,
};
});
gotoBookingPage({ router, booking: { uid: newBookingId } });
},
onError: (err, _, ctx) => {
reroutingState.setValue({
error: err,
newBooking: null,
type: "same_timeslot",
reschedulerWindow: null,
});
showToast(
"Looks like the timeslot is not available for the new team members. Try rescheduling with different timeslot",
"error"
);
},
});
useEventListeners({ reroutingState });
if (!chosenRoute) return null;
const isReroutingInNewTab =
reroutingState.value?.type === "reschedule_to_same_event_new_tab" ||
reroutingState.value?.type === "reschedule_to_different_event_new_tab";
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("new_routing_status")}</h2>
<div className="flex flex-col space-y-2">
{reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()}
{(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) &&
reroutingCTAs()}
{isReroutingInNewTab && newTabReroutingStatus()}
</div>
</div>
);
function rescheduleToSameTimeslotOfEvent({
eventType: { id: eventTypeId, slug: eventTypeSlug, team: eventTypeTeam, length: eventTypeLength },
}: {
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "id" | "slug" | "length"> & {
team: {
slug: string | null;
} | null;
};
}) {
if (!chosenRoute) {
console.error("Chosen route must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
if (!eventTypeTeam?.slug) {
console.error("Event type team slug must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
const teamSlug = eventTypeTeam.slug;
if (isBookingTimeslotInPast(booking)) {
showToast("You cannot reschedule to a past timeslot", "error");
return;
}
const booker = booking.attendees[0];
const getFieldsThatRemainSame = () => {
return {
responses: booking.responses,
timeZone: booker.timeZone,
language: booker.locale || "en",
metadata: booking.metadata || {},
...getBookingSeatFields(),
...getHashedLinkFields(),
};
function getBookingSeatFields() {
// During rerouting, do all the seats need to be re-routed?
// Let's not support re-routing for booking with seats for now.
return {
// bookingUid: booking.uid,
// seatReferenceUid: booking.eventType,
};
}
function getHashedLinkFields() {
// After booking hashedLink is deleted, so I don't believe we need to return anything here.
// Also, Rerouting is for a booking that was routed and routing doesn't involve hashed link even if it is enabled for the event.
return {};
}
};
const getFieldFromEventTypeThatRemainSame = () => {
return {
user: teamSlug,
eventTypeId: eventTypeId,
eventTypeSlug: eventTypeSlug,
};
};
const getTimeslotFields = () => {
return {
// It is possible that EvenType changed its duration, so we would consider that as well.
start: dayjs(booking.startTime).format(),
end: dayjs(booking.startTime)
// Defaults to the default event length in case no custom duration is set.
.add(eventTypeLength, "minute")
.format(),
};
};
const getRoutingFormRelatedFields = () => {
return {
routedTeamMemberIds: teamMemberIdsMatchingAttributeLogic,
routingFormResponseId: routedFromRoutingFormReponseId,
skipContactOwner: chosenRoute.attributeRoutingConfig?.skipContactOwner,
};
};
const getSalesforceContactOwnerFields = () => {
// Email field won't need to be corrected, so we don't need to support it.
return {};
};
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
createBookingMutation.mutate({
rescheduleUid: booking.uid,
// rescheduleReason,
reroutingFormResponses: reroutingFormResponses,
...getTimeslotFields(),
...getFieldsThatRemainSame(),
...getFieldFromEventTypeThatRemainSame(),
...getRoutingFormRelatedFields(),
...getSalesforceContactOwnerFields(),
});
reroutingState.setValue({
newBooking: null,
type: "same_timeslot",
reschedulerWindow: null,
});
}
function rescheduleToSameTimeslotOfSameEvent() {
return rescheduleToSameTimeslotOfEvent({
eventType: booking.eventType,
});
}
function rescheduleToSameTimeslotOfChosenEvent() {
if (!chosenEventTypeId && isReroutingToDifferentEvent) {
showToast(
"We don't have ID for the new event. Please go to the Routing Form and save it again.",
"error"
);
return;
}
if (!chosenEventType) {
// There are disabled/loading state on button to ensure that this function is not called if chosenEventType is not there
console.error("Chosen event type must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
return rescheduleToSameTimeslotOfEvent({
eventType: chosenEventType,
});
}
function rescheduleSameEventInNewTab() {
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
console.error("URL must be there for opening new tab");
throw new Error(t("something_went_wrong"));
}
const reschedulerWindow = rescheduleInNewTab({
url: chosenEventUrls.eventBookingAbsoluteUrl,
});
reroutingState.setValue({
type: "reschedule_to_same_event_new_tab",
reschedulerWindow,
newBooking: null,
});
}
function rescheduleDifferentEventInNewTab() {
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
console.error("URL must be there for opening new tab");
throw new Error(t("something_went_wrong"));
}
const reschedulerWindow = rescheduleInNewTab({
url: chosenEventUrls.eventBookingAbsoluteUrl,
});
console.log("SETTING NEW VALUE", {
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
reroutingState.setValue({
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
console.log("VALUE NOW", JSON.stringify(reroutingState));
}
function reroutingPreview() {
if (chosenRoute.action.type === RouteActionType.CustomPageMessage) {
return <span className="text-attention">{t("reroute_preview_custom_message")}</span>;
}
if (chosenRoute.action.type === RouteActionType.ExternalRedirectUrl) {
return (
<span className="text-attention">
{t("reroute_preview_external_redirect", { externalUrl: chosenRoute.action.value })}
</span>
);
}
if (chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl) {
// Computed value for eventTypeSlugToRedirect
const eventTypeSlugToRedirect =
chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl ? chosenRoute.action.value : null;
if (!eventTypeSlugToRedirect) {
console.error("Event type slug to redirect must be there if action is EventTypeRedirectUrl");
throw new Error(t("something_went_wrong"));
}
return (
<div className="flex flex-col space-y-2">
<span className="text-default" data-testid="reroute-preview-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
className="underline"
target="_blank"
href={`${bookerUrl}/${eventTypeSlugToRedirect}`}
rel="noreferrer"
data-testid="test-routing-result">
{eventTypeSlugToRedirect}
</a>
</span>
<span className="text-default" data-testid="reroute-preview-hosts">
<Tooltip content="Includes fixed hosts always and assumes that all the team members mentioned here are assigned to the event">
<span className="font-semibold">
{isRoundRobinScheduling ? t("reroute_preview_possible_host") : t("hosts")}:
</span>
</Tooltip>{" "}
{bookingHosts()}
</span>
</div>
);
}
function bookingHosts() {
if (teamMembersMatchingAttributeLogic.isPending) return t("loading");
const hostEmails = teamMembersMatchingAttributeLogic.data?.map((member) => member.email).join(", ");
return hostEmails || t("no_matching_members");
}
return null;
}
function reroutingCTAs() {
if(chosenRoute.action.type !== RouteActionType.EventTypeRedirectUrl) {
// There are no actions to perform if the new chosen route isn't an event type redirect
return null
}
const shouldDisableCTAs = teamMembersMatchingAttributeLogic.isPending || createBookingMutation.isPending;
return (
<div className="flex flex-col gap-2">
{!isReroutingToDifferentEvent ? (
<Button onClick={rescheduleSameEventInNewTab} disabled={shouldDisableCTAs}>
{t("reschedule_with_different_timeslot")}
</Button>
) : (
<Button onClick={rescheduleDifferentEventInNewTab} disabled={shouldDisableCTAs}>
{t("reschedule_to_the_new_event_with_different_timeslot")}
</Button>
)}
{!isReroutingToDifferentEvent ? (
<Button
disabled={shouldDisableCTAs}
loading={createBookingMutation.isPending}
onClick={rescheduleToSameTimeslotOfSameEvent}>
{t("reschedule_with_same_timeslot")}
</Button>
) : (
<Button
disabled={shouldDisableCTAs}
loading={createBookingMutation.isPending || isChosenEventTypeRequestInProgress}
onClick={rescheduleToSameTimeslotOfChosenEvent}>
{t("reschedule_with_same_timeslot_of_new_event")}
</Button>
)}
</div>
);
}
function newTabReroutingStatus() {
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_IN_PROGRESS) {
return null;
}
return (
<div>
<span className="text-attention">
<span>Continue with rerouting in the new</span>{" "}
<a
href="javascript:void(0)"
className="text-attention underline"
onClick={() => reroutingState.value?.reschedulerWindow?.focus()}>
tab
</a>
</span>
</div>
);
}
};
const CurrentRoutingStatus = ({
booking,
}: {
booking: Pick<TeamEventTypeBookingToReroute, "eventType" | "user" | "attendees">;
}) => {
const fullSlug = getFullSlugForEvent(booking.eventType);
const { t } = useLocale();
const bookerUrl = useBookerUrl();
if (!fullSlug) return null;
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("current_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<span className="text-default" data-testid="current-routing-status-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
className="underline"
target="_blank"
href={`${bookerUrl}/${fullSlug}`}
rel="noreferrer"
data-testid="test-routing-result">
{fullSlug}
</a>
</span>
<span className="text-default" data-testid="current-routing-status-organizer">
<span className="font-semibold">{t("organizer")}:</span> <span>{booking.user.email}</span>
</span>
<span className="text-default" data-testid="current-routing-status-attendees">
<span className="font-semibold">{t("attendees")}:</span>{" "}
<span>{booking.attendees.map((attendee) => attendee.email).join(", ")}</span>
</span>
</div>
</div>
);
};
const RerouteDialogContentAndFooter = ({
booking,
isOpenDialog,
setIsOpenDialog,
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
booking: TeamEventTypeBookingToReroute;
}) => {
const { data: responseWithForm, isPending: isRoutingFormLoading } =
trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const { t } = useLocale();
// useUpdateIsReroutingQueryParam({ isOpenDialog });
if (isRoutingFormLoading)
return (
<>
<FormInputFieldsSkeleton />
<DialogFooter>
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
{t("cancel")}
</Button>
<Button disabled data-testid="verify-new-route-button">
{t("verify_new_route")}
</Button>
</DialogFooter>
</>
);
if (!responseWithForm) return <div>{t("something_went_wrong")}</div>;
return (
<RerouteDialogContentAndFooterWithFormResponse
isOpenDialog={isOpenDialog}
setIsOpenDialog={setIsOpenDialog}
responseWithForm={responseWithForm}
booking={booking}
/>
);
};
const RerouteDialogContentAndFooterWithFormResponse = ({
responseWithForm,
booking,
isOpenDialog,
setIsOpenDialog,
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
responseWithForm: ResponseWithForm;
booking: TeamEventTypeBookingToReroute;
}) => {
const form = responseWithForm.form;
const { t } = useLocale();
const router = useRouter();
const [responseFromOrganizer, setResponseFromOrganizer] = useState<FormResponse>({});
const isResponseFromOrganizerUnpopulated = Object.keys(responseFromOrganizer).length === 0;
const currentResponse = isResponseFromOrganizerUnpopulated
? responseWithForm.response
: responseFromOrganizer;
const [chosenRoute, setChosenRoute] = useState<LocalRoute | null>(null);
const reroutingState = useReroutingState({
isOpenDialog,
});
const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState<
| {
id: number;
name: string | null;
email: string;
}[]
| null
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data);
},
});
function verifyRoute() {
// Reset all states
reroutingState.setValue(null);
setTeamMembersMatchingAttributeLogic(null);
const route = findMatchingRoute({
form,
response: currentResponse,
});
setChosenRoute(route || null);
if (!route) return;
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
routeId: route.id,
});
}
return (
<div>
<div>
<Link
className="text-emphasis text-semibold font-cal mb-4 flex underline"
href={`/apps/routing-forms/form-edit/${form.id}`}
target="_blank">
{form.name}
</Link>
<FormInputFields
response={currentResponse}
disabledFields={fieldsThatCannotBeCorrected}
form={form}
setResponse={setResponseFromOrganizer}
/>
<div className="flex flex-col gap-2">
<CurrentRoutingStatus booking={booking} />
{chosenRoute && (
<NewRoutingManager
chosenRoute={chosenRoute}
booking={booking}
teamMembersMatchingAttributeLogic={{
isPending: findTeamMembersMatchingAttributeLogicMutation.isPending,
data: teamMembersMatchingAttributeLogic,
}}
reroutingState={reroutingState}
form={form}
currentResponse={currentResponse}
/>
)}
</div>
</div>
{renderFooter()}
</div>
);
function renderFooter() {
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_COMPLETE) {
return (
<DialogFooter>
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
{t("cancel")}
</Button>
<Button
onClick={verifyRoute}
data-testid="verify-new-route-button"
disabled={
reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS ||
isResponseFromOrganizerUnpopulated
}>
{t("verify_new_route")}
</Button>
</DialogFooter>
);
}
if (reroutingState.status === ReroutingStatusEnum.REROUTING_COMPLETE) {
return (
<DialogFooter>
<Button
onClick={() => {
setIsOpenDialog(false);
}}>
{t("close")}
</Button>
</DialogFooter>
);
}
return null;
}
};
export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: RerouteDialogProps) => {
const { t } = useLocale();
const bookingEventType = booking.eventType;
if (!bookingEventType.team) {
console.error("Only team event can be re-routed");
throw new Error(t("something_went_wrong"));
}
if (!bookingEventType.team.slug) {
console.error("Team slug must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
if (!booking.user) {
// Not a hard requirement, but better to be strict with data.
console.error("Booking must have a user");
throw new Error(t("something_went_wrong"));
}
const teamEventTypeBooking = {
...booking,
user: booking.user,
eventType: {
...bookingEventType,
team: {
...bookingEventType.team,
slug: bookingEventType.team.slug,
},
},
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter
booking={teamEventTypeBooking}
isOpenDialog={isOpenDialog}
setIsOpenDialog={setIsOpenDialog}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,497 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { RerouteDialog } from "../RerouteDialog";
const mockRouter = {
push: vi.fn((path: string) => {
return;
}),
};
vi.mock("next/navigation", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/navigation")>();
return {
...actual,
useRouter: vi.fn(() => mockRouter),
};
});
vi.mock("@calcom/app-store/routing-forms/lib/processRoute", () => ({
findMatchingRoute: vi.fn(({ form, response }) => {
return form.routes.find((route: any) => route.__testMatching);
}),
}));
const fakeNewTabWindow = {
close: vi.fn(),
};
const mockOpen = vi.fn((_url: string) => {
return fakeNewTabWindow;
});
vi.stubGlobal("open", mockOpen);
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
return (
<div data-testid="mock-form-input-fields">
{form.fields?.map((field: any) => (
<div key={field.id}>
<label data-testid={`mock-form-field-${field.id}-label`}>{field.label}</label>
<div data-testid={`mock-form-field-${field.id}-value`}>{response[field.id]?.value}</div>
</div>
))}
<div data-testid="mock-form-field-disabled-fields-identifiers">{disabledFields.join(", ")}</div>
<button
data-testid="mock-form-update-response-button"
onClick={() =>
setResponse({
"company-size": {
label: "Company Size",
value: "small",
},
country: {
label: "Country",
value: "usa",
},
})
}>
Test Update Response
</button>
</div>
);
}),
FormInputFieldsSkeleton: vi.fn(() => <div data-testid="mock-form-input-fields-skeleton" />),
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: vi.fn(() => ({ t: (key: string) => key })),
}));
vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({
default: vi.fn(() => {
return {
setQuery: vi.fn(),
};
}),
}));
vi.mock("@calcom/ui", async (importOriginal) => {
// @ts-ignore
const actual = await importOriginal<any>("@calcom/ui");
return {
...actual,
Tooltip: vi.fn(({ children }) => <div data-testid="mock-tooltip">{children}</div>),
};
});
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
appRoutingForms: {
getResponseWithFormFields: {
useQuery: vi.fn(() => ({
data: {
form: {
id: "form-id",
name: "Test Form",
fields: [
{
id: "company-size",
label: "Company Size",
type: "select",
options: [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
],
},
{
id: "country",
label: "Country",
type: "select",
options: [
{ label: "USA", value: "usa" },
{ label: "Canada", value: "canada" },
{ label: "UK", value: "uk" },
],
},
{
id: "email",
label: "Email",
type: "email",
required: true,
},
],
routes: [
{
id: "mock-route-id",
action: {
eventTypeId: 123,
type: RouteActionType.EventTypeRedirectUrl,
value: "team/test-team/new-test-event",
},
__testMatching: true,
},
{
id: "fallback-route",
isFallback: true,
action: {
type: RouteActionType.CustomPageMessage,
value: "456",
},
__testMatching: false,
},
],
},
response: {
"company-size": {
label: "Company Size",
value: "small",
},
country: {
label: "Country",
value: "usa",
},
},
},
isPending: false,
})),
},
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess([
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
]);
}),
};
}),
},
},
eventTypes: {
get: {
useQuery: vi.fn(() => ({
data: {
eventType: {
id: "123",
title: "Mocked Event Type",
slug: "mocked-event-type",
length: 30,
schedulingType: "ROUND_ROBIN",
team: { slug: "mocked-team" },
},
},
isPending: false,
})),
},
},
},
},
}));
let mockMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
});
vi.mock("@tanstack/react-query", () => ({
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn((payload) => {
mockMutateFn({
...payload,
__testOnSuccess: onSuccess,
});
}),
isLoading: false,
isError: false,
error: null,
onSuccess: onSuccess,
};
}),
}));
async function mockMessageFromOpenedTab({ type, data }: { type: string; data: any }) {
const messageReceivedPromise = new Promise<boolean>((resolve) => {
window.addEventListener("message", () => {
resolve(true);
});
});
window.postMessage(
{
type,
data,
},
"*"
);
return messageReceivedPromise;
}
function correctResponses() {
fireEvent.click(screen.getByTestId("mock-form-update-response-button"));
}
function expectEventTypeInfoInCurrentRouting({
eventTypeText,
eventTypeHref,
}: {
eventTypeText: string;
eventTypeHref: string;
}) {
const eventTypeEl = screen.getByTestId("current-routing-status-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
function expectEventTypeInfoInReroutePreview({
eventTypeText,
eventTypeHref,
}: {
eventTypeText: string;
eventTypeHref: string;
}) {
const eventTypeEl = screen.getByTestId("reroute-preview-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
function expectOrganizerInfoInCurrentRouting({ organizerText }: { organizerText: string }) {
const organizerEl = screen.getByTestId("current-routing-status-organizer");
expect(organizerEl).toHaveTextContent(organizerText);
}
function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText: string }) {
const attendeesEl = screen.getByTestId("current-routing-status-attendees");
expect(attendeesEl).toHaveTextContent(attendeesText);
}
const mockBooking = {
id: 1,
uid: "original-booking-uid",
title: "Test Booking",
startTime: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString(),
metadata: {},
responses: {},
attendees: [{ email: "attendee@example.com", name: "Attendee", timeZone: "UTC", locale: "en" }],
eventType: {
id: 1,
slug: "test-event",
team: { slug: "test-team" },
length: 60,
schedulingType: SchedulingType.ROUND_ROBIN,
title: "Test Event",
},
user: { id: 1, name: "Test User", email: "user@example.com" },
routedFromRoutingFormReponse: { id: 1 },
status: BookingStatus.ACCEPTED, // Add this line
};
const buildBooking = () => {
return {
...mockBooking,
};
};
describe("RerouteDialog", () => {
const mockSetIsOpenDialog = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("renders the dialog when open", () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("reroute_booking")).toBeInTheDocument();
expect(screen.getByText("reroute_booking_description")).toBeInTheDocument();
});
test("doesn't render the dialog when closed", () => {
render(
<RerouteDialog isOpenDialog={false} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
expect(screen.queryByText("reroute_booking")).not.toBeInTheDocument();
});
test("displays current routing status", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("current_routing_status")).toBeInTheDocument();
expectEventTypeInfoInCurrentRouting({
eventTypeText: "team/test-team/test-event",
eventTypeHref: "https://cal.com/team/test-team/test-event",
});
expectOrganizerInfoInCurrentRouting({
organizerText: "user@example.com",
});
expectAttendeesInfoInCurrentRouting({
attendeesText: "attendee@example.com",
});
// screen.logTestingPlaygroundURL()
});
test("verify_new_route button is disabled when form fields are not filled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("verify_new_route")).toBeDisabled();
correctResponses();
expect(screen.getByText("verify_new_route")).toBeEnabled();
});
test("disabledFields are passed to FormInputFields with value ['email'] - email field is disabled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByTestId("mock-form-field-disabled-fields-identifiers")).toHaveTextContent(/^email$/);
});
test("Expect form fields and name to be rendered", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("Test Form")).toBeInTheDocument();
expect(screen.getByTestId("mock-form-field-company-size-label")).toHaveTextContent("Company Size");
expect(screen.getByTestId("mock-form-field-company-size-value")).toHaveTextContent("small");
expect(screen.getByTestId("mock-form-field-country-label")).toHaveTextContent("Country");
expect(screen.getByTestId("mock-form-field-country-value")).toHaveTextContent("usa");
});
test("cancel button closes the dialog", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("cancel")).toBeInTheDocument();
fireEvent.click(screen.getByText("cancel"));
expect(mockSetIsOpenDialog).toHaveBeenCalledWith(false);
});
describe("New Routing tests", () => {
test("when verify_new_route is clicked, the form is submitted", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
fireEvent.click(screen.getByText("verify_new_route"));
expectEventTypeInfoInReroutePreview({
eventTypeText: "team/test-team/new-test-event",
eventTypeHref: "https://cal.com/team/test-team/new-test-event",
});
expect(screen.getByText("verify_new_route")).toBeEnabled();
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("reroute_preview_possible_host");
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("matching-user-1@example.com");
expect(screen.getByText("reschedule_to_the_new_event_with_different_timeslot")).toBeInTheDocument();
expect(screen.getByText("reschedule_with_same_timeslot_of_new_event")).toBeInTheDocument();
});
describe("New tab rescheduling", () => {
test("new tab is closed when new booking is rerouted", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
await mockMessageFromOpenedTab({
type: "CAL:rescheduleBookingSuccessfulV2",
data: {
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
},
});
const rescheduleTabUrl = mockOpen.mock.calls[0][0] as unknown as string;
const rescheduleTabUrlObject = new URL(rescheduleTabUrl, "http://mockcal.com");
expect(Object.fromEntries(rescheduleTabUrlObject.searchParams.entries())).toEqual(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
"cal.rerouting": "true",
"cal.reroutingFormResponses": JSON.stringify({
"company-size": {
value: "small",
},
country: {
value: "usa",
},
}),
})
);
expect(fakeNewTabWindow.close).toHaveBeenCalled();
expectToBeNavigatedToBookingPage({
booking: {
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
},
});
});
test("Rescheduling with same timeslot works", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
reroutingFormResponses: {
"company-size": {
value: "small",
},
country: {
value: "usa",
},
},
})
);
expectToBeNavigatedToBookingPage({
booking: {
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
},
});
});
});
});
});
function clickVerifyNewRouteButton() {
act(() => {
fireEvent.click(screen.getByText("verify_new_route"));
});
}
function clickRescheduleToTheNewEventWithDifferentTimeslotButton() {
act(() => {
fireEvent.click(screen.getByText("reschedule_to_the_new_event_with_different_timeslot"));
});
}
function clickRescheduleWithSameTimeslotOfChosenEventButton() {
act(() => {
fireEvent.click(screen.getByText("reschedule_with_same_timeslot_of_new_event"));
});
}
function expectToBeNavigatedToBookingPage({
booking,
}: {
booking: {
uid: string;
};
}) {
expect(mockRouter.push).toHaveBeenCalledWith(`/booking/${booking.uid}?cal.rerouting=true`);
}
@@ -78,7 +78,6 @@ export default function Bookings() {
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 10,
@@ -88,7 +87,6 @@ export default function Bookings() {
},
},
{
// first render has status `undefined`
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
@@ -365,12 +365,16 @@ export default function Success(props: PageProps) {
const isNotAttendingSeatedEvent = isCancelled && seatReferenceUid;
const isEventCancelled = isCancelled && !seatReferenceUid;
const isPastBooking = isBookingInPast;
const isRerouting = searchParams?.get("cal.rerouting") === "true";
const successPageHeadline = (() => {
if (needsConfirmationAndReschedulable) {
return isRecurringBooking ? t("booking_submitted_recurring") : t("booking_submitted");
}
if (isRerouting) {
return t("This meeting has been rerouted");
}
if (isNotAttendingSeatedEvent) {
return t("no_longer_attending");
}
@@ -476,6 +480,7 @@ export default function Success(props: PageProps) {
id="modal-headline">
{successPageHeadline}
</h3>
<div className="mt-3">
<p className="text-default">{getTitle()}</p>
</div>
@@ -691,6 +696,7 @@ export default function Success(props: PageProps) {
{!requiresLoginToUpdate &&
(!needsConfirmation || !userIsOwner) &&
isReschedulable &&
!isRerouting &&
(!isCancellationMode ? (
<>
<hr className="border-subtle mb-8" />
@@ -750,6 +756,18 @@ export default function Success(props: PageProps) {
/>
</>
))}
{isRerouting && typeof window !== "undefined" && window.opener && (
<div className="flex justify-center">
<Button
type="button"
onClick={() => {
window.opener.focus();
window.close();
}}>
Go Back
</Button>
</div>
)}
{userIsOwner &&
!needsConfirmation &&
!isCancellationMode &&
Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 221 KiB

+9
View File
@@ -659,6 +659,15 @@
<path d="m16 13 5.223 3.482a.5.5 0 0 0 .777-.416V7.87a.5.5 0 0 0-.752-.432L16 10.5" ></path>
<rect x="2" y="6" width="14" height="12" rx="2" ></rect>
</symbol>
<symbol class="lucide lucide-waypoints" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="waypoints">
<circle cx="12" cy="4.5" r="2.5" ></circle>
<path d="m10.2 6.3-3.9 3.9" ></path>
<circle cx="4.5" cy="12" r="2.5" ></circle>
<path d="M7 12h10" ></path>
<circle cx="19.5" cy="12" r="2.5" ></circle>
<path d="m13.8 17.7 3.9-3.9" ></path>
<circle cx="12" cy="19.5" r="2.5" ></circle>
</symbol>
<symbol class="lucide lucide-webhook" viewBox="0 0 24 24" fill="inherit" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" id="webhook">
<path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2" ></path>
<path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06" ></path>

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

@@ -2655,11 +2655,26 @@
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
"mass_assign_attributes": "Mass assign attributes",
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
"reroute_preview_possible_host": "Possible host",
"current_routing_status": "Current routing status",
"reroute_booking": "Reroute booking",
"reroute_booking_description": "Reroute the booking to different team members",
"verify_new_route": "Verify new route",
"reroute": "Reroute",
"new_routing_status": "New routing status",
"salesforce_book_directly_with_attendee_owner": "If attendee exists in Salesforce, book directly with the owner",
"salesforce_check_owner_of": "Entity to check owner of to book directly",
"account": "Account",
"choose_hosts_schedule": "Choose host schedules",
"hosts_schedule_description": "You can select a different schedule for each host. Each host's default schedule is chosen by default.",
"no_hosts_description": "No hosts assigned, add hosts in assignment tab.",
"reschedule_with_same_timeslot": "Reschedule with same timeslot",
"reschedule_with_same_timeslot_of_new_event": "Reschedule with same timeslot of the new event",
"reschedule_to_the_new_event_with_different_timeslot": "Reschedule to the new event with different timeslot",
"reschedule_with_different_timeslot": "Reschedule with different timeslot",
"we_dont_have_id_for_the_new_event": "We don't have ID for the new event. Please go to the Routing Form and save it again.",
"rerouted_booking_successfully_redirecting_to_booking_page": "Rerouted booking successfully. Redirecting to booking page",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -76,6 +76,16 @@ export function handleEvent(event: { detail: Record<string, unknown> & { type: s
});
});
// Support sending all events to opener which is currently used by ReroutingDialog to identify if the booking is successfully rescheduled.
if (window.opener) {
window.opener.postMessage(
{
type: `CAL:${name}`,
...data,
},
"*"
);
}
return true;
}
+35 -11
View File
@@ -1,3 +1,8 @@
## Ready for Review Requirements
- Authorization check for fetch form response and form
## TODO - Routing
- [x] Use teamMemberIds to calculate availabilithy as well in addition to doing it in handleNewBooking
- [x] Don't let the Routing Form with attributesQueryValue be picked
@@ -7,13 +12,32 @@
- [x] Fix bug with Multiselect attribute
- [x] Connect Form Response to Booking
- [x] Test with non option based attributes and form field input
- [x] We might want to disable attributes query builder for non-orgs. Same for Re-routing.
- [ ] Switching from Select to MultiSelect in attribute
- [ ] It immediately makes the Routing logic to not work. Fix it.
- [ ] It removes the selected option from Routing. Fix it.
- [ ] Warnings are needed with recommendation to create new attribute instead in unsupported scenarios. We need to test for more such changes to Attribute types.
- [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option.
- So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute.
- [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there.
- [ ] Test Seated events as routing targets.
- Improvements
- [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option.
- So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute.
- [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there.
- [ ] When routing to a team that doesn't have the team members assigned that are chosen, we are unable to select those users. FIX it by ignoring the assigned members(except the fixed hosts) and just using the attributes matched team members.
- [x] Start storing eventTypeId in routing Action.
- [x] Make sure that without making the FORM dirty, user can't reroute
- [x] When 'Verify Route' is clicked, it doesn't seem to run the form field logic as there is a warning in console. Fix it.
- [x] Give a proper icon to Reroute button
- [ ] Fetch bookings on window focus but not when rerouting dialog is open. As soon as it is closed, we need to refetch again.
- [ ] Seated Events
- [ ] Disable routing for seated events. Disable only during creation.
- [ ] When rescheduling to a new event, the booking title remains same which will most probably not match the event-type title. This is the behaviour of reschedule. Should we use the new event-type title?
- [ ] What params should we forward to the booking page that we show after rerouting
- [ ] On completing the rerouting just show the new Routing status
- [ ] We need to disallow changing the email identifier field as during a reschedule user isn't allowed to change their email currently. Allowing to change that email response would mean that we support changing email during reschedule which might be disabled for some reasons unknown yet.
- [ ] What if there are changes to Form before rerouting but after initial routing.
- [ ] Not able to prefill rescheduleReason due to a bug in useInitialFormValues hook
- [ ] It is becoming a requirement to have BookingAttempt record to allow to store reroutingFormResponses as well as teamMemberIds in it. Other query params could also be stored later on when needed. It is important to reduce the payload of the query params due to routedTeamMemberIds and reroutingFormResponses
### Performance Tests and Improvements
- [ ] Test with ~1000 team members and ~100 attributes.
@@ -34,22 +58,22 @@
- [x] getRoutedUsers tests
- [ ] getAvailableSlots
- [ ] should use routedTeamMemberIds in availability query
- [ ] RerouteDialog
- [x] BASIC
- [ ] New Tab rescheduling
### 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.
- [ ] When re-routed, the booking page doesn't allow rescheduling/cancellation to avoid any possible issues. We can open it up later.
- [ ] Booking with seats not supported yet. Need to figure out which seats should be re-routed or if re-routing makes sense for it even?
- [ ] Hashed link re-routing doesn't make sense with. With Routing you route to an event from form and it doesn't involve hashed link even if it is enabled
- [ ] User needs to ensure for now that the team event they redirect to has 'Assign all and future members of the team' checked.
### 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
- [ ] Attributes configuration
1. through DB initially for POC
2. through CSV upload
3. through Salesforce Connect
- [ ] 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.
@@ -1,6 +1,8 @@
import type { App_RoutingForms_Form } from "@prisma/client";
import type { Dispatch, SetStateAction } from "react";
import { SkeletonText } from "@calcom/ui";
import getFieldIdentifier from "../lib/getFieldIdentifier";
import { getQueryBuilderConfigForFormFields } from "../lib/getQueryBuilderConfig";
import isRouterLinkedField from "../lib/isRouterLinkedField";
@@ -8,14 +10,21 @@ import { getUIOptionsForSelect } from "../lib/selectOptions";
import { getFieldResponseForJsonLogic } from "../lib/transformResponse";
import type { SerializableForm, FormResponse } from "../types/types";
type Props = {
form: SerializableForm<App_RoutingForms_Form>;
export type FormInputFieldsProps = {
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
/**
* Make sure that response is updated by setResponse
*/
response: FormResponse;
setResponse: Dispatch<SetStateAction<FormResponse>>;
/**
* Identifier of the fields that should be disabled
*/
disabledFields?: string[];
};
export default function FormInputFields(props: Props) {
const { form, response, setResponse } = props;
export default function FormInputFields(props: FormInputFieldsProps) {
const { form, response, setResponse, disabledFields = [] } = props;
const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(form);
@@ -33,6 +42,7 @@ export default function FormInputFields(props: Props) {
const Component = widget.factory;
const options = getUIOptionsForSelect(field);
const fieldIdentifier = getFieldIdentifier(field);
return (
<div key={field.id} className="mb-4 block flex-col sm:flex ">
<div className="min-w-48 mb-2 flex-grow">
@@ -48,10 +58,10 @@ export default function FormInputFields(props: Props) {
/* @ts-ignore */
required={!!field.required}
listValues={options}
data-testid={`form-field-${getFieldIdentifier(field)}`}
disabled={disabledFields?.includes(fieldIdentifier)}
data-testid={`form-field-${fieldIdentifier}`}
setValue={(value: number | string | string[]) => {
setResponse((response) => {
response = response || {};
setResponse(() => {
return {
...response,
[field.id]: {
@@ -68,3 +78,17 @@ export default function FormInputFields(props: Props) {
</>
);
}
export const FormInputFieldsSkeleton = () => {
const numberOfFields = 5;
return (
<>
{Array.from({ length: numberOfFields }).map((_, index) => (
<div key={index} className="mb-4 block flex-col sm:flex ">
<SkeletonText className="mb-2 h-3.5 w-64" />
<SkeletonText className="mb-2 h-9 w-32 w-full" />
</div>
))}
</>
);
};
@@ -1,16 +1,17 @@
import type { LocalRoute } from "../types/types";
import { RouteActionType } from "../zod";
export const RoutingPages: { label: string; value: NonNullable<LocalRoute["action"]>["type"] }[] = [
{
label: "Custom Page",
value: "customPageMessage",
value: RouteActionType.CustomPageMessage,
},
{
label: "External Redirect",
value: "externalRedirectUrl",
value: RouteActionType.ExternalRedirectUrl,
},
{
label: "Event Redirect",
value: "eventTypeRedirectUrl",
value: RouteActionType.EventTypeRedirectUrl,
},
];
@@ -1,6 +1,7 @@
import { Utils as QbUtils } from "react-awesome-query-builder";
import type { GlobalRoute, LocalRoute, SerializableRoute } from "../types/types";
import { RouteActionType } from "../zod";
export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
const uuid = QbUtils.uuid();
@@ -8,7 +9,7 @@ export const createFallbackRoute = (): Exclude<SerializableRoute, GlobalRoute> =
id: uuid,
isFallback: true,
action: {
type: "customPageMessage",
type: RouteActionType.CustomPageMessage,
value: "Thank you for your interest! We will be in touch soon.",
},
queryValue: { id: uuid, type: "group" } as LocalRoute["queryValue"],
@@ -14,7 +14,7 @@ export function findMatchingRoute({
form,
response,
}: {
form: SerializableForm<App_RoutingForms_Form>;
form: Pick<SerializableForm<App_RoutingForms_Form>, "routes" | "fields">;
response: Record<string, Pick<FormResponse[string], "value">>;
}) {
const queryBuilderConfig = getQueryBuilderConfigForFormFields(form);
@@ -42,6 +42,7 @@ import {
import isRouter from "../../lib/isRouter";
import type { SerializableForm } from "../../types/types";
import type { GlobalRoute, LocalRoute, SerializableRoute, Attribute } from "../../types/types";
import { RouteActionType } from "../../zod";
type FormFieldsQueryBuilderState = {
tree: ImmutableTree;
@@ -61,6 +62,7 @@ type LocalRouteWithRaqbStates = LocalRoute & {
type Form = inferSSRProps<typeof getServerSideProps>["form"];
type Route = LocalRouteWithRaqbStates | GlobalRoute;
type SetRoute = (id: string, route: Partial<Route>) => void;
const RoundRobinContactOwnerOverrideSwitch = ({
route,
@@ -93,6 +95,42 @@ type AttributesQueryValue = NonNullable<LocalRoute["attributesQueryValue"]>;
type FormFieldsQueryValue = LocalRoute["queryValue"];
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
/**
* We need eventTypeId in every redirect url action now for Rerouting to work smoothly.
* This hook ensures that it is there as soon as someone lands on a Routing Form and next save would automatically update it for them.
*/
function useEnsureEventTypeIdInRedirectUrlAction({
route,
eventOptions,
setRoute,
}: {
route: Route;
eventOptions: { label: string; value: string; eventTypeId: number }[];
setRoute: SetRoute;
}) {
useEffect(() => {
if (isRouter(route)) {
return;
}
if (
route.action.type !== RouteActionType.EventTypeRedirectUrl ||
// Must not be set already. Could be zero as well for custom
route.action.eventTypeId !== undefined
) {
return;
}
const matchingOption = eventOptions.find((eventOption) => eventOption.value === route.action.value);
if (!matchingOption) {
return;
}
setRoute(route.id, {
action: { ...route.action, eventTypeId: matchingOption.eventTypeId },
});
}, [eventOptions, setRoute, route.id, (route as unknown as any).action?.value]);
}
const hasRules = (route: Route) => {
if (isRouter(route)) return false;
route.queryValue.children1 && Object.keys(route.queryValue.children1).length;
@@ -106,7 +144,7 @@ const getEmptyRoute = (): Exclude<SerializableRoute, GlobalRoute> => {
return {
id: uuid,
action: {
type: "eventTypeRedirectUrl",
type: RouteActionType.EventTypeRedirectUrl,
value: "",
},
// It is actually formFieldsQueryValue
@@ -184,7 +222,7 @@ const Route = ({
form: Form;
route: Route;
routes: Route[];
setRoute: (id: string, route: Partial<Route>) => void;
setRoute: SetRoute;
setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial<AttributeRoutingConfig>) => void;
formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields;
attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null;
@@ -221,6 +259,12 @@ const Route = ({
}
}, [isLoading]);
useEnsureEventTypeIdInRedirectUrlAction({
route,
eventOptions,
setRoute,
});
const onChangeFormFieldsQuery = (
route: Route,
immutableTree: ImmutableTree,
@@ -336,7 +380,7 @@ const Route = ({
) : null;
const attributesQueryBuilder =
route.action?.type === "eventTypeRedirectUrl" && isTeamForm ? (
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)
@@ -453,11 +497,13 @@ const Route = ({
}
if (option.value !== "custom") {
setRoute(route.id, {
action: { ...route.action, value: option.value },
action: { ...route.action, value: option.value, eventTypeId: option.eventTypeId },
});
setCustomEventTypeSlug("");
} else {
setRoute(route.id, { action: { ...route.action, value: "custom" } });
setRoute(route.id, {
action: { ...route.action, value: "custom", eventTypeId: 0 },
});
setCustomEventTypeSlug("");
}
}}
@@ -1,19 +1,13 @@
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import getFieldIdentifier from "../../lib/getFieldIdentifier";
import type { LocalRoute } from "../../types/types";
import type { FormResponse, LocalRoute } from "../../types/types";
import type { getServerSideProps } from "./getServerSideProps";
type FormResponseValueOnly = { [key: string]: { value: FormResponse[keyof FormResponse]["value"] } };
type Props = inferSSRProps<typeof getServerSideProps>;
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
export function getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
formResponseId,
attributeRoutingConfig,
}: {
type GetUrlSearchParamsToForwardOptions = {
formResponse: Record<
string,
{
@@ -28,7 +22,18 @@ export function getUrlSearchParamsToForward({
formResponseId: number;
teamMembersMatchingAttributeLogic: number[] | null;
attributeRoutingConfig: AttributeRoutingConfig | null;
}) {
reroutingFormResponses?: FormResponseValueOnly;
};
export function getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
formResponseId,
attributeRoutingConfig,
reroutingFormResponses,
}: GetUrlSearchParamsToForwardOptions) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
@@ -84,6 +89,9 @@ export function getUrlSearchParamsToForward({
: null),
["cal.routingFormResponseId"]: String(formResponseId),
...(attributeRoutingConfig?.skipContactOwner ? { ["cal.skipContactOwner"]: "true" } : {}),
...(reroutingFormResponses
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
: null),
};
const allQueryURLSearchParams = new URLSearchParams();
@@ -98,3 +106,29 @@ export function getUrlSearchParamsToForward({
return allQueryURLSearchParams;
}
export function getUrlSearchParamsToForwardForReroute({
formResponse,
formResponseId,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
attributeRoutingConfig,
rescheduleUid,
reroutingFormResponses,
}: GetUrlSearchParamsToForwardOptions & {
rescheduleUid: string;
reroutingFormResponses: FormResponseValueOnly;
}) {
searchParams.set("rescheduleUid", rescheduleUid);
searchParams.set("cal.rerouting", "true");
return getUrlSearchParamsToForward({
formResponse,
formResponseId,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
attributeRoutingConfig,
reroutingFormResponses,
});
}
@@ -2,6 +2,7 @@ import type { BaseWidget } from "react-awesome-query-builder";
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { AttributeType } from "@calcom/prisma/enums";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { RoutingFormFieldType } from "../../lib/FieldTypes";
import { RaqbLogicResult } from "../../lib/evaluateRaqbLogic";
@@ -141,7 +142,7 @@ function buildRoute({
}: {
id: string;
action: {
type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl";
type: RouteActionType;
value: string;
};
queryValue: FormFieldsQueryValue;
@@ -164,7 +165,7 @@ function buildDefaultCustomPageRoute({
}) {
return buildRoute({
id,
action: { type: "customPageMessage", value: "test" },
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
attributesQueryValue,
});
@@ -193,7 +194,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
{
id: "test-route",
queryValue: { type: "group" } as unknown as FormFieldsQueryValue,
action: { type: "customPageMessage", value: "test" },
action: { type: RouteActionType.CustomPageMessage, value: "test" },
},
],
fields: [],
@@ -238,7 +239,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => {
routes: [
{
id: "test-route",
action: { type: "customPageMessage", value: "test" },
action: { type: RouteActionType.CustomPageMessage, value: "test" },
queryValue: {
type: "group",
} as unknown as FormFieldsQueryValue,
@@ -1,3 +1,5 @@
import { z } from "zod";
import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
@@ -45,6 +47,12 @@ const getHandler = async <
return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"];
};
export const ZFormByResponseIdInputSchema = z.object({
formResponseId: z.number(),
});
export type TFormQueryInputSchema = z.infer<typeof ZFormQueryInputSchema>;
const appRoutingForms = router({
public: router({
response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => {
@@ -57,6 +65,15 @@ const appRoutingForms = router({
const handler = await getHandler("formQuery", () => import("./formQuery.handler"));
return handler({ ctx, input });
}),
getResponseWithFormFields: authedProcedure
.input(ZFormByResponseIdInputSchema)
.query(async ({ ctx, input }) => {
const handler = await getHandler(
"getResponseWithFormFields",
() => import("./getResponseWithFormFields.handler")
);
return handler({ ctx, input });
}),
formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => {
const handler = await getHandler("formMutation", () => import("./formMutation.handler"));
return handler({ ctx, input });
@@ -0,0 +1,93 @@
import type { z } from "zod";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData";
import { getSerializableForm } from "../lib/getSerializableForm";
import type { FormResponse } from "../types/types";
import type { ZFormByResponseIdInputSchema } from "./_router";
import { canEditEntity } from "@calcom/lib/entityPermissionUtils";
type GetResponseWithFormFieldsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: z.infer<typeof ZFormByResponseIdInputSchema>;
};
async function getResponseWithFormFieldsHandler({ ctx, input }: GetResponseWithFormFieldsOptions) {
const { user } = ctx;
const { formResponseId } = input;
const formResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
where: {
id: formResponseId,
},
include: {
form: {
include: {
user: {
select: {
id: true,
movedToProfileId: true,
organization: {
select: {
slug: true,
},
},
username: true,
theme: true,
brandColor: true,
darkBrandColor: true,
metadata: true,
},
},
team: {
select: {
members: true,
slug: true,
parent: {
select: { slug: true },
},
parentId: true,
metadata: true,
},
},
},
},
},
});
if (!formResponse) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form response not found",
});
}
const form = formResponse.form;
if (!canEditEntity(form, user.id)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this form",
});
}
const { UserRepository } = await import("@calcom/lib/server/repository/user");
const formWithUserProfile = {
...form,
user: await UserRepository.enrichUserWithItsProfile({ user: form.user }),
};
return {
response: formResponse.response as FormResponse,
form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }),
};
}
export default getResponseWithFormFieldsHandler;
@@ -36,7 +36,7 @@ function compatibleForAttributeAndFormFieldMatch<T extends string | string[]>(
) as T extends string[] ? string[] : string;
}
const raqbQueryValueUtils = {
export const raqbQueryValueUtils = {
isQueryValueARuleGroup: function isQueryValueARuleGroup(queryValue: JsonTree): queryValue is JsonGroup {
return queryValue.type === "group";
},
@@ -77,6 +77,12 @@ const raqbQueryValueUtils = {
return raqbFieldValueType;
},
isQueryValueEmpty: function isQueryValueEmpty(queryValue: JsonTree | null): queryValue is null {
if (!queryValue) {
return true;
}
return !queryValue.children1;
},
};
/**
@@ -19,7 +19,7 @@ import isRouter from "../lib/isRouter";
import jsonLogic from "../lib/jsonLogic";
import type { SerializableField, OrderedResponses, AttributesQueryValue } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { acrossQueryValueCompatiblity } from "./raqbUtils";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
const {
getAttributesData: getAttributes,
@@ -201,7 +201,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute(
})
);
if (!attributesQueryValue) {
if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) {
return {
teamMembersMatchingAttributeLogic: null,
timeTaken: {
+17 -6
View File
@@ -55,6 +55,13 @@ const queryValueSchema = z.object({
properties: z.any(),
});
export enum RouteActionType {
CustomPageMessage = "customPageMessage",
ExternalRedirectUrl = "externalRedirectUrl",
EventTypeRedirectUrl = "eventTypeRedirectUrl",
}
export const routeActionTypeSchema = z.nativeEnum(RouteActionType);
/**
* Stricter schema for validating before saving to DB
*/
@@ -132,12 +139,8 @@ export const zodNonRouterRoute = z.object({
attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(),
isFallback: z.boolean().optional(),
action: z.object({
// TODO: Make it a union type of "customPageMessage" and ..
type: z.union([
z.literal("customPageMessage"),
z.literal("externalRedirectUrl"),
z.literal("eventTypeRedirectUrl"),
]),
type: routeActionTypeSchema,
eventTypeId: z.number().optional(),
value: z.string(),
}),
});
@@ -170,3 +173,11 @@ export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional
export const appDataSchema = z.any();
export const appKeysSchema = z.object({});
// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted
export const routingFormResponseInDbSchema = z.record(
z.object({
label: z.string().optional(),
value: z.union([z.string(), z.number(), z.array(z.string())]),
})
);
@@ -47,7 +47,7 @@ export const mapBookingToMutationInput = ({
const routingFormResponseIdParam = searchParams.get("cal.routingFormResponseId");
const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined;
const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true";
const reroutingFormResponses = searchParams.get("cal.reroutingFormResponses");
return {
...values,
user: username,
@@ -72,6 +72,8 @@ export const mapBookingToMutationInput = ({
routedTeamMemberIds,
routingFormResponseId,
skipContactOwner,
// In case of rerouting, the form responses are actually the responses that we need to update.
reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined,
};
};
@@ -235,6 +235,8 @@ async function handler(
rescheduleReason,
luckyUsers,
routedTeamMemberIds,
reroutingFormResponses,
routingFormResponseId,
...reqBody
} = bookingData;
@@ -918,24 +920,12 @@ async function handler(
})
);
// update original rescheduled booking (no seats event)
if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
await prisma.booking.update({
where: {
id: originalRescheduledBooking.id,
},
data: {
rescheduled: true,
status: BookingStatus.CANCELLED,
rescheduledBy: reqBody.rescheduledBy,
},
});
}
try {
booking = await createBooking({
uid,
routedFromRoutingFormResponseId: reqBody.routingFormResponseId,
rescheduledBy: reqBody.rescheduledBy,
routingFormResponseId: routingFormResponseId,
reroutingFormResponses: reroutingFormResponses ?? null,
reqBody: {
user: reqBody.user,
metadata: reqBody.metadata,
@@ -1,6 +1,8 @@
import { Prisma } from "@prisma/client";
import type short from "short-uuid";
import type { z } from "zod";
import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { isPrismaObjOrUndefined } from "@calcom/lib";
import prisma from "@calcom/prisma";
@@ -21,7 +23,9 @@ type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
type CreateBookingParams = {
uid: short.SUUID;
routedFromRoutingFormResponseId: number | undefined;
routingFormResponseId: number | undefined;
reroutingFormResponses: z.infer<typeof routingFormResponseInDbSchema> | null;
rescheduledBy: string | undefined;
reqBody: {
user: ReqBodyWithEnd["user"];
metadata: ReqBodyWithEnd["metadata"];
@@ -78,21 +82,20 @@ export async function createBooking({
input,
evt,
originalRescheduledBooking,
routedFromRoutingFormResponseId,
}: CreateBookingParams) {
routingFormResponseId,
reroutingFormResponses,
rescheduledBy,
}: CreateBookingParams & { rescheduledBy: string | undefined }) {
updateEventDetails(evt, originalRescheduledBooking, input.changedOrganizer);
const associatedBookingForFormResponse = routedFromRoutingFormResponseId
? await getAssociatedBookingForFormResponse(routedFromRoutingFormResponseId)
const associatedBookingForFormResponse = routingFormResponseId
? await getAssociatedBookingForFormResponse(routingFormResponseId)
: null;
const newBookingData = buildNewBookingData({
const bookingAndAssociatedData = buildNewBookingData({
uid,
// We allow only the first booking to be connected to the form response
// Other bookings could happen due to user doing a booking by using browser back button to reach the same booking form with same query params and changing the time
// Such case isn't what the Routing Form redirected the user to, so we avoid this at the moment.
routedFromRoutingFormResponseId: associatedBookingForFormResponse
? undefined
: routedFromRoutingFormResponseId,
rescheduledBy,
routingFormResponseId: shouldConnectBookingToFormResponse() ? routingFormResponseId : undefined,
reroutingFormResponses,
reqBody,
eventType,
input,
@@ -101,19 +104,38 @@ export async function createBooking({
});
return await saveBooking(
newBookingData,
bookingAndAssociatedData,
originalRescheduledBooking,
eventType.paymentAppData,
eventType.organizerUser
);
function shouldConnectBookingToFormResponse() {
const isRerouting = !!reroutingFormResponses;
// During rerouting, we want to connect the new booking to the existing form response for booking being rescheduled(original booking)
if (isRerouting) {
return true;
}
// If not rerouting and there is already an associated booking for the form response, we don't want to connect the new booking to the form response
// We allow only the first booking to be connected to the form response
// Other bookings could happen due to user doing a booking by using browser back button to reach the same booking form with same query params and changing the time
// Such case isn't what the Routing Form redirected the user to, so we avoid this at the moment.
if (associatedBookingForFormResponse) {
return false;
}
return true;
}
}
async function saveBooking(
newBookingData: Prisma.BookingCreateInput,
bookingAndAssociatedData: ReturnType<typeof buildNewBookingData>,
originalRescheduledBooking: OriginalRescheduledBooking,
paymentAppData: PaymentAppData,
organizerUser: CreateBookingParams["eventType"]["organizerUser"]
) {
const { newBookingData, reroutingFormResponseUpdateData, originalBookingUpdateDataForCancellation } =
bookingAndAssociatedData;
const createBookingObj = {
include: {
user: {
@@ -143,7 +165,21 @@ async function saveBooking(
});
}
return prisma.booking.create(createBookingObj);
/**
* Reschedule(Cancellation + Creation) with an update of reroutingFormResponse should be atomic
*/
return prisma.$transaction(async (tx) => {
if (originalBookingUpdateDataForCancellation) {
await tx.booking.update(originalBookingUpdateDataForCancellation);
}
const booking = await tx.booking.create(createBookingObj);
if (reroutingFormResponseUpdateData) {
await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData);
}
return booking;
});
}
function getEventTypeRel(eventTypeId: EventTypeId) {
@@ -164,12 +200,25 @@ function getAttendeesData(evt: Pick<CalendarEvent, "attendees" | "team">) {
}));
}
function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateInput {
const { uid, evt, reqBody, eventType, input, originalRescheduledBooking, routedFromRoutingFormResponseId } =
params;
function buildNewBookingData(params: CreateBookingParams) {
const {
uid,
evt,
reqBody,
eventType,
input,
originalRescheduledBooking,
routingFormResponseId,
reroutingFormResponses,
rescheduledBy,
} = params;
const attendeesData = getAttendeesData(evt);
const eventTypeRel = getEventTypeRel(eventType.id);
const reroutingFormResponseUpdateData = getReroutingFormResponseUpdateData({
reroutingFormResponses,
routingFormResponseId,
});
const newBookingData: Prisma.BookingCreateInput = {
uid,
@@ -206,8 +255,8 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI
}
: undefined,
routedFromRoutingFormReponse: routedFromRoutingFormResponseId
? { connect: { id: routedFromRoutingFormResponseId } }
routedFromRoutingFormReponse: routingFormResponseId
? { connect: { id: routingFormResponseId } }
: undefined,
};
@@ -215,6 +264,8 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI
newBookingData.recurringEventId = reqBody.recurringEventId;
}
let originalBookingUpdateDataForCancellation: Prisma.BookingUpdateArgs | undefined = undefined;
if (originalRescheduledBooking) {
newBookingData.metadata = {
...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata),
@@ -235,12 +286,53 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI
(attendee) => attendee.email === input.bookerEmail
);
}
if (originalRescheduledBooking.recurringEventId) {
newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId;
}
if (!evt.seatsPerTimeSlot && originalRescheduledBooking?.uid) {
originalBookingUpdateDataForCancellation = {
where: {
id: originalRescheduledBooking.id,
},
data: {
rescheduled: true,
status: BookingStatus.CANCELLED,
rescheduledBy: rescheduledBy,
},
};
}
}
return newBookingData;
return {
newBookingData,
reroutingFormResponseUpdateData,
originalBookingUpdateDataForCancellation,
};
function getReroutingFormResponseUpdateData({
reroutingFormResponses,
routingFormResponseId,
}: {
reroutingFormResponses: z.infer<typeof routingFormResponseInDbSchema> | null;
routingFormResponseId: number | undefined | null;
}) {
if (!routingFormResponseId) {
return null;
}
if (!reroutingFormResponses) {
return null;
}
return {
where: { id: routingFormResponseId },
data: {
response: reroutingFormResponses,
},
};
}
}
export type Booking = Prisma.PromiseReturnType<typeof createBooking>;
+6
View File
@@ -57,6 +57,12 @@ export const useBookingSuccessRedirect = () => {
query: Record<string, string | null | undefined | boolean>;
booking: SuccessRedirectBookingType;
}) => {
// Ensures that the param is added both to external redirect url and booking success page URL
query = {
...query,
"cal.rerouting": searchParams.get("cal.rerouting"),
};
if (successRedirectUrl) {
const url = new URL(successRedirectUrl);
// Using parent ensures, Embed iframe would redirect outside of the iframe.
+1
View File
@@ -631,6 +631,7 @@ model Booking {
cancelledBy String?
/// @zod.email()
rescheduledBy String?
// Ah, made a typo here. Should have been routedFromRoutingFormRe"s"ponse. Live with it :(
routedFromRoutingFormReponse App_RoutingForms_FormResponse?
@@index([eventTypeId])
+7 -1
View File
@@ -13,6 +13,7 @@ import type {
} from "zod";
import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated";
import { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid";
import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema";
@@ -252,6 +253,11 @@ export const bookingCreateBodySchema = z.object({
routedTeamMemberIds: z.array(z.number()).nullish(),
routingFormResponseId: z.number().optional(),
skipContactOwner: z.boolean().optional(),
/**
* Holds the corrected responses of the Form for a booking, provided during rerouting
*/
reroutingFormResponses: routingFormResponseInDbSchema.optional(),
});
export const requiredCustomInputSchema = z.union([
@@ -745,4 +751,4 @@ export const getStringAsNumberRequiredSchema = (t: TFunction) =>
export const bookingSeatDataSchema = z.object({
description: z.string().optional(),
responses: bookingResponses,
});
});
@@ -210,12 +210,23 @@ export async function getBookings({
const bookingSelect = {
...bookingMinimalSelect,
uid: true,
responses: true,
/**
* Who uses it -
* 1. We need to be able to decide which booking can have a 'Reroute' action
*/
routedFromRoutingFormReponse: {
select: {
id: true,
},
},
recurringEventId: true,
location: true,
eventType: {
select: {
slug: true,
id: true,
title: true,
eventName: true,
price: true,
recurringEvent: true,
@@ -225,10 +236,12 @@ export async function getBookings({
seatsShowAvailabilityCount: true,
eventTypeColor: true,
schedulingType: true,
length: true,
team: {
select: {
id: true,
name: true,
slug: true,
},
},
},
+10 -1
View File
@@ -90,12 +90,16 @@ type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content
Icon?: IconName;
enableOverflow?: boolean;
forceOverlayWhenNoModal?: boolean;
/**
* Disables the ability to close the dialog by clicking outside of it. Could be useful when the dialog is doing something critical which might be in progress.
*/
preventCloseOnOutsideClick?: boolean;
};
// enableOverflow:- use this prop whenever content inside DialogContent could overflow and require scrollbar
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
(
{ children, title, Icon: icon, enableOverflow, forceOverlayWhenNoModal, type = "creation", ...props },
{ children, title, Icon: icon, enableOverflow, forceOverlayWhenNoModal, type = "creation", preventCloseOnOutsideClick, ...props },
forwardedRef
) => {
const isPlatform = useIsPlatform();
@@ -119,6 +123,11 @@ export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps
)}
<Content
{...props}
onPointerDownOutside={(e) => {
if (preventCloseOnOutsideClick) {
e.preventDefault();
}
}}
className={classNames(
"fadeIn bg-default scroll-bar fixed left-1/2 top-1/2 z-50 w-full max-w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-md text-left shadow-xl focus-visible:outline-none sm:align-middle",
props.size == "xl"
@@ -135,4 +135,5 @@ export const lucideIconList = new Set([
"webhook",
"x",
"zap",
"waypoints"
]);
@@ -132,6 +132,7 @@ export type IconName =
| "users"
| "venetian-mask"
| "video"
| "waypoints"
| "webhook"
| "x"
| "zap";
-1
View File
@@ -1,5 +1,4 @@
import { defineConfig } from "vitest/config";
process.env.INTEGRATION_TEST_MODE = "true";
export default defineConfig({