Skip to content

Commit

Permalink
feat(sdk): next sdk compatibility layer (metabase#50230)
Browse files Browse the repository at this point in the history
* wip next sdk compat

* better code generation, dts works

* Last line of file should end in a newline character

* feat(sdk): make MetabaseProvider not break ssr

* aUpdate enterprise/frontend/src/embedding-sdk/package.template.json

Co-authored-by: Mahatthana (Kelvin) Nomsawadi <[email protected]>

* address pr comments

* refactor(sdk): use relative paths when fixing d.ts files

* make types work on moduleResolution: node

* add loading text

* fix(sdk): metabase#50736 make sure we don't break the host app if a sdk
component is rendered outside of the provider for a render

* fix: this should make types work for real?

* self review fixes

* update test for PublicComponentWrapper

* Update enterprise/frontend/src/embedding-sdk/components/private/PublicComponentWrapper/PublicComponentWrapper.tsx

Co-authored-by: Phoomparin Mano <[email protected]>

* remove unused component

* eslint-disable-next-line for MetabaseProvider

* list tsx as dependency

* /next -> /nextjs

---------

Co-authored-by: Mahatthana (Kelvin) Nomsawadi <[email protected]>
Co-authored-by: Phoomparin Mano <[email protected]>
  • Loading branch information
3 people authored Dec 9, 2024
1 parent 179554e commit c0eab44
Show file tree
Hide file tree
Showing 9 changed files with 495 additions and 66 deletions.
37 changes: 21 additions & 16 deletions bin/embedding-sdk/fixup-types-after-compilation.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ const fs = require("fs");
const path = require("path");

const SDK_DIST_DIR_PATH = path.resolve("./resources/embedding-sdk/dist");
const SDK_PACKAGE_NAME = "@metabase/embedding-sdk-react";

/*
* This script replaces all custom aliases in Embedding SDK generated ".d.ts" files so that this imports could be resolved
Expand All @@ -14,12 +13,12 @@ const SDK_PACKAGE_NAME = "@metabase/embedding-sdk-react";

// this map should be synced with "tsconfig.sdk.json"
const REPLACES_MAP = {
"metabase-enterprise": `${SDK_PACKAGE_NAME}/dist/enterprise/frontend/src/metabase-enterprise`,
"metabase-lib": `${SDK_PACKAGE_NAME}/dist/frontend/src/metabase-lib`,
"metabase-types": `${SDK_PACKAGE_NAME}/dist/frontend/src/metabase-types`,
metabase: `${SDK_PACKAGE_NAME}/dist/frontend/src/metabase`,
"embedding-sdk": `${SDK_PACKAGE_NAME}/dist/enterprise/frontend/src/embedding-sdk`,
cljs: `${SDK_PACKAGE_NAME}/dist/target/cljs_release`,
"metabase-enterprise": "enterprise/frontend/src/metabase-enterprise",
"metabase-lib": "frontend/src/metabase-lib",
"metabase-types": "frontend/src/metabase-types",
metabase: "frontend/src/metabase",
"embedding-sdk": "enterprise/frontend/src/embedding-sdk",
cljs: "target/cljs_release",
};

const traverseFilesTree = dir => {
Expand All @@ -42,24 +41,30 @@ const traverseFilesTree = dir => {
}
};

const getRelativePath = (fromPath, toPath) => {
const relativePath = path.relative(path.dirname(fromPath), toPath);
return relativePath.startsWith(".") ? relativePath : "./" + relativePath;
};

const replaceAliasedImports = filePath => {
let fileContent = fs.readFileSync(filePath, { encoding: "utf8" });

Object.entries(REPLACES_MAP).forEach(([alias, replacement]) => {
Object.entries(REPLACES_MAP).forEach(([alias, targetPath]) => {
const relativePath = getRelativePath(
filePath,
path.join(SDK_DIST_DIR_PATH, targetPath),
);

fileContent = fileContent
// replaces "metabase-lib/foo" with "<sdk>/metabase-lib/foo"
.replaceAll(`from "${alias}/`, `from "${replacement}/`)
// replaces "metabase-lib" with "<sdk>/metabase-lib"
.replaceAll(`from "${alias}"`, `from "${replacement}"`)
.replaceAll(`import("${alias}`, `import("${replacement}`)
.replaceAll(`from "${alias}/`, `from "${relativePath}/`)
.replaceAll(`from "${alias}"`, `from "${relativePath}"`)
.replaceAll(`import("${alias}`, `import("${relativePath}`)
.replace(
// replace dynamic imports using alias, with possible relative paths - "../../" and "frontend/src/"
// import("(../)*(frontend/src/)*<alias>
new RegExp(
`import\\("(\\.\\.\/)*(frontend\/src\/)*${alias.replace("/", "\\/")}`,
"gi",
),
`import("${replacement}`,
`import("${relativePath}`,
);
});

Expand Down
204 changes: 204 additions & 0 deletions enterprise/frontend/src/embedding-sdk/bin/generate-nextjs-compat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
/* eslint-disable no-console */
import fs from "fs";

import path from "path";
import prettier from "prettier";
import { match } from "ts-pattern";

type ComponentDefinition = {
mainComponent: string;
subComponents: string[];
};

// START OF CONFIGURATION
// Note: this list needs to be updated when new components are added

const COMPONENTS_TO_EXPORT: ComponentDefinition[] = [
// MetabaseProvider is added manually because it needs to render children while loading
// we may have other components that need to render children while loading, in that case we can add a flag here
// { mainComponent: "MetabaseProvider", subComponents: [] },
{ mainComponent: "StaticQuestion", subComponents: [] },
{
mainComponent: "InteractiveQuestion",
subComponents: [
"BackButton",
"FilterBar",
"Filter",
"FilterButton",
"FilterPicker",
"ResetButton",
"Title",
"Summarize",
"SummarizeButton",
"Editor",
"Notebook",
"NotebookButton",
"EditorButton",
"QuestionVisualization",
"SaveQuestionForm",
"SaveButton",
"ChartTypeSelector",
"EditorViewControl",
"QuestionSettings",
],
},
{
mainComponent: "StaticDashboard",
subComponents: [],
},
{ mainComponent: "InteractiveDashboard", subComponents: [] },
];

// END OF CONFIGURATION

// eslint-disable-next-line no-literal-metabase-strings -- it's code
const MetabaseProviderCode = `
const MetabaseProvider = ({
config,
children,
}) => {
const Provider = dynamic(
() =>
import("@metabase/embedding-sdk-react").then((m) => {
return { default: m.MetabaseProvider };
}),
{
ssr: false,
loading: () => {
return React.createElement("div", { id: "metabase-sdk-root" }, children);
},
}
);
return React.createElement(Provider, { config }, children);
};
`;

const destinationDir = path.resolve(
__dirname,
"../../../../../resources/embedding-sdk/dist",
);

const writeToFile = async (filePath: string, content: string) => {
const fullPath = path.resolve(destinationDir, filePath);
fs.mkdirSync(destinationDir, { recursive: true });
// formatting the content with prettier also has the benefit that if the
// generated code is outright wrong it will fail
const formattedContent = await formatContent(content);
fs.writeFileSync(fullPath, formattedContent);
console.log(`wrote ${fullPath}`);
};

const formatContent = async (content: string) => {
const prettierConfig = await prettier.resolveConfig(__dirname);
return prettier.format(content, {
...prettierConfig,
parser: "babel",
});
};

const generateCodeFor = ({
component,
type,
}: {
component: ComponentDefinition;
type: "cjs" | "js";
}) => {
const { mainComponent, subComponents } = component;

return `
// === ${mainComponent} ===
const ${mainComponent} = dynamic(
() =>
import("./main.bundle.js").then((m) => {
return { default: m.${mainComponent} };
}),
{ ssr: false, loading: () => "Loading..." }
);
${subComponents
.map(
subComponent => `${mainComponent}.${subComponent} = dynamic(
() =>
import("./main.bundle.js").then((m) => {
return { default: m.${mainComponent}.${subComponent} };
}),
{ ssr: false, loading: () => "Loading..." }
);`,
)
.join("\n\n")}
${match(type)
.with("cjs", () => `module.exports.${mainComponent} = ${mainComponent};`)
.with("js", () => `export {${mainComponent}};`)
.exhaustive()}
`;
};

const generateAllComponents = (type: "cjs" | "js") => {
return COMPONENTS_TO_EXPORT.map(component =>
generateCodeFor({ component, type }),
).join("\n");
};

// next uses either cjs or esm, it uses cjs for when running on the server on pages router

// nextjs.{cjs,js} => "index file" that re-exports the helpers and the components
// nextjs-no-ssr.{cjs,js} => file marked as "use client" that re-exports the components wrapped in dynamic import with no ssr

// we need to re-export these helpers so they can be used without importing the entire bundle, that will make next crash because window is undefined
const defineEmbeddingSdkConfig = "config => config";
const defineEmbeddingSdkTheme = "theme => theme";

const nextjs_cjs = `
module.exports.defineEmbeddingSdkConfig = ${defineEmbeddingSdkConfig};
module.exports.defineEmbeddingSdkTheme = ${defineEmbeddingSdkTheme};
module.exports = { ...module.exports, ...require("./nextjs-no-ssr.cjs") };
`;

const nextjs_js = `
export const defineEmbeddingSdkConfig = ${defineEmbeddingSdkConfig};
export const defineEmbeddingSdkTheme = ${defineEmbeddingSdkTheme};
export * from "./nextjs-no-ssr.js";
`;

// eslint-disable-next-line no-literal-metabase-strings -- it's code
const nextjs_no_ssr_cjs = `"use client";
const React = require("react");
const dynamic = require("next/dynamic").default;
${MetabaseProviderCode}
module.exports.MetabaseProvider = MetabaseProvider;
${generateAllComponents("cjs")}
`;

// eslint-disable-next-line no-literal-metabase-strings -- it's code
const nextjs_no_ssr_js = `"use client";
import dynamic from "next/dynamic";
const React = require("react");
${MetabaseProviderCode}
export { MetabaseProvider };
${generateAllComponents("js")}
`;

writeToFile("nextjs.cjs", nextjs_cjs);
writeToFile("nextjs.js", nextjs_js);

writeToFile("nextjs-no-ssr.cjs", nextjs_no_ssr_cjs);
writeToFile("nextjs-no-ssr.js", nextjs_no_ssr_js);

writeToFile(
"nextjs.d.ts",
`export * from "./enterprise/frontend/src/embedding-sdk/index.d.ts";`,
);
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import { SdkLoader } from "embedding-sdk/components/private/PublicComponentWrapp
import { useSdkSelector } from "embedding-sdk/store";
import { getLoginStatus, getUsageProblem } from "embedding-sdk/store/selectors";

import { useIsInSdkProvider } from "../SdkContext";

type PublicComponentWrapperProps = {
children: React.ReactNode;
className?: string;
style?: CSSProperties;
};
export const PublicComponentWrapper = React.forwardRef<
const PublicComponentWrapperInner = React.forwardRef<
HTMLDivElement,
PublicComponentWrapperProps
>(function PublicComponentWrapper({ children, className, style }, ref) {
Expand Down Expand Up @@ -44,3 +46,18 @@ export const PublicComponentWrapper = React.forwardRef<
</PublicComponentStylesWrapper>
);
});

export const PublicComponentWrapper = React.forwardRef<
HTMLDivElement,
PublicComponentWrapperProps
>(function PublicComponentWrapper(props, ref) {
// metabase##50736: make sure we don't break the host app if for a render the
// sdk components is rendered outside of the sdk provider
const isInSdkProvider = useIsInSdkProvider();
if (!isInSdkProvider) {
// eslint-disable-next-line no-literal-metabase-strings -- error message
return "This component requires the MetabaseProvider parent component. Please wrap it within <MetabaseProvider>...</MetabaseProvider> in your component tree.";
}

return <PublicComponentWrapperInner ref={ref} {...props} />;
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,36 @@ import {
} from "embedding-sdk/test/mocks/state";
import { createMockState } from "metabase-types/store/mocks";

import { SdkContextProvider } from "../SdkContext";

import { PublicComponentWrapper } from "./PublicComponentWrapper";

const setup = (status: LoginStatus = { status: "uninitialized" }) => {
const setup = (
status: LoginStatus = { status: "uninitialized" },
insideProvider = true,
) => {
const state = createMockState({
sdk: createMockSdkState({
loginStatus: createMockLoginStatusState(status),
}),
});

renderWithProviders(
const jsx = insideProvider ? (
<SdkContextProvider>
<PublicComponentWrapper>
<div>My component</div>
</PublicComponentWrapper>
</SdkContextProvider>
) : (
<PublicComponentWrapper>
<div>My component</div>
</PublicComponentWrapper>,
{
storeInitialState: state,
customReducers: sdkReducers,
},
</PublicComponentWrapper>
);

return renderWithProviders(jsx, {
storeInitialState: state,
customReducers: sdkReducers,
});
};

describe("PublicComponentWrapper", () => {
Expand Down Expand Up @@ -54,4 +66,10 @@ describe("PublicComponentWrapper", () => {
const component = screen.getByText("My component");
expect(component).toBeInTheDocument();
});

it("should not render children when rendered outside of the provider (metabase#50736)", () => {
setup({ status: "success" }, false);
const component = screen.queryByText("My component");
expect(component).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type React from "react";
import { createContext, useContext } from "react";

const SdkContext = createContext<boolean>(false);

export const SdkContextProvider = ({ children }: React.PropsWithChildren) => {
return <SdkContext.Provider value={true}>{children}</SdkContext.Provider>;
};

export const useIsInSdkProvider = () => {
return useContext(SdkContext);
};
Loading

0 comments on commit c0eab44

Please sign in to comment.