From 58c7b0ffa56c53f153a1fc4ecc896f60fb899a4e Mon Sep 17 00:00:00 2001 From: Emmad Usmani <emmadusmani@berkeley.edu> Date: Mon, 26 Jun 2023 15:17:42 -0700 Subject: [PATCH] optimize loading questions only for active tab (#31578) * only load cards and metadata for current dashboard tab * add e2e test * refactor * more refactoring * fix filters not working * update e2e test * fix bug with x-rays * add dashboard_tab_id to automagic-dashboard response * use schema check instead * fix on public dashboards * remove FE x-ray fix now that BE is fixed * add test for public dashboards * fix duplicate request for public dashboards * fix notification bug * fix failing unit tests in `reducers.unit.spec.js` --------- Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com> --- e2e/support/helpers/e2e-misc-helpers.js | 8 ++- e2e/test/scenarios/dashboard/tabs.cy.spec.js | 66 +++++++++++++++++++ .../dashboard/actions/data-fetching.js | 48 ++++++++++++-- .../components/Dashboard/Dashboard.jsx | 13 +++- frontend/src/metabase/dashboard/reducers.js | 35 +++++----- .../metabase/dashboard/reducers.unit.spec.js | 2 +- .../public/containers/PublicDashboard.jsx | 10 ++- .../automagic_dashboards/comparison.clj | 57 ++++++++-------- .../automagic_dashboards/populate.clj | 53 ++++++++------- .../api/automagic_dashboards_test.clj | 19 +++++- 10 files changed, 234 insertions(+), 77 deletions(-) diff --git a/e2e/support/helpers/e2e-misc-helpers.js b/e2e/support/helpers/e2e-misc-helpers.js index ab4a3a0f74d..c67ed1f7d60 100644 --- a/e2e/support/helpers/e2e-misc-helpers.js +++ b/e2e/support/helpers/e2e-misc-helpers.js @@ -169,7 +169,13 @@ export function visitDashboard(dashboard_id, { params = {} } = {}) { cy.intercept("GET", `/api/dashboard/${dashboard_id}`).as(dashboardAlias); const canViewDashboard = hasAccess(status); - const validQuestions = dashboardHasQuestions(ordered_cards); + + let validQuestions = dashboardHasQuestions(ordered_cards); + if (params.tab != null) { + validQuestions = validQuestions.filter( + card => card.dashboard_tab_id === params.tab, + ); + } if (canViewDashboard && validQuestions) { // If dashboard has valid questions (GUI or native), diff --git a/e2e/test/scenarios/dashboard/tabs.cy.spec.js b/e2e/test/scenarios/dashboard/tabs.cy.spec.js index a20d96f4b6b..fab38cf23d2 100644 --- a/e2e/test/scenarios/dashboard/tabs.cy.spec.js +++ b/e2e/test/scenarios/dashboard/tabs.cy.spec.js @@ -78,6 +78,72 @@ describe("scenarios > dashboard > tabs", () => { cy.findByText("Our analytics").should("be.visible"); }); }); + + it("should only fetch cards on the current tab", () => { + visitDashboardAndCreateTab({ dashboardId: 1, save: false }); + + // Add card to second tab + cy.icon("pencil").click(); + openQuestionsSidebar(); + sidebar().within(() => { + cy.findByText("Orders, Count").click(); + }); + saveDashboard(); + + cy.intercept( + "POST", + `/api/dashboard/1/dashcard/1/card/1/query`, + cy.spy().as("firstTabQuery"), + ); + cy.intercept( + "POST", + `/api/dashboard/1/dashcard/2/card/2/query`, + cy.spy().as("secondTabQuery"), + ); + + // Visit first tab and confirm only first card was queried + visitDashboard(1, { params: { tab: 1 } }); + cy.get("@firstTabQuery").should("have.been.calledOnce"); + cy.get("@secondTabQuery").should("not.have.been.called"); + + // Visit second tab and confirm only second card was queried + cy.findByRole("tab", { name: "Tab 2" }).click(); + cy.get("@firstTabQuery").should("have.been.calledOnce"); + cy.get("@secondTabQuery").should("have.been.calledOnce"); + + // Go back to first tab, expect no additional queries + cy.findByRole("tab", { name: "Tab 1" }).click(); + cy.get("@firstTabQuery").should("have.been.calledOnce"); + cy.get("@secondTabQuery").should("have.been.calledOnce"); + + // Go to public dashboard + cy.request("PUT", "/api/setting/enable-public-sharing", { value: true }); + cy.request("POST", `/api/dashboard/1/public_link`).then( + ({ body: { uuid } }) => { + cy.intercept( + "GET", + `/api/public/dashboard/${uuid}/dashcard/1/card/1?parameters=%5B%5D`, + cy.spy().as("publicFirstTabQuery"), + ); + cy.intercept( + "GET", + `/api/public/dashboard/${uuid}/dashcard/2/card/2?parameters=%5B%5D`, + cy.spy().as("publicSecondTabQuery"), + ); + + cy.visit(`public/dashboard/${uuid}`); + }, + ); + + // Check first tab requests + cy.get("@publicFirstTabQuery").should("have.been.calledOnce"); + cy.get("@publicSecondTabQuery").should("not.have.been.called"); + + // Visit second tab and confirm only second card was queried + cy.findByRole("tab", { name: "Tab 2" }).click(); + cy.get("@publicFirstTabQuery").should("have.been.calledOnce"); + cy.get("@publicSecondTabQuery").should("have.been.calledOnce"); + }); }); describeWithSnowplow("scenarios > dashboard > tabs", () => { diff --git a/frontend/src/metabase/dashboard/actions/data-fetching.js b/frontend/src/metabase/dashboard/actions/data-fetching.js index d92b46d37e8..fefbd091287 100644 --- a/frontend/src/metabase/dashboard/actions/data-fetching.js +++ b/frontend/src/metabase/dashboard/actions/data-fetching.js @@ -34,6 +34,7 @@ import { getCanShowAutoApplyFiltersToast, getDashboardById, getDashCardById, + getSelectedTabId, } from "../selectors"; import { @@ -60,6 +61,9 @@ export const FETCH_DASHBOARD_CARD_DATA = export const CANCEL_FETCH_DASHBOARD_CARD_DATA = "metabase/dashboard/CANCEL_FETCH_DASHBOARD_CARD_DATA"; +export const FETCH_DASHBOARD_CARD_METADATA = + "metabase/dashboard/FETCH_DASHBOARD_CARD_METADATA"; + export const FETCH_CARD_DATA = "metabase/dashboard/FETCH_CARD_DATA"; export const CANCEL_FETCH_CARD_DATA = "metabase/dashboard/CANCEL_FETCH_CARD_DATA"; @@ -197,7 +201,16 @@ export const fetchDashboard = createThunkAction( } if (dashboardType === "normal" || dashboardType === "transient") { - await dispatch(loadMetadataForDashboard(result.ordered_cards)); + const selectedTabId = getSelectedTabId(getState()); + + const cards = + selectedTabId === undefined + ? result.ordered_cards + : result.ordered_cards.filter( + c => c.dashboard_tab_id === selectedTabId, + ); + + await dispatch(loadMetadataForDashboard(cards)); } const isUsingCachedResults = entities != null; @@ -409,14 +422,23 @@ export const fetchDashboardCardData = createThunkAction( FETCH_DASHBOARD_CARD_DATA, options => (dispatch, getState) => { const dashboard = getDashboardComplete(getState()); + const selectedTabId = getSelectedTabId(getState()); + const dashcardIds = []; const promises = getAllDashboardCards(dashboard) .map(({ card, dashcard }) => { - if (!isVirtualDashCard(dashcard)) { - return dispatch(fetchCardData(card, dashcard, options)).then(() => { - return dispatch(updateLoadingTitle()); - }); + if ( + isVirtualDashCard(dashcard) || + (selectedTabId !== undefined && + dashcard.dashboard_tab_id !== selectedTabId) + ) { + return; } + + dashcardIds.push(dashcard.id); + return dispatch(fetchCardData(card, dashcard, options)).then(() => { + return dispatch(updateLoadingTitle()); + }); }) .filter(p => !!p); @@ -428,7 +450,21 @@ export const fetchDashboardCardData = createThunkAction( dispatch(loadingComplete()); }); - return { currentTime: performance.now() }; + return { currentTime: performance.now(), dashcardIds }; + }, +); + +export const fetchDashboardCardMetadata = createThunkAction( + FETCH_DASHBOARD_CARD_METADATA, + () => async (dispatch, getState) => { + const allDashCards = getDashboardComplete(getState()).ordered_cards; + const selectedTabId = getSelectedTabId(getState()); + + const cards = allDashCards.filter( + dc => + selectedTabId !== undefined && dc.dashboard_tab_id === selectedTabId, + ); + await dispatch(loadMetadataForDashboard(cards)); }, ); diff --git a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx index 2de013a9daf..5e1bbd32ada 100644 --- a/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx +++ b/frontend/src/metabase/dashboard/components/Dashboard/Dashboard.jsx @@ -59,6 +59,7 @@ class Dashboard extends Component { dashboard: PropTypes.object, dashboardId: PropTypes.number, + dashcardData: PropTypes.object, selectedTabId: PropTypes.number, parameters: PropTypes.array, parameterValues: PropTypes.object, @@ -73,6 +74,7 @@ class Dashboard extends Component { cancelFetchDashboardCardData: PropTypes.func.isRequired, fetchDashboard: PropTypes.func.isRequired, fetchDashboardCardData: PropTypes.func.isRequired, + fetchDashboardCardMetadata: PropTypes.func.isRequired, initialize: PropTypes.func.isRequired, onRefreshPeriodChange: PropTypes.func, saveDashboardAndCards: PropTypes.func.isRequired, @@ -140,7 +142,16 @@ class Dashboard extends Component { if (prevProps.dashboardId !== this.props.dashboardId) { await this.loadDashboard(this.props.dashboardId); this.throttleParameterWidgetStickiness(); - } else if ( + return; + } + + if (!_.isEqual(prevProps.selectedTabId, this.props.selectedTabId)) { + this.props.fetchDashboardCardData(); + this.props.fetchDashboardCardMetadata(); + return; + } + + if ( !_.isEqual(prevProps.parameterValues, this.props.parameterValues) || (!prevProps.dashboard && this.props.dashboard) ) { diff --git a/frontend/src/metabase/dashboard/reducers.js b/frontend/src/metabase/dashboard/reducers.js index 59ba8eeeecb..edebfb786ec 100644 --- a/frontend/src/metabase/dashboard/reducers.js +++ b/frontend/src/metabase/dashboard/reducers.js @@ -43,8 +43,9 @@ import { UNDO_REMOVE_CARD_FROM_DASH, SHOW_AUTO_APPLY_FILTERS_TOAST, tabsReducer, + SET_LOADING_DASHCARDS_COMPLETE, } from "./actions"; -import { isVirtualDashCard, syncParametersAndEmbeddingParams } from "./utils"; +import { syncParametersAndEmbeddingParams } from "./utils"; import { INITIAL_DASHBOARD_STATE } from "./constants"; const dashboardId = handleActions( @@ -367,26 +368,19 @@ const loadingDashCards = handleActions( loadingStatus: "idle", }), }, - [FETCH_DASHBOARD]: { - next: (state, { payload }) => { - const cardIds = Object.values(payload.entities.dashcard || {}) - .filter(dc => !isVirtualDashCard(dc)) - .map(dc => dc.id); + [FETCH_DASHBOARD_CARD_DATA]: { + next: (state, { payload: { currentTime, dashcardIds } }) => { + const loadingIds = Array.isArray(dashcardIds) ? dashcardIds : []; + return { ...state, - dashcardIds: cardIds, - loadingIds: cardIds, - loadingStatus: "idle", + dashcardIds: loadingIds, + loadingIds, + loadingStatus: loadingIds.length > 0 ? "running" : "idle", + startTime: loadingIds.length > 0 ? currentTime : null, }; }, }, - [FETCH_DASHBOARD_CARD_DATA]: { - next: (state, { payload: { currentTime } }) => ({ - ...state, - loadingStatus: state.loadingIds.length > 0 ? "running" : "idle", - startTime: state.loadingIds.length > 0 ? currentTime : null, - }), - }, [FETCH_CARD_DATA]: { next: (state, { payload: { dashcard_id, currentTime } }) => { const loadingIds = state.loadingIds.filter(id => id !== dashcard_id); @@ -409,6 +403,15 @@ const loadingDashCards = handleActions( }; }, }, + [SET_LOADING_DASHCARDS_COMPLETE]: { + next: state => { + return { + ...state, + loadingIds: [], + loadingStatus: "complete", + }; + }, + }, [RESET]: { next: state => ({ ...state, diff --git a/frontend/src/metabase/dashboard/reducers.unit.spec.js b/frontend/src/metabase/dashboard/reducers.unit.spec.js index 9b3b019b01a..39b100b07b1 100644 --- a/frontend/src/metabase/dashboard/reducers.unit.spec.js +++ b/frontend/src/metabase/dashboard/reducers.unit.spec.js @@ -251,7 +251,7 @@ describe("dashboard reducers", () => { }, { type: FETCH_DASHBOARD_CARD_DATA, - payload: { currentTime: 100 }, + payload: { currentTime: 100, dashcardIds }, }, ), ).toMatchObject({ diff --git a/frontend/src/metabase/public/containers/PublicDashboard.jsx b/frontend/src/metabase/public/containers/PublicDashboard.jsx index a84931a76ec..a89fff7d343 100644 --- a/frontend/src/metabase/public/containers/PublicDashboard.jsx +++ b/frontend/src/metabase/public/containers/PublicDashboard.jsx @@ -86,7 +86,9 @@ class PublicDashboard extends Component { initialize(); try { await fetchDashboard(uuid || token, location.query); - await fetchDashboardCardData({ reload: false, clearCache: true }); + if (this.props.dashboard.ordered_tabs.length === 0) { + await fetchDashboardCardData({ reload: false, clearCache: true }); + } } catch (error) { console.error(error); setErrorPage(error); @@ -106,6 +108,12 @@ class PublicDashboard extends Component { 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 }); } diff --git a/src/metabase/automagic_dashboards/comparison.clj b/src/metabase/automagic_dashboards/comparison.clj index 49c086f55c3..f7d26dfdca9 100644 --- a/src/metabase/automagic_dashboards/comparison.clj +++ b/src/metabase/automagic_dashboards/comparison.clj @@ -94,16 +94,16 @@ series (-> card-right (update :name #(format "%s (%s)" % (comparison-name right))) vector)] - (update dashboard :ordered_cards conj {:col 0 - :row row - :size_x populate/grid-width - :size_y height - :card card - :card_id (:id card) - :series series - :visualization_settings {:graph.y_axis.auto_split false - :graph.series_labels [(:name card) (:name (first series))]} - :id (gensym)})) + (update dashboard :ordered_cards conj (merge (populate/card-defaults) + {:col 0 + :row row + :size_x populate/grid-width + :size_y height + :card card + :card_id (:id card) + :series series + :visualization_settings {:graph.y_axis.auto_split false + :graph.series_labels [(:name card) (:name (first series))]}}))) (let [width (/ populate/grid-width 2) series-left (map clone-card (:series card-left)) series-right (map clone-card (:series card-right)) @@ -114,24 +114,25 @@ (not (multiseries? card-right)) (assoc-in [:visualization_settings :graph.colors] [color-right]))] (-> dashboard - (update :ordered_cards conj {:col 0 - :row row - :size_x width - :size_y height - :card card-left - :card_id (:id card-left) - :series series-left - :visualization_settings {} - :id (gensym)}) - (update :ordered_cards conj {:col width - :row row - :size_x width - :size_y height - :card card-right - :card_id (:id card-right) - :series series-right - :visualization_settings {} - :id (gensym)}))))) + (update :ordered_cards conj (merge (populate/card-defaults) + {:col 0 + :row row + :size_x width + :size_y height + :card card-left + :card_id (:id card-left) + :series series-left + :visualization_settings {}})) + (update :ordered_cards conj (merge (populate/card-defaults) + {:col width + :row row + :size_x width + :size_y height + :card card-right + :card_id (:id card-right) + :series series-right + :visualization_settings {}})))))) + (populate/add-text-card dashboard {:text (:text card) :width (/ populate/grid-width 2) :height (:height card) diff --git a/src/metabase/automagic_dashboards/populate.clj b/src/metabase/automagic_dashboards/populate.clj index a513487aee2..01068810423 100644 --- a/src/metabase/automagic_dashboards/populate.clj +++ b/src/metabase/automagic_dashboards/populate.clj @@ -126,6 +126,14 @@ y_label (assoc :graph.y_axis.title_text y_label)))})) + +(defn card-defaults + "Default properties for a dashcard on magic dashboard." + [] + {:id (gensym) + :dashboard_tab_id nil + :visualization_settings {}}) + (defn- add-card "Add a card to dashboard `dashboard` at position [`x`, `y`]." [dashboard {:keys [title description dataset_query width height id] :as card} [x y]] @@ -137,33 +145,34 @@ :id (or id (gensym))} (merge (visualization-settings card)) card/populate-query-fields)] - (update dashboard :ordered_cards conj {:col y - :row x - :size_x width - :size_y height - :card card - :card_id (:id card) - :visualization_settings {} - :id (gensym)}))) + (update dashboard :ordered_cards conj + (merge (card-defaults) + {:col y + :row x + :size_x width + :size_y height + :card card + :card_id (:id card) + :visualization_settings {}})))) (defn add-text-card "Add a text card to dashboard `dashboard` at position [`x`, `y`]." [dashboard {:keys [text width height visualization-settings]} [x y]] (update dashboard :ordered_cards conj - {:creator_id api/*current-user-id* - :visualization_settings (merge - {:text text - :virtual_card {:name nil - :display :text - :dataset_query {} - :visualization_settings {}}} - visualization-settings) - :col y - :row x - :size_x width - :size_y height - :card nil - :id (gensym)})) + (merge (card-defaults) + {:creator_id api/*current-user-id* + :visualization_settings (merge + {:text text + :virtual_card {:name nil + :display :text + :dataset_query {} + :visualization_settings {}}} + visualization-settings) + :col y + :row x + :size_x width + :size_y height + :card nil}))) (defn- make-grid [width height] diff --git a/test/metabase/api/automagic_dashboards_test.clj b/test/metabase/api/automagic_dashboards_test.clj index 799596f2b5d..8cac240a5c6 100644 --- a/test/metabase/api/automagic_dashboards_test.clj +++ b/test/metabase/api/automagic_dashboards_test.clj @@ -15,10 +15,25 @@ [metabase.transforms.materialize :as tf.materialize] [metabase.transforms.specs :as tf.specs] [metabase.util :as u] + [schema.core :as s] [toucan2.tools.with-temp :as t2.with-temp])) (use-fixtures :once (fixtures/initialize :db :web-server :test-users :test-users-personal-collections)) +(defn- ordered-cards-schema-check + [ordered_cards] + (testing "check if all cards in ordered_cards contain the required fields" + (doseq [card ordered_cards] + (is (schema= {:id (s/cond-pre s/Str s/Int) + :dashboard_tab_id (s/maybe s/Int) + :row s/Int + :col s/Int + :size_x s/Int + :size_y s/Int + :visualization_settings (s/maybe (s/named clojure.lang.IPersistentMap "valid map")) + s/Any s/Any} + card))))) + (defn- api-call ([template args] (api-call template args (constantly true))) @@ -30,7 +45,9 @@ (mt/with-test-user :rasta (with-dashboard-cleanup (let [api-endpoint (apply format (str "automagic-dashboards/" template) args) - result (validation-fn (mt/user-http-request :rasta :get 200 api-endpoint))] + resp (mt/user-http-request :rasta :get 200 api-endpoint) + _ (ordered-cards-schema-check (:ordered_cards resp)) + result (validation-fn resp)] (when (and result (try (testing "Endpoint should return 403 if user does not have permissions" -- GitLab