diff --git a/apps/web/components/booking/CancelBooking.tsx b/apps/web/components/booking/CancelBooking.tsx index ea04cd43c1..0c1189ca9c 100644 --- a/apps/web/components/booking/CancelBooking.tsx +++ b/apps/web/components/booking/CancelBooking.tsx @@ -47,6 +47,7 @@ export default function CancelBooking(props: Props) { const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => { if (node !== null) { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode node.scrollIntoView({ behavior: "smooth" }); node.focus(); } diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index ef9c89f03b..68a0a33f5d 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -292,6 +292,12 @@ export const createUsersFixture = ( { title: "Paid", slug: "paid", length: 30, price: 1000 }, { title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 }, { title: "Seated", slug: "seated", seatsPerTimeSlot: 2, length: 30 }, + { + title: "Multiple duration", + slug: "multiple-duration", + length: 30, + metadata: { multipleDuration: [30, 60, 90] }, + }, ]; if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes); diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 869eb5e5a4..62295db05f 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -417,6 +417,10 @@

Test Column View

+
+

Test Auto Scroll

+
+
diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index 4eb4141f65..08b7e45b55 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -531,6 +531,22 @@ if (only === "all" || only == "ns:columnView") { }); } +if (only === "all" || only == "ns:autoScrollTest") { + if (!calLink) { + throw new Error("cal-link parameter is required for autoScrollTest"); + } + Cal("init", "autoScrollTest", { + debug: true, + origin: origin, + }); + Cal.ns.autoScrollTest("inline", { + elementOrSelector: "#cal-booking-place-autoScrollTest .place", + calLink: calLink, + config: { + "flag.coep": "true", + }, + }); +} // Verifies that the type of e.detail.data is valid. type-check will fail if we accidentally break it. const bookingSuccessfulV2Callback = (e: EmbedEvent<"bookingSuccessfulV2">) => { const data = e.detail.data; diff --git a/packages/embeds/embed-core/playwright/lib/testUtils.ts b/packages/embeds/embed-core/playwright/lib/testUtils.ts index 8b9a5b1df5..7f0e405b74 100644 --- a/packages/embeds/embed-core/playwright/lib/testUtils.ts +++ b/packages/embeds/embed-core/playwright/lib/testUtils.ts @@ -29,6 +29,9 @@ export const getBooking = async (bookingId: string) => { return booking; }; +/** + * @deprecated use ensureEmbedIframe instead. + */ export const getEmbedIframe = async ({ calNamespace, page, @@ -59,6 +62,22 @@ export const getEmbedIframe = async ({ return null; }; +export const ensureEmbedIframe = async ({ + calNamespace, + page, + pathname, +}: { + calNamespace: string; + page: Page; + pathname: string; +}) => { + const embedIframe = await getEmbedIframe({ calNamespace, page, pathname }); + if (!embedIframe) { + throw new Error("Embed iframe not found"); + } + return embedIframe; +}; + async function selectFirstAvailableTimeSlotNextMonth(frame: Frame, page: Page) { await frame.click('[data-testid="incrementMonth"]'); diff --git a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts index afca976dd5..f769532b50 100644 --- a/packages/embeds/embed-core/playwright/tests/inline.e2e.ts +++ b/packages/embeds/embed-core/playwright/tests/inline.e2e.ts @@ -6,29 +6,23 @@ import { assertNoRequestIsBlocked, bookFirstEvent, deleteAllBookingsByEmail, - getEmbedIframe, + ensureEmbedIframe, } from "../lib/testUtils"; test.describe("Inline Iframe", () => { - test("Inline Iframe - Configured with Dark Theme. Do booking and verify that COEP/CORP headers are correctly set", async ({ + test("Configured with Dark Theme. Do booking and verify that COEP/CORP headers are correctly set", async ({ page, - embeds: { addEmbedListeners, getActionFiredDetails }, + embeds, }) => { await deleteAllBookingsByEmail("embed-user@example.com"); - await addEmbedListeners(""); - await page.goto("/?only=ns:default"); + await embeds.gotoPlayground({ calNamespace: "", url: "/?only=ns:default" }); const calNamespace = ""; - const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/pro" }); - expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, { - pathname: "/pro", + const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: "/pro" }); + expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, { searchParams: { theme: "dark", }, }); - // expect(await page.screenshot()).toMatchSnapshot("event-types-list.png"); - if (!embedIframe) { - throw new Error("Embed iframe not found"); - } assertNoRequestIsBlocked(page); @@ -57,6 +51,17 @@ test.describe("Inline Iframe", () => { }); }); + test("Ensure iframe doesn't hijack scroll in embed mode", async ({ page, embeds, users }) => { + const user = await users.create(); + const calNamespace = "autoScrollTest"; + await embeds.gotoPlayground({ calNamespace, url: `?only=ns:autoScrollTest` }); + const calLink = `${user.username}/multiple-duration`; + await page.goto(`/?only=ns:autoScrollTest&cal-link=${calLink}`); + const embedIframe = await ensureEmbedIframe({ calNamespace, page, pathname: `/${calLink}` }); + const finalScrollPosition = await page.evaluate(() => window.scrollY); + expect(finalScrollPosition).toBe(0); + }); + todo( "Ensure that on all pages - [user], [user]/[type], team/[slug], team/[slug]/book, UI styling works if these pages are directly linked in embed" ); diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts index 10a947cf6b..8f6e1d10a8 100644 --- a/packages/embeds/embed-core/src/embed.ts +++ b/packages/embeds/embed-core/src/embed.ts @@ -401,6 +401,7 @@ export class Cal { // Try to readjust and scroll into view if more than 25% is hidden. // Otherwise we assume that user might have positioned the content appropriately already if (top < 0 && Math.abs(top / height) >= 0.25) { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Intentionally done this.inlineEl.scrollIntoView({ behavior: "smooth" }); } }); diff --git a/packages/eslint-plugin/src/configs/recommended.ts b/packages/eslint-plugin/src/configs/recommended.ts index 2e4d48e90b..0569470233 100644 --- a/packages/eslint-plugin/src/configs/recommended.ts +++ b/packages/eslint-plugin/src/configs/recommended.ts @@ -7,6 +7,7 @@ const recommended = { "@calcom/eslint/avoid-web-storage": "error", "@calcom/eslint/avoid-prisma-client-import-for-enums": "error", "@calcom/eslint/no-prisma-include-true": "warn", + "@calcom/eslint/no-scroll-into-view-embed": "error", }, }; diff --git a/packages/eslint-plugin/src/rules/index.ts b/packages/eslint-plugin/src/rules/index.ts index 9f67c105d9..cb28efdeec 100644 --- a/packages/eslint-plugin/src/rules/index.ts +++ b/packages/eslint-plugin/src/rules/index.ts @@ -7,4 +7,5 @@ export default { "avoid-prisma-client-import-for-enums": require("./avoid-prisma-client-import-for-enums").default, "no-prisma-include-true": require("./no-prisma-include-true").default, "deprecated-imports-next-router": require("./deprecated-imports-next-router").default, + "no-scroll-into-view-embed": require("./no-scroll-into-view-embed").default, } as ESLint.Plugin["rules"]; diff --git a/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts b/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts new file mode 100644 index 0000000000..453ea7bc5f --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts @@ -0,0 +1,42 @@ +import type { TSESTree } from "@typescript-eslint/utils"; +import { ESLintUtils } from "@typescript-eslint/utils"; + +const createRule = ESLintUtils.RuleCreator((name) => `https://developer.cal.com/eslint/rule/${name}`); + +export default createRule({ + name: "no-scroll-into-view-embed", + meta: { + docs: { + description: "Disallow usage of scrollIntoView in embed mode", + recommended: "error", + }, + messages: { + noScrollIntoViewForEmbed: + "Make sure to call scrollIntoView conditionally if it is called without user action. Use useIsEmbed() to detect if embed mode and then don't call it for embed case.", + }, + type: "problem", + schema: [], + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node: TSESTree.CallExpression) { + const { callee } = node; + + if (callee.type === "MemberExpression") { + if (callee.property.type === "Identifier" && callee.property.name === "scrollIntoView") { + context.report({ + node, + messageId: "noScrollIntoViewForEmbed", + }); + } + } else if (callee.type === "Identifier" && callee.name === "scrollIntoView") { + context.report({ + node, + messageId: "noScrollIntoViewForEmbed", + }); + } + }, + }; + }, +}); diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx index 15a7cef368..6f4090b315 100644 --- a/packages/features/bookings/Booker/Booker.tsx +++ b/packages/features/bookings/Booker/Booker.tsx @@ -138,6 +138,7 @@ const BookerComponent = ({ const scrolledToTimeslotsOnce = useRef(false); const scrollToTimeSlots = () => { if (isMobile && !isEmbed && !scrolledToTimeslotsOnce.current) { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Conditional within !isEmbed condition timeslotsRef.current?.scrollIntoView({ behavior: "smooth" }); scrolledToTimeslotsOnce.current = true; } diff --git a/packages/features/bookings/Booker/components/hooks/useBookings.ts b/packages/features/bookings/Booker/components/hooks/useBookings.ts index 49e9d9aabe..e21cedb301 100644 --- a/packages/features/bookings/Booker/components/hooks/useBookings.ts +++ b/packages/features/bookings/Booker/components/hooks/useBookings.ts @@ -231,6 +231,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb }); }, onError: (err, _, ctx) => { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); }, }); @@ -243,7 +244,7 @@ export const useBookings = ({ event, hashedLink, bookingForm, metadata, teamMemb }, onError: (err, _, ctx) => { console.error("Error creating instant booking", err); - + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- It is only called when user takes an action in embed bookerFormErrorRef && bookerFormErrorRef.current?.scrollIntoView({ behavior: "smooth" }); }, }); diff --git a/packages/features/bookings/components/event-meta/Duration.tsx b/packages/features/bookings/components/event-meta/Duration.tsx index a897893e5a..b6bcc3d7c0 100644 --- a/packages/features/bookings/components/event-meta/Duration.tsx +++ b/packages/features/bookings/components/event-meta/Duration.tsx @@ -2,6 +2,7 @@ import type { TFunction } from "next-i18next"; import { useEffect, useRef } from "react"; import { useIsPlatform } from "@calcom/atoms/monorepo"; +import { useIsEmbed } from "@calcom/embed-core/embed-iframe"; import { useBookerStore } from "@calcom/features/bookings/Booker/store"; import type { BookerEvent } from "@calcom/features/bookings/types"; import { classNames } from "@calcom/lib"; @@ -64,7 +65,7 @@ export const EventDuration = ({ }; const isDynamicEvent = "isDynamic" in event && event.isDynamic; - + const isEmbed = useIsEmbed(); // Sets initial value of selected duration to the default duration. useEffect(() => { // Only store event duration in url if event has multiple durations. @@ -74,7 +75,9 @@ export const EventDuration = ({ useEffect(() => { const timeout = setTimeout(() => { + if (isEmbed) return; if (selectedDuration && itemRefs.current[selectedDuration]) { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Called on !isEmbed case itemRefs.current[selectedDuration]?.scrollIntoView({ behavior: "smooth", block: "center", @@ -83,7 +86,7 @@ export const EventDuration = ({ } }, 100); return () => clearTimeout(timeout); - }, [selectedDuration]); + }, [selectedDuration, isEmbed]); if ((!event?.metadata?.multipleDuration && !isDynamicEvent) || isPlatform) return <>{getDurationFormatted(event.length, t)}; diff --git a/packages/features/calendars/weeklyview/components/currentTime/index.tsx b/packages/features/calendars/weeklyview/components/currentTime/index.tsx index 2840031461..41a28698d8 100644 --- a/packages/features/calendars/weeklyview/components/currentTime/index.tsx +++ b/packages/features/calendars/weeklyview/components/currentTime/index.tsx @@ -39,6 +39,7 @@ export function CurrentTime() { if (!currentTimeRef.current || scrolledIntoView) return; // Within a small timeout so element has time to render. setTimeout(() => { + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Does't seem to cause any issue. Put it under condition if needed currentTimeRef?.current?.scrollIntoView({ block: "center" }); setScrolledIntoView(true); }, 100); diff --git a/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx b/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx index def3e1ce71..16af97a717 100644 --- a/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx +++ b/packages/features/settings/appDir/SettingsLayoutAppDirClient.tsx @@ -259,6 +259,7 @@ const TeamListCollapsible = () => { const tabMembers = Array.from(document.getElementsByTagName("a")).filter( (bottom) => bottom.dataset.testid === "vertical-tab-Members" )[1]; + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded tabMembers?.scrollIntoView({ behavior: "smooth" }); }, 100); } @@ -418,6 +419,7 @@ const SettingsSidebarContainer = ({ const tabMembers = Array.from(document.getElementsByTagName("a")).filter( (bottom) => bottom.dataset.testid === "vertical-tab-Members" )[1]; + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded tabMembers?.scrollIntoView({ behavior: "smooth" }); }, 100); } diff --git a/packages/features/settings/layouts/SettingsLayout.tsx b/packages/features/settings/layouts/SettingsLayout.tsx index 2243cb6b35..35aa012466 100644 --- a/packages/features/settings/layouts/SettingsLayout.tsx +++ b/packages/features/settings/layouts/SettingsLayout.tsx @@ -259,6 +259,7 @@ const TeamListCollapsible = () => { const tabMembers = Array.from(document.getElementsByTagName("a")).filter( (bottom) => bottom.dataset.testid === "vertical-tab-Members" )[1]; + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded tabMembers?.scrollIntoView({ behavior: "smooth" }); }, 100); } @@ -424,6 +425,7 @@ const SettingsSidebarContainer = ({ const tabMembers = Array.from(document.getElementsByTagName("a")).filter( (bottom) => bottom.dataset.testid === "vertical-tab-Members" )[1]; + // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded tabMembers?.scrollIntoView({ behavior: "smooth" }); }, 100); } diff --git a/playwright.config.ts b/playwright.config.ts index 27f202ac71..4f9fbbec3c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -200,12 +200,14 @@ expect.extend({ const u = new URL(iframe.url()); const pathname = u.pathname; - const expectedPathname = `${expectedUrlDetails.pathname}/embed`; - if (expectedPathname && expectedPathname !== pathname) { - return { - pass: false, - message: () => `Expected pathname to be ${expectedPathname} but got ${pathname}`, - }; + if (expectedUrlDetails.pathname) { + const expectedPathname = `${expectedUrlDetails.pathname}/embed`; + if (pathname !== expectedPathname) { + return { + pass: false, + message: () => `Expected pathname to be ${expectedPathname} but got ${pathname}`, + }; + } } const origin = u.origin;