Skip to content
Snippets Groups Projects
Unverified Commit 3c16a1bd authored by Oisin Coveney's avatar Oisin Coveney Committed by GitHub
Browse files

Add API endpoints for public + embedded entities (#48290)

parent 40ed53ef
Branches
Tags
No related merge requests found
Showing
with 481 additions and 278 deletions
......@@ -170,3 +170,7 @@ export interface WritebackActionListQuery {
export interface GetActionRequest {
id: number;
}
export type GetPublicAction = Pick<
WritebackActionBase,
"id" | "name" | "public_uuid" | "model_id"
>;
......@@ -350,3 +350,8 @@ export type CardQueryRequest = {
ignore_cache?: boolean;
parameters?: unknown[];
};
export type GetPublicOrEmbeddableCard = Pick<
Card,
"id" | "name" | "public_uuid"
>;
......@@ -289,3 +289,8 @@ export type CopyDashboardRequest = {
collection_position?: number | null;
is_deep_copy?: boolean | null;
};
export type GetPublicOrEmbeddableDashboard = Pick<
Dashboard,
"id" | "name" | "public_uuid"
>;
import cx from "classnames";
import { t } from "ttag";
import {
useListEmbeddableCardsQuery,
useListEmbeddableDashboardsQuery,
} from "metabase/api";
import CS from "metabase/css/core/index.css";
import * as Urls from "metabase/lib/urls";
import { Stack, Text } from "metabase/ui";
import type {
GetPublicOrEmbeddableCard,
GetPublicOrEmbeddableDashboard,
} from "metabase-types/api";
import { PublicLinksListing } from "./PublicLinksListing";
const DashboardEmbeddedResources = () => {
const query = useListEmbeddableDashboardsQuery();
return (
<div>
<Text mb="sm">{t`Embedded Dashboards`}</Text>
<div
className={cx(CS.bordered, CS.rounded, CS.full)}
style={{ maxWidth: 820 }}
>
<PublicLinksListing<GetPublicOrEmbeddableDashboard>
data-testid="-embedded-dashboards-setting"
getUrl={dashboard => Urls.dashboard(dashboard)}
noLinksMessage={t`No dashboards have been embedded yet.`}
{...query}
/>
</div>
</div>
);
};
export const QuestionEmbeddedResources = () => {
const query = useListEmbeddableCardsQuery();
return (
<div>
<Text mb="sm">{t`Embedded Questions`}</Text>
<div
className={cx(CS.bordered, CS.rounded, CS.full)}
style={{ maxWidth: 820 }}
>
<PublicLinksListing<GetPublicOrEmbeddableCard>
data-testid="-embedded-questions-setting"
getUrl={question => Urls.question(question)}
noLinksMessage={t`No questions have been embedded yet.`}
{...query}
/>
</div>
</div>
);
};
export const EmbeddedResources = () => {
return (
<Stack spacing="md" className={CS.flexFull}>
<DashboardEmbeddedResources />
<QuestionEmbeddedResources />
</Stack>
);
};
/* eslint-disable react/prop-types */
import cx from "classnames";
import { Component } from "react";
import { connect } from "react-redux";
import { t } from "ttag";
import Confirm from "metabase/components/Confirm";
import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
import ExternalLink from "metabase/core/components/ExternalLink";
import Link from "metabase/core/components/Link";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import * as Urls from "metabase/lib/urls";
import { getSetting } from "metabase/selectors/settings";
import { ActionsApi, CardApi, DashboardApi } from "metabase/services";
import { Icon, Stack, Text } from "metabase/ui";
import { RevokeIconWrapper } from "./PublicLinksListing.styled";
class PublicLinksListing extends Component {
constructor(props) {
super(props);
this.state = {
list: null,
error: null,
};
}
componentDidMount() {
this.load();
}
async load() {
try {
const list = await this.props.load();
this.setState({ list });
} catch (error) {
this.setState({ error });
}
}
async revoke(link) {
if (!this.props.revoke) {
return;
}
try {
await this.props.revoke(link);
this.load();
} catch (error) {
alert(error);
}
}
render() {
const { getUrl, getPublicUrl, revoke, noLinksMessage } = this.props;
let { list, error } = this.state;
if (list && list.length === 0) {
error = new Error(noLinksMessage);
}
return (
<LoadingAndErrorWrapper loading={!list} error={error}>
{() => (
<table
data-testId={this.props["data-testId"]}
className={AdminS.ContentTable}
>
<thead>
<tr>
<th>{t`Name`}</th>
{getPublicUrl && <th>{t`Public Link`}</th>}
{revoke && <th>{t`Revoke Link`}</th>}
</tr>
</thead>
<tbody>
{list &&
list.map(link => (
<tr key={link.id}>
<td>
{getUrl ? (
<Link to={getUrl(link)} className={CS.textWrap}>
{link.name}
</Link>
) : (
link.name
)}
</td>
{getPublicUrl && (
<td>
<ExternalLink
href={getPublicUrl(link)}
onClick={() => this.trackEvent("Public Link Clicked")}
className={cx(CS.link, CS.textWrap)}
>
{getPublicUrl(link)}
</ExternalLink>
</td>
)}
{revoke && (
<td className={cx(CS.flex, CS.layoutCentered)}>
<Confirm
title={t`Disable this link?`}
content={t`They won't work anymore, and can't be restored, but you can create new links.`}
action={() => {
this.revoke(link);
this.trackEvent("Revoked link");
}}
>
<RevokeIconWrapper
name="close"
aria-label={t`Revoke link`}
>
<Icon name="close" />
</RevokeIconWrapper>
</Confirm>
</td>
)}
</tr>
))}
</tbody>
</table>
)}
</LoadingAndErrorWrapper>
);
}
}
export const PublicLinksDashboardListing = () => (
<PublicLinksListing
load={DashboardApi.listPublic}
revoke={DashboardApi.deletePublicLink}
type={t`Public Dashboard Listing`}
getUrl={dashboard => Urls.dashboard(dashboard)}
getPublicUrl={({ public_uuid }) => Urls.publicDashboard(public_uuid)}
noLinksMessage={t`No dashboards have been publicly shared yet.`}
/>
);
export const PublicLinksQuestionListing = () => (
<PublicLinksListing
load={CardApi.listPublic}
revoke={CardApi.deletePublicLink}
type={t`Public Card Listing`}
getUrl={question => Urls.question(question)}
getPublicUrl={({ public_uuid }) =>
Urls.publicQuestion({ uuid: public_uuid })
}
noLinksMessage={t`No questions have been publicly shared yet.`}
/>
);
const mapStateToProps = state => ({
siteUrl: getSetting(state, "site-url"),
});
export const PublicLinksActionListing = connect(mapStateToProps)(
function PublicLinksActionListing({ siteUrl }) {
return (
<PublicLinksListing
load={ActionsApi.listPublic}
revoke={ActionsApi.deletePublicLink}
type={t`Public Action Form Listing`}
getUrl={action => Urls.action({ id: action.model_id }, action.id)}
getPublicUrl={({ public_uuid }) =>
Urls.publicAction(siteUrl, public_uuid)
}
noLinksMessage={t`No actions have been publicly shared yet.`}
/>
);
},
);
export const EmbeddedResources = () => (
<Stack spacing="md" className={CS.flexFull}>
<div>
<Text mb="sm">{t`Embedded Dashboards`}</Text>
<div
className={cx(CS.bordered, CS.rounded, CS.full)}
style={{ maxWidth: 820 }}
>
<PublicLinksListing
data-testId="-embedded-dashboards-setting"
load={DashboardApi.listEmbeddable}
getUrl={dashboard => Urls.dashboard(dashboard)}
type={t`Embedded Dashboard Listing`}
noLinksMessage={t`No dashboards have been embedded yet.`}
/>
</div>
</div>
<div>
<Text mb="sm">{t`Embedded Questions`}</Text>
<div
className={cx(CS.bordered, CS.rounded, CS.full)}
style={{ maxWidth: 820 }}
>
<PublicLinksListing
data-testId="-embedded-questions-setting"
load={CardApi.listEmbeddable}
getUrl={question => Urls.question(question)}
type={t`Embedded Card Listing`}
noLinksMessage={t`No questions have been embedded yet.`}
/>
</div>
</div>
</Stack>
);
import styled from "@emotion/styled";
import IconButtonWrapper from "metabase/components/IconButtonWrapper";
export const RevokeIconWrapper = styled(IconButtonWrapper)`
color: var(--mb-color-text-light);
padding: 0;
&:hover {
color: var(--mb-color-text-medium);
}
`;
import cx from "classnames";
import { t } from "ttag";
import Confirm from "metabase/components/Confirm";
import ExternalLink from "metabase/core/components/ExternalLink";
import Link from "metabase/core/components/Link";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import { ActionIcon, Icon, Loader } from "metabase/ui";
export const PublicLinksListing = <
T extends { id: string | number; name: string },
>({
isLoading,
data = [],
revoke,
getUrl,
getPublicUrl,
noLinksMessage,
"data-testid": dataTestId,
}: {
data?: T[];
isLoading: boolean;
revoke?: (item: T) => Promise<unknown>;
getUrl: (item: T) => string;
getPublicUrl?: (item: T) => string | null;
noLinksMessage: string;
"data-testid"?: string;
}) => {
if (isLoading) {
return <Loader />;
}
if (data.length === 0) {
return noLinksMessage;
}
return (
<table data-testid={dataTestId} className={AdminS.ContentTable}>
<thead>
<tr>
<th>{t`Name`}</th>
{getPublicUrl && <th>{t`Public Link`}</th>}
{revoke && <th>{t`Revoke Link`}</th>}
</tr>
</thead>
<tbody>
{data.map(item => {
const internalUrl = getUrl?.(item);
const publicUrl = getPublicUrl?.(item);
return (
<tr key={item.id}>
<td>
{internalUrl ? (
<Link to={internalUrl} className={CS.textWrap}>
{item.name}
</Link>
) : (
item.name
)}
</td>
{publicUrl && (
<td>
<ExternalLink
href={publicUrl}
className={cx(CS.link, CS.textWrap)}
>
{publicUrl}
</ExternalLink>
</td>
)}
{revoke && (
<td className={cx(CS.flex, CS.layoutCentered)}>
<Confirm
title={t`Disable this link?`}
content={t`They won't work anymore, and can't be restored, but you can create new links.`}
action={async () => {
await revoke(item);
}}
>
<ActionIcon aria-label={t`Revoke link`}>
<Icon name="close" />
</ActionIcon>
</Confirm>
</td>
)}
</tr>
);
})}
</tbody>
</table>
);
};
import { t } from "ttag";
import {
useDeleteActionPublicLinkMutation,
useDeleteCardPublicLinkMutation,
useDeleteDashboardPublicLinkMutation,
useListPublicActionsQuery,
useListPublicCardsQuery,
useListPublicDashboardsQuery,
} from "metabase/api";
import { useSetting } from "metabase/common/hooks";
import * as Urls from "metabase/lib/urls";
import type {
GetPublicAction,
GetPublicOrEmbeddableCard,
GetPublicOrEmbeddableDashboard,
} from "metabase-types/api";
import { PublicLinksListing } from "./PublicLinksListing";
export const PublicLinksDashboardListing = () => {
const query = useListPublicDashboardsQuery();
const [revoke] = useDeleteDashboardPublicLinkMutation();
return (
<PublicLinksListing<GetPublicOrEmbeddableDashboard>
revoke={revoke}
getUrl={dashboard => Urls.dashboard(dashboard)}
getPublicUrl={({ public_uuid }: GetPublicOrEmbeddableDashboard) => {
if (public_uuid) {
return Urls.publicDashboard(public_uuid);
}
return null;
}}
noLinksMessage={t`No dashboards have been publicly shared yet.`}
{...query}
/>
);
};
export const PublicLinksQuestionListing = () => {
const query = useListPublicCardsQuery();
const [revoke] = useDeleteCardPublicLinkMutation();
return (
<PublicLinksListing<GetPublicOrEmbeddableCard>
revoke={revoke}
getUrl={question => Urls.question(question)}
getPublicUrl={({ public_uuid }) => {
if (public_uuid) {
return Urls.publicQuestion({ uuid: public_uuid });
}
return null;
}}
noLinksMessage={t`No questions have been publicly shared yet.`}
{...query}
/>
);
};
export const PublicLinksActionListing = () => {
const siteUrl = useSetting("site-url");
const query = useListPublicActionsQuery();
const [revoke] = useDeleteActionPublicLinkMutation();
return (
<PublicLinksListing<GetPublicAction>
revoke={revoke}
getUrl={action => Urls.action({ id: action.model_id }, action.id)}
getPublicUrl={({ public_uuid }) => {
if (public_uuid) {
return Urls.publicAction(siteUrl, public_uuid);
}
return null;
}}
noLinksMessage={t`No actions have been publicly shared yet.`}
{...query}
/>
);
};
export * from "./PublicLinksListing";
export * from "./EmbeddedResources";
export * from "./PublicResources";
import type { GetActionRequest, WritebackAction } from "metabase-types/api";
import type {
GetActionRequest,
WritebackAction,
WritebackActionId,
} from "metabase-types/api";
import type { GetPublicAction } from "metabase-types/api/actions";
import { Api } from "./api";
import { idTag } from "./tags";
import { idTag, invalidateTags, listTag } from "./tags";
export const actionApi = Api.injectEndpoints({
endpoints: builder => ({
......@@ -12,7 +17,48 @@ export const actionApi = Api.injectEndpoints({
}),
providesTags: action => (action ? [idTag("action", action.id)] : []),
}),
listPublicActions: builder.query<GetPublicAction[], void>({
query: () => ({
method: "GET",
url: "/api/action/public",
}),
providesTags: (actions = []) => [
...actions.map(action => idTag("public-action", action.id)),
listTag("public-action"),
],
}),
deleteActionPublicLink: builder.mutation<void, { id: WritebackActionId }>({
query: ({ id }) => ({
method: "DELETE",
url: `/api/action/${id}/public_link`,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [
listTag("public-action"),
idTag("public-action", id),
]),
}),
createActionPublicLink: builder.mutation<
{ uuid: string },
{ id: WritebackActionId }
>({
query: ({ id }) => ({
method: "POST",
url: `/api/action/${id}/public_link`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("public-card")]),
}),
}),
});
export const { useGetActionQuery } = actionApi;
export const {
useGetActionQuery,
useListPublicActionsQuery,
useDeleteActionPublicLinkMutation,
endpoints: {
listPublicActions,
deleteActionPublicLink,
createActionPublicLink,
},
} = actionApi;
......@@ -6,6 +6,7 @@ import type {
CreateCardRequest,
Dataset,
GetCardRequest,
GetPublicOrEmbeddableCard,
ListCardsRequest,
UpdateCardRequest,
} from "metabase-types/api";
......@@ -148,6 +149,54 @@ export const cardApi = Api.injectEndpoints({
}, PERSISTED_MODEL_REFRESH_DELAY);
},
}),
listEmbeddableCards: builder.query<GetPublicOrEmbeddableCard[], void>({
query: params => ({
method: "GET",
url: "/api/card/embeddable",
params,
}),
providesTags: (result = []) => [
...result.map(res => idTag("embed-card", res.id)),
listTag("embed-card"),
],
}),
listPublicCards: builder.query<GetPublicOrEmbeddableCard[], void>({
query: params => ({
method: "GET",
url: "/api/card/public",
params,
}),
providesTags: (result = []) => [
...result.map(res => idTag("public-card", res.id)),
listTag("public-card"),
],
}),
deleteCardPublicLink: builder.mutation<void, GetPublicOrEmbeddableCard>({
query: ({ id, ...params }) => ({
method: "DELETE",
url: `/api/card/${id}/public_link`,
params,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [
listTag("public-card"),
idTag("public-card", id),
]),
}),
createCardPublicLink: builder.mutation<
{
uuid: Card["public_uuid"];
},
Pick<Card, "id">
>({
query: ({ id, ...params }) => ({
method: "POST",
url: `/api/card/${id}/public_link`,
params,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("public-card")]),
}),
}),
});
......@@ -163,4 +212,8 @@ export const {
useRefreshModelCacheMutation,
usePersistModelMutation,
useUnpersistModelMutation,
useListEmbeddableCardsQuery,
useListPublicCardsQuery,
useDeleteCardPublicLinkMutation,
endpoints: { createCardPublicLink, deleteCardPublicLink },
} = cardApi;
......@@ -6,6 +6,7 @@ import type {
DashboardQueryMetadata,
GetDashboardQueryMetadataRequest,
GetDashboardRequest,
GetPublicOrEmbeddableDashboard,
ListDashboardsRequest,
ListDashboardsResponse,
SaveDashboardRequest,
......@@ -108,6 +109,57 @@ export const dashboardApi = Api.injectEndpoints({
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("dashboard")]),
}),
listEmbeddableDashboards: builder.query<
GetPublicOrEmbeddableDashboard[],
void
>({
query: params => ({
method: "GET",
url: "/api/dashboard/embeddable",
params,
}),
providesTags: (result = []) => [
...result.map(res => idTag("embed-dashboard", res.id)),
listTag("embed-dashboard"),
],
}),
listPublicDashboards: builder.query<GetPublicOrEmbeddableDashboard[], void>(
{
query: params => ({
method: "GET",
url: "/api/dashboard/public",
params,
}),
providesTags: (result = []) => [
...result.map(res => idTag("public-dashboard", res.id)),
listTag("public-dashboard"),
],
},
),
createDashboardPublicLink: builder.mutation<
GetPublicOrEmbeddableDashboard,
Pick<Dashboard, "id">
>({
query: ({ id, ...params }) => ({
method: "POST",
url: `/api/dashboard/${id}/public_link`,
params,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("public-dashboard")]),
}),
deleteDashboardPublicLink: builder.mutation<void, Pick<Dashboard, "id">>({
query: ({ id, ...params }) => ({
method: "DELETE",
url: `/api/dashboard/${id}/public_link`,
params,
}),
invalidatesTags: (_, error, { id }) =>
invalidateTags(error, [
listTag("public-dashboard"),
idTag("public-dashboard", id),
]),
}),
}),
});
......@@ -120,4 +172,8 @@ export const {
useSaveDashboardMutation,
useDeleteDashboardMutation,
useCopyDashboardMutation,
useListEmbeddableDashboardsQuery,
useListPublicDashboardsQuery,
useDeleteDashboardPublicLinkMutation,
endpoints: { deleteDashboardPublicLink, createDashboardPublicLink },
} = dashboardApi;
......@@ -30,6 +30,11 @@ export const TAG_TYPES = [
"timeline",
"timeline-event",
"user",
"public-dashboard",
"embed-dashboard",
"public-card",
"embed-card",
"public-action",
] as const;
export const TAG_TYPE_MAPPING = {
......
......@@ -21,7 +21,6 @@ import type {
FieldId,
ForeignKey,
GroupListQuery,
ListDashboardsResponse,
ModelCacheRefreshStatus,
ModelIndex,
NativeQuerySnippet,
......@@ -266,7 +265,7 @@ export function provideDatabaseTags(
}
export function provideDashboardListTags(
dashboards: ListDashboardsResponse,
dashboards: Pick<Dashboard, "id">[],
): TagDescription<TagType>[] {
return [
listTag("dashboard"),
......
import {
createDashboardPublicLink,
deleteDashboardPublicLink,
} from "metabase/api";
import { SIDEBAR_NAME } from "metabase/dashboard/constants";
import { createAction } from "metabase/lib/redux";
import { createAction, createThunkAction } from "metabase/lib/redux";
import { DashboardApi } from "metabase/services";
import type { Dashboard, DashboardId } from "metabase-types/api";
import type { DashboardId } from "metabase-types/api";
import type { Dispatch, EmbedOptions } from "metabase-types/store";
import { closeSidebar, setSidebar } from "./ui";
......@@ -42,25 +46,31 @@ export const updateEmbeddingParams = createAction(
);
export const CREATE_PUBLIC_LINK = "metabase/dashboard/CREATE_PUBLIC_LINK";
export const createPublicLink = createAction(
export const createPublicLink = createThunkAction(
CREATE_PUBLIC_LINK,
async ({
id,
}: DashboardIdPayload): Promise<{
id: DashboardId;
uuid: Dashboard["public_uuid"];
}> => {
const { uuid } = await DashboardApi.createPublicLink({ id });
return { id, uuid };
},
({ id }: DashboardIdPayload) =>
async (dispatch: Dispatch) => {
const { data } = await (dispatch(
createDashboardPublicLink.initiate({
id,
}),
) as Promise<{ data: { uuid: string }; error: unknown }>);
return { id, uuid: data.uuid };
},
);
export const DELETE_PUBLIC_LINK = "metabase/dashboard/DELETE_PUBLIC_LINK";
export const deletePublicLink = createAction(
export const deletePublicLink = createThunkAction(
DELETE_PUBLIC_LINK,
async ({ id }: DashboardIdPayload): Promise<DashboardIdPayload> => {
await DashboardApi.deletePublicLink({ id });
return { id };
},
({ id }: DashboardIdPayload) =>
async (dispatch: Dispatch) => {
await dispatch(
deleteDashboardPublicLink.initiate({
id,
}),
);
return { id };
},
);
import { updateIn } from "icepick";
import { createAction } from "redux-actions";
import { t } from "ttag";
import _ from "underscore";
import { createActionPublicLink, deleteActionPublicLink } from "metabase/api";
import { createEntity, undo } from "metabase/lib/entities";
import { createThunkAction } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { ActionSchema } from "metabase/schema";
import { ActionsApi } from "metabase/services";
......@@ -124,28 +125,25 @@ const Actions = createEntity({
"archived",
],
objectActions: {
createPublicLink: createAction(
createPublicLink: createThunkAction(
CREATE_PUBLIC_LINK,
({ id }: { id: WritebackActionId }) => {
return ActionsApi.createPublicLink({ id }).then(
({ uuid }: { uuid: string }) => {
return {
id,
uuid,
};
},
);
},
({ id }: { id: WritebackActionId }) =>
async (dispatch: Dispatch) => {
const { data } = await (dispatch(
createActionPublicLink.initiate({ id }),
) as Promise<{ data: { uuid: string } }>);
return { id, uuid: data.uuid };
},
),
deletePublicLink: createAction(
deletePublicLink: createThunkAction(
DELETE_PUBLIC_LINK,
({ id }: { id: WritebackActionId }) => {
return ActionsApi.deletePublicLink({ id }).then(() => {
return {
id,
};
});
},
({ id }: { id: WritebackActionId }) =>
async (dispatch: Dispatch) => {
await dispatch(deleteActionPublicLink.initiate({ id }));
return { id };
},
),
setArchived: ({ id }: WritebackAction, archived: boolean) =>
Actions.actions.update(
......
import { createAction } from "redux-actions";
import { createCardPublicLink, deleteCardPublicLink } from "metabase/api";
import { createThunkAction } from "metabase/lib/redux";
import { CardApi } from "metabase/services";
import type { Card, CardId } from "metabase-types/api";
import type { Card } from "metabase-types/api";
import type { EmbedOptions } from "metabase-types/store";
type CardIdPayload = {
id: CardId;
};
type CardIdPayload = Pick<Card, "id">;
export const CREATE_PUBLIC_LINK = "metabase/card/CREATE_PUBLIC_LINK";
export const createPublicLink = createAction(
export const createPublicLink = createThunkAction(
CREATE_PUBLIC_LINK,
({
id,
}: Card): Promise<{
id: CardId;
uuid: Card["public_uuid"];
}> => {
return CardApi.createPublicLink({ id });
},
({ id }: Card) =>
async dispatch => {
const { data } = await (dispatch(
createCardPublicLink.initiate({ id }),
) as Promise<{ data: { uuid: string }; error: unknown }>);
return { id, uuid: data.uuid };
},
);
export const DELETE_PUBLIC_LINK = "metabase/card/DELETE_PUBLIC_LINK";
export const deletePublicLink = createAction(
export const deletePublicLink = createThunkAction(
DELETE_PUBLIC_LINK,
({ id }: CardIdPayload) => CardApi.deletePublicLink({ id }),
(card: Card) => async dispatch =>
await dispatch(deleteCardPublicLink.initiate(card)),
);
export const UPDATE_ENABLE_EMBEDDING = "metabase/card/UPDATE_ENABLE_EMBEDDING";
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment