Skip to content
Snippets Groups Projects
Unverified Commit 4c3df61d authored by Phoomparin Mano's avatar Phoomparin Mano Committed by GitHub
Browse files

feat(sdk): generate sample react component with the embedding cli (#46538)


* add setup commands

* fix settings definition

* update environment variables for cli

* handle instances not being ready

* update error messages

* add more specific loading messages

* loading spinner state

* improve error message

* use a fixed demo setup token

* remove extraneous spinner

* update status checks

* update container messages

* update wait timing

* create api keys

* extract constants

* remove manual steps

* Add anonymous tracking + other things. will need to clean up

* Modify SDK for better structure

* remove line from print.ts

* Update webpack.embedding-sdk-cli.config.js back to production

* Add types and add quick note

* Fix a typo

* Add index file, simplify types, use an array

* Add safer json parsing

* use delay of 100ms between each setup call

* Suggestions from review

* ensure that cli works

* Attempt to fix jest errors

* Remove node-fetch from sdk code to hopefully get unit tests working again

* add database connection

* add connection details handling

* refactor asking for database connection info

* apply actual database id for syncing schema

* fix failing database sync step

* allow table selection

* create model for each table

* handle errors in model creation

* fix incorrect model display name

* create x-rays based on user data

* consolidate instance setup message

* workaround for inquirer eventemitter issue

* add sample components

* fix yarn.lock file

* add sample components

* set spinner to fail state when instance setup fails

* use a transparent background by default

* update cli welcome messages

* add import notice

* refactor imports

* update snippet to add dashboard dropdown

* cli add credential file to users .gitignore

* add docs on quickstart

* clear screen with console.clear

* address code review feedback

Co-authored-by: default avatarNicolò Pretto <info@npretto.com>

* allow node 18 lts

---------

Co-authored-by: default avatarOisin Coveney <oisin@metabase.com>
Co-authored-by: default avatarNicolò Pretto <info@npretto.com>
parent 67644874
No related branches found
No related tags found
No related merge requests found
Showing
with 398 additions and 104 deletions
......@@ -46,6 +46,17 @@ Features not yet supported:
# Getting started
## Quickstart
Run `npx @metabase/embedding-sdk-react start` in your React project to get started.
This will start a local Metabase instance with Docker and generate sample dashboard components from your data. First run of this command will take 1 - 2 minutes to install the dependencies.
Prerequisites:
- [Node.js 18.x LTS](https://nodejs.org/en) or higher
- [Docker](https://docker.com)
## Start Metabase
Currently, the SDK only works with Metabase version 50.
......
......@@ -2,6 +2,9 @@ import { runCli } from "../run";
import { printError } from "../utils/print";
export async function start() {
// When the user runs the CLI with npx, there will be some deprecation warnings that we should clear.
console.clear();
try {
await runCli();
} catch (error) {
......
import { SDK_PACKAGE_NAME } from "embedding-sdk/cli/constants/config";
export const getCodeSample = (url: string, apiKey: string) => `
import {
MetabaseProvider,
InteractiveDashboard,
} from '${SDK_PACKAGE_NAME}'
/** @type {import('${SDK_PACKAGE_NAME}').SDKConfig} */
const config = {
metabaseInstanceUrl: \`${url}\`,
apiKey: '${apiKey}'
}
export const Analytics = () => (
<MetabaseProvider config={config}>
<InteractiveDashboard dashboardId={1} />
</MetabaseProvider>
)
`;
......@@ -27,13 +27,19 @@ export const EMBEDDING_FAILED_MESSAGE = `
${DELETE_CONTAINER_MESSAGE}
`;
// eslint-disable-next-line no-unconditional-metabase-links-render -- link for the CLI message
export const METABASE_INSTANCE_SETUP_COMPLETE_MESSAGE = `
export const PREMIUM_TOKEN_REQUIRED_MESSAGE =
" Don't forget to add your premium token to your Metabase instance in the admin settings! The embedding demo will not work without a license.";
export const getMetabaseInstanceSetupCompleteMessage = (instanceUrl: string) =>
// eslint-disable-next-line no-unconditional-metabase-links-render -- link for the CLI message
`
Metabase instance is ready for embedding.
Go to ${instanceUrl} to start using Metabase.
You can find your login credentials at METABASE_LOGIN.json
Don't forget to put this file in your .gitignore.
Metabase will phone home some data collected via Google Analytics and Snowplow.
Metabase will phone home some data collected via Snowplow.
We don’t collect any usernames, emails, server IPs, database details of any kind, or
any personally identifiable information (PII).
......
import { METABASE_INSTANCE_SETUP_COMPLETE_MESSAGE } from "./constants/messages";
import chalk from "chalk";
import {
PREMIUM_TOKEN_REQUIRED_MESSAGE,
getMetabaseInstanceSetupCompleteMessage,
} from "./constants/messages";
import {
addEmbeddingToken,
checkIsDockerRunning,
createApiKey,
generateCredentials,
generateCodeSample,
pollMetabaseInstance,
setupMetabaseInstance,
showMetabaseCliTitle,
......@@ -14,6 +18,7 @@ import {
addDatabaseConnectionStep,
pickDatabaseTables,
createModelsAndXrays,
generateReactComponentFiles,
} from "./steps";
import type { CliState } from "./types/cli";
import { printEmptyLines, printInfo } from "./utils/print";
......@@ -35,7 +40,10 @@ export const CLI_STEPS = [
{ id: "addDatabaseConnection", executeStep: addDatabaseConnectionStep },
{ id: "pickDatabaseTables", executeStep: pickDatabaseTables },
{ id: "createModelsAndXrays", executeStep: createModelsAndXrays },
{ id: "generateCodeSample", executeStep: generateCodeSample },
{
id: "generateReactComponentFiles",
executeStep: generateReactComponentFiles,
},
] as const;
export async function runCli() {
......@@ -57,12 +65,10 @@ export async function runCli() {
state = nextState;
}
console.log(METABASE_INSTANCE_SETUP_COMPLETE_MESSAGE);
console.log(getMetabaseInstanceSetupCompleteMessage(state.instanceUrl ?? ""));
if (!state.token) {
console.log(
" Don't forget to add your premium token to your Metabase instance in the admin settings!",
);
console.log(chalk.bold(PREMIUM_TOKEN_REQUIRED_MESSAGE));
}
printEmptyLines(1);
......
export const ANALYTICS_CSS_SNIPPET = `
.theme-switcher {
width: 28px;
height: 28px;
cursor: pointer;
}
.analytics-container {
width: 100%;
max-width: 1000px;
margin: 0 auto;
min-height: 100vh;
padding: 30px 0;
}
.analytics-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30px 0;
column-gap: 15px;
}
.analytics-header-right {
display: flex;
align-items: center;
justify-content: flex-end;
padding: 30px 0;
column-gap: 15px;
}
.analytics-header-right > a {
color: #509EE3;
}
.dashboard-select {
background: transparent;
color: #509EE3;
border: none;
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.dashboard-select:focus {
outline: 1px solid #509EE3;
border-radius: 2px;
}
`.trim();
import { SDK_PACKAGE_NAME } from "../constants/config";
import type { DashboardInfo } from "../types/dashboard";
export const getAnalyticsDashboardSnippet = (
instanceUrl: string,
dashboards: DashboardInfo[],
) => `
import { useState } from 'react'
import { InteractiveDashboard } from '${SDK_PACKAGE_NAME}'
import { ThemeSwitcher } from './theme-switcher'
export const AnalyticsDashboard = () => {
const [dashboardId, setDashboardId] = useState(DASHBOARDS[0].id)
const editLink = \`${instanceUrl}/dashboard/\${dashboardId}\`
return (
<div className="analytics-container">
<div className="analytics-header">
<div>
<select
className="dashboard-select"
onChange={(e) => setDashboardId(e.target.value)}
>
{DASHBOARDS.map((dashboard) => (
<option key={dashboard.id} value={dashboard.id}>
{dashboard.name}
</option>
))}
</select>
</div>
<div className="analytics-header-right">
{/** TODO: Remove. This is just a link to edit the dashboard in Metabase for your convenience. */}
<a href={editLink} target="_blank">
Edit this dashboard
</a>
<ThemeSwitcher />
</div>
</div>
<InteractiveDashboard dashboardId={dashboardId} withTitle withDownloads />
</div>
)
}
const DASHBOARDS = ${JSON.stringify(dashboards, null, 2)}
`;
/**
* A minimal setup that brings together SDK provider,
* theme switcher, and a sample dashboard.
*/
export const ANALYTICS_PAGE_SNIPPET = `
import { AnalyticsDashboard } from './analytics-dashboard'
import { MetabaseEmbedProvider, SampleThemeProvider } from './metabase-provider'
import './analytics.css'
export const AnalyticsPage = () => (
<SampleThemeProvider>
<MetabaseEmbedProvider>
<AnalyticsDashboard />
</MetabaseEmbedProvider>
</SampleThemeProvider>
)
`;
export * from "./analytics-dashboard-snippet";
export * from "./metabase-provider-snippet";
export * from "./analytics-page-snippet";
export * from "./theme-switcher-snippet";
import { SDK_PACKAGE_NAME } from "embedding-sdk/cli/constants/config";
export const getMetabaseProviderSnippet = (url: string, apiKey: string) => `
import { createContext, useContext, useMemo, useState } from 'react'
import { MetabaseProvider } from '${SDK_PACKAGE_NAME}'
/** @type {import('${SDK_PACKAGE_NAME}').SDKConfig} */
const config = {
metabaseInstanceUrl: \`${url}\`,
apiKey: '${apiKey}'
}
// Used for the example theme switcher component
export const SampleThemeContext = createContext({ themeKey: 'light' })
export const MetabaseEmbedProvider = ({ children }) => {
const { themeKey } = useContext(SampleThemeContext)
const theme = useMemo(() => THEMES[themeKey], [themeKey])
return (
<MetabaseProvider config={config} theme={theme}>
{children}
</MetabaseProvider>
)
}
export const SampleThemeProvider = ({ children }) => {
const [themeKey, setThemeKey] = useState('light')
return (
<SampleThemeContext.Provider value={{themeKey, setThemeKey}}>
{children}
</SampleThemeContext.Provider>
)
}
/**
* Sample themes for Metabase components.
*
* @type {Record<string, import('${SDK_PACKAGE_NAME}').MetabaseTheme>}
*/
const THEMES = {
// Light theme
"light": {
colors: {
brand: "#509EE3",
filter: "#7172AD",
"text-primary": "#4C5773",
"text-secondary": "#696E7B",
"text-tertiary": "#949AAB",
border: "#EEECEC",
background: "#F9FBFC",
"background-hover": "#F9FBFC",
positive: "#84BB4C",
negative: "#ED6E6E"
}
},
// Dark theme
"dark": {
colors: {
brand: "#509EE3",
filter: "#7172AD",
"text-primary": "#FFFFFF",
"text-secondary": "#FFFFFF",
"text-tertiary": "#FFFFFF",
border: "#5A5F6B",
background: "#2D353A",
"background-hover": "#2D353A",
positive: "#84BB4C",
negative: "#ED6E6E"
}
}
}
`;
export const THEME_SWITCHER_SNIPPET = `
import { useContext } from 'react'
import { SampleThemeContext } from './metabase-provider'
export const ThemeSwitcher = () => {
const { themeKey, setThemeKey } = useContext(SampleThemeContext)
const ThemeIcon = ICONS[themeKey]
return (
<div
className="theme-switcher"
onClick={() => setThemeKey(themeKey === 'light' ? 'dark' : 'light')}
>
<ThemeIcon />
</div>
)
}
const ICONS = {
light: () => (
<svg viewBox="0 0 24 24">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 12a4 4 0 1 0 8 0a4 4 0 1 0-8 0m-5 0h1m8-9v1m8 8h1m-9 8v1M5.6 5.6l.7.7m12.1-.7l-.7.7m0 11.4l.7.7m-12.1-.7l-.7.7"
/>
</svg>
),
dark: () => (
<svg viewBox="0 0 24 24">
<path
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 3h.393a7.5 7.5 0 0 0 7.92 12.446A9 9 0 1 1 12 2.992z"
/>
</svg>
)
}
`;
import ora from "ora";
import { createXrayDashboardFromModel } from "embedding-sdk/cli/utils/xray-models";
import type { DashboardId } from "metabase-types/api";
import type { CliStepMethod } from "../types/cli";
import type { DashboardInfo } from "../types/dashboard";
import { createModelFromTable } from "../utils/create-model-from-table";
import { createXrayDashboardFromModel } from "../utils/xray-models";
export const createModelsAndXrays: CliStepMethod = async state => {
const { instanceUrl = "", databaseId, cookie = "", tables = [] } = state;
......@@ -17,7 +16,7 @@ export const createModelsAndXrays: CliStepMethod = async state => {
try {
// Create a model for each table
const modelIds = await Promise.all(
const models = await Promise.all(
tables.map(table =>
createModelFromTable({
table,
......@@ -30,23 +29,23 @@ export const createModelsAndXrays: CliStepMethod = async state => {
spinner.start("X-raying your data to create dashboards...");
const dashboardIds: DashboardId[] = [];
const dashboards: DashboardInfo[] = [];
// We create dashboard sequentially to prevent multiple
// "Automatically Generated Dashboards" collection from being created.
for (const modelId of modelIds) {
for (const model of models) {
const dashboardId = await createXrayDashboardFromModel({
modelId,
modelId: model.modelId,
instanceUrl,
cookie,
});
dashboardIds.push(dashboardId);
dashboards.push({ id: dashboardId, name: model.modelName });
}
spinner.succeed();
return [{ type: "done" }, { ...state, dashboardIds }];
return [{ type: "done" }, { ...state, dashboards }];
} catch (error) {
spinner.fail();
......
import fs from "fs/promises";
import { input } from "@inquirer/prompts";
import { ANALYTICS_CSS_SNIPPET } from "../snippets/analytics-css-snippet";
import type { CliStepMethod } from "../types/cli";
import { getComponentSnippets } from "../utils/get-component-snippets";
import { printError, printSuccess } from "../utils/print";
export const generateReactComponentFiles: CliStepMethod = async state => {
const { instanceUrl, apiKey, dashboards = [] } = state;
if (!instanceUrl || !apiKey) {
return [
{ type: "error", message: "Missing instance URL or API key." },
state,
];
}
let path: string;
// eslint-disable-next-line no-constant-condition -- ask until user provides a valid path
while (true) {
path = await input({
message: "Where do you want to save the example React components?",
default: "./components/metabase",
});
// Create a directory if it doesn't already exist.
try {
await fs.mkdir(path, { recursive: true });
break;
} catch (error) {
printError(
`The current path is not writeable. Please pick a different path.`,
);
}
}
const sampleComponents = getComponentSnippets({
instanceUrl,
apiKey,
dashboards,
});
// Generate sample components files in the specified directory.
for (const { name, content } of sampleComponents) {
await fs.writeFile(`${path}/${name}.jsx`, content);
}
// Generate analytics.css sample styles.
await fs.writeFile(`${path}/analytics.css`, ANALYTICS_CSS_SNIPPET);
// Generate index.js file with all the component exports.
const exportIndexContent = sampleComponents
.map(file => `export * from "./${file.name}";`)
.join("\n")
.trim();
await fs.writeFile(`${path}/index.js`, exportIndexContent);
printSuccess(
`Generated example React components files in "${path}". You can import the <AnalyticsPage /> component in your React app from this path.`,
);
return [{ type: "done" }, state];
};
......@@ -2,11 +2,13 @@ import fs from "fs/promises";
import { input } from "@inquirer/prompts";
import type { CliStepMethod } from "embedding-sdk/cli/types/cli";
import { generateRandomDemoPassword } from "embedding-sdk/cli/utils/generate-password";
import { OUTPUT_STYLES, printEmptyLines } from "embedding-sdk/cli/utils/print";
import { isEmail } from "metabase/lib/email";
import type { CliStepMethod } from "../types/cli";
import { addFileToGitIgnore } from "../utils/add-file-to-git-ignore";
import { generateRandomDemoPassword } from "../utils/generate-password";
import { OUTPUT_STYLES, printEmptyLines } from "../utils/print";
export const generateCredentials: CliStepMethod = async state => {
printEmptyLines();
const email = await input({
......@@ -18,9 +20,13 @@ export const generateCredentials: CliStepMethod = async state => {
const password = generateRandomDemoPassword();
const credentialFile = "METABASE_LOGIN.json";
await addFileToGitIgnore(credentialFile);
// Store the login credentials to a file.
await fs.writeFile(
"./METABASE_LOGIN.json",
`./${credentialFile}`,
JSON.stringify({ email, password }, null, 2),
);
......
import clipboard from "clipboardy";
import toggle from "inquirer-toggle";
import { getCodeSample } from "embedding-sdk/cli/constants/code-sample";
import type { CliStepMethod } from "embedding-sdk/cli/types/cli";
import {
printEmptyLines,
printInfo,
printSuccess,
} from "embedding-sdk/cli/utils/print";
export const generateCodeSample: CliStepMethod = async state => {
if (!state.instanceUrl || !state.apiKey) {
return [
{
type: "error",
message: "Missing instance URL or API key",
},
state,
];
}
printEmptyLines(1);
printSuccess(
"API key generated successfully. Here's a code sample to embed the Metabase SDK in your React application:",
);
const codeSample = getCodeSample(state.instanceUrl, state.apiKey);
printEmptyLines();
printInfo(codeSample.trim());
printEmptyLines();
const shouldCopyToClipboard = await toggle({
message: "Would you like to copy the code to your clipboard?",
default: true,
});
if (shouldCopyToClipboard) {
await clipboard.write(codeSample);
printSuccess(
"Code copied to clipboard. Paste it into your React application.",
);
} else {
printSuccess(
"Paste the code above into your React application to embed Metabase.",
);
printInfo(
"Then, put <Analytics /> in your component to view the dashboard!",
);
}
return [
{
type: "done",
},
state,
];
};
......@@ -2,7 +2,6 @@ export * from "./add-embedding-token";
export * from "./check-docker-running";
export * from "./create-api-key";
export * from "./generate-credentials";
export * from "./get-code-sample";
export * from "./poll-metabase-instance";
export * from "./setup-metabase-instance";
export * from "./show-metabase-cli-title";
......@@ -12,3 +11,4 @@ export * from "./check-sdk-available";
export * from "./add-database-connection";
export * from "./pick-database-tables";
export * from "./create-models-and-xrays";
export * from "./generate-component-files";
......@@ -94,6 +94,7 @@ export const setupMetabaseInstance: CliStepMethod = async state => {
if (!res.ok) {
const errorMessage = await res.text();
spinner.fail();
// Error message: The /api/setup route can only be used to create the first user, however a user currently exists.
if (errorMessage.includes("a user currently exists")) {
......@@ -162,6 +163,7 @@ export const setupMetabaseInstance: CliStepMethod = async state => {
if (!res.ok) {
const errorMessage = await res.text();
spinner.fail();
if (errorMessage.includes("Unauthenticated")) {
return onInstanceConfigured();
......
import type { CLI_STEPS } from "embedding-sdk/cli/run";
import type { DashboardId, Settings, Table } from "metabase-types/api";
import type { Settings, Table } from "metabase-types/api";
import type { DashboardInfo } from "../types/dashboard";
export type CliState = Partial<{
port: number;
......@@ -17,8 +19,8 @@ export type CliState = Partial<{
/** Database tables selected by the user */
tables: Table[];
/** IDs of auto-generated dashboards */
dashboardIds: DashboardId[];
/** IDs and names of auto-generated dashboards */
dashboards: DashboardInfo[];
}>;
export type CliError = {
......
import type { DashboardId } from "metabase-types/api";
export type DashboardInfo = { id: DashboardId; name: string };
import fs from "fs/promises";
const GITIGNORE_PATH = ".gitignore";
/**
* Adds the credential file to .gitignore if exists.
*/
export async function addFileToGitIgnore(fileName: string) {
try {
// Check if .gitignore exists
await fs.access(GITIGNORE_PATH);
let gitignoreContent = await fs.readFile(GITIGNORE_PATH, "utf-8");
// If the credential file is not in .gitignore, add it.
if (!gitignoreContent.includes(fileName)) {
gitignoreContent += `\n${fileName}`;
await fs.writeFile(GITIGNORE_PATH, gitignoreContent);
}
} catch (error) {
// skip if .gitignore does not exist
}
}
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment