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:
1 parent
6364e85293
commit
f6f964d495
14 files changed
+376
-129
No files matched your search
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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<
|
||||
{
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user