diff --git a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js index 8baf5616d0784140ab068acfba8bf3f4abf34476..2731910b9f54c1a070594418474c304c434aa81a 100644 --- a/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js +++ b/e2e/test/scenarios/dashboard-cards/click-behavior.cy.spec.js @@ -944,6 +944,8 @@ describeEE("scenarios > dashboard > dashboard cards > click behavior", () => { }, ]); + cy.go("back"); + cy.log("return to the dashboard"); cy.go("back"); testChangingBackToDefaultBehavior(); }); diff --git a/frontend/src/metabase-lib/v1/Question.ts b/frontend/src/metabase-lib/v1/Question.ts index a71d30eec3a9152cb1c3fab5dfac96640c369adc..11ef44a4c22a627221ec0f83809e19cdf69c2ff3 100644 --- a/frontend/src/metabase-lib/v1/Question.ts +++ b/frontend/src/metabase-lib/v1/Question.ts @@ -50,7 +50,7 @@ import type { LastEditInfo, ParameterId, Parameter as ParameterObject, - ParameterValues, + ParameterValuesMap, TableId, UserInfo, VisualizationSettings, @@ -64,7 +64,7 @@ export type QuestionCreatorOpts = { tableId?: TableId; collectionId?: CollectionId; metadata?: Metadata; - parameterValues?: ParameterValues; + parameterValues?: ParameterValuesMap; type?: "query" | "native"; name?: string; display?: CardDisplayType; @@ -93,7 +93,7 @@ class Question { * Parameter values mean either the current values of dashboard filters or SQL editor template parameters. * They are in the grey area between UI state and question state, but having them in Question wrapper is convenient. */ - _parameterValues: ParameterValues; + _parameterValues: ParameterValuesMap; private __mlv2Query: Lib.Query | undefined; @@ -105,7 +105,7 @@ class Question { constructor( card: any, metadata?: Metadata, - parameterValues?: ParameterValues, + parameterValues?: ParameterValuesMap, ) { this._card = card; this._metadata = diff --git a/frontend/src/metabase-types/store/qb.ts b/frontend/src/metabase-types/store/qb.ts index ad910bcbfa0ac1930c6be6e4d63b7f0d87e12e7f..580f5bbdcd9b9abcb72286abcb74cbae10d2ff60 100644 --- a/frontend/src/metabase-types/store/qb.ts +++ b/frontend/src/metabase-types/store/qb.ts @@ -1,3 +1,4 @@ +import type { Deferred } from "metabase/lib/promise"; import type { QueryModalType } from "metabase/query_builder/constants"; import type { Widget } from "metabase/visualizations/components/ChartSettings/types"; import type { @@ -69,7 +70,7 @@ export interface QueryBuilderState { queryStatus: QueryBuilderQueryStatus; queryResults: Dataset[] | null; queryStartTime: number | null; - cancelQueryDeferred: Promise<void> | null; + cancelQueryDeferred: Deferred<void> | null; card: Card | null; originalCard: Card | null; diff --git a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx index 4515754a22a3345ebea13226b4c61b6219fa2c79..9e0ebb0aa73c181175bdac3131d90ef4a654a1ee 100644 --- a/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx +++ b/frontend/src/metabase/nav/components/search/SearchBar/SearchBar.tsx @@ -92,7 +92,7 @@ function SearchBarView({ location, onSearchActive, onSearchInactive }: Props) { // if we're already looking at the right model, don't navigate, just update the zoomed in row const isSameModel = result?.model_id === location?.state?.cardId; if (isSameModel && result.model === "indexed-entity") { - zoomInRow({ objectId: result.id })(dispatch); + dispatch(zoomInRow({ objectId: result.id })); } else { onChangeLocation(result.getUrl()); } diff --git a/frontend/src/metabase/plugins/index.ts b/frontend/src/metabase/plugins/index.ts index 504102161dbbb19030834d860b5fc36a366166cf..82522b52f26cd0f1613e012217a0670fad054a91 100644 --- a/frontend/src/metabase/plugins/index.ts +++ b/frontend/src/metabase/plugins/index.ts @@ -225,7 +225,7 @@ const defaultLoginPageIllustration = { isDefault: true, }; -const getLoadingMessage = (isSlow: boolean) => +const getLoadingMessage = (isSlow: boolean = false) => isSlow ? t`Waiting for results...` : t`Doing science...`; // selectors that customize behavior between app versions diff --git a/frontend/src/metabase/query_builder/actions/core/core.js b/frontend/src/metabase/query_builder/actions/core/core.ts similarity index 88% rename from frontend/src/metabase/query_builder/actions/core/core.js rename to frontend/src/metabase/query_builder/actions/core/core.ts index 946b1b53bf3a2ae2fc63a3fe2c191c5a3f456b42..fa54bc6f5c76eae399eaad25f28a2cf905de0e1d 100644 --- a/frontend/src/metabase/query_builder/actions/core/core.js +++ b/frontend/src/metabase/query_builder/actions/core/core.ts @@ -8,6 +8,7 @@ import Questions from "metabase/entities/questions"; import Revision from "metabase/entities/revisions"; import { shouldOpenInBlankWindow } from "metabase/lib/dom"; import { createThunkAction } from "metabase/lib/redux"; +import { isNotNull } from "metabase/lib/types"; import * as Urls from "metabase/lib/urls"; import { copy } from "metabase/lib/utils"; import { loadMetadataForCard } from "metabase/questions/actions"; @@ -22,6 +23,14 @@ import { cardIsEquivalent, cardQueryIsEquivalent, } from "metabase-lib/v1/queries/utils/card"; +import type { + Card, + Database, + DatasetQuery, + ParameterId, + ParameterValuesMap, +} from "metabase-types/api"; +import type { Dispatch, GetState } from "metabase-types/store"; import { trackNewQuestionSaved } from "../../analytics"; import { @@ -65,21 +74,27 @@ export const reloadCard = createThunkAction(RELOAD_CARD, () => { dispatch(resetQB()); + if (!outdatedQuestion) { + return; + } + const action = await dispatch( Questions.actions.fetch({ id: outdatedQuestion.id() }, { reload: true }), ); const card = Questions.HACK_getObjectFromAction(action); - // We need to manually massage the paramters into the parameterValues shape, + // We need to manually massage the parameters into the parameterValues shape, // to be able to pass them to new Question. // We could use _parameterValues here but prefer not to use internal fields. - const parameterValues = outdatedQuestion.parameters().reduce( - (acc, next) => ({ - ...acc, - [next.id]: next.value, - }), - {}, - ); + const parameterValues: ParameterValuesMap = outdatedQuestion + .parameters() + .reduce( + (acc, next) => ({ + ...acc, + [next.id]: next.value, + }), + {}, + ); const question = new Question( card, @@ -110,8 +125,11 @@ export const reloadCard = createThunkAction(RELOAD_CARD, () => { * - `navigateToNewCardInsideQB` is being called (see below) */ export const SET_CARD_AND_RUN = "metabase/qb/SET_CARD_AND_RUN"; -export const setCardAndRun = (nextCard, { shouldUpdateUrl = true } = {}) => { - return async (dispatch, getState) => { +export const setCardAndRun = ( + nextCard: Card, + { shouldUpdateUrl = true } = {}, +) => { + return async (dispatch: Dispatch, getState: GetState) => { // clone const card = copy(nextCard); @@ -183,25 +201,30 @@ export const navigateToNewCardInsideQB = createThunkAction( // DEPRECATED, still used in a couple places export const setDatasetQuery = - (datasetQuery, options) => (dispatch, getState) => { + (datasetQuery: DatasetQuery) => (dispatch: Dispatch, getState: GetState) => { if (datasetQuery instanceof Query) { datasetQuery = datasetQuery.datasetQuery(); } const question = getQuestion(getState()); - dispatch(updateQuestion(question.setDatasetQuery(datasetQuery), options)); + + if (!question) { + return; + } + + dispatch(updateQuestion(question.setDatasetQuery(datasetQuery))); }; export const API_CREATE_QUESTION = "metabase/qb/API_CREATE_QUESTION"; -export const apiCreateQuestion = question => { - return async (dispatch, getState) => { +export const apiCreateQuestion = (question: Question) => { + return async (dispatch: Dispatch, getState: GetState) => { const submittableQuestion = getSubmittableQuestion(getState(), question); const createdQuestion = await reduxCreateQuestion( submittableQuestion, dispatch, ); - const databases = Databases.selectors.getList(getState()); + const databases: Database[] = Databases.selectors.getList(getState()); if (databases && !databases.some(d => d.is_saved_questions)) { dispatch({ type: Databases.actionTypes.INVALIDATE_LISTS_ACTION }); } @@ -224,12 +247,17 @@ export const apiCreateQuestion = question => { if (isModel || isMetric) { dispatch(runQuestionQuery()); } + + return createdQuestion; }; }; export { API_UPDATE_QUESTION }; -export const apiUpdateQuestion = (question, { rerunQuery } = {}) => { - return async (dispatch, getState) => { +export const apiUpdateQuestion = ( + question: Question, + { rerunQuery }: { rerunQuery?: boolean } = {}, +) => { + return async (dispatch: Dispatch, getState: GetState) => { const originalQuestion = getOriginalQuestion(getState()); question = question || getQuestion(getState()); @@ -286,7 +314,7 @@ export const apiUpdateQuestion = (question, { rerunQuery } = {}) => { export const SET_PARAMETER_VALUE = "metabase/qb/SET_PARAMETER_VALUE"; export const setParameterValue = createAction( SET_PARAMETER_VALUE, - (parameterId, value) => { + (parameterId: ParameterId, value: string | string[]) => { return { id: parameterId, value: normalizeValue(value) }; }, ); @@ -307,7 +335,7 @@ export const setParameterValueToDefault = createThunkAction( }, ); -function normalizeValue(value) { +function normalizeValue(value: string | string[]) { if (value === "") { return null; } @@ -330,14 +358,14 @@ export const revertToRevision = createThunkAction( }, ); -async function reduxCreateQuestion(question, dispatch) { +async function reduxCreateQuestion(question: Question, dispatch: Dispatch) { const action = await dispatch(Questions.actions.create(question.card())); return question.setCard(Questions.HACK_getObjectFromAction(action)); } async function reduxUpdateQuestion( - question, - dispatch, + question: Question, + dispatch: Dispatch, { excludeDatasetQuery = false, excludeVisualisationSettings = false }, ) { const fullCard = question.card(); @@ -345,7 +373,7 @@ async function reduxUpdateQuestion( const keysToOmit = [ excludeDatasetQuery ? "dataset_query" : null, excludeVisualisationSettings ? "visualization_settings" : null, - ].filter(Boolean); + ].filter(isNotNull); const card = _.omit(fullCard, ...keysToOmit); diff --git a/frontend/src/metabase/query_builder/actions/core/types.js b/frontend/src/metabase/query_builder/actions/core/types.ts similarity index 100% rename from frontend/src/metabase/query_builder/actions/core/types.js rename to frontend/src/metabase/query_builder/actions/core/types.ts diff --git a/frontend/src/metabase/query_builder/actions/models.js b/frontend/src/metabase/query_builder/actions/models.js deleted file mode 100644 index 4dcf7b72c035032d8484c5a2b5df2f59b344287d..0000000000000000000000000000000000000000 --- a/frontend/src/metabase/query_builder/actions/models.js +++ /dev/null @@ -1,85 +0,0 @@ -import { push } from "react-router-redux"; -import { createAction } from "redux-actions"; -import { t } from "ttag"; - -import Questions from "metabase/entities/questions"; -import { loadMetadataForCard } from "metabase/questions/actions"; -import { addUndo } from "metabase/redux/undo"; -import { getMetadata } from "metabase/selectors/metadata"; - -import { getQuestion } from "../selectors"; - -import { API_UPDATE_QUESTION, apiUpdateQuestion, updateQuestion } from "./core"; -import { runDirtyQuestionQuery, runQuestionQuery } from "./querying"; -import { setQueryBuilderMode } from "./ui"; - -export const setDatasetEditorTab = datasetEditorTab => dispatch => { - dispatch( - setQueryBuilderMode("dataset", { datasetEditorTab, replaceState: false }), - ); - dispatch(runDirtyQuestionQuery()); -}; - -export const onCancelCreateNewModel = () => async dispatch => { - await dispatch(push("/")); -}; - -export const turnQuestionIntoModel = () => async (dispatch, getState) => { - const question = getQuestion(getState()); - - await dispatch( - Questions.actions.update( - { - id: question.id(), - }, - question - .setType("model") - .setPinned(true) - .setDisplay("table") - .setSettings({}) - .card(), - ), - ); - - const metadata = getMetadata(getState()); - const dataset = metadata.question(question.id()); - - await dispatch(loadMetadataForCard(dataset.card())); - - await dispatch({ type: API_UPDATE_QUESTION, payload: dataset.card() }); - - await dispatch( - runQuestionQuery({ - shouldUpdateUrl: true, - }), - ); - - dispatch( - addUndo({ - message: t`This is a model now.`, - actions: [apiUpdateQuestion(question, { rerunQuery: true })], - }), - ); -}; - -export const turnModelIntoQuestion = () => async (dispatch, getState) => { - const model = getQuestion(getState()); - const question = model.setType("question"); - await dispatch(apiUpdateQuestion(question, { rerunQuery: true })); - - dispatch( - addUndo({ - message: t`This is a question now.`, - actions: [apiUpdateQuestion(model)], - }), - ); -}; - -export const SET_METADATA_DIFF = "metabase/qb/SET_METADATA_DIFF"; -export const setMetadataDiff = createAction(SET_METADATA_DIFF); - -export const onModelPersistenceChange = isEnabled => (dispatch, getState) => { - const question = getQuestion(getState()); - const nextQuestion = question.setPersisted(isEnabled); - dispatch(updateQuestion(nextQuestion, { shouldStartAdHocQuestion: false })); -}; diff --git a/frontend/src/metabase/query_builder/actions/models.ts b/frontend/src/metabase/query_builder/actions/models.ts new file mode 100644 index 0000000000000000000000000000000000000000..e02e20855d3abbe5210308a76a95a1405dcd7dc4 --- /dev/null +++ b/frontend/src/metabase/query_builder/actions/models.ts @@ -0,0 +1,108 @@ +import { push } from "react-router-redux"; +import { createAction } from "redux-actions"; +import { t } from "ttag"; + +import Questions from "metabase/entities/questions"; +import { loadMetadataForCard } from "metabase/questions/actions"; +import { addUndo } from "metabase/redux/undo"; +import { getMetadata } from "metabase/selectors/metadata"; +import type { Dispatch, GetState } from "metabase-types/store"; + +import { getQuestion } from "../selectors"; + +import { API_UPDATE_QUESTION, apiUpdateQuestion, updateQuestion } from "./core"; +import { runDirtyQuestionQuery, runQuestionQuery } from "./querying"; +import { setQueryBuilderMode } from "./ui"; + +export const setDatasetEditorTab = + (datasetEditorTab: "query" | "metadata") => (dispatch: Dispatch) => { + dispatch( + setQueryBuilderMode("dataset", { datasetEditorTab, replaceState: false }), + ); + dispatch(runDirtyQuestionQuery()); + }; + +export const onCancelCreateNewModel = () => async (dispatch: Dispatch) => { + await dispatch(push("/")); +}; + +export const turnQuestionIntoModel = + () => async (dispatch: Dispatch, getState: GetState) => { + const question = getQuestion(getState()); + + if (!question) { + return; + } + + await dispatch( + Questions.actions.update( + { + id: question.id(), + }, + question + .setType("model") + .setPinned(true) + .setDisplay("table") + .setSettings({}) + .card(), + ), + ); + + const metadata = getMetadata(getState()); + const dataset = metadata.question(question.id()); + + if (!dataset) { + return; + } + + await dispatch(loadMetadataForCard(dataset.card())); + + await dispatch({ type: API_UPDATE_QUESTION, payload: dataset.card() }); + + await dispatch( + runQuestionQuery({ + shouldUpdateUrl: true, + }), + ); + + dispatch( + addUndo({ + message: t`This is a model now.`, + actions: [apiUpdateQuestion(question, { rerunQuery: true })], + }), + ); + }; + +export const turnModelIntoQuestion = + () => async (dispatch: Dispatch, getState: GetState) => { + const model = getQuestion(getState()); + + if (!model) { + return; + } + + const question = model.setType("question"); + await dispatch(apiUpdateQuestion(question, { rerunQuery: true })); + + dispatch( + addUndo({ + message: t`This is a question now.`, + actions: [apiUpdateQuestion(model)], + }), + ); + }; + +export const SET_METADATA_DIFF = "metabase/qb/SET_METADATA_DIFF"; +export const setMetadataDiff = createAction(SET_METADATA_DIFF); + +export const onModelPersistenceChange = + (isEnabled: boolean) => (dispatch: Dispatch, getState: GetState) => { + const question = getQuestion(getState()); + + if (!question) { + return; + } + + const nextQuestion = question.setPersisted(isEnabled); + dispatch(updateQuestion(nextQuestion, { shouldStartAdHocQuestion: false })); + }; diff --git a/frontend/src/metabase/query_builder/actions/navigation.js b/frontend/src/metabase/query_builder/actions/navigation.ts similarity index 89% rename from frontend/src/metabase/query_builder/actions/navigation.js rename to frontend/src/metabase/query_builder/actions/navigation.ts index 9b4db3013dbaa06a632999887fe65f0c01117d88..4a9ac89daa1845043d3fc7843f5ef1c63e04b9d0 100644 --- a/frontend/src/metabase/query_builder/actions/navigation.js +++ b/frontend/src/metabase/query_builder/actions/navigation.ts @@ -1,3 +1,4 @@ +import type { Location, LocationDescriptor } from "history"; import { push, replace } from "react-router-redux"; import { createAction } from "redux-actions"; import { parse as parseUrl } from "url"; @@ -7,7 +8,9 @@ import { createThunkAction } from "metabase/lib/redux"; import { equals } from "metabase/lib/utils"; import { getLocation } from "metabase/selectors/routing"; import * as Lib from "metabase-lib"; +import type Question from "metabase-lib/v1/Question"; import { isAdHocModelOrMetricQuestion } from "metabase-lib/v1/metadata/utils/models"; +import type { Dispatch } from "metabase-types/store"; import { getCard, @@ -25,7 +28,7 @@ import { getURLForCardState, } from "../utils"; -import { initializeQB, setCardAndRun } from "./core"; +import { type QueryParams, initializeQB, setCardAndRun } from "./core"; import { resetRowZoom, zoomInRow } from "./object-detail"; import { cancelQuery } from "./querying"; import { resetUIControls, setQueryBuilderMode } from "./ui"; @@ -90,7 +93,7 @@ export const popState = createThunkAction( }, ); -const getURL = (location, { includeMode = false } = {}) => +const getURL = (location: Location, { includeMode = false } = {}) => // strip off trailing queryBuilderMode (includeMode ? location.pathname @@ -100,7 +103,8 @@ const getURL = (location, { includeMode = false } = {}) => // Logic for handling location changes, dispatched by top-level QueryBuilder component export const locationChanged = - (location, nextLocation, nextParams) => dispatch => { + (location: Location, nextLocation: Location, nextParams: QueryParams) => + (dispatch: Dispatch) => { if (location !== nextLocation) { if (nextLocation.action === "POP") { if ( @@ -126,7 +130,7 @@ export const UPDATE_URL = "metabase/qb/UPDATE_URL"; export const updateUrl = createThunkAction( UPDATE_URL, ( - question, + question?: Question | null, { dirty, replaceState, @@ -139,6 +143,10 @@ export const updateUrl = createThunkAction( (dispatch, getState) => { if (!question) { question = getQuestion(getState()); + + if (!question) { + return; + } } if (dirty == null) { @@ -180,14 +188,14 @@ export const updateUrl = createThunkAction( const url = getURLForCardState(newState, dirty, queryParams, objectId); const urlParsed = parseUrl(url); - const locationDescriptor = { + const locationDescriptor: LocationDescriptor = { pathname: getPathNameFromQueryBuilderMode({ pathname: urlParsed.pathname || "", queryBuilderMode, datasetEditorTab, }), - search: urlParsed.search, - hash: urlParsed.hash, + search: urlParsed.search ?? undefined, + hash: urlParsed.hash ?? undefined, state: newState, }; @@ -197,15 +205,17 @@ export const updateUrl = createThunkAction( (locationDescriptor.hash || "") === (window.location.hash || ""); const isSameCard = currentState && isEqualCard(currentState.card, newState.card); - const isSameMode = - getQueryBuilderModeFromLocation(locationDescriptor).mode === - getQueryBuilderModeFromLocation(window.location).mode; if (isSameCard && isSameURL) { return; } if (replaceState == null) { + const isSameMode = + getQueryBuilderModeFromLocation(locationDescriptor) + .queryBuilderMode === + getQueryBuilderModeFromLocation(window.location).queryBuilderMode; + // if the serialized card is identical replace the previous state instead of adding a new one // e.x. when saving a new card we want to replace the state and URL with one with the new card ID replaceState = isSameCard && isSameMode; diff --git a/frontend/src/metabase/query_builder/actions/object-detail.js b/frontend/src/metabase/query_builder/actions/object-detail.ts similarity index 79% rename from frontend/src/metabase/query_builder/actions/object-detail.js rename to frontend/src/metabase/query_builder/actions/object-detail.ts index 8b57ee433b2e8b4afdaee108bfb53e0074b4d8e6..f5164667c78f696d978686d4c9bf1344f4ab7e14 100644 --- a/frontend/src/metabase/query_builder/actions/object-detail.js +++ b/frontend/src/metabase/query_builder/actions/object-detail.ts @@ -3,8 +3,13 @@ import _ from "underscore"; import { createThunkAction } from "metabase/lib/redux"; import { getMetadata } from "metabase/selectors/metadata"; import { MetabaseApi } from "metabase/services"; +import type { ObjectId } from "metabase/visualizations/components/ObjectDetail/types"; import * as Lib from "metabase-lib"; import Question from "metabase-lib/v1/Question"; +import type Field from "metabase-lib/v1/metadata/Field"; +import type ForeignKey from "metabase-lib/v1/metadata/ForeignKey"; +import type { Card, DatasetColumn, FieldId } from "metabase-types/api"; +import type { Dispatch, GetState } from "metabase-types/store"; import { getCanZoomNextRow, @@ -22,8 +27,8 @@ import { updateUrl } from "./navigation"; export const ZOOM_IN_ROW = "metabase/qb/ZOOM_IN_ROW"; export const zoomInRow = - ({ objectId }) => - (dispatch, getState) => { + ({ objectId }: { objectId: ObjectId }) => + (dispatch: Dispatch, getState: GetState) => { dispatch({ type: ZOOM_IN_ROW, payload: { objectId } }); // don't show object id in url if it is a row index @@ -32,12 +37,16 @@ export const zoomInRow = }; export const RESET_ROW_ZOOM = "metabase/qb/RESET_ROW_ZOOM"; -export const resetRowZoom = () => dispatch => { +export const resetRowZoom = () => (dispatch: Dispatch) => { dispatch({ type: RESET_ROW_ZOOM }); dispatch(updateUrl()); }; -function filterByFk(query, field, objectId) { +function filterByFk( + query: Lib.Query, + field: DatasetColumn | Field, + objectId: ObjectId, +) { const stageIndex = -1; const column = Lib.fromLegacyColumn(query, stageIndex, field); const filterClause = @@ -72,6 +81,11 @@ export const followForeignKey = createThunkAction( const metadata = getMetadata(getState()); const databaseId = new Question(card, metadata).databaseId(); + + if (!databaseId) { + return; + } + const tableId = fk.origin.table.id; const metadataProvider = Lib.metadataProvider(databaseId, metadata); const table = Lib.tableOrCardMetadata(metadataProvider, tableId); @@ -90,6 +104,11 @@ export const followForeignKey = createThunkAction( }, ); +interface FKInfo { + status: number; + value: string | number | null; +} + export const LOAD_OBJECT_DETAIL_FK_REFERENCES = "metabase/qb/LOAD_OBJECT_DETAIL_FK_REFERENCES"; export const loadObjectDetailFKReferences = createThunkAction( @@ -105,13 +124,19 @@ export const loadObjectDetailFKReferences = createThunkAction( return null; } - const card = getCard(state); + const card: Card = getCard(state); const queryResult = getFirstQueryResult(state); - async function getFKCount(card, fk) { + async function getFKCount( + card: Card, + fk: ForeignKey, + ): Promise<FKInfo | undefined> { const metadata = getMetadata(getState()); const databaseId = new Question(card, metadata).databaseId(); - const tableId = fk.origin.table_id; + const tableId = fk.origin?.table_id; + if (!tableId || !databaseId || !fk.origin) { + return; + } const metadataProvider = Lib.metadataProvider(databaseId, metadata); const table = Lib.tableOrCardMetadata(metadataProvider, tableId); const baseQuery = Lib.queryFromTableOrCardMetadata( @@ -124,7 +149,10 @@ export const loadObjectDetailFKReferences = createThunkAction( .setQuery(query) .datasetQuery(); - const info = { status: 0, value: null }; + const info: FKInfo = { + status: 0, + value: null, + }; try { const result = await MetabaseApi.dataset(finalCard); @@ -148,11 +176,12 @@ export const loadObjectDetailFKReferences = createThunkAction( // skipping that for now because it's easier to just run this each time // run a query on FK origin table where FK origin field = objectDetailIdValue - const fkReferences = {}; + const fkReferences: Record<FieldId, FKInfo | undefined> = {}; for (let i = 0; i < tableForeignKeys.length; i++) { const fk = tableForeignKeys[i]; const info = await getFKCount(card, fk); - fkReferences[fk.origin.id] = info; + + fkReferences[fk.origin_id] = info; } // It's possible that while we were running those queries, the object @@ -171,7 +200,7 @@ export const CLEAR_OBJECT_DETAIL_FK_REFERENCES = "metabase/qb/CLEAR_OBJECT_DETAIL_FK_REFERENCES"; export const viewNextObjectDetail = () => { - return (dispatch, getState) => { + return (dispatch: Dispatch, getState: GetState) => { if (getCanZoomNextRow(getState())) { const objectId = getNextRowPKValue(getState()); dispatch(zoomInRow({ objectId })); @@ -180,7 +209,7 @@ export const viewNextObjectDetail = () => { }; export const viewPreviousObjectDetail = () => { - return (dispatch, getState) => { + return (dispatch: Dispatch, getState: GetState) => { if (getCanZoomPreviousRow(getState())) { const objectId = getPreviousRowPKValue(getState()); dispatch(zoomInRow({ objectId })); @@ -188,4 +217,5 @@ export const viewPreviousObjectDetail = () => { }; }; -export const closeObjectDetail = () => dispatch => dispatch(resetRowZoom()); +export const closeObjectDetail = () => (dispatch: Dispatch) => + dispatch(resetRowZoom()); diff --git a/frontend/src/metabase/query_builder/actions/querying.js b/frontend/src/metabase/query_builder/actions/querying.ts similarity index 85% rename from frontend/src/metabase/query_builder/actions/querying.js rename to frontend/src/metabase/query_builder/actions/querying.ts index 1db82f6a5cb911f2c59c80058a54f65ac620becf..a4e342d8281f8afbd7f00cd30a65517cd43af5e3 100644 --- a/frontend/src/metabase/query_builder/actions/querying.js +++ b/frontend/src/metabase/query_builder/actions/querying.ts @@ -8,7 +8,10 @@ import { getWhiteLabeledLoadingMessageFactory } from "metabase/selectors/whitela import { runQuestionQuery as apiRunQuestionQuery } from "metabase/services"; import { getSensibleDisplays } from "metabase/visualizations"; import * as Lib from "metabase-lib"; +import type Question from "metabase-lib/v1/Question"; import { isAdHocModelOrMetricQuestion } from "metabase-lib/v1/metadata/utils/models"; +import type { Dataset } from "metabase-types/api"; +import type { Dispatch, GetState } from "metabase-types/store"; import { getCard, @@ -71,18 +74,23 @@ const loadCompleteUIControls = createThunkAction( }, ); -export const runDirtyQuestionQuery = () => async (dispatch, getState) => { - const areResultsDirty = getIsResultDirty(getState()); - const queryResults = getQueryResults(getState()); - const hasResults = !!queryResults; +export const runDirtyQuestionQuery = + () => async (dispatch: Dispatch, getState: GetState) => { + const areResultsDirty = getIsResultDirty(getState()); + const queryResults = getQueryResults(getState()); - if (hasResults && !areResultsDirty) { - const question = getQuestion(getState()); - return dispatch(queryCompleted(question, queryResults)); - } + if (queryResults && !areResultsDirty) { + const question = getQuestion(getState()); - return dispatch(runQuestionQuery()); -}; + if (!question) { + return; + } + + return dispatch(queryCompleted(question, queryResults)); + } + + return dispatch(runQuestionQuery()); + }; /** * Queries the result for the currently active question or alternatively for the card question provided in `overrideWithQuestion`. @@ -93,8 +101,12 @@ export const runQuestionQuery = ({ shouldUpdateUrl = true, ignoreCache = false, overrideWithQuestion = null, +}: { + shouldUpdateUrl?: boolean; + ignoreCache?: boolean; + overrideWithQuestion?: Question | null; } = {}) => { - return async (dispatch, getState) => { + return async (dispatch: Dispatch, getState: GetState) => { dispatch(loadStartUIControls()); const question = overrideWithQuestion @@ -102,6 +114,10 @@ export const runQuestionQuery = ({ : getQuestion(getState()); const originalQuestion = getOriginalQuestion(getState()); + if (!question) { + return; + } + const isCardDirty = originalQuestion ? question.isDirtyComparedToWithoutParameters(originalQuestion) || question.id() == null @@ -166,8 +182,8 @@ export const CLEAR_QUERY_RESULT = "metabase/query_builder/CLEAR_QUERY_RESULT"; export const clearQueryResult = createAction(CLEAR_QUERY_RESULT); export const QUERY_COMPLETED = "metabase/qb/QUERY_COMPLETED"; -export const queryCompleted = (question, queryResults) => { - return async (dispatch, getState) => { +export const queryCompleted = (question: Question, queryResults: Dataset[]) => { + return async (dispatch: Dispatch, getState: GetState) => { const [{ data, error }] = queryResults; const prevCard = getCard(getState()); const { data: prevData, error: prevError } = @@ -222,14 +238,14 @@ export const queryErrored = createThunkAction( if (error && error.isCancelled) { return null; } else { - return { error: error, duration: new Date() - startTime }; + return { error: error, duration: Date.now() - startTime }; } }; }, ); export const CANCEL_QUERY = "metabase/qb/CANCEL_QUERY"; -export const cancelQuery = () => (dispatch, getState) => { +export const cancelQuery = () => (dispatch: Dispatch, getState: GetState) => { const isRunning = getIsRunning(getState()); if (isRunning) { const { cancelQueryDeferred } = getState().qb; diff --git a/frontend/src/metabase/query_builder/actions/timelines.js b/frontend/src/metabase/query_builder/actions/timelines.ts similarity index 77% rename from frontend/src/metabase/query_builder/actions/timelines.js rename to frontend/src/metabase/query_builder/actions/timelines.ts index ddcbd2eb26929237b7aca5bdf4ea693eb9e99a72..46cb883db3428e9f18d48388dc371285e18f3e2c 100644 --- a/frontend/src/metabase/query_builder/actions/timelines.js +++ b/frontend/src/metabase/query_builder/actions/timelines.ts @@ -1,5 +1,8 @@ import { createAction } from "redux-actions"; +import type { CollectionId, Timeline } from "metabase-types/api"; +import type { Dispatch, GetState } from "metabase-types/store"; + import { getFetchedTimelines } from "../selectors"; export const SELECT_TIMELINE_EVENTS = "metabase/qb/SELECT_TIMELINE_EVENTS"; @@ -15,8 +18,9 @@ export const SHOW_TIMELINE_EVENTS = "metabase/qb/SHOW_TIMELINE_EVENTS"; export const showTimelineEvents = createAction(SHOW_TIMELINE_EVENTS); export const showTimelinesForCollection = - collectionId => (dispatch, getState) => { - const fetchedTimelines = getFetchedTimelines(getState()); + (collectionId?: CollectionId | null) => + (dispatch: Dispatch, getState: GetState) => { + const fetchedTimelines: Timeline[] = getFetchedTimelines(getState()); const collectionTimelines = collectionId ? fetchedTimelines.filter(t => t.collection_id === collectionId) : fetchedTimelines.filter(t => t.collection_id == null); diff --git a/frontend/src/metabase/query_builder/components/QueryVisualization.unit.spec.tsx b/frontend/src/metabase/query_builder/components/QueryVisualization.unit.spec.tsx index 530fb97e7aab70ceb8defc90ff239ce442c25879..0640d9875e5e2a6d7e690cb2190da29e5820201a 100644 --- a/frontend/src/metabase/query_builder/components/QueryVisualization.unit.spec.tsx +++ b/frontend/src/metabase/query_builder/components/QueryVisualization.unit.spec.tsx @@ -4,7 +4,7 @@ import { PLUGIN_SELECTORS } from "metabase/plugins"; import { VisualizationRunningState } from "./QueryVisualization"; type SetupOpts = { - customMessage?: (isSlow: boolean) => string; + customMessage?: (isSlow?: boolean) => string; }; function setup({ customMessage }: SetupOpts = {}) { @@ -31,7 +31,7 @@ describe("VisualizationRunningState", () => { }); it("should only render the custom loading message when it was customized", async () => { - const customMessage = (isSlow: boolean) => + const customMessage = (isSlow?: boolean) => isSlow ? `Custom message (slow)...` : `Custom message...`; setup({ customMessage }); diff --git a/frontend/src/metabase/redux/app.ts b/frontend/src/metabase/redux/app.ts index 135c48d74ee6f2e7c7ec0f6fd52301787dc8f94b..a2dabff407f8b7a8f149e3fc1994ab60d5145477 100644 --- a/frontend/src/metabase/redux/app.ts +++ b/frontend/src/metabase/redux/app.ts @@ -49,7 +49,8 @@ interface IOpenUrlOptions { } export const openUrl = - (url: string, options: IOpenUrlOptions) => (dispatch: Dispatch) => { + (url: string, options: IOpenUrlOptions = {}) => + (dispatch: Dispatch) => { if (shouldOpenInBlankWindow(url, options)) { openInBlankWindow(url); } else {