import type { App_RoutingForms_Form, Team } from "@prisma/client"; 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 LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired"; import AddMembersWithSwitch from "@calcom/features/eventtypes/components/AddMembersWithSwitch"; import { ShellMain } from "@calcom/features/shell/Shell"; import cn from "@calcom/lib/classNames"; import { IS_CALCOM } from "@calcom/lib/constants"; import useApp from "@calcom/lib/hooks/useApp"; import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc, TRPCClientError } from "@calcom/trpc/react"; import useMeQuery from "@calcom/trpc/react/hooks/useMeQuery"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import type { Brand } from "@calcom/types/utils"; import { Alert, Badge, Button, ButtonGroup, Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DropdownMenuSeparator, Form, SettingsToggle, showToast, TextAreaField, TextField, Tooltip, VerticalDivider, } from "@calcom/ui"; import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl"; import { RoutingPages } from "../lib/RoutingPages"; import { isFallbackRoute } from "../lib/isFallbackRoute"; import { findMatchingRoute } from "../lib/processRoute"; import type { FormResponse, NonRouterRoute, SerializableForm } 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"; type RoutingForm = SerializableForm; export type RoutingFormWithResponseCount = RoutingForm & { team: { slug: Team["slug"]; name: Team["name"]; } | null; _count: { responses: number; }; }; const Actions = ({ form, mutation, }: { form: RoutingFormWithResponseCount; mutation: { isPending: boolean; }; }) => { const { t } = useLocale(); const { data: typeformApp } = useApp("typeform"); return (
{typeformApp?.isInstalled ? ( {t("copy_redirect_url")} ) : null}
{t("preview")} {t("copy_link_to_form")} {t("download_responses")} {t("embed")} {typeformApp ? ( {t("Copy Typeform Redirect Url")} ) : null} {t("delete")}
); }; 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 (
{membersMatchResult.perUserData.weights ? : null} {membersMatchResult.perUserData.calibrations ? ( ) : null} {membersMatchResult.perUserData.bookingShortfalls ? ( ) : null} {matchingMembers.map((member, index) => ( {perUserData.weights ? ( ) : null} {perUserData.calibrations ? ( ) : null} {perUserData.bookingShortfalls ? ( ) : null} ))}
# {t("email")} {t("bookings")}{t("weight")}{t("calibration")}{t("shortfall")}
{index + 1} {member.email} {perUserData.bookingsCount[member.id] ?? 0}{perUserData.weights[member.id] ?? 0}{perUserData.calibrations[member.id] ?? 0}{perUserData.bookingShortfalls[member.id] ?? 0}
); } 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} ) : (
{chosenRoute.action.value} {renderTeamMembersMatchResult( showAllData, findTeamMembersMatchingAttributeLogicMutation.isPending )}
)}
); }; const onClose = () => { setChosenRoute(null); setResponse({}); }; return (
{ e.preventDefault(); resetMembersMatchResult(); testRouting(); }}>
{form && }
{!renderFooter ? (
) : ( <> )}
{renderTestResult(showAllData)}
{renderFooter?.(onClose)}
); }; 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")} )} />
); }; 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 ( <>
{ // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore mutation.mutate({ ...data, }); }}>
{form.name}
{form.team && ( {form.team.name} )} } subtitle={form.description || ""} backPath={`${appUrl}/forms`} CTA={}>
{form.teamId ? (
{t("routing_forms_send_email_to")} ({ value: member.id.toString(), label: member.name || member.email, avatar: member.avatarUrl || "", email: member.email, isFixed: true, defaultScheduleId: member.defaultScheduleId, }))} value={sendUpdatesTo.map((userId) => ({ isFixed: true, userId: userId, priority: 2, weight: 100, scheduleId: 1, }))} onChange={(value) => { hookForm.setValue( "settings.sendUpdatesTo", value.map((teamMember) => teamMember.userId), { shouldDirty: true } ); hookForm.setValue("settings.emailOwnerOnSubmission", false, { shouldDirty: true, }); }} assignAllTeamMembers={sendToAll} setAssignAllTeamMembers={(value) => { hookForm.setValue("settings.sendToAll", !!value, { shouldDirty: true }); }} automaticAddAllEnabled={true} isFixed={true} onActive={() => { hookForm.setValue( "settings.sendUpdatesTo", form.teamMembers.map((teamMember) => teamMember.id), { shouldDirty: true } ); hookForm.setValue("settings.emailOwnerOnSubmission", false, { shouldDirty: true, }); }} placeholder={t("select_members")} containerClassName="!px-0 !pb-0 !pt-0" />
) : ( { return ( { onChange(val); hookForm.unregister("settings.sendUpdatesTo"); }} /> ); }} /> )}
{form.routers.length ? (
{t("routers")}

{t("modifications_in_fields_warning")}

{form.routers.map((router) => { return (
{router.name}
); })}
) : null} {connectedForms?.length ? (
{t("connected_forms")}

{t("form_modifications_warning")}

{connectedForms.map((router) => { return (
{router.name}
); })}
) : null}
{IS_CALCOM && ( )}
{form.routes?.every(isFallbackRoute) && ( )} {!form._count?.responses && ( <> )}
{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 };