diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
deleted file mode 100644
index 952b309186b53dfd2643c890598f76811c4c2e9a..0000000000000000000000000000000000000000
--- a/frontend/src/metabase/query_builder/actions.js
+++ /dev/null
@@ -1,1772 +0,0 @@
-import { fetchAlertsForQuestion } from "metabase/alert/alert";
-
-/*global ace*/
-import { createAction } from "redux-actions";
-import _ from "underscore";
-import { assocIn, getIn, merge, updateIn } from "icepick";
-import { t } from "ttag";
-
-import * as Urls from "metabase/lib/urls";
-
-import { createThunkAction } from "metabase/lib/redux";
-import { push, replace } from "react-router-redux";
-import { openUrl, setErrorPage } from "metabase/redux/app";
-import { loadMetadataForQueries } from "metabase/redux/metadata";
-import { addUndo } from "metabase/redux/undo";
-
-import * as MetabaseAnalytics from "metabase/lib/analytics";
-import { startTimer } from "metabase/lib/performance";
-import {
-  cleanCopyCard,
-  deserializeCardFromUrl,
-  loadCard,
-  serializeCardForUrl,
-  startNewCard,
-} from "metabase/lib/card";
-import { shouldOpenInBlankWindow } from "metabase/lib/dom";
-import * as Q_DEPRECATED from "metabase/lib/query";
-import { isLocalField, isSameField } from "metabase/lib/query/field_ref";
-import { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils";
-import Utils from "metabase/lib/utils";
-import { defer } from "metabase/lib/promise";
-
-import Question from "metabase-lib/lib/Question";
-import { FieldDimension } from "metabase-lib/lib/Dimension";
-import { cardIsEquivalent, cardQueryIsEquivalent } from "metabase/meta/Card";
-import { getValueAndFieldIdPopulatedParametersFromCard } from "metabase/parameters/utils/cards";
-import { hasMatchingParameters } from "metabase/parameters/utils/dashboards";
-
-import { getParameterValuesByIdFromQueryParams } from "metabase/parameters/utils/parameter-values";
-import { normalize } from "cljs/metabase.mbql.js";
-
-import {
-  getCard,
-  getDatasetEditorTab,
-  getFirstQueryResult,
-  getIsEditing,
-  getIsPreviewing,
-  getIsRunning,
-  getIsShowingTemplateTagsEditor,
-  getNativeEditorCursorOffset,
-  getNativeEditorSelectedText,
-  getNextRowPKValue,
-  getOriginalCard,
-  getOriginalQuestion,
-  getPreviousQueryBuilderMode,
-  getPreviousRowPKValue,
-  getQueryBuilderMode,
-  getQueryResults,
-  getQuestion,
-  getRawSeries,
-  getResultsMetadata,
-  getSnippetCollectionId,
-  getTableForeignKeys,
-  getFetchedTimelines,
-  getTransformedSeries,
-  getZoomedObjectId,
-  isBasedOnExistingQuestion,
-  getTimeoutId,
-} from "./selectors";
-import { trackNewQuestionSaved } from "./analytics";
-
-import { CardApi, DashboardApi, MetabaseApi, UserApi } from "metabase/services";
-
-import { parse as urlParse } from "url";
-import querystring from "querystring";
-
-import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery";
-import { getSensibleDisplays } from "metabase/visualizations";
-import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/utils";
-import { getPersistableDefaultSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
-
-import Databases from "metabase/entities/databases";
-import Questions from "metabase/entities/questions";
-import Snippets from "metabase/entities/snippets";
-
-import { getMetadata } from "metabase/selectors/metadata";
-import { setRequestUnloaded } from "metabase/redux/requests";
-
-import {
-  getCurrentQueryParams,
-  getNextTemplateTagVisibilityState,
-  getPathNameFromQueryBuilderMode,
-  getQueryBuilderModeFromLocation,
-  getURLForCardState,
-} from "./utils";
-
-const PREVIEW_RESULT_LIMIT = 10;
-
-export const SET_UI_CONTROLS = "metabase/qb/SET_UI_CONTROLS";
-export const setUIControls = createAction(SET_UI_CONTROLS);
-
-export const RESET_UI_CONTROLS = "metabase/qb/RESET_UI_CONTROLS";
-export const resetUIControls = createAction(RESET_UI_CONTROLS);
-
-export const SET_DOCUMENT_TITLE = "metabase/qb/SET_DOCUMENT_TITLE";
-const setDocumentTitle = createAction(SET_DOCUMENT_TITLE);
-
-export const SET_SHOW_LOADING_COMPLETE_FAVICON =
-  "metabase/qb/SET_SHOW_LOADING_COMPLETE_FAVICON";
-const showLoadingCompleteFavicon = createAction(
-  SET_SHOW_LOADING_COMPLETE_FAVICON,
-  () => true,
-);
-const hideLoadingCompleteFavicon = createAction(
-  SET_SHOW_LOADING_COMPLETE_FAVICON,
-  () => false,
-);
-
-const LOAD_COMPLETE_UI_CONTROLS = "metabase/qb/LOAD_COMPLETE_UI_CONTROLS";
-const LOAD_START_UI_CONTROLS = "metabase/qb/LOAD_START_UI_CONTROLS";
-export const SET_DOCUMENT_TITLE_TIMEOUT_ID =
-  "metabase/qb/SET_DOCUMENT_TITLE_TIMEOUT_ID";
-const setDocumentTitleTimeoutId = createAction(SET_DOCUMENT_TITLE_TIMEOUT_ID);
-
-export const setQueryBuilderMode = (
-  queryBuilderMode,
-  { shouldUpdateUrl = true, datasetEditorTab = "query" } = {},
-) => async dispatch => {
-  await dispatch(
-    setUIControls({
-      queryBuilderMode,
-      datasetEditorTab,
-      isShowingChartSettingsSidebar: false,
-    }),
-  );
-  if (shouldUpdateUrl) {
-    await dispatch(updateUrl(null, { queryBuilderMode, datasetEditorTab }));
-  }
-  if (queryBuilderMode === "notebook") {
-    dispatch(cancelQuery());
-  }
-  if (queryBuilderMode === "dataset") {
-    dispatch(runQuestionQuery());
-  }
-};
-
-export const onEditSummary = createAction("metabase/qb/EDIT_SUMMARY");
-export const onCloseSummary = createAction("metabase/qb/CLOSE_SUMMARY");
-export const onAddFilter = createAction("metabase/qb/ADD_FITLER");
-export const onCloseFilter = createAction("metabase/qb/CLOSE_FILTER");
-export const onOpenChartSettings = createAction(
-  "metabase/qb/OPEN_CHART_SETTINGS",
-);
-export const onCloseChartSettings = createAction(
-  "metabase/qb/CLOSE_CHART_SETTINGS",
-);
-export const onOpenChartType = createAction("metabase/qb/OPEN_CHART_TYPE");
-export const onOpenQuestionDetails = createAction(
-  "metabase/qb/OPEN_QUESTION_DETAILS",
-);
-export const onCloseQuestionDetails = createAction(
-  "metabase/qb/CLOSE_QUESTION_DETAILS",
-);
-export const onOpenQuestionHistory = createAction(
-  "metabase/qb/OPEN_QUESTION_HISTORY",
-);
-export const onCloseQuestionHistory = createAction(
-  "metabase/qb/CLOSE_QUESTION_HISTORY",
-);
-
-export const onOpenTimelines = createAction("metabase/qb/OPEN_TIMELINES");
-export const onCloseTimelines = createAction("metabase/qb/CLOSE_TIMELINES");
-
-export const onCloseChartType = createAction("metabase/qb/CLOSE_CHART_TYPE");
-export const onCloseSidebars = createAction("metabase/qb/CLOSE_SIDEBARS");
-
-export const SET_CURRENT_STATE = "metabase/qb/SET_CURRENT_STATE";
-const setCurrentState = createAction(SET_CURRENT_STATE);
-
-export const POP_STATE = "metabase/qb/POP_STATE";
-export const popState = createThunkAction(
-  POP_STATE,
-  location => async (dispatch, getState) => {
-    dispatch(cancelQuery());
-
-    const zoomedObjectId = getZoomedObjectId(getState());
-    if (zoomedObjectId) {
-      const { locationBeforeTransitions = {} } = getState().routing;
-      const { state, query } = locationBeforeTransitions;
-      const previouslyZoomedObjectId = state?.objectId || query?.objectId;
-
-      if (
-        previouslyZoomedObjectId &&
-        zoomedObjectId !== previouslyZoomedObjectId
-      ) {
-        dispatch(zoomInRow({ objectId: previouslyZoomedObjectId }));
-      } else {
-        dispatch(resetRowZoom());
-      }
-      return;
-    }
-
-    const card = getCard(getState());
-    if (location.state && location.state.card) {
-      if (!Utils.equals(card, location.state.card)) {
-        const shouldRefreshUrl = location.state.card.dataset;
-        await dispatch(setCardAndRun(location.state.card, shouldRefreshUrl));
-        await dispatch(setCurrentState(location.state));
-      }
-    }
-
-    const {
-      mode: queryBuilderModeFromURL,
-      ...uiControls
-    } = getQueryBuilderModeFromLocation(location);
-
-    if (getQueryBuilderMode(getState()) !== queryBuilderModeFromURL) {
-      await dispatch(
-        setQueryBuilderMode(queryBuilderModeFromURL, {
-          ...uiControls,
-          shouldUpdateUrl: queryBuilderModeFromURL === "dataset",
-        }),
-      );
-    }
-  },
-);
-
-const getURL = (location, { includeMode = false } = {}) =>
-  // strip off trailing queryBuilderMode
-  (includeMode
-    ? location.pathname
-    : location.pathname.replace(/\/(notebook|view)$/, "")) +
-  location.search +
-  location.hash;
-
-// Logic for handling location changes, dispatched by top-level QueryBuilder component
-export const locationChanged = (location, nextLocation, nextParams) => (
-  dispatch,
-  getState,
-) => {
-  if (location !== nextLocation) {
-    if (nextLocation.action === "POP") {
-      if (
-        getURL(nextLocation, { includeMode: true }) !==
-        getURL(location, { includeMode: true })
-      ) {
-        // the browser forward/back button was pressed
-        dispatch(popState(nextLocation));
-      }
-    } else if (
-      (nextLocation.action === "PUSH" || nextLocation.action === "REPLACE") &&
-      // ignore PUSH/REPLACE with `state` because they were initiated by the `updateUrl` action
-      nextLocation.state === undefined
-    ) {
-      // a link to a different qb url was clicked
-      dispatch(initializeQB(nextLocation, nextParams));
-    }
-  }
-};
-
-export const CREATE_PUBLIC_LINK = "metabase/card/CREATE_PUBLIC_LINK";
-export const createPublicLink = createAction(CREATE_PUBLIC_LINK, ({ id }) =>
-  CardApi.createPublicLink({ id }),
-);
-
-export const DELETE_PUBLIC_LINK = "metabase/card/DELETE_PUBLIC_LINK";
-export const deletePublicLink = createAction(DELETE_PUBLIC_LINK, ({ id }) =>
-  CardApi.deletePublicLink({ id }),
-);
-
-export const UPDATE_ENABLE_EMBEDDING = "metabase/card/UPDATE_ENABLE_EMBEDDING";
-export const updateEnableEmbedding = createAction(
-  UPDATE_ENABLE_EMBEDDING,
-  ({ id }, enable_embedding) => CardApi.update({ id, enable_embedding }),
-);
-
-export const UPDATE_EMBEDDING_PARAMS = "metabase/card/UPDATE_EMBEDDING_PARAMS";
-export const updateEmbeddingParams = createAction(
-  UPDATE_EMBEDDING_PARAMS,
-  ({ id }, embedding_params) => CardApi.update({ id, embedding_params }),
-);
-
-export const UPDATE_URL = "metabase/qb/UPDATE_URL";
-export const updateUrl = createThunkAction(
-  UPDATE_URL,
-  (
-    card,
-    {
-      dirty,
-      replaceState,
-      preserveParameters = true,
-      queryBuilderMode,
-      datasetEditorTab,
-      objectId,
-    } = {},
-  ) => (dispatch, getState) => {
-    let question;
-    if (!card) {
-      card = getCard(getState());
-      question = getQuestion(getState());
-    } else {
-      question = new Question(card, getMetadata(getState()));
-    }
-
-    if (dirty == null) {
-      const originalQuestion = getOriginalQuestion(getState());
-      const isAdHocModel = isAdHocModelQuestion(question, originalQuestion);
-      dirty =
-        !originalQuestion ||
-        (!isAdHocModel && question.isDirtyComparedTo(originalQuestion));
-    }
-
-    // prevent clobbering of hash when there are fake parameters on the question
-    // consider handling this in a more general way, somehow
-    if (question.isStructured() && question.parameters().length > 0) {
-      dirty = true;
-    }
-
-    if (!queryBuilderMode) {
-      queryBuilderMode = getQueryBuilderMode(getState());
-    }
-    if (!datasetEditorTab) {
-      datasetEditorTab = getDatasetEditorTab(getState());
-    }
-
-    const copy = cleanCopyCard(card);
-
-    const newState = {
-      card: copy,
-      cardId: copy.id,
-      serializedCard: serializeCardForUrl(copy),
-      objectId,
-    };
-
-    const { currentState } = getState().qb;
-    const queryParams = preserveParameters ? getCurrentQueryParams() : {};
-    const url = getURLForCardState(newState, dirty, queryParams, objectId);
-
-    const urlParsed = urlParse(url);
-    const locationDescriptor = {
-      pathname: getPathNameFromQueryBuilderMode({
-        pathname: urlParsed.pathname || "",
-        queryBuilderMode,
-        datasetEditorTab,
-      }),
-      search: urlParsed.search,
-      hash: urlParsed.hash,
-      state: newState,
-    };
-
-    const isSameURL =
-      locationDescriptor.pathname === window.location.pathname &&
-      (locationDescriptor.search || "") === (window.location.search || "") &&
-      (locationDescriptor.hash || "") === (window.location.hash || "");
-    const isSameCard =
-      currentState && currentState.serializedCard === newState.serializedCard;
-    const isSameMode =
-      getQueryBuilderModeFromLocation(locationDescriptor).mode ===
-      getQueryBuilderModeFromLocation(window.location).mode;
-
-    if (isSameCard && isSameURL) {
-      return;
-    }
-
-    if (replaceState == null) {
-      // 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;
-    }
-
-    // this is necessary because we can't get the state from history.state
-    dispatch(setCurrentState(newState));
-    if (replaceState) {
-      dispatch(replace(locationDescriptor));
-    } else {
-      dispatch(push(locationDescriptor));
-    }
-  },
-);
-
-export const REDIRECT_TO_NEW_QUESTION_FLOW =
-  "metabase/qb/REDIRECT_TO_NEW_QUESTION_FLOW";
-export const redirectToNewQuestionFlow = createThunkAction(
-  REDIRECT_TO_NEW_QUESTION_FLOW,
-  () => (dispatch, getState) => dispatch(replace("/question/new")),
-);
-
-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));
-  }
-}
-
-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 TOGGLE_DATA_REFERENCE = "metabase/qb/TOGGLE_DATA_REFERENCE";
-export const toggleDataReference = createAction(TOGGLE_DATA_REFERENCE, () => {
-  MetabaseAnalytics.trackStructEvent("QueryBuilder", "Toggle Data Reference");
-});
-
-export const TOGGLE_TEMPLATE_TAGS_EDITOR =
-  "metabase/qb/TOGGLE_TEMPLATE_TAGS_EDITOR";
-export const toggleTemplateTagsEditor = createAction(
-  TOGGLE_TEMPLATE_TAGS_EDITOR,
-  () => {
-    MetabaseAnalytics.trackStructEvent(
-      "QueryBuilder",
-      "Toggle Template Tags Editor",
-    );
-  },
-);
-
-export const SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR =
-  "metabase/qb/SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR";
-export const setIsShowingTemplateTagsEditor = isShowingTemplateTagsEditor => ({
-  type: SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR,
-  isShowingTemplateTagsEditor,
-});
-
-export const TOGGLE_SNIPPET_SIDEBAR = "metabase/qb/TOGGLE_SNIPPET_SIDEBAR";
-export const toggleSnippetSidebar = createAction(TOGGLE_SNIPPET_SIDEBAR, () => {
-  MetabaseAnalytics.trackStructEvent("QueryBuilder", "Toggle Snippet Sidebar");
-});
-
-export const SET_IS_SHOWING_SNIPPET_SIDEBAR =
-  "metabase/qb/SET_IS_SHOWING_SNIPPET_SIDEBAR";
-export const setIsShowingSnippetSidebar = isShowingSnippetSidebar => ({
-  type: SET_IS_SHOWING_SNIPPET_SIDEBAR,
-  isShowingSnippetSidebar,
-});
-
-export const setIsPreviewing = isPreviewing => ({
-  type: SET_UI_CONTROLS,
-  payload: { isPreviewing },
-});
-
-export const setIsNativeEditorOpen = isNativeEditorOpen => ({
-  type: SET_UI_CONTROLS,
-  payload: { isNativeEditorOpen },
-});
-
-export const SET_NATIVE_EDITOR_SELECTED_RANGE =
-  "metabase/qb/SET_NATIVE_EDITOR_SELECTED_RANGE";
-export const setNativeEditorSelectedRange = createAction(
-  SET_NATIVE_EDITOR_SELECTED_RANGE,
-);
-
-export const SET_MODAL_SNIPPET = "metabase/qb/SET_MODAL_SNIPPET";
-export const setModalSnippet = createAction(SET_MODAL_SNIPPET);
-
-export const SET_SNIPPET_COLLECTION_ID =
-  "metabase/qb/SET_SNIPPET_COLLECTION_ID";
-export const setSnippetCollectionId = createAction(SET_SNIPPET_COLLECTION_ID);
-
-export const openSnippetModalWithSelectedText = () => (dispatch, getState) => {
-  const state = getState();
-  const content = getNativeEditorSelectedText(state);
-  const collection_id = getSnippetCollectionId(state);
-  dispatch(setModalSnippet({ content, collection_id }));
-};
-
-export const closeSnippetModal = () => (dispatch, getState) => {
-  dispatch(setModalSnippet(null));
-};
-
-export const insertSnippet = snip => (dispatch, getState) => {
-  const name = snip.name;
-  const question = getQuestion(getState());
-  const query = question.query();
-  const nativeEditorCursorOffset = getNativeEditorCursorOffset(getState());
-  const nativeEditorSelectedText = getNativeEditorSelectedText(getState());
-  const selectionStart =
-    nativeEditorCursorOffset - (nativeEditorSelectedText || "").length;
-  const newText =
-    query.queryText().slice(0, selectionStart) +
-    `{{snippet: ${name}}}` +
-    query.queryText().slice(nativeEditorCursorOffset);
-  const datasetQuery = query
-    .setQueryText(newText)
-    .updateSnippetsWithIds([snip])
-    .datasetQuery();
-  dispatch(updateQuestion(question.setDatasetQuery(datasetQuery)));
-};
-
-export const CLOSE_QB_NEWB_MODAL = "metabase/qb/CLOSE_QB_NEWB_MODAL";
-export const closeQbNewbModal = createThunkAction(CLOSE_QB_NEWB_MODAL, () => {
-  return async (dispatch, getState) => {
-    // persist the fact that this user has seen the NewbModal
-    const { currentUser } = getState();
-    await UserApi.update_qbnewb({ id: currentUser.id });
-    MetabaseAnalytics.trackStructEvent("QueryBuilder", "Close Newb Modal");
-  };
-});
-
-export const loadMetadataForCard = card => (dispatch, getState) => {
-  const metadata = getMetadata(getState());
-  const question = new Question(card, metadata);
-  const queries = [question.query()];
-  if (question.isDataset()) {
-    queries.push(question.composeDataset().query());
-  }
-  return dispatch(
-    loadMetadataForQueries(queries, question.dependentMetadata()),
-  );
-};
-
-function hasNewColumns(question, queryResult) {
-  // NOTE: this assume column names will change
-  // technically this is wrong because you could add and remove two columns with the same name
-  const query = question.query();
-  const previousColumns =
-    (queryResult && queryResult.data.cols.map(col => col.name)) || [];
-  const nextColumns =
-    query instanceof StructuredQuery ? query.columnNames() : [];
-  return _.difference(nextColumns, previousColumns).length > 0;
-}
-
-export const updateCardVisualizationSettings = settings => async (
-  dispatch,
-  getState,
-) => {
-  const question = getQuestion(getState());
-  const previousQueryBuilderMode = getPreviousQueryBuilderMode(getState());
-  const queryBuilderMode = getQueryBuilderMode(getState());
-  const datasetEditorTab = getDatasetEditorTab(getState());
-  const isEditingDatasetMetadata =
-    queryBuilderMode === "dataset" && datasetEditorTab === "metadata";
-  const wasJustEditingModel =
-    previousQueryBuilderMode === "dataset" && queryBuilderMode !== "dataset";
-  const changedSettings = Object.keys(settings);
-  const isColumnWidthResetEvent =
-    changedSettings.length === 1 &&
-    changedSettings.includes("table.column_widths") &&
-    settings["table.column_widths"] === undefined;
-
-  if (
-    (isEditingDatasetMetadata || wasJustEditingModel) &&
-    isColumnWidthResetEvent
-  ) {
-    return;
-  }
-
-  // The check allows users without data permission to resize/rearrange columns
-  const hasWritePermissions = question.query().isEditable();
-  await dispatch(
-    updateQuestion(question.updateSettings(settings), {
-      run: hasWritePermissions ? "auto" : false,
-      shouldUpdateUrl: hasWritePermissions,
-    }),
-  );
-};
-
-export const replaceAllCardVisualizationSettings = settings => async (
-  dispatch,
-  getState,
-) => {
-  const question = getQuestion(getState());
-
-  // The check allows users without data permission to resize/rearrange columns
-  const hasWritePermissions = question.query().isEditable();
-  await dispatch(
-    updateQuestion(question.setSettings(settings), {
-      run: hasWritePermissions ? "auto" : false,
-      shouldUpdateUrl: hasWritePermissions,
-    }),
-  );
-};
-
-export const SET_TEMPLATE_TAG = "metabase/qb/SET_TEMPLATE_TAG";
-export const setTemplateTag = createThunkAction(
-  SET_TEMPLATE_TAG,
-  templateTag => {
-    return (dispatch, getState) => {
-      const {
-        qb: { card, uiControls },
-      } = getState();
-
-      const updatedCard = Utils.copy(card);
-
-      // when the query changes on saved card we change this into a new query w/ a known starting point
-      if (
-        !uiControls.isEditing &&
-        uiControls.queryBuilderMode !== "dataset" &&
-        updatedCard.id
-      ) {
-        delete updatedCard.id;
-        delete updatedCard.name;
-        delete updatedCard.description;
-      }
-
-      // we need to preserve the order of the keys to avoid UI jumps
-      return updateIn(
-        updatedCard,
-        ["dataset_query", "native", "template-tags"],
-        tags => {
-          const { name } = templateTag;
-          const newTag =
-            tags[name] && tags[name].type !== templateTag.type
-              ? // when we switch type, null out any default
-                { ...templateTag, default: null }
-              : templateTag;
-          return { ...tags, [name]: newTag };
-        },
-      );
-    };
-  },
-);
-
-export const SET_PARAMETER_VALUE = "metabase/qb/SET_PARAMETER_VALUE";
-export const setParameterValue = createAction(
-  SET_PARAMETER_VALUE,
-  (parameterId, value) => {
-    return { id: parameterId, value };
-  },
-);
-
-// refetches the card without triggering a run of the card's query
-export const SOFT_RELOAD_CARD = "metabase/qb/SOFT_RELOAD_CARD";
-export const softReloadCard = createThunkAction(SOFT_RELOAD_CARD, () => {
-  return async (dispatch, getState) => {
-    const outdatedCard = getCard(getState());
-    const action = await dispatch(
-      Questions.actions.fetch({ id: outdatedCard.id }, { reload: true }),
-    );
-
-    return Questions.HACK_getObjectFromAction(action);
-  };
-});
-
-export const RELOAD_CARD = "metabase/qb/RELOAD_CARD";
-export const reloadCard = createThunkAction(RELOAD_CARD, () => {
-  return async (dispatch, getState) => {
-    const outdatedCard = getCard(getState());
-
-    dispatch(resetQB());
-
-    const action = await dispatch(
-      Questions.actions.fetch({ id: outdatedCard.id }, { reload: true }),
-    );
-    const card = Questions.HACK_getObjectFromAction(action);
-
-    dispatch(loadMetadataForCard(card));
-
-    dispatch(
-      runQuestionQuery({
-        overrideWithCard: card,
-        shouldUpdateUrl: false,
-      }),
-    );
-
-    // if the name of the card changed this will update the url slug
-    dispatch(updateUrl(card, { dirty: false }));
-
-    return card;
-  };
-});
-
-/**
- * `setCardAndRun` is used when:
- *     - navigating browser history
- *     - clicking in the entity details view
- *     - `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) => {
-    // clone
-    const card = Utils.copy(nextCard);
-
-    const originalCard = card.original_card_id
-      ? // If the original card id is present, dynamically load its information for showing lineage
-        await loadCard(card.original_card_id)
-      : // Otherwise, use a current card as the original card if the card has been saved
-      // This is needed for checking whether the card is in dirty state or not
-      card.id
-      ? card
-      : null;
-
-    // Update the card and originalCard before running the actual query
-    dispatch.action(SET_CARD_AND_RUN, { card, originalCard });
-    dispatch(runQuestionQuery({ shouldUpdateUrl }));
-
-    // Load table & database metadata for the current question
-    dispatch(loadMetadataForCard(card));
-  };
-};
-
-/**
- * User-triggered events that are handled with this action:
- *     - clicking a legend:
- *         * series legend (multi-aggregation, multi-breakout, multiple questions)
- *     - clicking the visualization itself
- *         * drill-through (single series, multi-aggregation, multi-breakout, multiple questions)
- *         * (not in 0.24.2 yet: drag on line/area/bar visualization)
- *     - clicking an action widget action
- *
- * All these events can be applied either for an unsaved question or a saved question.
- */
-export const NAVIGATE_TO_NEW_CARD = "metabase/qb/NAVIGATE_TO_NEW_CARD";
-export const navigateToNewCardInsideQB = createThunkAction(
-  NAVIGATE_TO_NEW_CARD,
-  ({ nextCard, previousCard, objectId }) => {
-    return async (dispatch, getState) => {
-      if (previousCard === nextCard) {
-        // Do not reload questions with breakouts when clicked on a legend item
-      } else if (cardIsEquivalent(previousCard, nextCard)) {
-        // This is mainly a fallback for scenarios where a visualization legend is clicked inside QB
-        dispatch(setCardAndRun(await loadCard(nextCard.id)));
-      } else {
-        const card = getCardAfterVisualizationClick(nextCard, previousCard);
-        const url = Urls.serializedQuestion(card);
-        if (shouldOpenInBlankWindow(url, { blankOnMetaOrCtrlKey: true })) {
-          dispatch(openUrl(url));
-        } else {
-          dispatch(onCloseSidebars());
-          if (!cardQueryIsEquivalent(previousCard, nextCard)) {
-            // clear the query result so we don't try to display the new visualization before running the new query
-            dispatch(clearQueryResult());
-          }
-          // When the dataset query changes, we should loose the dataset flag,
-          // to start building a new ad-hoc question based on a dataset
-          dispatch(setCardAndRun({ ...card, dataset: false }));
-        }
-        if (objectId !== undefined) {
-          dispatch(zoomInRow({ objectId }));
-        }
-      }
-    };
-  },
-);
-
-// TODO Atte Keinänen 6/2/2017 See if we should stick to `updateX` naming convention instead of `setX` in all Redux actions
-// We talked with Tom that `setX` method names could be reserved to metabase-lib classes
-
-/**
- * Replaces the currently actived question with the given Question object.
- * Also shows/hides the template tag editor if the number of template tags has changed.
- */
-export const UPDATE_QUESTION = "metabase/qb/UPDATE_QUESTION";
-export const updateQuestion = (
-  newQuestion,
-  { run = false, shouldUpdateUrl = false } = {},
-) => {
-  return async (dispatch, getState) => {
-    const oldQuestion = getQuestion(getState());
-    const mode = getQueryBuilderMode(getState());
-
-    const shouldConvertIntoAdHoc = newQuestion.query().isEditable();
-
-    // TODO Atte Keinänen 6/2/2017 Ways to have this happen automatically when modifying a question?
-    // Maybe the Question class or a QB-specific question wrapper class should know whether it's being edited or not?
-    if (
-      shouldConvertIntoAdHoc &&
-      !getIsEditing(getState()) &&
-      newQuestion.isSaved() &&
-      mode !== "dataset"
-    ) {
-      newQuestion = newQuestion.withoutNameAndId();
-
-      // When the dataset query changes, we should loose the dataset flag,
-      // to start building a new ad-hoc question based on a dataset
-      if (newQuestion.isDataset()) {
-        newQuestion = newQuestion.setDataset(false);
-        dispatch(onCloseQuestionDetails());
-      }
-    }
-
-    const queryResult = getFirstQueryResult(getState());
-    newQuestion = newQuestion.syncColumnsAndSettings(oldQuestion, queryResult);
-
-    if (run === "auto") {
-      run = hasNewColumns(newQuestion, queryResult);
-    }
-
-    if (!newQuestion.canAutoRun()) {
-      run = false;
-    }
-
-    // <PIVOT LOGIC>
-    // We have special logic when going to, coming from, or updating a pivot table.
-    const isPivot = newQuestion.display() === "pivot";
-    const wasPivot = oldQuestion.display() === "pivot";
-    const queryHasBreakouts =
-      isPivot &&
-      newQuestion.isStructured() &&
-      newQuestion.query().breakouts().length > 0;
-
-    // we can only pivot queries with breakouts
-    if (isPivot && queryHasBreakouts) {
-      // compute the pivot setting now so we can query the appropriate data
-      const series = assocIn(
-        getRawSeries(getState()),
-        [0, "card"],
-        newQuestion.card(),
-      );
-      const key = "pivot_table.column_split";
-      const setting = getQuestionWithDefaultVisualizationSettings(
-        newQuestion,
-        series,
-      ).setting(key);
-      newQuestion = newQuestion.updateSettings({ [key]: setting });
-    }
-
-    if (
-      // switching to pivot
-      (isPivot && !wasPivot && queryHasBreakouts) ||
-      // switching away from pivot
-      (!isPivot && wasPivot) ||
-      // updating the pivot rows/cols
-      (isPivot &&
-        queryHasBreakouts &&
-        !_.isEqual(
-          newQuestion.setting("pivot_table.column_split"),
-          oldQuestion.setting("pivot_table.column_split"),
-        ))
-    ) {
-      run = true; // force a run when switching to/from pivot or updating it's setting
-    }
-    // </PIVOT LOGIC>
-
-    // Native query should never be in notebook mode (metabase#12651)
-    if (mode === "notebook" && newQuestion.isNative()) {
-      await dispatch(
-        setQueryBuilderMode("view", {
-          shouldUpdateUrl: false,
-        }),
-      );
-    }
-
-    // Replace the current question with a new one
-    await dispatch.action(UPDATE_QUESTION, { card: newQuestion.card() });
-
-    if (shouldUpdateUrl) {
-      dispatch(updateUrl(null, { dirty: true }));
-    }
-
-    // See if the template tags editor should be shown/hidden
-    const isTemplateTagEditorVisible = getIsShowingTemplateTagsEditor(
-      getState(),
-    );
-    const nextTagEditorVisibilityState = getNextTemplateTagVisibilityState({
-      oldQuestion,
-      newQuestion,
-      isTemplateTagEditorVisible,
-      queryBuilderMode: mode,
-    });
-    if (nextTagEditorVisibilityState !== "deferToCurrentState") {
-      dispatch(
-        setIsShowingTemplateTagsEditor(
-          nextTagEditorVisibilityState === "visible",
-        ),
-      );
-    }
-
-    try {
-      if (
-        !_.isEqual(
-          oldQuestion.query().dependentMetadata(),
-          newQuestion.query().dependentMetadata(),
-        )
-      ) {
-        await dispatch(loadMetadataForCard(newQuestion.card()));
-      }
-
-      // setDefaultQuery requires metadata be loaded, need getQuestion to use new metadata
-      const question = getQuestion(getState());
-      const questionWithDefaultQuery = question.setDefaultQuery();
-      if (!questionWithDefaultQuery.isEqual(question)) {
-        await dispatch.action(UPDATE_QUESTION, {
-          card: questionWithDefaultQuery.setDefaultDisplay().card(),
-        });
-      }
-    } catch (e) {
-      // this will fail if user doesn't have data permissions but thats ok
-      console.warn("Couldn't load metadata", e);
-    }
-
-    // run updated query
-    if (run) {
-      dispatch(runQuestionQuery());
-    }
-  };
-};
-
-// DEPRECATED, still used in a couple places
-export const setDatasetQuery = (datasetQuery, options) => (
-  dispatch,
-  getState,
-) => {
-  const question = getQuestion(getState());
-  dispatch(updateQuestion(question.setDatasetQuery(datasetQuery), options));
-};
-
-export const API_CREATE_QUESTION = "metabase/qb/API_CREATE_QUESTION";
-export const apiCreateQuestion = question => {
-  return async (dispatch, getState) => {
-    // Needed for persisting visualization columns for pulses/alerts, see #6749
-    const series = getTransformedSeries(getState());
-    const questionWithVizSettings = series
-      ? getQuestionWithDefaultVisualizationSettings(question, series)
-      : question;
-
-    const resultsMetadata = getResultsMetadata(getState());
-    const createdQuestion = await questionWithVizSettings
-      .setQuery(question.query().clean())
-      .setResultsMetadata(resultsMetadata)
-      .reduxCreate(dispatch);
-
-    // remove the databases in the store that are used to populate the QB databases list.
-    // This is done when saving a Card because the newly saved card will be eligible for use as a source query
-    // so we want the databases list to be re-fetched next time we hit "New Question" so it shows up
-    dispatch(setRequestUnloaded(["entities", "databases"]));
-
-    dispatch(updateUrl(createdQuestion.card(), { dirty: false }));
-    MetabaseAnalytics.trackStructEvent(
-      "QueryBuilder",
-      "Create Card",
-      createdQuestion.query().datasetQuery().type,
-    );
-    trackNewQuestionSaved(
-      question,
-      createdQuestion,
-      isBasedOnExistingQuestion(getState()),
-    );
-
-    // Saving a card, locks in the current display as though it had been
-    // selected in the UI.
-    const card = createdQuestion.lockDisplay().card();
-
-    dispatch.action(API_CREATE_QUESTION, card);
-  };
-};
-
-export const API_UPDATE_QUESTION = "metabase/qb/API_UPDATE_QUESTION";
-export const apiUpdateQuestion = (question, { rerunQuery = false } = {}) => {
-  return async (dispatch, getState) => {
-    const originalQuestion = getOriginalQuestion(getState());
-    question = question || getQuestion(getState());
-
-    // Needed for persisting visualization columns for pulses/alerts, see #6749
-    const series = getTransformedSeries(getState());
-    const questionWithVizSettings = series
-      ? getQuestionWithDefaultVisualizationSettings(question, series)
-      : question;
-
-    const resultsMetadata = getResultsMetadata(getState());
-    const updatedQuestion = await questionWithVizSettings
-      .setQuery(question.query().clean())
-      .setResultsMetadata(resultsMetadata)
-      // When viewing a dataset, its dataset_query is swapped with a clean query using the dataset as a source table
-      // (it's necessary for datasets to behave like tables opened in simple mode)
-      // When doing updates like changing name, description, etc., we need to omit the dataset_query in the request body
-      .reduxUpdate(dispatch, {
-        excludeDatasetQuery: isAdHocModelQuestion(question, originalQuestion),
-      });
-
-    // reload the question alerts for the current question
-    // (some of the old alerts might be removed during update)
-    await dispatch(fetchAlertsForQuestion(updatedQuestion.id()));
-
-    // remove the databases in the store that are used to populate the QB databases list.
-    // This is done when saving a Card because the newly saved card will be eligible for use as a source query
-    // so we want the databases list to be re-fetched next time we hit "New Question" so it shows up
-    dispatch(setRequestUnloaded(["entities", "databases"]));
-
-    MetabaseAnalytics.trackStructEvent(
-      "QueryBuilder",
-      "Update Card",
-      updatedQuestion.query().datasetQuery().type,
-    );
-
-    dispatch.action(API_UPDATE_QUESTION, updatedQuestion.card());
-
-    if (rerunQuery) {
-      await dispatch(loadMetadataForCard(question.card()));
-      dispatch(runQuestionQuery());
-    }
-  };
-};
-
-/**
- * Queries the result for the currently active question or alternatively for the card provided in `overrideWithCard`.
- * The API queries triggered by this action creator can be cancelled using the deferred provided in RUN_QUERY action.
- */
-export const RUN_QUERY = "metabase/qb/RUN_QUERY";
-export const runQuestionQuery = ({
-  shouldUpdateUrl = true,
-  ignoreCache = false,
-  overrideWithCard,
-} = {}) => {
-  return async (dispatch, getState) => {
-    dispatch(loadStartUIControls());
-    const questionFromCard = card =>
-      card && new Question(card, getMetadata(getState()));
-
-    let question = overrideWithCard
-      ? questionFromCard(overrideWithCard)
-      : getQuestion(getState());
-    const originalQuestion = getOriginalQuestion(getState());
-
-    const cardIsDirty = originalQuestion
-      ? question.isDirtyComparedToWithoutParameters(originalQuestion) ||
-        question.card().id == null
-      : true;
-
-    if (shouldUpdateUrl) {
-      const isAdHocModel =
-        question.isDataset() &&
-        isAdHocModelQuestion(question, originalQuestion);
-
-      dispatch(
-        updateUrl(question.card(), { dirty: !isAdHocModel && cardIsDirty }),
-      );
-    }
-
-    if (getIsPreviewing(getState())) {
-      question = question.setDatasetQuery(
-        assocIn(
-          question.datasetQuery(),
-          ["constraints", "max-results"],
-          PREVIEW_RESULT_LIMIT,
-        ),
-      );
-    }
-
-    const startTime = new Date();
-    const cancelQueryDeferred = defer();
-
-    const queryTimer = startTimer();
-
-    question
-      .apiGetResults({
-        cancelDeferred: cancelQueryDeferred,
-        ignoreCache: ignoreCache,
-        isDirty: cardIsDirty,
-      })
-      .then(queryResults => {
-        queryTimer(duration =>
-          MetabaseAnalytics.trackStructEvent(
-            "QueryBuilder",
-            "Run Query",
-            question.query().datasetQuery().type,
-            duration,
-          ),
-        );
-        // clearTimeout(timeoutId);
-        return dispatch(queryCompleted(question, queryResults));
-      })
-      .catch(error => dispatch(queryErrored(startTime, error)));
-
-    // TODO Move this out from Redux action asap
-    // HACK: prevent SQL editor from losing focus
-    try {
-      ace.edit("id_sql").focus();
-    } catch (e) {}
-
-    dispatch.action(RUN_QUERY, { cancelQueryDeferred });
-  };
-};
-
-const loadStartUIControls = createThunkAction(
-  LOAD_START_UI_CONTROLS,
-  () => (dispatch, getState) => {
-    dispatch(setDocumentTitle(t`Doing Science...`));
-    const timeoutId = setTimeout(() => {
-      dispatch(setDocumentTitle(t`Still Here...`));
-    }, 10000);
-    dispatch(setDocumentTitleTimeoutId(timeoutId));
-  },
-);
-
-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) => {
-    const [{ data }] = queryResults;
-    const [{ data: prevData }] = getQueryResults(getState()) || [{}];
-    const originalQuestion = getOriginalQuestion(getState());
-    const isDirty =
-      question.query().isEditable() &&
-      question.isDirtyComparedTo(originalQuestion);
-
-    if (isDirty) {
-      if (question.isNative()) {
-        question = question.syncColumnsAndSettings(
-          originalQuestion,
-          queryResults[0],
-        );
-      }
-      // Only update the display if the question is new or has been changed.
-      // Otherwise, trust that the question was saved with the correct display.
-      question = question
-        // if we are going to trigger autoselection logic, check if the locked display no longer is "sensible".
-        .maybeUnlockDisplay(
-          getSensibleDisplays(data),
-          prevData && getSensibleDisplays(prevData),
-        )
-        .setDefaultDisplay()
-        .switchTableScalar(data);
-    }
-
-    const card = question.card();
-    const isEditingModel = getQueryBuilderMode(getState()) === "dataset";
-    const resultsMetadata = data?.results_metadata?.columns;
-    if (isEditingModel && Array.isArray(resultsMetadata)) {
-      card.result_metadata = resultsMetadata;
-    }
-
-    dispatch.action(QUERY_COMPLETED, { card, queryResults });
-    dispatch(loadCompleteUIControls());
-  };
-};
-
-const loadCompleteUIControls = createThunkAction(
-  LOAD_COMPLETE_UI_CONTROLS,
-  () => (dispatch, getState) => {
-    const timeoutId = getTimeoutId(getState());
-    clearTimeout(timeoutId);
-    dispatch(showLoadingCompleteFavicon());
-    if (document.hidden) {
-      dispatch(setDocumentTitle(t`Your question is ready!`));
-      document.addEventListener(
-        "visibilitychange",
-        () => {
-          dispatch(setDocumentTitle(""));
-          setTimeout(() => {
-            dispatch(hideLoadingCompleteFavicon());
-          }, 3000);
-        },
-        { once: true },
-      );
-    } else {
-      dispatch(setDocumentTitle(""));
-      setTimeout(() => {
-        dispatch(hideLoadingCompleteFavicon());
-      }, 3000);
-    }
-  },
-);
-
-/**
- * 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.
- *
- * Needed for persisting visualization columns for pulses/alerts, see #6749.
- */
-const getQuestionWithDefaultVisualizationSettings = (question, series) => {
-  const oldVizSettings = question.settings();
-  const newVizSettings = {
-    ...oldVizSettings,
-    ...getPersistableDefaultSettingsForSeries(series),
-  };
-
-  // Don't update the question unnecessarily
-  // (even if fields values haven't changed, updating the settings will make the question appear dirty)
-  if (!_.isEqual(oldVizSettings, newVizSettings)) {
-    return question.setSettings(newVizSettings);
-  } else {
-    return question;
-  }
-};
-
-export const QUERY_ERRORED = "metabase/qb/QUERY_ERRORED";
-export const queryErrored = createThunkAction(
-  QUERY_ERRORED,
-  (startTime, error) => {
-    return async (dispatch, getState) => {
-      if (error && error.isCancelled) {
-        // cancelled, do nothing
-        return null;
-      } else {
-        return { error: error, duration: new Date() - startTime };
-      }
-    };
-  },
-);
-
-// cancelQuery
-export const CANCEL_QUERY = "metabase/qb/CANCEL_QUERY";
-export const cancelQuery = () => (dispatch, getState) => {
-  const isRunning = getIsRunning(getState());
-  if (isRunning) {
-    const { cancelQueryDeferred } = getState().qb;
-    if (cancelQueryDeferred) {
-      cancelQueryDeferred.resolve();
-    }
-    return { type: CANCEL_QUERY };
-  }
-};
-
-export const ZOOM_IN_ROW = "metabase/qb/ZOOM_IN_ROW";
-export const zoomInRow = ({ objectId }) => dispatch => {
-  dispatch({ type: ZOOM_IN_ROW, payload: { objectId } });
-  dispatch(updateUrl(null, { objectId, replaceState: false }));
-};
-
-export const RESET_ROW_ZOOM = "metabase/qb/RESET_ROW_ZOOM";
-export const resetRowZoom = () => dispatch => {
-  dispatch({ type: RESET_ROW_ZOOM });
-  dispatch(updateUrl());
-};
-
-function getFilterForFK(zoomedObjectId, fk) {
-  const field = new FieldDimension(fk.origin.id);
-  return ["=", field.mbql(), zoomedObjectId];
-}
-
-export const FOLLOW_FOREIGN_KEY = "metabase/qb/FOLLOW_FOREIGN_KEY";
-export const followForeignKey = createThunkAction(
-  FOLLOW_FOREIGN_KEY,
-  ({ objectId, fk }) => {
-    return async (dispatch, getState) => {
-      const state = getState();
-
-      const card = getCard(state);
-      const queryResult = getFirstQueryResult(state);
-
-      if (!queryResult || !fk) {
-        return false;
-      }
-
-      const newCard = startNewCard("query", card.dataset_query.database);
-
-      newCard.dataset_query.query["source-table"] = fk.origin.table.id;
-      newCard.dataset_query.query.filter = getFilterForFK(objectId, fk);
-
-      dispatch(resetRowZoom());
-      dispatch(setCardAndRun(newCard));
-    };
-  },
-);
-
-export const LOAD_OBJECT_DETAIL_FK_REFERENCES =
-  "metabase/qb/LOAD_OBJECT_DETAIL_FK_REFERENCES";
-export const loadObjectDetailFKReferences = createThunkAction(
-  LOAD_OBJECT_DETAIL_FK_REFERENCES,
-  ({ objectId }) => {
-    return async (dispatch, getState) => {
-      dispatch.action(CLEAR_OBJECT_DETAIL_FK_REFERENCES);
-
-      const state = getState();
-      const tableForeignKeys = getTableForeignKeys(state);
-
-      if (!Array.isArray(tableForeignKeys)) {
-        return null;
-      }
-
-      const card = getCard(state);
-      const queryResult = getFirstQueryResult(state);
-
-      async function getFKCount(card, queryResult, fk) {
-        const fkQuery = Q_DEPRECATED.createQuery("query");
-
-        fkQuery.database = card.dataset_query.database;
-        fkQuery.query["source-table"] = fk.origin.table_id;
-        fkQuery.query.aggregation = ["count"];
-        fkQuery.query.filter = getFilterForFK(objectId, fk);
-
-        const info = { status: 0, value: null };
-
-        try {
-          const result = await MetabaseApi.dataset(fkQuery);
-          if (
-            result &&
-            result.status === "completed" &&
-            result.data.rows.length > 0
-          ) {
-            info["value"] = result.data.rows[0][0];
-          } else {
-            info["value"] = "Unknown";
-          }
-        } finally {
-          info["status"] = 1;
-        }
-
-        return info;
-      }
-
-      // TODO: there are possible cases where running a query would not require refreshing this data, but
-      // 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 = {};
-      for (let i = 0; i < tableForeignKeys.length; i++) {
-        const fk = tableForeignKeys[i];
-        const info = await getFKCount(card, queryResult, fk);
-        fkReferences[fk.origin.id] = info;
-      }
-
-      // It's possible that while we were running those queries, the object
-      // detail id changed. If so, these fk reference are stale and we shouldn't
-      // put them in state. The detail id is used in the query so we check that.
-      const updatedQueryResult = getFirstQueryResult(getState());
-      if (!_.isEqual(queryResult.json_query, updatedQueryResult.json_query)) {
-        return null;
-      }
-      return fkReferences;
-    };
-  },
-);
-
-export const CLEAR_OBJECT_DETAIL_FK_REFERENCES =
-  "metabase/qb/CLEAR_OBJECT_DETAIL_FK_REFERENCES";
-
-export const viewNextObjectDetail = () => {
-  return (dispatch, getState) => {
-    const objectId = getNextRowPKValue(getState());
-    if (objectId != null) {
-      dispatch(zoomInRow({ objectId }));
-    }
-  };
-};
-
-export const viewPreviousObjectDetail = () => {
-  return (dispatch, getState) => {
-    const objectId = getPreviousRowPKValue(getState());
-    if (objectId != null) {
-      dispatch(zoomInRow({ objectId }));
-    }
-  };
-};
-
-export const closeObjectDetail = () => dispatch => dispatch(resetRowZoom());
-
-export const SHOW_CHART_SETTINGS = "metabase/query_builder/SHOW_CHART_SETTINGS";
-export const showChartSettings = createAction(SHOW_CHART_SETTINGS);
-
-// these are just temporary mappings to appease the existing QB code and it's naming prefs
-export const onUpdateVisualizationSettings = updateCardVisualizationSettings;
-export const onReplaceAllVisualizationSettings = replaceAllCardVisualizationSettings;
-
-export const REVERT_TO_REVISION = "metabase/qb/REVERT_TO_REVISION";
-export const revertToRevision = createThunkAction(
-  REVERT_TO_REVISION,
-  revision => {
-    return async dispatch => {
-      await revision.revert();
-      await dispatch(reloadCard());
-    };
-  },
-);
-
-export const setDatasetEditorTab = datasetEditorTab => dispatch => {
-  dispatch(setQueryBuilderMode("dataset", { datasetEditorTab }));
-};
-
-export const CANCEL_DATASET_CHANGES = "metabase/qb/CANCEL_DATASET_CHANGES";
-export const onCancelDatasetChanges = () => (dispatch, getState) => {
-  const cardBeforeChanges = getOriginalCard(getState());
-  dispatch.action(CANCEL_DATASET_CHANGES, {
-    card: cardBeforeChanges,
-  });
-  dispatch(runQuestionQuery());
-};
-
-export const turnQuestionIntoDataset = () => async (dispatch, getState) => {
-  const question = getQuestion(getState());
-  const dataset = question.setDataset(true);
-  await dispatch(apiUpdateQuestion(dataset, { rerunQuery: true }));
-
-  dispatch(
-    addUndo({
-      message: t`This is a model now.`,
-      actions: [apiUpdateQuestion(question, { rerunQuery: true })],
-    }),
-  );
-};
-
-export const turnDatasetIntoQuestion = () => async (dispatch, getState) => {
-  const dataset = getQuestion(getState());
-  const question = dataset.setDataset(false);
-  await dispatch(apiUpdateQuestion(question, { rerunQuery: true }));
-
-  dispatch(
-    addUndo({
-      message: t`This is a question now.`,
-      actions: [apiUpdateQuestion(dataset, { rerunQuery: true })],
-    }),
-  );
-};
-
-export const SET_RESULTS_METADATA = "metabase/qb/SET_RESULTS_METADATA";
-export const setResultsMetadata = createAction(SET_RESULTS_METADATA);
-
-export const SET_METADATA_DIFF = "metabase/qb/SET_METADATA_DIFF";
-export const setMetadataDiff = createAction(SET_METADATA_DIFF);
-
-export const setFieldMetadata = ({ field_ref, changes }) => (
-  dispatch,
-  getState,
-) => {
-  const question = getQuestion(getState());
-  const resultsMetadata = getResultsMetadata(getState());
-
-  const nextColumnMetadata = resultsMetadata.columns.map(fieldMetadata => {
-    const compareExact =
-      !isLocalField(field_ref) || !isLocalField(fieldMetadata.field_ref);
-    const isTargetField = isSameField(
-      field_ref,
-      fieldMetadata.field_ref,
-      compareExact,
-    );
-    return isTargetField ? merge(fieldMetadata, changes) : fieldMetadata;
-  });
-
-  const nextResultsMetadata = {
-    ...resultsMetadata,
-    columns: nextColumnMetadata,
-  };
-
-  const nextQuestion = question.setResultsMetadata(nextResultsMetadata);
-
-  dispatch(updateQuestion(nextQuestion));
-  dispatch(setMetadataDiff({ field_ref, changes }));
-  dispatch(setResultsMetadata(nextResultsMetadata));
-};
-
-export const SHOW_TIMELINES = "metabase/qb/SHOW_TIMELINES";
-export const showTimelines = createAction(SHOW_TIMELINES);
-
-export const HIDE_TIMELINES = "metabase/qb/HIDE_TIMELINES";
-export const hideTimelines = createAction(HIDE_TIMELINES);
-
-export const SELECT_TIMELINE_EVENTS = "metabase/qb/SELECT_TIMELINE_EVENTS";
-export const selectTimelineEvents = createAction(SELECT_TIMELINE_EVENTS);
-
-export const DESELECT_TIMELINE_EVENTS = "metabase/qb/DESELECT_TIMELINE_EVENTS";
-export const deselectTimelineEvents = createAction(DESELECT_TIMELINE_EVENTS);
-
-export const showTimelinesForCollection = collectionId => (
-  dispatch,
-  getState,
-) => {
-  const fetchedTimelines = getFetchedTimelines(getState());
-  const collectionTimelines = collectionId
-    ? fetchedTimelines.filter(t => t.collection_id === collectionId)
-    : fetchedTimelines.filter(t => t.collection_id == null);
-
-  dispatch(showTimelines(collectionTimelines));
-};
-
-export const PERSIST_DATASET = "metabase/qb/PERSIST_DATASET";
-export const persistDataset = createAction(PERSIST_DATASET, id =>
-  CardApi.persist({ id }),
-);
-
-export const UNPERSIST_DATASET = "metabase/qb/UNPERSIST_DATASET";
-export const unpersistDataset = createAction(UNPERSIST_DATASET, id =>
-  CardApi.unpersist({ id }),
-);
diff --git a/frontend/src/metabase/query_builder/actions/core.js b/frontend/src/metabase/query_builder/actions/core.js
new file mode 100644
index 0000000000000000000000000000000000000000..0bcb8ef09241ec10a5cd1e66834368944d2f5522
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/core.js
@@ -0,0 +1,822 @@
+import _ from "underscore";
+import { assocIn, getIn } from "icepick";
+import querystring from "querystring";
+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 { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils";
+import { shouldOpenInBlankWindow } from "metabase/lib/dom";
+import * as Urls from "metabase/lib/urls";
+import Utils from "metabase/lib/utils";
+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 { 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 {
+  getCard,
+  getFirstQueryResult,
+  getIsEditing,
+  getIsShowingTemplateTagsEditor,
+  getOriginalQuestion,
+  getQueryBuilderMode,
+  getQuestion,
+  getRawSeries,
+  getResultsMetadata,
+  getTransformedSeries,
+  isBasedOnExistingQuestion,
+} from "../selectors";
+import {
+  getNextTemplateTagVisibilityState,
+  getQueryBuilderModeFromLocation,
+} from "../utils";
+
+import { redirectToNewQuestionFlow, updateUrl } from "./navigation";
+import { setIsShowingTemplateTagsEditor } from "./native";
+import { zoomInRow } from "./object-detail";
+import { cancelQuery, clearQueryResult, runQuestionQuery } from "./querying";
+import {
+  onCloseSidebars,
+  onCloseQuestionDetails,
+  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.
+ *
+ * Needed for persisting visualization columns for pulses/alerts, see #6749.
+ */
+const getQuestionWithDefaultVisualizationSettings = (question, series) => {
+  const oldVizSettings = question.settings();
+  const newVizSettings = {
+    ...oldVizSettings,
+    ...getPersistableDefaultSettingsForSeries(series),
+  };
+
+  // Don't update the question unnecessarily
+  // (even if fields values haven't changed, updating the settings will make the question appear dirty)
+  if (!_.isEqual(oldVizSettings, newVizSettings)) {
+    return question.setSettings(newVizSettings);
+  } else {
+    return question;
+  }
+};
+
+function hasNewColumns(question, queryResult) {
+  // NOTE: this assume column names will change
+  // technically this is wrong because you could add and remove two columns with the same name
+  const query = question.query();
+  const previousColumns =
+    (queryResult && queryResult.data.cols.map(col => col.name)) || [];
+  const nextColumns =
+    query instanceof StructuredQuery ? query.columnNames() : [];
+  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);
+  const queries = [question.query()];
+  if (question.isDataset()) {
+    queries.push(question.composeDataset().query());
+  }
+  return dispatch(
+    loadMetadataForQueries(queries, question.dependentMetadata()),
+  );
+};
+
+// refreshes the card without triggering a run of the card's query
+export const SOFT_RELOAD_CARD = "metabase/qb/SOFT_RELOAD_CARD";
+export const softReloadCard = createThunkAction(SOFT_RELOAD_CARD, () => {
+  return async (dispatch, getState) => {
+    const outdatedCard = getCard(getState());
+    const action = await dispatch(
+      Questions.actions.fetch({ id: outdatedCard.id }, { reload: true }),
+    );
+
+    return Questions.HACK_getObjectFromAction(action);
+  };
+});
+
+export const RELOAD_CARD = "metabase/qb/RELOAD_CARD";
+export const reloadCard = createThunkAction(RELOAD_CARD, () => {
+  return async (dispatch, getState) => {
+    const outdatedCard = getCard(getState());
+
+    dispatch(resetQB());
+
+    const action = await dispatch(
+      Questions.actions.fetch({ id: outdatedCard.id }, { reload: true }),
+    );
+    const card = Questions.HACK_getObjectFromAction(action);
+
+    dispatch(loadMetadataForCard(card));
+
+    dispatch(
+      runQuestionQuery({
+        overrideWithCard: card,
+        shouldUpdateUrl: false,
+      }),
+    );
+
+    // if the name of the card changed this will update the url slug
+    dispatch(updateUrl(card, { dirty: false }));
+
+    return card;
+  };
+});
+
+/**
+ * `setCardAndRun` is used when:
+ *     - navigating browser history
+ *     - clicking in the entity details view
+ *     - `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) => {
+    // clone
+    const card = Utils.copy(nextCard);
+
+    const originalCard = card.original_card_id
+      ? // If the original card id is present, dynamically load its information for showing lineage
+        await loadCard(card.original_card_id)
+      : // Otherwise, use a current card as the original card if the card has been saved
+      // This is needed for checking whether the card is in dirty state or not
+      card.id
+      ? card
+      : null;
+
+    // Update the card and originalCard before running the actual query
+    dispatch.action(SET_CARD_AND_RUN, { card, originalCard });
+    dispatch(runQuestionQuery({ shouldUpdateUrl }));
+
+    // Load table & database metadata for the current question
+    dispatch(loadMetadataForCard(card));
+  };
+};
+
+/**
+ * User-triggered events that are handled with this action:
+ *     - clicking a legend:
+ *         * series legend (multi-aggregation, multi-breakout, multiple questions)
+ *     - clicking the visualization itself
+ *         * drill-through (single series, multi-aggregation, multi-breakout, multiple questions)
+ *     - clicking an action widget action
+ *
+ * All these events can be applied either for an unsaved question or a saved question.
+ */
+export const NAVIGATE_TO_NEW_CARD = "metabase/qb/NAVIGATE_TO_NEW_CARD";
+export const navigateToNewCardInsideQB = createThunkAction(
+  NAVIGATE_TO_NEW_CARD,
+  ({ nextCard, previousCard, objectId }) => {
+    return async (dispatch, getState) => {
+      if (previousCard === nextCard) {
+        // Do not reload questions with breakouts when clicked on a legend item
+      } else if (cardIsEquivalent(previousCard, nextCard)) {
+        // This is mainly a fallback for scenarios where a visualization legend is clicked inside QB
+        dispatch(setCardAndRun(await loadCard(nextCard.id)));
+      } else {
+        const card = getCardAfterVisualizationClick(nextCard, previousCard);
+        const url = Urls.serializedQuestion(card);
+        if (shouldOpenInBlankWindow(url, { blankOnMetaOrCtrlKey: true })) {
+          dispatch(openUrl(url));
+        } else {
+          dispatch(onCloseSidebars());
+          if (!cardQueryIsEquivalent(previousCard, nextCard)) {
+            // clear the query result so we don't try to display the new visualization before running the new query
+            dispatch(clearQueryResult());
+          }
+          // When the dataset query changes, we should loose the dataset flag,
+          // to start building a new ad-hoc question based on a dataset
+          dispatch(setCardAndRun({ ...card, dataset: false }));
+        }
+        if (objectId !== undefined) {
+          dispatch(zoomInRow({ objectId }));
+        }
+      }
+    };
+  },
+);
+
+/**
+ * Replaces the currently active question with the given Question object.
+ * Also shows/hides the template tag editor if the number of template tags has changed.
+ */
+export const UPDATE_QUESTION = "metabase/qb/UPDATE_QUESTION";
+export const updateQuestion = (
+  newQuestion,
+  { run = false, shouldUpdateUrl = false } = {},
+) => {
+  return async (dispatch, getState) => {
+    const oldQuestion = getQuestion(getState());
+    const mode = getQueryBuilderMode(getState());
+
+    const shouldConvertIntoAdHoc = newQuestion.query().isEditable();
+
+    // TODO Atte Keinänen 6/2/2017 Ways to have this happen automatically when modifying a question?
+    // Maybe the Question class or a QB-specific question wrapper class should know whether it's being edited or not?
+    if (
+      shouldConvertIntoAdHoc &&
+      !getIsEditing(getState()) &&
+      newQuestion.isSaved() &&
+      mode !== "dataset"
+    ) {
+      newQuestion = newQuestion.withoutNameAndId();
+
+      // When the dataset query changes, we should loose the dataset flag,
+      // to start building a new ad-hoc question based on a dataset
+      if (newQuestion.isDataset()) {
+        newQuestion = newQuestion.setDataset(false);
+        dispatch(onCloseQuestionDetails());
+      }
+    }
+
+    const queryResult = getFirstQueryResult(getState());
+    newQuestion = newQuestion.syncColumnsAndSettings(oldQuestion, queryResult);
+
+    if (run === "auto") {
+      run = hasNewColumns(newQuestion, queryResult);
+    }
+
+    if (!newQuestion.canAutoRun()) {
+      run = false;
+    }
+
+    // <PIVOT LOGIC>
+    // We have special logic when going to, coming from, or updating a pivot table.
+    const isPivot = newQuestion.display() === "pivot";
+    const wasPivot = oldQuestion.display() === "pivot";
+    const queryHasBreakouts =
+      isPivot &&
+      newQuestion.isStructured() &&
+      newQuestion.query().breakouts().length > 0;
+
+    // we can only pivot queries with breakouts
+    if (isPivot && queryHasBreakouts) {
+      // compute the pivot setting now so we can query the appropriate data
+      const series = assocIn(
+        getRawSeries(getState()),
+        [0, "card"],
+        newQuestion.card(),
+      );
+      const key = "pivot_table.column_split";
+      const setting = getQuestionWithDefaultVisualizationSettings(
+        newQuestion,
+        series,
+      ).setting(key);
+      newQuestion = newQuestion.updateSettings({ [key]: setting });
+    }
+
+    if (
+      // switching to pivot
+      (isPivot && !wasPivot && queryHasBreakouts) ||
+      // switching away from pivot
+      (!isPivot && wasPivot) ||
+      // updating the pivot rows/cols
+      (isPivot &&
+        queryHasBreakouts &&
+        !_.isEqual(
+          newQuestion.setting("pivot_table.column_split"),
+          oldQuestion.setting("pivot_table.column_split"),
+        ))
+    ) {
+      run = true; // force a run when switching to/from pivot or updating it's setting
+    }
+    // </PIVOT LOGIC>
+
+    // Native query should never be in notebook mode (metabase#12651)
+    if (mode === "notebook" && newQuestion.isNative()) {
+      await dispatch(
+        setQueryBuilderMode("view", {
+          shouldUpdateUrl: false,
+        }),
+      );
+    }
+
+    // Replace the current question with a new one
+    await dispatch.action(UPDATE_QUESTION, { card: newQuestion.card() });
+
+    if (shouldUpdateUrl) {
+      dispatch(updateUrl(null, { dirty: true }));
+    }
+
+    // See if the template tags editor should be shown/hidden
+    const isTemplateTagEditorVisible = getIsShowingTemplateTagsEditor(
+      getState(),
+    );
+    const nextTagEditorVisibilityState = getNextTemplateTagVisibilityState({
+      oldQuestion,
+      newQuestion,
+      isTemplateTagEditorVisible,
+      queryBuilderMode: mode,
+    });
+    if (nextTagEditorVisibilityState !== "deferToCurrentState") {
+      dispatch(
+        setIsShowingTemplateTagsEditor(
+          nextTagEditorVisibilityState === "visible",
+        ),
+      );
+    }
+
+    try {
+      if (
+        !_.isEqual(
+          oldQuestion.query().dependentMetadata(),
+          newQuestion.query().dependentMetadata(),
+        )
+      ) {
+        await dispatch(loadMetadataForCard(newQuestion.card()));
+      }
+
+      // setDefaultQuery requires metadata be loaded, need getQuestion to use new metadata
+      const question = getQuestion(getState());
+      const questionWithDefaultQuery = question.setDefaultQuery();
+      if (!questionWithDefaultQuery.isEqual(question)) {
+        await dispatch.action(UPDATE_QUESTION, {
+          card: questionWithDefaultQuery.setDefaultDisplay().card(),
+        });
+      }
+    } catch (e) {
+      // this will fail if user doesn't have data permissions but thats ok
+      console.warn("Couldn't load metadata", e);
+    }
+
+    // run updated query
+    if (run) {
+      dispatch(runQuestionQuery());
+    }
+  };
+};
+
+// DEPRECATED, still used in a couple places
+export const setDatasetQuery = (datasetQuery, options) => (
+  dispatch,
+  getState,
+) => {
+  const question = getQuestion(getState());
+  dispatch(updateQuestion(question.setDatasetQuery(datasetQuery), options));
+};
+
+export const API_CREATE_QUESTION = "metabase/qb/API_CREATE_QUESTION";
+export const apiCreateQuestion = question => {
+  return async (dispatch, getState) => {
+    // Needed for persisting visualization columns for pulses/alerts, see #6749
+    const series = getTransformedSeries(getState());
+    const questionWithVizSettings = series
+      ? getQuestionWithDefaultVisualizationSettings(question, series)
+      : question;
+
+    const resultsMetadata = getResultsMetadata(getState());
+    const createdQuestion = await questionWithVizSettings
+      .setQuery(question.query().clean())
+      .setResultsMetadata(resultsMetadata)
+      .reduxCreate(dispatch);
+
+    // remove the databases in the store that are used to populate the QB databases list.
+    // This is done when saving a Card because the newly saved card will be eligible for use as a source query
+    // so we want the databases list to be re-fetched next time we hit "New Question" so it shows up
+    dispatch(setRequestUnloaded(["entities", "databases"]));
+
+    dispatch(updateUrl(createdQuestion.card(), { dirty: false }));
+    MetabaseAnalytics.trackStructEvent(
+      "QueryBuilder",
+      "Create Card",
+      createdQuestion.query().datasetQuery().type,
+    );
+    trackNewQuestionSaved(
+      question,
+      createdQuestion,
+      isBasedOnExistingQuestion(getState()),
+    );
+
+    // Saving a card, locks in the current display as though it had been
+    // selected in the UI.
+    const card = createdQuestion.lockDisplay().card();
+
+    dispatch.action(API_CREATE_QUESTION, card);
+  };
+};
+
+export const API_UPDATE_QUESTION = "metabase/qb/API_UPDATE_QUESTION";
+export const apiUpdateQuestion = (question, { rerunQuery = false } = {}) => {
+  return async (dispatch, getState) => {
+    const originalQuestion = getOriginalQuestion(getState());
+    question = question || getQuestion(getState());
+
+    // Needed for persisting visualization columns for pulses/alerts, see #6749
+    const series = getTransformedSeries(getState());
+    const questionWithVizSettings = series
+      ? getQuestionWithDefaultVisualizationSettings(question, series)
+      : question;
+
+    const resultsMetadata = getResultsMetadata(getState());
+    const updatedQuestion = await questionWithVizSettings
+      .setQuery(question.query().clean())
+      .setResultsMetadata(resultsMetadata)
+      // When viewing a dataset, its dataset_query is swapped with a clean query using the dataset as a source table
+      // (it's necessary for datasets to behave like tables opened in simple mode)
+      // When doing updates like changing name, description, etc., we need to omit the dataset_query in the request body
+      .reduxUpdate(dispatch, {
+        excludeDatasetQuery: isAdHocModelQuestion(question, originalQuestion),
+      });
+
+    // reload the question alerts for the current question
+    // (some of the old alerts might be removed during update)
+    await dispatch(fetchAlertsForQuestion(updatedQuestion.id()));
+
+    // remove the databases in the store that are used to populate the QB databases list.
+    // This is done when saving a Card because the newly saved card will be eligible for use as a source query
+    // so we want the databases list to be re-fetched next time we hit "New Question" so it shows up
+    dispatch(setRequestUnloaded(["entities", "databases"]));
+
+    MetabaseAnalytics.trackStructEvent(
+      "QueryBuilder",
+      "Update Card",
+      updatedQuestion.query().datasetQuery().type,
+    );
+
+    dispatch.action(API_UPDATE_QUESTION, updatedQuestion.card());
+
+    if (rerunQuery) {
+      await dispatch(loadMetadataForCard(question.card()));
+      dispatch(runQuestionQuery());
+    }
+  };
+};
+
+export const SET_PARAMETER_VALUE = "metabase/qb/SET_PARAMETER_VALUE";
+export const setParameterValue = createAction(
+  SET_PARAMETER_VALUE,
+  (parameterId, value) => {
+    return { id: parameterId, value };
+  },
+);
+
+export const REVERT_TO_REVISION = "metabase/qb/REVERT_TO_REVISION";
+export const revertToRevision = createThunkAction(
+  REVERT_TO_REVISION,
+  revision => {
+    return async dispatch => {
+      await revision.revert();
+      await dispatch(reloadCard());
+    };
+  },
+);
diff --git a/frontend/src/metabase/query_builder/actions/index.ts b/frontend/src/metabase/query_builder/actions/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..86529251500482588dc6ce3b97c5a97b624b3609
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/index.ts
@@ -0,0 +1,10 @@
+export * from "./core";
+export * from "./models";
+export * from "./native";
+export * from "./navigation";
+export * from "./object-detail";
+export * from "./querying";
+export * from "./sharing";
+export * from "./timelines";
+export * from "./ui";
+export * from "./visualization-settings";
diff --git a/frontend/src/metabase/query_builder/actions/models.js b/frontend/src/metabase/query_builder/actions/models.js
new file mode 100644
index 0000000000000000000000000000000000000000..b6a2d6005e05f962325c61062fed2c1d62684968
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/models.js
@@ -0,0 +1,100 @@
+import { createAction } from "redux-actions";
+import _ from "underscore";
+import { merge } from "icepick";
+import { t } from "ttag";
+
+import { isLocalField, isSameField } from "metabase/lib/query/field_ref";
+
+import { addUndo } from "metabase/redux/undo";
+import { CardApi } from "metabase/services";
+
+import { getOriginalCard, getQuestion, getResultsMetadata } from "../selectors";
+
+import { apiUpdateQuestion, updateQuestion } from "./core";
+import { runQuestionQuery } from "./querying";
+import { setQueryBuilderMode } from "./ui";
+
+export const setDatasetEditorTab = datasetEditorTab => dispatch => {
+  dispatch(setQueryBuilderMode("dataset", { datasetEditorTab }));
+};
+
+export const CANCEL_DATASET_CHANGES = "metabase/qb/CANCEL_DATASET_CHANGES";
+export const onCancelDatasetChanges = () => (dispatch, getState) => {
+  const cardBeforeChanges = getOriginalCard(getState());
+  dispatch.action(CANCEL_DATASET_CHANGES, {
+    card: cardBeforeChanges,
+  });
+  dispatch(runQuestionQuery());
+};
+
+export const turnQuestionIntoDataset = () => async (dispatch, getState) => {
+  const question = getQuestion(getState());
+  const dataset = question.setDataset(true);
+  await dispatch(apiUpdateQuestion(dataset, { rerunQuery: true }));
+
+  dispatch(
+    addUndo({
+      message: t`This is a model now.`,
+      actions: [apiUpdateQuestion(question, { rerunQuery: true })],
+    }),
+  );
+};
+
+export const turnDatasetIntoQuestion = () => async (dispatch, getState) => {
+  const dataset = getQuestion(getState());
+  const question = dataset.setDataset(false);
+  await dispatch(apiUpdateQuestion(question, { rerunQuery: true }));
+
+  dispatch(
+    addUndo({
+      message: t`This is a question now.`,
+      actions: [apiUpdateQuestion(dataset, { rerunQuery: true })],
+    }),
+  );
+};
+
+export const SET_RESULTS_METADATA = "metabase/qb/SET_RESULTS_METADATA";
+export const setResultsMetadata = createAction(SET_RESULTS_METADATA);
+
+export const SET_METADATA_DIFF = "metabase/qb/SET_METADATA_DIFF";
+export const setMetadataDiff = createAction(SET_METADATA_DIFF);
+
+export const setFieldMetadata = ({ field_ref, changes }) => (
+  dispatch,
+  getState,
+) => {
+  const question = getQuestion(getState());
+  const resultsMetadata = getResultsMetadata(getState());
+
+  const nextColumnMetadata = resultsMetadata.columns.map(fieldMetadata => {
+    const compareExact =
+      !isLocalField(field_ref) || !isLocalField(fieldMetadata.field_ref);
+    const isTargetField = isSameField(
+      field_ref,
+      fieldMetadata.field_ref,
+      compareExact,
+    );
+    return isTargetField ? merge(fieldMetadata, changes) : fieldMetadata;
+  });
+
+  const nextResultsMetadata = {
+    ...resultsMetadata,
+    columns: nextColumnMetadata,
+  };
+
+  const nextQuestion = question.setResultsMetadata(nextResultsMetadata);
+
+  dispatch(updateQuestion(nextQuestion));
+  dispatch(setMetadataDiff({ field_ref, changes }));
+  dispatch(setResultsMetadata(nextResultsMetadata));
+};
+
+export const PERSIST_DATASET = "metabase/qb/PERSIST_DATASET";
+export const persistDataset = createAction(PERSIST_DATASET, id =>
+  CardApi.persist({ id }),
+);
+
+export const UNPERSIST_DATASET = "metabase/qb/UNPERSIST_DATASET";
+export const unpersistDataset = createAction(UNPERSIST_DATASET, id =>
+  CardApi.unpersist({ id }),
+);
diff --git a/frontend/src/metabase/query_builder/actions/native.js b/frontend/src/metabase/query_builder/actions/native.js
new file mode 100644
index 0000000000000000000000000000000000000000..823eedf1ad238013d74a5e3ab14c817baa4ca542
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/native.js
@@ -0,0 +1,142 @@
+import _ from "underscore";
+import { updateIn } from "icepick";
+
+import { createAction } from "redux-actions";
+
+import * as MetabaseAnalytics from "metabase/lib/analytics";
+import { createThunkAction } from "metabase/lib/redux";
+import Utils from "metabase/lib/utils";
+
+import {
+  getNativeEditorCursorOffset,
+  getNativeEditorSelectedText,
+  getQuestion,
+  getSnippetCollectionId,
+} from "../selectors";
+
+import { updateQuestion } from "./core";
+import { SET_UI_CONTROLS } from "./ui";
+
+export const TOGGLE_DATA_REFERENCE = "metabase/qb/TOGGLE_DATA_REFERENCE";
+export const toggleDataReference = createAction(TOGGLE_DATA_REFERENCE, () => {
+  MetabaseAnalytics.trackStructEvent("QueryBuilder", "Toggle Data Reference");
+});
+
+export const TOGGLE_TEMPLATE_TAGS_EDITOR =
+  "metabase/qb/TOGGLE_TEMPLATE_TAGS_EDITOR";
+export const toggleTemplateTagsEditor = createAction(
+  TOGGLE_TEMPLATE_TAGS_EDITOR,
+  () => {
+    MetabaseAnalytics.trackStructEvent(
+      "QueryBuilder",
+      "Toggle Template Tags Editor",
+    );
+  },
+);
+
+export const SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR =
+  "metabase/qb/SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR";
+export const setIsShowingTemplateTagsEditor = isShowingTemplateTagsEditor => ({
+  type: SET_IS_SHOWING_TEMPLATE_TAGS_EDITOR,
+  isShowingTemplateTagsEditor,
+});
+
+export const TOGGLE_SNIPPET_SIDEBAR = "metabase/qb/TOGGLE_SNIPPET_SIDEBAR";
+export const toggleSnippetSidebar = createAction(TOGGLE_SNIPPET_SIDEBAR, () => {
+  MetabaseAnalytics.trackStructEvent("QueryBuilder", "Toggle Snippet Sidebar");
+});
+
+export const SET_IS_SHOWING_SNIPPET_SIDEBAR =
+  "metabase/qb/SET_IS_SHOWING_SNIPPET_SIDEBAR";
+export const setIsShowingSnippetSidebar = isShowingSnippetSidebar => ({
+  type: SET_IS_SHOWING_SNIPPET_SIDEBAR,
+  isShowingSnippetSidebar,
+});
+
+export const setIsNativeEditorOpen = isNativeEditorOpen => ({
+  type: SET_UI_CONTROLS,
+  payload: { isNativeEditorOpen },
+});
+
+export const SET_NATIVE_EDITOR_SELECTED_RANGE =
+  "metabase/qb/SET_NATIVE_EDITOR_SELECTED_RANGE";
+export const setNativeEditorSelectedRange = createAction(
+  SET_NATIVE_EDITOR_SELECTED_RANGE,
+);
+
+export const SET_MODAL_SNIPPET = "metabase/qb/SET_MODAL_SNIPPET";
+export const setModalSnippet = createAction(SET_MODAL_SNIPPET);
+
+export const SET_SNIPPET_COLLECTION_ID =
+  "metabase/qb/SET_SNIPPET_COLLECTION_ID";
+export const setSnippetCollectionId = createAction(SET_SNIPPET_COLLECTION_ID);
+
+export const openSnippetModalWithSelectedText = () => (dispatch, getState) => {
+  const state = getState();
+  const content = getNativeEditorSelectedText(state);
+  const collection_id = getSnippetCollectionId(state);
+  dispatch(setModalSnippet({ content, collection_id }));
+};
+
+export const closeSnippetModal = () => dispatch => {
+  dispatch(setModalSnippet(null));
+};
+
+export const insertSnippet = snip => (dispatch, getState) => {
+  const name = snip.name;
+  const question = getQuestion(getState());
+  const query = question.query();
+  const nativeEditorCursorOffset = getNativeEditorCursorOffset(getState());
+  const nativeEditorSelectedText = getNativeEditorSelectedText(getState());
+  const selectionStart =
+    nativeEditorCursorOffset - (nativeEditorSelectedText || "").length;
+  const newText =
+    query.queryText().slice(0, selectionStart) +
+    `{{snippet: ${name}}}` +
+    query.queryText().slice(nativeEditorCursorOffset);
+  const datasetQuery = query
+    .setQueryText(newText)
+    .updateSnippetsWithIds([snip])
+    .datasetQuery();
+  dispatch(updateQuestion(question.setDatasetQuery(datasetQuery)));
+};
+
+export const SET_TEMPLATE_TAG = "metabase/qb/SET_TEMPLATE_TAG";
+export const setTemplateTag = createThunkAction(
+  SET_TEMPLATE_TAG,
+  templateTag => {
+    return (dispatch, getState) => {
+      const {
+        qb: { card, uiControls },
+      } = getState();
+
+      const updatedCard = Utils.copy(card);
+
+      // when the query changes on saved card we change this into a new query w/ a known starting point
+      if (
+        !uiControls.isEditing &&
+        uiControls.queryBuilderMode !== "dataset" &&
+        updatedCard.id
+      ) {
+        delete updatedCard.id;
+        delete updatedCard.name;
+        delete updatedCard.description;
+      }
+
+      // we need to preserve the order of the keys to avoid UI jumps
+      return updateIn(
+        updatedCard,
+        ["dataset_query", "native", "template-tags"],
+        tags => {
+          const { name } = templateTag;
+          const newTag =
+            tags[name] && tags[name].type !== templateTag.type
+              ? // when we switch type, null out any default
+                { ...templateTag, default: null }
+              : templateTag;
+          return { ...tags, [name]: newTag };
+        },
+      );
+    };
+  },
+);
diff --git a/frontend/src/metabase/query_builder/actions/navigation.js b/frontend/src/metabase/query_builder/actions/navigation.js
new file mode 100644
index 0000000000000000000000000000000000000000..f83705cbe7b58a3512c0ceaa9c523cc87b739861
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/navigation.js
@@ -0,0 +1,224 @@
+import _ from "underscore";
+import { parse as parseUrl } from "url";
+import { createAction } from "redux-actions";
+import { push, replace } from "react-router-redux";
+
+import { cleanCopyCard, serializeCardForUrl } from "metabase/lib/card";
+import { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils";
+import { createThunkAction } from "metabase/lib/redux";
+import Utils from "metabase/lib/utils";
+
+import { getMetadata } from "metabase/selectors/metadata";
+
+import Question from "metabase-lib/lib/Question";
+
+import {
+  getCard,
+  getDatasetEditorTab,
+  getZoomedObjectId,
+  getOriginalQuestion,
+  getQueryBuilderMode,
+  getQuestion,
+} from "../selectors";
+import {
+  getCurrentQueryParams,
+  getPathNameFromQueryBuilderMode,
+  getQueryBuilderModeFromLocation,
+  getURLForCardState,
+} from "../utils";
+
+import { initializeQB, setCardAndRun } from "./core";
+import { zoomInRow, resetRowZoom } from "./object-detail";
+import { cancelQuery } from "./querying";
+import { setQueryBuilderMode } from "./ui";
+
+export const SET_CURRENT_STATE = "metabase/qb/SET_CURRENT_STATE";
+const setCurrentState = createAction(SET_CURRENT_STATE);
+
+export const POP_STATE = "metabase/qb/POP_STATE";
+export const popState = createThunkAction(
+  POP_STATE,
+  location => async (dispatch, getState) => {
+    dispatch(cancelQuery());
+
+    const zoomedObjectId = getZoomedObjectId(getState());
+    if (zoomedObjectId) {
+      const { locationBeforeTransitions = {} } = getState().routing;
+      const { state, query } = locationBeforeTransitions;
+      const previouslyZoomedObjectId = state?.objectId || query?.objectId;
+
+      if (
+        previouslyZoomedObjectId &&
+        zoomedObjectId !== previouslyZoomedObjectId
+      ) {
+        dispatch(zoomInRow({ objectId: previouslyZoomedObjectId }));
+      } else {
+        dispatch(resetRowZoom());
+      }
+      return;
+    }
+
+    const card = getCard(getState());
+    if (location.state && location.state.card) {
+      if (!Utils.equals(card, location.state.card)) {
+        const shouldRefreshUrl = location.state.card.dataset;
+        await dispatch(setCardAndRun(location.state.card, shouldRefreshUrl));
+        await dispatch(setCurrentState(location.state));
+      }
+    }
+
+    const {
+      mode: queryBuilderModeFromURL,
+      ...uiControls
+    } = getQueryBuilderModeFromLocation(location);
+
+    if (getQueryBuilderMode(getState()) !== queryBuilderModeFromURL) {
+      await dispatch(
+        setQueryBuilderMode(queryBuilderModeFromURL, {
+          ...uiControls,
+          shouldUpdateUrl: queryBuilderModeFromURL === "dataset",
+        }),
+      );
+    }
+  },
+);
+
+const getURL = (location, { includeMode = false } = {}) =>
+  // strip off trailing queryBuilderMode
+  (includeMode
+    ? location.pathname
+    : location.pathname.replace(/\/(notebook|view)$/, "")) +
+  location.search +
+  location.hash;
+
+// Logic for handling location changes, dispatched by top-level QueryBuilder component
+export const locationChanged = (
+  location,
+  nextLocation,
+  nextParams,
+) => dispatch => {
+  if (location !== nextLocation) {
+    if (nextLocation.action === "POP") {
+      if (
+        getURL(nextLocation, { includeMode: true }) !==
+        getURL(location, { includeMode: true })
+      ) {
+        // the browser forward/back button was pressed
+        dispatch(popState(nextLocation));
+      }
+    } else if (
+      (nextLocation.action === "PUSH" || nextLocation.action === "REPLACE") &&
+      // ignore PUSH/REPLACE with `state` because they were initiated by the `updateUrl` action
+      nextLocation.state === undefined
+    ) {
+      // a link to a different qb url was clicked
+      dispatch(initializeQB(nextLocation, nextParams));
+    }
+  }
+};
+
+export const REDIRECT_TO_NEW_QUESTION_FLOW =
+  "metabase/qb/REDIRECT_TO_NEW_QUESTION_FLOW";
+
+export const redirectToNewQuestionFlow = createThunkAction(
+  REDIRECT_TO_NEW_QUESTION_FLOW,
+  () => dispatch => dispatch(replace("/question/new")),
+);
+
+export const UPDATE_URL = "metabase/qb/UPDATE_URL";
+export const updateUrl = createThunkAction(
+  UPDATE_URL,
+  (
+    card,
+    {
+      dirty,
+      replaceState,
+      preserveParameters = true,
+      queryBuilderMode,
+      datasetEditorTab,
+      objectId,
+    } = {},
+  ) => (dispatch, getState) => {
+    let question;
+    if (!card) {
+      card = getCard(getState());
+      question = getQuestion(getState());
+    } else {
+      question = new Question(card, getMetadata(getState()));
+    }
+
+    if (dirty == null) {
+      const originalQuestion = getOriginalQuestion(getState());
+      const isAdHocModel = isAdHocModelQuestion(question, originalQuestion);
+      dirty =
+        !originalQuestion ||
+        (!isAdHocModel && question.isDirtyComparedTo(originalQuestion));
+    }
+
+    // prevent clobbering of hash when there are fake parameters on the question
+    // consider handling this in a more general way, somehow
+    if (question.isStructured() && question.parameters().length > 0) {
+      dirty = true;
+    }
+
+    if (!queryBuilderMode) {
+      queryBuilderMode = getQueryBuilderMode(getState());
+    }
+    if (!datasetEditorTab) {
+      datasetEditorTab = getDatasetEditorTab(getState());
+    }
+
+    const copy = cleanCopyCard(card);
+
+    const newState = {
+      card: copy,
+      cardId: copy.id,
+      serializedCard: serializeCardForUrl(copy),
+      objectId,
+    };
+
+    const { currentState } = getState().qb;
+    const queryParams = preserveParameters ? getCurrentQueryParams() : {};
+    const url = getURLForCardState(newState, dirty, queryParams, objectId);
+
+    const urlParsed = parseUrl(url);
+    const locationDescriptor = {
+      pathname: getPathNameFromQueryBuilderMode({
+        pathname: urlParsed.pathname || "",
+        queryBuilderMode,
+        datasetEditorTab,
+      }),
+      search: urlParsed.search,
+      hash: urlParsed.hash,
+      state: newState,
+    };
+
+    const isSameURL =
+      locationDescriptor.pathname === window.location.pathname &&
+      (locationDescriptor.search || "") === (window.location.search || "") &&
+      (locationDescriptor.hash || "") === (window.location.hash || "");
+    const isSameCard =
+      currentState && currentState.serializedCard === newState.serializedCard;
+    const isSameMode =
+      getQueryBuilderModeFromLocation(locationDescriptor).mode ===
+      getQueryBuilderModeFromLocation(window.location).mode;
+
+    if (isSameCard && isSameURL) {
+      return;
+    }
+
+    if (replaceState == null) {
+      // 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;
+    }
+
+    // this is necessary because we can't get the state from history.state
+    dispatch(setCurrentState(newState));
+    if (replaceState) {
+      dispatch(replace(locationDescriptor));
+    } else {
+      dispatch(push(locationDescriptor));
+    }
+  },
+);
diff --git a/frontend/src/metabase/query_builder/actions/object-detail.js b/frontend/src/metabase/query_builder/actions/object-detail.js
new file mode 100644
index 0000000000000000000000000000000000000000..e4a1f44e0f93ac4bd8128217c813ea18b592cce7
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/object-detail.js
@@ -0,0 +1,153 @@
+import _ from "underscore";
+
+import { startNewCard } from "metabase/lib/card";
+import { createThunkAction } from "metabase/lib/redux";
+import * as Q_DEPRECATED from "metabase/lib/query";
+
+import { MetabaseApi } from "metabase/services";
+
+import { FieldDimension } from "metabase-lib/lib/Dimension";
+
+import {
+  getCard,
+  getFirstQueryResult,
+  getNextRowPKValue,
+  getPreviousRowPKValue,
+  getTableForeignKeys,
+} from "../selectors";
+import { setCardAndRun } from "./core";
+import { updateUrl } from "./navigation";
+
+export const ZOOM_IN_ROW = "metabase/qb/ZOOM_IN_ROW";
+export const zoomInRow = ({ objectId }) => dispatch => {
+  dispatch({ type: ZOOM_IN_ROW, payload: { objectId } });
+  dispatch(updateUrl(null, { objectId, replaceState: false }));
+};
+
+export const RESET_ROW_ZOOM = "metabase/qb/RESET_ROW_ZOOM";
+export const resetRowZoom = () => dispatch => {
+  dispatch({ type: RESET_ROW_ZOOM });
+  dispatch(updateUrl());
+};
+
+function getFilterForFK(zoomedObjectId, fk) {
+  const field = new FieldDimension(fk.origin.id);
+  return ["=", field.mbql(), zoomedObjectId];
+}
+
+export const FOLLOW_FOREIGN_KEY = "metabase/qb/FOLLOW_FOREIGN_KEY";
+export const followForeignKey = createThunkAction(
+  FOLLOW_FOREIGN_KEY,
+  ({ objectId, fk }) => {
+    return async (dispatch, getState) => {
+      const state = getState();
+
+      const card = getCard(state);
+      const queryResult = getFirstQueryResult(state);
+
+      if (!queryResult || !fk) {
+        return false;
+      }
+
+      const newCard = startNewCard("query", card.dataset_query.database);
+
+      newCard.dataset_query.query["source-table"] = fk.origin.table.id;
+      newCard.dataset_query.query.filter = getFilterForFK(objectId, fk);
+
+      dispatch(resetRowZoom());
+      dispatch(setCardAndRun(newCard));
+    };
+  },
+);
+
+export const LOAD_OBJECT_DETAIL_FK_REFERENCES =
+  "metabase/qb/LOAD_OBJECT_DETAIL_FK_REFERENCES";
+export const loadObjectDetailFKReferences = createThunkAction(
+  LOAD_OBJECT_DETAIL_FK_REFERENCES,
+  ({ objectId }) => {
+    return async (dispatch, getState) => {
+      dispatch.action(CLEAR_OBJECT_DETAIL_FK_REFERENCES);
+
+      const state = getState();
+      const tableForeignKeys = getTableForeignKeys(state);
+
+      if (!Array.isArray(tableForeignKeys)) {
+        return null;
+      }
+
+      const card = getCard(state);
+      const queryResult = getFirstQueryResult(state);
+
+      async function getFKCount(card, queryResult, fk) {
+        const fkQuery = Q_DEPRECATED.createQuery("query");
+
+        fkQuery.database = card.dataset_query.database;
+        fkQuery.query["source-table"] = fk.origin.table_id;
+        fkQuery.query.aggregation = ["count"];
+        fkQuery.query.filter = getFilterForFK(objectId, fk);
+
+        const info = { status: 0, value: null };
+
+        try {
+          const result = await MetabaseApi.dataset(fkQuery);
+          if (
+            result &&
+            result.status === "completed" &&
+            result.data.rows.length > 0
+          ) {
+            info["value"] = result.data.rows[0][0];
+          } else {
+            info["value"] = "Unknown";
+          }
+        } finally {
+          info["status"] = 1;
+        }
+
+        return info;
+      }
+
+      // TODO: there are possible cases where running a query would not require refreshing this data, but
+      // 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 = {};
+      for (let i = 0; i < tableForeignKeys.length; i++) {
+        const fk = tableForeignKeys[i];
+        const info = await getFKCount(card, queryResult, fk);
+        fkReferences[fk.origin.id] = info;
+      }
+
+      // It's possible that while we were running those queries, the object
+      // detail id changed. If so, these fk reference are stale and we shouldn't
+      // put them in state. The detail id is used in the query so we check that.
+      const updatedQueryResult = getFirstQueryResult(getState());
+      if (!_.isEqual(queryResult.json_query, updatedQueryResult.json_query)) {
+        return null;
+      }
+      return fkReferences;
+    };
+  },
+);
+
+export const CLEAR_OBJECT_DETAIL_FK_REFERENCES =
+  "metabase/qb/CLEAR_OBJECT_DETAIL_FK_REFERENCES";
+
+export const viewNextObjectDetail = () => {
+  return (dispatch, getState) => {
+    const objectId = getNextRowPKValue(getState());
+    if (objectId != null) {
+      dispatch(zoomInRow({ objectId }));
+    }
+  };
+};
+
+export const viewPreviousObjectDetail = () => {
+  return (dispatch, getState) => {
+    const objectId = getPreviousRowPKValue(getState());
+    if (objectId != null) {
+      dispatch(zoomInRow({ objectId }));
+    }
+  };
+};
+
+export const closeObjectDetail = () => dispatch => dispatch(resetRowZoom());
diff --git a/frontend/src/metabase/query_builder/actions/querying.js b/frontend/src/metabase/query_builder/actions/querying.js
new file mode 100644
index 0000000000000000000000000000000000000000..e7cb149d928195a4dd4cf2d9a04e84bcf6fece11
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/querying.js
@@ -0,0 +1,239 @@
+import _ from "underscore";
+import { assocIn } from "icepick";
+import { t } from "ttag";
+
+import { createAction } from "redux-actions";
+
+import * as MetabaseAnalytics from "metabase/lib/analytics";
+import { isAdHocModelQuestion } from "metabase/lib/data-modeling/utils";
+import { startTimer } from "metabase/lib/performance";
+import { defer } from "metabase/lib/promise";
+import { createThunkAction } from "metabase/lib/redux";
+
+import { getMetadata } from "metabase/selectors/metadata";
+import { getSensibleDisplays } from "metabase/visualizations";
+
+import Question from "metabase-lib/lib/Question";
+
+import {
+  getIsPreviewing,
+  getIsRunning,
+  getOriginalQuestion,
+  getQueryBuilderMode,
+  getQueryResults,
+  getQuestion,
+  getTimeoutId,
+} from "../selectors";
+
+import { updateUrl } from "./navigation";
+
+const PREVIEW_RESULT_LIMIT = 10;
+
+export const SET_DOCUMENT_TITLE = "metabase/qb/SET_DOCUMENT_TITLE";
+const setDocumentTitle = createAction(SET_DOCUMENT_TITLE);
+
+export const SET_SHOW_LOADING_COMPLETE_FAVICON =
+  "metabase/qb/SET_SHOW_LOADING_COMPLETE_FAVICON";
+const showLoadingCompleteFavicon = createAction(
+  SET_SHOW_LOADING_COMPLETE_FAVICON,
+  () => true,
+);
+const hideLoadingCompleteFavicon = createAction(
+  SET_SHOW_LOADING_COMPLETE_FAVICON,
+  () => false,
+);
+
+const LOAD_COMPLETE_UI_CONTROLS = "metabase/qb/LOAD_COMPLETE_UI_CONTROLS";
+const LOAD_START_UI_CONTROLS = "metabase/qb/LOAD_START_UI_CONTROLS";
+export const SET_DOCUMENT_TITLE_TIMEOUT_ID =
+  "metabase/qb/SET_DOCUMENT_TITLE_TIMEOUT_ID";
+const setDocumentTitleTimeoutId = createAction(SET_DOCUMENT_TITLE_TIMEOUT_ID);
+
+const loadCompleteUIControls = createThunkAction(
+  LOAD_COMPLETE_UI_CONTROLS,
+  () => (dispatch, getState) => {
+    const timeoutId = getTimeoutId(getState());
+    clearTimeout(timeoutId);
+    dispatch(showLoadingCompleteFavicon());
+    if (document.hidden) {
+      dispatch(setDocumentTitle(t`Your question is ready!`));
+      document.addEventListener(
+        "visibilitychange",
+        () => {
+          dispatch(setDocumentTitle(""));
+          setTimeout(() => {
+            dispatch(hideLoadingCompleteFavicon());
+          }, 3000);
+        },
+        { once: true },
+      );
+    } else {
+      dispatch(setDocumentTitle(""));
+      setTimeout(() => {
+        dispatch(hideLoadingCompleteFavicon());
+      }, 3000);
+    }
+  },
+);
+
+/**
+ * Queries the result for the currently active question or alternatively for the card provided in `overrideWithCard`.
+ * The API queries triggered by this action creator can be cancelled using the deferred provided in RUN_QUERY action.
+ */
+export const RUN_QUERY = "metabase/qb/RUN_QUERY";
+export const runQuestionQuery = ({
+  shouldUpdateUrl = true,
+  ignoreCache = false,
+  overrideWithCard,
+} = {}) => {
+  return async (dispatch, getState) => {
+    dispatch(loadStartUIControls());
+    const questionFromCard = card =>
+      card && new Question(card, getMetadata(getState()));
+
+    let question = overrideWithCard
+      ? questionFromCard(overrideWithCard)
+      : getQuestion(getState());
+    const originalQuestion = getOriginalQuestion(getState());
+
+    const cardIsDirty = originalQuestion
+      ? question.isDirtyComparedToWithoutParameters(originalQuestion) ||
+        question.card().id == null
+      : true;
+
+    if (shouldUpdateUrl) {
+      const isAdHocModel =
+        question.isDataset() &&
+        isAdHocModelQuestion(question, originalQuestion);
+
+      dispatch(
+        updateUrl(question.card(), { dirty: !isAdHocModel && cardIsDirty }),
+      );
+    }
+
+    if (getIsPreviewing(getState())) {
+      question = question.setDatasetQuery(
+        assocIn(
+          question.datasetQuery(),
+          ["constraints", "max-results"],
+          PREVIEW_RESULT_LIMIT,
+        ),
+      );
+    }
+
+    const startTime = new Date();
+    const cancelQueryDeferred = defer();
+
+    const queryTimer = startTimer();
+
+    question
+      .apiGetResults({
+        cancelDeferred: cancelQueryDeferred,
+        ignoreCache: ignoreCache,
+        isDirty: cardIsDirty,
+      })
+      .then(queryResults => {
+        queryTimer(duration =>
+          MetabaseAnalytics.trackStructEvent(
+            "QueryBuilder",
+            "Run Query",
+            question.query().datasetQuery().type,
+            duration,
+          ),
+        );
+        // clearTimeout(timeoutId);
+        return dispatch(queryCompleted(question, queryResults));
+      })
+      .catch(error => dispatch(queryErrored(startTime, error)));
+
+    // TODO Move this out from Redux action asap
+    // HACK: prevent SQL editor from losing focus
+    try {
+      // eslint-disable-next-line no-undef
+      ace.edit("id_sql").focus();
+    } catch (e) {}
+
+    dispatch.action(RUN_QUERY, { cancelQueryDeferred });
+  };
+};
+
+const loadStartUIControls = createThunkAction(
+  LOAD_START_UI_CONTROLS,
+  () => (dispatch, getState) => {
+    dispatch(setDocumentTitle(t`Doing Science...`));
+    const timeoutId = setTimeout(() => {
+      dispatch(setDocumentTitle(t`Still Here...`));
+    }, 10000);
+    dispatch(setDocumentTitleTimeoutId(timeoutId));
+  },
+);
+
+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) => {
+    const [{ data }] = queryResults;
+    const [{ data: prevData }] = getQueryResults(getState()) || [{}];
+    const originalQuestion = getOriginalQuestion(getState());
+    const isDirty =
+      question.query().isEditable() &&
+      question.isDirtyComparedTo(originalQuestion);
+
+    if (isDirty) {
+      if (question.isNative()) {
+        question = question.syncColumnsAndSettings(
+          originalQuestion,
+          queryResults[0],
+        );
+      }
+      // Only update the display if the question is new or has been changed.
+      // Otherwise, trust that the question was saved with the correct display.
+      question = question
+        // if we are going to trigger autoselection logic, check if the locked display no longer is "sensible".
+        .maybeUnlockDisplay(
+          getSensibleDisplays(data),
+          prevData && getSensibleDisplays(prevData),
+        )
+        .setDefaultDisplay()
+        .switchTableScalar(data);
+    }
+
+    const card = question.card();
+    const isEditingModel = getQueryBuilderMode(getState()) === "dataset";
+    const resultsMetadata = data?.results_metadata?.columns;
+    if (isEditingModel && Array.isArray(resultsMetadata)) {
+      card.result_metadata = resultsMetadata;
+    }
+
+    dispatch.action(QUERY_COMPLETED, { card, queryResults });
+    dispatch(loadCompleteUIControls());
+  };
+};
+
+export const QUERY_ERRORED = "metabase/qb/QUERY_ERRORED";
+export const queryErrored = createThunkAction(
+  QUERY_ERRORED,
+  (startTime, error) => {
+    return async () => {
+      if (error && error.isCancelled) {
+        return null;
+      } else {
+        return { error: error, duration: new Date() - startTime };
+      }
+    };
+  },
+);
+
+export const CANCEL_QUERY = "metabase/qb/CANCEL_QUERY";
+export const cancelQuery = () => (dispatch, getState) => {
+  const isRunning = getIsRunning(getState());
+  if (isRunning) {
+    const { cancelQueryDeferred } = getState().qb;
+    if (cancelQueryDeferred) {
+      cancelQueryDeferred.resolve();
+    }
+    return { type: CANCEL_QUERY };
+  }
+};
diff --git a/frontend/src/metabase/query_builder/actions/sharing.js b/frontend/src/metabase/query_builder/actions/sharing.js
new file mode 100644
index 0000000000000000000000000000000000000000..f9771914c0f424c6e75bce66e037a7d783ec2ce8
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/sharing.js
@@ -0,0 +1,24 @@
+import { createAction } from "redux-actions";
+import { CardApi } from "metabase/services";
+
+export const CREATE_PUBLIC_LINK = "metabase/card/CREATE_PUBLIC_LINK";
+export const createPublicLink = createAction(CREATE_PUBLIC_LINK, ({ id }) =>
+  CardApi.createPublicLink({ id }),
+);
+
+export const DELETE_PUBLIC_LINK = "metabase/card/DELETE_PUBLIC_LINK";
+export const deletePublicLink = createAction(DELETE_PUBLIC_LINK, ({ id }) =>
+  CardApi.deletePublicLink({ id }),
+);
+
+export const UPDATE_ENABLE_EMBEDDING = "metabase/card/UPDATE_ENABLE_EMBEDDING";
+export const updateEnableEmbedding = createAction(
+  UPDATE_ENABLE_EMBEDDING,
+  ({ id }, enable_embedding) => CardApi.update({ id, enable_embedding }),
+);
+
+export const UPDATE_EMBEDDING_PARAMS = "metabase/card/UPDATE_EMBEDDING_PARAMS";
+export const updateEmbeddingParams = createAction(
+  UPDATE_EMBEDDING_PARAMS,
+  ({ id }, embedding_params) => CardApi.update({ id, embedding_params }),
+);
diff --git a/frontend/src/metabase/query_builder/actions/timelines.js b/frontend/src/metabase/query_builder/actions/timelines.js
new file mode 100644
index 0000000000000000000000000000000000000000..288c7d8794bb5c9476c70462904bdf9db21575d9
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/timelines.js
@@ -0,0 +1,27 @@
+import { createAction } from "redux-actions";
+
+import { getFetchedTimelines } from "../selectors";
+
+export const SHOW_TIMELINES = "metabase/qb/SHOW_TIMELINES";
+export const showTimelines = createAction(SHOW_TIMELINES);
+
+export const HIDE_TIMELINES = "metabase/qb/HIDE_TIMELINES";
+export const hideTimelines = createAction(HIDE_TIMELINES);
+
+export const SELECT_TIMELINE_EVENTS = "metabase/qb/SELECT_TIMELINE_EVENTS";
+export const selectTimelineEvents = createAction(SELECT_TIMELINE_EVENTS);
+
+export const DESELECT_TIMELINE_EVENTS = "metabase/qb/DESELECT_TIMELINE_EVENTS";
+export const deselectTimelineEvents = createAction(DESELECT_TIMELINE_EVENTS);
+
+export const showTimelinesForCollection = collectionId => (
+  dispatch,
+  getState,
+) => {
+  const fetchedTimelines = getFetchedTimelines(getState());
+  const collectionTimelines = collectionId
+    ? fetchedTimelines.filter(t => t.collection_id === collectionId)
+    : fetchedTimelines.filter(t => t.collection_id == null);
+
+  dispatch(showTimelines(collectionTimelines));
+};
diff --git a/frontend/src/metabase/query_builder/actions/ui.js b/frontend/src/metabase/query_builder/actions/ui.js
new file mode 100644
index 0000000000000000000000000000000000000000..468cf730280cdc5ff01ae3c4b63a412afa8120df
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/ui.js
@@ -0,0 +1,85 @@
+import _ from "underscore";
+import { createAction } from "redux-actions";
+
+import * as MetabaseAnalytics from "metabase/lib/analytics";
+import { createThunkAction } from "metabase/lib/redux";
+import { UserApi } from "metabase/services";
+
+import { runQuestionQuery, cancelQuery } from "./querying";
+import { updateUrl } from "./navigation";
+
+export const SET_UI_CONTROLS = "metabase/qb/SET_UI_CONTROLS";
+export const setUIControls = createAction(SET_UI_CONTROLS);
+
+export const RESET_UI_CONTROLS = "metabase/qb/RESET_UI_CONTROLS";
+export const resetUIControls = createAction(RESET_UI_CONTROLS);
+
+export const setQueryBuilderMode = (
+  queryBuilderMode,
+  { shouldUpdateUrl = true, datasetEditorTab = "query" } = {},
+) => async dispatch => {
+  await dispatch(
+    setUIControls({
+      queryBuilderMode,
+      datasetEditorTab,
+      isShowingChartSettingsSidebar: false,
+    }),
+  );
+  if (shouldUpdateUrl) {
+    await dispatch(updateUrl(null, { queryBuilderMode, datasetEditorTab }));
+  }
+  if (queryBuilderMode === "notebook") {
+    dispatch(cancelQuery());
+  }
+  if (queryBuilderMode === "dataset") {
+    dispatch(runQuestionQuery());
+  }
+};
+
+export const onEditSummary = createAction("metabase/qb/EDIT_SUMMARY");
+export const onCloseSummary = createAction("metabase/qb/CLOSE_SUMMARY");
+export const onAddFilter = createAction("metabase/qb/ADD_FITLER");
+export const onCloseFilter = createAction("metabase/qb/CLOSE_FILTER");
+export const onOpenChartSettings = createAction(
+  "metabase/qb/OPEN_CHART_SETTINGS",
+);
+export const onCloseChartSettings = createAction(
+  "metabase/qb/CLOSE_CHART_SETTINGS",
+);
+export const onOpenChartType = createAction("metabase/qb/OPEN_CHART_TYPE");
+export const onOpenQuestionDetails = createAction(
+  "metabase/qb/OPEN_QUESTION_DETAILS",
+);
+export const onCloseQuestionDetails = createAction(
+  "metabase/qb/CLOSE_QUESTION_DETAILS",
+);
+export const onOpenQuestionHistory = createAction(
+  "metabase/qb/OPEN_QUESTION_HISTORY",
+);
+export const onCloseQuestionHistory = createAction(
+  "metabase/qb/CLOSE_QUESTION_HISTORY",
+);
+
+export const onOpenTimelines = createAction("metabase/qb/OPEN_TIMELINES");
+export const onCloseTimelines = createAction("metabase/qb/CLOSE_TIMELINES");
+
+export const onCloseChartType = createAction("metabase/qb/CLOSE_CHART_TYPE");
+export const onCloseSidebars = createAction("metabase/qb/CLOSE_SIDEBARS");
+
+export const setIsPreviewing = isPreviewing => ({
+  type: SET_UI_CONTROLS,
+  payload: { isPreviewing },
+});
+
+export const CLOSE_QB_NEWB_MODAL = "metabase/qb/CLOSE_QB_NEWB_MODAL";
+export const closeQbNewbModal = createThunkAction(CLOSE_QB_NEWB_MODAL, () => {
+  return async (dispatch, getState) => {
+    // persist the fact that this user has seen the NewbModal
+    const { currentUser } = getState();
+    await UserApi.update_qbnewb({ id: currentUser.id });
+    MetabaseAnalytics.trackStructEvent("QueryBuilder", "Close Newb Modal");
+  };
+});
+
+export const SHOW_CHART_SETTINGS = "metabase/query_builder/SHOW_CHART_SETTINGS";
+export const showChartSettings = createAction(SHOW_CHART_SETTINGS);
diff --git a/frontend/src/metabase/query_builder/actions/visualization-settings.js b/frontend/src/metabase/query_builder/actions/visualization-settings.js
new file mode 100644
index 0000000000000000000000000000000000000000..67c3e2e749d416426c7a54c6f8012d9e3c99eada
--- /dev/null
+++ b/frontend/src/metabase/query_builder/actions/visualization-settings.js
@@ -0,0 +1,65 @@
+import _ from "underscore";
+
+import {
+  getDatasetEditorTab,
+  getPreviousQueryBuilderMode,
+  getQueryBuilderMode,
+  getQuestion,
+} from "../selectors";
+
+import { updateQuestion } from "./core";
+
+export const updateCardVisualizationSettings = settings => async (
+  dispatch,
+  getState,
+) => {
+  const question = getQuestion(getState());
+  const previousQueryBuilderMode = getPreviousQueryBuilderMode(getState());
+  const queryBuilderMode = getQueryBuilderMode(getState());
+  const datasetEditorTab = getDatasetEditorTab(getState());
+  const isEditingDatasetMetadata =
+    queryBuilderMode === "dataset" && datasetEditorTab === "metadata";
+  const wasJustEditingModel =
+    previousQueryBuilderMode === "dataset" && queryBuilderMode !== "dataset";
+  const changedSettings = Object.keys(settings);
+  const isColumnWidthResetEvent =
+    changedSettings.length === 1 &&
+    changedSettings.includes("table.column_widths") &&
+    settings["table.column_widths"] === undefined;
+
+  if (
+    (isEditingDatasetMetadata || wasJustEditingModel) &&
+    isColumnWidthResetEvent
+  ) {
+    return;
+  }
+
+  // The check allows users without data permission to resize/rearrange columns
+  const hasWritePermissions = question.query().isEditable();
+  await dispatch(
+    updateQuestion(question.updateSettings(settings), {
+      run: hasWritePermissions ? "auto" : false,
+      shouldUpdateUrl: hasWritePermissions,
+    }),
+  );
+};
+
+export const replaceAllCardVisualizationSettings = settings => async (
+  dispatch,
+  getState,
+) => {
+  const question = getQuestion(getState());
+
+  // The check allows users without data permission to resize/rearrange columns
+  const hasWritePermissions = question.query().isEditable();
+  await dispatch(
+    updateQuestion(question.setSettings(settings), {
+      run: hasWritePermissions ? "auto" : false,
+      shouldUpdateUrl: hasWritePermissions,
+    }),
+  );
+};
+
+// these are just temporary mappings to appease the existing QB code and it's naming prefs
+export const onUpdateVisualizationSettings = updateCardVisualizationSettings;
+export const onReplaceAllVisualizationSettings = replaceAllCardVisualizationSettings;