Files
calendar/packages/app-store/routing-forms/components/SingleForm.tsx
T
2025-05-27 11:55:02 -04:00

246 lines
8.3 KiB
TypeScript

"use client";
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import type { SingleFormComponentProps } from "../types/shared";
import type { RoutingFormWithResponseCount } from "../types/types";
import type { NewFormDialogState } from "./FormActions";
import { FormActionsProvider } from "./FormActions";
import { InfoLostWarningDialog } from "./InfoLostWarningDialog";
import { Header } from "./_components/Header";
import { TestFormRenderer, type UptoDateForm } from "./_components/TestForm";
const BREAKPOINTS = {
sm: 640,
md: 768,
lg: 1024,
xl: 1280,
"2xl": 1536,
} as const;
type Breakpoint = keyof typeof BREAKPOINTS;
type BreakpointState = Record<Breakpoint, boolean>;
function useBreakPoints() {
const [breakpoints, setBreakpoints] = useState<BreakpointState>({
sm: false,
md: false,
lg: false,
xl: false,
"2xl": false,
});
useEffect(() => {
const updateBreakpoints = () => {
const width = window.innerWidth;
setBreakpoints({
sm: width >= BREAKPOINTS.sm,
md: width >= BREAKPOINTS.md,
lg: width >= BREAKPOINTS.lg,
xl: width >= BREAKPOINTS.xl,
"2xl": width >= BREAKPOINTS["2xl"],
});
};
// Initial check
updateBreakpoints();
// Add resize listener
window.addEventListener("resize", updateBreakpoints);
return () => window.removeEventListener("resize", updateBreakpoints);
}, []);
return {
...breakpoints,
// Convenience properties
isMobile: !breakpoints.md,
isTablet: breakpoints.md && !breakpoints.lg,
isDesktop: breakpoints.lg,
};
}
/**
* 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.
*/
function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleFormComponentProps) {
const utils = trpc.useUtils();
const { t } = useLocale();
const [newFormDialogState, setNewFormDialogState] = useState<NewFormDialogState>(null);
const [isTestPreviewOpen, setIsTestPreviewOpen] = useState(false);
const [skipFirstUpdate, setSkipFirstUpdate] = useState(true);
const [showInfoLostDialog, setShowInfoLostDialog] = useState(false);
const hookForm = useFormContext<RoutingFormWithResponseCount>();
const { isDesktop } = useBreakPoints();
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 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 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;
const handleSubmit = (data: RoutingFormWithResponseCount) => {
mutation.mutate({
...data,
});
};
return (
<>
<Form form={hookForm} handleSubmit={handleSubmit}>
<FormActionsProvider
appUrl={appUrl}
newFormDialogState={newFormDialogState}
setNewFormDialogState={setNewFormDialogState}>
<div className="flex h-full min-h-screen w-full flex-col">
<Header
routingForm={form}
isSaving={mutation.isPending}
appUrl={appUrl}
setShowInfoLostDialog={setShowInfoLostDialog}
setIsTestPreviewOpen={setIsTestPreviewOpen}
isTestPreviewOpen={isTestPreviewOpen}
/>
<div
className={classNames(
"bg-default flex-1",
isDesktop && "grid gap-8",
isDesktop && isTestPreviewOpen && "grid-cols-[1fr,400px]",
isDesktop && !isTestPreviewOpen && "grid-cols-1",
!isDesktop && "flex flex-col"
)}>
{isDesktop ? (
<motion.div
layout
className="mx-auto w-full max-w-4xl px-2 lg:px-4 xl:px-0"
transition={{ duration: 0.3, ease: "easeInOut" }}>
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</motion.div>
) : (
<div className="mx-auto w-full max-w-4xl px-2">
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
)}
<AnimatePresence>
{isTestPreviewOpen && isDesktop ? (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3, ease: "easeInOut" }}>
<TestFormRenderer
isMobile={!isDesktop}
testForm={uptoDateForm}
isTestPreviewOpen={isTestPreviewOpen}
setIsTestPreviewOpen={setIsTestPreviewOpen}
/>
</motion.div>
) : isTestPreviewOpen ? (
<div>
<TestFormRenderer
isMobile={!isDesktop}
testForm={uptoDateForm}
isTestPreviewOpen={isTestPreviewOpen}
setIsTestPreviewOpen={setIsTestPreviewOpen}
/>
</div>
) : null}
</AnimatePresence>
</div>
</div>
</FormActionsProvider>
{showInfoLostDialog && (
<InfoLostWarningDialog
handleSubmit={() => {
mutation.mutate({
...hookForm.getValues(),
});
}}
goToRoute={`${appUrl}/route-builder/${form?.id}`}
isOpenInfoLostDialog={showInfoLostDialog}
setIsOpenInfoLostDialog={setShowInfoLostDialog}
/>
)}
</Form>
</>
);
}
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 (
<LicenseRequired>
<SingleForm form={form} {...props} />
</LicenseRequired>
);
}