From ed24366ea9855cb4e5f41eb76eef06d80e4ba21f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Pretto?= <info@npretto.com> Date: Thu, 26 Sep 2024 18:53:00 +0200 Subject: [PATCH] fix(sdk): remove ts limitation on custom fonts + some minimal e2e tests for the fonts (#48071) * IS_EMBEDDING_SDK_BUILD -> IS_EMBEDDING_SDK to keep things simple, as we already have that name in other places * don't load metabase css in sdk stories * first two stories * make the store not a global "singleton" anymore * basic test for font-family styles in the SDK * allow any custom font in theme.fontFamily as we actually support it * fix tests using hardcoded default font family --- .storybook/main.js | 2 +- .storybook/preview.tsx | 36 ++-- e2e/support/helpers/e2e-embedding-helpers.js | 6 + .../metabase-sdk-styles-tests.cy.spec.ts | 170 ++++++++++++++++++ .../components/private/SdkThemeProvider.tsx | 5 +- .../components/public/MetabaseProvider.tsx | 11 +- .../frontend/src/embedding-sdk/store/index.ts | 19 +- .../test/CommonSdkStoryWrapper.tsx | 7 +- .../tests/styling-sdk-tests.stories.tsx | 71 ++++++++ .../src/embedding-sdk/types/theme/index.ts | 6 +- frontend/src/metabase/env.ts | 2 +- webpack.embedding-sdk.config.js | 2 +- 12 files changed, 300 insertions(+), 37 deletions(-) create mode 100644 e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts create mode 100644 enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx diff --git a/.storybook/main.js b/.storybook/main.js index a522fb6c9c8..3342d8e9867 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -55,7 +55,7 @@ module.exports = { }), new webpack.EnvironmentPlugin({ EMBEDDING_SDK_VERSION, - IS_EMBEDDING_SDK_BUILD: isEmbeddingSDK, + IS_EMBEDDING_SDK: isEmbeddingSDK, }), ], module: { diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index cd48e8781ad..52a0d45125d 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,10 +1,14 @@ import React from "react"; import { ThemeProvider } from "metabase/ui"; -import "metabase/css/core/index.css"; -import "metabase/css/vendor.css"; -import "metabase/css/index.module.css"; -import "metabase/lib/dayjs"; +const isEmbeddingSDK = process.env.IS_EMBEDDING_SDK === "true"; + +if (!isEmbeddingSDK) { + require("metabase/css/core/index.css"); + require("metabase/css/vendor.css"); + require("metabase/css/index.module.css"); + require("metabase/lib/dayjs"); +} import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider"; import { getMetabaseCssVariables } from "metabase/styled-components/theme/css-variables"; @@ -35,17 +39,19 @@ const globalStyles = css` ${baseStyle} `; -export const decorators = [ - renderStory => ( - <EmotionCacheProvider> - <ThemeProvider> - <Global styles={globalStyles} /> - <CssVariables /> - {renderStory()} - </ThemeProvider> - </EmotionCacheProvider> - ), -]; +export const decorators = isEmbeddingSDK + ? [] // No decorators for Embedding SDK stories, as we want to simulate real use cases + : [ + renderStory => ( + <EmotionCacheProvider> + <ThemeProvider> + <Global styles={globalStyles} /> + <CssVariables /> + {renderStory()} + </ThemeProvider> + </EmotionCacheProvider> + ), + ]; function CssVariables() { const theme = useTheme(); diff --git a/e2e/support/helpers/e2e-embedding-helpers.js b/e2e/support/helpers/e2e-embedding-helpers.js index bcfb8533411..c6f03faf715 100644 --- a/e2e/support/helpers/e2e-embedding-helpers.js +++ b/e2e/support/helpers/e2e-embedding-helpers.js @@ -262,6 +262,12 @@ export function createPublicDashboardLink(dashboardId) { return cy.request("POST", `/api/dashboard/${dashboardId}/public_link`, {}); } +/** + * @param {Object} options + * @param {string} options.url + * @param {Object} options.qs + * @param {Function} [options.onBeforeLoad] + */ export const visitFullAppEmbeddingUrl = ({ url, qs, onBeforeLoad }) => { cy.visit({ url, diff --git a/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts b/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts new file mode 100644 index 00000000000..f3b05af7126 --- /dev/null +++ b/e2e/test/scenarios/embedding-sdk/metabase-sdk-styles-tests.cy.spec.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-unscoped-text-selectors */ +import { ORDERS_QUESTION_ID } from "e2e/support/cypress_sample_instance_data"; +import { + 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 STORIES = { + NO_STYLES_SUCCESS: "embeddingsdk-styles-tests--no-styles-success", + NO_STYLES_ERROR: "embeddingsdk-styles-tests--no-styles-error", + FONT_FROM_CONFIG: "embeddingsdk-styles-tests--font-from-config", + GET_BROWSER_DEFAUL_FONT: + "embeddingsdk-styles-tests--get-browser-default-font", +} as const; + +describeSDK("scenarios > embedding-sdk > static-dashboard", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + setupJwt(); + cy.signOut(); + }); + + const wrapBrowserDefaultFont = () => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.GET_BROWSER_DEFAUL_FONT, + viewMode: "story", + }, + }); + + cy.findByText("paragraph with default browser font").then($element => { + const fontFamily = $element.css("font-family"); + cy.wrap(fontFamily).as("defaultBrowserFonteFamily"); + }); + }; + + describe("style leaking", () => { + it("[success scenario] should use the default fonts outside of our components, and Lato on our components", () => { + wrapBrowserDefaultFont(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_SUCCESS, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.get("@defaultBrowserFonteFamily").then(defaultBrowserFonteFamily => { + cy.findByText("This is outside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + cy.findByText("This is inside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + cy.findByText("Product ID").should( + "have.css", + "font-family", + "Lato, sans-serif", + ); + }); + }); + + it("[error scenario] should use the default fonts outside of our components, and Lato on our components", () => { + wrapBrowserDefaultFont(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_ERROR, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.get("@defaultBrowserFonteFamily").then(defaultBrowserFonteFamily => { + cy.findByText("This is outside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + + cy.findByText("This is inside of the provider").should( + "have.css", + "font-family", + defaultBrowserFonteFamily, + ); + + cy.findByText( + "Could not authenticate: invalid JWT URI or JWT provider did not return a valid JWT token", + ).should("have.css", "font-family", "Lato, sans-serif"); + }); + }); + }); + + describe("fontFamily", () => { + it("should use the font from the theme if set", () => { + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.FONT_FROM_CONFIG, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.findByText("Product ID").should( + "have.css", + "font-family", + "Impact, sans-serif", + ); + }); + + it("should fallback to the font from the instance if no fontFamily is set on the theme", () => { + cy.signInAsAdmin(); + cy.request("PUT", "/api/setting/application-font", { + value: "Roboto Mono", + }); + cy.signOut(); + + visitFullAppEmbeddingUrl({ + url: EMBEDDING_SDK_STORY_HOST, + qs: { + id: STORIES.NO_STYLES_SUCCESS, + viewMode: "story", + }, + onBeforeLoad: (window: any) => { + window.JWT_SHARED_SECRET = JWT_SHARED_SECRET; + window.METABASE_INSTANCE_URL = Cypress.config().baseUrl; + window.QUESTION_ID = ORDERS_QUESTION_ID; + }, + }); + + cy.findByText("Product ID").should( + "have.css", + "font-family", + '"Roboto Mono", sans-serif', + ); + }); + }); +}); diff --git a/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx b/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx index 3e6c9d78b19..f29721404d4 100644 --- a/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/private/SdkThemeProvider.tsx @@ -2,6 +2,7 @@ import { Global } from "@emotion/react"; import { useMemo } from "react"; import type { MetabaseTheme } from "embedding-sdk"; +import { DEFAULT_FONT } from "embedding-sdk/config"; import { getEmbeddingThemeOverride, setGlobalEmbeddingColors, @@ -42,7 +43,9 @@ export const SdkThemeProvider = ({ theme, children }: Props) => { function GlobalSdkCssVariables() { const theme = useMantineTheme(); - const font = useSelector(getFont); + + // the default is needed for when the sdk can't connect to the instance and get the default from there + const font = useSelector(getFont) ?? DEFAULT_FONT; return <Global styles={getMetabaseSdkCssVariables(theme, font)} />; } diff --git a/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx b/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx index 4fbbf6bf3cc..c920e95614d 100644 --- a/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx +++ b/enterprise/frontend/src/embedding-sdk/components/public/MetabaseProvider.tsx @@ -10,7 +10,7 @@ import { import { useInitData } from "embedding-sdk/hooks"; import type { SdkEventHandlersConfig } from "embedding-sdk/lib/events"; import type { SdkPluginsConfig } from "embedding-sdk/lib/plugins"; -import { store } from "embedding-sdk/store"; +import { getSdkStore } from "embedding-sdk/store"; import { setErrorComponent, setEventHandlers, @@ -95,9 +95,12 @@ export const MetabaseProviderInternal = ({ ); }; -export const MetabaseProvider = memo(function MetabaseProvider( - props: MetabaseProviderProps, -) { +export const MetabaseProvider = memo(function MetabaseProvider({ + // @ts-expect-error -- we don't want to expose the store prop + // eslint-disable-next-line react/prop-types + store = getSdkStore(), + ...props +}: MetabaseProviderProps) { return ( <Provider store={store}> <MetabaseProviderInternal store={store} {...props} /> diff --git a/enterprise/frontend/src/embedding-sdk/store/index.ts b/enterprise/frontend/src/embedding-sdk/store/index.ts index 5be403a894f..9571b6fc008 100644 --- a/enterprise/frontend/src/embedding-sdk/store/index.ts +++ b/enterprise/frontend/src/embedding-sdk/store/index.ts @@ -18,15 +18,16 @@ export const sdkReducers = { sdk, } as unknown as Record<string, Reducer>; -export const store = getStore(sdkReducers, null, { - embed: { - options: {}, - isEmbeddingSdk: true, - }, - app: { - isDndAvailable: false, - }, -}) as unknown as Store<SdkStoreState, AnyAction>; +export const getSdkStore = () => + getStore(sdkReducers, null, { + embed: { + options: {}, + isEmbeddingSdk: true, + }, + app: { + isDndAvailable: false, + }, + }) as unknown as Store<SdkStoreState, AnyAction>; export const useSdkDispatch: () => ThunkDispatch< SdkStoreState, diff --git a/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx b/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx index fb1c056d246..13c9448b29d 100644 --- a/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx +++ b/enterprise/frontend/src/embedding-sdk/test/CommonSdkStoryWrapper.tsx @@ -11,7 +11,10 @@ const METABASE_JWT_SHARED_SECRET = const secret = new TextEncoder().encode(METABASE_JWT_SHARED_SECRET); -const DEFAULT_CONFIG: SDKConfig = { +/** + * SDK config that signs the jwt on the FE + */ +export const storybookSdkDefaultConfig: SDKConfig = { metabaseInstanceUrl: METABASE_INSTANCE_URL, jwtProviderUri: `${METABASE_INSTANCE_URL}/sso/metabase`, fetchRequestToken: async () => { @@ -39,7 +42,7 @@ const DEFAULT_CONFIG: SDKConfig = { }; export const CommonSdkStoryWrapper = (Story: Story) => ( - <MetabaseProvider config={DEFAULT_CONFIG}> + <MetabaseProvider config={storybookSdkDefaultConfig}> <Story /> </MetabaseProvider> ); diff --git a/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx b/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx new file mode 100644 index 00000000000..ef9b445fcd7 --- /dev/null +++ b/enterprise/frontend/src/embedding-sdk/tests/styling-sdk-tests.stories.tsx @@ -0,0 +1,71 @@ +import { + MetabaseProvider, + StaticQuestion, +} from "embedding-sdk/components/public"; +import { storybookSdkDefaultConfig } from "embedding-sdk/test/CommonSdkStoryWrapper"; +import type { SDKConfig } from "embedding-sdk/types"; + +export default { + title: "EmbeddingSDK/styles tests", +}; + +const configThatWillError: SDKConfig = { + apiKey: "TEST", + metabaseInstanceUrl: "http://localhost", +}; + +/** + * This simulates an empty project with just the provider, we should not mess + * with the styles either inside or outside of the provider + */ +export const NoStylesError = () => ( + <div> + <h1>No styles applied anywhere, should use browser default</h1> + <div style={{ border: "1px solid black" }}> + <h1>This is outside of the provider</h1> + </div> + + <MetabaseProvider config={configThatWillError}> + <div style={{ border: "1px solid black" }}> + <h1>This is inside of the provider</h1> + </div> + + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +export const NoStylesSuccess = () => ( + <div> + <h1>No styles applied anywhere, should use browser default</h1> + <div style={{ border: "1px solid black" }}> + <h1>This is outside of the provider</h1> + </div> + + <MetabaseProvider config={storybookSdkDefaultConfig}> + <div style={{ border: "1px solid black" }}> + <h1>This is inside of the provider</h1> + </div> + + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +export const FontFromConfig = () => ( + <div> + <MetabaseProvider + config={storybookSdkDefaultConfig} + theme={{ fontFamily: "Impact" }} + > + <StaticQuestion questionId={(window as any).QUESTION_ID || 1} /> + </MetabaseProvider> + </div> +); + +/** + * This story is only needed to get the default font of the browser + */ +export const GetBrowserDefaultFont = () => ( + <p>paragraph with default browser font</p> +); diff --git a/enterprise/frontend/src/embedding-sdk/types/theme/index.ts b/enterprise/frontend/src/embedding-sdk/types/theme/index.ts index 02473658763..26e84d44357 100644 --- a/enterprise/frontend/src/embedding-sdk/types/theme/index.ts +++ b/enterprise/frontend/src/embedding-sdk/types/theme/index.ts @@ -17,10 +17,10 @@ export interface MetabaseTheme { fontSize?: string; /** - * Base font family supported by Metabase, defaults to `Lato`. - * Custom fonts are not yet supported in this version. + * Font family that will be used for all text, it defaults to the instance's default font. **/ - fontFamily?: MetabaseFontFamily; + // eslint-disable-next-line @typescript-eslint/ban-types -- this is needed to allow any string but keep autocomplete for the built-in ones + fontFamily?: MetabaseFontFamily | (string & {}); /** Base line height */ lineHeight?: string | number; diff --git a/frontend/src/metabase/env.ts b/frontend/src/metabase/env.ts index 5ad8833c1ca..40ac3d501db 100644 --- a/frontend/src/metabase/env.ts +++ b/frontend/src/metabase/env.ts @@ -12,4 +12,4 @@ export const shouldLogAnalytics = process.env.MB_LOG_ANALYTICS === "true"; export const isChartsDebugLoggingEnabled = process.env.MB_LOG_CHARTS_DEBUG === "true"; -export const isEmbeddingSdk = !!process.env.IS_EMBEDDING_SDK_BUILD; +export const isEmbeddingSdk = !!process.env.IS_EMBEDDING_SDK; diff --git a/webpack.embedding-sdk.config.js b/webpack.embedding-sdk.config.js index 1ff7056f86e..fcd7acc5244 100644 --- a/webpack.embedding-sdk.config.js +++ b/webpack.embedding-sdk.config.js @@ -149,7 +149,7 @@ module.exports = env => { }), new webpack.EnvironmentPlugin({ EMBEDDING_SDK_VERSION, - IS_EMBEDDING_SDK_BUILD: true, + IS_EMBEDDING_SDK: true, }), new ForkTsCheckerWebpackPlugin({ async: isDevMode, -- GitLab