import Link from "next/link";
import { useEffect, useState } from "react";
import type { UseFormReturn } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import AddMembersWithSwitch from "@calcom/features/eventtypes/components/AddMembersWithSwitch";
import { ShellMain } from "@calcom/features/shell/Shell";
import { IS_CALCOM } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { Brand } from "@calcom/types/utils";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import { ButtonGroup } from "@calcom/ui/components/buttonGroup";
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
import { VerticalDivider } from "@calcom/ui/components/divider";
import { DropdownMenuSeparator } from "@calcom/ui/components/dropdown";
import { Form } from "@calcom/ui/components/form";
import { TextAreaField } from "@calcom/ui/components/form";
import { TextField } from "@calcom/ui/components/form";
import { SettingsToggle } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { TRPCClientError } from "@trpc/react-query";
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
import { RoutingPages } from "../lib/RoutingPages";
import { isFallbackRoute } from "../lib/isFallbackRoute";
import { findMatchingRoute } from "../lib/processRoute";
import type { FormResponse, NonRouterRoute, RoutingFormWithResponseCount, RoutingForm } from "../types/types";
import type { NewFormDialogState } from "./FormActions";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "./FormActions";
import FormInputFields from "./FormInputFields";
import { InfoLostWarningDialog } from "./InfoLostWarningDialog";
import RoutingNavBar from "./RoutingNavBar";
import { getServerSidePropsForSingleFormView } from "./getServerSidePropsSingleForm";
const Actions = ({
form,
mutation,
}: {
form: RoutingFormWithResponseCount;
mutation: {
isPending: boolean;
};
}) => {
const { t } = useLocale();
return (
{t("preview")}
{t("copy_link_to_form")}
{t("download_responses")}
{t("embed")}
{t("delete")}
{t("save")}
);
};
type SingleFormComponentProps = {
form: RoutingFormWithResponseCount;
appUrl: string;
Page: React.FC<{
form: RoutingFormWithResponseCount;
appUrl: string;
hookForm: UseFormReturn;
}>;
enrichedWithUserProfileForm: inferSSRProps<
typeof getServerSidePropsForSingleFormView
>["enrichedWithUserProfileForm"];
};
type MembersMatchResultType = {
isUsingAttributeWeights: boolean;
eventTypeRedirectUrl: string | null;
contactOwnerEmail: string | null;
teamMembersMatchingAttributeLogic: { id: number; name: string | null; email: string }[] | null;
perUserData: {
bookingsCount: Record;
bookingShortfalls: Record | null;
calibrations: Record | null;
weights: Record | null;
} | null;
checkedFallback: boolean;
mainWarnings: string[] | null;
fallbackWarnings: string[] | null;
} | null;
const TeamMembersMatchResult = ({
membersMatchResult,
chosenRouteName,
showAllData,
}: {
membersMatchResult: MembersMatchResultType;
chosenRouteName: string;
showAllData: boolean;
}) => {
const { t } = useLocale();
if (!membersMatchResult) return null;
const hasMainWarnings = (membersMatchResult.mainWarnings?.length ?? 0) > 0;
const hasFallbackWarnings = (membersMatchResult.fallbackWarnings?.length ?? 0) > 0;
const renderFallbackLogicStatus = () => {
if (!membersMatchResult.checkedFallback) {
return t("fallback_not_needed");
} else if (
isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic) ||
membersMatchResult.teamMembersMatchingAttributeLogic.length > 0
) {
return t("yes");
} else {
return t("no");
}
};
const renderMainLogicStatus = () => {
return !membersMatchResult.checkedFallback ? t("yes") : t("no");
};
const renderQueue = () => {
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
if (!showAllData) return {t("no_active_queues")}asdf
;
if (membersMatchResult.checkedFallback) {
return (
{t(
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback"
)}
);
}
return (
{t("all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules")}
);
}
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic;
if (matchingMembers.length && membersMatchResult.perUserData) {
const perUserData = membersMatchResult.perUserData;
return (
#
{t("email")}
{t("bookings")}
{membersMatchResult.perUserData.weights ? {t("weight")} : null}
{membersMatchResult.perUserData.calibrations ? (
{t("calibration")}
) : null}
{membersMatchResult.perUserData.bookingShortfalls ? (
{t("shortfall")}
) : null}
{matchingMembers.map((member, index) => (
{index + 1}
{member.email}
{perUserData.bookingsCount[member.id] ?? 0}
{perUserData.weights ? (
{perUserData.weights[member.id] ?? 0}
) : null}
{perUserData.calibrations ? (
{perUserData.calibrations[member.id] ?? 0}
) : null}
{perUserData.bookingShortfalls ? (
{perUserData.bookingShortfalls[member.id] ?? 0}
) : null}
))}
);
}
return (
{t("all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match")}
);
};
return (
{showAllData ? (
<>
{t("chosen_route")}: {chosenRouteName}
{t("attribute_logic_matched")}:
{renderMainLogicStatus()}
{hasMainWarnings && (
)}
{t("attribute_logic_fallback_matched")}:{" "}
{renderFallbackLogicStatus()}
{hasFallbackWarnings && (
)}
>
) : (
<>>
)}
{membersMatchResult.contactOwnerEmail ? (
{t("contact_owner")}:{" "}
{membersMatchResult.contactOwnerEmail}
) : showAllData ? (
{t("contact_owner")}: Not found
) : (
<>>
)}
{showAllData ? (
<>
{membersMatchResult.isUsingAttributeWeights
? t("matching_members_queue_using_attribute_weights")
: t("matching_members_queue_using_event_assignee_weights")}
>
) : (
<>>
)}
{renderQueue()}
);
function isNoLogicFound(
teamMembersMatchingAttributeLogic: NonNullable["teamMembersMatchingAttributeLogic"]
): teamMembersMatchingAttributeLogic is null {
return teamMembersMatchingAttributeLogic === null;
}
};
/**
* It has the the ongoing changes in the form along with enrichedWithUserProfileForm specific data.
* So, it can be used to test the form in the test preview dialog without saving the changes even.
*/
type UptoDateForm = Brand<
NonNullable,
"UptoDateForm"
>;
export const TestForm = ({
form,
supportsTeamMembersMatchingLogic,
showAllData = true,
renderFooter,
}: {
form: UptoDateForm | RoutingForm;
supportsTeamMembersMatchingLogic: boolean;
showAllData?: boolean;
renderFooter?: (onClose: () => void) => React.ReactNode;
}) => {
const { t } = useLocale();
const [response, setResponse] = useState({});
const [chosenRoute, setChosenRoute] = useState(null);
const [eventTypeUrlWithoutParams, setEventTypeUrlWithoutParams] = useState("");
const searchParams = useCompatSearchParams();
const [membersMatchResult, setMembersMatchResult] = useState(null);
const resetMembersMatchResult = () => {
setMembersMatchResult(null);
};
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogicOfRoute.useMutation({
onSuccess(data) {
setMembersMatchResult({
isUsingAttributeWeights: data.isUsingAttributeWeights,
eventTypeRedirectUrl: data.eventTypeRedirectUrl,
contactOwnerEmail: data.contactOwnerEmail,
teamMembersMatchingAttributeLogic: data.result ? data.result.users : data.result,
perUserData: data.result ? data.result.perUserData : null,
checkedFallback: data.checkedFallback,
mainWarnings: data.mainWarnings,
fallbackWarnings: data.fallbackWarnings,
});
},
onError(e) {
if (e instanceof TRPCClientError) {
showToast(e.message, "error");
} else {
showToast(t("something_went_wrong"), "error");
}
},
});
function testRouting() {
const route = findMatchingRoute({ form, response });
let eventTypeRedirectUrl: string | null = null;
if (route?.action?.type === "eventTypeRedirectUrl") {
// only needed in routing form testing (type UptoDateForm)
if ("team" in form) {
eventTypeRedirectUrl = getAbsoluteEventTypeRedirectUrl({
eventTypeRedirectUrl: route.action.value,
form,
allURLSearchParams: new URLSearchParams(),
});
setEventTypeUrlWithoutParams(eventTypeRedirectUrl);
}
}
setChosenRoute(route || null);
if (!route) return;
if (supportsTeamMembersMatchingLogic) {
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response,
route,
isPreview: true,
_enablePerf: searchParams.get("enablePerf") === "true",
});
}
}
const renderTestResult = (showAllData: boolean) => {
if (!form.routes || !chosenRoute) return null;
const chosenRouteIndex = form.routes.findIndex((route) => route.id === chosenRoute.id);
const chosenRouteName = () => {
if (chosenRoute.isFallback) {
return t("fallback_route");
}
return `Route ${chosenRouteIndex + 1}`;
};
const renderTeamMembersMatchResult = (showAllData: boolean, isPending: boolean) => {
if (!supportsTeamMembersMatchingLogic) return null;
if (isPending) return Loading...
;
return (
);
};
if (!showAllData) {
if (
chosenRoute.action.type !== "customPageMessage" &&
chosenRoute.action.type !== "externalRedirectUrl"
) {
{
return renderTeamMembersMatchResult(false, findTeamMembersMatchingAttributeLogicMutation.isPending);
}
}
return {t("no_active_queues")}
;
}
return (
{t("route_to")}:
{RoutingPages.map((page) => {
if (page.value !== chosenRoute.action.type) return null;
return (
{page.label}
);
})}
:{" "}
{chosenRoute.action.type === "customPageMessage" ? (
{chosenRoute.action.value}
) : chosenRoute.action.type === "externalRedirectUrl" ? (
{chosenRoute.action.value}
) : (
)}
);
};
const onClose = () => {
setChosenRoute(null);
setResponse({});
};
return (
);
};
export const TestFormDialog = ({
form,
isTestPreviewOpen,
setIsTestPreviewOpen,
}: {
form: UptoDateForm;
isTestPreviewOpen: boolean;
setIsTestPreviewOpen: (value: boolean) => void;
}) => {
const { t } = useLocale();
const isSubTeamForm = !!form.team?.parentId;
return (
(
{
setIsTestPreviewOpen(false);
onClose();
}}>
{t("close")}
{t("test_routing")}
)}
/>
);
};
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const { data: user } = useMeQuery();
const [newFormDialogState, setNewFormDialogState] = useState(null);
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [showInfoLostDialog, setShowInfoLostDialog] = useState(false);
const hookForm = useFormContext();
useEffect(() => {
// The first time a tab is opened, the hookForm copies the form data (saved version, from the backend),
// and then it is considered the source of truth.
// There are two events we need to overwrite the hookForm data with the form data coming from the server.
// 1 - When we change the edited form.
// 2 - When the form is saved elsewhere (such as in another browser tab)
// In the second case. We skipped the first execution of useEffect to differentiate a tab change from a form change,
// because each time a tab changes, a new component is created and another useEffect is executed.
// An update from the form always occurs after the first useEffect execution.
if (Object.keys(hookForm.getValues()).length === 0 || hookForm.getValues().id !== form.id) {
hookForm.reset(form);
}
if (skipFirstUpdate) {
setSkipFirstUpdate(false);
} else {
hookForm.reset(form);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form]);
const sendUpdatesTo = hookForm.watch("settings.sendUpdatesTo", []) as number[];
const sendToAll = hookForm.watch("settings.sendToAll", false) as boolean;
const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({
onSuccess() {
showToast(t("form_updated_successfully"), "success");
},
onError(e) {
if (e.message) {
showToast(e.message, "error");
return;
}
showToast(`Something went wrong`, "error");
},
onSettled() {
utils.viewer.appRoutingForms.formQuery.invalidate({ id: form.id });
},
});
const connectedForms = form.connectedForms;
const uptoDateForm = {
...hookForm.getValues(),
routes: hookForm.watch("routes"),
user: enrichedWithUserProfileForm.user,
team: enrichedWithUserProfileForm.team,
nonOrgUsername: enrichedWithUserProfileForm.nonOrgUsername,
nonOrgTeamslug: enrichedWithUserProfileForm.nonOrgTeamslug,
userOrigin: enrichedWithUserProfileForm.userOrigin,
teamOrigin: enrichedWithUserProfileForm.teamOrigin,
} as UptoDateForm;
return (
<>
{showInfoLostDialog && (
)}
>
);
}
export default function SingleFormWrapper({ form: _form, ...props }: SingleFormComponentProps) {
const { data: form, isPending } = trpc.viewer.appRoutingForms.formQuery.useQuery(
{ id: _form.id },
{
initialData: _form,
trpc: {},
}
);
const { t } = useLocale();
if (isPending) {
// It shouldn't be possible because we are passing the data from SSR to it as initialData. So, no need for skeleton here
return null;
}
if (!form) {
throw new Error(t("something_went_wrong"));
}
return (
);
}
export { getServerSidePropsForSingleFormView };