From 0b06c1daeee86b6abb8dd7db7f5b54435ad5b03a Mon Sep 17 00:00:00 2001 From: Nick Fitzpatrick <nick@metabase.com> Date: Sun, 11 Feb 2024 10:50:38 -0400 Subject: [PATCH] Usage insight links on questions and dashboards (#38573) * adding links with hardcoded IDs * add audit-info endpoint * fix perms * Fetching Dashboard IDs from the API * e2e tests * add BE tests * kinda busted * InstanceAnalyticsButton * removing unused file * Update enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com> --------- Co-authored-by: Jerry Huang <jhuang37050@gmail.com> Co-authored-by: Jerry Huang <34140255+qwef@users.noreply.github.com> Co-authored-by: Noah Moss <32746338+noahmoss@users.noreply.github.com> --- .../collections/instance-analytics.cy.spec.js | 108 ++++++++++++++++++ .../audit_app/api/user.clj | 20 +++- .../src/metabase_enterprise/audit_db.clj | 21 +++- .../audit_app/api/user_test.clj | 34 +++++- .../audit_app/permissions_test.clj | 2 +- .../InstanceAnalyticsButton.tsx | 46 ++++++++ .../metabase-enterprise/audit_app/index.js | 37 ++++++ .../metabase-enterprise/audit_app/reducer.ts | 40 +++++++ .../audit_app/selectors.ts | 20 ++++ .../audit_app/types/state.ts | 15 +++ .../components/EntityMenu/EntityMenu.jsx | 6 + .../DashboardHeader/DashboardHeader.tsx | 7 +- frontend/src/metabase/plugins/index.ts | 12 ++ .../QuestionActions/QuestionActions.tsx | 8 +- 14 files changed, 364 insertions(+), 12 deletions(-) create mode 100644 enterprise/frontend/src/metabase-enterprise/audit_app/components/InstanceAnalyticsButton/InstanceAnalyticsButton.tsx create mode 100644 enterprise/frontend/src/metabase-enterprise/audit_app/reducer.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/audit_app/selectors.ts create mode 100644 enterprise/frontend/src/metabase-enterprise/audit_app/types/state.ts diff --git a/e2e/test/scenarios/collections/instance-analytics.cy.spec.js b/e2e/test/scenarios/collections/instance-analytics.cy.spec.js index dcbe3cf3aed..d73e0c0fcb5 100644 --- a/e2e/test/scenarios/collections/instance-analytics.cy.spec.js +++ b/e2e/test/scenarios/collections/instance-analytics.cy.spec.js @@ -1,3 +1,7 @@ +import { + ORDERS_DASHBOARD_ID, + ORDERS_QUESTION_ID, +} from "e2e/support/cypress_sample_instance_data"; import { restore, setTokenFeatures, @@ -6,6 +10,8 @@ import { modal, visitDashboard, visitModel, + visitQuestion, + describeOSS, } from "e2e/support/helpers"; const ANALYTICS_COLLECTION_NAME = "Metabase analytics"; @@ -251,6 +257,108 @@ describeEE("scenarios > Metabase Analytics Collection (AuditV2) ", () => { }); }); +describe("question and dashboard links", () => { + describeEE("ee", () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + setTokenFeatures("all"); + }); + + it("should show a analytics link for questions", () => { + visitQuestion(ORDERS_QUESTION_ID); + + cy.intercept("GET", "/api/collection/**").as("collection"); + + cy.findByTestId("qb-header-action-panel") + .button(/\.\.\./) + .click(); + popover().findByText("Usage insights").click(); + + cy.wait("@collection"); + + cy.findByDisplayValue("Question overview").should("exist"); + + cy.findByRole("button", { name: /Question ID/ }).should( + "contain.text", + ORDERS_QUESTION_ID, + ); + + cy.findAllByTestId("dashcard") + .contains("[data-testid=dashcard]", "Question metadata") + .within(() => { + cy.findByText("Entity ID"); + cy.findByText(ORDERS_QUESTION_ID); + cy.findByText("Name"); + cy.findByText("Orders"); + cy.findByText("Entity Type"); + cy.findByText("question"); + }); + }); + + it("should show a analytics link for dashboards", () => { + visitDashboard(ORDERS_DASHBOARD_ID); + cy.intercept("GET", "/api/collection/**").as("collection"); + cy.button("dashboard-menu-button").click(); + popover().findByText("Usage insights").click(); + + cy.wait("@collection"); + + cy.findByDisplayValue("Dashboard overview").should("exist"); + + cy.findByRole("button", { name: /Dashboard ID/ }).should( + "contain.text", + ORDERS_DASHBOARD_ID, + ); + + cy.findAllByTestId("dashcard") + .contains("[data-testid=dashcard]", "Dashboard metadata") + .within(() => { + cy.findByText("Entity ID"); + cy.findByText(ORDERS_DASHBOARD_ID); + cy.findByText("Name"); + cy.findByText("Orders in a dashboard"); + cy.findByText("Entity Type"); + cy.findByText("dashboard"); + }); + }); + + it("should not show option for users with no access to Metabase Analytics", () => { + cy.signInAsNormalUser(); + visitQuestion(ORDERS_QUESTION_ID); + + cy.findByTestId("qb-header-action-panel") + .button(/\.\.\./) + .click(); + popover().findByText("Usage insights").should("not.exist"); + + visitDashboard(ORDERS_DASHBOARD_ID); + + cy.button("dashboard-menu-button").click(); + popover().findByText("Usage insights").should("not.exist"); + }); + }); + describeOSS("oss", { tags: "@OSS" }, () => { + beforeEach(() => { + restore(); + cy.signInAsAdmin(); + }); + it("should never appear in OSS", () => { + visitQuestion(ORDERS_QUESTION_ID); + + cy.findByTestId("qb-header-action-panel") + .button(/\.\.\./) + .click(); + popover().findByText("Usage insights").should("not.exist"); + + visitDashboard(ORDERS_DASHBOARD_ID); + + cy.button("dashboard-menu-button").click(); + popover().findByText("Usage insights").should("not.exist"); + }); + }); +}); + function getCollectionId(collectionName) { return cy.request("GET", "/api/collection").then(({ body }) => { const collection = body.find(({ name }) => name === collectionName); diff --git a/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj b/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj index 095337ff575..67122333ffa 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_app/api/user.clj @@ -1,14 +1,32 @@ (ns metabase-enterprise.audit-app.api.user "`/api/ee/audit-app/user` endpoints. These only work if you have a premium token with the `:audit-app` feature." (:require - [compojure.core :refer [DELETE]] + [compojure.core :refer [DELETE GET]] + [metabase-enterprise.audit-db :as audit-db] [metabase.api.common :as api] [metabase.api.user :as api.user] + [metabase.models.interface :as mi] [metabase.models.pulse :refer [Pulse]] [metabase.models.pulse-channel-recipient :refer [PulseChannelRecipient]] + [metabase.util :as u] [metabase.util.malli.schema :as ms] [toucan2.core :as t2])) +(api/defendpoint GET "/audit-info" + "Gets audit info for the current user if he has permissions to access the audit collection. + Otherwise return an empty map." + [] + (let [custom-reports (audit-db/default-custom-reports-collection) + question-overview (audit-db/entity-id->object :model/Dashboard audit-db/default-question-overview-entity-id) + dashboard-overview (audit-db/entity-id->object :model/Dashboard audit-db/default-dashboard-overview-entity-id)] + (merge + {} + (when (mi/can-read? (audit-db/default-custom-reports-collection)) + {(:slug custom-reports) (:id custom-reports)}) + (when (mi/can-read? (audit-db/default-audit-collection)) + {(u/slugify (:name question-overview)) (:id question-overview) + (u/slugify (:name dashboard-overview)) (:id dashboard-overview)})))) + (api/defendpoint DELETE "/:id/subscriptions" "Delete all Alert and DashboardSubscription subscriptions for a User (i.e., so they will no longer receive them). Archive all Alerts and DashboardSubscriptions created by the User. Only allowed for admins or for the current user." diff --git a/enterprise/backend/src/metabase_enterprise/audit_db.clj b/enterprise/backend/src/metabase_enterprise/audit_db.clj index 9e692439da9..281c4026b92 100644 --- a/enterprise/backend/src/metabase_enterprise/audit_db.clj +++ b/enterprise/backend/src/metabase_enterprise/audit_db.clj @@ -76,24 +76,33 @@ "Default custom reports entity id." "okNLSZKdSxaoG58JSQY54") -(defn collection-entity-id->collection - "Returns the collection from entity id for collections. Memoizes from entity id." - [entity-id] +(def default-question-overview-entity-id + "Default Question Overview (this is a dashboard) entity id." + "jm7KgY6IuS6pQjkBZ7WUI") + +(def default-dashboard-overview-entity-id + "Default Dashboard Overview (this is a dashboard) entity id." + "bJEYb0o5CXlfWFcIztDwJ") + +(defn entity-id->object + "Returns the object from entity id and model. Memoizes from entity id. + Should only be used for audit/pre-loaded objects." + [model entity-id] ((mdb.connection/memoize-for-application-db (fn [entity-id] - (t2/select-one :model/Collection :entity_id entity-id))) entity-id)) + (t2/select-one model :entity_id entity-id))) entity-id)) (defenterprise default-custom-reports-collection "Default custom reports collection." :feature :none [] - (collection-entity-id->collection default-custom-reports-entity-id)) + (entity-id->object :model/Collection default-custom-reports-entity-id)) (defenterprise default-audit-collection "Default audit collection (instance analytics) collection." :feature :none [] - (collection-entity-id->collection default-audit-collection-entity-id)) + (entity-id->object :model/Collection default-audit-collection-entity-id)) (defn- install-database! "Creates the audit db, a clone of the app db used for auditing purposes. diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj index 9c61a124061..19e5d8ec604 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/api/user_test.clj @@ -1,7 +1,12 @@ (ns ^:mb/once metabase-enterprise.audit-app.api.user-test (:require [clojure.test :refer :all] - [metabase.models :refer [Card Dashboard DashboardCard Pulse PulseCard PulseChannel PulseChannelRecipient User]] + [metabase-enterprise.audit-app.permissions-test :as ee-perms-test] + [metabase-enterprise.audit-db :as audit-db] + [metabase.models :refer [Card Dashboard DashboardCard Pulse PulseCard + PulseChannel PulseChannelRecipient User]] + [metabase.models.permissions :as perms] + [metabase.models.permissions-group :as perms-group] [metabase.test :as mt] [toucan2.core :as t2] [toucan2.tools.with-temp :as t2.with-temp])) @@ -83,3 +88,30 @@ :alert-archived? true :dashboard-subscription-archived? true} (describe-objects)))))))))))) + +(deftest get-audit-info-test + (testing "GET /api/ee/audit-app/user/audit-info" + (mt/with-premium-features #{:audit-app} + (ee-perms-test/install-audit-db-if-needed!) + (testing "None of the ids show up when perms aren't given" + (perms/revoke-collection-permissions! (perms-group/all-users) (audit-db/default-custom-reports-collection)) + (perms/revoke-collection-permissions! (perms-group/all-users) (audit-db/default-audit-collection)) + (is (= #{} + (->> + (mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info") + keys + (into #{}))))) + (testing "Custom reports collection shows up when perms are given" + (perms/grant-collection-read-permissions! (perms-group/all-users) (audit-db/default-custom-reports-collection)) + (is (= #{:custom_reports} + (->> + (mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info") + keys + (into #{}))))) + (testing "Everything shows up when all perms are given" + (perms/grant-collection-read-permissions! (perms-group/all-users) (audit-db/default-audit-collection)) + (is (= #{:question_overview :dashboard_overview :custom_reports} + (->> + (mt/user-http-request :rasta :get 200 "/ee/audit-app/user/audit-info") + keys + (into #{})))))))) diff --git a/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj b/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj index 6a409bbbdf0..d80c4d9a24d 100644 --- a/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj +++ b/enterprise/backend/test/metabase_enterprise/audit_app/permissions_test.clj @@ -116,7 +116,7 @@ (update-graph! (assoc-in (graph :clear-revisions? true) [:groups group-id (:id collection)] :write))))))))) ;; TODO: re-enable these tests once they're no longer flaky -(defn- install-audit-db-if-needed! +(defn install-audit-db-if-needed! "Checks if there's an audit-db. if not, it will create it and serialize audit content, including the `default-audit-collection`. If the audit-db is there, this does nothing." [] diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/components/InstanceAnalyticsButton/InstanceAnalyticsButton.tsx b/enterprise/frontend/src/metabase-enterprise/audit_app/components/InstanceAnalyticsButton/InstanceAnalyticsButton.tsx new file mode 100644 index 00000000000..897e418c3f6 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/components/InstanceAnalyticsButton/InstanceAnalyticsButton.tsx @@ -0,0 +1,46 @@ +import { t } from "ttag"; +import { useEffect } from "react"; +import { push } from "react-router-redux"; +import { loadInfo } from "metabase-enterprise/audit_app/reducer"; +import EntityMenuItem from "metabase/components/EntityMenuItem"; +import { useDispatch, useSelector } from "metabase/lib/redux"; +import type { AuditInfoState } from "metabase-enterprise/audit_app/types/state"; +import type { DashboardId } from "metabase-types/api"; + +interface InstanceAnalyticsButtonProps { + entitySelector: (state: AuditInfoState) => number; + linkQueryParams: { dashboard_id: DashboardId } | { question_id: number }; +} + +export const InstanceAnalyticsButton = ({ + entitySelector, + linkQueryParams, +}: InstanceAnalyticsButtonProps) => { + const dispatch = useDispatch(); + const entityId = useSelector(state => + entitySelector(state as AuditInfoState), + ); + + useEffect(() => { + dispatch(loadInfo()); + }, [dispatch]); + + if (entityId !== undefined) { + return ( + <EntityMenuItem + icon="audit" + title={t`Usage insights`} + action={() => { + dispatch( + push({ + pathname: `/dashboard/${entityId}`, + query: linkQueryParams, + }), + ); + }} + /> + ); + } else { + return null; + } +}; diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/index.js b/enterprise/frontend/src/metabase-enterprise/audit_app/index.js index e7cf09827e4..328ea47a596 100644 --- a/enterprise/frontend/src/metabase-enterprise/audit_app/index.js +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/index.js @@ -4,9 +4,16 @@ import { PLUGIN_ADMIN_ROUTES, PLUGIN_ADMIN_USER_MENU_ITEMS, PLUGIN_ADMIN_USER_MENU_ROUTES, + PLUGIN_REDUCERS, + PLUGIN_DASHBOARD_HEADER, + PLUGIN_QUERY_BUILDER_HEADER, } from "metabase/plugins"; import { hasPremiumFeature } from "metabase-enterprise/settings"; import getAuditRoutes, { getUserMenuRotes } from "./routes"; +import { auditInfo } from "./reducer"; +import { getDashboardOverviewId, getQuestionOverviewId } from "./selectors"; + +import { InstanceAnalyticsButton } from "./components/InstanceAnalyticsButton/InstanceAnalyticsButton"; if (hasPremiumFeature("audit_app")) { PLUGIN_ADMIN_NAV_ITEMS.push({ @@ -24,4 +31,34 @@ if (hasPremiumFeature("audit_app")) { ]); PLUGIN_ADMIN_USER_MENU_ROUTES.push(getUserMenuRotes); + + PLUGIN_REDUCERS.auditInfo = auditInfo; + + PLUGIN_DASHBOARD_HEADER.extraButtons = dashboard => { + return [ + { + key: "Usage insights", + component: ( + <InstanceAnalyticsButton + entitySelector={getDashboardOverviewId} + linkQueryParams={{ dashboard_id: dashboard.id }} + /> + ), + }, + ]; + }; + + PLUGIN_QUERY_BUILDER_HEADER.extraButtons = question => { + return [ + { + key: "Usage insights", + component: ( + <InstanceAnalyticsButton + entitySelector={getQuestionOverviewId} + linkQueryParams={{ question_id: question.id() }} + /> + ), + }, + ]; + }; } diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/reducer.ts b/enterprise/frontend/src/metabase-enterprise/audit_app/reducer.ts new file mode 100644 index 00000000000..135113c8a4e --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/reducer.ts @@ -0,0 +1,40 @@ +import { createReducer } from "@reduxjs/toolkit"; +import { createAsyncThunk } from "metabase/lib/redux"; + +import { GET } from "metabase/lib/api"; +import { isAuditInfoComplete } from "./selectors"; +import type { AuditInfoState } from "./types/state"; + +const getAuditInfo = GET("/api/ee/audit-app/user/audit-info"); + +const LOAD_AUDIT_INFO = "metabase-enterprise/audit/FETCH_AUDIT_INFO"; + +export const loadInfo = createAsyncThunk( + LOAD_AUDIT_INFO, + async (_, { getState }) => { + const state = getState() as AuditInfoState; + const isComplete = isAuditInfoComplete(state); + if (!isComplete) { + const data = await getAuditInfo(); + return data; + } + }, +); + +const initialState = { + isLoading: false, + isComplete: false, + data: undefined, +}; + +export const auditInfo = createReducer(initialState, builder => { + builder.addCase(loadInfo.pending, state => { + state.isLoading = true; + }); + + builder.addCase(loadInfo.fulfilled, (state, { payload }) => { + state.isLoading = false; + state.isComplete = true; + state.data = payload || state.data; + }); +}); diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/selectors.ts b/enterprise/frontend/src/metabase-enterprise/audit_app/selectors.ts new file mode 100644 index 00000000000..817be82a2c9 --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/selectors.ts @@ -0,0 +1,20 @@ +import type { AuditInfoState } from "./types/state"; + +export const isAuditInfoLoading = (state: AuditInfoState) => { + const { + plugins: { auditInfo }, + } = state; + return auditInfo.isLoading; +}; + +export const isAuditInfoComplete = (state: AuditInfoState) => { + const { + plugins: { auditInfo }, + } = state; + return auditInfo.isComplete; +}; + +export const getDashboardOverviewId = (state: AuditInfoState) => + state.plugins.auditInfo.data?.dashboard_overview ?? undefined; +export const getQuestionOverviewId = (state: AuditInfoState) => + state.plugins.auditInfo.data?.question_overview ?? undefined; diff --git a/enterprise/frontend/src/metabase-enterprise/audit_app/types/state.ts b/enterprise/frontend/src/metabase-enterprise/audit_app/types/state.ts new file mode 100644 index 00000000000..dd889e59a8d --- /dev/null +++ b/enterprise/frontend/src/metabase-enterprise/audit_app/types/state.ts @@ -0,0 +1,15 @@ +import type { DashboardId } from "metabase-types/api"; +import type { State } from "metabase-types/store"; + +export interface AuditInfoState extends State { + plugins: { + auditInfo: { + isLoading: boolean; + isComplete: boolean; + data: { + dashboard_overview: DashboardId; + question_overview: number; + }; + }; + }; +} diff --git a/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx b/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx index 087fe0f0142..5ec73f8bd69 100644 --- a/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx +++ b/frontend/src/metabase/components/EntityMenu/EntityMenu.jsx @@ -107,6 +107,12 @@ class EntityMenu extends Component { /> </li> ); + } else if (item.component) { + return ( + <li key={item.title} data-testid={item.testId}> + {item.component} + </li> + ); } else { return ( <li key={item.title} data-testid={item.testId}> diff --git a/frontend/src/metabase/dashboard/components/DashboardHeader/DashboardHeader.tsx b/frontend/src/metabase/dashboard/components/DashboardHeader/DashboardHeader.tsx index 99a9793b7c5..16ddadc46a4 100644 --- a/frontend/src/metabase/dashboard/components/DashboardHeader/DashboardHeader.tsx +++ b/frontend/src/metabase/dashboard/components/DashboardHeader/DashboardHeader.tsx @@ -4,7 +4,7 @@ import { connect } from "react-redux"; import { push } from "react-router-redux"; import { msgid, ngettext, t } from "ttag"; import _ from "underscore"; -import type { Location } from "history"; +import type { Location, LocationDescriptor } from "history"; import { trackExportDashboardToPDF } from "metabase/dashboard/analytics"; @@ -73,6 +73,7 @@ import type { State, } from "metabase-types/store"; +import { PLUGIN_DASHBOARD_HEADER } from "metabase/plugins"; import type { UiParameter } from "metabase-lib/parameters/types"; import { ExtraEditButtonsMenu } from "../ExtraEditButtonsMenu/ExtraEditButtonsMenu"; import { DashboardButtonTooltip } from "../DashboardButtonTooltip"; @@ -162,7 +163,7 @@ interface DispatchProps { deleteBookmark: (args: { id: DashboardId }) => void; fetchPulseFormInput: () => void; toggleSidebar: (sidebarName: DashboardSidebarName) => void; - onChangeLocation: (location: Location) => void; + onChangeLocation: (location: LocationDescriptor) => void; addActionToDashboard: ( opts: NewDashCardOpts & { action: Partial<WritebackAction>; @@ -591,6 +592,8 @@ class DashboardHeaderContainer extends Component<DashboardHeaderProps> { link: `${location.pathname}/archive`, event: "Dashboard;Archive", }); + + extraButtons.push(...PLUGIN_DASHBOARD_HEADER.extraButtons(dashboard)); } } diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index 70337e44511..8795442dd9c 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -15,6 +15,7 @@ import type { Collection, CollectionAuthorityLevelConfig, CollectionInstanceAnaltyicsConfig, + Dashboard, Dataset, Group, GroupPermissions, @@ -130,6 +131,7 @@ export const PLUGIN_SELECTORS = { getIsWhiteLabeling: (_state: State) => false, getApplicationName: (_state: State) => "Metabase", getShowMetabaseLinks: (_state: State) => true, + getDashboardOverviewId: (_state: State) => undefined, }; export const PLUGIN_FORM_WIDGETS: Record<string, ComponentType<any>> = {}; @@ -251,10 +253,12 @@ export const PLUGIN_REDUCERS: { applicationPermissionsPlugin: any; sandboxingPlugin: any; shared: any; + auditInfo: any; } = { applicationPermissionsPlugin: () => null, sandboxingPlugin: () => null, shared: () => null, + auditInfo: () => null, }; export const PLUGIN_ADVANCED_PERMISSIONS = { @@ -323,3 +327,11 @@ export const PLUGIN_EMBEDDING = { export const PLUGIN_CONTENT_VERIFICATION = { VerifiedFilter: {} as SearchFilterComponent<"verified">, }; + +export const PLUGIN_DASHBOARD_HEADER = { + extraButtons: (_dashboard: Dashboard) => [], +}; + +export const PLUGIN_QUERY_BUILDER_HEADER = { + extraButtons: (_question: Question) => [], +}; diff --git a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx index d8125849ee7..ffcfb548eb5 100644 --- a/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx +++ b/frontend/src/metabase/query_builder/components/view/ViewHeader/components/QuestionActions/QuestionActions.tsx @@ -7,7 +7,11 @@ import Button from "metabase/core/components/Button"; import Tooltip from "metabase/core/components/Tooltip"; import EntityMenu from "metabase/components/EntityMenu"; -import { PLUGIN_MODERATION, PLUGIN_MODEL_PERSISTENCE } from "metabase/plugins"; +import { + PLUGIN_MODERATION, + PLUGIN_MODEL_PERSISTENCE, + PLUGIN_QUERY_BUILDER_HEADER, +} from "metabase/plugins"; import { MODAL_TYPES } from "metabase/query_builder/constants"; @@ -224,6 +228,8 @@ export const QuestionActions = ({ }); } + extraButtons.push(...PLUGIN_QUERY_BUILDER_HEADER.extraButtons(question)); + const fileInputRef = useRef<HTMLInputElement>(null); const handleUploadClick = () => { -- GitLab