From c6fc30b12183819c9358f03bac90d14e18f61ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Pretto?= Date: Wed, 25 Sep 2024 12:09:26 +0200 Subject: [PATCH 1/4] empty commit to allow opening the pr From 7a355c400adaa906a20539d2560646e9a91788f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Pretto?= Date: Wed, 25 Sep 2024 12:45:11 +0200 Subject: [PATCH 2/4] adds reference to embedding sdk on the embedding homepage (#47975) --- .../home/components/EmbedHomepage/Badge.ts | 27 +++++++++++++++++++ .../EmbedHomepage/EmbedHomepage.tsx | 1 + .../EmbedHomepage/EmbedHomepageView.tsx | 23 ++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 frontend/src/metabase/home/components/EmbedHomepage/Badge.ts diff --git a/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts b/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts new file mode 100644 index 0000000000000..9fbbde0545b44 --- /dev/null +++ b/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts @@ -0,0 +1,27 @@ +import styled from "@emotion/styled"; + +type BadgeColor = "brand" | "gray"; + +const COLOR_VARIANTS = { + brand: { + color: "var(--mb-color-text-white)", + background: "var(--mb-color-brand)", + }, + gray: { + color: "var(--mb-color-text-black)", + background: "var(--mb-base-color-gray-20)", + }, +}; + +export const Badge = styled.span<{ color: BadgeColor; uppercase?: boolean }>` + padding: 0px 4px; + display: inline-block; + line-height: 1rem; + font-size: 0.625rem; + font-weight: 700; + border-radius: 4px; + text-transform: ${props => + (props.uppercase ?? true) ? "uppercase" : "none"}; + color: ${props => COLOR_VARIANTS[props.color].color}; + background: ${props => COLOR_VARIANTS[props.color].background}; +`; diff --git a/frontend/src/metabase/home/components/EmbedHomepage/EmbedHomepage.tsx b/frontend/src/metabase/home/components/EmbedHomepage/EmbedHomepage.tsx index 39e68763611a5..9618fee779928 100644 --- a/frontend/src/metabase/home/components/EmbedHomepage/EmbedHomepage.tsx +++ b/frontend/src/metabase/home/components/EmbedHomepage/EmbedHomepage.tsx @@ -106,6 +106,7 @@ export const EmbedHomepage = () => { } learnMoreInteractiveEmbedUrl={learnMoreInteractiveEmbedding + utmTags} learnMoreStaticEmbedUrl={learnMoreStaticEmbedding + utmTags} + sdkUrl={"https://metaba.se/sdk" + utmTags} /> { @@ -39,6 +41,7 @@ export const EmbedHomepageView = (props: EmbedHomepageViewProps) => { initialTab, embeddingDocsUrl, analyticsDocsUrl, + sdkUrl, onDismiss, } = props; return ( @@ -86,6 +89,26 @@ export const EmbedHomepageView = (props: EmbedHomepageViewProps) => { + + + {/* eslint-disable-next-line no-literal-metabase-strings -- only visible to admins */} + {t`New in Metabase 0.51`} + + + {t`Embedded analytics SDK`} + {t`PRO & ENTERPRISE`} + {t`Beta`} + + + + {/* eslint-disable-next-line no-literal-metabase-strings -- only visible to admins */} + {t`Interactive embedding with full, granular control. Embed and style individual Metabase components in your app, and tailor the experience to each person. Allows for CSS styling, custom user flows, event subscriptions, and more. Only available with SSO via JWT.`}{" "} + + {t`Read more in the docs.`} + + + + {embeddingAutoEnabled && ( Date: Thu, 3 Oct 2024 19:28:44 +0200 Subject: [PATCH 3/4] 47840 - surface sdk in share modal (#47967) * WIP code * use svg illustrations instead of png-in-svg * public embed ui * fix unit tests * more unit tests fixes * e2e updates * fix a bunch of tests by updating a helper * update other tests * update other tests * update more tests * test * unskip test and mock clipboardData to avoid window.prompt pausing the test * hopefully the last test i have to update * something something removing empty beforeEach * cleanup while doing self review * update unit test * move public embed card to its own file * styled -> css modules * wip copy * remove unused lint rule * update copy * fix: close popover when clicking outside of it * remove "seamless" and use only "PRO" in the badge * fix padding of public embed card popover --- e2e/support/helpers/e2e-embedding-helpers.js | 2 +- ...rd-filters-with-question-revert.cy.spec.js | 2 +- ...c-sharing-embed-button-behavior.cy.spec.js | 61 ++--- .../sharing/subscriptions.cy.spec.js | 4 +- .../home/components/EmbedHomepage/Badge.ts | 1 + .../EmbedModalContent.unit.spec.tsx | 20 +- .../InteractiveEmbeddingCTA.styled.tsx | 22 -- .../InteractiveEmbeddingCTA.tsx | 106 -------- .../InteractiveEmbeddingCTA/index.ts | 1 - .../test/common.unit.spec.tsx | 22 -- .../test/enterprise.unit.spec.tsx | 31 --- .../test/premium.unit.spec.tsx | 34 --- .../InteractiveEmbeddingCTA/test/setup.tsx | 43 ---- .../SelectEmbedTypePane/PublicEmbedCard.tsx | 87 +++++++ .../SelectEmbedTypePane.tsx | 236 +++++++++++------- .../SelectEmbedTypePane.unit.spec.tsx | 81 ++---- .../SharingPaneButton.module.css | 13 + .../SharingPaneButton/SharingPaneButton.tsx | 60 ++--- .../PublicEmbedIcon.styled.tsx | 33 --- .../icons/PublicEmbedIcon/PublicEmbedIcon.tsx | 33 --- .../StaticEmbedIcon.styled.tsx | 21 -- .../icons/StaticEmbedIcon/StaticEmbedIcon.tsx | 38 --- .../SelectEmbedTypePane/icons/index.ts | 2 - .../illustrations/embedding-sdk.svg | 11 + .../illustrations/interactive-embedding.svg | 11 + .../illustrations/static-embedding.svg | 12 + 26 files changed, 380 insertions(+), 607 deletions(-) delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.styled.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/index.ts delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/common.unit.spec.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/enterprise.unit.spec.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/premium.unit.spec.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/setup.tsx create mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/PublicEmbedCard.tsx create mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx delete mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts create mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg create mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg create mode 100644 frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index c6f03faf7158d..934ab36c53182 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -169,7 +169,7 @@ export function openStaticEmbeddingModal({ cy.findByRole("button", { name: "Save" }).click(); } - cy.findByTestId("sharing-pane-static-embed-button").click(); + cy.findByText("Static embedding").click(); if (acceptTerms) { cy.findByTestId("accept-legalese-terms-button").click(); diff --git a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js index a8206e1f9030e..b2bbe43ca3e6c 100644 --- a/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js +++ b/e2e/test/scenarios/filters-reproductions/dashboard-filters-with-question-revert.cy.spec.js @@ -226,7 +226,7 @@ describe("issue 35954", () => { visitDashboard(id); openSharingMenu("Embed"); - modal().findByText("Static embed").click(); + modal().findByText("Static embedding").click(); cy.findByTestId("embedding-preview").within(() => { cy.intercept("GET", "api/preview_embed/dashboard/**").as( diff --git a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js index ee6299725f253..0724a10c14776 100644 --- a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js @@ -157,8 +157,11 @@ import { cy.findByTestId("embed-menu-embed-modal-item").click(); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); + cy.findByText("Static embedding").should("be.visible"); + cy.findByText(/Use public embedding/).should("not.exist"); + cy.findByText("Public embeds and links are disabled.").should( + "be.visible", + ); }); }); }); @@ -190,23 +193,17 @@ describe("embed modal display", () => { }); describeEE("when the user has a paid instance", () => { - it("should display a link to the interactive embedding settings", () => { + it("should display a link to the Interactive embedding settings", () => { setTokenFeatures("all"); visitDashboard("@dashboardId"); openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); - cy.findByTestId("interactive-embedding-cta").within(() => { - cy.findByText("Interactive Embedding").should("be.visible"); - cy.findByText( - "Your plan allows you to use Interactive Embedding create interactive embedding experiences with drill-through and more.", - ).should("be.visible"); - cy.findByText("Set it up").should("be.visible"); - }); - cy.findByTestId("interactive-embedding-cta").click(); + cy.findByText("Static embedding").should("be.visible"); + cy.findByText("Interactive embedding").should("be.visible"); + + cy.findByText("Interactive embedding").click(); cy.url().should( "equal", @@ -224,16 +221,10 @@ describe("embed modal display", () => { openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Static embed").should("be.visible"); - cy.findByText("Public embed").should("be.visible"); - cy.findByTestId("interactive-embedding-cta").within(() => { - cy.findByText("Interactive Embedding").should("be.visible"); - cy.findByText( - "Give your customers the full power of Metabase in your own app, with SSO, advanced permissions, customization, and more.", - ).should("be.visible"); - cy.findByText("Learn more").should("be.visible"); - }); - cy.findByTestId("interactive-embedding-cta").should( + cy.findByText("Static embedding").should("be.visible"); + cy.findByText("Interactive embedding").should("be.visible"); + + cy.findByRole("link", { name: /Interactive embedding/ }).should( "have.attr", "href", "https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedding-interactive&utm_content=static-embed-popover&source_plan=oss", @@ -361,10 +352,20 @@ describe("#39152 sharing an unsaved question", () => { }); openSharingMenu("Embed"); - cy.findByTestId("sharing-pane-public-embed-button").within(() => { - cy.findByText("Get an embed link").click(); - cy.findByTestId("copy-button").realClick(); + + modal().findByText("Get embedding code").click(); + + // mock clipboardData so that copy-to-clipboard doesn't use window.prompt, pausing the tests + cy.window().then(win => { + win.clipboardData = { + setData: (...args) => + // eslint-disable-next-line no-console + console.log("clipboardData.setData", ...args), + }; }); + + popover().findByTestId("copy-button").click(); + expectGoodSnowplowEvent({ event: "public_embed_code_copied", artifact: resource, @@ -378,10 +379,10 @@ describe("#39152 sharing an unsaved question", () => { }); openSharingMenu("Embed"); - cy.findByTestId("sharing-pane-public-embed-button").within(() => { - cy.findByText("Get an embed link").click(); - cy.button("Remove public URL").click(); - }); + modal().findByText("Get embedding code").click(); + + popover().findByText("Remove public link").click(); + expectGoodSnowplowEvent({ event: "public_link_removed", artifact: resource, diff --git a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js index 77ff3f2d5539e..0a1d18e4b8e2f 100644 --- a/e2e/test/scenarios/sharing/subscriptions.cy.spec.js +++ b/e2e/test/scenarios/sharing/subscriptions.cy.spec.js @@ -52,8 +52,8 @@ describe("scenarios > dashboard > subscriptions", () => { openSharingMenu("Embed"); getEmbedModalSharingPane().within(() => { - cy.findByText("Public embed").should("be.visible"); - cy.findByText("Static embed").should("be.visible"); + cy.findByText("public embedding").should("be.visible"); + cy.findByText("Static embedding").should("be.visible"); }); }); diff --git a/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts b/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts index 9fbbde0545b44..f99313bc90b6b 100644 --- a/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts +++ b/frontend/src/metabase/home/components/EmbedHomepage/Badge.ts @@ -13,6 +13,7 @@ const COLOR_VARIANTS = { }, }; +// TODO: use Badge from metabase/ui when it's available export const Badge = styled.span<{ color: BadgeColor; uppercase?: boolean }>` padding: 0px 4px; display: inline-block; diff --git a/frontend/src/metabase/public/components/EmbedModal/EmbedModalContent/EmbedModalContent.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/EmbedModalContent/EmbedModalContent.unit.spec.tsx index 1287dcf57c6ae..dcde811b9b57b 100644 --- a/frontend/src/metabase/public/components/EmbedModal/EmbedModalContent/EmbedModalContent.unit.spec.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/EmbedModalContent/EmbedModalContent.unit.spec.tsx @@ -14,8 +14,9 @@ describe("EmbedModalContent", () => { it("should render", () => { setup(); - expect(screen.getByText("Static embed")).toBeInTheDocument(); - expect(screen.getByText("Public embed")).toBeInTheDocument(); + expect(screen.getByText("Static embedding")).toBeInTheDocument(); + expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); }); it("should switch to StaticEmbedSetupPane", async () => { @@ -23,7 +24,7 @@ describe("EmbedModalContent", () => { expect(goToNextStep).toHaveBeenCalledTimes(0); - await userEvent.click(screen.getByText("Set this up")); + await userEvent.click(screen.getByText("Static embedding")); expect(goToNextStep).toHaveBeenCalledTimes(1); }); @@ -37,6 +38,19 @@ describe("EmbedModalContent", () => { expect(screen.getByText("Setting up a static embed")).toBeInTheDocument(); }); + + it("should mention the sdk and link to metaba.se/sdk", () => { + setup(); + + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: /Embedded analytics SDK/ }), + ).toHaveAttribute( + "href", + "https://metaba.se/sdk?utm_source=product&source_plan=oss&utm_content=embed-modal", + ); + }); }); }); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.styled.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.styled.tsx deleted file mode 100644 index 203f6678cea05..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.styled.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import styled from "@emotion/styled"; - -import type { PaperProps } from "metabase/ui"; -import { Group, Icon, Paper, Title } from "metabase/ui"; - -export const ProBadge = styled(Group)` - border-radius: ${({ theme }) => theme.radius.xs}; -`; - -export const CTAContainer = styled(Paper)``; - -export const ClickIcon = styled(Icon)` - ${CTAContainer}:hover & { - color: var(--mb-color-brand); - } -`; - -export const CTAHeader = styled(Title)` - ${CTAContainer}:hover & { - color: var(--mb-color-brand); - } -`; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.tsx deleted file mode 100644 index 6117e347bb8de..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/InteractiveEmbeddingCTA.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import { t } from "ttag"; - -import { getPlan } from "metabase/common/utils/plan"; -import Link from "metabase/core/components/Link"; -import { useSelector } from "metabase/lib/redux"; -import { PLUGIN_EMBEDDING } from "metabase/plugins"; -import { getSetting } from "metabase/selectors/settings"; -import { Box, Group, Stack, Text } from "metabase/ui"; - -import { - CTAContainer, - CTAHeader, - ClickIcon, - ProBadge, -} from "./InteractiveEmbeddingCTA.styled"; - -const useCTAText = () => { - const isInteractiveEmbeddingEnabled = useSelector( - PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled, - ); - const plan = useSelector(state => - getPlan(getSetting(state, "token-features")), - ); - - if (isInteractiveEmbeddingEnabled) { - return { - showProBadge: false, - description: t`Your plan allows you to use Interactive Embedding create interactive embedding experiences with drill-through and more.`, - linkText: t`Set it up`, - url: "/admin/settings/embedding-in-other-applications/full-app", - }; - } - - return { - showProBadge: true, - // eslint-disable-next-line no-literal-metabase-strings -- This only shows for admins - description: t`Give your customers the full power of Metabase in your own app, with SSO, advanced permissions, customization, and more.`, - linkText: t`Learn more`, - url: `https://www.metabase.com/product/embedded-analytics?${new URLSearchParams( - { - utm_source: "product", - utm_medium: "upsell", - utm_campaign: "embedding-interactive", - utm_content: "static-embed-popover", - source_plan: plan, - }, - )}`, - target: "_blank", - }; -}; - -export const InteractiveEmbeddingCTA = () => { - const { showProBadge, description, linkText, url, target } = useCTAText(); - - const badge = ( - // TODO: Check padding because design keeps using non-mantine-standard units - - - {t`Pro`} - - - ); - - return ( - - - - - - - - - {t`Interactive Embedding`} - {showProBadge && badge} - - - {description} - {" "} - - {linkText} - - - - - - - ); -}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/index.ts b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/index.ts deleted file mode 100644 index 49265691942e8..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./InteractiveEmbeddingCTA"; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/common.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/common.unit.spec.tsx deleted file mode 100644 index 83c6ea5a42b09..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/common.unit.spec.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { screen } from "__support__/ui"; - -import { setup } from "./setup"; - -describe("InteractiveEmbeddingCTA", () => { - it("should display a CTA to the product page when plan is OSS", () => { - setup(); - - expect(screen.getByText("Interactive Embedding")).toBeInTheDocument(); - expect(screen.getByText("Pro")).toBeInTheDocument(); - expect( - screen.getByText( - "Give your customers the full power of Metabase in your own app, with SSO, advanced permissions, customization, and more.", - ), - ).toBeInTheDocument(); - - expect(screen.getByTestId("interactive-embedding-cta")).toHaveAttribute( - "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedding-interactive&utm_content=static-embed-popover&source_plan=oss", - ); - }); -}); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/enterprise.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/enterprise.unit.spec.tsx deleted file mode 100644 index ac4000c1bb759..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/enterprise.unit.spec.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { screen } from "__support__/ui"; - -import { type InteractiveEmbeddingCTASetupOptions, setup } from "./setup"; - -const setupEnterprise = ( - opts?: Partial, -) => { - setup({ - ...opts, - hasEnterprisePlugins: true, - }); -}; - -describe("InteractiveEmbeddingCTA", () => { - it("should display a CTA to the product page when plan is starter", () => { - setupEnterprise(); - - expect(screen.getByText("Interactive Embedding")).toBeInTheDocument(); - expect(screen.getByText("Pro")).toBeInTheDocument(); - expect( - screen.getByText( - "Give your customers the full power of Metabase in your own app, with SSO, advanced permissions, customization, and more.", - ), - ).toBeInTheDocument(); - - expect(screen.getByTestId("interactive-embedding-cta")).toHaveAttribute( - "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=product&utm_medium=upsell&utm_campaign=embedding-interactive&utm_content=static-embed-popover&source_plan=oss", - ); - }); -}); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/premium.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/premium.unit.spec.tsx deleted file mode 100644 index a5bf30fd66a7c..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/premium.unit.spec.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import userEvent from "@testing-library/user-event"; - -import { screen } from "__support__/ui"; -import { createMockTokenFeatures } from "metabase-types/api/mocks"; - -import { type InteractiveEmbeddingCTASetupOptions, setup } from "./setup"; - -const setupPremium = (opts?: Partial) => { - return setup({ - ...opts, - tokenFeatures: createMockTokenFeatures({ embedding: true }), - hasEnterprisePlugins: true, - }); -}; - -describe("InteractiveEmbeddingCTA", () => { - it("should display a link to the embedding settings when plan is pro", async () => { - const { history } = setupPremium(); - - expect(screen.getByText("Interactive Embedding")).toBeInTheDocument(); - expect(screen.queryByText("Pro")).not.toBeInTheDocument(); - expect( - screen.getByText( - "Your plan allows you to use Interactive Embedding create interactive embedding experiences with drill-through and more.", - ), - ).toBeInTheDocument(); - - await userEvent.click(screen.getByTestId("interactive-embedding-cta")); - - expect(history.getCurrentLocation().pathname).toEqual( - "/admin/settings/embedding-in-other-applications/full-app", - ); - }); -}); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/setup.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/setup.tsx deleted file mode 100644 index b2abca71c05a6..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA/test/setup.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { Route } from "react-router"; - -import { setupEnterprisePlugins } from "__support__/enterprise"; -import { mockSettings } from "__support__/settings"; -import { renderWithProviders } from "__support__/ui"; -import { checkNotNull } from "metabase/lib/types"; -import { InteractiveEmbeddingCTA } from "metabase/public/components/EmbedModal/SelectEmbedTypePane/InteractiveEmbeddingCTA"; -import type { TokenFeatures } from "metabase-types/api"; -import { createMockTokenFeatures } from "metabase-types/api/mocks"; -import { createMockState } from "metabase-types/store/mocks"; - -export type InteractiveEmbeddingCTASetupOptions = { - tokenFeatures?: TokenFeatures; - hasEnterprisePlugins?: boolean; - isPaidPlan?: boolean; -}; - -export const setup = ({ - tokenFeatures = createMockTokenFeatures(), - hasEnterprisePlugins = false, -}: InteractiveEmbeddingCTASetupOptions = {}) => { - const settings = mockSettings({ "token-features": tokenFeatures }); - - const state = createMockState({ - settings, - }); - - if (hasEnterprisePlugins) { - setupEnterprisePlugins(); - } - - const { history } = renderWithProviders( - , - { - storeInitialState: state, - withRouter: true, - }, - ); - - return { - history: checkNotNull(history), - }; -}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/PublicEmbedCard.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/PublicEmbedCard.tsx new file mode 100644 index 0000000000000..bda80c9414804 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/PublicEmbedCard.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; +import { useAsync } from "react-use"; +import { jt, t } from "ttag"; + +import { trackPublicEmbedCodeCopied } from "metabase/public/lib/analytics"; +import { PublicLinkCopyPanel } from "metabase/sharing/components/PublicLinkPopover/PublicLinkCopyPanel"; +import { + Button, + Center, + Group, + Loader, + Popover, + Stack, + Text, +} from "metabase/ui"; + +export const PublicEmbedCard = ({ + publicEmbedCode, + createPublicLink, + deletePublicLink, + resourceType, +}: any) => { + const [isOpen, setIsOpen] = useState(false); + + const { loading } = useAsync(async () => { + if (isOpen && !publicEmbedCode) { + return createPublicLink(); + } + return null; + }, [publicEmbedCode, isOpen]); + + return ( + + + {jt`Use ${( + + {t`public embedding`} + + )} to add a publicly-visible iframe embed to your web page or blog + post.`} + + setIsOpen(false)} + > + + + + + + {loading ? ( +
+ +
+ ) : ( + { + setIsOpen(false); + deletePublicLink(e); + }} + removeButtonLabel={t`Remove public link`} + removeTooltipLabel={t`Affects both public link and embed URL for this dashboard`} + onCopy={() => + trackPublicEmbedCodeCopied({ + artifact: resourceType, + source: "public-embed", + }) + } + /> + )} +
+
+
+
+ ); +}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx index 86c853187d96d..9657570a81aa4 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.tsx @@ -1,27 +1,28 @@ -import type { MouseEvent } from "react"; +import cx from "classnames"; import { useState } from "react"; import { t } from "ttag"; +import { getPlan } from "metabase/common/utils/plan"; import Link from "metabase/core/components/Link"; +import CS from "metabase/css/core/index.css"; +import { Badge } from "metabase/home/components/EmbedHomepage/Badge"; import { useSelector } from "metabase/lib/redux"; -import { - trackPublicEmbedCodeCopied, - trackPublicLinkRemoved, -} from "metabase/public/lib/analytics"; +import { PLUGIN_EMBEDDING } from "metabase/plugins"; +import { trackPublicLinkRemoved } from "metabase/public/lib/analytics"; import { getPublicEmbedHTML } from "metabase/public/lib/code"; import type { EmbedResource, EmbedResourceType, } from "metabase/public/lib/types"; import { getSetting } from "metabase/selectors/settings"; -import { PublicLinkCopyPanel } from "metabase/sharing/components/PublicLinkPopover/PublicLinkCopyPanel"; import type { ExportFormatType } from "metabase/sharing/components/PublicLinkPopover/types"; -import { Anchor, Group, Stack, Text } from "metabase/ui"; +import { Group, Icon, List, Stack, Text } from "metabase/ui"; -import { InteractiveEmbeddingCTA } from "./InteractiveEmbeddingCTA"; +import { PublicEmbedCard } from "./PublicEmbedCard"; import { SharingPaneButton } from "./SharingPaneButton/SharingPaneButton"; -import { SharingPaneActionButton } from "./SharingPaneButton/SharingPaneButton.styled"; -import { PublicEmbedIcon, StaticEmbedIcon } from "./icons"; +import SdkIllustration from "./illustrations/embedding-sdk.svg?component"; +import InteractiveEmbeddingIllustration from "./illustrations/interactive-embedding.svg?component"; +import StaticEmbeddingIllustration from "./illustrations/static-embedding.svg?component"; interface SelectEmbedTypePaneProps { resource: EmbedResource; @@ -42,14 +43,25 @@ export function SelectEmbedTypePane({ }: SelectEmbedTypePaneProps) { const hasPublicLink = resource.public_uuid != null; + const interactiveEmbeddingCta = useInteractiveEmbeddingCta(); + + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + const utmTags = `?utm_source=product&source_plan=${plan}&utm_content=embed-modal`; + const isPublicSharingEnabled = useSelector(state => getSetting(state, "enable-public-sharing"), ); const [isLoadingLink, setIsLoadingLink] = useState(false); - const createPublicLink = async (e: MouseEvent) => { - e.stopPropagation(); + const publicEmbedCode = + resource.public_uuid && + getPublicEmbedHTML(getPublicUrl(resource.public_uuid)); + + const createPublicLink = async () => { if (!isLoadingLink && !hasPublicLink) { setIsLoadingLink(true); await onCreatePublicLink(); @@ -57,8 +69,7 @@ export function SelectEmbedTypePane({ } }; - const deletePublicLink = async (e: MouseEvent) => { - e.stopPropagation(); + const deletePublicLink = async () => { if (!isLoadingLink && hasPublicLink) { setIsLoadingLink(true); @@ -72,93 +83,134 @@ export function SelectEmbedTypePane({ } }; - const publicLinkInfoText = - !isLoadingLink && hasPublicLink - ? // TextInput has a hardcoded marginTop that we need to account for here. - t`Just copy this snippet to add a publicly-visible iframe embed to your web page or blog post.` - : t`Use this to add a publicly-visible iframe embed to your web page or blog post.`; - - const getPublicLinkElement = () => { - if (isLoadingLink) { - return ( - {t`Loading…`} - ); - } - - if (hasPublicLink && resource.public_uuid != null) { - const iframeSource = getPublicEmbedHTML( - getPublicUrl(resource.public_uuid), - ); - - return ( - - trackPublicEmbedCodeCopied({ - artifact: resourceType, - source: "public-embed", - }) - } - onRemoveLink={deletePublicLink} - removeButtonLabel={t`Remove public URL`} - removeTooltipLabel={t`Affects both embed URL and public link for this dashboard`} - /> - ); - } - - return ( - {t`Get an embed link`} - ); - }; - return ( - - + + + {/* STATIC EMBEDDING*/} } + title={t`Static embedding`} + illustration={} onClick={goToNextStep} > - - {resource.enable_embedding ? t`Edit settings` : t`Set this up`} - + + {t`Embedded, signed charts in iframes.`} + {t`No query builder or row-level data access.`} + {t`Data restriction with locked parameters.`} + - - {t`Public embeds and links are disabled.`}{" "} - {t`Settings`} -
- ) + {/* INTERACTIVE EMBEDDING */} + + {t`Pro`}} + illustration={} + > + + {/* eslint-disable-next-line no-literal-metabase-strings -- only admin sees this */} + {t`Embed all of Metabase in an iframe.`} + {t`Let people can click on to explore.`} + {t`Customize appearance with your logo, font, and colors.`} + + + + + {/* REACT SDK */} + + + {t`Beta`} + {t`Pro`} + + } + illustration={} + externalLink + > + + {/* eslint-disable-next-line no-literal-metabase-strings -- visible only to admin */} + {t`Embed Metabase components with React (like standalone charts, dashboards, the Query Builder, and more)`} + {t`Manage access and interactivity per component`} + {t`Advanced customization options for styling`} + + + + + + {/* PUBLIC EMBEDDING */} + {isPublicSharingEnabled ? ( + + ) : ( + + {t`Public embeds and links are disabled.`}{" "} + {t`Settings`} + + )} + } > - {getPublicLinkElement()} - + {t`Compare options`} + - ); } + +export const useInteractiveEmbeddingCta = () => { + const isInteractiveEmbeddingEnabled = useSelector( + PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled, + ); + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + if (isInteractiveEmbeddingEnabled) { + return { + url: "/admin/settings/embedding-in-other-applications/full-app", + }; + } + + return { + url: `https://www.metabase.com/product/embedded-analytics?${new URLSearchParams( + { + utm_source: "product", + utm_medium: "upsell", + utm_campaign: "embedding-interactive", + utm_content: "static-embed-popover", + source_plan: plan, + }, + )}`, + target: "_blank", + }; +}; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx index ec626ae9f1146..927da58d57c5d 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SelectEmbedTypePane.unit.spec.tsx @@ -70,40 +70,20 @@ const setup = ({ describe("SelectEmbedTypePane", () => { describe("static embed button", () => { describe("when the resource is published", () => { - it("should render `Edit settings`", () => { - setup({ isResourcePublished: true }); - - expect( - screen.getByRole("button", { name: "Edit settings" }), - ).toBeInTheDocument(); - }); - - it("should call `goToNextStep` with `application` when `Edit settings` is clicked", async () => { + it("should call `goToNextStep` with `application` when the static embedding option is clicked", async () => { const { goToNextStep } = setup({ isResourcePublished: true }); - await userEvent.click( - screen.getByRole("button", { name: "Edit settings" }), - ); + await userEvent.click(screen.getByText("Static embedding")); expect(goToNextStep).toHaveBeenCalled(); }); }); describe("when the resource is not published", () => { - it("should render `Set this up`", () => { - setup({ isResourcePublished: false }); - - expect( - screen.getByRole("button", { name: "Set this up" }), - ).toBeInTheDocument(); - }); - - it("should call `goToNextStep` with `application` when `Set this up` is clicked", async () => { - const { goToNextStep } = setup({ isResourcePublished: false }); + it("should call `goToNextStep` with `application` when the static embedding option is clicked", async () => { + const { goToNextStep } = setup({ isResourcePublished: true }); - await userEvent.click( - screen.getByRole("button", { name: "Set this up" }), - ); + await userEvent.click(screen.getByText("Static embedding")); expect(goToNextStep).toHaveBeenCalled(); }); @@ -112,29 +92,12 @@ describe("SelectEmbedTypePane", () => { describe("public embed button", () => { describe("when public sharing is disabled", () => { - it("should render link to settings and a disabled button with `Get an embed link`", () => { - setup({ isPublicSharingEnabled: false }); - - expect( - screen.getByText("Public embeds and links are disabled."), - ).toBeInTheDocument(); - expect( - screen.getByTestId("sharing-pane-settings-link"), - ).toBeInTheDocument(); - - const embedLinkButton = screen.getByRole("button", { - name: "Get an embed link", - }); - expect(embedLinkButton).toBeInTheDocument(); - expect(embedLinkButton).toBeDisabled(); - }); - it("should redirect to settings when public sharing is disabled and `Settings` is clicked", async () => { const { history } = setup({ isPublicSharingEnabled: false, }); - await userEvent.click(screen.getByTestId("sharing-pane-settings-link")); + await userEvent.click(screen.getByRole("link", { name: "Settings" })); expect(history.getCurrentLocation().pathname).toEqual( "/admin/settings/public-sharing", @@ -146,57 +109,49 @@ describe("SelectEmbedTypePane", () => { it("should render iframe link, copy button, `Copy snippet` description, and `Affects public url and link` tooltip", async () => { setup({ hasPublicLink: true, isPublicSharingEnabled: true }); - expect( - screen.getByText( - "Just copy this snippet to add a publicly-visible iframe embed to your web page or blog post.", - ), - ).toBeInTheDocument(); + await userEvent.click(screen.getByText("Get embedding code")); expect(screen.getByTestId("public-link-input")).toHaveDisplayValue( //s, ); expect(screen.getByTestId("copy-button")).toBeInTheDocument(); - expect(screen.getByText("Remove public URL")).toBeInTheDocument(); + expect(screen.getByText("Remove public link")).toBeInTheDocument(); - await userEvent.hover(screen.getByText("Remove public URL")); + await userEvent.hover(screen.getByText("Remove public link")); expect( screen.getByText( - "Affects both embed URL and public link for this dashboard", + "Affects both public link and embed URL for this dashboard", ), ).toBeInTheDocument(); }); - it("should call `onDeletePublicLink` when `Remove public URL` is clicked", async () => { + it("should call `onDeletePublicLink` when `Remove public link` is clicked", async () => { const { onDeletePublicLink } = setup({ hasPublicLink: true, isPublicSharingEnabled: true, }); - await userEvent.click(screen.getByText("Remove public URL")); + await userEvent.click(screen.getByText("Get embedding code")); + + await userEvent.click(screen.getByText("Remove public link")); expect(onDeletePublicLink).toHaveBeenCalled(); }); }); describe("when a public link doesn't exist", () => { - it("should render `Get an embed link` and `Use this` description", () => { + it("should render `Get embedding code`", () => { setup({ hasPublicLink: false, isPublicSharingEnabled: true }); - expect( - screen.getByText( - "Use this to add a publicly-visible iframe embed to your web page or blog post.", - ), - ).toBeInTheDocument(); - - expect(screen.getByText("Get an embed link")).toBeInTheDocument(); + expect(screen.getByText("Get embedding code")).toBeInTheDocument(); }); - it("should call `onCreatePublicLink` when `Get an embed link` is clicked", async () => { + it("should call `onCreatePublicLink` when `Get embedding code` is clicked", async () => { const { onCreatePublicLink } = setup({ isPublicSharingEnabled: true }); await userEvent.click( - screen.getByRole("button", { name: "Get an embed link" }), + screen.getByRole("button", { name: "Get embedding code" }), ); expect(onCreatePublicLink).toHaveBeenCalled(); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css new file mode 100644 index 0000000000000..bcc720b175117 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.module.css @@ -0,0 +1,13 @@ +.Container:not([data-css-specificity-hack="🤷"]) { + cursor: pointer; + border-color: var(--mb-color-brand-light); + + --external-link-icon-color: var(--mb-color-text-light); + + &:hover { + border-color: var(--mb-base-color-blue-40); + background-color: var(--mb-color-bg-light); + + --external-link-icon-color: var(--mb-base-color-blue-40); + } +} diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx index 119bbe6c87319..c1202a9afae46 100644 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/SharingPaneButton/SharingPaneButton.tsx @@ -1,49 +1,51 @@ -import type { MouseEvent, MouseEventHandler, ReactNode } from "react"; +import type { MouseEventHandler, ReactNode } from "react"; -import { Box, Center, Stack, Text } from "metabase/ui"; +import { Box, Center, Group, Icon, Paper, Stack, Title } from "metabase/ui"; -import { - SharingPaneButtonContent, - SharingPaneButtonTitle, -} from "./SharingPaneButton.styled"; +import S from "./SharingPaneButton.module.css"; type SharingOptionProps = { illustration: JSX.Element; children: ReactNode; - header: string; - description: ReactNode | string; - disabled?: boolean; + title: string; + badge?: ReactNode; onClick?: MouseEventHandler; "data-testid"?: string; + externalLink?: boolean; }; export const SharingPaneButton = ({ illustration, children, - header, - description, - disabled, + title, onClick, + badge, + externalLink = false, "data-testid": dataTestId, }: SharingOptionProps) => ( - -
!disabled && onClick?.(event)} - > - - {illustration} - - {header} - - {description} - {children} - -
-
+ + {externalLink && ( + + + + )} +
{illustration}
+ + {title} + {badge} + + {children} +
+ ); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx deleted file mode 100644 index b0c7845ae3620..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.styled.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { css } from "@emotion/react"; -import styled from "@emotion/styled"; - -import { SharingPaneButtonContent } from "../../SharingPaneButton/SharingPaneButton.styled"; - -interface PublicEmbedIconRootProps { - disabled: boolean; -} - -export const PublicEmbedIconRoot = styled.svg` - ${({ disabled }) => css` - color: var(--mb-color-bg-medium); - - .innerFill { - stroke: ${disabled - ? "var(--mb-color-text-light)" - : "var(--mb-color-bg-dark)"}; - opacity: ${disabled ? 0.5 : 1}; - } - `} - - ${({ disabled }) => - !disabled && - css` - ${SharingPaneButtonContent}:hover & { - color: var(--mb-color-bg-dark); - - .innerFill { - stroke: var(--mb-color-brand); - } - } - `} -`; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx deleted file mode 100644 index c280caf384cba..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/PublicEmbedIcon/PublicEmbedIcon.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PublicEmbedIconRoot } from "./PublicEmbedIcon.styled"; - -interface PublicEmbedIconProps { - disabled: boolean; -} -export const PublicEmbedIcon = ({ disabled }: PublicEmbedIconProps) => ( - - - - - - - -); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx deleted file mode 100644 index 141747dc6163c..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.styled.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import styled from "@emotion/styled"; - -import { SharingPaneButtonContent } from "../../SharingPaneButton/SharingPaneButton.styled"; - -export const StaticEmbedIconRoot = styled.svg` - color: var(--mb-color-bg-medium); - - .innerFill { - fill: var(--mb-color-bg-dark); - fill-opacity: 0.5; - } - - ${SharingPaneButtonContent}:hover & { - color: var(--mb-color-focus); - - .innerFill { - fill: var(--mb-color-brand); - fill-opacity: 1; - } - } -`; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx deleted file mode 100644 index ddb9fe0bc4b3b..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/StaticEmbedIcon/StaticEmbedIcon.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { StaticEmbedIconRoot } from "./StaticEmbedIcon.styled"; - -export const StaticEmbedIcon = () => ( - - - - - - - - -); diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts deleted file mode 100644 index 66d1fc6319e6e..0000000000000 --- a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/icons/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { PublicEmbedIcon } from "./PublicEmbedIcon/PublicEmbedIcon"; -export { StaticEmbedIcon } from "./StaticEmbedIcon/StaticEmbedIcon"; diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg new file mode 100644 index 0000000000000..90603b02c8e07 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/embedding-sdk.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg new file mode 100644 index 0000000000000..37e4fff8df891 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/interactive-embedding.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg new file mode 100644 index 0000000000000..7768bde3f3036 --- /dev/null +++ b/frontend/src/metabase/public/components/EmbedModal/SelectEmbedTypePane/illustrations/static-embedding.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + From 236ece195004d9e90508f2c7e6f52933b8cb4c5e Mon Sep 17 00:00:00 2001 From: Oisin Coveney Date: Tue, 8 Oct 2024 13:18:50 +0300 Subject: [PATCH 4/4] [WIP] #46970 Milestone 2 (#48069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Oisin Coveney Co-authored-by: Mahatthana (Kelvin) Nomsawadi Co-authored-by: bryan Co-authored-by: Nicolò Pretto --- e2e/snapshot-creators/default.cy.snap.js | 5 +- e2e/support/commands/embedding-sdk/utils.ts | 0 e2e/support/config.js | 12 + e2e/support/helpers/e2e-jwt-tasks.ts | 11 + .../static-dashboard-cors.cy.spec.js | 199 +++++ .../embedding-sdk/static-dashboard.cy.spec.js | 3 + .../embedding/embedding-smoketests.cy.spec.js | 38 +- ...c-sharing-embed-button-behavior.cy.spec.js | 6 +- .../backend/src/metabase_enterprise/stale.clj | 4 +- .../test/metabase/embed/settings_test.clj | 178 +++- .../advanced_permissions/api/setting_test.clj | 8 +- .../public_settings_test.clj | 23 +- .../sso/integrations/jwt_test.clj | 2 +- ...CypressStaticDashboardWithCors.stories.tsx | 24 + .../test/CommonSdkCorsStoryWrapper.tsx | 17 + .../embedding-sdk/index.js | 6 + .../EmbeddingAppSameSiteCookieDescription.tsx | 1 + .../SameSiteSelectWidget.tsx | 29 +- .../InteractiveEmbeddingSettings.tsx | 95 ++ .../metabase-enterprise/embedding/index.js | 65 +- .../src/metabase-enterprise/plugins.js | 1 + .../components/UserProvisioning.tsx | 2 +- .../metabase-types/analytics/embed-share.ts | 13 +- .../src/metabase-types/api/mocks/settings.ts | 18 +- frontend/src/metabase-types/api/settings.ts | 28 +- .../SettingsEditor/SettingsEditor.jsx | 6 +- .../SettingsEditor/tests/common.unit.spec.tsx | 33 +- .../tests/embedding.unit.spec.tsx | 10 +- .../embedding/common.embedding.unit.spec.tsx | 657 ++++++++++++-- .../enterprise.embedding.unit.spec.tsx | 653 ++++++++++++-- .../embedding/premium.embedding.unit.spec.tsx | 814 ++++++++++++++++-- .../SettingsEditor/tests/embedding/setup.tsx | 38 +- .../SettingsEditor/tests/embedding/types.ts | 3 + .../tests/enterprise.unit.spec.tsx | 11 +- .../AuthCard/AuthCard.unit.spec.tsx | 11 +- .../EmbeddingSdkSettings.tsx | 242 ++++++ .../EmbeddingSettings/EmbeddingSettings.tsx | 54 ++ .../InteractiveEmbeddingSettings.tsx | 7 + .../StaticEmbeddingSettings.tsx | 83 ++ .../components/EmbeddingSettings/index.ts | 4 + .../components/EmbeddingSettings/types.ts | 10 + .../settings/components/SettingsSetting.jsx | 8 +- .../SettingsUpdatesForm.tsx | 2 +- .../EmbeddingOption.styled.tsx | 28 - .../EmbeddingOption/EmbeddingOption.tsx | 164 +--- .../EmbeddingSdkOptionCard.tsx | 54 ++ .../EmbeddingSdkOptionCard/SdkIcon.tsx | 28 + .../EmbeddingSdkOptionCard/index.ts | 1 + .../InteractiveEmbeddingOff.svg | 22 - .../InteractiveEmbeddingOn.svg | 14 - .../InteractiveEmbeddingIcon.tsx | 37 + .../InteractiveEmbeddingOptionCard.tsx | 99 +++ .../InteractiveEmbeddingOptionCard/index.ts | 1 + .../EmbeddingOption/LinkButton/LinkButton.tsx | 19 + .../EmbeddingOption/LinkButton/index.ts | 1 + .../EmbeddingOption/StaticEmbeddingOff.svg | 8 - .../EmbeddingOption/StaticEmbeddingOn.svg | 8 - .../StaticEmbeddingIcon.tsx | 32 + .../StaticEmbeddingOptionCard.tsx | 58 ++ .../StaticEmbeddingOptionCard/index.ts | 1 + .../SwitchWithSetByEnvVar.tsx | 43 + .../SwitchWithSetByEnvVar.unit.spec.tsx | 143 +++ .../SwitchWithSetByEnvVar/index.ts | 1 + .../widgets/EmbeddingOption/index.ts | 7 +- .../widgets/EmbeddingOption/types.ts | 3 + .../use-embedding-settings-icon-colors.tsx | 19 + .../EmbeddingSwitchWidget.tsx | 26 - .../widgets/EmbeddingSwitchWidget/index.ts | 1 - .../GroupMappingsWidget.jsx | 2 +- .../components/widgets/HttpsOnlyWidget.jsx | 2 +- .../SecretKeyWidget/SecretKeyWidget.tsx | 1 + .../SettingTextInput/SettingTextInput.tsx | 7 +- .../{SettingToggle.jsx => SettingToggle.tsx} | 29 +- .../admin/settings/selectors/index.ts | 2 + .../settings/{ => selectors}/selectors.js | 162 +--- .../settings/selectors/typed-selectors.ts | 7 + frontend/src/metabase/admin/settings/types.ts | 78 +- .../common/hooks/use-setting/use-setting.ts | 29 +- .../src/metabase/css/core/colors.module.css | 11 +- .../home/components/EmbedHomepage/Badge.ts | 21 +- .../components/EmbedHomepage/tests/setup.tsx | 3 +- frontend/src/metabase/lib/analytics.ts | 6 +- .../WhatsNewNotification.unit.spec.tsx | 3 +- frontend/src/metabase/plugins/index.ts | 11 +- .../SharingMenu/MenuItems/EmbedMenuItem.tsx | 6 +- .../components/SharingMenu/test/setup.tsx | 2 +- .../components/inputs/Input/Input.styled.tsx | 8 +- .../migrations/001_update_migrations.yaml | 196 +++++ .../com.metabase/embed_share/jsonschema/1-0-0 | 2 +- .../com.metabase/embed_share/jsonschema/1-0-1 | 57 ++ src/metabase/analytics/stats.clj | 16 +- src/metabase/api/common/validation.clj | 2 +- src/metabase/api/setup.clj | 2 +- src/metabase/core.clj | 5 + src/metabase/embed/settings.clj | 214 ++++- src/metabase/server/middleware/security.clj | 64 +- test/metabase/analytics/stats_test.clj | 1 + test/metabase/api/card_test.clj | 8 +- test/metabase/api/dashboard_test.clj | 2 +- test/metabase/api/embed_test.clj | 17 +- test/metabase/api/preview_embed_test.clj | 10 +- test/metabase/api/setting_test.clj | 15 +- test/metabase/db/schema_migrations_test.clj | 84 ++ .../query_processor/streaming_test.clj | 2 +- .../server/middleware/security_test.clj | 70 +- 105 files changed, 4493 insertions(+), 916 deletions(-) create mode 100644 e2e/support/commands/embedding-sdk/utils.ts create mode 100644 e2e/support/helpers/e2e-jwt-tasks.ts create mode 100644 e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js create mode 100644 enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx create mode 100644 enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js create mode 100644 enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx create mode 100644 frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingSdkOptionCard/EmbeddingSdkOptionCard.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingSdkOptionCard/SdkIcon.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingSdkOptionCard/index.ts delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOff.svg delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOn.svg create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/InteractiveEmbeddingIcon.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/InteractiveEmbeddingOptionCard.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/index.ts delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/StaticEmbeddingOff.svg delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/StaticEmbeddingOn.svg create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/StaticEmbeddingOptionCard/StaticEmbeddingIcon.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/StaticEmbeddingOptionCard/StaticEmbeddingOptionCard.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/StaticEmbeddingOptionCard/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/SwitchWithSetByEnvVar/SwitchWithSetByEnvVar.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/SwitchWithSetByEnvVar/SwitchWithSetByEnvVar.unit.spec.tsx create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/SwitchWithSetByEnvVar/index.ts create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/types.ts create mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/use-embedding-settings-icon-colors.tsx delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingSwitchWidget/EmbeddingSwitchWidget.tsx delete mode 100644 frontend/src/metabase/admin/settings/components/widgets/EmbeddingSwitchWidget/index.ts rename frontend/src/metabase/admin/settings/components/widgets/{SettingToggle.jsx => SettingToggle.tsx} (54%) create mode 100644 frontend/src/metabase/admin/settings/selectors/index.ts rename frontend/src/metabase/admin/settings/{ => selectors}/selectors.js (79%) create mode 100644 frontend/src/metabase/admin/settings/selectors/typed-selectors.ts create mode 100644 snowplow/iglu-client-embedded/schemas/com.metabase/embed_share/jsonschema/1-0-1 diff --git a/e2e/snapshot-creators/default.cy.snap.js b/e2e/snapshot-creators/default.cy.snap.js index 1951da5c3d55f..a86a64e03844e 100644 --- a/e2e/snapshot-creators/default.cy.snap.js +++ b/e2e/snapshot-creators/default.cy.snap.js @@ -91,7 +91,10 @@ describe("snapshots", () => { function updateSettings() { updateSetting("enable-public-sharing", true); - updateSetting("enable-embedding", true).then(() => { + // interactive is not enabled in the snapshots as it requires a premium feature + // updateSetting("enable-embedding-interactive", true); + updateSetting("enable-embedding-sdk", true); + updateSetting("enable-embedding-static", true).then(() => { updateSetting("embedding-secret-key", METABASE_SECRET_KEY); }); diff --git a/e2e/support/commands/embedding-sdk/utils.ts b/e2e/support/commands/embedding-sdk/utils.ts new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/e2e/support/config.js b/e2e/support/config.js index 7c07011d29a9d..9581cb1d9d55b 100644 --- a/e2e/support/config.js +++ b/e2e/support/config.js @@ -8,6 +8,7 @@ import { verifyDownloadTasks, } from "./commands/downloads/downloadUtils"; import * as dbTasks from "./db_tasks"; +import { signJwt } from "./helpers/e2e-jwt-tasks"; const createBundler = require("@bahmutov/cypress-esbuild-preprocessor"); // This function is called when a project is opened or re-opened (e.g. due to the project's config changing) const { @@ -27,6 +28,8 @@ const targetVersion = process.env["CROSS_VERSION_TARGET"]; const feHealthcheckEnabled = process.env["CYPRESS_FE_HEALTHCHECK"] === "true"; +const isEmbeddingSdk = process.env.CYPRESS_IS_EMBEDDING_SDK === "true"; + // docs say that tsconfig paths should handle aliases, but they don't const assetsResolverPlugin = { name: "assetsResolver", @@ -104,6 +107,7 @@ const defaultConfig = { ...dbTasks, ...verifyDownloadTasks, removeDirectory, + signJwt, }); // this is an official workaround to keep recordings of the failed specs only @@ -169,6 +173,14 @@ const defaultConfig = { const mainConfig = { ...defaultConfig, + ...(isEmbeddingSdk + ? { + chromeWebSecurity: true, + hosts: { + "my-site.local": "127.0.0.1", + }, + } + : {}), projectId: "ywjy9z", numTestsKeptInMemory: process.env["CI"] ? 1 : 50, reporter: "cypress-multi-reporters", diff --git a/e2e/support/helpers/e2e-jwt-tasks.ts b/e2e/support/helpers/e2e-jwt-tasks.ts new file mode 100644 index 0000000000000..6b9f36d14b62d --- /dev/null +++ b/e2e/support/helpers/e2e-jwt-tasks.ts @@ -0,0 +1,11 @@ +import jwt from "jsonwebtoken"; + +export function signJwt({ + payload, + secret, +}: { + payload: Record; + secret: string; +}): string { + return jwt.sign(payload, secret); +} diff --git a/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js b/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js new file mode 100644 index 0000000000000..6609183ba74f8 --- /dev/null +++ b/e2e/test/scenarios/embedding-sdk/static-dashboard-cors.cy.spec.js @@ -0,0 +1,199 @@ +import { USERS } from "e2e/support/cypress_data"; +import { + ORDERS_DASHBOARD_DASHCARD_ID, + ORDERS_QUESTION_ID, +} from "e2e/support/cypress_sample_instance_data"; +import { + getTextCardDetails, + restore, + setTokenFeatures, + visitFullAppEmbeddingUrl, +} from "e2e/support/helpers"; +import { + EMBEDDING_SDK_STORY_HOST, + describeSDK, +} from "e2e/support/helpers/e2e-embedding-sdk-helpers"; +import { + JWT_SHARED_SECRET, + setupJwt, +} from "e2e/support/helpers/e2e-jwt-helpers"; + +const STORYBOOK_ID = "embeddingsdk-cypressstaticdashboardwithcors--default"; +describeSDK("scenarios > embedding-sdk > static-dashboard", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + setupJwt(); + + const textCard = getTextCardDetails({ col: 16, text: "Text text card" }); + const questionCard = { + id: ORDERS_DASHBOARD_DASHCARD_ID, + card_id: ORDERS_QUESTION_ID, + row: 0, + col: 0, + size_x: 16, + size_y: 8, + visualization_settings: { + "card.title": "Test question card", + }, + }; + + cy.createDashboard( + { + name: "Embedding Sdk Test Dashboard", + dashcards: [questionCard, textCard], + }, + { wrapId: true }, + ); + + cy.intercept("GET", "/api/dashboard/*").as("getDashboard"); + cy.intercept("GET", "/api/user/current").as("getUser"); + cy.intercept("POST", "/api/dashboard/*/dashcard/*/card/*/query").as( + "dashcardQuery", + ); + + cy.task("signJwt", { + payload: { + email: USERS.normal.email, + exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minute expiration + }, + secret: JWT_SHARED_SECRET, + }).then(jwtToken => { + const ssoUrl = new URL("/auth/sso", Cypress.config().baseUrl); + ssoUrl.searchParams.set("jwt", jwtToken); + ssoUrl.searchParams.set("token", "true"); + cy.request(ssoUrl.toString()).then(({ body }) => { + cy.wrap(body).as("metabaseSsoResponse"); + }); + }); + cy.get("@metabaseSsoResponse").then(ssoResponse => { + cy.intercept("GET", "/sso/metabase", ssoResponse); + }); + }); + + it("should not render dashboard when embedding SDK is not enabled", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": false, + }); + cy.signOut(); + + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.get("#metabase-sdk-root").within(() => { + cy.findByText("Error").should("be.visible"); + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("be.visible"); + }); + }); + + it("should show dashboard content", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.wait("@getUser").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.wait("@getDashboard").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.get("#metabase-sdk-root") + .should("be.visible") + .within(() => { + cy.findByText("Embedding Sdk Test Dashboard").should("be.visible"); // dashboard title + + cy.findByText("Text text card").should("be.visible"); // text card content + + cy.wait("@dashcardQuery"); + cy.findByText("Test question card").should("be.visible"); // question card content + }); + }); + + it("should not render the SDK on non localhost sites when embedding SDK origins is not set", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: "http://my-site.local:6006/iframe.html", + qs: { + id: STORYBOOK_ID, + viewMode: "story", + }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.get("#metabase-sdk-root").within(() => { + cy.findByText("Error").should("be.visible"); + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("be.visible"); + }); + }); + + it("should show dashboard content", () => { + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + "embedding-app-origins-sdk": "my-site.local:6006", + }); + cy.signOut(); + cy.get("@dashboardId").then(dashboardId => { + visitFullAppEmbeddingUrl({ + url: "http://my-site.local:6006/iframe.html", + qs: { id: STORYBOOK_ID, viewMode: "story" }, + onBeforeLoad: window => { + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.DASHBOARD_ID = dashboardId; + }, + }); + }); + + cy.wait("@getUser").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.wait("@getDashboard").then(({ response }) => { + expect(response?.statusCode).to.equal(200); + }); + + cy.get("#metabase-sdk-root") + .should("be.visible") + .within(() => { + cy.findByText("Embedding Sdk Test Dashboard").should("be.visible"); // dashboard title + + cy.findByText("Text text card").should("be.visible"); // text card content + + cy.wait("@dashcardQuery"); + cy.findByText("Test question card").should("be.visible"); // question card content + }); + }); +}); diff --git a/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js b/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js index 35e98e352c8a8..306e64707a59e 100644 --- a/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js +++ b/e2e/test/scenarios/embedding-sdk/static-dashboard.cy.spec.js @@ -23,6 +23,9 @@ describeSDK("scenarios > embedding-sdk > static-dashboard", () => { cy.signInAsAdmin(); setTokenFeatures("all"); setupJwt(); + cy.request("PUT", "/api/setting", { + "enable-embedding-sdk": true, + }); const textCard = getTextCardDetails({ col: 16, text: "Text text card" }); const questionCard = { diff --git a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js index 26f23a7bd5d59..911cbdf26c474 100644 --- a/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js +++ b/e2e/test/scenarios/embedding/embedding-smoketests.cy.spec.js @@ -59,25 +59,14 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); cy.location("pathname").should("eq", embeddingPage); - cy.findByTestId("enable-embedding-setting").within(() => { - cy.findByText(embeddingDescription); - - cy.findByLabelText("Enable Embedding").click({ force: true }); - }); - // The URL should stay the same - cy.location("pathname").should("eq", embeddingPage); - - cy.findByTestId("enable-embedding-setting").within(() => { - cy.findByRole("checkbox").should("be.checked"); - }); - + mainPage().findByText(embeddingDescription).should("be.visible"); cy.log( "With the embedding enabled, we should now see two new sections on the main page", ); cy.log("The first section: 'Static embedding'"); - cy.findByTestId("-static-embedding-setting").within(() => { + cy.findByRole("article", { name: "Static embedding" }).within(() => { // FE unit tests are making sure this section doesn't exist when a valid token is provided, - // so we don't have to do it here usign a conditional logic + // so we don't have to do it here using conditional logic assertLinkMatchesUrl("upgrade to a paid plan", upgradeUrl); cy.findByRole("link", { name: "Manage" }) @@ -89,7 +78,15 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); cy.log("Standalone embeds page"); + // TODO: Remove this when the actual BE is implemented, this flag still controls the static embedding + // I've tried to change this but it failed like 500 BE tests. + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); mainPage().within(() => { + cy.findByLabelText("Enable Static embedding") + .click({ force: true }) + .should("be.checked"); cy.findByTestId("embedding-secret-key-setting").within(() => { cy.findByText("Embedding secret key"); cy.findByText( @@ -99,7 +96,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { cy.button("Regenerate key"); }); - cy.findByTestId("-embedded-resources-setting").within(() => { + cy.findByTestId("embedded-resources").within(() => { cy.findByText("Embedded Dashboards"); cy.findByText("No dashboards have been embedded yet."); @@ -112,7 +109,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { cy.location("pathname").should("eq", embeddingPage); cy.log("The second section: 'Interactive embedding'"); - cy.findByTestId("-interactive-embedding-setting").within(() => { + cy.findByRole("article", { name: "Interactive embedding" }).within(() => { cy.findByText("Interactive embedding"); cy.findByRole("link", { name: "Learn More" }) @@ -142,6 +139,9 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }; ["question", "dashboard"].forEach(object => { it(`should be able to publish/embed and then unpublish a ${object} without filters`, () => { + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); const embeddableObject = object === "question" ? "card" : "dashboard"; const objectName = object === "question" ? "Orders" : "Orders in a dashboard"; @@ -248,6 +248,10 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); it("should regenerate embedding token and invalidate previous embed url", () => { + cy.request("PUT", "/api/setting/enable-embedding-static", { + value: true, + }); + cy.request("PUT", `/api/card/${ORDERS_QUESTION_ID}`, { enable_embedding: true, }); @@ -311,7 +315,7 @@ describe("scenarios > embedding > smoke tests", { tags: "@OSS" }, () => { }); function resetEmbedding() { - updateSetting("enable-embedding", false); + updateSetting("enable-embedding-static", false); updateSetting("embedding-secret-key", null); } diff --git a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js index 0724a10c14776..3a55950991b1a 100644 --- a/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js +++ b/e2e/test/scenarios/sharing/public-sharing-embed-button-behavior.cy.spec.js @@ -37,7 +37,7 @@ import { describe("when embedding is disabled", () => { beforeEach(() => { - updateSetting("enable-embedding", false); + updateSetting("enable-embedding-static", false); }); describe("when user is admin", () => { @@ -72,7 +72,7 @@ import { describe("when public sharing is enabled", () => { beforeEach(() => { updateSetting("enable-public-sharing", true); - updateSetting("enable-embedding", true); + updateSetting("enable-embedding-static", true); }); describe("when user is admin", () => { @@ -138,7 +138,7 @@ import { describe("when public sharing is disabled", () => { beforeEach(() => { updateSetting("enable-public-sharing", false); - updateSetting("enable-embedding", true); + updateSetting("enable-embedding-static", true); }); describe("when user is admin", () => { diff --git a/enterprise/backend/src/metabase_enterprise/stale.clj b/enterprise/backend/src/metabase_enterprise/stale.clj index aeedcf684d82c..56398761e2565 100644 --- a/enterprise/backend/src/metabase_enterprise/stale.clj +++ b/enterprise/backend/src/metabase_enterprise/stale.clj @@ -44,7 +44,7 @@ [:= :moderation_review.id nil] [:= :report_card.archived false] [:<= :report_card.last_used_at (-> args :cutoff-date)] - (when (embed.settings/enable-embedding) + (when (embed.settings/some-embedding-enabled?) [:= :report_card.enable_embedding false]) (when (public-settings/enable-public-sharing) [:= :report_card.public_uuid nil]) @@ -67,7 +67,7 @@ [:= :pulse.id nil] [:= :report_dashboard.archived false] [:<= :report_dashboard.last_viewed_at (-> args :cutoff-date)] - (when (embed.settings/enable-embedding) + (when (embed.settings/some-embedding-enabled?) [:= :report_dashboard.enable_embedding false]) (when (public-settings/enable-public-sharing) [:= :report_dashboard.public_uuid nil]) diff --git a/enterprise/backend/test/metabase/embed/settings_test.clj b/enterprise/backend/test/metabase/embed/settings_test.clj index 81df0b3e4088d..4e6f4d116dd44 100644 --- a/enterprise/backend/test/metabase/embed/settings_test.clj +++ b/enterprise/backend/test/metabase/embed/settings_test.clj @@ -1,31 +1,185 @@ (ns metabase.embed.settings-test (:require + [clojure.string :as str] [clojure.test :refer :all] [metabase.analytics.snowplow-test :as snowplow-test] [metabase.embed.settings :as embed.settings] [metabase.test :as mt] [toucan2.core :as t2])) +(defn- embedding-event? + "Used to make sure we only test against embedding-events in `snowplow-test/pop-event-data-and-user-id!`." + [event] + (-> event :data (get "event") ((fn [s] (boolean (re-matches #".*embedding.*" s)))))) + (deftest enable-embedding-test - (testing "A snowplow event is sent whenever embedding is enabled or disabled" + (testing "A snowplow event is sent whenever embedding is toggled" (mt/with-test-user :crowberto (mt/with-premium-features #{:embedding} - (mt/with-temporary-setting-values [enable-embedding false - embedding-app-origin "https://example.com"] + (mt/with-temporary-setting-values [embedding-app-origins-interactive "https://example.com" + enable-embedding-interactive false] (let [embedded-dash-count (t2/count :model/Dashboard :enable_embedding true) embedded-card-count (t2/count :model/Card :enable_embedding true) expected-payload {"embedding_app_origin_set" true "number_embedded_questions" embedded-card-count "number_embedded_dashboards" embedded-dash-count}] (snowplow-test/with-fake-snowplow-collector - (embed.settings/enable-embedding! true) - (is (= [{:data - (merge expected-payload {"event" "embedding_enabled"}) + (embed.settings/enable-embedding-interactive! true) + (is (= [{:data (merge expected-payload {"event" "interactive_embedding_enabled"}) :user-id (str (mt/user->id :crowberto))}] - (-> (snowplow-test/pop-event-data-and-user-id!)))) + (filter embedding-event? (snowplow-test/pop-event-data-and-user-id!)))) - (embed.settings/enable-embedding! false) - (is (= [{:data - (merge expected-payload {"event" "embedding_disabled"}) - :user-id (str (mt/user->id :crowberto))}] - (-> (snowplow-test/pop-event-data-and-user-id!))))))))))) + (mt/with-temporary-setting-values [enable-embedding-interactive false] + (is (= [{:data + (merge expected-payload {"event" "interactive_embedding_disabled"}) + :user-id (str (mt/user->id :crowberto))}] + (filter embedding-event? (snowplow-test/pop-event-data-and-user-id!)))))))))))) + +(def ^:private other-ip "1.2.3.4:5555") + +(deftest enable-embedding-SDK-true-ignores-localhosts + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding-sdk true] + (let [origin-value "localhost:*"] + (embed.settings/embedding-app-origins-sdk! origin-value) + (testing "All localhosty origins should be ignored, so the result should be \"localhost:*\"" + (embed.settings/embedding-app-origins-sdk! (str origin-value " localhost:8080")) + (is (= "localhost:*" (embed.settings/embedding-app-origins-sdk)))) + (testing "Normal ips are added to the list" + (embed.settings/embedding-app-origins-sdk! (str origin-value " " other-ip)) + (is (= (str "localhost:* " other-ip) (embed.settings/embedding-app-origins-sdk)))))))) + +(deftest enable-embedding-SDK-false-returns-nothing + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding-sdk false] + (embed.settings/embedding-app-origins-sdk! "") + (let [origin-value (str "localhost:* " other-ip " " + (str/join " " (map #(str "localhost:" %) (range 1000 2000))))] + (embed.settings/embedding-app-origins-sdk! origin-value) + (is (not (and (embed.settings/enable-embedding-sdk) + (embed.settings/embedding-app-origins-sdk)))))))) + +(defn- depricated-setting-throws [f env & [reason]] + (is (thrown-with-msg? + clojure.lang.ExceptionInfo + #".* deprecated.*env vars are set.*" + (f env)) reason)) + +(deftest deprecated-enabled-embedding-settings-test + ;; OK: + (is (nil? (#'embed.settings/check-enable-settings! {}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-static true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-static false :mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive true :mb-enable-embedding-static false}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive false :mb-enable-embedding-sdk true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding-interactive false :mb-enable-embedding-sdk true :mb-enable-embedding-static true}))) + (is (nil? (#'embed.settings/check-enable-settings! {:mb-enable-embedding true}))) + ;; ;; Not OK: + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-static false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-sdk false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-sdk false :mb-enable-embedding-static true}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive true :mb-enable-embedding-sdk false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false :mb-enable-embedding-static true}) + (depricated-setting-throws #'embed.settings/check-enable-settings! {:mb-enable-embedding true :mb-enable-embedding-interactive false :mb-enable-embedding-sdk true :mb-enable-embedding-static true})) + +(deftest deprecated-origin-embedding-settings-test + ;; OK: + (is (nil? (#'embed.settings/check-origins-settings! {}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin false})) + "shouldn't matter if a setting is true or false, only that it is set (not nil).") + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin nil})) + "shouldn't matter if a setting is true or false, only that it is set (not nil).") + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-sdk true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-interactive true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origins-interactive false :mb-embedding-app-origins-sdk true}))) + (is (nil? (#'embed.settings/check-origins-settings! {:mb-embedding-app-origin nil :mb-embedding-app-origins-interactive nil :mb-embedding-app-origins-sdk nil}))) + ;; Not OK: + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-sdk false}) + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-interactive false}) + (depricated-setting-throws #'embed.settings/check-origins-settings! {:mb-embedding-app-origin true :mb-embedding-app-origins-interactive true :mb-embedding-app-origins-sdk false})) + +(defn test-enabled-sync! [env expected-behavior] + (let [unsyncd-settings {:enable-embedding #_:clj-kondo/ignore (embed.settings/enable-embedding) + :enable-embedding-interactive (embed.settings/enable-embedding-interactive) + :enable-embedding-sdk (embed.settings/enable-embedding-sdk) + :enable-embedding-static (embed.settings/enable-embedding-static)}] + ;; called for side effects: + (#'embed.settings/sync-enable-settings! env) + (cond + (= expected-behavior :no-op) + (do (is (= [:no-op (:enable-embedding-interactive unsyncd-settings)] [:no-op (embed.settings/enable-embedding-interactive)])) + (is (= [:no-op (:enable-embedding-sdk unsyncd-settings)] [:no-op (embed.settings/enable-embedding-sdk)])) + (is (= [:no-op (:enable-embedding-static unsyncd-settings)] [:no-op (embed.settings/enable-embedding-static)]))) + + (= expected-behavior :sets-all-true) + (do (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-interactive)])) + (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-sdk)])) + (is (= [expected-behavior true] [:sets-all-true (embed.settings/enable-embedding-static)]))) + + (= expected-behavior :sets-all-false) + (do (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-interactive)])) + (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-sdk)])) + (is (= [expected-behavior false] [:sets-all-false (embed.settings/enable-embedding-static)]))) + + :else (throw (ex-info "Invalid expected-behavior in test-enabled-sync." {:expected-behavior expected-behavior}))))) + +(deftest sync-enabled-test + (mt/with-premium-features #{:embedding :embedding-sdk} + ;; n.b. illegal combinations will be disallowed by [[embed.settings/check-and-sync-settings-on-startup!]], so we don't test syncing for them. + (test-enabled-sync! {} :no-op) + (test-enabled-sync! {:mb-enable-embedding-static true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-static false} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive false} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive true :mb-enable-embedding-static true} :no-op) + (test-enabled-sync! {:mb-enable-embedding-interactive false :mb-enable-embedding-static true} :no-op) + + (test-enabled-sync! {:mb-enable-embedding true} :sets-all-true) + + (test-enabled-sync! {:mb-enable-embedding false} :sets-all-false))) + +(defn test-origin-sync! [env expected-behavior] + (testing (str "origin sync with expected-behavior: " expected-behavior) + (let [unsyncd-setting {:embedding-app-origin #_:clj-kondo/ignore (embed.settings/embedding-app-origin) + :embedding-app-origins-interactive (embed.settings/embedding-app-origins-interactive) + :embedding-app-origins-sdk (embed.settings/embedding-app-origins-sdk)}] + ;; called for side effects + (#'embed.settings/sync-origins-settings! env) + (cond + (= expected-behavior :no-op) + (do (is (= (:embedding-app-origins-interactive unsyncd-setting) + (embed.settings/embedding-app-origins-interactive))) + (is (= (#'embed.settings/add-localhost (:embedding-app-origins-sdk unsyncd-setting)) + (embed.settings/embedding-app-origins-sdk)))) + + (= expected-behavior :sets-both) + (do (is (= (:mb-embedding-app-origin env) + (embed.settings/embedding-app-origins-interactive))) + (is (= (#'embed.settings/add-localhost (:mb-embedding-app-origin env)) + (embed.settings/embedding-app-origins-sdk)))) + + :else (throw (ex-info "Invalid expected-behavior in test-origin-sync." {:expected-behavior expected-behavior})))))) + +(deftest sync-origins-test + ;; n.b. illegal combinations will be disallowed by [[embed.settings/check-and-sync-settings-on-startup!]], so we don't test syncing for them. + (mt/with-premium-features #{:embedding :embedding-sdk} + (mt/with-temporary-setting-values [enable-embedding true + enable-embedding-sdk true + enable-embedding-interactive true + enable-embedding-static true + embedding-app-origin nil + embedding-app-origins-interactive nil + embedding-app-origins-sdk nil] + (test-origin-sync! {} :no-op) + + (test-origin-sync! {:mb-embedding-app-origins-sdk other-ip} :no-op) + (test-origin-sync! {:mb-embedding-app-origins-sdk nil} :no-op) + + (test-origin-sync! {:mb-embedding-app-origins-interactive other-ip} :no-op) + (test-origin-sync! {:mb-embedding-app-origins-interactive nil} :no-op) + + (test-origin-sync! {:mb-embedding-app-origin other-ip} :sets-both)))) diff --git a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj index 66813da171b13..04f49a296eaea 100644 --- a/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj +++ b/enterprise/backend/test/metabase_enterprise/advanced_permissions/api/setting_test.clj @@ -162,7 +162,7 @@ (deftest dashboard-api-test (testing "/api/dashboard" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding-static true] (mt/with-user-in-groups [group {:name "New Group"} user [group]] @@ -207,7 +207,7 @@ (deftest action-api-test (testing "/api/action" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding true] (mt/with-actions-enabled (mt/with-user-in-groups [group {:name "New Group"} @@ -241,7 +241,7 @@ (deftest card-api-test (testing "/api/card" (mt/with-temporary-setting-values [enable-public-sharing true - enable-embedding true] + enable-embedding-static true] (mt/with-user-in-groups [group {:name "New Group"} user [group]] @@ -250,7 +250,7 @@ (mt/user-http-request user :get status "card/public"))) (get-embeddable-cards [user status] - (testing (format "get embeddable dashboards with %s user" (mt/user-descriptor user)) + (testing (format "get embeddable cards with %s user" (mt/user-descriptor user)) (t2.with-temp/with-temp [Card _ {:enable_embedding true}] (mt/user-http-request user :get status "card/embeddable")))) diff --git a/enterprise/backend/test/metabase_enterprise/public_settings_test.clj b/enterprise/backend/test/metabase_enterprise/public_settings_test.clj index 3cc34d6ee47ac..71e123f79e1c6 100644 --- a/enterprise/backend/test/metabase_enterprise/public_settings_test.clj +++ b/enterprise/backend/test/metabase_enterprise/public_settings_test.clj @@ -28,25 +28,24 @@ (public-settings/enable-password-login)))))))) (deftest toggle-full-app-embedding-test - (mt/discard-setting-changes [embedding-app-origin] - (testing "can't change embedding-app-origin if :embedding feature is not available" + (mt/discard-setting-changes [embedding-app-origins-interactive] + (testing "can't change embedding-app-origins-interactive if :embedding feature is not available" (mt/with-premium-features #{} (is (thrown-with-msg? clojure.lang.ExceptionInfo - #"Setting embedding-app-origin is not enabled because feature :embedding is not available" - (embed.settings/embedding-app-origin! "https://metabase.com"))) + #"Setting embedding-app-origins-interactive is not enabled because feature :embedding is not available" + (embed.settings/embedding-app-origins-interactive! "https://metabase.com"))) (testing "even if env is set, return the default value" - (mt/with-temp-env-var-value! [mb-embedding-app-origin "https://metabase.com"] - (is (nil? (embed.settings/embedding-app-origin))))))) + (mt/with-temp-env-var-value! [mb-embedding-app-origins-interactive "https://metabase.com"] + (is (nil? (embed.settings/embedding-app-origins-interactive))))))) - (testing "can change embedding-app-origin if :embedding is enabled" + (testing "can change embedding-app-origins-interactive if :embedding is enabled" (mt/with-premium-features #{:embedding} - (embed.settings/embedding-app-origin! "https://metabase.com") + (embed.settings/embedding-app-origins-interactive! "https://metabase.com") (is (= "https://metabase.com" - (embed.settings/embedding-app-origin))) - + (embed.settings/embedding-app-origins-interactive))) (testing "it works with env too" - (mt/with-temp-env-var-value! [mb-embedding-app-origin "ssh://metabase.com"] + (mt/with-temp-env-var-value! [mb-embedding-app-origins-interactive "ssh://metabase.com"] (is (= "ssh://metabase.com" - (embed.settings/embedding-app-origin))))))))) + (embed.settings/embedding-app-origins-interactive))))))))) diff --git a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj index ed33b5b84a783..ad9eee7caa89b 100644 --- a/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj +++ b/enterprise/backend/test/metabase_enterprise/sso/integrations/jwt_test.clj @@ -346,7 +346,7 @@ (deftest jwt-token-test (testing "should return a session token when token=true" (with-jwt-default-setup! - (mt/with-temporary-setting-values [enable-embedding true] + (mt/with-temporary-setting-values [enable-embedding-static true] (let [jwt-iat-time (buddy-util/now) jwt-exp-time (+ (buddy-util/now) 3600) jwt-payload (jwt/sign {:email "rasta@metabase.com" diff --git a/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx new file mode 100644 index 0000000000000..14c3fcc4b59c3 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/components/public/StaticDashboard/CypressStaticDashboardWithCors.stories.tsx @@ -0,0 +1,24 @@ +import type { ComponentStory } from "@storybook/react"; + +import { StaticDashboard } from "embedding-sdk"; +import { CommonSdkStoryCorsWrapper } from "embedding-sdk/test/CommonSdkCorsStoryWrapper"; + +const DASHBOARD_ID = (window as any).DASHBOARD_ID || "1"; + +export default { + title: "EmbeddingSDK/CypressStaticDashboardWithCors", + component: StaticDashboard, + parameters: { + layout: "fullscreen", + }, + decorators: [CommonSdkStoryCorsWrapper], +}; + +const Template: ComponentStory = args => { + return ; +}; + +export const Default = Template.bind({}); +Default.args = { + dashboardId: DASHBOARD_ID, +}; diff --git a/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx b/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx new file mode 100644 index 0000000000000..703a5ee320d6a --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/test/CommonSdkCorsStoryWrapper.tsx @@ -0,0 +1,17 @@ +import type { Story } from "@storybook/react"; + +import { MetabaseProvider, type SDKConfig } from "embedding-sdk"; + +const METABASE_INSTANCE_URL = + (window as any).METABASE_INSTANCE_URL || "http://localhost:3000"; + +const DEFAULT_CONFIG: SDKConfig = { + metabaseInstanceUrl: METABASE_INSTANCE_URL, + jwtProviderUri: `${METABASE_INSTANCE_URL}/sso/metabase`, +}; + +export const CommonSdkStoryCorsWrapper = (Story: Story) => ( + + + +); diff --git a/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js b/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js new file mode 100644 index 0000000000000..25382cec7e042 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/embedding-sdk/index.js @@ -0,0 +1,6 @@ +import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins"; +import { hasPremiumFeature } from "metabase-enterprise/settings"; + +if (hasPremiumFeature("embedding_sdk")) { + PLUGIN_EMBEDDING_SDK.isEnabled = () => true; +} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx index 8b329d481be93..36e86d1535d65 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/EmbeddingAppSameSiteCookieDescription.tsx @@ -23,6 +23,7 @@ export const EmbeddingAppSameSiteCookieDescription = () => { const embeddingAuthorizedOrigins = useSetting("embedding-app-origin"); const shouldDisplayNote = + embeddingAuthorizedOrigins && embeddingSameSiteCookieSetting !== "none" && authorizedOriginsContainsNonInstanceDomain(embeddingAuthorizedOrigins); diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx index da96a3029422a..dab2f3529e7a2 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/EmbeddingAppSameSiteCookieDescription/SameSiteSelectWidget.tsx @@ -1,8 +1,28 @@ import { useState } from "react"; +import { t } from "ttag"; import { Button, Group, Icon, Menu, Text } from "metabase/ui"; import type { SessionCookieSameSite } from "metabase-types/api"; +const SAME_SITE_OPTIONS: Options[] = [ + { + value: "lax", + name: t`Lax (default)`, + description: t`Allows cookies to be sent when a user is navigating to the origin site from an external site (like when following a link).`, + }, + { + value: "strict", + name: t`Strict (not recommended)`, + // eslint-disable-next-line no-literal-metabase-strings -- Metabase settings + description: t`Never allows cookies to be sent on a cross-site request. Warning: this will prevent users from following external links to Metabase.`, + }, + { + value: "none", + name: t`None`, + description: t`Allows all cross-site requests. Incompatible with most Safari and iOS-based browsers.`, + }, +]; + interface Options { value: SessionCookieSameSite; name: string; @@ -14,19 +34,18 @@ interface SameSiteSelectWidgetProps { setting: { key: "session-cookie-samesite"; value?: SessionCookieSameSite; - defaultValue: SessionCookieSameSite; - options: Options[]; }; } +const DEFAULT_SAME_SITE_VALUE = "lax"; export function SameSiteSelectWidget({ setting, onChange, }: SameSiteSelectWidgetProps) { const [opened, setOpened] = useState(false); - const selectedValue = setting.value ?? setting.defaultValue; - const selectedOption = setting.options.find( + const selectedValue = setting.value ?? DEFAULT_SAME_SITE_VALUE; + const selectedOption = SAME_SITE_OPTIONS.find( ({ value }) => value === selectedValue, ); @@ -47,7 +66,7 @@ export function SameSiteSelectWidget({ - {setting.options.map(({ value, name, description }) => ( + {SAME_SITE_OPTIONS.map(({ value, name, description }) => ( onChange(value)}> {name} {description} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx b/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx new file mode 100644 index 0000000000000..d97180628d8a2 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/embedding/components/InteractiveEmbeddingSettings.tsx @@ -0,0 +1,95 @@ +import { t } from "ttag"; + +import type { AdminSettingComponentProps } from "metabase/admin/settings/components/EmbeddingSettings/types"; +import SettingHeader from "metabase/admin/settings/components/SettingHeader"; +import { SetByEnvVarWrapper } from "metabase/admin/settings/components/SettingsSetting"; +import { SwitchWithSetByEnvVar } from "metabase/admin/settings/components/widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { SettingTextInput } from "metabase/admin/settings/components/widgets/SettingTextInput"; +import { useMergeSetting } from "metabase/common/hooks"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import { Box, Stack } from "metabase/ui"; +import type { SessionCookieSameSite } from "metabase-types/api"; + +import { EmbeddingAppOriginDescription } from "./EmbeddingAppOriginDescription"; +import { + EmbeddingAppSameSiteCookieDescription, + SameSiteSelectWidget, +} from "./EmbeddingAppSameSiteCookieDescription"; + +const INTERACTIVE_EMBEDDING_ORIGINS_SETTING = { + key: "embedding-app-origins-interactive", + display_name: t`Authorized origins`, + description: , + placeholder: "https://*.example.com", +} as const; + +const SAME_SITE_SETTING = { + key: "session-cookie-samesite", + display_name: t`SameSite cookie setting`, + description: , + widget: SameSiteSelectWidget, +} as const; + +export function InteractiveEmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + function handleToggleInteractiveEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-interactive" }, value); + } + + const interactiveEmbeddingOriginsSetting = useMergeSetting( + INTERACTIVE_EMBEDDING_ORIGINS_SETTING, + ); + + function handleChangeInteractiveEmbeddingOrigins(value: string | null) { + updateSetting({ key: interactiveEmbeddingOriginsSetting.key }, value); + } + + const sameSiteSetting = useMergeSetting(SAME_SITE_SETTING); + + function handleChangeSameSite(value: SessionCookieSameSite) { + updateSetting({ key: sameSiteSetting.key }, value); + } + + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/enterprise/frontend/src/metabase-enterprise/embedding/index.js b/enterprise/frontend/src/metabase-enterprise/embedding/index.js index 7bcab6a377024..b0b481ebb6479 100644 --- a/enterprise/frontend/src/metabase-enterprise/embedding/index.js +++ b/enterprise/frontend/src/metabase-enterprise/embedding/index.js @@ -1,71 +1,14 @@ -import { t } from "ttag"; - -import { - PLUGIN_ADMIN_SETTINGS_UPDATES, - PLUGIN_EMBEDDING, -} from "metabase/plugins"; +import { PLUGIN_ADMIN_SETTINGS, PLUGIN_EMBEDDING } from "metabase/plugins"; import { isInteractiveEmbeddingEnabled } from "metabase-enterprise/embedding/selectors"; import { hasPremiumFeature } from "metabase-enterprise/settings"; -import { EmbeddingAppOriginDescription } from "./components/EmbeddingAppOriginDescription"; -import { - EmbeddingAppSameSiteCookieDescription, - SameSiteSelectWidget, -} from "./components/EmbeddingAppSameSiteCookieDescription"; - -const SLUG = "embedding-in-other-applications/full-app"; +import { InteractiveEmbeddingSettings } from "./components/InteractiveEmbeddingSettings"; if (hasPremiumFeature("embedding")) { PLUGIN_EMBEDDING.isEnabled = () => true; PLUGIN_EMBEDDING.isInteractiveEmbeddingEnabled = isInteractiveEmbeddingEnabled; - PLUGIN_ADMIN_SETTINGS_UPDATES.push(sections => { - return { - ...sections, - [SLUG]: { - ...sections[SLUG], - settings: [ - ...sections[SLUG]["settings"], - { - key: "embedding-app-origin", - display_name: t`Authorized origins`, - description: , - placeholder: "https://*.example.com", - type: "string", - getHidden: (_, derivedSettings) => - !derivedSettings["enable-embedding"], - }, - { - key: "session-cookie-samesite", - display_name: t`SameSite cookie setting`, - description: , - type: "select", - options: [ - { - value: "lax", - name: t`Lax (default)`, - description: t`Allows cookies to be sent when a user is navigating to the origin site from an external site (like when following a link).`, - }, - { - value: "strict", - name: t`Strict (not recommended)`, - // eslint-disable-next-line no-literal-metabase-strings -- Metabase settings - description: t`Never allows cookies to be sent on a cross-site request. Warning: this will prevent users from following external links to Metabase.`, - }, - { - value: "none", - name: t`None`, - description: t`Allows all cross-site requests. Incompatible with most Safari and iOS-based browsers.`, - }, - ], - defaultValue: "lax", - widget: SameSiteSelectWidget, - getHidden: (_, derivedSettings) => - !derivedSettings["enable-embedding"], - }, - ], - }, - }; - }); + PLUGIN_ADMIN_SETTINGS.InteractiveEmbeddingSettings = + InteractiveEmbeddingSettings; } diff --git a/enterprise/frontend/src/metabase-enterprise/plugins.js b/enterprise/frontend/src/metabase-enterprise/plugins.js index cc278b0831a10..77e73e14de81c 100644 --- a/enterprise/frontend/src/metabase-enterprise/plugins.js +++ b/enterprise/frontend/src/metabase-enterprise/plugins.js @@ -16,6 +16,7 @@ import "./collections"; import "./content_verification"; import "./whitelabel"; import "./embedding"; +import "./embedding-sdk"; import "./snippets"; import "./sharing"; import "./moderation"; diff --git a/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx b/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx index 7a47ee8df53b6..86b84897c2d5e 100644 --- a/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx +++ b/enterprise/frontend/src/metabase-enterprise/user_provisioning/components/UserProvisioning.tsx @@ -4,7 +4,7 @@ import { t } from "ttag"; import _ from "underscore"; import { AuthTabs } from "metabase/admin/settings/components/AuthTabs"; -import SettingToggle from "metabase/admin/settings/components/widgets/SettingToggle"; +import { SettingToggle } from "metabase/admin/settings/components/widgets/SettingToggle"; import type { SettingElement } from "metabase/admin/settings/types"; import { useSetting } from "metabase/common/hooks"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; diff --git a/frontend/src/metabase-types/analytics/embed-share.ts b/frontend/src/metabase-types/analytics/embed-share.ts index 979772b888f2d..c2eeae2d72ade 100644 --- a/frontend/src/metabase-types/analytics/embed-share.ts +++ b/frontend/src/metabase-types/analytics/embed-share.ts @@ -10,15 +10,24 @@ type ValidateEvent< Record, never>, > = T; +type EmbeddingEventName = + | "embedding" + | "sdk_embedding" + | "interactive_embedding" + | "static_embedding"; + +type EmbeddingEventEnabled = `${EmbeddingEventName}_enabled`; +type EmbeddingEventDisabled = `${EmbeddingEventName}_disabled`; + export type EmbeddingEnabledEvent = ValidateEvent<{ - event: "embedding_enabled"; + event: EmbeddingEventEnabled; authorized_origins_set: boolean; number_embedded_questions: number; number_embedded_dashboards: number; }>; export type EmbeddingDisabledEvent = ValidateEvent<{ - event: "embedding_disabled"; + event: EmbeddingEventDisabled; authorized_origins_set: boolean; number_embedded_questions: number; number_embedded_dashboards: number; diff --git a/frontend/src/metabase-types/api/mocks/settings.ts b/frontend/src/metabase-types/api/mocks/settings.ts index 82a2c255c09fb..e65860a18277d 100644 --- a/frontend/src/metabase-types/api/mocks/settings.ts +++ b/frontend/src/metabase-types/api/mocks/settings.ts @@ -5,6 +5,7 @@ import type { EngineSource, FontFile, SettingDefinition, + SettingKey, Settings, TokenFeatures, TokenStatus, @@ -129,13 +130,14 @@ export const createMockTokenFeatures = ( ...opts, }); -export const createMockSettingDefinition = ( - opts?: Partial, -): SettingDefinition => ({ - key: "key", +export const createMockSettingDefinition = < + Key extends SettingKey = SettingKey, +>( + opts: SettingDefinition, +): SettingDefinition => ({ env_name: "", is_env_setting: false, - value: null, + value: opts.value, ...opts, }); @@ -169,7 +171,12 @@ export const createMockSettings = ( "email-smtp-username": null, "email-smtp-password": null, "embedding-app-origin": "", + "embedding-app-origins-sdk": "", + "embedding-app-origins-interactive": "", "enable-embedding": false, + "enable-embedding-static": false, + "enable-embedding-sdk": false, + "enable-embedding-interactive": false, "enable-enhancements?": false, "enable-nested-queries": true, "expand-browse-in-nav": true, @@ -212,6 +219,7 @@ export const createMockSettings = ( "report-timezone-long": "Europe/London", "saml-configured": false, "saml-enabled": false, + "saml-identity-provider-uri": null, "scim-enabled": false, "scim-base-url": "http://localhost:3000/api/ee/scim/v2/", "snowplow-url": "", diff --git a/frontend/src/metabase-types/api/settings.ts b/frontend/src/metabase-types/api/settings.ts index c1cbc3e89e6ce..d7ee125d6e41b 100644 --- a/frontend/src/metabase-types/api/settings.ts +++ b/frontend/src/metabase-types/api/settings.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + export interface FormattingSettings { "type/Temporal"?: DateFormattingSettings; "type/Number"?: NumberFormattingSettings; @@ -184,17 +186,17 @@ export type PasswordComplexity = { export type SessionCookieSameSite = "lax" | "strict" | "none"; -export type UpdateChannel = "latest" | "beta" | "nightly"; - -export interface SettingDefinition { - key: string; +export interface SettingDefinition { + key: Key; env_name?: string; - is_env_setting: boolean; - value?: unknown; - default?: unknown; - description?: string; + is_env_setting?: boolean; + value?: SettingValue; + default?: SettingValue; + description?: string | ReactNode | null; } +export type UpdateChannel = "latest" | "beta" | "nightly"; + export interface OpenAiModel { id: string; owned_by: string; @@ -216,6 +218,9 @@ interface InstanceSettings { "email-smtp-username": string | null; "email-smtp-password": string | null; "enable-embedding": boolean; + "enable-embedding-static": boolean; + "enable-embedding-sdk": boolean; + "enable-embedding-interactive": boolean; "enable-nested-queries": boolean; "enable-public-sharing": boolean; "enable-xrays": boolean; @@ -254,6 +259,7 @@ interface AdminSettings { "premium-embedding-token": string | null; "saml-configured"?: boolean; "saml-enabled"?: boolean; + "saml-identity-provider-uri": string | null; "other-sso-enabled?"?: boolean; // yes the question mark is in the variable name "show-database-syncing-modal": boolean; "token-status": TokenStatus | null; @@ -295,7 +301,9 @@ interface PublicSettings { "custom-homepage-dashboard": number | null; "ee-ai-features-enabled"?: boolean; "email-configured?": boolean; - "embedding-app-origin": string; + "embedding-app-origin": string | null; + "embedding-app-origins-sdk": string | null; + "embedding-app-origins-interactive": string | null; "enable-enhancements?": boolean; "enable-password-login": boolean; engines: Record; @@ -355,4 +363,4 @@ export type Settings = InstanceSettings & export type SettingKey = keyof Settings; -export type SettingValue = Settings[SettingKey]; +export type SettingValue = Settings[Key]; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx index 1cc3353becf68..bb7a94a42c9b3 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/SettingsEditor.jsx @@ -88,7 +88,7 @@ class SettingsEditor extends Component { * @param {function} [options.onChanged] - callback fired after the setting has been updated * @param {function} [options.onError] - callback fired after the setting has failed to update */ - updateSetting = async (setting, newValue, options) => { + handleUpdateSetting = async (setting, newValue, options) => { const { settingValues, updateSetting, reloadSettings, dispatch } = this.props; @@ -181,7 +181,7 @@ class SettingsEditor extends Component { elements={activeSection.settings} settingValues={settingValues} derivedSettingValues={derivedSettingValues} - updateSetting={this.updateSetting.bind(this)} + updateSetting={this.handleUpdateSetting.bind(this)} onChangeSetting={this.handleChangeSetting.bind(this)} reloadSettings={this.props.reloadSettings} /> @@ -193,7 +193,7 @@ class SettingsEditor extends Component { settingElements={activeSection.settings} settingValues={settingValues} derivedSettingValues={derivedSettingValues} - updateSetting={this.updateSetting.bind(this)} + updateSetting={this.handleUpdateSetting.bind(this)} onChangeSetting={this.handleChangeSetting.bind(this)} reloadSettings={this.props.reloadSettings} /> diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx index 56bbc40e6128f..189c24d289328 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/common.unit.spec.tsx @@ -1,43 +1,12 @@ -import userEvent from "@testing-library/user-event"; - import { screen } from "__support__/ui"; import { createMockSettingDefinition, createMockSettings, } from "metabase-types/api/mocks"; -import { EMAIL_URL, FULL_APP_EMBEDDING_URL, setup } from "./setup"; +import { EMAIL_URL, setup } from "./setup"; describe("SettingsEditor", () => { - describe("full-app embedding", () => { - it("should show info about interactive embedding", async () => { - await setup({ - settings: [createMockSettingDefinition({ key: "enable-embedding" })], - settingValues: createMockSettings({ "enable-embedding": true }), - }); - - await userEvent.click(screen.getByText("Embedding")); - await userEvent.click(screen.getByText("Interactive embedding")); - expect(screen.queryByText("Authorized origins")).not.toBeInTheDocument(); - expect( - screen.queryByText("SameSite cookie setting"), - ).not.toBeInTheDocument(); - }); - - it("should allow visiting the full-app embedding page even if embedding is not enabled", async () => { - await setup({ - settings: [createMockSettingDefinition({ key: "enable-embedding" })], - settingValues: createMockSettings({ "enable-embedding": false }), - initialRoute: FULL_APP_EMBEDDING_URL, - }); - - expect( - screen.getByText(/Embed dashboards, questions/), - ).toBeInTheDocument(); - expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); - }); - }); - describe("subscription allowed domains", () => { it("should not be visible", async () => { await setup({ diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx index 3a9c993a2fd82..486a04415dc23 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding.unit.spec.tsx @@ -1,6 +1,6 @@ import userEvent from "@testing-library/user-event"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { createMockSettingDefinition, createMockSettings, @@ -115,5 +115,11 @@ describe("SettingsEditor", () => { }); const goToInteractiveEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Configure")); + await userEvent.click( + within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByText("Configure"), + ); }; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx index 8be967b4ded61..38e9403fc7216 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/common.embedding.unit.spec.tsx @@ -1,54 +1,438 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; + +import { act, screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import { embeddingSettingsUrl, - getQuickStartLink, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; describe("[OSS] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", + ); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": false }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { + "enable-embedding-interactive": false, + }, + }) + ).history; + }); - expect(screen.getByRole("button", { name: "Manage" })).toBeDisabled(); + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeDisabled(); + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { act(() => { - history.push(staticEmbeddingSettingsUrl); + history.push(interactiveEmbeddingSettingsUrl); }); expect(history.getCurrentLocation().pathname).toEqual( embeddingSettingsUrl, ); }); + }); + }); - it("should prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); - expect(screen.getByText("upgrade to a paid plan")).toBeInTheDocument(); + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "upgrade to a paid plan" }), + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), ).toHaveProperty( "href", "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", ); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": true }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); }); + }); + + describe("when interactive embedding is enabled", () => { + let history: History; + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-interactive": true }, + }) + ).history; + }); describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeDisabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { + expect( + screen.queryByRole("button", { name: "Configure" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); act(() => { history.push(interactiveEmbeddingSettingsUrl); @@ -58,79 +442,230 @@ describe("[OSS] embedding settings", () => { embeddingSettingsUrl, ); }); + }); + }); - it("should have a learn more button for interactive embedding", async () => { - await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should link to quickstart for interactive embedding", async () => { + await setupEmbedding({ + settingValues: { + "enable-embedding": false, + version: { tag: "v0.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: false, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.getByText("Static embedding")).toBeInTheDocument(); + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); + expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); + }); + + describe("self-hosted (OSS)", () => { + beforeEach(async () => { + await setupEmbedding({ + isHosted: false, + settingValues: { "is-hosted?": false }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, switch Metabase binaries, upgrade to Metabase Pro and implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); expect( - screen.getByRole("link", { name: "Learn More" }), + withinApiKeyBanner.getByRole("link", { + name: "switch Metabase binaries", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/docs/latest/paid-features/activating-the-enterprise-edition.html?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", + ); + }); + + it("should show quick start section", () => { + expect( + screen.getByText("Try Embedded analytics SDK"), + ).toBeInTheDocument(); + expect( + screen.getByText("Use the SDK with API keys for development."), ).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty( + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); - it("should link to quickstart for interactive embedding", async () => { - await setupEmbedding({ - settingValues: { - "enable-embedding": false, - version: { tag: "v0.49.3" }, - }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + const corsSettingDescription = screen.getByText( + /Try out the SDK on localhost. To enable other sites/, + ); + expect(corsSettingDescription).toHaveTextContent( + "Try out the SDK on localhost. To enable other sites, upgrade to Metabase Pro and Enter the origins for the websites or apps where you want to allow SDK embedding.", + ); + + expect( + within(corsSettingDescription).getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); - it("should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", async () => { - await setupEmbedding({ - settingValues: { "enable-embedding": false }, - }); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); expect( - screen.getByText("offer multi-tenant, self-service analytics"), + within(documentationText).getByRole("link", { + name: "documentation", + }), ).toHaveProperty( "href", - "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=oss", ); }); + + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": true }, - }); - await goToStaticEmbeddingSettings(); + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); }); - it("should not allow going to interactive embedding settings page", async () => { - const { history } = await setupEmbedding({ - settingValues: { "enable-embedding": true }, + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, }); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); expect( - screen.queryByRole("button", { name: "Configure" }), - ).not.toBeInTheDocument(); + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupEmbedding({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, + }); + + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); expect( - screen.getByRole("link", { name: "Learn More" }), - ).toBeInTheDocument(); + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); + }); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupEmbedding({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Try it out")); + + expect(screen.getByText(/this has been set by the/i)).toBeInTheDocument(); + expect( + screen.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect(screen.getByText(/environment variable/i)).toBeInTheDocument(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx index 625029589aef0..f2e65e46392b0 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/enterprise.embedding.unit.spec.tsx @@ -1,14 +1,23 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; + +import { act, screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import type { SetupOpts } from "./setup"; import { embeddingSettingsUrl, - getQuickStartLink, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; const setupEnterprise = (opts?: SetupOpts) => { return setupEmbedding({ @@ -19,45 +28,419 @@ const setupEnterprise = (opts?: SetupOpts) => { }; describe("[EE, no token] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEmbedding({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", + ); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": false }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": false }, + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); - expect(screen.getByRole("button", { name: "Manage" })).toBeDisabled(); + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeDisabled(); + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { act(() => { - history.push(staticEmbeddingSettingsUrl); + history.push(interactiveEmbeddingSettingsUrl); }); expect(history.getCurrentLocation().pathname).toEqual( embeddingSettingsUrl, ); }); + }); + }); - it("should prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); + + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); - expect(screen.getByText("upgrade to a paid plan")).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("upgrade to a paid plan"), + ).toBeInTheDocument(); expect( - screen.getByRole("link", { name: "upgrade to a paid plan" }), + withinStaticEmbeddingCard.getByRole("link", { + name: "upgrade to a paid plan", + }), ).toHaveProperty( "href", "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_content=embed-settings&source_plan=oss", ); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": true }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); + + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeDisabled(); + }); + }); + }); + + describe("when interactive embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": true }, + }) + ).history; }); describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeDisabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + ); + + // should have a learn more button for interactive embedding + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByRole("link", { + name: "Learn More", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should not allow access to interactive embedding settings page", async () => { + expect( + screen.queryByRole("button", { name: "Configure" }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); act(() => { history.push(interactiveEmbeddingSettingsUrl); @@ -67,80 +450,230 @@ describe("[EE, no token] embedding settings", () => { embeddingSettingsUrl, ); }); + }); + }); - it("should have a learn more button for interactive embedding", async () => { - await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should link to quickstart for interactive embedding", async () => { + await setupEnterprise({ + settingValues: { + "enable-embedding": false, + version: { tag: "v0.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + ); + }); + + it("should redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: true, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.getByText("Static embedding")).toBeInTheDocument(); + expect(screen.getByText("Embedded analytics SDK")).toBeInTheDocument(); + expect(screen.getByText("Interactive embedding")).toBeInTheDocument(); + }); + + describe("cloud (Cloud starter)", () => { + beforeEach(async () => { + await setupEnterprise({ + isHosted: true, + settingValues: { "is-hosted?": true }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Try it out" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, upgrade to Metabase Pro and implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); expect( - screen.getByRole("link", { name: "Learn More" }), + withinApiKeyBanner.getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", + ); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", + ); + }); + + it("should show quick start section", () => { + expect( + screen.getByText("Try Embedded analytics SDK"), + ).toBeInTheDocument(); + expect( + screen.getByText("Use the SDK with API keys for development."), ).toBeInTheDocument(); - expect(screen.getByRole("link", { name: "Learn More" })).toHaveProperty( + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( "href", - "https://www.metabase.com/product/embedded-analytics?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); - it("should link to quickstart for interactive embedding", async () => { - await setupEnterprise({ - settingValues: { - "enable-embedding": false, - version: { tag: "v1.49.3" }, - }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + const corsSettingDescription = screen.getByText( + /Try out the SDK on localhost. To enable other sites/, + ); + expect(corsSettingDescription).toHaveTextContent( + "Try out the SDK on localhost. To enable other sites, upgrade to Metabase Pro and Enter the origins for the websites or apps where you want to allow SDK embedding.", + ); + + expect( + within(corsSettingDescription).getByRole("link", { + name: "upgrade to Metabase Pro", + }), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=oss&utm_media=embed-settings", + "https://www.metabase.com/upgrade?utm_source=product&utm_medium=upsell&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); - it("should link to https://www.metabase.com/blog/why-full-app-embedding", async () => { - await setupEnterprise({ - settingValues: { "enable-embedding": false }, - }); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); expect( - screen.getByText("offer multi-tenant, self-service analytics"), + within(documentationText).getByRole("link", { + name: "documentation", + }), ).toHaveProperty( "href", - "https://www.metabase.com/blog/why-full-app-embedding?utm_source=oss&utm_media=embed-settings", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=starter", ); }); + + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": true }, + + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupEnterprise({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, }); - await goToStaticEmbeddingSettings(); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Try it out")); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); }); - it("should not allow going to interactive embedding settings page", async () => { - const { history } = await setupEnterprise({ - settingValues: { "enable-embedding": true }, + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, }); + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); expect( - screen.queryByRole("button", { name: "Configure" }), - ).not.toBeInTheDocument(); + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, + }); + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); expect( - screen.getByRole("link", { name: "Learn More" }), - ).toBeInTheDocument(); + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupEnterprise({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), ); + expect( + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx index 4282048323ced..bb7c0a54ad2d6 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/premium.embedding.unit.spec.tsx @@ -1,17 +1,26 @@ -import { act, screen } from "__support__/ui"; +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { screen, within } from "__support__/ui"; +import { + createMockSettingDefinition, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; + +import { FULL_APP_EMBEDDING_URL, setup } from "../setup"; import type { SetupOpts } from "./setup"; import { - embeddingSettingsUrl, - getQuickStartLink, - goToInteractiveEmbeddingSettings, - goToStaticEmbeddingSettings, + getInteractiveEmbeddingQuickStartLink, interactiveEmbeddingSettingsUrl, setupEmbedding, staticEmbeddingSettingsUrl, } from "./setup"; +import type { History } from "./types"; const setupPremium = (opts?: SetupOpts) => { + fetchMock.put("path:/api/setting/enable-embedding-interactive", 204); return setupEmbedding({ ...opts, hasEnterprisePlugins: true, @@ -20,92 +29,793 @@ const setupPremium = (opts?: SetupOpts) => { }; describe("[EE, with token] embedding settings", () => { - describe("when the embedding is disabled", () => { + describe("when static embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupPremium({ + settingValues: { "enable-embedding-static": false }, + }) + ).history; + }); + describe("static embedding", () => { - it("should not allow going to static embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + + expect( + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + }); + + it("should not prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.queryByText("upgrade to a paid plan"), + ).not.toBeInTheDocument(); + }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).not.toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); + }); + }); + + describe("when embedding SDK is disabled", () => { + beforeEach(async () => { + await setupPremium({ + settingValues: { + "enable-embedding-sdk": false, + "embedding-app-origins-sdk": "metabase-sdk.com", + }, + }); + }); + + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Disabled")).toBeEnabled(); + }); + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).not.toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); expect( - await screen.findByRole("button", { name: "Manage" }), + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), ).toBeDisabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toHaveValue("metabase-sdk.com"); + }); + }); + }); - act(() => { - history.push(staticEmbeddingSettingsUrl); - }); + describe("when interactive embedding is disabled", () => { + let history: History; + + beforeEach(async () => { + history = ( + await setupPremium({ + settingValues: { + "enable-embedding-interactive": false, + "embedding-app-origins-interactive": "localhost:9999", + "session-cookie-samesite": "strict", + }, + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).not.toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Disabled"), + ).toBeEnabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( + "href", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should allow access to interactive embedding settings page", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.queryByRole("link", { + name: "Learn More", + }), + ).not.toBeInTheDocument(); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByRole("button", { + name: "Configure", + }), + ); + + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).not.toBeChecked(); + await userEvent.click( + screen.getByLabelText("Enable Interactive embedding"), + ); + + expect(screen.getByLabelText("Authorized origins")).toBeEnabled(); + expect(screen.getByLabelText("Authorized origins")).toHaveValue( + "localhost:9999", + ); + + expect(screen.getByText("SameSite cookie setting")).toBeInTheDocument(); + expect( + screen.getByText("Strict (not recommended)"), + ).toBeInTheDocument(); expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + interactiveEmbeddingSettingsUrl, ); }); + }); + }); - it("should not prompt to upgrade to remove the Powered by text", async () => { + describe("when static embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + settingValues: { "enable-embedding-static": true }, + }) + ).history; + }); + + describe("static embedding", () => { + it("should show info about static embedding", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); expect( - screen.queryByText("upgrade to a paid plan"), + withinStaticEmbeddingCard.getByRole("heading", { + name: "Static embedding", + }), + ).toBeInTheDocument(); + expect( + withinStaticEmbeddingCard.getByText(/Use static embedding when/), + ).toBeInTheDocument(); + + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinStaticEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + }); + + it("should not prompt to upgrade to remove the Powered by text", async () => { + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.queryByText("upgrade to a paid plan"), ).not.toBeInTheDocument(); }); + + it("should allow access to static embedding settings page", async () => { + // Go to static embedding settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Static embedding", + }), + ).getByRole("button", { name: "Manage" }), + ); + + const staticEmbeddingToggle = screen.getByLabelText( + "Enable Static embedding", + ); + expect(staticEmbeddingToggle).toBeEnabled(); + expect(staticEmbeddingToggle).toBeChecked(); + + expect(screen.getByText("Embedding secret key")).toBeInTheDocument(); + expect(screen.getByText("Manage embeds")).toBeInTheDocument(); + + const location = history.getCurrentLocation(); + expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + }); }); + }); - describe("interactive embedding", () => { - it("should not allow going to interactive settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": false }, - }); + describe("when embedding SDK is enabled", () => { + beforeEach(async () => { + await setupPremium({ + settingValues: { + "enable-embedding-sdk": true, + "embedding-app-origins-sdk": "metabase-sdk.com", + }, + }); + }); - expect( - await screen.findByRole("button", { name: "Configure" }), - ).toBeDisabled(); + describe("embedding SDK", () => { + it("should show info about embedding SDK", async () => { + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); - act(() => { - history.push(interactiveEmbeddingSettingsUrl); - }); + expect( + withinEmbeddingSdkCard.getByRole("heading", { + name: "Embedded analytics SDK", + }), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByText("Beta")).toBeInTheDocument(); + expect( + withinEmbeddingSdkCard.getByText( + /Interactive embedding with full, granular control./, + ), + ).toBeInTheDocument(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeChecked(); + expect(withinEmbeddingSdkCard.getByLabelText("Enabled")).toBeEnabled(); + }); - expect(history.getCurrentLocation().pathname).toEqual( - embeddingSettingsUrl, + it("should allow access to embedding SDK settings page", async () => { + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), ); + + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeChecked(); + expect( + screen.getByLabelText("Enable Embedded analytics SDK"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toHaveValue("metabase-sdk.com"); }); + }); + }); - it("should link to quickstart for interactive embedding", async () => { + describe("when interactive embedding is enabled", () => { + let history: History; + + beforeEach(async () => { + history = ( await setupPremium({ settingValues: { - "enable-embedding": false, - version: { tag: "v1.49.3" }, + "enable-embedding-interactive": true, + "embedding-app-origins-interactive": "localhost:9999", + "session-cookie-samesite": "strict", }, - }); - expect(getQuickStartLink()).toBeInTheDocument(); - expect(getQuickStartLink()).toHaveProperty( + }) + ).history; + }); + + describe("interactive embedding", () => { + it("should show info about interactive embedding", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + expect( + withinInteractiveEmbeddingCard.getByRole("heading", { + name: "Interactive embedding", + }), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByText( + /Use interactive embedding when/, + ), + ).toBeInTheDocument(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeChecked(); + expect( + withinInteractiveEmbeddingCard.getByLabelText("Enabled"), + ).toBeEnabled(); + + // should link to https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings + expect( + withinInteractiveEmbeddingCard.getByText( + "offer multi-tenant, self-service analytics", + ), + ).toHaveProperty( "href", - "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=pro-self-hosted&utm_media=embed-settings", + "https://www.metabase.com/blog/why-full-app-embedding?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should allow access to interactive embedding settings page", async () => { + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.queryByRole("link", { + name: "Learn More", + }), + ).not.toBeInTheDocument(); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByRole("button", { + name: "Configure", + }), + ); + + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeEnabled(); + expect( + screen.getByLabelText("Enable Interactive embedding"), + ).toBeChecked(); + await userEvent.click( + screen.getByLabelText("Enable Interactive embedding"), + ); + + expect(screen.getByLabelText("Authorized origins")).toBeEnabled(); + expect(screen.getByLabelText("Authorized origins")).toHaveValue( + "localhost:9999", + ); + + expect(screen.getByText("SameSite cookie setting")).toBeInTheDocument(); + expect( + screen.getByText("Strict (not recommended)"), + ).toBeInTheDocument(); + + expect(history.getCurrentLocation().pathname).toEqual( + interactiveEmbeddingSettingsUrl, ); }); }); }); - describe("when the embedding is enabled", () => { - it("should allow going to static embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": true }, + + it("should link to quickstart for interactive embedding", async () => { + await setupPremium({ + settingValues: { + "enable-embedding": false, + version: { tag: "v1.49.3" }, + }, + }); + expect(getInteractiveEmbeddingQuickStartLink()).toBeInTheDocument(); + expect(getInteractiveEmbeddingQuickStartLink()).toHaveProperty( + "href", + "https://www.metabase.com/docs/v0.49/embedding/interactive-embedding-quick-start-guide.html?utm_source=pro-self-hosted&utm_media=embed-settings", + ); + }); + + it("should not redirect users back to embedding settings page when visiting the full-app embedding page when embedding is not enabled", async () => { + await setup({ + settings: [createMockSettingDefinition({ key: "enable-embedding" })], + settingValues: createMockSettings({ "enable-embedding": false }), + tokenFeatures: createMockTokenFeatures({ + embedding: false, + embedding_sdk: false, + }), + hasEnterprisePlugins: true, + initialRoute: FULL_APP_EMBEDDING_URL, + }); + + expect(screen.queryByText("Static embedding")).not.toBeInTheDocument(); + expect( + screen.queryByText("Embedded analytics SDK"), + ).not.toBeInTheDocument(); + }); + + describe("self-hosted (pro)", () => { + beforeEach(async () => { + await setupPremium({ + isHosted: false, + settingValues: { "is-hosted?": false }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); + }); + + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); + + it("should show quick start section", () => { + expect(screen.getByText("Get started")).toBeInTheDocument(); + expect( + screen.queryByText("Use the SDK with API keys for development."), + ).not.toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); + + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.", + ), + ).toBeInTheDocument(); }); - await goToStaticEmbeddingSettings(); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); + + expect( + within(documentationText).getByRole("link", { + name: "documentation", + }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-self-hosted", + ); + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(staticEmbeddingSettingsUrl); + it("should not show version pinning section", () => { + expect(screen.queryByText("Version pinning")).not.toBeInTheDocument(); + expect( + screen.queryByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("link", { name: "Request version pinning" }), + ).not.toBeInTheDocument(); + }); + }); + }); + + describe("cloud (Pro)", () => { + beforeEach(async () => { + await setupPremium({ + isHosted: true, + settingValues: { "is-hosted?": true }, + }); + + // Go to embedding SDK settings page + await userEvent.click( + within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ).getByRole("button", { name: "Configure" }), + ); }); - it("should allow going to interactive embedding settings page", async () => { - const { history } = await setupPremium({ - settingValues: { "enable-embedding": true }, + describe("Embedding SDK settings page", () => { + it("should show API key banner", () => { + const apiKeyBanner = screen.getByText( + /You can test Embedded analytics SDK/, + ); + expect(apiKeyBanner).toHaveTextContent( + "You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, implement JWT SSO.", + ); + + const withinApiKeyBanner = within(apiKeyBanner); + expect( + withinApiKeyBanner.getByRole("link", { + name: "implement JWT SSO", + }), + ).toHaveProperty( + "href", + "https://www.metabase.com/learn/metabase-basics/embedding/securing-embeds?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show quick start section", () => { + expect(screen.getByText("Get started")).toBeInTheDocument(); + expect( + screen.queryByText("Use the SDK with API keys for development."), + ).not.toBeInTheDocument(); + + expect( + screen.getByRole("link", { name: "Check out the Quick Start" }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-quick-start?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show CORS settings", () => { + expect( + screen.getByLabelText("Cross-Origin Resource Sharing (CORS)"), + ).toBeInTheDocument(); + + expect( + screen.getByText( + "Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.", + ), + ).toBeInTheDocument(); }); - await goToInteractiveEmbeddingSettings(); + it("should show documentation link", () => { + const documentationText = screen.getByTestId("sdk-documentation"); + expect(documentationText).toHaveTextContent( + "Check out the documentation for more.", + ); + + expect( + within(documentationText).getByRole("link", { + name: "documentation", + }), + ).toHaveProperty( + "href", + "https://metaba.se/sdk-docs?utm_source=product&utm_medium=docs&utm_campaign=embedding-sdk&utm_content=embedding-sdk-admin&source_plan=pro-cloud", + ); + }); + + it("should show version pinning section", () => { + expect(screen.getByText("Version pinning")).toBeInTheDocument(); + expect( + screen.getByText( + "Metabase Cloud instances are automatically upgraded to new releases. SDK packages are strictly compatible with specific version of Metabase. You can request to pin your Metabase to a major version and upgrade your Metabase and SDK dependency in a coordinated fashion.", + ), + ).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "Request version pinning" }), + ).toHaveProperty("href", "mailto:help@metabase.com"); + }); + }); + }); + + describe("when environment variables are set", () => { + it("should show `Set by environment variable` when the embedding-app-origins-interactive is an env var", async () => { + await setupPremium({ + settingValues: { + "embedding-app-origins-interactive": null, + "enable-embedding-interactive": true, + "enable-embedding": true, + }, + isEnvVar: true, + }); + + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + + await userEvent.click( + withinInteractiveEmbeddingCard.getByText("Configure"), + ); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); + + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-interactive/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); + }); + + it("should show `Set by environment variable` when the embedding-app-origins-sdk is an env var", async () => { + await setupPremium({ + settingValues: { "embedding-app-origins-sdk": null }, + isEnvVar: true, + }); + + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + + await userEvent.click(withinEmbeddingSdkCard.getByText("Configure")); + + const withinEnvVarMessage = within( + screen.getByTestId("setting-env-var-message"), + ); + + expect( + withinEnvVarMessage.getByText(/this has been set by the/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/embedding-app-origins-sdk/i), + ).toBeInTheDocument(); + expect( + withinEnvVarMessage.getByText(/environment variable/i), + ).toBeInTheDocument(); + }); + + it("should show `Set by environment variable` when the enable-embedding-static is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-static": true }, + isEnvVar: true, + }); + + const withinStaticEmbeddingCard = within( + screen.getByRole("article", { + name: "Static embedding", + }), + ); + expect( + withinStaticEmbeddingCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-sdk is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-sdk": true }, + isEnvVar: true, + }); + + const withinEmbeddingSdkCard = within( + screen.getByRole("article", { + name: "Embedded analytics SDK", + }), + ); + expect( + withinEmbeddingSdkCard.getByText("Set via environment variable"), + ).toBeVisible(); + }); + + it("should show `Set by environment variable` when the enable-embedding-interactive is an env var", async () => { + await setupPremium({ + settingValues: { "enable-embedding-interactive": true }, + isEnvVar: true, + }); - const location = history.getCurrentLocation(); - expect(location.pathname).toEqual(interactiveEmbeddingSettingsUrl); + const withinInteractiveEmbeddingCard = within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ); + expect( + withinInteractiveEmbeddingCard.getByText( + "Set via environment variable", + ), + ).toBeVisible(); }); }); }); diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx index 46e942e955074..bf251473a794c 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/setup.tsx @@ -1,10 +1,12 @@ import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; +import _ from "underscore"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { checkNotNull } from "metabase/lib/types"; import type { Settings } from "metabase-types/api"; import { + createMockSettingDefinition, createMockSettings, createMockTokenFeatures, } from "metabase-types/api/mocks"; @@ -13,19 +15,35 @@ import { setup } from "../setup"; export type SetupOpts = { settingValues?: Partial; + isEnvVar?: boolean; + isHosted?: boolean; hasEmbeddingFeature?: boolean; hasEnterprisePlugins?: boolean; }; export const setupEmbedding = async ({ - settingValues, + settingValues = {}, + isEnvVar = false, + isHosted = false, hasEmbeddingFeature = false, hasEnterprisePlugins = false, }: SetupOpts) => { const returnedValue = await setup({ + settings: _.pairs>(settingValues).map(([key, value]) => + createMockSettingDefinition({ + key, + value, + is_env_setting: isEnvVar, + // in reality this would be the MB_[whatever] env name, but + // we can just use the key for easier testing + env_name: key, + }), + ), settingValues: createMockSettings(settingValues), tokenFeatures: createMockTokenFeatures({ + hosting: isHosted, embedding: hasEmbeddingFeature, + embedding_sdk: hasEmbeddingFeature, }), hasEnterprisePlugins, }); @@ -38,16 +56,12 @@ export const setupEmbedding = async ({ return { ...returnedValue, history: checkNotNull(returnedValue.history) }; }; -export const goToStaticEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Manage")); -}; - -export const goToInteractiveEmbeddingSettings = async () => { - await userEvent.click(screen.getByText("Configure")); -}; - -export const getQuickStartLink = () => { - return screen.getByRole("link", { name: "Check out our Quick Start" }); +export const getInteractiveEmbeddingQuickStartLink = () => { + return within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByRole("link", { name: "Check out our Quick Start" }); }; export const embeddingSettingsUrl = diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts new file mode 100644 index 0000000000000..ecadace66d74a --- /dev/null +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/embedding/types.ts @@ -0,0 +1,3 @@ +import type { setupEmbedding } from "./setup"; + +export type History = Awaited>["history"]; diff --git a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx index 203e64468a6fb..fa7cc58ada974 100644 --- a/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/app/components/SettingsEditor/tests/enterprise.unit.spec.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import fetchMock from "fetch-mock"; import { setupGroupsEndpoint } from "__support__/server-mocks"; -import { screen } from "__support__/ui"; +import { screen, within } from "__support__/ui"; import { createMockGroup, createMockSettingDefinition, @@ -48,7 +48,14 @@ describe("SettingsEditor", () => { }); await userEvent.click(screen.getByText("Embedding")); - await userEvent.click(screen.getByText("Interactive embedding")); + expect( + within( + screen.getByRole("article", { + name: "Interactive embedding", + }), + ).getByRole("link", { name: "Learn More" }), + ).toBeInTheDocument(); + expect(screen.queryByText("Authorized origins")).not.toBeInTheDocument(); expect( screen.queryByText("SameSite cookie setting"), diff --git a/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx b/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx index 3f7386be4e785..9e9a822aeff9a 100644 --- a/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx +++ b/frontend/src/metabase/admin/settings/auth/components/AuthCard/AuthCard.unit.spec.tsx @@ -1,4 +1,5 @@ import userEvent from "@testing-library/user-event"; +import _ from "underscore"; import { renderWithProviders, screen } from "__support__/ui"; import { createMockSettingDefinition } from "metabase-types/api/mocks"; @@ -79,11 +80,15 @@ describe("AuthCard", () => { }); }); -const getSetting = (opts?: Partial): AuthSetting => - createMockSettingDefinition({ +const getSetting = (opts?: Partial): AuthSetting => { + const settingDefinition = createMockSettingDefinition({ + key: "google-auth-enabled", value: false, ...opts, - }) as AuthSetting; + }); + + return _.omit(settingDefinition, "key") as AuthSetting; +}; const getProps = (opts?: Partial): AuthCardProps => ({ setting: getSetting(), diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx new file mode 100644 index 0000000000000..605fda6611798 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSdkSettings.tsx @@ -0,0 +1,242 @@ +import { match } from "ts-pattern"; +import { jt, t } from "ttag"; + +import { useDocsUrl, useMergeSetting, useSetting } from "metabase/common/hooks"; +import { getPlan } from "metabase/common/utils/plan"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import ExternalLink from "metabase/core/components/ExternalLink"; +import { useSelector } from "metabase/lib/redux"; +import { PLUGIN_EMBEDDING_SDK } from "metabase/plugins"; +import { + getLearnUrl, + getSetting, + getUpgradeUrl, +} from "metabase/selectors/settings"; +import { Alert, Box, Button, Icon, Stack, Text } from "metabase/ui"; + +import SettingHeader from "../SettingHeader"; +import { SetByEnvVarWrapper } from "../SettingsSetting"; +import { SwitchWithSetByEnvVar } from "../widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { SettingTextInput } from "../widgets/SettingTextInput"; + +import type { AdminSettingComponentProps } from "./types"; + +export function EmbeddingSdkSettings({ + updateSetting, +}: AdminSettingComponentProps) { + const isEE = PLUGIN_EMBEDDING_SDK.isEnabled(); + const isEmbeddingSdkEnabled = useSetting("enable-embedding-sdk"); + const canEditSdkOrigins = isEE && isEmbeddingSdkEnabled; + + const plan = useSelector(state => + getPlan(getSetting(state, "token-features")), + ); + + const addUtmToLink = (url: string) => + `${url}?${new URLSearchParams({ + utm_source: "product", + utm_medium: "docs", + utm_campaign: "embedding-sdk", + utm_content: "embedding-sdk-admin", + source_plan: plan, + })}`; + + const isHosted = useSetting("is-hosted?"); + + const upgradeUrl = useSelector(state => + getUpgradeUrl(state, { + utm_campaign: "embedding-sdk", + utm_content: "embedding-sdk-admin", + }), + ); + + const sdkOriginsSetting = useMergeSetting( + !isEE + ? { + key: "embedding-app-origins-sdk", + placeholder: "https://*.example.com", + display_name: t`Cross-Origin Resource Sharing (CORS)`, + description: jt`Try out the SDK on localhost. To enable other sites, ${( + + {t`upgrade to Metabase Pro`} + + )} and Enter the origins for the websites or apps where you want to allow SDK embedding.`, + } + : { + key: "embedding-app-origins-sdk", + placeholder: "https://*.example.com", + display_name: t`Cross-Origin Resource Sharing (CORS)`, + description: t`Enter the origins for the websites or apps where you want to allow SDK embedding, separated by a space. Localhost is automatically included.`, + }, + ); + + function handleChangeSdkOrigins(value: string | null) { + updateSetting({ key: sdkOriginsSetting.key }, value); + } + + function handleToggleEmbeddingSdk(value: boolean) { + updateSetting({ key: "enable-embedding-sdk" }, value); + } + + const { url: activationUrl } = useDocsUrl( + "paid-features/activating-the-enterprise-edition", + ); + + const switchMetabaseBinariesUrl = addUtmToLink(activationUrl); + + const implementJwtUrl = addUtmToLink( + getLearnUrl("metabase-basics/embedding/securing-embeds"), + ); + + const quickStartUrl = addUtmToLink("https://metaba.se/sdk-quick-start"); + const documentationUrl = addUtmToLink("https://metaba.se/sdk-docs"); + + const apiKeyBannerText = match({ + isOSS: !isEE && !isHosted, + isCloudStarter: !isEE && isHosted, + isEE, + }) + .with( + { isOSS: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + switch Metabase binaries + + )}, ${( + + {t`upgrade to Metabase Pro`} + + )} and ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .with( + { isCloudStarter: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + {t`upgrade to Metabase Pro`} + + )} and ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .with( + { isEE: true }, + () => + jt`You can test Embedded analytics SDK on localhost quickly by using API keys. To use the SDK on other sites, ${( + + {t`implement JWT SSO`} + + )}.`, + ) + .otherwise(() => null); + + return ( + + + + + + + } + bg="var(--mb-color-background-info)" + style={{ + borderColor: "var(--mb-color-border)", + }} + variant="outline" + px="lg" + py="md" + maw={620} + > + {apiKeyBannerText} + + + + + + + + + + + + + + + {isEE && isHosted && ( + + + + + )} + + + {jt`Check out the ${( + + {t`documentation`} + + )} for more.`} + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx new file mode 100644 index 0000000000000..a99134e8b03e3 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/EmbeddingSettings.tsx @@ -0,0 +1,54 @@ +import { t } from "ttag"; + +import CS from "metabase/css/core/index.css"; +import { Box, Stack, Text } from "metabase/ui"; + +import SettingHeader from "../SettingHeader"; +import { + EmbeddingSdkOptionCard, + InteractiveEmbeddingOptionCard, + StaticEmbeddingOptionCard, +} from "../widgets/EmbeddingOption"; + +import type { AdminSettingComponentProps } from "./types"; + +export function EmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + function handleToggleStaticEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-static" }, value); + } + + function handleToggleEmbeddingSdk(value: boolean) { + updateSetting({ key: "enable-embedding-sdk" }, value); + } + + function handleToggleInteractiveEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-interactive" }, value); + } + + return ( + + + + + + + {t`Embed dashboards, questions, or the entire Metabase app into your application. Integrate with your server code to create a secure environment, limited to specific users or organizations.`} + + + + + + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx new file mode 100644 index 0000000000000..0c6aa7dfb7bd0 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/InteractiveEmbeddingSettings.tsx @@ -0,0 +1,7 @@ +import RedirectWidget from "../widgets/RedirectWidget"; + +export function InteractiveEmbeddingSettings() { + return ( + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx new file mode 100644 index 0000000000000..f2ecae560e87d --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/StaticEmbeddingSettings.tsx @@ -0,0 +1,83 @@ +import { t } from "ttag"; + +import { useMergeSetting, useSetting } from "metabase/common/hooks"; +import Breadcrumbs from "metabase/components/Breadcrumbs"; +import { Box, Stack } from "metabase/ui"; +import type { SettingValue } from "metabase-types/api"; + +import type { SettingElement } from "../../types"; +import SettingHeader from "../SettingHeader"; +import { SettingTitle } from "../SettingHeader/SettingHeader.styled"; +import { SetByEnvVarWrapper } from "../SettingsSetting"; +import { SwitchWithSetByEnvVar } from "../widgets/EmbeddingOption/SwitchWithSetByEnvVar"; +import { EmbeddedResources } from "../widgets/PublicLinksListing/EmbeddedResources"; +import SecretKeyWidget from "../widgets/SecretKeyWidget"; + +import type { AdminSettingComponentProps } from "./types"; + +const EMBEDDING_SECRET_KEY_SETTING: SettingElement<"embedding-secret-key"> = { + key: "embedding-secret-key", + display_name: t`Embedding secret key`, + description: t`Standalone Embed Secret Key used to sign JSON Web Tokens for requests to /api/embed endpoints. This lets you create a secure environment limited to specific users or organizations.`, +} as const; + +export function StaticEmbeddingSettings({ + updateSetting, +}: AdminSettingComponentProps) { + const embeddingSecretKeySetting = useMergeSetting( + EMBEDDING_SECRET_KEY_SETTING, + ); + + const isStaticEmbeddingEnabled = useSetting("enable-embedding-static"); + + const handleChangeEmbeddingSecretKey = ( + value: SettingValue<"embedding-secret-key">, + ) => updateSetting({ key: embeddingSecretKeySetting.key }, value); + + function handleToggleStaticEmbedding(value: boolean) { + updateSetting({ key: "enable-embedding-static" }, value); + } + + return ( + + + + + + + + + + + + {t`Manage embeds`} + {/* Right now, when changing the setting, we don't have a mechanism to reload the data. + For now we'll have to use this key. */} + + + + + ); +} diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts new file mode 100644 index 0000000000000..cca1b5c95e886 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/index.ts @@ -0,0 +1,4 @@ +export { EmbeddingSettings } from "./EmbeddingSettings"; +export { StaticEmbeddingSettings } from "./StaticEmbeddingSettings"; +export { EmbeddingSdkSettings } from "./EmbeddingSdkSettings"; +export { InteractiveEmbeddingSettings } from "./InteractiveEmbeddingSettings"; diff --git a/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts new file mode 100644 index 0000000000000..4984ca78ff2f4 --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/EmbeddingSettings/types.ts @@ -0,0 +1,10 @@ +import type { SettingKey, Settings } from "metabase-types/api"; + +type Setting = { key: K }; + +export interface AdminSettingComponentProps { + updateSetting: ( + setting: Setting, + value: Settings[K] | null, + ) => Promise; +} diff --git a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx index 80c8bef9bcd46..dbc09ec4820dc 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx +++ b/frontend/src/metabase/admin/settings/components/SettingsSetting.jsx @@ -23,7 +23,7 @@ import SettingNumber from "./widgets/SettingNumber"; import SettingPassword from "./widgets/SettingPassword"; import SettingRadio from "./widgets/SettingRadio"; import SettingText from "./widgets/SettingText"; -import SettingToggle from "./widgets/SettingToggle"; +import { SettingToggle } from "./widgets/SettingToggle"; import SettingSelect from "./widgets/deprecated/SettingSelect"; const SETTING_WIDGET_MAP = { @@ -115,9 +115,11 @@ export const SetByEnvVar = ({ setting }) => { const { url: docsUrl } = useGetEnvVarDocsUrl(setting.env_name); return ( - + {jt`This has been set by the ${( - {setting.env_name} + + {setting.env_name} + )} environment variable.`} ); diff --git a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx index 7717cff05504a..b363923564ddd 100644 --- a/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx +++ b/frontend/src/metabase/admin/settings/components/SettingsUpdatesForm/SettingsUpdatesForm.tsx @@ -23,7 +23,7 @@ const updateChannelSetting = { { name: c("describes a software version").t`Beta`, value: "beta" }, { name: c("describes a software version").t`Nightly`, value: "nightly" }, ], -}; +} as const; export function SettingsUpdatesForm({ elements, diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx deleted file mode 100644 index 1e9f118b0a352..0000000000000 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.styled.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import styled from "@emotion/styled"; - -import Card from "metabase/components/Card"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { space } from "metabase/styled-components/theme"; - -export const StyledCard = styled(Card)` - padding: 2.5rem; - width: 100%; - max-width: 40rem; -`; - -export const Label = styled.span` - padding: ${space(0)} ${space(1)}; - display: inline-block; - line-height: 1.3; - font-size: 0.75rem; - font-weight: 700; - border-radius: 0.25rem; - text-transform: uppercase; - color: var(--mb-color-text-white); - background: var(--mb-color-brand); -`; - -export const BoldExternalLink = styled(ExternalLink)` - color: var(--mb-color-brand); - font-weight: bold; -`; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx index 571ad97e8c9fe..a681a79f2c5ba 100644 --- a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/EmbeddingOption.tsx @@ -1,45 +1,39 @@ -import { Link } from "react-router"; -import { jt, t } from "ttag"; +import type { ReactNode } from "react"; -import { useSetting } from "metabase/common/hooks"; -import { getPlan } from "metabase/common/utils/plan"; -import ExternalLink from "metabase/core/components/ExternalLink"; -import { useSelector } from "metabase/lib/redux"; -import { PLUGIN_EMBEDDING } from "metabase/plugins"; -import { - getDocsUrl, - getSetting, - getUpgradeUrl, -} from "metabase/selectors/settings"; -import type { ButtonProps } from "metabase/ui"; -import { Button, Flex, Text, Title } from "metabase/ui"; +import { useUniqueId } from "metabase/hooks/use-unique-id"; +import { Flex, Paper, Text, Title } from "metabase/ui"; -import { BoldExternalLink, Label, StyledCard } from "./EmbeddingOption.styled"; -import InteractiveEmbeddingOff from "./InteractiveEmbeddingOff.svg?component"; -import InteractiveEmbeddingOn from "./InteractiveEmbeddingOn.svg?component"; -import StaticEmbeddingOff from "./StaticEmbeddingOff.svg?component"; -import StaticEmbeddingOn from "./StaticEmbeddingOn.svg?component"; -interface EmbeddingOptionProps { +type EmbeddingOptionProps = { title: string; - label?: string; - children?: React.ReactNode; - description: React.ReactNode; - icon: React.ReactNode; -} + label?: ReactNode; + children?: ReactNode; + description: ReactNode; + icon: ReactNode; +}; -function EmbeddingOption({ +export function EmbeddingOption({ title, label, description, children, icon, }: EmbeddingOptionProps) { + const titleId = useUniqueId(); return ( - + {icon} - - {title} - {label && } + + + {title} + + {label} {description} @@ -47,114 +41,6 @@ function EmbeddingOption({ {children} - + ); } - -export const StaticEmbeddingOptionCard = () => { - const enabled = useSetting("enable-embedding"); - const upgradeUrl = useSelector(state => - getUpgradeUrl(state, { utm_content: "embed-settings" }), - ); - const shouldPromptToUpgrade = !PLUGIN_EMBEDDING.isEnabled(); - - const upgradeText = jt`A "powered by Metabase" banner appears on static embeds. You can ${( - - {t`upgrade to a paid plan`} - - )} to remove it.`; - - return ( - : } - title={t`Static embedding`} - description={jt`Use static embedding when you don’t want to give people ad hoc query access to their data for whatever reason, or you want to present data that applies to all of your tenants at once.${ - shouldPromptToUpgrade && ( - - {upgradeText} - - ) - }`} - > - - {t`Manage`} - - - ); -}; - -export const InteractiveEmbeddingOptionCard = () => { - const isEE = PLUGIN_EMBEDDING.isEnabled(); - const plan = useSelector(state => - getPlan(getSetting(state, "token-features")), - ); - const enabled = useSetting("enable-embedding"); - const quickStartUrl = useSelector(state => - getDocsUrl(state, { - page: "embedding/interactive-embedding-quick-start-guide", - }), - ); - - return ( - - ) : ( - - ) - } - title={t`Interactive embedding`} - label={t`PRO & ENTERPRISE`} - description={jt`Use interactive embedding when you want to ${( - - {t`offer multi-tenant, self-service analytics`} - - )} and people want to create their own questions, dashboards, models, and more, all in their own data sandbox.`} - > - - {t`Check out our Quick Start`} - - {isEE ? ( - - {t`Configure`} - - ) : ( - - )} - - ); -}; - -// component={Link} breaks the styling when the button is disabled -// disabling a link button doesn't look like a common enough scenario to make an exported component -const LinkButton = ({ - to, - disabled, - ...buttonProps -}: { to: string; disabled?: boolean } & ButtonProps) => { - return disabled ? ( - + )} + + + + ); +}; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts new file mode 100644 index 0000000000000..907cd9265623c --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/InteractiveEmbeddingOptionCard/index.ts @@ -0,0 +1 @@ +export * from "./InteractiveEmbeddingOptionCard"; diff --git a/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx new file mode 100644 index 0000000000000..581365153515a --- /dev/null +++ b/frontend/src/metabase/admin/settings/components/widgets/EmbeddingOption/LinkButton/LinkButton.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router"; + +import { Button, type ButtonProps } from "metabase/ui"; + +// component={Link} breaks the styling when the button is disabled +// disabling a link button doesn't look like a common enough scenario to make an exported component +export const LinkButton = ({ + to, + disabled, + ...buttonProps +}: { to: string; disabled?: boolean } & ButtonProps) => { + return disabled ? ( +