Compare commits
55 Commits
view-count
...
hackaghon/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca963d0eb | ||
|
|
7eeae6f210 | ||
|
|
cb53be1b17 | ||
|
|
324dd8cf9a | ||
|
|
bfb46008a9 | ||
|
|
2cb07d3734 | ||
|
|
3984950693 | ||
|
|
5e6b42b4ee | ||
|
|
e8813d5d09 | ||
|
|
1ed70d8c6b | ||
|
|
da41baa8c5 | ||
|
|
ba5940934d | ||
|
|
27007c93ef | ||
|
|
2c8396318c | ||
|
|
5b14bfa69a | ||
|
|
a03ffb1059 | ||
|
|
1a16919b24 | ||
|
|
10df8c09f3 | ||
|
|
a82d370f78 | ||
|
|
2228762fa2 | ||
|
|
c129197c6a | ||
|
|
3502e49eb3 | ||
|
|
6f37b13e85 | ||
|
|
96a76bf8d0 | ||
|
|
66aa4e7da3 | ||
|
|
b7b7d56ab7 | ||
|
|
69700f56fd | ||
|
|
97d9792dda | ||
|
|
52771825f6 | ||
|
|
51d4b37074 | ||
|
|
d7a37b178c | ||
|
|
2a0f647c31 | ||
|
|
efa83b6265 | ||
|
|
f9a3177314 | ||
|
|
ac53cf512f | ||
|
|
b10d5dab59 | ||
|
|
ac9b9620d8 | ||
|
|
d34503373d | ||
|
|
71952ab1a3 | ||
|
|
9bdd7e926c | ||
|
|
eb8e199625 | ||
|
|
3718f2d3ae | ||
|
|
b704429325 | ||
|
|
7653d2fe06 | ||
|
|
27b7ade789 | ||
|
|
7f38319360 | ||
|
|
f612a5e5e7 | ||
|
|
1bb09a7c4c | ||
|
|
470cc7d9b1 | ||
|
|
a4599b3432 | ||
|
|
17aa273aea | ||
|
|
95c356ec88 | ||
|
|
5330bb6b2f | ||
|
|
f451285bef | ||
|
|
25cd3c4f54 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
[_]
|
||||
|
||||
8
frontend/src/embedding-sdk/README.md
Normal file
8
frontend/src/embedding-sdk/README.md
Normal 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.
|
||||
31
frontend/src/embedding-sdk/SdkEmotionCacheProvider.tsx
Normal file
31
frontend/src/embedding-sdk/SdkEmotionCacheProvider.tsx
Normal 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>;
|
||||
};
|
||||
26
frontend/src/embedding-sdk/SdkGlobalStyles.tsx
Normal file
26
frontend/src/embedding-sdk/SdkGlobalStyles.tsx
Normal 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} />;
|
||||
};
|
||||
111
frontend/src/embedding-sdk/SdkProvider.tsx
Normal file
111
frontend/src/embedding-sdk/SdkProvider.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import styled from "@emotion/styled";
|
||||
import type * as React from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
import { Provider } from "react-redux";
|
||||
import { alpha, color } from "metabase/lib/colors";
|
||||
import { aceEditorStyles } from "metabase/query_builder/components/NativeQueryEditor/NativeQueryEditor.styled";
|
||||
import reducers from "metabase/reducers-main";
|
||||
import { setOptions } from "metabase/redux/embed";
|
||||
import { getStore } from "metabase/store";
|
||||
import { ThemeProvider } from "metabase/ui/components/theme/ThemeProvider";
|
||||
import { saveDomImageStyles } from "metabase/visualizations/lib/save-chart-image";
|
||||
|
||||
import type { SDKConfigType } from "./config";
|
||||
import { SDK_CONTEXT_CLASS_NAME } from "./config";
|
||||
import { EmbeddingContext } from "./context";
|
||||
import { SdkEmotionCacheProvider } from "./SdkEmotionCacheProvider";
|
||||
|
||||
import type { SDKPlugin } from "./plugins";
|
||||
import { COMPUTED_SDK_PLUGINS, mergePlugins } from "./plugins";
|
||||
|
||||
import { useInitData } from "./hooks";
|
||||
import "./styles.css";
|
||||
|
||||
const MetabaseProviderInternal = ({
|
||||
children,
|
||||
config,
|
||||
plugins,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
config: SDKConfigType;
|
||||
plugins: SDKPlugin[];
|
||||
}): JSX.Element => {
|
||||
const store = getStore(reducers);
|
||||
|
||||
useEffect(() => {
|
||||
if (window.location.hostname === "localhost") {
|
||||
(window as any).metaReduxStore = store;
|
||||
}
|
||||
}, [store]);
|
||||
|
||||
const [font, setFont] = useState<string>(config.font ?? "Lato");
|
||||
|
||||
useEffect(() => {
|
||||
// this should probably be saved in a context, not in a global variable
|
||||
COMPUTED_SDK_PLUGINS.current = mergePlugins(plugins);
|
||||
}, [plugins]);
|
||||
|
||||
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;
|
||||
`;
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Button, Text } from "metabase/ui";
|
||||
|
||||
export const NotLoggedInBlock = () => {
|
||||
return (
|
||||
<div>
|
||||
<Text>You should be logged in to see this content.</Text>
|
||||
<Button>Log in</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./PublicDashboard";
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useEffect } from "react";
|
||||
import type { CardId } from "metabase-types/api";
|
||||
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import {
|
||||
initializeQB,
|
||||
navigateToNewCardInsideQB,
|
||||
updateQuestion,
|
||||
} from "metabase/query_builder/actions";
|
||||
import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
|
||||
import { FilterHeader } from "metabase/query_builder/components/view/ViewHeader/components";
|
||||
import {
|
||||
getCard,
|
||||
getFirstQueryResult,
|
||||
getMode,
|
||||
getQuestion,
|
||||
getUiControls,
|
||||
} from "metabase/query_builder/selectors";
|
||||
import { Button, Group, Stack } from "metabase/ui";
|
||||
|
||||
import { useEmbeddingContext } from "../../../hooks/private/use-sdk-context";
|
||||
import type { SDKContext } from "../../../plugins";
|
||||
import { COMPUTED_SDK_PLUGINS } from "../../../plugins";
|
||||
import { NotLoggedInBlock } from "../NotLoggedInBlock";
|
||||
|
||||
interface InteractiveQuestionProps {
|
||||
questionId: CardId;
|
||||
}
|
||||
|
||||
export const InteractiveQuestionSdk = (
|
||||
props: InteractiveQuestionProps,
|
||||
): JSX.Element | null => {
|
||||
const { isInitialized, isLoggedIn } = useEmbeddingContext();
|
||||
const dispatch = useDispatch();
|
||||
const mode = useSelector(getMode);
|
||||
const question = useSelector(getQuestion);
|
||||
const card = useSelector(getCard);
|
||||
const result = useSelector(getFirstQueryResult);
|
||||
const uiControls = useSelector(getUiControls);
|
||||
|
||||
const { questionId } = props;
|
||||
const { isRunning } = uiControls;
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: change pathname based on isInteractive value to trigger proper QB viewMode
|
||||
const mockLocation = {
|
||||
query: {}, // TODO: add here wrapped parameterValues
|
||||
hash: "",
|
||||
pathname: `/question/${questionId}`,
|
||||
};
|
||||
const params = {
|
||||
slug: `${questionId}`,
|
||||
};
|
||||
|
||||
dispatch(initializeQB(mockLocation, params));
|
||||
}, [dispatch, questionId]);
|
||||
|
||||
const appState = useSelector(state => state);
|
||||
|
||||
if (!isInitialized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return <NotLoggedInBlock />;
|
||||
}
|
||||
|
||||
const defaultActions = [
|
||||
{
|
||||
key: "download",
|
||||
label: "Download",
|
||||
icon: "download [default]",
|
||||
onClick: () => alert("Default download action"),
|
||||
},
|
||||
{
|
||||
key: "alerts",
|
||||
label: "Alerts",
|
||||
icon: "Alerts [default]",
|
||||
onClick: () => alert("Default alerts action"),
|
||||
},
|
||||
];
|
||||
|
||||
const context: SDKContext = {
|
||||
appState,
|
||||
};
|
||||
|
||||
const actions =
|
||||
// TODO: wrap in a hook
|
||||
// it should calculate the context and pass it to the plugin
|
||||
COMPUTED_SDK_PLUGINS.current.questionFooterActions(defaultActions, context);
|
||||
|
||||
return (
|
||||
<LoadingAndErrorWrapper
|
||||
className="flex-full full-width"
|
||||
loading={!result}
|
||||
error={typeof result === "string" ? result : null}
|
||||
noWrapper
|
||||
>
|
||||
{() => {
|
||||
if (!question) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack h="100%" style={{ border: "1px solid red" }}>
|
||||
<p>hello from the embed sdk</p>
|
||||
|
||||
{FilterHeader.shouldRender({
|
||||
question,
|
||||
queryBuilderMode: uiControls.queryBuilderMode,
|
||||
isObjectDetail: false,
|
||||
}) && (
|
||||
<FilterHeader
|
||||
expanded
|
||||
question={question}
|
||||
updateQuestion={(...args) => dispatch(updateQuestion(...args))}
|
||||
/>
|
||||
)}
|
||||
{actions.length > 0 && (
|
||||
<Group spacing="md">
|
||||
{actions.map(action => (
|
||||
<Button
|
||||
key={action.label}
|
||||
onClick={() => action.onClick(question)}
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Group h="100%" pos="relative" align="flex-start">
|
||||
<QueryVisualization
|
||||
className="full-width flex-full"
|
||||
question={question}
|
||||
rawSeries={[{ card, data: result && result.data }]}
|
||||
isRunning={isRunning}
|
||||
isObjectDetail={false}
|
||||
isResultDirty={false}
|
||||
isNativeEditorOpen={false}
|
||||
result={result}
|
||||
noHeader
|
||||
mode={mode}
|
||||
navigateToNewCardInsideQB={(props: any) => {
|
||||
dispatch(navigateToNewCardInsideQB(props));
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
}}
|
||||
</LoadingAndErrorWrapper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
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, Group } from "metabase/ui";
|
||||
import { PublicMode } from "metabase/visualizations/click-actions/modes/PublicMode";
|
||||
import QueryVisualization from "metabase/query_builder/components/QueryVisualization";
|
||||
import Question from "metabase-lib/Question";
|
||||
|
||||
import { NotLoggedInBlock } from "../NotLoggedInBlock";
|
||||
import { useEmbeddingContext } from "../../../hooks/private/use-sdk-context";
|
||||
|
||||
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 <NotLoggedInBlock />;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
<QueryVisualization
|
||||
className="full-width flex-full"
|
||||
question={question}
|
||||
rawSeries={[{ card, data: result && result.data }]}
|
||||
isRunning={state.loading}
|
||||
isObjectDetail={false}
|
||||
isResultDirty={false}
|
||||
isNativeEditorOpen={false}
|
||||
result={result}
|
||||
noHeader
|
||||
mode={PublicMode}
|
||||
/>
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
</LoadingAndErrorWrapper>
|
||||
);
|
||||
};
|
||||
3
frontend/src/embedding-sdk/components/public/index.ts
Normal file
3
frontend/src/embedding-sdk/components/public/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { QueryVisualizationSdk } from "./Question/QueryVisualization";
|
||||
export { InteractiveQuestionSdk } from "./Question/InteractiveQuestion";
|
||||
export * from "./PublicDashboard";
|
||||
15
frontend/src/embedding-sdk/config.ts
Normal file
15
frontend/src/embedding-sdk/config.ts
Normal 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;
|
||||
}
|
||||
);
|
||||
15
frontend/src/embedding-sdk/context.ts
Normal file
15
frontend/src/embedding-sdk/context.ts
Normal 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: () => {},
|
||||
});
|
||||
2
frontend/src/embedding-sdk/hooks/index.ts
Normal file
2
frontend/src/embedding-sdk/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./public";
|
||||
export * from "./private";
|
||||
2
frontend/src/embedding-sdk/hooks/private/index.ts
Normal file
2
frontend/src/embedding-sdk/hooks/private/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./use-sdk-context";
|
||||
export * from "./use-init-data";
|
||||
79
frontend/src/embedding-sdk/hooks/private/use-init-data.ts
Normal file
79
frontend/src/embedding-sdk/hooks/private/use-init-data.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useContext } from "react";
|
||||
import { EmbeddingContext } from "../../context";
|
||||
|
||||
export const useEmbeddingContext = () => {
|
||||
return useContext(EmbeddingContext);
|
||||
};
|
||||
5
frontend/src/embedding-sdk/hooks/public/index.ts
Normal file
5
frontend/src/embedding-sdk/hooks/public/index.ts
Normal 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";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getApplicationName } from "metabase/selectors/whitelabel";
|
||||
|
||||
export const useApplicationName = () => useSelector(getApplicationName);
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
|
||||
export const useCurrentUser = () => useSelector(getUser);
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
6
frontend/src/embedding-sdk/index.ts
Normal file
6
frontend/src/embedding-sdk/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { MetabaseProvider } from "./SdkProvider";
|
||||
|
||||
export * from "./hooks/public";
|
||||
export * from "./components/public";
|
||||
export * from "./plugins";
|
||||
export { MetabaseProvider };
|
||||
67
frontend/src/embedding-sdk/plugins.ts
Normal file
67
frontend/src/embedding-sdk/plugins.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
// NOTE:
|
||||
// right now the hook/plugins have code spread out throughout the file
|
||||
// it's probably better to have a createhook() function that defines the behaviour
|
||||
// for example the default value, the merge function, etc
|
||||
|
||||
import type { State } from "metabase-types/store";
|
||||
|
||||
// a plugin "hooks" into one or more hooks to change behaviors
|
||||
export type SDKPlugin = {
|
||||
questionFooterActions?: QuestionFooterActionExtensibilityHook;
|
||||
};
|
||||
|
||||
export type SDKContext = {
|
||||
// global context, ideally derived from redux so it can be a selector
|
||||
|
||||
// temporary, we won't send all the appState like this
|
||||
appState: State;
|
||||
};
|
||||
|
||||
const SDK_DEFAULT_PLUGIN: SDKPlugin = {
|
||||
questionFooterActions: actions => actions,
|
||||
};
|
||||
|
||||
// just to force people to have typechecking, or defaults in the future
|
||||
export const createPlugin = (
|
||||
plugins: Partial<SDKPlugin>,
|
||||
): Partial<SDKPlugin> => {
|
||||
return plugins;
|
||||
};
|
||||
|
||||
// TODO: find a better name, this just means "all the plugins merged together"
|
||||
export type ComputedPlugins = Required<SDKPlugin>;
|
||||
|
||||
export const mergePlugins = (
|
||||
plugins: Partial<SDKPlugin>[],
|
||||
): ComputedPlugins => {
|
||||
const withDefaults = [SDK_DEFAULT_PLUGIN, ...plugins];
|
||||
|
||||
return {
|
||||
questionFooterActions: (
|
||||
defaultActions: QuestionFooterAction[],
|
||||
ctx?: SDKContext,
|
||||
) =>
|
||||
withDefaults.reduce(
|
||||
(acc, plugin) =>
|
||||
plugin.questionFooterActions
|
||||
? plugin.questionFooterActions(acc, ctx)
|
||||
: acc,
|
||||
defaultActions,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const COMPUTED_SDK_PLUGINS: { current: ComputedPlugins } = {
|
||||
current: mergePlugins([]),
|
||||
};
|
||||
|
||||
type QuestionFooterAction = {
|
||||
label: string;
|
||||
icon: string;
|
||||
onClick: (question: any) => void;
|
||||
};
|
||||
|
||||
type QuestionFooterActionExtensibilityHook = (
|
||||
actions: QuestionFooterAction[],
|
||||
ctx?: SDKContext,
|
||||
) => QuestionFooterAction[];
|
||||
1
frontend/src/embedding-sdk/styles.css
Normal file
1
frontend/src/embedding-sdk/styles.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "../metabase/css/index.css";
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -30,5 +30,6 @@ export const createMockState = (
|
||||
settings: createMockSettingsState(),
|
||||
setup: createMockSetupState(),
|
||||
upload: createMockUploadState(),
|
||||
public: null,
|
||||
...opts,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
38
frontend/src/metabase/css/core/base-sdk.css
Normal file
38
frontend/src/metabase/css/core/base-sdk.css
Normal 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;
|
||||
}
|
||||
27
frontend/src/metabase/css/core/index-sdk.css
Normal file
27
frontend/src/metabase/css/core/index-sdk.css
Normal 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";
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
// }
|
||||
|
||||
@@ -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)
|
||||
@@ -194,6 +181,7 @@ function EmbedFrame({
|
||||
<Body>{children}</Body>
|
||||
</ContentContainer>
|
||||
{showFooter && (
|
||||
// this footer
|
||||
<Footer className="EmbedFrame-footer" variant={footerVariant}>
|
||||
{hasEmbedBranding && (
|
||||
<LogoBadge variant={footerVariant} dark={theme === "night"} />
|
||||
@@ -208,4 +196,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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
258
frontend/src/metabase/public/containers/PublicDashboard.tsx
Normal file
258
frontend/src/metabase/public/containers/PublicDashboard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -210,6 +210,7 @@ class PublicQuestionInner extends Component {
|
||||
|
||||
return (
|
||||
<EmbedFrame
|
||||
location={this.props.location}
|
||||
name={card && card.name}
|
||||
description={card && card.description}
|
||||
actionButtons={actionButtons}
|
||||
|
||||
72
frontend/src/metabase/public/reducers.ts
Normal file
72
frontend/src/metabase/public/reducers.ts
Normal 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 };
|
||||
@@ -73,6 +73,7 @@ const QueryDownloadWidget = ({
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip tooltip={t`Download full results`}>
|
||||
<h1>QueryDownloaDWidget.tsx</h1>
|
||||
<DownloadIcon
|
||||
onClick={() => setIsPopoverOpen(!isPopoverOpen)}
|
||||
name="download"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -154,7 +154,7 @@ export default class ChoroplethMap extends Component {
|
||||
this.setState({
|
||||
geoJson: geoJson,
|
||||
geoJsonPath: geoJsonPath,
|
||||
minimalBounds: computeMinimalBounds(geoJson.features),
|
||||
minimalBounds: computeMinimalBounds(geoJson?.features ?? []),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 || []) {
|
||||
|
||||
4
host-app-example/.env.example
Normal file
4
host-app-example/.env.example
Normal 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
24
host-app-example/.gitignore
vendored
Normal 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
|
||||
49
host-app-example/package.json
Normal file
49
host-app-example/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
host-app-example/public/favicon.ico
Normal file
BIN
host-app-example/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.8 KiB |
43
host-app-example/public/index.html
Normal file
43
host-app-example/public/index.html
Normal 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>
|
||||
BIN
host-app-example/public/logo192.png
Normal file
BIN
host-app-example/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
host-app-example/public/logo512.png
Normal file
BIN
host-app-example/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
25
host-app-example/public/manifest.json
Normal file
25
host-app-example/public/manifest.json
Normal 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"
|
||||
}
|
||||
3
host-app-example/public/robots.txt
Normal file
3
host-app-example/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
83
host-app-example/src/App.js
Normal file
83
host-app-example/src/App.js
Normal file
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
MetabaseProvider,
|
||||
createPlugin,
|
||||
SDKContext,
|
||||
} 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} plugins={plugins}>
|
||||
<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;
|
||||
|
||||
const addCustomShare = createPlugin({
|
||||
questionFooterActions: (actions = [], ctx) => {
|
||||
return [
|
||||
...actions,
|
||||
{
|
||||
name: "Share",
|
||||
icon: "sharing-icon",
|
||||
onClick: () => {
|
||||
const filter = ctx.appState.qb.card.dataset_query.query.filter;
|
||||
|
||||
console.log("Sharing from the plugin", ctx);
|
||||
alert("Sharing from the plugin\n" + JSON.stringify(filter, null, 2));
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const hideQuestionFooterPlugin = createPlugin({
|
||||
questionFooterActions: () => [],
|
||||
});
|
||||
|
||||
const hideAlerts = createPlugin({
|
||||
questionFooterActions: (actions = []) => {
|
||||
return actions.filter(action => action.key !== "alerts");
|
||||
},
|
||||
});
|
||||
|
||||
const plugins = [
|
||||
//
|
||||
hideQuestionFooterPlugin,
|
||||
addCustomShare,
|
||||
// hideAlerts,
|
||||
];
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
39
host-app-example/src/DashboardPage/DashboardPage.js
Normal file
39
host-app-example/src/DashboardPage/DashboardPage.js
Normal 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>
|
||||
);
|
||||
};
|
||||
1
host-app-example/src/DashboardPage/index.js
Normal file
1
host-app-example/src/DashboardPage/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./DashboardPage"
|
||||
11
host-app-example/src/FontSelector/FontSelector.css
Normal file
11
host-app-example/src/FontSelector/FontSelector.css
Normal 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;
|
||||
}
|
||||
25
host-app-example/src/FontSelector/FontSelector.js
Normal file
25
host-app-example/src/FontSelector/FontSelector.js
Normal 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>
|
||||
);
|
||||
};
|
||||
1
host-app-example/src/FontSelector/index.js
Normal file
1
host-app-example/src/FontSelector/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./FontSelector"
|
||||
3
host-app-example/src/Logout/Logout.css
Normal file
3
host-app-example/src/Logout/Logout.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.Logout--container {
|
||||
@apply tw-flex tw-flex-row tw-items-center tw-gap-4;
|
||||
}
|
||||
35
host-app-example/src/Logout/Logout.js
Normal file
35
host-app-example/src/Logout/Logout.js
Normal 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>
|
||||
);
|
||||
};
|
||||
1
host-app-example/src/Logout/index.js
Normal file
1
host-app-example/src/Logout/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Logout"
|
||||
11
host-app-example/src/Page/Page.css
Normal file
11
host-app-example/src/Page/Page.css
Normal 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;
|
||||
}
|
||||
52
host-app-example/src/Page/Page.js
Normal file
52
host-app-example/src/Page/Page.js
Normal file
@@ -0,0 +1,52 @@
|
||||
import { InteractiveQuestionSdk } from "metabase-embedding-sdk";
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { QuestionSearchDropdown } from "../QuestionSearchDropdown";
|
||||
import { ShowVisualizationToggle } from "../ShowVisualizationToggle";
|
||||
|
||||
import "./Page.css";
|
||||
|
||||
export const Page = () => {
|
||||
let { questionId } = useParams();
|
||||
|
||||
const [question, setQuestion] = useState(
|
||||
questionId ? { id: questionId } : null,
|
||||
);
|
||||
const [showVisualizationSelector, setShowVisualizationSelector] =
|
||||
useState(false);
|
||||
|
||||
console.log("question", question);
|
||||
|
||||
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">
|
||||
<h1>hello from host app</h1>
|
||||
{question ? (
|
||||
<div className="tw-w-full tw-h-full tw-flex tw-flex-col">
|
||||
<ShowVisualizationToggle
|
||||
onClick={() =>
|
||||
setShowVisualizationSelector(!showVisualizationSelector)
|
||||
}
|
||||
showVisualizationSelector={showVisualizationSelector}
|
||||
question={question}
|
||||
/>
|
||||
<InteractiveQuestionSdk
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
1
host-app-example/src/Page/index.js
Normal file
1
host-app-example/src/Page/index.js
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Page"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./QuestionSearchBar"
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ResultsDropdown";
|
||||
3
host-app-example/src/QuestionSearchDropdown/index.js
Normal file
3
host-app-example/src/QuestionSearchDropdown/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./QuestionSearchDropdown"
|
||||
export * from "./QuestionSearchBar/QuestionSearchBar"
|
||||
export * from "./ResultsDropdown/ResultsDropdown"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import "./ShowVisualizationToggle.css";
|
||||
export function ShowVisualizationToggle({
|
||||
onClick,
|
||||
question,
|
||||
showVisualizationSelector,
|
||||
}) {
|
||||
return (
|
||||
<div className="ShowVisualizationToggle--container" classNames="flex">
|
||||
<button onClick={onClick} className="ShowVisualizationToggle--button">
|
||||
{showVisualizationSelector ? "←" : "→"}
|
||||
</button>
|
||||
<div className="ShowVisualizationToggle--question-name">
|
||||
{question.name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user