Skip to content

Commit

Permalink
chore: refactor handling logic for embeds in app router (#18362)
Browse files Browse the repository at this point in the history
* refactor handling logic for embeds in app router

* fix type checks

* add test for withEmbedSsrAppDir

* fix
  • Loading branch information
hbjORbj authored Dec 26, 2024
1 parent ae38027 commit 3994929
Show file tree
Hide file tree
Showing 17 changed files with 300 additions and 132 deletions.
247 changes: 247 additions & 0 deletions apps/web/app/WithEmbedSSR.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse, Redirect } from "next";
import { redirect, notFound } from "next/navigation";
import { createMocks } from "node-mocks-http";
import { describe, expect, it, vi } from "vitest";

import withEmbedSsrAppDir from "./WithEmbedSSR";

export type CustomNextApiRequest = NextApiRequest & Request;
export type CustomNextApiResponse = NextApiResponse & Response;

export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
}

vi.mock("next/navigation", () => ({
redirect: vi.fn(),
notFound: vi.fn(),
}));

function getServerSidePropsFnGenerator(
config:
| { redirectUrl: string }
| { props: Record<string, unknown> }
| {
notFound: true;
}
) {
if ("redirectUrl" in config)
return async () => {
return {
redirect: {
permanent: false,
destination: config.redirectUrl,
} satisfies Redirect,
};
};

if ("props" in config)
return async () => {
return {
props: config.props,
};
};

if ("notFound" in config)
return async () => {
return {
notFound: true as const,
};
};

throw new Error("Invalid config");
}

interface ServerSidePropsContext {
embedRelatedParams?: Record<string, string>;
}

function getServerSidePropsContextArg({ embedRelatedParams = {} }: ServerSidePropsContext) {
const { req, res } = createMockNextJsRequest();
return {
req,
res,
query: {
...embedRelatedParams,
},
resolvedUrl: "/MOCKED_RESOLVED_URL",
};
}

describe("withEmbedSsrAppDir", () => {
describe("when gSSP returns redirect", () => {
describe("when redirect destination is relative", () => {
it("should redirect with layout and embed params from the current query", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith("/reschedule/embed?layout=week_view&embed=namespace1");
});

it("should preserve existing query params in redirect URL", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith(
"/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1"
);
});

it("should handle empty embed namespace", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "/reschedule?redirectParam=1",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith("/reschedule/embed?redirectParam=1&layout=week_view&embed=");
});
});

describe("when redirect destination is absolute", () => {
it("should handle HTTPS URLs", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "https://calcom.cal.local/owner",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith(
"https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});

it("should handle HTTP URLs", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "http://calcom.cal.local/owner",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith(
"http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});

it("should treat URLs without protocol as relative", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
redirectUrl: "calcom.cal.local/owner",
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "namespace1",
},
})
).catch(() => {});

expect(redirect).toHaveBeenCalledWith(
"/calcom.cal.local/owner/embed?layout=week_view&embed=namespace1"
);
});
});
});

describe("when gSSP returns props", () => {
it("should add isEmbed=true prop", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
props: {
prop1: "value1",
},
})
);

const ret = await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
);

expect(ret).toEqual({
prop1: "value1",
isEmbed: true,
});
});
});

describe("when gSSP returns notFound", () => {
it("should throw notFound", async () => {
const withEmbedGetSsr = withEmbedSsrAppDir(
getServerSidePropsFnGenerator({
notFound: true,
})
);

await withEmbedGetSsr(
getServerSidePropsContextArg({
embedRelatedParams: {
layout: "week_view",
embed: "",
},
})
).catch(() => {});

expect(notFound).toHaveBeenCalled();
});
});
});
80 changes: 38 additions & 42 deletions apps/web/app/WithEmbedSSR.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import type { GetServerSidePropsContext } from "next";
import { isNotFoundError } from "next/dist/client/components/not-found";
import { getURLFromRedirectError, isRedirectError } from "next/dist/client/components/redirect";
import type { GetServerSideProps, GetServerSidePropsContext } from "next";
import { notFound, redirect } from "next/navigation";

import { WebAppURL } from "@calcom/lib/WebAppURL";
Expand All @@ -9,48 +7,46 @@ export type EmbedProps = {
isEmbed?: boolean;
};

export default function withEmbedSsrAppDir<T extends Record<string, any>>(
getData: (context: GetServerSidePropsContext) => Promise<T>
) {
return async (context: GetServerSidePropsContext): Promise<T> => {
const withEmbedSsrAppDir =
<T extends Record<string, any>>(getServerSideProps: GetServerSideProps<T>) =>
async (context: GetServerSidePropsContext): Promise<T> => {
const { embed, layout } = context.query;
try {
const props = await getData(context);

return {
...props,
isEmbed: true,
};
} catch (e) {
if (isRedirectError(e)) {
const destinationUrl = getURLFromRedirectError(e);
let urlPrefix = "";

// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
const destinationUrlObj = new WebAppURL(destinationUrl);

// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
urlPrefix = destinationUrlObj.origin;
} else {
// Don't use any prefix for relative URLs to ensure we stay on the same domain
urlPrefix = "";
}

const destinationQueryStr = destinationUrlObj.searchParams.toString();
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
destinationQueryStr ? `${destinationQueryStr}&` : ""
}layout=${layout}&embed=${embed}`;

redirect(newDestinationUrl);
}
const ssrResponse = await getServerSideProps(context);

if ("redirect" in ssrResponse) {
const destinationUrl = ssrResponse.redirect.destination;
let urlPrefix = "";

if (isNotFoundError(e)) {
notFound();
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
const destinationUrlObj = new WebAppURL(ssrResponse.redirect.destination);

// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
urlPrefix = destinationUrlObj.origin;
} else {
// Don't use any prefix for relative URLs to ensure we stay on the same domain
urlPrefix = "";
}

throw e;
const destinationQueryStr = destinationUrlObj.searchParams.toString();
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
destinationQueryStr ? `${destinationQueryStr}&` : ""
}layout=${layout}&embed=${embed}`;
redirect(newDestinationUrl);
}

if ("notFound" in ssrResponse) {
notFound();
}

return {
...ssrResponse.props,
...("trpcState" in ssrResponse.props && {
dehydratedState: ssrResponse.props.trpcState,
}),
isEmbed: true,
};
};
}

export default withEmbedSsrAppDir;
5 changes: 1 addition & 4 deletions apps/web/app/booking/[uid]/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";

import OldPage from "~/bookings/views/bookings-single-view";
import { getServerSideProps, type PageProps } from "~/bookings/views/bookings-single-view.getServerSideProps";

const getData = withAppDirSsr<PageProps>(getServerSideProps);

const getEmbedData = withEmbedSsrAppDir(getData);
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);

export default WithLayout({ getLayout: null, getData: getEmbedData, Page: OldPage });
7 changes: 1 addition & 6 deletions apps/web/app/future/[user]/[type]/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,10 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";

import { getServerSideProps } from "@server/lib/[user]/[type]/getServerSideProps";

import LegacyPage, { type PageProps } from "~/users/views/users-type-public-view";

export { generateMetadata } from "../page";

const getData = withAppDirSsr<PageProps>(getServerSideProps);

const getEmbedData = withEmbedSsrAppDir(getData);
const getEmbedData = withEmbedSsrAppDir<PageProps>(getServerSideProps);

export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
7 changes: 1 addition & 6 deletions apps/web/app/future/[user]/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import withEmbedSsrAppDir from "app/WithEmbedSSR";
import { WithLayout } from "app/layoutHOC";

Expand All @@ -7,10 +6,6 @@ import { getServerSideProps } from "@server/lib/[user]/getServerSideProps";
import type { PageProps as UserPageProps } from "~/users/views/users-public-view";
import LegacyPage from "~/users/views/users-public-view";

export { generateMetadata } from "../page";

const getData = withAppDirSsr<UserPageProps>(getServerSideProps);

const getEmbedData = withEmbedSsrAppDir(getData);
const getEmbedData = withEmbedSsrAppDir<UserPageProps>(getServerSideProps);

export default WithLayout({ getLayout: null, getData: getEmbedData, Page: LegacyPage })<"P">;
Loading

0 comments on commit 3994929

Please sign in to comment.