Skip to content
Snippets Groups Projects
Unverified Commit 98f6cd82 authored by Denis Berezin's avatar Denis Berezin Committed by GitHub
Browse files

Embedding SDK - integration commit (#40198)

* Minimal SDK code

* Fix viz, styles

* Move SDK code to enterprise folder

* Fix files structure

* Clean-up

* Review fixes

* Review fixes

* Review fixes

* Remove elementid

* Reuse some options from main webpack config

* Actualize package.json

* Actualize package.json
parent 65e527cd
Branches
Tags
No related merge requests found
Showing
with 425 additions and 0 deletions
......@@ -51,6 +51,7 @@
/resources/frontend_client/embed.html
/resources/frontend_client/index.html
/resources/frontend_client/public.html
/resources/embedding-sdk/dist/
/resources/i18n/*.edn
/resources/instance_analytics.zip
/resources/license-backend-third-party.txt
......
# Embedding SDK
### Build
`yarn build`
`yarn build-embedding-sdk`
Build results are located at `<root>/resources/embedding-sdk`
import type * as React from "react";
import { t } from "ttag";
import { DEFAULT_FONT } from "../../config";
import { EmbeddingContext } from "../../context";
import { useInitData } from "../../hooks";
import type { SDKConfigType } from "../../types";
import { SdkContentWrapper } from "./SdkContentWrapper";
interface AppInitializeControllerProps {
children: React.ReactNode;
config: SDKConfigType;
}
export const AppInitializeController = ({
config,
children,
}: AppInitializeControllerProps) => {
const { isLoggedIn, isInitialized } = useInitData({
config,
});
return (
<EmbeddingContext.Provider
value={{
isInitialized,
isLoggedIn,
}}
>
<SdkContentWrapper font={config.font ?? DEFAULT_FONT}>
{!isInitialized ? <div>{t`Loading…`}</div> : children}
</SdkContentWrapper>
</EmbeddingContext.Provider>
);
};
import styled from "@emotion/styled";
import { alpha, color } from "metabase/lib/colors";
import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled";
import { saveDomImageStyles } from "metabase/visualizations/lib/save-chart-image";
export const SdkContentWrapper = styled.div<{ font: string }>`
--default-font-family: "${({ font }) => font}";
--color-brand: ${color("brand")};
--color-brand-alpha-04: ${alpha("brand", 0.04)};
--color-brand-alpha-88: ${alpha("brand", 0.88)};
--color-focus: ${color("focus")};
${aceEditorStyles}
${saveDomImageStyles}
--default-font-size: 0.875em;
--default-font-color: var(--color-text-dark);
--default-bg-color: var(--color-bg-light);
font-family: var(--default-font-family), sans-serif;
font-size: var(--default-font-size);
font-weight: 400;
font-style: normal;
color: var(--color-text-dark);
margin: 0;
height: 100%; /* ensure the entire page will fill the window */
display: flex;
flex-direction: column;
background-color: var(--color-bg-light);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
`;
import type { AnyAction, Store } from "@reduxjs/toolkit";
import type * as React from "react";
import { memo } from "react";
import { Provider } from "react-redux";
import reducers from "metabase/reducers-main";
import { getStore } from "metabase/store";
import { EmotionCacheProvider } from "metabase/styled-components/components/EmotionCacheProvider";
import { ThemeProvider } from "metabase/ui/components/theme/ThemeProvider";
import type { State } from "metabase-types/store";
import type { SDKConfigType } from "../../types";
import { AppInitializeController } from "../private/AppInitializeController";
import "metabase/css/vendor.css";
import "metabase/css/index.module.css";
const store = getStore(reducers) as unknown as Store<State, AnyAction>;
const MetabaseProviderInternal = ({
children,
config,
}: {
children: React.ReactNode;
config: SDKConfigType;
}): React.JSX.Element => {
return (
<Provider store={store}>
<EmotionCacheProvider>
<ThemeProvider>
<AppInitializeController config={config}>
{children}
</AppInitializeController>
</ThemeProvider>
</EmotionCacheProvider>
</Provider>
);
};
export const MetabaseProvider = memo(MetabaseProviderInternal);
import { useEffect, useState } from "react";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useSelector } from "metabase/lib/redux";
import {
onCloseChartType,
onOpenChartSettings,
setUIControls,
} from "metabase/query_builder/actions";
import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
import ChartTypeSidebar from "metabase/query_builder/components/view/sidebars/ChartTypeSidebar";
import { getMetadata } from "metabase/selectors/metadata";
import { CardApi } from "metabase/services";
import { Box, Group, Text } from "metabase/ui";
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
import Question from "metabase-lib/v1/Question";
import type { Card, CardId, Dataset } from "metabase-types/api";
import { useEmbeddingContext } from "../../context";
interface QueryVisualizationProps {
questionId: CardId;
showVisualizationSelector?: boolean;
}
type State = {
loading: boolean;
card: Card | null;
cardError?: Card | string | null;
result: Dataset | null;
resultError?: Dataset | string | null;
};
export const QueryVisualizationSdk = ({
questionId,
showVisualizationSelector,
}: QueryVisualizationProps): JSX.Element | null => {
const { isInitialized, isLoggedIn } = useEmbeddingContext();
const metadata = useSelector(getMetadata);
const [{ loading, card, result, cardError, resultError }, setState] =
useState<State>({
loading: false,
card: null,
cardError: null,
result: null,
resultError: null,
});
const loadCardData = async ({ questionId }: { questionId: number }) => {
setState(prevState => ({
...prevState,
loading: true,
}));
Promise.all([
CardApi.get({ cardId: questionId }),
CardApi.query({
cardId: questionId,
}),
])
.then(([card, result]) => {
setState(prevState => ({
...prevState,
card,
result,
loading: false,
cardError: null,
resultError: null,
}));
})
.catch(([cardError, resultError]) => {
setState(prevState => ({
...prevState,
result: null,
card: null,
loading: false,
cardError,
resultError,
}));
});
};
useEffect(() => {
if (!isInitialized || !isLoggedIn) {
setState({
loading: false,
card: null,
result: null,
cardError: null,
resultError: null,
});
} else {
loadCardData({ questionId });
}
}, [isInitialized, isLoggedIn, questionId]);
const changeVisualization = (newQuestion: Question) => {
setState({
card: newQuestion.card(),
result: result,
loading: false,
});
};
if (!isInitialized) {
return null;
}
if (!isLoggedIn) {
return (
<div>
<Text>You should be logged in to see this content.</Text>
</div>
);
}
const isLoading = loading || (!result && !resultError);
return (
<LoadingAndErrorWrapper
className="flex-full full-width"
loading={isLoading}
error={cardError || resultError}
noWrapper
>
{() => {
const question = new Question(card, metadata);
const legacyQuery = question.legacyQuery({
useStructuredQuery: true,
});
return (
<Group h="100%" pos="relative" align="flex-start">
{showVisualizationSelector && (
<Box w="355px">
<ChartTypeSidebar
question={question}
result={result}
onOpenChartSettings={onOpenChartSettings}
onCloseChartType={onCloseChartType}
query={legacyQuery}
setUIControls={setUIControls}
updateQuestion={changeVisualization}
/>
</Box>
)}
<QueryVisualization
className="flex full-width"
question={question}
rawSeries={[{ card, data: result?.data }]}
isRunning={isLoading}
isObjectDetail={false}
isResultDirty={false}
isNativeEditorOpen={false}
result={result}
noHeader
mode={PublicMode}
/>
</Group>
);
}}
</LoadingAndErrorWrapper>
);
};
export { QueryVisualizationSdk } from "./QueryVisualization";
export { MetabaseProvider } from "./MetabaseProvider";
export const DEFAULT_FONT = "Lato";
import { createContext, useContext } from "react";
interface EmbeddingSdkContextData {
isInitialized: boolean;
isLoggedIn: boolean;
}
export const EmbeddingContext = createContext<EmbeddingSdkContextData>({
isInitialized: false,
isLoggedIn: false,
});
export const useEmbeddingContext = () => {
return useContext(EmbeddingContext);
};
export * from "./public";
export * from "./private";
export * from "./use-init-data";
import { useEffect, useState } from "react";
import _ from "underscore";
import { reloadSettings } from "metabase/admin/settings/settings";
import api from "metabase/lib/api";
import { useDispatch } from "metabase/lib/redux";
import { refreshCurrentUser } from "metabase/redux/user";
import registerVisualizations from "metabase/visualizations/register";
import type { SDKConfigType } from "../../types";
const registerVisualizationsOnce = _.once(registerVisualizations);
interface InitDataLoaderParameters {
config: SDKConfigType;
}
export const useInitData = ({
config,
}: InitDataLoaderParameters): {
isLoggedIn: boolean;
isInitialized: boolean;
} => {
const dispatch = useDispatch();
const [isInitialized, setIsInitialized] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
registerVisualizationsOnce();
}, []);
useEffect(() => {
api.basename = config.metabaseInstanceUrl;
if (config.authType === "apiKey" && config.apiKey) {
api.apiKey = config.apiKey;
} else {
setIsLoggedIn(false);
return;
}
Promise.all([
dispatch(refreshCurrentUser()),
dispatch(reloadSettings()),
]).then(() => {
setIsInitialized(true);
setIsLoggedIn(true);
});
}, [config, dispatch]);
return {
isLoggedIn,
isInitialized,
};
};
export * from "./use-current-user";
export * from "./use-application-name";
export * from "./use-question-search";
export * from "./use-available-fonts";
import { useSelector } from "metabase/lib/redux";
import { getApplicationName } from "metabase/selectors/whitelabel";
export const useApplicationName = () => useSelector(getApplicationName);
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
export const useAvailableFonts = () => {
return {
availableFonts: useSelector(state => getSetting(state, "available-fonts")),
};
};
import { useSelector } from "react-redux";
import { getUser } from "metabase/selectors/user";
export const useCurrentUser = () => useSelector(getUser);
import { useMemo } from "react";
import { useSearchListQuery } from "metabase/common/hooks";
import { useEmbeddingContext } from "../../context";
export const useQuestionSearch = (searchQuery?: string) => {
const { isInitialized, isLoggedIn } = useEmbeddingContext();
const query = useMemo(() => {
return searchQuery
? {
q: searchQuery,
models: ["card" as const],
}
: {
models: ["card" as const],
};
}, [searchQuery]);
return useSearchListQuery({
query,
enabled: isLoggedIn && isInitialized,
});
};
export * from "./hooks/public";
export * from "./components/public";
export type SDKConfigType = {
metabaseInstanceUrl: string;
font?: string;
authType: "apiKey";
apiKey: string;
};
......@@ -31,6 +31,8 @@ const DEFAULT_OPTIONS = {
export class Api extends EventEmitter {
basename = "";
apiKey = "";
sessionToken = "";
GET;
POST;
......@@ -89,6 +91,15 @@ export class Api extends EventEmitter {
delete headers["Content-Type"];
}
if (this.apiKey) {
headers["X-Api-Key"] = this.apiKey;
}
if (this.sessionToken) {
// eslint-disable-next-line no-literal-metabase-strings -- not a UI string
headers["X-Metabase-Session"] = this.sessionToken;
}
if (isWithinIframe()) {
// eslint-disable-next-line no-literal-metabase-strings -- Not a user facing string
headers["X-Metabase-Embedded"] = "true";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment