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 <hariombalhara@gmgmail.com>
This commit is contained in:
Hariom BalharaandHariom Balhara authored and GitHub committed 2025-02-13 15:13:13 +05:30
1 parent 6364e85293
commit f6f964d495
14 files changed
+376 -129

No files matched your search

+13
View File
@@ -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*",
@@ -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<App_RoutingForms_Form>;
export type NewFormDialogState = { action: "new" | "duplicate"; target: string | null } | null;
export type SetNewFormDialogState = React.Dispatch<React.SetStateAction<NewFormDialogState>>;
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<typeof newFormModalQuerySchema>) => {
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<typeof newFormModalQuerySchema>;
const formToDuplicate = action === "duplicate" ? target : null;
const teamId = action === "new" ? Number(target) : null;
const { register } = hookForm;
return (
<Dialog name="new-form" clearQueryParamsOnClose={["target", "action"]}>
<Dialog open={newFormDialogState !== null} onOpenChange={(open) => !open && setNewFormDialogState(null)}>
<DialogContent className="overflow-y-auto">
<div className="mb-1">
<h3
@@ -187,15 +180,20 @@ function Dialogs({
deleteDialogOpen,
setDeleteDialogOpen,
deleteDialogFormId,
newFormDialogState,
setNewFormDialogState,
}: {
appUrl: string;
deleteDialogOpen: boolean;
setDeleteDialogOpen: (open: boolean) => 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({
</ul>
</ConfirmationDialogContent>
</Dialog>
<NewFormDialog appUrl={appUrl} />
<NewFormDialog
appUrl={appUrl}
newFormDialogState={newFormDialogState}
setNewFormDialogState={setNewFormDialogState}
/>
</div>
);
}
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<string | null>(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 (
<>
<actionsCtx.Provider
value={{
appUrl,
_delete: {
onAction: ({ routingForm }) => {
if (!routingForm) {
return;
}
setDeleteDialogOpen(true);
setDeleteDialogFormId(routingForm.id);
<EmbedDialogProvider>
<actionsCtx.Provider
value={{
appUrl,
setNewFormDialogState,
newFormDialogState,
_delete: {
onAction: ({ routingForm }) => {
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}
</actionsCtx.Provider>
<Dialogs
appUrl={appUrl}
deleteDialogFormId={deleteDialogFormId}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
/>
}}>
{children}
</actionsCtx.Provider>
<Dialogs
appUrl={appUrl}
deleteDialogFormId={deleteDialogFormId}
deleteDialogOpen={deleteDialogOpen}
setDeleteDialogOpen={setDeleteDialogOpen}
newFormDialogState={newFormDialogState}
setNewFormDialogState={setNewFormDialogState}
/>
</EmbedDialogProvider>
</>
);
}
@@ -398,7 +420,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
extraClassNames,
...additionalProps
} = props;
const { appUrl, _delete, toggle } = useContext(actionsCtx);
const { appUrl, _delete, toggle, setNewFormDialogState } = useContext(actionsCtx);
const dropdownCtxValue = useContext(dropdownCtx);
const dropdown = dropdownCtxValue?.dropdown;
const embedLink = `forms/${routingForm?.id}`;
@@ -412,7 +434,6 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
});
const { t } = useLocale();
const openModal = useOpenModal();
const actionData: Record<
FormActionType,
ButtonProps & { as?: React.ElementType; render?: FormActionProps<unknown>["render"] }
@@ -427,7 +448,7 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
},
},
duplicate: {
onClick: () => 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<T extends typeof Button
loading: _delete.isPending,
},
create: {
onClick: () => openModal({ action: "new", target: "" }),
onClick: () => setNewFormDialogState?.({ action: "new", target: "" }),
},
copyRedirectUrl: {
onClick: () => {
@@ -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<NewFormDialogState>(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,
});
}}>
<FormActionsProvider appUrl={appUrl}>
<FormActionsProvider
appUrl={appUrl}
newFormDialogState={newFormDialogState}
setNewFormDialogState={setNewFormDialogState}>
<Meta title={form.name} description={form.description || ""} />
<ShellMain
heading={
+4 -4
View File
@@ -1,7 +1,7 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Routing Forms",
"title": "Routing Forms",
"name": "Routing",
"title": "Routing",
"isGlobal": true,
"slug": "routing-forms",
"type": "routing-forms_other",
@@ -10,11 +10,11 @@
"variant": "other",
"categories": ["automation"],
"publisher": "Cal.com, Inc.",
"simplePath": "/apps/routing-forms",
"simplePath": "/routing",
"email": "help@cal.com",
"licenseRequired": true,
"teamsPlanRequired": {
"upgradeUrl": "/routing-forms/forms"
"upgradeUrl": "/routing"
},
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user",
"__createdUsingCli": true,
@@ -1,7 +1,7 @@
"use client";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
@@ -32,25 +32,20 @@ import {
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import {
FormAction,
FormActionsDropdown,
FormActionsProvider,
useOpenModal,
} from "../../components/FormActions";
import type { SetNewFormDialogState, NewFormDialogState } from "../../components/FormActions";
import { FormAction, FormActionsDropdown, FormActionsProvider } from "../../components/FormActions";
import type { RoutingFormWithResponseCount } from "../../components/SingleForm";
import { isFallbackRoute } from "../../lib/isFallbackRoute";
import { getServerSideProps } from "./getServerSideProps";
function NewFormButton() {
function NewFormButton({ setNewFormDialogState }: { setNewFormDialogState: SetNewFormDialogState }) {
const { t } = useLocale();
const openModal = useOpenModal();
return (
<CreateButtonWithTeamsList
subtitle={t("create_routing_form_on").toUpperCase()}
data-testid="new-routing-form"
createFunction={(teamId) => {
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<NewFormDialogState>(null);
const { data: typeformApp } = useApp("typeform");
const forms = queryRes.data?.filtered;
const features = [
@@ -151,7 +148,11 @@ export default function RoutingForms({
<LicenseRequired>
<ShellMain
heading={t("routing")}
CTA={hasPaidPlan && forms?.length ? <NewFormButton /> : null}
CTA={
hasPaidPlan && forms?.length ? (
<NewFormButton setNewFormDialogState={setNewFormDialogState} />
) : null
}
subtitle={t("routing_forms_description")}>
<UpgradeTip
plan="team"
@@ -172,7 +173,10 @@ export default function RoutingForms({
</ButtonGroup>
</div>
}>
<FormActionsProvider appUrl={appUrl}>
<FormActionsProvider
appUrl={appUrl}
newFormDialogState={newFormDialogState}
setNewFormDialogState={setNewFormDialogState}>
<div className="mb-10 w-full">
<div className="flex">
<TeamsFilter />
@@ -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={<NewFormButton />}
buttonRaw={<NewFormButton setNewFormDialogState={setNewFormDialogState} />}
/>
}
noResultsScreen={
@@ -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
+143 -40
View File
@@ -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<string, string>) => {
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 (
<components.Control {...props}>
@@ -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 (
<DialogContent className="rounded-lg p-10" type="creation" size="lg">
<div className="mb-2">
@@ -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,
});
}}>
<div className="bg-default order-none box-border flex-none rounded-md border border-solid transition dark:bg-transparent dark:invert">
@@ -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<HTMLIFrameElement>(null);
const dialogContentRef = useRef<HTMLDivElement>(null);
const emailContentRef = useRef<HTMLDivElement>(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<HTMLTextAreaElement>> = {};
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 = ({
<h3
className="text-emphasis mb-2.5 flex items-center text-xl font-semibold leading-5"
id="modal-title">
<button
className="h-6 w-6"
onClick={() => {
removeQueryParams(["embedType", "embedTabName"]);
}}>
<button className="h-6 w-6" onClick={gotoEmbedTypeSelectionState}>
<Icon name="arrow-left" className="mr-4 w-4" />
</button>
{embed.title}
@@ -1123,7 +1214,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
<div
key={tab.href}
className={classNames(
searchParams?.get("embedTabName") === tab.href.split("=")[1] ? "flex-1" : "hidden"
embedParams.embedTabName === tab.href.split("=")[1] ? "flex-1" : "hidden"
)}>
{tab.type === "code" && (
<tab.Component
@@ -1135,9 +1226,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
/>
)}
<div
className={
searchParams?.get("embedTabName") === "embed-preview" ? "mt-2 block" : "hidden"
}
className={embedParams.embedTabName === "embed-preview" ? "mt-2 block" : "hidden"}
/>
</div>
);
@@ -1162,11 +1251,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
}
/>
</div>
<div
className={
searchParams?.get("embedTabName") === "embed-preview" ? "mt-2 block" : "hidden"
}
/>
<div className={embedParams.embedTabName === "embed-preview" ? "mt-2 block" : "hidden"} />
</div>
);
})}
@@ -1191,7 +1276,7 @@ const EmbedTypeCodeAndPreviewDialogContent = ({
if (embedType === "email") {
handleCopyEmailText();
} else {
const currentTabHref = searchParams?.get("embedTabName");
const currentTabHref = embedParams.embedTabName;
const currentTabName = tabs.find(
(tab) => tab.href === `embedTabName=${currentTabHref}`
)?.name;
@@ -1219,23 +1304,41 @@ export const EmbedDialog = ({
tabs,
eventTypeHideOptionDisabled,
defaultBrandColor,
noQueryParamMode = false,
}: EmbedDialogProps) => {
const searchParams = useCompatSearchParams();
const embedUrl = (searchParams?.get("embedUrl") || "") as string;
const namespace = (searchParams?.get("namespace") || "") as string;
const { embedState, setEmbedState } = useEmbedDialogCtx(noQueryParamMode);
const embedParams = useEmbedParams(noQueryParamMode);
const handleDialogClose = () => {
if (noQueryParamMode) {
setEmbedState(null);
}
};
return (
<Dialog name="embed" clearQueryParamsOnClose={queryParamsForDialog}>
{!searchParams?.get("embedType") ? (
<ChooseEmbedTypesDialogContent types={types} />
<Dialog
{...(noQueryParamMode
? {
open: embedState !== null,
onOpenChange: (open) => !open && handleDialogClose(),
}
: {
// Must not set name when noQueryParam mode as required by Dialog component
name: "embed",
clearQueryParamsOnClose: queryParamsForDialog,
})}>
{!embedParams.embedType ? (
<ChooseEmbedTypesDialogContent types={types} noQueryParamMode={noQueryParamMode} />
) : (
<EmbedTypeCodeAndPreviewDialogContent
embedType={searchParams?.get("embedType") as EmbedType}
embedUrl={embedUrl}
namespace={namespace}
embedType={embedParams.embedType as EmbedType}
embedUrl={embedParams.embedUrl}
namespace={embedParams.namespace}
tabs={tabs}
types={types}
eventTypeHideOptionDisabled={eventTypeHideOptionDisabled}
defaultBrandColor={defaultBrandColor}
noQueryParamMode={noQueryParamMode}
/>
)}
</Dialog>
@@ -1249,6 +1352,7 @@ type EmbedButtonProps<T> = {
className?: string;
as?: T;
eventId?: number;
noQueryParamMode?: boolean;
};
export const EmbedButton = <T extends React.ElementType = typeof Button>({
@@ -1258,13 +1362,14 @@ export const EmbedButton = <T extends React.ElementType = typeof Button>({
as,
eventId,
namespace,
noQueryParamMode,
...props
}: EmbedButtonProps<T> & React.ComponentPropsWithoutRef<T>) => {
const { goto } = useRouterHelpers();
const { gotoState } = useEmbedGoto(noQueryParamMode);
className = classNames("hidden lg:inline-flex", className);
const openEmbedModal = () => {
goto({
gotoState({
dialog: "embed",
eventId: eventId ? eventId.toString() : "",
namespace,
@@ -1280,9 +1385,7 @@ export const EmbedButton = <T extends React.ElementType = typeof Button>({
data-test-embed-url={embedUrl}
data-testid="embed"
type="button"
onClick={() => {
openEmbedModal();
}}>
onClick={openEmbedModal}>
{children}
</Component>
);
+2 -1
View File
@@ -16,10 +16,11 @@ export const RoutingFormEmbedDialog = () => {
tabs={tabs}
eventTypeHideOptionDisabled={true}
defaultBrandColor={user ? { brandColor: user.brandColor, darkBrandColor: user.darkBrandColor } : null}
noQueryParamMode={true}
/>
);
};
export const RoutingFormEmbedButton = (props: ComponentProps<typeof EmbedButton>) => {
return <EmbedButton {...props} />;
return <EmbedButton {...props} noQueryParamMode={true} />;
};
@@ -0,0 +1,36 @@
import { createContext, useContext } from "react";
import React, { useState } from "react";
import type { EmbedState } from "../../types";
type EmbedDialogContextType = {
embedState: EmbedState;
setEmbedState: React.Dispatch<React.SetStateAction<EmbedState>>;
};
const EmbedDialogContext = createContext<EmbedDialogContextType | null>(null);
export function EmbedDialogProvider({ children }: { children: React.ReactNode }) {
const [embedState, setEmbedState] = useState<EmbedState>(null);
return (
<EmbedDialogContext.Provider value={{ embedState, setEmbedState }}>
{children}
</EmbedDialogContext.Provider>
);
}
export function useEmbedDialogCtx(noQueryParamMode: boolean) {
const context = useContext(EmbedDialogContext);
if (noQueryParamMode) {
if (!context) {
throw new Error("useEmbedDialogCtx must be used within an EmbedDialogProvider");
}
return context;
} else {
return {
embedState: null,
// eslint-disable-next-line @typescript-eslint/no-empty-function
setEmbedState: ((_state: EmbedState) => {}) as React.Dispatch<React.SetStateAction<EmbedState>>,
};
}
}
@@ -0,0 +1,39 @@
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import type { EmbedType } from "../../types";
import { useEmbedDialogCtx } from "./useEmbedDialogCtx";
type EmbedParams = {
embedType: EmbedType | null;
embedUrl: string;
namespace: string;
embedTabName: string | null;
eventId: string | null;
date: string | null;
month: string | null;
};
export function useEmbedParams(noQueryParamMode = false): EmbedParams {
const searchParams = useCompatSearchParams();
const { embedState } = useEmbedDialogCtx(noQueryParamMode);
return noQueryParamMode
? {
embedType: embedState?.embedType ?? null,
embedUrl: embedState?.embedUrl ?? "",
namespace: embedState?.namespace ?? "",
embedTabName: embedState?.embedTabName ?? null,
eventId: embedState?.eventId ?? null,
date: embedState?.date ?? null,
month: embedState?.month ?? null,
}
: {
embedType: (searchParams?.get("embedType") as EmbedType) ?? null,
embedUrl: (searchParams?.get("embedUrl") || "") as string,
namespace: (searchParams?.get("namespace") || "") as string,
embedTabName: (searchParams?.get("embedTabName") || "") as string,
eventId: (searchParams?.get("eventId") || "") as string,
date: (searchParams?.get("date") || "") as string,
month: (searchParams?.get("month") || "") as string,
};
}
+10
View File
@@ -9,6 +9,16 @@ type EmbedConfig = {
theme?: Theme;
};
export type EmbedState = {
embedType: EmbedType | null;
embedTabName: string | null;
embedUrl: string | null;
eventId: string | null;
namespace: string | null;
date: string | null;
month: string | null;
} | null;
export type PreviewState = {
inline: Brand<
{
+12 -3
View File
@@ -152,9 +152,18 @@ export function ShellMain(props: LayoutProps) {
variant="icon"
size="sm"
color="minimal"
onClick={() =>
typeof props.backPath === "string" ? router.push(props.backPath as string) : router.back()
}
onClick={() => {
if (typeof props.backPath === "string") {
// Prevents weird crash when navigating from /routing to /routing/forms
if (props.backPath.startsWith("/routing")) {
window.location.href = props.backPath;
} else {
router.push(props.backPath as string);
}
} else {
router.back();
}
}}
StartIcon="arrow-left"
aria-label="Go Back"
className="rounded-md ltr:mr-2 rtl:ml-2"
@@ -90,11 +90,11 @@ const getNavigationItems = (orgBranding: OrganizationBranding): NavigationItemTy
},
{
name: "routing",
href: "/apps/routing-forms/forms",
href: "/routing",
icon: "split",
badge: <Badge variant="green">NEW</Badge>,
isCurrent: ({ pathname }) => pathname?.startsWith("/apps/routing-forms/") ?? false,
isCurrent: ({ pathname }) => pathname?.startsWith("/routing") ?? false,
moreOnMobile: true,
},
{
@@ -57,6 +57,13 @@ export const NavigationItem: React.FC<{
<Link
data-test-id={item.name}
href={item.href}
onClick={(e) => {
// Prevents weird crash when navigating from /routing to /routing/forms
if (item.href.startsWith("/routing")) {
e.preventDefault();
window.location.href = item.href;
}
}}
aria-label={t(item.name)}
target={item.target}
className={classNames(