Compare commits

...

53 Commits

Author SHA1 Message Date
Denis Berezin
cb53be1b17 Add api key env 2024-03-06 09:52:34 +03:00
Oisin Coveney
324dd8cf9a Add toggles + switchers for themes, parameters, and styling options 2024-03-01 18:51:43 +03:00
Oisin Coveney
bfb46008a9 remove location from SDK Dashboard 2024-03-01 12:45:00 +03:00
Oisin Coveney
2cb07d3734 Continue static dashboards 2024-02-28 19:43:03 +01:00
Oisin Coveney
3984950693 Continue moving location props out of dashboard components 2024-02-28 12:51:02 +01:00
Oisin Coveney
5e6b42b4ee Move types around on EmbedFrame 2024-02-27 18:52:32 +01:00
Oisin Coveney
e8813d5d09 Add really dirty version of dashboards 2024-02-27 18:16:30 +01:00
Oisin Coveney
1ed70d8c6b save progress on dashboard 2024-02-27 09:53:17 +01:00
Oisin Coveney
da41baa8c5 Formatting and make the style leak flag more useful 2024-02-23 12:38:39 +00:00
Oisin Coveney
ba5940934d Formatting 2024-02-22 12:23:42 +00:00
Oisin Coveney
27007c93ef Quick font adjustment 2024-02-22 10:55:06 +00:00
Oisin Coveney
2c8396318c Add style leak flag to indicate whether a font is leaky 2024-02-21 21:02:11 +00:00
Oisin Coveney
5b14bfa69a Add tailwind and modify app to show off a little more 2024-02-21 20:36:30 +00:00
Oisin Coveney
a03ffb1059 Ensure that public hooks are exposed 2024-02-21 13:39:34 +00:00
Oisin Coveney
1a16919b24 Remove apiUrl from EmbeddingContext 2024-02-21 11:16:34 +00:00
Oisin Coveney
10df8c09f3 Separate hooks and components into public and private folders 2024-02-21 11:04:32 +00:00
Oisin Coveney
a82d370f78 Remove log file from font util API 2024-02-21 10:17:02 +00:00
Oisin Coveney
2228762fa2 Add font selector component to host app and hook for getting and setting fonts within the EmbeddingContext 2024-02-20 18:55:51 +00:00
Oisin Coveney
c129197c6a Reconcile new auth loading, fonts, and config 2024-02-20 09:36:58 +00:00
Oisin Coveney
3502e49eb3 Hide JWT 2024-02-19 20:27:41 +00:00
Denis Berezin
6f37b13e85 Update for default settings 2024-02-15 00:57:25 +07:00
Denis Berezin
96a76bf8d0 Add dynamic api key / jwt usage based on env 2024-02-14 21:27:31 +07:00
Oisin Coveney
66aa4e7da3 Move backend folder to new repo and return files to their rightful place 2024-02-13 17:18:07 +01:00
Oisin Coveney
b7b7d56ab7 Add simple README and redux 2024-02-13 11:06:58 +01:00
Oisin Coveney
69700f56fd Add backend 2024-02-12 17:19:44 +01:00
Oisin Coveney
97d9792dda Add backend 2024-02-12 17:16:20 +01:00
Oisin Coveney
52771825f6 Add embedding check for auth/sso endpoint 2024-02-12 14:39:01 +01:00
Oisin Coveney
51d4b37074 Merge remote-tracking branch 'origin/master' into embedding-sdk-poc-denis 2024-02-12 13:13:42 +01:00
Oisin Coveney
d7a37b178c Move some files around and include the nodejs backend as a new folder 2024-02-09 16:44:50 +01:00
Oisin Coveney
2a0f647c31 Add hooks for managing data and tokens 2024-02-08 20:39:48 +01:00
Oisin Coveney
efa83b6265 Add ability to use session tokens with JWT SSO 2024-02-07 19:05:01 +01:00
Denis Berezin
f9a3177314 Fix user loading, demo prep 2024-02-02 05:10:36 +07:00
Denis Berezin
ac53cf512f Merge branch 'embedding-sdk-poc-denis' of github.com:metabase/metabase into embedding-sdk-poc-denis
# Conflicts:
#	frontend/src/embedding-sdk/QueryVisualization.tsx
#	frontend/src/embedding-sdk/SdkProvider.tsx
#	host-app-example/src/App.css
#	host-app-example/src/App.js
#	host-app-example/src/ChooseQuestionInput.js
2024-02-02 03:50:06 +07:00
Denis Berezin
b10d5dab59 Update demo 2024-02-02 02:11:44 +07:00
Oisin Coveney
ac9b9620d8 Remove unneeded loads for settings + properties 2024-02-01 18:01:27 +00:00
Oisin Coveney
d34503373d Adjust to make sure that fonts are initialized 2024-02-01 17:55:12 +00:00
Oisin Coveney
71952ab1a3 Add question list hook and move MetabaseProvider to root 2024-02-01 17:46:34 +00:00
Oisin Coveney
9bdd7e926c Add question search hook and question list 2024-02-01 16:33:17 +00:00
Oisin Coveney
eb8e199625 Change import order to make things work 2024-02-01 16:31:17 +00:00
Oisin Coveney
3718f2d3ae Remove useApi 2024-02-01 14:13:15 +00:00
Oisin Coveney
b704429325 Remove unneeded files, add hooks, change styles 2024-02-01 14:09:38 +00:00
Oisin Coveney
7653d2fe06 Move css to styled components 2024-02-01 12:51:51 +00:00
Oisin Coveney
27b7ade789 Merge changes 2024-02-01 11:33:25 +00:00
Denis Berezin
7f38319360 Add fonts loading 2024-01-31 23:50:29 +07:00
Denis Berezin
f612a5e5e7 Fix css and emotion global styles 2024-01-30 23:33:39 +07:00
Oisin Coveney
1bb09a7c4c Allow for changes in visualization type 2024-01-29 15:12:51 -06:00
Denis Berezin
470cc7d9b1 Add basic css loading 2024-01-30 03:08:19 +07:00
Denis Berezin
a4599b3432 Fix api data loading 2024-01-30 03:02:25 +07:00
Denis Berezin
17aa273aea Add api token usage 2024-01-29 23:05:50 +07:00
Denis Berezin
95c356ec88 Fixes 2024-01-27 01:02:23 +07:00
Denis Berezin
5330bb6b2f Add bundling and sample app 2024-01-27 00:10:16 +07:00
Denis Berezin
f451285bef Add basic question and visualization bar 2024-01-26 01:48:30 +07:00
Denis Berezin
25cd3c4f54 Draft sdk structure 2024-01-24 22:36:13 +07:00
129 changed files with 16069 additions and 340 deletions

4
.gitignore vendored
View File

@@ -50,6 +50,8 @@
/resources/frontend_client/embed.html
/resources/frontend_client/index.html
/resources/frontend_client/public.html
/resources/frontend_client/embedding-example.html
/resources/embedding-sdk/dist/
/resources/i18n/*.edn
/resources/instance_analytics.zip
/resources/license-backend-third-party.txt
@@ -106,3 +108,5 @@ modules/drivers/*/.lsp/*
.clj-kondo/metosin/malli-types-clj
.github/static-viz-sources.yaml
/frontend/src/embedding-sdk/example-nodejs-backend/node_modules/

View File

@@ -26,7 +26,9 @@
(try
(sso.i/sso-get req)
(catch Throwable e
(log/error #_e (trs "Error returning SSO entry point"))
(log/error e (trs "Error returning SSO entry point"))
;; Optionally, you can also log the stack trace or more details if needed
(log/error (str "Stack trace: " (.printStackTrace e)))
(throw e))))
(defn- sso-error-page [^Throwable e]

View File

@@ -8,12 +8,14 @@
[metabase-enterprise.sso.integrations.sso-settings :as sso-settings]
[metabase-enterprise.sso.integrations.sso-utils :as sso-utils]
[metabase.api.common :as api]
[metabase.api.common.validation :as validation]
[metabase.api.session :as api.session]
[metabase.integrations.common :as integrations.common]
[metabase.public-settings.premium-features :as premium-features]
[metabase.server.middleware.session :as mw.session]
[metabase.server.request.util :as request.u]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[ring.util.response :as response])
(:import
(java.net URLEncoder)))
@@ -75,40 +77,62 @@
(group-names->ids group-names)
(all-mapped-group-ids))))))
(defn- set-jwt-session-cookies
[request session redirect-url]
(mw.session/set-session-cookies request (response/redirect redirect-url) session (t/zoned-date-time (t/zone-id "GMT"))))
(defn- login-jwt-user
[jwt {{redirect :return_to} :params, :as request}]
[jwt {{redirect :return_to} :params
:as request}]
(let [redirect-url (or redirect (URLEncoder/encode "/"))]
(sso-utils/check-sso-redirect redirect-url)
(let [jwt-data (try
(jwt/unsign jwt (sso-settings/jwt-shared-secret)
{:max-age three-minutes-in-seconds})
(catch Throwable e
(throw (ex-info (ex-message e)
(assoc (ex-data e) :status-code 401)
e))))
login-attrs (jwt-data->login-attributes jwt-data)
email (get jwt-data (jwt-attribute-email))
first-name (get jwt-data (jwt-attribute-firstname))
last-name (get jwt-data (jwt-attribute-lastname))
user (fetch-or-create-user! first-name last-name email login-attrs)
session (api.session/create-session! :sso user (request.u/device-info request))]
(let [jwt-data (try
(jwt/unsign jwt (sso-settings/jwt-shared-secret)
{:max-age three-minutes-in-seconds})
(catch Throwable e
(throw (ex-info (ex-message e)
(assoc (ex-data e) :status-code 401)
e))))
login-attrs (jwt-data->login-attributes jwt-data)
email (get jwt-data (jwt-attribute-email))
first-name (get jwt-data (jwt-attribute-firstname))
last-name (get jwt-data (jwt-attribute-lastname))
user (fetch-or-create-user! first-name last-name email login-attrs)
session (api.session/create-session! :sso user (request.u/device-info request))]
(sync-groups! user jwt-data)
(mw.session/set-session-cookies request (response/redirect redirect-url) session (t/zoned-date-time (t/zone-id "GMT"))))))
{:session session, :redirect-url redirect-url, :jwt-data jwt-data})))
(defn- check-jwt-enabled []
(api/check (sso-settings/jwt-enabled)
[400 (tru "JWT SSO has not been enabled and/or configured")]))
(defmethod sso.i/sso-get :jwt
[{{:keys [jwt redirect]} :params, :as request}]
[{{:keys [jwt redirect token]
:or {token false}} :params
:as request}]
(validation/check-embedding-enabled)
(premium-features/assert-has-feature :sso-jwt (tru "JWT-based authentication"))
(check-jwt-enabled)
(if jwt
(login-jwt-user jwt request)
(let [idp (sso-settings/jwt-identity-provider-uri)
return-to-param (if (str/includes? idp "?") "&return_to=" "?return_to=")]
(response/redirect (str idp (when redirect
(str return-to-param redirect)))))))
(log/info {:jwt jwt
:redirect redirect
:token token})
(when jwt
(let [{:keys [session redirect-url jwt-data]} (login-jwt-user jwt request)]
(log/info {:jwt jwt
:session session
:redirect-url redirect-url})
(if token
(do
(log/info "Setting JWT session token as " session)
(response/response {:id (:id session)
:exp (:exp jwt-data)
:iat (:iat jwt-data)}))
(do
(log/info "Setting JWT session cookies")
(set-jwt-session-cookies request session redirect-url)
(let [idp (sso-settings/jwt-identity-provider-uri)
return-to-param (if (str/includes? idp "?") "&return_to=" "?return_to=")]
(response/redirect (str idp (when redirect (str return-to-param redirect))))))))))
(defmethod sso.i/sso-post :jwt
[_]

View File

@@ -0,0 +1,8 @@
#### How to run this example
1. Navigate to the `example-nodejs-backend` folder and run `yarn` to install dependencies.
2. Navigate to the root `metabase` folder and run `yarn build-embedding-sdk`. Once that's done, copy the `resources/embedding-sdk` folder to the `host-app-example/node_modules` folder using:
```cp -R ./resources/embedding-sdk ./host-app-example/node_modules/metabase-embedding-sdk```
3. Run the backend server with `yarn run-embedding-example-backend`.
4. Run the frontend running `yarn start` in the `host-app-example` folder.
5. Open `localhost:3004`. Login to the app with `rene@example.com` and `foobar` as the password.

View File

@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
import { useMemo } from "react";
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore -- there are no types for this library
import createExtraScopePlugin from "stylis-plugin-extra-scope";
import { SDK_CONTEXT_CLASS_NAME } from "./config";
interface EmotionCacheProviderProps {
children?: ReactNode;
}
export const SdkEmotionCacheProvider = ({
children,
}: EmotionCacheProviderProps) => {
const emotionCache = useMemo(() => {
const cache = createCache({
key: "emotion",
nonce: window.MetabaseNonce,
stylisPlugins: [createExtraScopePlugin(`#${SDK_CONTEXT_CLASS_NAME}`)],
});
// This disables :first-child not working in SSR warnings
// Source: https://github.com/emotion-js/emotion/issues/1105#issuecomment-557726922
cache.compat = true;
return cache;
}, []);
return <CacheProvider value={emotionCache}>{children}</CacheProvider>;
};

View File

@@ -0,0 +1,26 @@
import { css, Global } from "@emotion/react";
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";
import { SDK_CONTEXT_CLASS_NAME } from "./config";
interface GlobalStylesProps {
font: string;
}
export const SdkGlobalStyles = ({ font }: GlobalStylesProps): JSX.Element => {
const styles = css`
#${SDK_CONTEXT_CLASS_NAME} {
--default-font-family: "${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}
}
`;
return <Global styles={styles} />;
};

View File

@@ -0,0 +1,95 @@
import type * as React from "react";
import { memo, useEffect, useState } from "react";
import { Provider } from "react-redux";
import styled from "@emotion/styled";
import { ThemeProvider } from "metabase/ui/components/theme/ThemeProvider";
import { getStore } from "metabase/store";
import reducers from "metabase/reducers-main";
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";
import { setOptions } from "metabase/redux/embed";
import { SdkEmotionCacheProvider } from "./SdkEmotionCacheProvider";
import { EmbeddingContext } from "./context";
import type { SDKConfigType } from "./config";
import { SDK_CONTEXT_CLASS_NAME } from "./config";
import "./styles.css";
import { useInitData } from "./hooks";
const MetabaseProviderInternal = ({
children,
config,
}: {
children: React.ReactNode;
config: SDKConfigType;
}): JSX.Element => {
const store = getStore(reducers);
const [font, setFont] = useState<string>(config.font ?? "Lato");
useEffect(() => {
if (font) {
store.dispatch(setOptions({ font }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [font]);
const { isLoggedIn, isInitialized } = useInitData({
store,
config,
});
return (
<EmbeddingContext.Provider
value={{
isInitialized,
isLoggedIn,
font,
setFont,
}}
>
<Provider store={store}>
<SdkEmotionCacheProvider>
<ThemeProvider>
<ContentWrapper id={SDK_CONTEXT_CLASS_NAME} font={font}>
{!isInitialized ? <div>Initializing...</div> : children}
</ContentWrapper>
</ThemeProvider>
</SdkEmotionCacheProvider>
</Provider>
</EmbeddingContext.Provider>
);
};
export const MetabaseProvider = memo(MetabaseProviderInternal);
const ContentWrapper = 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;
`;

View File

@@ -0,0 +1,26 @@
import type { Location } from "history";
import { PublicDashboard as InternalPublicDashboard } from "metabase/public/containers/PublicDashboard";
import type { Dashboard } from "metabase-types/api";
import type { SuperDuperEmbedOptions } from "metabase/public/components/EmbedFrame/types";
export const PublicDashboard = ({
uuid,
embedOptions = {
bordered: true,
titled: true,
theme: "transparent",
hide_parameters: false,
hide_download_button: false,
},
}: {
uuid: Dashboard["public_uuid"];
location?: Location;
embedOptions: SuperDuperEmbedOptions;
}) => (
<InternalPublicDashboard
uuid={uuid}
embedOptions={embedOptions}
parameterSelection={{}}
hasAbsolutePositioning={false}
/>
);

View File

@@ -0,0 +1 @@
export * from "./PublicDashboard";

View File

@@ -0,0 +1,6 @@
import styled from "@emotion/styled";
import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
export const QueryVisualizationSdkWrapper = styled(QueryVisualization)`
flex: 1;
`;

View File

@@ -0,0 +1,155 @@
import { useEffect, useState } from "react";
import type { Card, CardId, Dataset } from "metabase-types/api";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { useSelector } from "metabase/lib/redux";
import { getMetadata } from "metabase/selectors/metadata";
import ChartTypeSidebar from "metabase/query_builder/components/view/sidebars/ChartTypeSidebar";
import {
onCloseChartType,
onOpenChartSettings,
setUIControls,
} from "metabase/query_builder/actions";
import { GET, POST } from "metabase/lib/api";
import { Box, Button, Group, Text } from "metabase/ui";
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
import Question from "metabase-lib/Question";
import { useEmbeddingContext } from "../../../hooks";
import { QueryVisualizationSdkWrapper } from "./QueryVisualization.styled";
interface QueryVisualizationProps {
questionId: CardId;
showVisualizationSelector?: boolean;
}
type State = {
loading: boolean;
card: Card | null;
result: Dataset | null;
};
export const QueryVisualizationSdk = (
props: QueryVisualizationProps,
): JSX.Element | null => {
const { isInitialized, isLoggedIn } = useEmbeddingContext();
const metadata = useSelector(getMetadata);
const { questionId } = props;
const [state, setState] = useState<State>({
loading: false,
card: null,
result: null,
});
const loadCardData = async ({ questionId }: { questionId: number }) => {
setState(prevState => ({
...prevState,
loading: true,
}));
Promise.all([
GET("/api/card/:cardId")({ cardId: questionId }),
POST("/api/card/:cardId/query")({
cardId: questionId,
}),
])
.then(([card, result]) => {
setState(prevState => ({
...prevState,
card,
result,
loading: false,
}));
})
.catch(([_cardError, resultError]) => {
setState(prevState => ({
...prevState,
result: resultError?.data,
}));
});
};
useEffect(() => {
if (!isInitialized || !isLoggedIn) {
setState({
loading: false,
card: null,
result: null,
});
return;
}
loadCardData({ questionId });
}, [isInitialized, isLoggedIn, questionId]);
const { card, result } = state;
const changeVisualization = (newQuestion: Question) => {
setState({
card: newQuestion.card(),
result: state.result,
loading: false,
});
};
if (!isInitialized) {
return null;
}
if (!isLoggedIn) {
return (
<div>
<Text>You should be logged in to see this content.</Text>
<Button>Log in</Button>
</div>
);
}
return (
<LoadingAndErrorWrapper
className="flex-full full-width"
loading={!result}
error={typeof result === "string" ? result : null}
noWrapper
>
{() => {
const question = new Question(card, metadata);
const legacyQuery = question.legacyQuery({
useStructuredQuery: true,
});
return (
<Group h="100%" pos="relative" align="flex-start">
{props.showVisualizationSelector && (
<Box w="355px">
<ChartTypeSidebar
question={question}
result={result}
onOpenChartSettings={onOpenChartSettings}
onCloseChartType={onCloseChartType}
query={legacyQuery}
setUIControls={setUIControls}
updateQuestion={changeVisualization}
/>
</Box>
)}
<QueryVisualizationSdkWrapper
className="full-width"
question={question}
rawSeries={[{ card, data: result && result.data }]}
isRunning={state.loading}
isObjectDetail={false}
isResultDirty={false}
isNativeEditorOpen={false}
result={result}
noHeader
mode={PublicMode}
/>
</Group>
);
}}
</LoadingAndErrorWrapper>
);
};

View File

@@ -0,0 +1 @@
export * from "./QueryVisualization";

View File

@@ -0,0 +1,2 @@
export * from "./QueryVisualization";
export * from "./PublicDashboard";

View File

@@ -0,0 +1,15 @@
export const SDK_CONTEXT_CLASS_NAME = "metabase-sdk"; // this should be synced with webpack.embedding-sdk.config.js
export type SDKConfigType = {
metabaseInstanceUrl: string;
font?: string;
} & (
| {
authType: "jwt";
jwtProviderUri: string;
}
| {
authType: "apiKey";
apiKey: string;
}
);

View File

@@ -0,0 +1,15 @@
import { createContext } from "react";
interface EmbeddingSdkContextData {
isInitialized: boolean;
isLoggedIn: boolean;
font: string | null;
setFont: (font: string) => void;
}
export const EmbeddingContext = createContext<EmbeddingSdkContextData>({
isInitialized: false,
isLoggedIn: false,
font: null,
setFont: () => {},
});

View File

@@ -0,0 +1,2 @@
export * from "./public";
export * from "./private";

View File

@@ -0,0 +1,2 @@
export * from "./use-sdk-context";
export * from "./use-init-data";

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from "react";
import registerVisualizations from "metabase/visualizations/register";
import api from "metabase/lib/api";
import { refreshCurrentUser } from "metabase/redux/user";
import { reloadSettings } from "metabase/admin/settings/settings";
import { getOrRefreshSession, getSessionToken } from "metabase/public/reducers";
import type { PublicTokenState } from "metabase-types/store";
import type { SDKConfigType } from "../../config";
type InitDataLoaderProps = {
store: any;
config: SDKConfigType;
};
export const useInitData = ({
store,
config,
}: InitDataLoaderProps): {
isLoggedIn: boolean;
isInitialized: boolean;
} => {
const [isInitialized, setIsInitialized] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [sessionToken, setSessionToken] = useState<PublicTokenState>(null);
useEffect(() => {
if (config.authType === "jwt") {
const updateToken = () => {
const currentState = store.getState();
setSessionToken(getSessionToken(currentState));
};
const unsubscribe = store.subscribe(updateToken);
if (config.jwtProviderUri) {
store.dispatch(getOrRefreshSession(config.jwtProviderUri));
}
updateToken();
return () => unsubscribe();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
registerVisualizations();
}, []);
useEffect(() => {
api.basename = config.metabaseInstanceUrl;
if (config.authType === "jwt" && sessionToken?.token?.id) {
api.onBeforeRequest = () =>
store.dispatch(getOrRefreshSession(config.jwtProviderUri));
api.sessionToken = sessionToken.token.id;
} else if (config.authType === "apiKey" && config.apiKey) {
api.apiKey = config.apiKey;
} else {
setIsLoggedIn(false);
return;
}
Promise.all([
store.dispatch(refreshCurrentUser()),
store.dispatch(reloadSettings()),
]).then(() => {
setIsInitialized(true);
setIsLoggedIn(true);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config, store.dispatch, sessionToken]);
return {
isLoggedIn,
isInitialized,
};
};

View File

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { EmbeddingContext } from "../../context";
export const useEmbeddingContext = () => {
return useContext(EmbeddingContext);
};

View File

@@ -0,0 +1,5 @@
export * from "./use-current-user";
export * from "./use-application-name";
export * from "./use-question-search";
export * from "./use-available-fonts";
export * from "./use-dashboard-parameters";

View File

@@ -0,0 +1,4 @@
import { useSelector } from "metabase/lib/redux";
import { getApplicationName } from "metabase/selectors/whitelabel";
export const useApplicationName = () => useSelector(getApplicationName);

View File

@@ -0,0 +1,13 @@
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { useEmbeddingContext } from "../../hooks/private/use-sdk-context";
export const useAvailableFonts = () => {
const { font, setFont } = useEmbeddingContext();
return {
availableFonts: useSelector(state => getSetting(state, "available-fonts")),
currentFont: font,
setFont,
};
};

View File

@@ -0,0 +1,4 @@
import { useSelector } from "react-redux";
import { getUser } from "metabase/selectors/user";
export const useCurrentUser = () => useSelector(getUser);

View File

@@ -0,0 +1,7 @@
import { getDashboardParametersByDashboardId } from "metabase/dashboard/selectors";
import type { DashboardId } from "metabase-types/api";
import { useSelector } from "metabase/lib/redux";
export const useDashboardParameters = (dashboardId: DashboardId) => {
return useSelector(state => getDashboardParametersByDashboardId(state, dashboardId));
};

View File

@@ -0,0 +1,24 @@
import { useMemo } from "react";
import { useSearchListQuery } from "metabase/common/hooks";
import { useEmbeddingContext } from "../private/use-sdk-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,
});
};

View File

@@ -0,0 +1,5 @@
import { MetabaseProvider } from "./SdkProvider";
export * from "./hooks/public";
export * from "./components/public";
export { MetabaseProvider };

View File

@@ -0,0 +1 @@
@import "../metabase/css/index.css";

View File

@@ -265,6 +265,7 @@ class StructuredQuery extends AtomicQuery {
* @deprecated use metabase-lib v2 to manage joins
*/
joins = _.once((): JoinWrapper[] => {
console.log(this.legacyQuery({ useStructuredQuery: true }))
return Q.getJoins(this.legacyQuery({ useStructuredQuery: true })).map(
(join, index) => new JoinWrapper(join, index, this),
);
@@ -1123,6 +1124,7 @@ class StructuredQuery extends AtomicQuery {
}
}
console.log(this.joins())
// any explicitly joined tables
for (const join of this.joins()) {
join.dependentMetadata().forEach(addDependency);

View File

@@ -85,7 +85,10 @@ export { getFilterClause } from "./filter";
// JOIN
export const getJoins = query => J.getJoins(query.joins);
export const getJoins = query => {
console.log("getJoins", query)
return J.getJoins(query.joins);
};
export const addJoin = (query, join) =>
setJoinClause(query, J.addJoin(query.joins, join));
export const updateJoin = (query, index, join) =>

View File

@@ -30,5 +30,6 @@ export const createMockState = (
settings: createMockSettingsState(),
setup: createMockSetupState(),
upload: createMockUploadState(),
public: null,
...opts,
});

View File

@@ -12,6 +12,15 @@ import type { SetupState } from "./setup";
import type { FileUploadState } from "./upload";
import type { AuthState } from "./auth";
export type PublicTokenState = {
token: {
id: string;
exp: number;
};
loading: boolean;
error: any;
} | null;
export interface State {
admin: AdminState;
app: AppState;
@@ -26,6 +35,7 @@ export interface State {
settings: SettingsState;
setup: SetupState;
upload: FileUploadState;
public: PublicTokenState;
}
export type Dispatch<T = any> = (action: T) => void;

View File

@@ -0,0 +1,38 @@
.pointer-events-none {
pointer-events: none;
}
.disabled,
:local(.disabled) {
pointer-events: none;
opacity: 0.4;
}
.faded,
:local(.faded) {
opacity: 0.4;
}
.fade-in-hover:hover {
opacity: 1;
transition: opacity 0.3s;
}
.MB-lightBG {
background-color: var(--color-bg-light);
}
.circle {
border-radius: 99px;
}
.undefined {
border: 1px solid red !important;
}
.no-outline * {
outline: none;
}
.outline-none {
outline: none;
}

View File

@@ -0,0 +1,27 @@
@import "./animation.css";
@import "./arrow.css";
@import "./base-sdk.css";
@import "./bordered.css";
@import "./box_sizing.css";
@import "./breakpoints.css";
@import "./clearfix.css";
@import "./colors.css";
@import "./cursor.css";
@import "./flex.css";
@import "./float.css";
@import "./fonts.css";
@import "./grid.css";
@import "./headings.css";
@import "./hide.css";
@import "./hover.css";
@import "./inputs.css";
@import "./layout.css";
@import "./link.css";
@import "./overflow.css";
@import "./rounded.css";
@import "./scroll.css";
@import "./shadow.css";
@import "./spacing.css";
@import "./text.css";
@import "./transitions.css";
@import "./width.css";

View File

@@ -55,6 +55,7 @@ import {
} from "../utils";
import { DASHBOARD_SLOW_TIMEOUT } from "../constants";
import { loadMetadataForDashboard } from "./metadata";
import { PayloadAction } from "@reduxjs/toolkit";
// normalizr schemas
const dashcard = new schema.Entity("dashcard");
@@ -147,6 +148,32 @@ let fetchDashboardCancellation;
export const fetchDashboard = createAsyncThunk(
"metabase/dashboard/FETCH_DASHBOARD",
/**
* @typedef {Object} FetchDashboardSuccess
* @property {Object} entities - The entities object.
* @property {Object} dashboard - The dashboard object.
* @property {string} dashboardId - The ID of the dashboard.
* @property {Object} parameterValues - The parameter values object.
* @property {boolean} preserveParameters - Whether to preserve parameters.
*/
/**
* @typedef {Object} FetchDashboardError
* @property {string} error - The error message.
*/
/**
* Fetches a dashboard based on the provided parameters.
* @param {Object} params - The parameters for fetching the dashboard.
* @param {string} params.dashId - The ID of the dashboard to fetch.
* @param {Object} params.queryParams - The query parameters for the request.
* @param {Object} context - The context object containing getState, dispatch, and rejectWithValue.
* @param {Function} context.getState - The function to get the current state.
* @param {Function} context.dispatch - The function to dispatch actions.
* @param {Function} context.rejectWithValue - The function to handle rejected promises.
* @returns {Promise<FetchDashboardSuccess|FetchDashboardError>} A promise that resolves to either a FetchDashboardSuccess object or a FetchDashboardError object.
*/
async (
{
dashId,
@@ -275,6 +302,10 @@ export const fetchDashboard = createAsyncThunk(
questions,
);
console.log({
parameters, queryParams
})
const parameterValuesById = preserveParameters
? getParameterValues(getState())
: getParameterValuesByIdFromQueryParams(parameters, queryParams);
@@ -486,6 +517,13 @@ export const fetchCardData = createThunkAction(
},
);
/**
* @param {Object} [options={}]
* @param {boolean} [options.isRefreshing=false]
* @param {boolean} [options.reload=false]
* @param {boolean} [options.clearCache=false]
* @param {boolean} [options.ignoreCache=false]
*/
export const fetchDashboardCardData =
({ isRefreshing, ...options } = {}) =>
(dispatch, getState) => {

View File

@@ -23,6 +23,8 @@ const loadMetadataForCards = cards => (dispatch, getState) => {
.filter(card => card.dataset_query) // exclude queries without perms
.map(card => new Question(card, metadata));
console.log(questions)
return dispatch(
loadMetadataForQueries(
questions.map(question =>

View File

@@ -10,6 +10,31 @@ import {
RefreshWidgetButton,
} from "./DashboardActions.styled";
/**
* Generates dashboard action buttons based on the provided conditions and states.
*
* @param {Object} self - The context `this` refers to, not used in the function body.
* @param {Object} options - An object containing all parameters and flags for action generation.
* @param {Object} options.dashboard - The dashboard object to determine loaded state and card content.
* @param {boolean} [options.isAdmin] - Flag indicating if the user has admin privileges.
* @param {boolean} [options.canManageSubscriptions] - Flag indicating if the user can manage subscriptions.
* @param {Object} [options.formInput] - Object containing form input data, used to check if channels are configured.
* @param {boolean} [options.isEditing=false] - Flag indicating if the dashboard is currently being edited.
* @param {boolean} [options.isEmpty=false] - Flag indicating if the dashboard is empty.
* @param {boolean} [options.isFullscreen] - Flag indicating if the dashboard is in fullscreen mode.
* @param {boolean} [options.isNightMode] - Flag indicating if the dashboard is in night mode.
* @param {boolean} [options.isPublic=false] - Flag indicating if the dashboard is public.
* @param {Function} [options.onNightModeChange] - Function to call when night mode is toggled.
* @param {number} [options.refreshPeriod] - The current refresh period for the dashboard.
* @param {Function} [options.setRefreshElapsedHook] - Function to set the hook for refresh elapsed time.
* @param {Function} [options.onRefreshPeriodChange] - Function to call when the refresh period is changed.
* @param {Function} [options.onSharingClick] - Function to call when the sharing button is clicked.
* @param {Function} [options.onFullscreenChange] - Function to call when fullscreen mode is toggled.
* @param {boolean} [options.hasNightModeToggle] - Flag indicating if the night mode toggle is available.
*
* @returns {Array<JSX.Element>} An array of JSX elements representing the action buttons for the dashboard.
*
*/
export const getDashboardActions = (
self,
{

View File

@@ -49,6 +49,16 @@ import type {
import type { Mode } from "metabase/visualizations/click-actions/Mode";
import type Metadata from "metabase-lib/metadata/Metadata";
import {
navigateToNewCardFromDashboard,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
removeCardFromDashboard,
setDashCardAttributes,
setMultipleDashCardAttributes,
showClickBehaviorSidebar,
undoRemoveCardFromDashboard,
} from "../actions";
import {
DashboardCardContainer,
DashboardGridContainer,
@@ -154,7 +164,17 @@ interface DashboardGridState {
isAnimationPaused: boolean;
}
const mapDispatchToProps = { addUndo };
const mapDispatchToProps = {
addUndo,
setDashCardAttributes,
setMultipleDashCardAttributes,
removeCardFromDashboard,
undoRemoveCardFromDashboard,
onReplaceAllDashCardVisualizationSettings,
onUpdateDashCardVisualizationSettings,
navigateToNewCardFromDashboard,
showClickBehaviorSidebar,
};
class DashboardGrid extends Component<DashboardGridProps, DashboardGridState> {
static contextType = ContentViewportContext;

View File

@@ -149,7 +149,7 @@ interface OwnProps {
}
interface StateProps {
formInput: unknown;
formInput: any;
selectedTabId: DashboardTabId | null;
isBookmarked: boolean;
isNavBarOpen: boolean;
@@ -597,7 +597,26 @@ class DashboardHeaderContainer extends Component<DashboardHeaderProps> {
}
}
buttons.push(...getDashboardActions(this, this.props));
buttons.push(
...getDashboardActions(this, {
dashboard,
isAdmin: this.props.isAdmin,
canManageSubscriptions: this.props.canManageSubscriptions,
formInput: this.props.formInput,
isEditing: this.props.isEditing,
isEmpty: !dashboard.dashcards.length,
isFullscreen: this.props.isFullscreen,
isNightMode: this.props.isNightMode,
isPublic: false,
onNightModeChange: this.props.onNightModeChange,
refreshPeriod: this.props.refreshPeriod ?? undefined,
setRefreshElapsedHook: this.props.setRefreshElapsedHook,
onRefreshPeriodChange: this.props.onRefreshPeriodChange,
onSharingClick: this.props.onSharingClick,
onFullscreenChange: onFullscreenChange,
hasNightModeToggle: this.props.hasNightModeToggle,
}),
);
if (!isEditing) {
buttons.push(

View File

@@ -13,14 +13,14 @@ import { useDashboardTabs } from "./use-dashboard-tabs";
interface DashboardTabsProps {
dashboardId: DashboardId;
location: Location;
location?: Location;
isEditing?: boolean;
className?: string;
}
export function DashboardTabs({
dashboardId,
location,
// location,
isEditing = false,
className,
}: DashboardTabsProps) {
@@ -33,7 +33,10 @@ export function DashboardTabs({
selectTab,
selectedTabId,
moveTab,
} = useDashboardTabs({ location, dashboardId });
} = useDashboardTabs({
// location,
dashboardId
});
const hasMultipleTabs = tabs.length > 1;
const showTabs = hasMultipleTabs || isEditing;
const showPlaceholder = tabs.length === 0 && isEditing;

View File

@@ -29,18 +29,18 @@ function isTabIdType(id: unknown): id is SelectedTabId {
}
export function useDashboardTabs({
location,
// location,
dashboardId,
}: {
location: Location;
// location: Location;
dashboardId: DashboardId;
}) {
const dispatch = useDispatch();
const tabs = useSelector(getTabs);
const selectedTabId = useSelector(getSelectedTabId);
useSyncURLSlug({ location });
useMount(() => dispatch(initTabs({ slug: parseSlug({ location }) })));
// useSyncURLSlug({ location });
// useMount(() => dispatch(initTabs({ slug: parseSlug({ location }) })));
const duplicateTab = (tabId: UniqueIdentifier | null) => {
if (!isTabIdType(tabId)) {

View File

@@ -22,6 +22,8 @@ import type {
QuestionDashboardCard,
DashboardId,
DashCardId,
DashboardCard,
ActionDashboardCard,
} from "metabase-types/api";
import type {
ClickBehaviorSidebarState,
@@ -287,10 +289,21 @@ export const getEditingParameter = createSelector(
},
);
const getCard = (state: State, { card }: { card: Card }) => card;
const getCard = (
state: State,
{
card,
}: {
card: Card;
},
) => card;
const getDashCard = (
state: State,
{ dashcard }: { dashcard: QuestionDashboardCard },
{
dashcard,
}: {
dashcard: QuestionDashboardCard;
},
) => dashcard;
export const getParameterTarget = createSelector(
@@ -364,6 +377,42 @@ export const getParameters = createSelector(
);
},
);
export const getDashboardParametersByDashboardId = (
state: State,
dashboardId: DashboardId,
) => {
const metadata = getMetadata(state);
const questions = getQuestions(state);
const dashboard = getDashboardById(state, dashboardId);
console.log({
state,
metadata,
questions,
dashboard,
dbs: getDashboards(state),
});
const dashcards = dashboard?.dashcards
.map(id => state.dashboard.dashcards[id])
.filter(dc => !dc.isRemoved)
.sort((a, b) => {
const rowDiff = a.row - b.row;
// sort by y position first
if (rowDiff !== 0) {
return rowDiff;
}
// for items on the same row, sort by x position
return a.col - b.col;
}) ?? []
return getDashboardUiParameters(
dashcards,
dashboard?.parameters ?? null,
metadata,
questions,
);
};
export const getMissingRequiredParameters = createSelector(
[getParameters],
@@ -379,7 +428,15 @@ export const getMissingRequiredParameters = createSelector(
* It's a memoized version, it uses LRU cache per card identified by id
*/
export const getQuestionByCard = createCachedSelector(
[(_state: State, props: { card: Card }) => props.card, getMetadata],
[
(
_state: State,
props: {
card: Card;
},
) => props.card,
getMetadata,
],
(card, metadata) => {
return new Question(card, metadata);
},

View File

@@ -31,18 +31,24 @@ const DEFAULT_OPTIONS = {
export class Api extends EventEmitter {
basename = "";
apiKey = "";
sessionToken = "";
onBeforeRequest;
GET;
POST;
PUT;
DELETE;
constructor() {
constructor(basename) {
super();
this.GET = this._makeMethod("GET", { retry: true });
this.DELETE = this._makeMethod("DELETE", {});
this.POST = this._makeMethod("POST", { hasBody: true, retry: true });
this.PUT = this._makeMethod("PUT", { hasBody: true });
this.basename = basename || "";
}
_makeMethod(method, creatorOptions = {}) {
@@ -58,6 +64,10 @@ export class Api extends EventEmitter {
};
return async (rawData, invocationOptions = {}) => {
if (this.onBeforeRequest) {
await this.onBeforeRequest();
}
const options = { ...defaultOptions, ...invocationOptions };
let url = urlTemplate;
const data = { ...rawData };
@@ -89,6 +99,14 @@ export class Api extends EventEmitter {
delete headers["Content-Type"];
}
if (this.apiKey) {
headers["x-api-key"] = this.apiKey;
}
if (this.sessionToken) {
headers["X-Metabase-Session"] = this.sessionToken;
}
if (isWithinIframe()) {
headers["X-Metabase-Embedded"] = "true";
}

View File

@@ -1,5 +1,6 @@
import { push } from "react-router-redux";
import _ from "underscore";
import { parseSearchOptions, parseHashOptions } from "metabase/lib/browser";
import { isWithinIframe, IFRAMED_IN_SELF } from "metabase/lib/dom";
import { setOptions } from "metabase/redux/embed";
import { isFitViewportMode } from "metabase/hoc/FitViewPort";
@@ -41,7 +42,12 @@ export function initializeEmbedding(store) {
}
});
store.dispatch(setOptions(window.location));
store.dispatch(
setOptions({
...parseSearchOptions(window.location.search),
...parseHashOptions(window.location.hash),
}),
);
}
}

View File

@@ -172,15 +172,15 @@ export function siteLocale() {
}
}
// register site locale with ttag, if needed later
if (window.MetabaseSiteLocalization) {
const translationsObject = window.MetabaseSiteLocalization;
const locale = translationsObject.headers.language;
addMsgIds(translationsObject);
addLocale(locale, translationsObject);
}
// set the initial localization to user locale
if (window.MetabaseUserLocalization) {
setLocalization(window.MetabaseUserLocalization);
}
// // register site locale with ttag, if needed later
// if (window.MetabaseSiteLocalization) {
// const translationsObject = window.MetabaseSiteLocalization;
// const locale = translationsObject.headers.language;
// addMsgIds(translationsObject);
// addLocale(locale, translationsObject);
// }
//
// // set the initial localization to user locale
// if (window.MetabaseUserLocalization) {
// setLocalization(window.MetabaseUserLocalization);
// }

View File

@@ -1,18 +1,16 @@
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import { withRouter } from "react-router";
import { connect } from "react-redux";
import cx from "classnames";
import _ from "underscore";
import type { Location } from "history";
import { useMount } from "react-use";
import type { WithRouterProps } from "react-router/lib/withRouter";
import TitleAndDescription from "metabase/components/TitleAndDescription";
import { getSetting } from "metabase/selectors/settings";
import { isWithinIframe, initializeIframeResizer } from "metabase/lib/dom";
import { parseHashOptions } from "metabase/lib/browser";
import { initializeIframeResizer } from "metabase/lib/dom";
import SyncedParametersList from "metabase/parameters/components/SyncedParametersList/SyncedParametersList";
import { FilterApplyButton } from "metabase/parameters/components/FilterApplyButton";
@@ -24,9 +22,7 @@ import type {
ParameterValueOrArray,
} from "metabase-types/api";
import type { State } from "metabase-types/store";
import { useDispatch } from "metabase/lib/redux";
import { setOptions } from "metabase/redux/embed";
import type { SuperDuperEmbedOptions } from "metabase/public/components/EmbedFrame/types";
import { FixedWidthContainer } from "metabase/dashboard/components/Dashboard/Dashboard.styled";
import type Question from "metabase-lib/Question";
import { getValuePopulatedParameters } from "metabase-lib/parameters/utils/parameter-values";
@@ -64,26 +60,17 @@ interface OwnProps {
setParameterValueToDefault: (id: ParameterId) => void;
children: ReactNode;
dashboardTabs?: ReactNode;
embedOptions: SuperDuperEmbedOptions;
hasAbsolutePositioning?: boolean;
}
interface StateProps {
type StateProps = {
hasEmbedBranding: boolean;
}
};
type Props = OwnProps &
StateProps & {
location: Location;
};
type Props = OwnProps & StateProps & WithRouterProps;
interface HashOptions {
bordered?: boolean;
titled?: boolean;
theme?: string;
hide_parameters?: string;
hide_download_button?: boolean;
}
function mapStateToProps(state: State) {
function mapStateToProps(state: State): StateProps {
return {
hasEmbedBranding: !getSetting(state, "hide-embed-branding?"),
};
@@ -99,7 +86,7 @@ function EmbedFrame({
actionButtons,
dashboardTabs = null,
footerVariant = "default",
location,
embedOptions,
hasEmbedBranding,
parameters,
parameterValues,
@@ -108,25 +95,25 @@ function EmbedFrame({
setParameterValue,
setParameterValueToDefault,
enableParameterRequiredBehavior,
hasAbsolutePositioning,
}: Props) {
const [hasInnerScroll, setInnerScroll] = useState(true);
useMount(() => {
initializeIframeResizer(() => setInnerScroll(false));
if (hasAbsolutePositioning) {
initializeIframeResizer(() => setInnerScroll(false));
} else {
setInnerScroll(false);
}
});
const dispatch = useDispatch();
useEffect(() => {
dispatch(setOptions(location));
}, [dispatch, location]);
const {
bordered = isWithinIframe(),
titled = true,
theme,
hide_parameters,
hide_download_button,
} = parseHashOptions(location.hash) as HashOptions;
bordered = false,
titled = !!name,
theme = "transparent",
hide_parameters = false,
hide_download_button = false,
} = embedOptions || {};
const hideParameters = [hide_parameters, hiddenParameterSlugs]
.filter(Boolean)
@@ -208,4 +195,4 @@ function EmbedFrame({
}
// eslint-disable-next-line import/no-default-export -- deprecated usage
export default _.compose(connect(mapStateToProps), withRouter)(EmbedFrame);
export default _.compose(connect(mapStateToProps))(EmbedFrame);

View File

@@ -0,0 +1,8 @@
export type SuperDuperEmbedOptions = {
font?: null | string;
theme?: "light" | "night" | "transparent";
bordered?: boolean;
titled?: boolean;
hide_download_button?: boolean | null;
hide_parameters?: boolean;
}

View File

@@ -22,7 +22,7 @@ import { DisplayOptionSection } from "./StaticEmbedSetupPane.styled";
import { StaticEmbedSetupPaneSettingsContentSection } from "./StaticEmbedSetupPaneSettingsContentSection";
const THEME_OPTIONS = [
{ label: t`Light`, value: "light" },
{ label: t`Light`, value: "light" },
{ label: t`Dark`, value: "night" },
{ label: t`Transparent`, value: "transparent" },
] as const;

View File

@@ -1,216 +0,0 @@
/* eslint-disable react/prop-types */
import { Component } from "react";
import { connect } from "react-redux";
import { push } from "react-router-redux";
import cx from "classnames";
import _ from "underscore";
import { isWithinIframe } from "metabase/lib/dom";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { DashboardGridConnected } from "metabase/dashboard/components/DashboardGrid";
import { DashboardControls } from "metabase/dashboard/hoc/DashboardControls";
import { getDashboardActions } from "metabase/dashboard/components/DashboardActions";
import title from "metabase/hoc/Title";
import { setErrorPage } from "metabase/redux/app";
import { getMetadata } from "metabase/selectors/metadata";
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
import {
getDashboardComplete,
getCardData,
getSlowCards,
getParameters,
getParameterValues,
getDraftParameterValues,
getSelectedTabId,
} from "metabase/dashboard/selectors";
import * as dashboardActions from "metabase/dashboard/actions";
import {
setPublicDashboardEndpoints,
setEmbedDashboardEndpoints,
} from "metabase/services";
import EmbedFrame from "../components/EmbedFrame";
import {
DashboardContainer,
DashboardGridContainer,
StyledDashboardTabs,
} from "./PublicDashboard.styled";
const mapStateToProps = (state, props) => {
return {
metadata: getMetadata(state, props),
dashboardId:
props.params.dashboardId || props.params.uuid || props.params.token,
dashboard: getDashboardComplete(state, props),
dashcardData: getCardData(state, props),
slowCards: getSlowCards(state, props),
parameters: getParameters(state, props),
parameterValues: getParameterValues(state, props),
draftParameterValues: getDraftParameterValues(state, props),
selectedTabId: getSelectedTabId(state),
};
};
const mapDispatchToProps = {
...dashboardActions,
setErrorPage,
onChangeLocation: push,
};
class PublicDashboard extends Component {
_initialize = async () => {
const {
initialize,
fetchDashboard,
fetchDashboardCardData,
setErrorPage,
location,
params: { uuid, token },
} = this.props;
if (uuid) {
setPublicDashboardEndpoints();
} else if (token) {
setEmbedDashboardEndpoints();
}
initialize();
const result = await fetchDashboard({
dashId: uuid || token,
queryParams: location.query,
});
if (result.error) {
setErrorPage(result.payload);
return;
}
try {
if (this.props.dashboard.tabs.length === 0) {
await fetchDashboardCardData({ reload: false, clearCache: true });
}
} catch (error) {
console.error(error);
setErrorPage(error);
}
};
async componentDidMount() {
this._initialize();
}
componentWillUnmount() {
this.props.cancelFetchDashboardCardData();
}
async componentDidUpdate(prevProps) {
if (this.props.dashboardId !== prevProps.dashboardId) {
return this._initialize();
}
if (!_.isEqual(prevProps.selectedTabId, this.props.selectedTabId)) {
this.props.fetchDashboardCardData();
this.props.fetchDashboardCardMetadata();
return;
}
if (!_.isEqual(this.props.parameterValues, prevProps.parameterValues)) {
this.props.fetchDashboardCardData({ reload: false, clearCache: true });
}
}
getCurrentTabDashcards = () => {
const { dashboard, selectedTabId } = this.props;
if (!Array.isArray(dashboard?.dashcards)) {
return [];
}
if (!selectedTabId) {
return dashboard.dashcards;
}
return dashboard.dashcards.filter(
dashcard => dashcard.dashboard_tab_id === selectedTabId,
);
};
getHiddenParameterSlugs = () => {
const { parameters } = this.props;
const currentTabParameterIds = this.getCurrentTabDashcards().flatMap(
dashcard =>
dashcard.parameter_mappings?.map(mapping => mapping.parameter_id) ?? [],
);
const hiddenParameters = parameters.filter(
parameter => !currentTabParameterIds.includes(parameter.id),
);
return hiddenParameters.map(parameter => parameter.slug).join(",");
};
render() {
const {
dashboard,
parameters,
parameterValues,
draftParameterValues,
isFullscreen,
isNightMode,
setParameterValueToDefault,
} = this.props;
const buttons = !isWithinIframe()
? getDashboardActions(this, { ...this.props, isPublic: true })
: [];
return (
<EmbedFrame
name={dashboard && dashboard.name}
description={dashboard && dashboard.description}
dashboard={dashboard}
parameters={parameters}
parameterValues={parameterValues}
draftParameterValues={draftParameterValues}
hiddenParameterSlugs={this.getHiddenParameterSlugs()}
setParameterValue={this.props.setParameterValue}
setParameterValueToDefault={setParameterValueToDefault}
enableParameterRequiredBehavior
actionButtons={
buttons.length > 0 && <div className="flex">{buttons}</div>
}
dashboardTabs={<StyledDashboardTabs location={this.props.location} />}
>
<LoadingAndErrorWrapper
className={cx({
"Dashboard--fullscreen": isFullscreen,
"Dashboard--night": isNightMode,
})}
loading={!dashboard}
>
{() => (
<DashboardContainer>
<DashboardGridContainer>
<DashboardGridConnected
{...this.props}
isPublic
className="spread"
mode={PublicMode}
metadata={this.props.metadata}
navigateToNewCardFromDashboard={() => {}}
/>
</DashboardGridContainer>
</DashboardContainer>
)}
</LoadingAndErrorWrapper>
</EmbedFrame>
);
}
}
export default _.compose(
connect(mapStateToProps, mapDispatchToProps),
title(({ dashboard }) => dashboard && dashboard.name),
DashboardControls,
)(PublicDashboard);

View File

@@ -0,0 +1,258 @@
import { isRejected } from "@reduxjs/toolkit";
import { useMount, usePrevious, useUnmount, useUpdateEffect } from "react-use";
import _ from "underscore";
import cx from "classnames";
import { useCallback } from "react";
import { getMetadata } from "metabase/selectors/metadata";
import {
getCardData,
getDashboardComplete,
getDraftParameterValues,
getParameters,
getParameterValues,
getSelectedTabId,
getSlowCards,
} from "metabase/dashboard/selectors";
import {
cancelFetchDashboardCardData,
fetchDashboard,
fetchDashboardCardData,
fetchDashboardCardMetadata,
initialize as initializeDashboard,
setParameterValue,
setParameterValueToDefault,
} from "metabase/dashboard/actions";
import { useDispatch, useSelector } from "metabase/lib/redux";
import {
setEmbedDashboardEndpoints,
setPublicDashboardEndpoints,
} from "metabase/services";
import { setErrorPage } from "metabase/redux/app";
import { isWithinIframe } from "metabase/lib/dom";
import { getDashboardActions } from "metabase/dashboard/components/DashboardActions";
import type {
Dashboard,
ParameterId,
ParameterValue,
} from "metabase-types/api";
import EmbedFrame from "metabase/public/components/EmbedFrame/EmbedFrame";
import {
DashboardContainer,
DashboardGridContainer,
StyledDashboardTabs,
} from "metabase/public/containers/PublicDashboard.styled";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import { DashboardGridConnected } from "metabase/dashboard/components/DashboardGrid";
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
import { checkNotNull } from "metabase/lib/types";
import type { SuperDuperEmbedOptions } from "../components/EmbedFrame/types";
type DashboardIdOption = {
uuid?: Dashboard["public_uuid"];
token?: string;
};
const getDashboardId = (option: DashboardIdOption): string => {
return checkNotNull(option.token ?? option.uuid);
};
export const PublicDashboard = ({
uuid,
token,
isFullscreen,
isNightMode,
parameterSelection,
embedOptions,
hasAbsolutePositioning = true,
}: {
uuid?: Dashboard["public_uuid"];
token?: string;
isFullscreen?: boolean;
isNightMode?: boolean;
embedOptions: SuperDuperEmbedOptions;
parameterSelection: Record<string, string | string[] | null | undefined>;
hasAbsolutePositioning?: boolean;
}) => {
const dispatch = useDispatch();
const assetId = getDashboardId({ uuid, token });
const {
metadata,
dashboard,
dashcardData,
slowCards,
parameters,
parameterValues,
draftParameterValues,
selectedTabId,
} = useSelector(state => ({
metadata: getMetadata(state),
dashboard: getDashboardComplete(state) as Dashboard | undefined,
dashcardData: getCardData(state),
slowCards: getSlowCards(state),
parameters: getParameters(state),
parameterValues: getParameterValues(state),
draftParameterValues: getDraftParameterValues(state),
selectedTabId: getSelectedTabId(state),
}));
const initializeComponent = useCallback(async () => {
if (uuid) {
setPublicDashboardEndpoints();
} else if (token) {
setEmbedDashboardEndpoints();
}
initializeDashboard();
const result = await dispatch(
fetchDashboard({
dashId: assetId,
queryParams: parameterSelection,
}),
);
if (isRejected(result)) {
dispatch(setErrorPage(result.payload));
return;
}
try {
if (dashboard?.tabs?.length === 0) {
dispatch(
fetchDashboardCardData({
isRefreshing: false,
reload: false,
clearCache: true,
}),
);
}
} catch (error) {
console.error(error);
dispatch(setErrorPage(error));
}
}, [
assetId,
dashboard?.tabs?.length,
dispatch,
parameterSelection,
token,
uuid,
]);
useMount(initializeComponent);
useUnmount(() => dispatch(cancelFetchDashboardCardData()));
const prevProps = usePrevious({
assetId,
selectedTabId,
parameterValues,
});
useUpdateEffect(() => {
if (assetId !== prevProps?.assetId) {
initializeComponent();
return;
}
if (!_.isEqual(prevProps?.selectedTabId, selectedTabId)) {
dispatch(fetchDashboardCardData());
dispatch(fetchDashboardCardMetadata());
return;
}
if (!_.isEqual(parameterValues, prevProps?.parameterValues)) {
dispatch(fetchDashboardCardData({ reload: false, clearCache: true }));
return;
}
}, [assetId, selectedTabId, parameterValues]);
const getCurrentTabDashcards = () => {
if (!dashboard || !Array.isArray(dashboard?.dashcards)) {
return [];
}
if (!selectedTabId) {
return dashboard.dashcards;
}
return dashboard.dashcards.filter(
dashcard => dashcard.dashboard_tab_id === selectedTabId,
);
};
const getHiddenParameterSlugs = () => {
const currentTabParameterIds = getCurrentTabDashcards().flatMap(
dashcard =>
dashcard.parameter_mappings?.map(mapping => mapping.parameter_id) ?? [],
);
const hiddenParameters = parameters.filter(
parameter => !currentTabParameterIds.includes(parameter.id),
);
return hiddenParameters.map(parameter => parameter.slug).join(",");
};
const buttons =
!isWithinIframe() && dashboard
? getDashboardActions(
{},
{
dashboard,
isPublic: true,
},
)
: [];
return (
<EmbedFrame
hasAbsolutePositioning={hasAbsolutePositioning}
embedOptions={embedOptions}
name={dashboard && dashboard.name}
description={dashboard && dashboard.description}
dashboard={dashboard}
parameters={parameters}
parameterValues={parameterValues}
draftParameterValues={draftParameterValues}
hiddenParameterSlugs={getHiddenParameterSlugs()}
setParameterValue={(parameterId: ParameterId, value: ParameterValue) =>
dispatch(setParameterValue(parameterId, value))
}
setParameterValueToDefault={(id: ParameterId) =>
dispatch(setParameterValueToDefault(id))
}
enableParameterRequiredBehavior
actionButtons={
buttons.length > 0 ? <div className="flex">{buttons}</div> : []
}
dashboardTabs={<StyledDashboardTabs dashboardId={assetId} />}
>
<LoadingAndErrorWrapper
className={cx({
"Dashboard--fullscreen": isFullscreen,
"Dashboard--night": isNightMode,
})}
loading={!dashboard}
>
{() => (
<DashboardContainer>
<DashboardGridContainer>
<DashboardGridConnected
isPublic
className="spread"
mode={PublicMode}
metadata={metadata}
navigateToNewCardFromDashboard={() => {}}
dashboard={dashboard}
dashcardData={dashcardData}
slowCards={slowCards}
parameterValues={parameterValues}
/>
</DashboardGridContainer>
</DashboardContainer>
)}
</LoadingAndErrorWrapper>
</EmbedFrame>
);
};

View File

@@ -0,0 +1,65 @@
import type { Location } from "history";
import {useEffect} from "react";
import { PublicDashboard } from "metabase/public/containers/PublicDashboard";
import {setOptions} from "metabase/redux/embed";
import {parseHashOptions, parseSearchOptions} from "metabase/lib/browser";
import {isWithinIframe} from "metabase/lib/dom";
import {useDispatch} from "metabase/lib/redux";
import type {SuperDuperEmbedOptions} from "metabase/public/components/EmbedFrame/types";
import {useSyncURLSlug} from "metabase/dashboard/components/DashboardTabs/use-sync-url-slug";
const themeTypeGuard = (theme: string | string[] | undefined): theme is "light" | "night" | "transparent" => {
if (Array.isArray(theme)) {
return false;
}
return theme === "light" || theme === "night" || theme === "transparent";
}
export const PublicDashboardRouterWrapper = ({
location,
params: { uuid, tabSlug, token },
...rest
}: {
location: Location;
params: { uuid: string; tabSlug: string, token: string };
rest: any;
}) => {
// useSyncURLSlug({ location });
const dispatch = useDispatch();
useEffect(() => {
dispatch(setOptions({
...parseSearchOptions(location.search),
...parseHashOptions(location.hash),
}),);
}, [dispatch, location]);
const {
bordered = isWithinIframe(),
titled = true,
theme,
hide_parameters,
hide_download_button,
} = parseHashOptions(location.hash);
const embedOptions: SuperDuperEmbedOptions = {
font: "Lato",
bordered: Boolean(bordered),
titled: Boolean(titled),
theme: themeTypeGuard(theme) ? theme : "light",
hide_parameters: Boolean(hide_parameters),
hide_download_button: Boolean(hide_download_button),
}
return (
<PublicDashboard
uuid={uuid}
token={token}
parameterSelection={location.query}
embedOptions={embedOptions}
/>
);
};

View File

@@ -210,6 +210,7 @@ class PublicQuestionInner extends Component {
return (
<EmbedFrame
location={this.props.location}
name={card && card.name}
description={card && card.description}
actionButtons={actionButtons}

View File

@@ -0,0 +1,72 @@
/* eslint-disable */
import { createReducer } from "@reduxjs/toolkit";
import {
createAsyncThunk,
createThunkAction,
} from "metabase/lib/redux/typed-utils";
import { createAction } from "metabase/lib/redux";
import type { PublicTokenState, State } from "metabase-types/store";
// selector for the token
export const getSessionToken = (state: State) => state.public;
const GET_OR_REFRESH_SESSION = "metabase/public/GET_OR_REFRESH_SESSION";
export const getOrRefreshSession = createThunkAction(
GET_OR_REFRESH_SESSION,
(url: string) => async (_dispatch, getState) => {
const state = getState().public;
const token = getState().public?.token;
console.log("getOrRefreshSession - start");
console.log(
"running refresh? ",
!state?.loading && (!token || token.exp * 1000 < Date.now()),
);
if (!state?.loading && (!token || token.exp * 1000 < Date.now())) {
_dispatch(refreshTokenAsync(url));
}
console.log("getOrRefreshSession - end", getState().public?.token);
return getState().public?.token;
},
);
const REFRESH_TOKEN = "metabase/public/REFRESH_TOKEN";
export const refreshTokenAsync = createAsyncThunk(
REFRESH_TOKEN,
async (url: string) => {
const response = await fetch(url, {
method: "GET",
credentials: "include",
});
return await response.json();
},
);
const initialState = {
token: null,
loading: false,
error: null,
};
const tokenReducer = createReducer(initialState, {
// @ts-ignore
[refreshTokenAsync.pending]: (state, action) => {
console.log("action pending", action);
state.loading = true;
},
// @ts-ignore
[refreshTokenAsync.fulfilled]: (state, action) => {
console.log("action fulfilled", { state, action });
state.token = action.payload;
state.loading = false;
},
// @ts-ignore
[refreshTokenAsync.rejected]: (state, action) => {
console.log("action rejected", action);
state.error = action.error;
state.loading = false;
},
});
export { tokenReducer };

View File

@@ -12,6 +12,7 @@ import entities, { enhanceRequestsReducer } from "metabase/redux/entities";
/* user */
import { currentUser } from "metabase/redux/user";
import { tokenReducer } from "metabase/public/reducers";
export default {
// global reducers
@@ -25,4 +26,6 @@ export default {
entities,
upload,
auth,
public: tokenReducer,
};

View File

@@ -3,7 +3,6 @@ import {
createAction,
handleActions,
} from "metabase/lib/redux";
import { parseHashOptions, parseSearchOptions } from "metabase/lib/browser";
export const DEFAULT_EMBED_OPTIONS = {
top_nav: true,
@@ -18,22 +17,17 @@ export const DEFAULT_EMBED_OPTIONS = {
} as const;
export const SET_OPTIONS = "metabase/embed/SET_OPTIONS";
export const setOptions = createAction(
SET_OPTIONS,
({ search, hash }: { search: string; hash: string }) => {
return {
...parseSearchOptions(search),
...parseHashOptions(hash),
};
},
);
export const setOptions = createAction(SET_OPTIONS);
const options = handleActions(
{
[SET_OPTIONS]: (state, { payload }) => ({
...DEFAULT_EMBED_OPTIONS,
...payload,
}),
[SET_OPTIONS]: (state, { payload }) => {
console.log(payload)
return {
...DEFAULT_EMBED_OPTIONS,
...payload,
};
},
},
{},
);

View File

@@ -299,7 +299,10 @@ export const loadMetadataForQuery = (query, extraDependencies) =>
export const loadMetadataForQueries =
(queries, extraDependencies, options) => dispatch => {
const dependencies = _.chain(queries)
.map(q => q.dependentMetadata())
.map(q => {
console.log("loadMetadataForQueries", q)
return q.dependentMetadata();
})
.push(...(extraDependencies ?? []))
.flatten()
.uniq(false, dep => dep.type + dep.id)

View File

@@ -18,7 +18,7 @@ export const refreshSiteSettings = createAsyncThunk(
);
export const settings = createReducer(
{ values: window.MetabaseBootstrap, loading: false },
{ values: window.MetabaseBootstrap || {}, loading: false },
builder => {
builder.addCase(refreshSiteSettings.pending, state => {
state.loading = true;

View File

@@ -4,13 +4,13 @@ import PublicNotFound from "metabase/public/components/PublicNotFound";
import PublicApp from "metabase/public/containers/PublicApp";
import { PublicQuestion } from "metabase/public/containers/PublicQuestion";
import PublicDashboard from "metabase/public/containers/PublicDashboard";
import { PublicDashboardRouterWrapper } from "./public/containers/PublicDashboardRouterWrapper";
export const getRoutes = store => (
<Route>
<Route path="embed" component={PublicApp}>
<Route path="question/:token" component={PublicQuestion} />
<Route path="dashboard/:token" component={PublicDashboard} />
<Route path="dashboard/:token" component={PublicDashboardRouterWrapper} />
<Route path="*" component={PublicNotFound} />
</Route>
<Route path="*" component={PublicNotFound} />

View File

@@ -5,7 +5,7 @@ import PublicNotFound from "metabase/public/components/PublicNotFound";
import PublicApp from "metabase/public/containers/PublicApp";
import PublicAction from "metabase/public/containers/PublicAction";
import { PublicQuestion } from "metabase/public/containers/PublicQuestion";
import PublicDashboard from "metabase/public/containers/PublicDashboard";
import {PublicDashboardRouterWrapper} from "metabase/public/containers/PublicDashboardRouterWrapper";
import { getApplicationName } from "./selectors/whitelabel";
export const getRoutes = store => {
@@ -15,7 +15,7 @@ export const getRoutes = store => {
<Route path="public" component={PublicApp}>
<Route path="action/:uuid" component={PublicAction} />
<Route path="question/:uuid" component={PublicQuestion} />
<Route path="dashboard/:uuid(/:tabSlug)" component={PublicDashboard} />
<Route path="dashboard/:uuid(/:tabSlug)" component={PublicDashboardRouterWrapper} />
<Route path="*" component={PublicNotFound} />
</Route>
<Route path="*" component={PublicNotFound} />

View File

@@ -71,7 +71,7 @@ import getCollectionTimelineRoutes from "metabase/timelines/collections/routes";
import { getRoutes as getModelRoutes } from "metabase/models/routes";
import { PublicQuestion } from "metabase/public/containers/PublicQuestion";
import PublicDashboard from "metabase/public/containers/PublicDashboard";
import { PublicDashboard } from "metabase/public/containers/PublicDashboard";
import { ArchiveDashboardModalConnected } from "metabase/dashboard/containers/ArchiveDashboardModal";
import { DashboardMoveModalConnected } from "metabase/dashboard/components/DashboardMoveModal";
import { DashboardCopyModalConnected } from "metabase/dashboard/components/DashboardCopyModal";

View File

@@ -154,7 +154,7 @@ export default class ChoroplethMap extends Component {
this.setState({
geoJson: geoJson,
geoJsonPath: geoJsonPath,
minimalBounds: computeMinimalBounds(geoJson.features),
minimalBounds: computeMinimalBounds(geoJson?.features ?? []),
});
});
}

View File

@@ -45,10 +45,11 @@ export function registerVisualization(visualization: Visualization) {
);
}
if (visualizations.has(identifier)) {
throw new Error(
t`Visualization with that identifier is already registered: ` +
visualization.name,
);
return;
// throw new Error(
// t`Visualization with that identifier is already registered: ` +
// visualization.name,
// );
}
visualizations.set(identifier, visualization);
for (const alias of visualization.aliases || []) {

View File

@@ -0,0 +1,4 @@
REACT_APP_METABASE_INSTANCE_URL=http://localhost:3000
; REACT_APP_JWT_PROVIDER_URI=/sso/metabase
; REACT_APP_AUTH_API_HOST=http://localhost:8081
REACT_APP_API_KEY=

24
host-app-example/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env

View File

@@ -0,0 +1,49 @@
{
"name": "host-app-example",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"localforage": "^1.10.0",
"lodash": "^4.17.21",
"match-sorter": "^6.3.3",
"metabase-embedding-sdk": "file:../resources/embedding-sdk",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-error-boundary": "^4.0.12",
"react-router-dom": "^6.22.0",
"react-scripts": "5.0.1",
"sort-by": "^1.2.0",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "yarn --force && rimraf ./node_modules/.cache && PORT=3004 BROWSER=none react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"rimraf": "^5.0.5",
"tailwindcss": "^3.4.1"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,44 @@
import { MetabaseProvider } from "metabase-embedding-sdk";
import { Outlet } from "react-router-dom";
import { Welcome } from "./Welcome";
import { LogoutButton } from "./Logout";
import { FontSelector } from "./FontSelector";
import { StyleLeakFlag } from "./StyleLeakFlag/StyleLeakFlag";
import { ViewToggle } from "./ViewToggle/ViewToggle";
const config = {
metabaseInstanceUrl:
process.env.REACT_APP_METABASE_INSTANCE_URL || "http://localhost:3000",
font: "Inter",
authType: "apiKey",
// jwtProviderUri: "http://localhost:8081/sso/metabase",
apiKey:
process.env.REACT_APP_API_KEY ||
"mb_Zhns2vrYNTIAAc/GHBuIoUshpysJ2laLWsZ9MbJavj0=",
};
const App = () => {
return (
<MetabaseProvider config={config}>
<div className="Page--container">
<header className="Page--header">
<Welcome />
<ViewToggle />
<LogoutButton />
</header>
<div className="tw-flex-1 tw-overflow-scroll">
<Outlet />
</div>
<footer className="Page--footer">
<FontSelector />
<StyleLeakFlag />
</footer>
</div>
</MetabaseProvider>
);
};
export default App;

View File

@@ -0,0 +1,34 @@
export const DashboardBoolToggle = ({ label, value, onChange, className }) => (
<label
className={`${className} tw-flex tw-flex-row tw-items-center tw-cursor-pointer tw-flex-nowrap tw-text-nowrap`}
>
{/* just having fun with these css classes */}
<input
className="tw-mr-1
tw-bg-transparent
tw-border-2
tw-border-gray-400
tw-bg-gray-100s
tw-rounded
tw-w-2
tw-h-2
tw-appearance-none
checked:tw-bg-gray-600
checked:tw-border-transparent
checked:tw-ring-2
checked:tw-ring-offset-2
checked:tw-ring-offset-white
checked:tw-ring-white
checked:tw-ring-opacity-60
checked:tw-ring-offset-opacity-40
tw-transition
tw-duration-300
tw-ease-in-out
"
type="checkbox"
checked={value}
onChange={e => onChange(e.target.checked)}
/>
<span>{label}</span>
</label>
);

View File

@@ -0,0 +1,45 @@
import { DashboardThemeSelector } from "./DashboardThemeSelector";
import { DashboardBoolToggle } from "./DashboardBoolToggle";
import { DashboardParameterSelector } from "./DashboardParameterSelector";
const TEST_EMBED_OPTIONS = [
"titled",
"bordered",
"hide_download_button",
];
const BUTTON_CLASSES =
"tw-font-bold tw-bg-gray-900 tw-text-white tw-text-center tw-rounded hover:tw-bg-gray-700 tw-px-2 tw-py-1 tw-gap-1";
export const DashboardOptionToggleSection = ({
dashboardId,
currentOptions,
setOptions,
}) => (
<div className="tw-bg-gray-800 tw-px-2 tw-py-4 tw-flex tw-flex-row tw-gap-1 tw-sticky tw-z-40 tw-shadow-lg tw-border-b tw-border-gray-500 ">
<div className={`${BUTTON_CLASSES} tw-bg-transparent`}>
<span>Options:</span>
</div>
<DashboardThemeSelector
className={BUTTON_CLASSES}
value={currentOptions.theme}
onChange={value => setOptions({ theme: value })}
/>
<DashboardParameterSelector
className={BUTTON_CLASSES}
dashboardId={dashboardId}
value={currentOptions.hide_parameters}
onChange={value => setOptions({ hide_parameters: value })}
/>
{TEST_EMBED_OPTIONS.map(option => (
<DashboardBoolToggle
className={BUTTON_CLASSES}
key={option}
label={option.replace(/_/g, " ").replace(/\b\w/g, l => l.toUpperCase())}
value={currentOptions[option]}
onChange={value => setOptions({ [option]: value })}
/>
))}
</div>
);

View File

@@ -0,0 +1,55 @@
import { useDashboardParameters } from "metabase-embedding-sdk";
import { useMemo, useState } from "react";
import { xor } from "lodash";
export const DashboardParameterSelector = ({
dashboardId,
value: hiddenParameters,
onChange,
className,
}) => {
const dashboardParameters = useDashboardParameters(dashboardId);
const [isOpen, setIsOpen] = useState(false);
const parameterList = useMemo(() => {
return dashboardParameters.map(parameter => {
return {
name: parameter.name,
value: parameter.slug,
};
});
}, [dashboardParameters]);
// select multiple parameters
return (
<div className="tw-relative tw-z-50">
<button className={className} onClick={() => setIsOpen(!isOpen)}>
Hide Parameters
</button>
{isOpen && (
<div className="tw-absolute tw-flex tw-flex-col tw-shadow-2xl tw-px-2 tw-py-2 tw-bg-gray-800 tw-border tw-border-gray-400">
{parameterList.map(parameter => (
<button
key={parameter.value}
onClick={() => {
onChange(
hiddenParameters
? xor(hiddenParameters, [parameter.value])
: xor(parameterList, [parameter.value]),
);
}}
className={[
hiddenParameters?.includes(parameter.value)
? "tw-bg-gray-700 tw-line-through tw-text-gray-500"
: "tw-bg-gray-800",
"tw-text-nowrap tw-whitespace-nowrap tw-px-2 tw-py-1 tw-font-bold hover:tw-bg-gray-500 tw-text-left",
]}
>
{parameter.name}
</button>
))}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,7 @@
.DashboardThemeSelector--dropdown-button {
@apply tw-font-bold tw-bg-gray-900 tw-text-white tw-text-center tw-rounded;
}
.DashboardThemeSelector--dropdown-button:hover {
@apply tw-bg-gray-700;
}

View File

@@ -0,0 +1,23 @@
import { t } from "ttag";
import "./DashboardThemeSelector.css";
const THEME_OPTIONS = [
{ label: t`Light`, value: "light" },
{ label: t`Dark`, value: "night" },
{ label: t`Transparent`, value: "transparent" },
];
export const DashboardThemeSelector = ({ value, onChange, className }) => {
return (
<select
value={value}
onChange={e => onChange(e.target.value)}
className={className}
>
{THEME_OPTIONS.map(({ label, value: themeValue }) => (
<option value={themeValue}>{label}</option>
))}
</select>
);
};

View File

@@ -0,0 +1,39 @@
import {
PublicDashboard,
} from "metabase-embedding-sdk";
import { ErrorBoundary } from "react-error-boundary";
import { DashboardOptionToggleSection } from "../DashboardOptionToggleSection/DashboardOptionToggleSection";
import { useState } from "react";
export const DashboardPage = () => {
const [options, setOptions] = useState({
theme: "light",
titled: false,
});
const dashboardId = "f8c9e612-def0-4e8e-9cfc-a81653e40b5e";
return (
<ErrorBoundary fallback={<div>something went wrong</div>}>
<div className="tw-bg-gray-800">
<DashboardOptionToggleSection
dashboardId={dashboardId}
currentOptions={options}
setOptions={newValue =>
setOptions({
...options,
...newValue,
})
}
/>
<div className="tw-p-12 tw-z-0">
<PublicDashboard
uuid={dashboardId}
embedOptions={options}
/>
</div>
</div>
</ErrorBoundary>
);
};

View File

@@ -0,0 +1 @@
export * from "./DashboardPage"

View File

@@ -0,0 +1,11 @@
.FontSelector--container {
@apply tw-flex tw-flex-row tw-justify-center tw-items-center tw-gap-4;
}
.FontSelector--label {
@apply tw-font-bold tw-text-lg;
}
.FontSelector-button {
@apply tw-bg-gray-600 tw-text-white tw-font-bold tw-py-2 tw-px-5 tw-rounded;
}

View File

@@ -0,0 +1,25 @@
import { useAvailableFonts } from "metabase-embedding-sdk";
import "./FontSelector.css";
export const FontSelector = () => {
const { availableFonts: fonts, currentFont, setFont } = useAvailableFonts();
return (
<div className="FontSelector--container">
<label className="FontSelector--label">Select a font:</label>
<select
value={currentFont}
onChange={e => setFont(e.target.value)}
className="FontSelector-button"
style={{
fontFamily: currentFont,
}}
>
{fonts?.map(font => (
<option value={font}>{font}</option>
))}
</select>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./FontSelector"

View File

@@ -0,0 +1,3 @@
.Logout--container {
@apply tw-flex tw-flex-row tw-items-center tw-gap-4;
}

View File

@@ -0,0 +1,35 @@
import { useNavigate } from "react-router-dom";
import { AUTH_API_HOST } from "../config";
import { UserInfo } from "../UserInfo";
import { UserDropdown } from "../UserDropdown";
import "./Logout.css";
export const LogoutButton = () => {
const navigate = useNavigate();
const onLogout = e => {
e.preventDefault();
return fetch(`${AUTH_API_HOST}/logout`, {
method: "GET",
})
.then(response => {
console.log(response);
if (response.status === 200) {
console.log(response.status);
navigate("/");
}
})
.catch(error => {
console.error("Error:", error);
});
};
return (
<div className="Logout--container">
<UserInfo />
<UserDropdown onClick={onLogout} />
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./Logout"

View File

@@ -0,0 +1,11 @@
.Page--container {
@apply tw-flex tw-bg-gray-800 tw-flex-col tw-h-screen tw-text-white;
}
.Page--header {
@apply tw-flex tw-flex-row tw-items-center tw-justify-between tw-py-4 tw-px-8 tw-bg-gray-900 tw-shadow-2xl tw-z-50;
}
.Page--footer {
@apply tw-bg-gray-900 tw-flex tw-flex-row tw-justify-between tw-p-4 tw-z-50;
}

View File

@@ -0,0 +1,48 @@
import { Welcome } from "../Welcome";
import { QuestionSearchDropdown } from "../QuestionSearchDropdown";
import { useState } from "react";
import { QueryVisualizationSdk } from "metabase-embedding-sdk";
import { FontSelector } from "../FontSelector";
import { LogoutButton } from "../Logout";
import { ShowVisualizationToggle } from "../ShowVisualizationToggle";
import "./Page.css";
import { StyleLeakFlag } from "../StyleLeakFlag/StyleLeakFlag";
export const Page = () => {
const [question, setQuestion] = useState(null);
const [showVisualizationSelector, setShowVisualizationSelector] =
useState(false);
return (
<div className="tw-h-full tw-w-full tw-flex tw-flex-col">
<div className="tw-p-5">
<QuestionSearchDropdown
selectedQuestion={question}
setSelectedQuestion={setQuestion}
/>
</div>
<div className="tw-flex-1">
{question ? (
<div className="tw-w-full tw-h-full tw-flex tw-flex-col">
<ShowVisualizationToggle
onClick={() =>
setShowVisualizationSelector(!showVisualizationSelector)
}
showVisualizationSelector={showVisualizationSelector}
question={question}
/>
<QueryVisualizationSdk
showVisualizationSelector={showVisualizationSelector}
questionId={question.id}
/>
</div>
) : (
<div className="tw-grid tw-place-items-center tw-h-full tw-font-bold tw-text-gray-400 tw-text-3xl">
Select a question to display here.
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./Page"

View File

@@ -0,0 +1,11 @@
.QuestionSearchBar--container {
@apply tw-flex tw-flex-row tw-justify-center tw-items-center tw-gap-4;
}
.QuestionSearchBar--input {
@apply tw-bg-gray-600 tw-text-white tw-font-bold tw-py-2 tw-px-5 tw-rounded tw-w-full;
}
.QuestionSearchBar--button {
@apply tw-bg-blue-500 tw-text-white tw-p-2 tw-rounded-md;
}

View File

@@ -0,0 +1,23 @@
import "./QuestionSearchBar.css"
export function QuestionSearchBar({ onChange, onClick, onFocus, value }) {
return (
<div className="QuestionSearchBar--container">
<input
className="QuestionSearchBar--input"
type="text"
placeholder="Search for a question"
value={value}
onChange={onChange}
onFocus={onFocus} // Show dropdown on input focus
/>
<button
className="QuestionSearchBar--button"
type="button"
onClick={onClick}
>
Search
</button>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./QuestionSearchBar"

View File

@@ -0,0 +1,65 @@
import { useQuestionSearch } from "metabase-embedding-sdk";
import { useCallback, useEffect, useRef, useState } from "react";
import { ResultsDropdown } from "./ResultsDropdown";
import { QuestionSearchBar } from "./QuestionSearchBar";
export const QuestionSearchDropdown = ({
selectedQuestion,
setSelectedQuestion,
}) => {
const [searchTerm, setSearchTerm] = useState("");
const [inputValue, setInputValue] = useState("");
const ref = useRef();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const { data, isLoading } = useQuestionSearch(searchTerm);
const showDropdown = () => {
setIsDropdownVisible(true);
};
const hideDropdown = () => {
setIsDropdownVisible(false);
};
// Close dropdown when user clicks outside the component
const handleClickOutside = useCallback(event => {
if (ref.current && !ref.current.contains(event.target)) {
hideDropdown();
}
}, []);
// Add event listeners for clicking outside the component
useEffect(() => {
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [handleClickOutside]);
return (
<div className="tw-relative" ref={ref}>
<QuestionSearchBar
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onFocus={showDropdown}
onClick={() => {
setSearchTerm(inputValue);
showDropdown();
}}
/>
{isDropdownVisible && !isLoading && data && data.length > 0 && (
<ResultsDropdown
data={data}
selectedQuestion={selectedQuestion}
setSelectedQuestion={(q) => {
setSelectedQuestion(q);
hideDropdown();
}}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,11 @@
.ResultsDropdown--container {
@apply tw-absolute tw-z-50 tw-w-96 tw-h-96 tw-left-0 tw-overflow-scroll tw-shadow-2xl tw-border tw-border-gray-500 tw-bg-gray-600 tw-rounded tw-gap-1;
}
.ResultsDropdown--result-list {
@apply tw-p-2 tw-flex tw-flex-col tw-gap-2;
}
.ResultsDropdown--list-item {
@apply tw-px-4 tw-py-1 tw-flex tw-flex-row tw-w-full tw-rounded;
}

View File

@@ -0,0 +1,29 @@
import "./ResultsDropdown.css"
export function ResultsDropdown({
data,
selectedQuestion,
setSelectedQuestion,
}) {
return (
<div className="ResultsDropdown--container">
<ul className="ResultsDropdown--result-list">
{data.map(q => (
<li
key={q.id}
onClick={() => {
setSelectedQuestion(q);
}}
className={`ResultsDropdown--list-item ${
selectedQuestion?.id === q.id
? "tw-bg-blue-500"
: "hover:tw-cursor-pointer hover:tw-bg-gray-400 hover:tw-font-bold"
}`}
>
{q.name}
</li>
))}
</ul>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./ResultsDropdown";

View File

@@ -0,0 +1,3 @@
export * from "./QuestionSearchDropdown"
export * from "./QuestionSearchBar/QuestionSearchBar"
export * from "./ResultsDropdown/ResultsDropdown"

View File

@@ -0,0 +1,11 @@
.ShowVisualizationToggle--container {
@apply tw-flex tw-flex-row tw-items-center tw-px-4 tw-gap-4 tw-py-4;
}
.ShowVisualizationToggle--button {
@apply tw-rounded-full tw-w-6 tw-h-6 tw-bg-sky-500 tw-border-white tw-border tw-font-bold;
}
.ShowVisualizationToggle--question-name {
@apply tw-font-black tw-text-lg;
}

View File

@@ -0,0 +1,17 @@
import "./ShowVisualizationToggle.css";
export function ShowVisualizationToggle({
onClick,
question,
showVisualizationSelector,
}) {
return (
<div className="ShowVisualizationToggle--container">
<button onClick={onClick} className="ShowVisualizationToggle--button">
{showVisualizationSelector ? "←" : "→"}
</button>
<div className="ShowVisualizationToggle--question-name">
{question.name}
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export * from "./ShowVisualizationToggle"

View File

@@ -0,0 +1,27 @@
input.SignIn--input {
@apply tw-py-2 tw-px-3 tw-border tw-border-gray-200 tw-rounded tw-bg-gray-100;
}
.SignIn--form {
@apply tw-flex tw-flex-col tw-gap-8;
}
.SignIn--form > div {
@apply tw-flex tw-flex-col tw-justify-start tw-items-start;
}
.SignIn--form > div > label {
@apply tw-text-gray-600 tw-font-bold;
}
.SignIn--screen {
@apply tw-grid tw-place-items-center tw-h-screen;
}
.SignIn--container {
@apply tw-flex tw-flex-col tw-gap-8 tw-items-center tw-bg-white tw-shadow-2xl tw-border tw-border-gray-200 tw-p-8 tw-rounded-2xl tw-w-96 tw-h-96;
}
.SignIn--submit {
@apply tw-py-2 tw-px-5 tw-border tw-border-gray-200 tw-rounded tw-bg-sky-500 tw-font-bold tw-text-white hover:tw-bg-sky-600;
}

Some files were not shown because too many files have changed in this diff Show More