From f6f964d4954ae4f42f0d029edd8776ae09e224fe Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 13 Feb 2025 15:13:13 +0530 Subject: [PATCH] feat: Use new /routing URL for routing forms (#19235) * feat: Use new /routing URL for routing forms * Fixes * Fix bug * precent crash --------- Co-authored-by: Hariom Balhara --- apps/web/next.config.js | 13 ++ .../routing-forms/components/FormActions.tsx | 147 ++++++++------ .../routing-forms/components/SingleForm.tsx | 8 +- packages/app-store/routing-forms/config.json | 8 +- .../pages/forms/[...appPages].tsx | 30 +-- .../playwright/tests/basic.e2e.ts | 2 +- packages/features/embed/Embed.tsx | 183 ++++++++++++++---- packages/features/embed/RoutingFormEmbed.tsx | 3 +- .../embed/lib/hooks/useEmbedDialogCtx.tsx | 36 ++++ .../embed/lib/hooks/useEmbedParams.ts | 39 ++++ packages/features/embed/types/index.d.ts | 10 + packages/features/shell/Shell.tsx | 15 +- .../features/shell/navigation/Navigation.tsx | 4 +- .../shell/navigation/NavigationItem.tsx | 7 + 14 files changed, 376 insertions(+), 129 deletions(-) create mode 100644 packages/features/embed/lib/hooks/useEmbedDialogCtx.tsx create mode 100644 packages/features/embed/lib/hooks/useEmbedParams.ts diff --git a/apps/web/next.config.js b/apps/web/next.config.js index f66a47cedf..4ce4cbeafc 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -297,6 +297,14 @@ const nextConfig = { source: "/forms/:formQuery*", destination: "/apps/routing-forms/routing-link/:formQuery*", }, + { + source: "/routing", + destination: "/routing/forms", + }, + { + source: "/routing/:path*", + destination: "/apps/routing-forms/:path*", + }, { source: "/success/:path*", has: [ @@ -528,6 +536,11 @@ const nextConfig = { }, async redirects() { const redirects = [ + { + source: "/apps/routing-forms", + destination: "/routing/forms", + permanent: false, + }, { source: "/api/app-store/:path*", destination: "/app-store/:path*", diff --git a/packages/app-store/routing-forms/components/FormActions.tsx b/packages/app-store/routing-forms/components/FormActions.tsx index 4678169871..688ee5b2c9 100644 --- a/packages/app-store/routing-forms/components/FormActions.tsx +++ b/packages/app-store/routing-forms/components/FormActions.tsx @@ -1,5 +1,5 @@ import type { App_RoutingForms_Form } from "@prisma/client"; -import { usePathname, useRouter } from "next/navigation"; +import { useRouter } from "next/navigation"; import { createContext, forwardRef, useContext, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { v4 as uuidv4 } from "uuid"; @@ -7,11 +7,10 @@ import { z } from "zod"; import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider"; import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed"; +import { EmbedDialogProvider } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx"; import { classNames } from "@calcom/lib"; import { WEBSITE_URL } from "@calcom/lib/constants"; -import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery"; import slugify from "@calcom/lib/slugify"; import { trpc } from "@calcom/trpc/react"; import type { ButtonProps } from "@calcom/ui"; @@ -38,33 +37,29 @@ import getFieldIdentifier from "../lib/getFieldIdentifier"; import type { SerializableForm } from "../types/types"; type RoutingForm = SerializableForm; - +export type NewFormDialogState = { action: "new" | "duplicate"; target: string | null } | null; +export type SetNewFormDialogState = React.Dispatch>; const newFormModalQuerySchema = z.object({ action: z.literal("new").or(z.literal("duplicate")), target: z.string().optional(), }); -export const useOpenModal = () => { - const router = useRouter(); - const pathname = usePathname(); - const searchParams = useCompatSearchParams(); - const openModal = (option: z.infer) => { - const newQuery = new URLSearchParams(searchParams ?? undefined); - newQuery.set("dialog", "new-form"); - Object.keys(option).forEach((key) => { - newQuery.set(key, option[key as keyof typeof option] || ""); - }); - router.push(`${pathname}?${newQuery.toString()}`); - }; - return openModal; -}; - -function NewFormDialog({ appUrl }: { appUrl: string }) { - const routerQuery = useRouterQuery(); +function NewFormDialog({ + appUrl, + newFormDialogState, + setNewFormDialogState, +}: { + appUrl: string; + newFormDialogState: NewFormDialogState; + setNewFormDialogState: SetNewFormDialogState; +}) { const { t } = useLocale(); const router = useRouter(); const utils = trpc.useUtils(); + const action = newFormDialogState?.action; + const target = newFormDialogState?.target; + const mutation = trpc.viewer.appRoutingForms.formMutation.useMutation({ onSuccess: (_data, variables) => { router.push(`${appUrl}/form-edit/${variables.id}`); @@ -83,14 +78,12 @@ function NewFormDialog({ appUrl }: { appUrl: string }) { shouldConnect: boolean; }>(); - const { action, target } = routerQuery as z.infer; - const formToDuplicate = action === "duplicate" ? target : null; const teamId = action === "new" ? Number(target) : null; const { register } = hookForm; return ( - + !open && setNewFormDialogState(null)}>

void; deleteDialogFormId: string | null; + newFormDialogState: NewFormDialogState | null; + setNewFormDialogState: SetNewFormDialogState; }) { const utils = trpc.useUtils(); const router = useRouter(); const { t } = useLocale(); + const deleteMutation = trpc.viewer.appRoutingForms.deleteForm.useMutation({ onMutate: async ({ id: formId }) => { await utils.viewer.appRoutingForms.forms.cancel(); @@ -253,13 +251,20 @@ function Dialogs({

- + ); } const actionsCtx = createContext({ appUrl: "", + // eslint-disable-next-line @typescript-eslint/no-empty-function + setNewFormDialogState: null as SetNewFormDialogState | null, + newFormDialogState: null as NewFormDialogState, _delete: { // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars onAction: (_arg: { routingForm: RoutingForm | null }) => {}, @@ -272,12 +277,23 @@ const actionsCtx = createContext({ }, }); -export function FormActionsProvider({ appUrl, children }: { appUrl: string; children: React.ReactNode }) { +interface FormActionsProviderProps { + appUrl: string; + children: React.ReactNode; + newFormDialogState: NewFormDialogState; + setNewFormDialogState: SetNewFormDialogState; +} + +export function FormActionsProvider({ + appUrl, + children, + newFormDialogState, + setNewFormDialogState, +}: FormActionsProviderProps) { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogFormId, setDeleteDialogFormId] = useState(null); const { t } = useLocale(); const utils = trpc.useUtils(); - const toggleMutation = trpc.viewer.appRoutingForms.formMutation.useMutation({ onMutate: async ({ id: formId, disabled }) => { await utils.viewer.appRoutingForms.forms.cancel(); @@ -320,40 +336,46 @@ export function FormActionsProvider({ appUrl, children }: { appUrl: string; chil return ( <> - { - if (!routingForm) { - return; - } - setDeleteDialogOpen(true); - setDeleteDialogFormId(routingForm.id); + + { + if (!routingForm) { + return; + } + setDeleteDialogOpen(true); + setDeleteDialogFormId(routingForm.id); + }, + isPending: false, }, - isPending: false, - }, - toggle: { - onAction: ({ routingForm, checked }) => { - if (!routingForm) { - return; - } - toggleMutation.mutate({ - ...routingForm, - disabled: !checked, - }); + toggle: { + onAction: ({ routingForm, checked }) => { + if (!routingForm) { + return; + } + toggleMutation.mutate({ + ...routingForm, + disabled: !checked, + }); + }, + isPending: toggleMutation.isPending, }, - isPending: toggleMutation.isPending, - }, - }}> - {children} - - + }}> + {children} + + + ); } @@ -398,7 +420,7 @@ export const FormAction = forwardRef(function FormAction["render"] } @@ -427,7 +448,7 @@ export const FormAction = forwardRef(function FormAction openModal({ action: "duplicate", target: routingForm?.id }), + onClick: () => setNewFormDialogState?.({ action: "duplicate", target: routingForm?.id ?? null }), }, embed: { as: RoutingFormEmbedButton, @@ -448,7 +469,7 @@ export const FormAction = forwardRef(function FormAction openModal({ action: "new", target: "" }), + onClick: () => setNewFormDialogState?.({ action: "new", target: "" }), }, copyRedirectUrl: { onClick: () => { diff --git a/packages/app-store/routing-forms/components/SingleForm.tsx b/packages/app-store/routing-forms/components/SingleForm.tsx index a7aeeae80e..58a9cff63b 100644 --- a/packages/app-store/routing-forms/components/SingleForm.tsx +++ b/packages/app-store/routing-forms/components/SingleForm.tsx @@ -42,6 +42,7 @@ 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"; @@ -680,7 +681,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF 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); @@ -752,7 +753,10 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF ...data, }); }}> - + { - openModal({ action: "new", target: teamId ? String(teamId) : "" }); + setNewFormDialogState({ action: "new", target: teamId ? String(teamId) : "" }); }} /> ); @@ -89,6 +84,8 @@ export default function RoutingForms({ filters, }); + const [newFormDialogState, setNewFormDialogState] = useState(null); + const { data: typeformApp } = useApp("typeform"); const forms = queryRes.data?.filtered; const features = [ @@ -151,7 +148,11 @@ export default function RoutingForms({ : null} + CTA={ + hasPaidPlan && forms?.length ? ( + + ) : null + } subtitle={t("routing_forms_description")}> }> - +
@@ -184,7 +188,7 @@ export default function RoutingForms({ Icon="git-merge" headline={t("create_your_first_form")} description={t("create_your_first_form_description")} - buttonRaw={} + buttonRaw={} /> } noResultsScreen={ diff --git a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts index ce421eee54..1f9a22393a 100644 --- a/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts +++ b/packages/app-store/routing-forms/playwright/tests/basic.e2e.ts @@ -84,7 +84,7 @@ test.describe("Routing Forms", () => { test("should be able to add a new form and view it", async ({ page }) => { const formId = await addForm(page); - await page.click('[href*="/forms"]'); + await page.click('[data-test-id="routing"]'); await page.waitForSelector('[data-testid="routing-forms-list"]'); // Ensure that it's visible in forms list diff --git a/packages/features/embed/Embed.tsx b/packages/features/embed/Embed.tsx index d447e7ee90..1784fec9d6 100644 --- a/packages/features/embed/Embed.tsx +++ b/packages/features/embed/Embed.tsx @@ -43,6 +43,8 @@ import { import { useBookerTime } from "../bookings/Booker/components/hooks/useBookerTime"; import { buildCssVarsPerTheme } from "./lib/buildCssVarsPerTheme"; import { getDimension } from "./lib/getDimension"; +import { useEmbedDialogCtx } from "./lib/hooks/useEmbedDialogCtx"; +import { useEmbedParams } from "./lib/hooks/useEmbedParams"; import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState } from "./types"; type EventType = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"] | undefined; @@ -51,6 +53,18 @@ type EmbedDialogProps = { tabs: EmbedTabs; eventTypeHideOptionDisabled: boolean; defaultBrandColor: { brandColor: string | null; darkBrandColor: string | null } | null; + noQueryParamMode?: boolean; +}; + +type GotoStateProps = { + embedType?: EmbedType | null; + embedTabName?: string | null; + embedUrl?: string | null; + eventId?: string | null; + namespace?: string | null; + date?: string | null; + month?: string | null; + dialog?: string; }; const enum Theme { @@ -89,6 +103,8 @@ function useRouterHelpers() { const goto = (newSearchParams: Record) => { const newQuery = new URLSearchParams(searchParams ?? undefined); + newQuery.delete("slug"); + newQuery.delete("pages"); Object.keys(newSearchParams).forEach((key) => { newQuery.set(key, newSearchParams[key]); }); @@ -109,6 +125,58 @@ function useRouterHelpers() { return { goto, removeQueryParams }; } +function useEmbedGoto(noQueryParamMode = false) { + const { goto, removeQueryParams } = useRouterHelpers(); + const { setEmbedState } = useEmbedDialogCtx(noQueryParamMode); + + const gotoState = (props: GotoStateProps) => { + if (noQueryParamMode) { + setEmbedState((prev) => ({ + ...prev, + embedType: props.embedType ?? prev?.embedType ?? null, + embedTabName: props.embedTabName ?? prev?.embedTabName ?? null, + embedUrl: props.embedUrl ?? prev?.embedUrl ?? null, + eventId: props.eventId ?? prev?.eventId ?? null, + namespace: props.namespace ?? prev?.namespace ?? null, + date: props.date ?? prev?.date ?? null, + month: props.month ?? prev?.month ?? null, + })); + } else { + const validQueryParams = Object.fromEntries( + Object.entries(props).filter(([_, value]) => value !== null) as [string, string][] + ); + goto(validQueryParams); + } + }; + + const resetState = () => { + if (noQueryParamMode) { + setEmbedState(null); + } else { + removeQueryParams(["dialog", ...queryParamsForDialog]); + } + }; + + const gotoEmbedTypeSelectionState = () => { + if (noQueryParamMode) { + setEmbedState((prev) => ({ + ...prev, + embedType: null, + embedTabName: null, + embedUrl: prev?.embedUrl ?? null, + eventId: prev?.eventId ?? null, + namespace: prev?.namespace ?? null, + date: prev?.date ?? null, + month: prev?.month ?? null, + })); + } else { + removeQueryParams(["embedType", "embedTabName"]); + } + }; + + return { gotoState, resetState, gotoEmbedTypeSelectionState }; +} + const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; label: string }, false>) => { return ( @@ -118,9 +186,15 @@ const ThemeSelectControl = ({ children, ...props }: ControlProps<{ value: Theme; ); }; -const ChooseEmbedTypesDialogContent = ({ types }: { types: EmbedTypes }) => { +const ChooseEmbedTypesDialogContent = ({ + types, + noQueryParamMode, +}: { + types: EmbedTypes; + noQueryParamMode: boolean; +}) => { const { t } = useLocale(); - const { goto } = useRouterHelpers(); + const { gotoState } = useEmbedGoto(noQueryParamMode); return (
@@ -138,8 +212,8 @@ const ChooseEmbedTypesDialogContent = ({ types }: { types: EmbedTypes }) => { key={index} data-testid={embed.type} onClick={() => { - goto({ - embedType: embed.type, + gotoState({ + embedType: embed.type as EmbedType, }); }}>
@@ -564,16 +638,17 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ eventTypeHideOptionDisabled, types, defaultBrandColor, + noQueryParamMode, }: EmbedDialogProps & { embedType: EmbedType; embedUrl: string; namespace: string; - eventTypeHideOptionDisabled: boolean; + noQueryParamMode?: boolean; }) => { const { t } = useLocale(); const searchParams = useCompatSearchParams(); const pathname = usePathname(); - const { goto, removeQueryParams } = useRouterHelpers(); + const { resetState, gotoState, gotoEmbedTypeSelectionState } = useEmbedGoto(noQueryParamMode); const iframeRef = useRef(null); const dialogContentRef = useRef(null); const emailContentRef = useRef(null); @@ -583,7 +658,9 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ (state) => [state.month, state.selectedDatesAndTimes], shallow ); - const eventId = searchParams?.get("eventId"); + + const embedParams = useEmbedParams(noQueryParamMode); + const eventId = embedParams.eventId; const parsedEventId = parseInt(eventId ?? "", 10); const calLink = decodeURIComponent(embedUrl); const { data: eventTypeData } = trpc.viewer.eventTypes.get.useQuery( @@ -600,7 +677,25 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ _searchParams.set(a, b); return `${pathname?.split("?")[0] ?? ""}?${_searchParams.toString()}`; }; - const parsedTabs = tabs.map((t) => ({ ...t, href: s(t.href) })); + const parsedTabs = tabs.map((t) => { + const { href, ...rest } = t; + const tabName = href.split("=")[1]; + return { + ...rest, + isActive: tabName === embedParams.embedTabName, + ...(noQueryParamMode + ? { + onClick: () => { + gotoState({ embedTabName: tabName }); + }, + // We still pass the href(which is unique) so that all the tabs aren't marked as active + href: t.href, + } + : { + href: s(t.href), + }), + }; + }); const embedCodeRefs: Record<(typeof tabs)[0]["name"], RefObject> = {}; tabs .filter((tab) => tab.type === "code") @@ -651,12 +746,12 @@ const EmbedTypeCodeAndPreviewDialogContent = ({ }); const close = () => { - removeQueryParams(["dialog", ...queryParamsForDialog]); + resetState(); }; // Use embed-code as default tab - if (!searchParams?.get("embedTabName")) { - goto({ + if (!embedParams.embedTabName) { + gotoState({ embedTabName: "embed-code", }); } @@ -787,11 +882,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({