From 1fe8fb58e4d542b0c1e5c3585b647d19c90c1cd7 Mon Sep 17 00:00:00 2001 From: Anton Kulyk <kuliks.anton@gmail.com> Date: Wed, 15 Jun 2022 11:50:21 +0100 Subject: [PATCH] Add unit-tests for `initializeQB` action (#23262) * Move initializeQB to its own file * Extensive test coverage for `initializeQB` action * Add tests for native questions with snippets * Extract `baseSetup` helper * Add coverage for blank questions * Fix missing coverage --- frontend/src/metabase-types/types/Query.ts | 4 + .../query_builder/actions/{ => core}/core.js | 364 +------- .../query_builder/actions/core/index.ts | 2 + .../actions/core/initializeQB.js | 364 ++++++++ .../actions/core/initializeQB.unit.spec.ts | 825 ++++++++++++++++++ 5 files changed, 1206 insertions(+), 353 deletions(-) rename frontend/src/metabase/query_builder/actions/{ => core}/core.js (58%) create mode 100644 frontend/src/metabase/query_builder/actions/core/index.ts create mode 100644 frontend/src/metabase/query_builder/actions/core/initializeQB.js create mode 100644 frontend/src/metabase/query_builder/actions/core/initializeQB.unit.spec.ts diff --git a/frontend/src/metabase-types/types/Query.ts b/frontend/src/metabase-types/types/Query.ts index 9b533783e4f..355b486e844 100644 --- a/frontend/src/metabase-types/types/Query.ts +++ b/frontend/src/metabase-types/types/Query.ts @@ -64,6 +64,10 @@ export type TemplateTag = { "widget-type"?: ParameterType; required?: boolean; default?: string; + + // Snippet specific + "snippet-id"?: number; + "snippet-name"?: string; }; export type TemplateTags = { [key: TemplateTagName]: TemplateTag }; diff --git a/frontend/src/metabase/query_builder/actions/core.js b/frontend/src/metabase/query_builder/actions/core/core.js similarity index 58% rename from frontend/src/metabase/query_builder/actions/core.js rename to frontend/src/metabase/query_builder/actions/core/core.js index 8481c2e3a4d..704e348cbbb 100644 --- a/frontend/src/metabase/query_builder/actions/core.js +++ b/frontend/src/metabase/query_builder/actions/core/core.js @@ -1,15 +1,9 @@ import _ from "underscore"; -import { assocIn, getIn } from "icepick"; -import querystring from "querystring"; +import { assocIn } from "icepick"; import { createAction } from "redux-actions"; -import { normalize } from "cljs/metabase.mbql.js"; import * as MetabaseAnalytics from "metabase/lib/analytics"; -import { - deserializeCardFromUrl, - loadCard, - startNewCard, -} from "metabase/lib/card"; +import { loadCard } from "metabase/lib/card"; import { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils"; import { shouldOpenInBlankWindow } from "metabase/lib/dom"; import * as Urls from "metabase/lib/urls"; @@ -18,29 +12,21 @@ import { createThunkAction } from "metabase/lib/redux"; import { cardIsEquivalent, cardQueryIsEquivalent } from "metabase/meta/Card"; -import { DashboardApi } from "metabase/services"; - import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/utils"; import { getPersistableDefaultSettingsForSeries } from "metabase/visualizations/lib/settings/visualization"; -import { openUrl, setErrorPage } from "metabase/redux/app"; +import { openUrl } from "metabase/redux/app"; import { setRequestUnloaded } from "metabase/redux/requests"; import { loadMetadataForQueries } from "metabase/redux/metadata"; import { getMetadata } from "metabase/selectors/metadata"; -import Databases from "metabase/entities/databases"; import Questions from "metabase/entities/questions"; -import Snippets from "metabase/entities/snippets"; import { fetchAlertsForQuestion } from "metabase/alert/alert"; -import { getValueAndFieldIdPopulatedParametersFromCard } from "metabase/parameters/utils/cards"; -import { hasMatchingParameters } from "metabase/parameters/utils/dashboards"; -import { getParameterValuesByIdFromQueryParams } from "metabase/parameters/utils/parameter-values"; - import Question from "metabase-lib/lib/Question"; import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; -import { trackNewQuestionSaved } from "../analytics"; +import { trackNewQuestionSaved } from "../../analytics"; import { getCard, getFirstQueryResult, @@ -53,47 +39,18 @@ import { getResultsMetadata, getTransformedSeries, isBasedOnExistingQuestion, -} from "../selectors"; -import { - getNextTemplateTagVisibilityState, - getQueryBuilderModeFromLocation, -} from "../utils"; +} from "../../selectors"; +import { getNextTemplateTagVisibilityState } from "../../utils"; -import { redirectToNewQuestionFlow, updateUrl } from "./navigation"; -import { setIsShowingTemplateTagsEditor } from "./native"; -import { zoomInRow } from "./object-detail"; -import { cancelQuery, clearQueryResult, runQuestionQuery } from "./querying"; -import { onCloseSidebars, setQueryBuilderMode } from "./ui"; +import { updateUrl } from "../navigation"; +import { setIsShowingTemplateTagsEditor } from "../native"; +import { zoomInRow } from "../object-detail"; +import { clearQueryResult, runQuestionQuery } from "../querying"; +import { onCloseSidebars, setQueryBuilderMode } from "../ui"; export const RESET_QB = "metabase/qb/RESET_QB"; export const resetQB = createAction(RESET_QB); -async function verifyMatchingDashcardAndParameters({ - dispatch, - dashboardId, - dashcardId, - cardId, - parameters, - metadata, -}) { - try { - const dashboard = await DashboardApi.get({ dashId: dashboardId }); - if ( - !hasMatchingParameters({ - dashboard, - dashcardId, - cardId, - parameters, - metadata, - }) - ) { - dispatch(setErrorPage({ status: 403 })); - } - } catch (error) { - dispatch(setErrorPage(error)); - } -} - /** * Saves to `visualization_settings` property of a question those visualization settings that * 1) don't have a value yet and 2) have `persistDefault` flag enabled. @@ -127,305 +84,6 @@ function hasNewColumns(question, queryResult) { return _.difference(nextColumns, previousColumns).length > 0; } -export const INITIALIZE_QB = "metabase/qb/INITIALIZE_QB"; -export const initializeQB = (location, params) => { - return async (dispatch, getState) => { - const queryParams = location.query; - // do this immediately to ensure old state is cleared before the user sees it - dispatch(resetQB()); - dispatch(cancelQuery()); - - const { currentUser } = getState(); - - const cardId = Urls.extractEntityId(params.slug); - let card, originalCard; - - const { - mode: queryBuilderMode, - ...otherUiControls - } = getQueryBuilderModeFromLocation(location); - const uiControls = { - isEditing: false, - isShowingTemplateTagsEditor: false, - queryBuilderMode, - ...otherUiControls, - }; - - // load up or initialize the card we'll be working on - let options = {}; - let serializedCard; - // hash can contain either query params starting with ? or a base64 serialized card - if (location.hash) { - const hash = location.hash.replace(/^#/, ""); - if (hash.charAt(0) === "?") { - options = querystring.parse(hash.substring(1)); - } else { - serializedCard = hash; - } - } - - let preserveParameters = false; - let snippetFetch; - if (cardId || serializedCard) { - // existing card being loaded - try { - // if we have a serialized card then unpack and use it - if (serializedCard) { - card = deserializeCardFromUrl(serializedCard); - // if serialized query has database we normalize syntax to support older mbql - if (card.dataset_query.database != null) { - card.dataset_query = normalize(card.dataset_query); - } - } else { - card = {}; - } - - const deserializedCard = card; - - // load the card either from `cardId` parameter or the serialized card - if (cardId) { - card = await loadCard(cardId); - // when we are loading from a card id we want an explicit clone of the card we loaded which is unmodified - originalCard = Utils.copy(card); - // for showing the "started from" lineage correctly when adding filters/breakouts and when going back and forth - // in browser history, the original_card_id has to be set for the current card (simply the id of card itself for now) - card.original_card_id = card.id; - - // if there's a card in the url, it may have parameters from a dashboard - if (deserializedCard && deserializedCard.parameters) { - const metadata = getMetadata(getState()); - const { dashboardId, dashcardId, parameters } = deserializedCard; - verifyMatchingDashcardAndParameters({ - dispatch, - dashboardId, - dashcardId, - cardId, - parameters, - metadata, - }); - - card.parameters = parameters; - card.dashboardId = dashboardId; - card.dashcardId = dashcardId; - } - } else if (card.original_card_id) { - const deserializedCard = card; - // deserialized card contains the card id, so just populate originalCard - originalCard = await loadCard(card.original_card_id); - - if (cardIsEquivalent(deserializedCard, originalCard)) { - card = Utils.copy(originalCard); - - if ( - !cardIsEquivalent(deserializedCard, originalCard, { - checkParameters: true, - }) - ) { - const metadata = getMetadata(getState()); - const { dashboardId, dashcardId, parameters } = deserializedCard; - verifyMatchingDashcardAndParameters({ - dispatch, - dashboardId, - dashcardId, - cardId: card.id, - parameters, - metadata, - }); - - card.parameters = parameters; - card.dashboardId = dashboardId; - card.dashcardId = dashcardId; - } - } - } - // if this card has any snippet tags we might need to fetch snippets pending permissions - if ( - Object.values( - getIn(card, ["dataset_query", "native", "template-tags"]) || {}, - ).filter(t => t.type === "snippet").length > 0 - ) { - const dbId = card.database_id; - let database = Databases.selectors.getObject(getState(), { - entityId: dbId, - }); - // if we haven't already loaded this database, block on loading dbs now so we can check write permissions - if (!database) { - await dispatch(Databases.actions.fetchList()); - database = Databases.selectors.getObject(getState(), { - entityId: dbId, - }); - } - - // database could still be missing if the user doesn't have any permissions - // if the user has native permissions against this db, fetch snippets - if (database && database.native_permissions === "write") { - snippetFetch = dispatch(Snippets.actions.fetchList()); - } - } - - MetabaseAnalytics.trackStructEvent( - "QueryBuilder", - "Query Loaded", - card.dataset_query.type, - ); - - // if we have deserialized card from the url AND loaded a card by id then the user should be dropped into edit mode - uiControls.isEditing = !!options.edit; - - // if this is the users first time loading a saved card on the QB then show them the newb modal - if (cardId && currentUser.is_qbnewb) { - uiControls.isShowingNewbModal = true; - MetabaseAnalytics.trackStructEvent("QueryBuilder", "Show Newb Modal"); - } - - if (card.archived) { - // use the error handler in App.jsx for showing "This question has been archived" message - dispatch( - setErrorPage({ - data: { - error_code: "archived", - }, - context: "query-builder", - }), - ); - card = null; - } - - if (!card.dataset && location.pathname.startsWith("/model")) { - dispatch( - setErrorPage({ - data: { - error_code: "not-found", - }, - context: "query-builder", - }), - ); - card = null; - } - - preserveParameters = true; - } catch (error) { - console.warn("initializeQb failed because of an error:", error); - card = null; - dispatch(setErrorPage(error)); - } - } else { - // we are starting a new/empty card - // if no options provided in the hash, redirect to the new question flow - if ( - !options.db && - !options.table && - !options.segment && - !options.metric - ) { - await dispatch(redirectToNewQuestionFlow()); - return; - } - - const databaseId = options.db ? parseInt(options.db) : undefined; - card = startNewCard("query", databaseId); - - // initialize parts of the query based on optional parameters supplied - if (card.dataset_query.query) { - if (options.table != null) { - card.dataset_query.query["source-table"] = parseInt(options.table); - } - if (options.segment != null) { - card.dataset_query.query.filter = [ - "segment", - parseInt(options.segment), - ]; - } - if (options.metric != null) { - // show the summarize sidebar for metrics - uiControls.isShowingSummarySidebar = true; - card.dataset_query.query.aggregation = [ - "metric", - parseInt(options.metric), - ]; - } - } - - MetabaseAnalytics.trackStructEvent( - "QueryBuilder", - "Query Started", - card.dataset_query.type, - ); - } - - /**** All actions are dispatched here ****/ - - // Fetch alerts for the current question if the question is saved - if (card && card.id != null) { - dispatch(fetchAlertsForQuestion(card.id)); - } - // Fetch the question metadata (blocking) - if (card) { - await dispatch(loadMetadataForCard(card)); - } - - let question = card && new Question(card, getMetadata(getState())); - if (question && question.isSaved()) { - // loading a saved question prevents auto-viz selection - question = question.lockDisplay(); - } - - if (question && question.isNative() && snippetFetch) { - await snippetFetch; - const snippets = Snippets.selectors.getList(getState()); - question = question.setQuery( - question.query().updateQueryTextWithNewSnippetNames(snippets), - ); - } - - card = question && question.card(); - const metadata = getMetadata(getState()); - const parameters = getValueAndFieldIdPopulatedParametersFromCard( - card, - metadata, - ); - const parameterValues = getParameterValuesByIdFromQueryParams( - parameters, - queryParams, - metadata, - ); - - const objectId = params?.objectId || queryParams?.objectId; - - // Update the question to Redux state together with the initial state of UI controls - dispatch.action(INITIALIZE_QB, { - card, - originalCard, - uiControls, - parameterValues, - objectId, - }); - - // if we have loaded up a card that we can run then lets kick that off as well - // but don't bother for "notebook" mode - if (question && uiControls.queryBuilderMode !== "notebook") { - if (question.canRun()) { - // NOTE: timeout to allow Parameters widget to set parameterValues - setTimeout( - () => - // TODO Atte Keinänen 5/31/17: Check if it is dangerous to create a question object without metadata - dispatch(runQuestionQuery({ shouldUpdateUrl: false })), - 0, - ); - } - - // clean up the url and make sure it reflects our card state - dispatch( - updateUrl(card, { - replaceState: true, - preserveParameters, - objectId, - }), - ); - } - }; -}; - export const loadMetadataForCard = card => (dispatch, getState) => { const metadata = getMetadata(getState()); const question = new Question(card, metadata); diff --git a/frontend/src/metabase/query_builder/actions/core/index.ts b/frontend/src/metabase/query_builder/actions/core/index.ts new file mode 100644 index 00000000000..003ea700276 --- /dev/null +++ b/frontend/src/metabase/query_builder/actions/core/index.ts @@ -0,0 +1,2 @@ +export * from "./core"; +export * from "./initializeQB"; diff --git a/frontend/src/metabase/query_builder/actions/core/initializeQB.js b/frontend/src/metabase/query_builder/actions/core/initializeQB.js new file mode 100644 index 00000000000..17b492d492f --- /dev/null +++ b/frontend/src/metabase/query_builder/actions/core/initializeQB.js @@ -0,0 +1,364 @@ +import _ from "underscore"; +import { getIn } from "icepick"; +import querystring from "querystring"; +import { normalize } from "cljs/metabase.mbql.js"; + +import * as MetabaseAnalytics from "metabase/lib/analytics"; +import { + deserializeCardFromUrl, + loadCard, + startNewCard, +} from "metabase/lib/card"; +import * as Urls from "metabase/lib/urls"; +import Utils from "metabase/lib/utils"; + +import { cardIsEquivalent } from "metabase/meta/Card"; + +import { DashboardApi } from "metabase/services"; + +import { setErrorPage } from "metabase/redux/app"; +import { getMetadata } from "metabase/selectors/metadata"; + +import Databases from "metabase/entities/databases"; +import Snippets from "metabase/entities/snippets"; +import { fetchAlertsForQuestion } from "metabase/alert/alert"; + +import { getValueAndFieldIdPopulatedParametersFromCard } from "metabase/parameters/utils/cards"; +import { hasMatchingParameters } from "metabase/parameters/utils/dashboards"; +import { getParameterValuesByIdFromQueryParams } from "metabase/parameters/utils/parameter-values"; + +import Question from "metabase-lib/lib/Question"; +import { getQueryBuilderModeFromLocation } from "../../utils"; + +import { redirectToNewQuestionFlow, updateUrl } from "../navigation"; +import { cancelQuery, runQuestionQuery } from "../querying"; + +import { loadMetadataForCard, resetQB } from "./core"; + +async function verifyMatchingDashcardAndParameters({ + dispatch, + dashboardId, + dashcardId, + cardId, + parameters, + metadata, +}) { + try { + const dashboard = await DashboardApi.get({ dashId: dashboardId }); + if ( + !hasMatchingParameters({ + dashboard, + dashcardId, + cardId, + parameters, + metadata, + }) + ) { + dispatch(setErrorPage({ status: 403 })); + } + } catch (error) { + dispatch(setErrorPage(error)); + } +} + +export const INITIALIZE_QB = "metabase/qb/INITIALIZE_QB"; +export const initializeQB = (location, params) => { + return async (dispatch, getState) => { + const queryParams = location.query; + // do this immediately to ensure old state is cleared before the user sees it + dispatch(resetQB()); + dispatch(cancelQuery()); + + const { currentUser } = getState(); + + const cardId = Urls.extractEntityId(params.slug); + let card, originalCard; + + const { + mode: queryBuilderMode, + ...otherUiControls + } = getQueryBuilderModeFromLocation(location); + const uiControls = { + isEditing: false, + isShowingTemplateTagsEditor: false, + queryBuilderMode, + ...otherUiControls, + }; + + // load up or initialize the card we'll be working on + let options = {}; + let serializedCard; + // hash can contain either query params starting with ? or a base64 serialized card + if (location.hash) { + const hash = location.hash.replace(/^#/, ""); + if (hash.charAt(0) === "?") { + options = querystring.parse(hash.substring(1)); + } else { + serializedCard = hash; + } + } + + let preserveParameters = false; + let snippetFetch; + if (cardId || serializedCard) { + // existing card being loaded + try { + // if we have a serialized card then unpack and use it + if (serializedCard) { + card = deserializeCardFromUrl(serializedCard); + // if serialized query has database we normalize syntax to support older mbql + if (card.dataset_query.database != null) { + card.dataset_query = normalize(card.dataset_query); + } + } else { + card = {}; + } + + const deserializedCard = card; + + // load the card either from `cardId` parameter or the serialized card + if (cardId) { + card = await loadCard(cardId); + // when we are loading from a card id we want an explicit clone of the card we loaded which is unmodified + originalCard = Utils.copy(card); + // for showing the "started from" lineage correctly when adding filters/breakouts and when going back and forth + // in browser history, the original_card_id has to be set for the current card (simply the id of card itself for now) + card.original_card_id = card.id; + + // if there's a card in the url, it may have parameters from a dashboard + if (deserializedCard && deserializedCard.parameters) { + const metadata = getMetadata(getState()); + const { dashboardId, dashcardId, parameters } = deserializedCard; + verifyMatchingDashcardAndParameters({ + dispatch, + dashboardId, + dashcardId, + cardId, + parameters, + metadata, + }); + + card.parameters = parameters; + card.dashboardId = dashboardId; + card.dashcardId = dashcardId; + } + } else if (card.original_card_id) { + const deserializedCard = card; + // deserialized card contains the card id, so just populate originalCard + originalCard = await loadCard(card.original_card_id); + + if (cardIsEquivalent(deserializedCard, originalCard)) { + card = Utils.copy(originalCard); + + if ( + !cardIsEquivalent(deserializedCard, originalCard, { + checkParameters: true, + }) + ) { + const metadata = getMetadata(getState()); + const { dashboardId, dashcardId, parameters } = deserializedCard; + verifyMatchingDashcardAndParameters({ + dispatch, + dashboardId, + dashcardId, + cardId: card.id, + parameters, + metadata, + }); + + card.parameters = parameters; + card.dashboardId = dashboardId; + card.dashcardId = dashcardId; + } + } + } + // if this card has any snippet tags we might need to fetch snippets pending permissions + if ( + Object.values( + getIn(card, ["dataset_query", "native", "template-tags"]) || {}, + ).filter(t => t.type === "snippet").length > 0 + ) { + const dbId = getIn(card, ["dataset_query", "database"]); + let database = Databases.selectors.getObject(getState(), { + entityId: dbId, + }); + // if we haven't already loaded this database, block on loading dbs now so we can check write permissions + if (!database) { + await dispatch(Databases.actions.fetchList()); + database = Databases.selectors.getObject(getState(), { + entityId: dbId, + }); + } + + // database could still be missing if the user doesn't have any permissions + // if the user has native permissions against this db, fetch snippets + if (database && database.native_permissions === "write") { + snippetFetch = dispatch(Snippets.actions.fetchList()); + } + } + + MetabaseAnalytics.trackStructEvent( + "QueryBuilder", + "Query Loaded", + card.dataset_query.type, + ); + + // if we have deserialized card from the url AND loaded a card by id then the user should be dropped into edit mode + uiControls.isEditing = !!options.edit; + + // if this is the users first time loading a saved card on the QB then show them the newb modal + if (cardId && currentUser.is_qbnewb) { + uiControls.isShowingNewbModal = true; + MetabaseAnalytics.trackStructEvent("QueryBuilder", "Show Newb Modal"); + } + + if (card.archived) { + // use the error handler in App.jsx for showing "This question has been archived" message + dispatch( + setErrorPage({ + data: { + error_code: "archived", + }, + context: "query-builder", + }), + ); + card = null; + } + + if (!card?.dataset && location.pathname.startsWith("/model")) { + dispatch( + setErrorPage({ + data: { + error_code: "not-found", + }, + context: "query-builder", + }), + ); + card = null; + } + + preserveParameters = true; + } catch (error) { + console.warn("initializeQb failed because of an error:", error); + card = null; + dispatch(setErrorPage(error)); + } + } else { + // we are starting a new/empty card + // if no options provided in the hash, redirect to the new question flow + if ( + !options.db && + !options.table && + !options.segment && + !options.metric + ) { + await dispatch(redirectToNewQuestionFlow()); + return; + } + + const databaseId = options.db ? parseInt(options.db) : undefined; + card = startNewCard("query", databaseId); + + // initialize parts of the query based on optional parameters supplied + if (card.dataset_query.query) { + if (options.table != null) { + card.dataset_query.query["source-table"] = parseInt(options.table); + } + if (options.segment != null) { + card.dataset_query.query.filter = [ + "segment", + parseInt(options.segment), + ]; + } + if (options.metric != null) { + // show the summarize sidebar for metrics + uiControls.isShowingSummarySidebar = true; + card.dataset_query.query.aggregation = [ + "metric", + parseInt(options.metric), + ]; + } + } + + MetabaseAnalytics.trackStructEvent( + "QueryBuilder", + "Query Started", + card.dataset_query.type, + ); + } + + /**** All actions are dispatched here ****/ + + // Fetch alerts for the current question if the question is saved + if (card && card.id != null) { + dispatch(fetchAlertsForQuestion(card.id)); + } + // Fetch the question metadata (blocking) + if (card) { + await dispatch(loadMetadataForCard(card)); + } + + let question = card && new Question(card, getMetadata(getState())); + if (question && question.isSaved()) { + // loading a saved question prevents auto-viz selection + question = question.lockDisplay(); + } + + if (question && question.isNative() && snippetFetch) { + await snippetFetch; + const snippets = Snippets.selectors.getList(getState()); + question = question.setQuery( + question.query().updateQueryTextWithNewSnippetNames(snippets), + ); + } + + card = question && question.card(); + const metadata = getMetadata(getState()); + const parameters = getValueAndFieldIdPopulatedParametersFromCard( + card, + metadata, + ); + const parameterValues = getParameterValuesByIdFromQueryParams( + parameters, + queryParams, + metadata, + ); + + const objectId = params?.objectId || queryParams?.objectId; + + // Update the question to Redux state together with the initial state of UI controls + dispatch({ + type: INITIALIZE_QB, + payload: { + card, + originalCard, + uiControls, + parameterValues, + objectId, + }, + }); + + // if we have loaded up a card that we can run then lets kick that off as well + // but don't bother for "notebook" mode + if (question && uiControls.queryBuilderMode !== "notebook") { + if (question.canRun()) { + // NOTE: timeout to allow Parameters widget to set parameterValues + setTimeout( + () => + // TODO Atte Keinänen 5/31/17: Check if it is dangerous to create a question object without metadata + dispatch(runQuestionQuery({ shouldUpdateUrl: false })), + 0, + ); + } + + // clean up the url and make sure it reflects our card state + dispatch( + updateUrl(card, { + replaceState: true, + preserveParameters, + objectId, + }), + ); + } + }; +}; diff --git a/frontend/src/metabase/query_builder/actions/core/initializeQB.unit.spec.ts b/frontend/src/metabase/query_builder/actions/core/initializeQB.unit.spec.ts new file mode 100644 index 00000000000..c1fdadbdfc4 --- /dev/null +++ b/frontend/src/metabase/query_builder/actions/core/initializeQB.unit.spec.ts @@ -0,0 +1,825 @@ +import { LocationDescriptorObject } from "history"; +import _ from "underscore"; +import xhrMock from "xhr-mock"; + +import * as CardLib from "metabase/lib/card"; +import * as Urls from "metabase/lib/urls"; + +import * as alert from "metabase/alert/alert"; +import Databases from "metabase/entities/databases"; +import Snippets from "metabase/entities/snippets"; +import { setErrorPage } from "metabase/redux/app"; + +import Question from "metabase-lib/lib/Question"; +import NativeQuery from "metabase-lib/lib/queries/NativeQuery"; +import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery"; +import { + getAdHocQuestion, + getSavedStructuredQuestion, + getSavedNativeQuestion, + getUnsavedNativeQuestion, + getStructuredModel, + getNativeModel, +} from "metabase-lib/mocks"; + +import { User } from "metabase-types/api"; +import { createMockUser } from "metabase-types/api/mocks"; +import { Card, NativeDatasetQuery } from "metabase-types/types/Card"; +import { TemplateTag } from "metabase-types/types/Query"; +import { createMockState } from "metabase-types/store/mocks"; + +import { + SAMPLE_DATABASE, + ORDERS, + state as entitiesState, + metadata, +} from "__support__/sample_database_fixture"; + +import * as navigation from "../navigation"; +import * as querying from "../querying"; + +import * as core from "./core"; +import { initializeQB } from "./initializeQB"; + +type BaseSetupOpts = { + user?: User; + location: LocationDescriptorObject; + params: Record<string, unknown>; +}; + +async function baseSetup({ user, location, params }: BaseSetupOpts) { + jest.useFakeTimers(); + + const dispatch = jest.fn().mockReturnValue({ mock: "mock" }); + + const state = { + ...createMockState(), + ...entitiesState, + }; + if (user) { + state.currentUser = user; + } + const getState = () => state; + + await initializeQB(location, params)(dispatch, getState); + jest.runAllTimers(); + + const actions = dispatch.mock.calls.find( + call => call[0]?.type === "metabase/qb/INITIALIZE_QB", + ); + const hasDispatchedInitAction = Array.isArray(actions); + const result = hasDispatchedInitAction ? actions[0].payload : null; + + return { dispatch, state, result }; +} + +function getLocationForQuestion( + question: Question, + extra: LocationDescriptorObject = {}, +): LocationDescriptorObject { + const card = question.card(); + const isSaved = question.isSaved(); + return { + pathname: isSaved ? Urls.question(card) : Urls.serializedQuestion(card), + hash: !isSaved ? CardLib.serializeCardForUrl(card) : "", + query: {}, + ...extra, + }; +} + +function getQueryParamsForQuestion( + question: Question, + extra: Record<string, unknown> = {}, +): Record<string, unknown> { + if (!question.isSaved()) { + return extra; + } + const id = question.id(); + const name = question.displayName(); + return { + slug: `${id}-${name}`, + ...extra, + }; +} + +type SetupOpts = Omit<BaseSetupOpts, "location" | "params"> & { + question: Question; + location?: LocationDescriptorObject; + params?: Record<string, unknown>; +}; + +async function setup({ + question, + location = getLocationForQuestion(question), + params = getQueryParamsForQuestion(question), + ...opts +}: SetupOpts) { + const card = question.card(); + + if ("id" in card) { + xhrMock.get(`/api/card/${card.id}`, { + body: JSON.stringify(card), + }); + } + + return baseSetup({ location, params, ...opts }); +} + +const SNIPPET: TemplateTag = { + id: "id", + "snippet-id": 1, + "display-name": "foo", + name: "foo", + "snippet-name": "foo", + type: "snippet", +}; + +const NATIVE_QUESTION_WITH_SNIPPET: NativeDatasetQuery = { + type: "native", + database: 1, + native: { + query: "select * from orders {{ foo }}", + "template-tags": { + foo: SNIPPET, + }, + }, +}; + +describe("QB Actions > initializeQB", () => { + beforeAll(() => { + console.warn = jest.fn(); + }); + + beforeEach(() => { + xhrMock.setup(); + }); + + afterEach(() => { + xhrMock.teardown(); + jest.restoreAllMocks(); + }); + + const TEST_CASE = { + SAVED_STRUCTURED_QUESTION: { + question: getSavedStructuredQuestion(), + questionType: "saved structured question", + }, + UNSAVED_STRUCTURED_QUESTION: { + question: getAdHocQuestion(), + questionType: "ad-hoc structured question", + }, + + SAVED_NATIVE_QUESTION: { + question: getSavedNativeQuestion(), + questionType: "saved native question", + }, + UNSAVED_NATIVE_QUESTION: { + question: getUnsavedNativeQuestion(), + questionType: "unsaved native question", + }, + + STRUCTURED_MODEL: { + question: getStructuredModel(), + questionType: "structured model", + }, + NATIVE_MODEL: { + question: getNativeModel(), + questionType: "native model", + }, + }; + + const ALL_TEST_CASES = Object.values(TEST_CASE); + + const SAVED_QUESTION_TEST_CASES = [ + TEST_CASE.SAVED_STRUCTURED_QUESTION, + TEST_CASE.SAVED_NATIVE_QUESTION, + ]; + + const UNSAVED_QUESTION_TEST_CASES = [ + TEST_CASE.UNSAVED_STRUCTURED_QUESTION, + TEST_CASE.UNSAVED_NATIVE_QUESTION, + ]; + + const MODEL_TEST_CASES = [TEST_CASE.STRUCTURED_MODEL, TEST_CASE.NATIVE_MODEL]; + + const NATIVE_SNIPPETS_TEST_CASES = [ + { + question: getSavedNativeQuestion({ + dataset_query: NATIVE_QUESTION_WITH_SNIPPET, + }), + questionType: "saved native question with snippets", + }, + { + question: getUnsavedNativeQuestion({ + dataset_query: NATIVE_QUESTION_WITH_SNIPPET, + }), + questionType: "unsaved native question with snippets", + }, + ]; + + describe("common", () => { + ALL_TEST_CASES.forEach(testCase => { + const { question, questionType } = testCase; + + describe(questionType, () => { + it("resets QB state before doing anything", async () => { + const resetQBSpy = jest.spyOn(core, "resetQB"); + await setup({ question }); + expect(resetQBSpy).toHaveBeenCalledTimes(1); + }); + + it("cancels running query before doing anything", async () => { + const cancelQuerySpy = jest.spyOn(querying, "cancelQuery"); + await setup({ question }); + expect(cancelQuerySpy).toHaveBeenCalledTimes(1); + }); + + it("fetches question metadata", async () => { + const loadMetadataForCardSpy = jest.spyOn( + core, + "loadMetadataForCard", + ); + + await setup({ question }); + + expect(loadMetadataForCardSpy).toHaveBeenCalledTimes(1); + expect(loadMetadataForCardSpy).toHaveBeenCalledWith( + expect.objectContaining(question.card()), + ); + }); + + it("runs question query in view mode", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + await setup({ question }); + expect(runQuestionQuerySpy).toHaveBeenCalledTimes(1); + }); + + it("does not run non-runnable question queries", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + jest.spyOn(Question.prototype, "canRun").mockReturnValue(false); + + await setup({ question }); + + expect(runQuestionQuerySpy).not.toHaveBeenCalled(); + }); + + it("does not run question query in notebook mode", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/notebook`, + }); + + await setup({ question, location }); + + expect(runQuestionQuerySpy).not.toHaveBeenCalled(); + }); + + it("passes object ID from params correctly", async () => { + const params = getQueryParamsForQuestion(question, { objectId: 123 }); + const { result } = await setup({ question, params }); + expect(result.objectId).toBe(123); + }); + + it("passes object ID from location query params correctly", async () => { + const location = getLocationForQuestion(question, { + query: { objectId: 123 }, + }); + const { result } = await setup({ question, location }); + expect(result.objectId).toBe(123); + }); + + it("sets original card id on the card", async () => { + const { result } = await setup({ question }); + expect(result.card.original_card_id).toBe(question.id()); + }); + + it("sets QB mode correctly", async () => { + const { result } = await setup({ question }); + expect(result.uiControls.queryBuilderMode).toBe("view"); + }); + + it("sets QB mode to notebook if opening /notebook route", async () => { + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/notebook`, + }); + + const { result } = await setup({ question, location }); + + expect(result.uiControls.queryBuilderMode).toBe("notebook"); + }); + }); + }); + }); + + describe("saved questions and models", () => { + [...SAVED_QUESTION_TEST_CASES, ...MODEL_TEST_CASES].forEach(testCase => { + const { question, questionType } = testCase; + + describe(questionType, () => { + it("locks question display", async () => { + const { result } = await setup({ + question: question.setDisplayIsLocked(false), + }); + expect(result.card.displayIsLocked).toBe(true); + }); + + it("fetches alerts", async () => { + const fetchAlertsForQuestionSpy = jest.spyOn( + alert, + "fetchAlertsForQuestion", + ); + + await setup({ question }); + + expect(fetchAlertsForQuestionSpy).toHaveBeenCalledWith(question.id()); + }); + + it("passes object ID from params correctly", async () => { + const params = getQueryParamsForQuestion(question, { objectId: 123 }); + const { result } = await setup({ question, params }); + expect(result.objectId).toBe(123); + }); + + it("passes object ID from location query params correctly", async () => { + const location = getLocationForQuestion(question, { + query: { objectId: 123 }, + }); + const { result } = await setup({ question, location }); + expect(result.objectId).toBe(123); + }); + + describe("newb modal", () => { + it("shows modal if user has not yet seen it", async () => { + const { result } = await setup({ + question, + user: createMockUser({ is_qbnewb: true }), + }); + expect(result.uiControls.isShowingNewbModal).toBe(true); + }); + + it("does not show modal if user has seen it", async () => { + const { result } = await setup({ + question, + user: createMockUser({ is_qbnewb: false }), + }); + expect(result.uiControls.isShowingNewbModal).toBeFalsy(); + }); + }); + + it("throws error for archived card", async () => { + const { dispatch } = await setup({ + question: question.setCard({ + ...question.card(), + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + archived: true, + }), + }); + + expect(dispatch).toHaveBeenCalledWith( + setErrorPage( + expect.objectContaining({ data: { error_code: "archived" } }), + ), + ); + }); + }); + }); + }); + + describe("saved questions", () => { + SAVED_QUESTION_TEST_CASES.forEach(testCase => { + const { question, questionType } = testCase; + + describe(questionType, () => { + it("throws not found error when opening question with /model URL", async () => { + const { dispatch } = await setup({ + question, + location: { pathname: `/model/${question.id()}` }, + }); + + expect(dispatch).toHaveBeenCalledWith( + setErrorPage( + expect.objectContaining({ data: { error_code: "not-found" } }), + ), + ); + }); + }); + }); + }); + + describe("unsaved questions", () => { + UNSAVED_QUESTION_TEST_CASES.forEach(testCase => { + const { question, questionType } = testCase; + + const ORIGINAL_CARD_ID = 321; + + function getOriginalQuestion(card?: Partial<Card>) { + return question.setCard({ + ...question.card(), + ...card, + id: ORIGINAL_CARD_ID, + }); + } + + function setupWithOriginalQuestion({ + originalQuestion, + question, + ...opts + }: SetupOpts & { originalQuestion: Question }) { + const q = question.setCard({ + ...question.card(), + original_card_id: ORIGINAL_CARD_ID, + }); + + xhrMock.get(`/api/card/${originalQuestion.id()}`, { + body: JSON.stringify(originalQuestion.card()), + }); + + return setup({ question: q, ...opts }); + } + + describe(questionType, () => { + it("loads original card", async () => { + const originalQuestion = getOriginalQuestion({ display: "line" }); + + const { result } = await setupWithOriginalQuestion({ + question, + originalQuestion, + }); + + expect(result.card.original_card_id).toBe(ORIGINAL_CARD_ID); + expect(result.originalCard).toEqual(originalQuestion.card()); + }); + + it("replaces card with original card if they're equal", async () => { + const originalQuestion = getOriginalQuestion(); + + const { result } = await setupWithOriginalQuestion({ + question, + originalQuestion, + }); + + expect(result.card.original_card_id).toBeUndefined(); + expect(result.originalCard).toEqual(originalQuestion.card()); + expect(result.card).toEqual(originalQuestion.lockDisplay().card()); + }); + + it("does not lock question display", async () => { + const { result } = await setup({ question }); + expect(result.card.displayIsLocked).toBeFalsy(); + }); + + it("does not try to fetch alerts", async () => { + const fetchAlertsForQuestionSpy = jest.spyOn( + alert, + "fetchAlertsForQuestion", + ); + + await setup({ question }); + + expect(fetchAlertsForQuestionSpy).not.toHaveBeenCalled(); + }); + + it("does not show qbnewb modal", async () => { + const { result } = await setup({ + question, + user: createMockUser({ is_qbnewb: true }), + }); + expect(result.uiControls.isShowingNewbModal).toBeFalsy(); + }); + + it("handles error if couldn't deserialize card hash", async () => { + const error = new Error("failed to deserialize card"); + jest + .spyOn(CardLib, "deserializeCardFromUrl") + .mockImplementation(() => { + throw error; + }); + + const { dispatch } = await setup({ question }); + + expect(dispatch).toHaveBeenCalledWith(setErrorPage(error)); + }); + }); + }); + }); + + describe("models", () => { + MODEL_TEST_CASES.forEach(testCase => { + const { question, questionType } = testCase; + + describe(questionType, () => { + it("runs question query on /query route", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/query`, + }); + + await setup({ question, location }); + + expect(runQuestionQuerySpy).toHaveBeenCalledTimes(1); + }); + it("runs question query on /metadata route", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/metadata`, + }); + + await setup({ question, location }); + + expect(runQuestionQuerySpy).toHaveBeenCalledTimes(1); + }); + + it("sets UI state correctly for /query route", async () => { + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/query`, + }); + + const { result } = await setup({ question, location }); + + expect(result.uiControls.queryBuilderMode).toBe("dataset"); + expect(result.uiControls.datasetEditorTab).toBe("query"); + }); + + it("sets UI state correctly for /metadata route", async () => { + const baseUrl = Urls.question(question.card()); + const location = getLocationForQuestion(question, { + pathname: `${baseUrl}/metadata`, + }); + + const { result } = await setup({ question, location }); + + expect(result.uiControls.queryBuilderMode).toBe("dataset"); + expect(result.uiControls.datasetEditorTab).toBe("metadata"); + }); + }); + }); + }); + + describe("native questions with snippets", () => { + NATIVE_SNIPPETS_TEST_CASES.forEach(testCase => { + const { question, questionType } = testCase; + + type SnippetsSetupOpts = Omit<SetupOpts, "question"> & { + hasLoadedDatabase?: boolean; + hasDatabaseWritePermission?: boolean; + snippet?: unknown; + }; + + function setupSnippets({ + hasLoadedDatabase = true, + hasDatabaseWritePermission = true, + snippet, + ...opts + }: SnippetsSetupOpts) { + const mockDatabase = { + native_permissions: hasDatabaseWritePermission ? "write" : "none", + }; + + Databases.selectors.getObject = jest + .fn() + .mockReturnValue(hasLoadedDatabase ? mockDatabase : null); + Databases.actions.fetchList = jest.fn(); + + Snippets.actions.fetchList = jest.fn(); + Snippets.selectors.getList = jest + .fn() + .mockReturnValue(snippet ? [snippet] : []); + + return setup({ question, ...opts }); + } + + describe(questionType, () => { + it("loads databases if has not yet loaded question DB", async () => { + await setupSnippets({ hasLoadedDatabase: false }); + expect(Databases.actions.fetchList).toHaveBeenCalledTimes(1); + }); + + it("does not load databases if has already loaded question DB", async () => { + const { state } = await setupSnippets({ + hasLoadedDatabase: true, + snippet: SNIPPET, + }); + + expect(Databases.actions.fetchList).toHaveBeenCalledTimes(0); + expect(Databases.selectors.getObject).toHaveBeenCalledWith(state, { + entityId: question.databaseId(), + }); + }); + + it("loads snippets if have DB write permissions", async () => { + await setupSnippets({ hasDatabaseWritePermission: true }); + expect(Snippets.actions.fetchList).toHaveBeenCalledTimes(1); + }); + + it("does not load snippets if missing DB write permissions", async () => { + Databases.selectors.getObject = jest.fn().mockReturnValue({ + native_permissions: "none", + }); + Snippets.actions.fetchList = jest.fn(); + Snippets.selectors.getList = jest.fn().mockReturnValue([SNIPPET]); + + await setupSnippets({ hasDatabaseWritePermission: false }); + + expect(Snippets.actions.fetchList).not.toHaveBeenCalled(); + }); + + it("replaces snippet names with fresh ones from the backend", async () => { + const { result } = await setupSnippets({ + snippet: { + id: SNIPPET["snippet-id"], + name: "bar", + }, + }); + const formattedQuestion = new Question(result.card, metadata); + const query = formattedQuestion.query() as NativeQuery; + + expect(query.queryText().toLowerCase()).toBe( + "select * from orders {{snippet: bar}}", + ); + }); + }); + }); + }); + + describe("blank question", () => { + type BlankSetupOpts = Omit<BaseSetupOpts, "location" | "params"> & { + db?: number; + table?: number; + segment?: number; + metric?: number; + }; + + function setupBlank({ + db, + table, + segment, + metric, + ...opts + }: BlankSetupOpts = {}) { + const hashParams = [ + db ? `db=${db}` : "", + table ? `table=${table}` : "", + segment ? `segment=${segment}` : "", + metric ? `metric=${metric}` : "", + ].filter(Boolean); + + let hash = hashParams.join("&"); + if (hash) { + hash = "#?" + hash; + } + + const location: LocationDescriptorObject = { + pathname: "/question", + hash, + }; + + const params = { + db: db ? String(db) : undefined, + table: table ? String(table) : undefined, + segment: segment ? String(segment) : undefined, + metric: metric ? String(metric) : undefined, + }; + + return baseSetup({ location, params, ...opts }); + } + + async function setupOrdersTable( + opts: Omit<BlankSetupOpts, "db" | "table"> = {}, + ) { + const { result, ...rest } = await setupBlank({ + db: SAMPLE_DATABASE?.id, + table: ORDERS.id, + ...opts, + }); + + const question = new Question(result.card, metadata); + const query = question.query() as StructuredQuery; + + return { + question, + query, + result, + ...rest, + }; + } + + it("redirects to new question flow if missing any options", async () => { + const redirectSpy = jest.spyOn(navigation, "redirectToNewQuestionFlow"); + await setupBlank(); + expect(redirectSpy).toHaveBeenCalledTimes(1); + }); + + it("constructs a card based on provided 'db' param", async () => { + const card = Question.create({ + databaseId: SAMPLE_DATABASE?.id, + }).card(); + const expectedCard = { ...card, name: null, collection_id: undefined }; + + const { result } = await setupBlank({ db: SAMPLE_DATABASE?.id }); + const question = new Question(result.card, metadata); + const query = question.query() as StructuredQuery; + + expect(result.card).toEqual(expectedCard); + expect(query.sourceTableId()).toBe(null); + expect(result.originalCard).toBeUndefined(); + }); + + it("constructs a card based on provided 'db' and 'table' params", async () => { + const expectedCard = { + ...ORDERS.question().card(), + name: null, + collection_id: undefined, + }; + + const { result } = await setupOrdersTable(); + + expect(result.card).toEqual(expectedCard); + expect(result.originalCard).toBeUndefined(); + }); + + it("applies 'segment' param correctly", async () => { + const SEGMENT_ID = 777; + + const { query } = await setupOrdersTable({ segment: SEGMENT_ID }); + const [filter] = query.filters(); + + expect(filter.raw()).toEqual(["segment", SEGMENT_ID]); + }); + + it("opens summarization sidebar if metric is applied", async () => { + const METRIC_ID = 777; + const { result } = await setupOrdersTable({ metric: METRIC_ID }); + expect(result.uiControls.isShowingSummarySidebar).toBe(true); + }); + + it("applies 'metric' param correctly", async () => { + const METRIC_ID = 777; + + const { query } = await setupOrdersTable({ metric: METRIC_ID }); + const [aggregation] = query.aggregations(); + + expect(aggregation.raw()).toEqual(["metric", METRIC_ID]); + }); + + it("applies both 'metric' and 'segment' params", async () => { + const SEGMENT_ID = 111; + const METRIC_ID = 222; + + const { query } = await setupOrdersTable({ + segment: SEGMENT_ID, + metric: METRIC_ID, + }); + const [filter] = query.filters(); + const [aggregation] = query.aggregations(); + + expect(filter.raw()).toEqual(["segment", SEGMENT_ID]); + expect(aggregation.raw()).toEqual(["metric", METRIC_ID]); + }); + + it("fetches question metadata", async () => { + const loadMetadataForCardSpy = jest.spyOn(core, "loadMetadataForCard"); + + const { question } = await setupOrdersTable(); + + expect(loadMetadataForCardSpy).toHaveBeenCalledTimes(1); + expect(loadMetadataForCardSpy).toHaveBeenCalledWith( + expect.objectContaining(question.card()), + ); + }); + + it("runs question query", async () => { + const runQuestionQuerySpy = jest.spyOn(querying, "runQuestionQuery"); + await setupOrdersTable(); + expect(runQuestionQuerySpy).toHaveBeenCalledTimes(1); + }); + + it("does not lock question display", async () => { + const { result } = await setupOrdersTable(); + expect(result.card.displayIsLocked).toBeFalsy(); + }); + + it("does not try to fetch alerts", async () => { + const fetchAlertsForQuestionSpy = jest.spyOn( + alert, + "fetchAlertsForQuestion", + ); + + await setupOrdersTable(); + + expect(fetchAlertsForQuestionSpy).not.toHaveBeenCalled(); + }); + + it("does not show qbnewb modal", async () => { + const { result } = await setupOrdersTable({ + user: createMockUser({ is_qbnewb: true }), + }); + expect(result.uiControls.isShowingNewbModal).toBeFalsy(); + }); + }); +}); -- GitLab