diff --git a/frontend/src/metabase-lib/lib/Alert.js b/frontend/src/metabase-lib/lib/Alert.js
index 8fdb52cfc231118cd3b82c6ee030b2a3e8f9fe16..67f14fd16b0cd85e5f8f3ea70c26d886c08c1428 100644
--- a/frontend/src/metabase-lib/lib/Alert.js
+++ b/frontend/src/metabase-lib/lib/Alert.js
@@ -7,8 +7,8 @@ export type AlertType =
     | ALERT_TYPE_TIMESERIES_GOAL
     | ALERT_TYPE_PROGRESS_BAR_GOAL;
 
-export const getDefaultAlert = (question, user) => {
-    const alertType = question.alertType();
+export const getDefaultAlert = (question, user, visualizationSettings) => {
+    const alertType = question.alertType(visualizationSettings);
 
     const typeDependentAlertFields = alertType === ALERT_TYPE_ROWS
         ? { alert_condition: "rows", alert_first_only: false }
diff --git a/frontend/src/metabase-lib/lib/Question.js b/frontend/src/metabase-lib/lib/Question.js
index 4856f6db62ddd22d61031b35dafa9c881f9d8a17..a494bb0080a617d51f512330239fd9c406172a08 100644
--- a/frontend/src/metabase-lib/lib/Question.js
+++ b/frontend/src/metabase-lib/lib/Question.js
@@ -12,9 +12,7 @@ import StructuredQuery, {
 import NativeQuery from "./queries/NativeQuery";
 
 import { memoize } from "metabase-lib/lib/utils";
-import Utils from "metabase/lib/utils";
 import * as Card_DEPRECATED from "metabase/lib/card";
-import Query_DEPRECATED from "metabase/lib/query";
 
 import { getParametersWithExtras } from "metabase/meta/Card";
 
@@ -230,21 +228,37 @@ export default class Question {
         return this._card && this._card.can_write;
     }
 
-    alertType() {
-        const mode = this.mode();
+    /**
+     * Returns the type of alert that current question supports
+     *
+     * The `visualization_settings` in card object doesn't contain default settings,
+     * so you can provide the complete visualization settings object to `alertType`
+     * for taking those into account
+     */
+    alertType(visualizationSettings) {
         const display = this.display();
 
         if (!this.canRun()) {
             return null;
         }
 
+        const isLineAreaBar = display === "line" ||
+            display === "area" ||
+            display === "bar";
+
         if (display === "progress") {
             return ALERT_TYPE_PROGRESS_BAR_GOAL;
-        } else if (mode && mode.name() === "timeseries") {
-            const vizSettings = this.card().visualization_settings;
-            // NOTE Atte Keinänen 11/6/17: Seems that `graph.goal_value` setting can be missing if
-            // only the "Show goal" toggle has been toggled but "Goal value" value hasn't explicitly been set
-            if (vizSettings["graph.show_goal"] === true) {
+        } else if (isLineAreaBar) {
+            const vizSettings = visualizationSettings
+                ? visualizationSettings
+                : this.card().visualization_settings;
+
+            const goalEnabled = vizSettings["graph.show_goal"];
+            const hasSingleYAxisColumn = vizSettings["graph.metrics"] &&
+                vizSettings["graph.metrics"].length === 1;
+
+            // We don't currently support goal alerts for multiseries question
+            if (goalEnabled && hasSingleYAxisColumn) {
                 return ALERT_TYPE_TIMESERIES_GOAL;
             } else {
                 return ALERT_TYPE_ROWS;
@@ -344,6 +358,14 @@ export default class Question {
         return this.setCard(assoc(this.card(), "name", name));
     }
 
+    collectionId(): ?number {
+        return this._card && this._card.collection_id;
+    }
+
+    setCollectionId(collectionId: number) {
+        return this.setCard(assoc(this.card(), "collection_id", collectionId));
+    }
+
     id(): number {
         return this._card && this._card.id;
     }
@@ -365,13 +387,24 @@ export default class Question {
             : Urls.question(this.id(), "");
     }
 
+    setResultsMetadata(resultsMetadata) {
+        let metadataColumns = resultsMetadata && resultsMetadata.columns;
+        let metadataChecksum = resultsMetadata && resultsMetadata.checksum;
+
+        return this.setCard({
+            ...this.card(),
+            result_metadata: metadataColumns,
+            metadata_checksum: metadataChecksum
+        });
+    }
+
     /**
      * Runs the query and returns an array containing results for each single query.
      *
      * If we have a saved and clean single-query question, we use `CardApi.query` instead of a ad-hoc dataset query.
      * This way we benefit from caching and query optimizations done by Metabase backend.
      */
-    async getResults(
+    async apiGetResults(
         { cancelDeferred, isDirty = false, ignoreCache = false } = {}
     ): Promise<[Dataset]> {
         // TODO Atte Keinänen 7/5/17: Should we clean this query with Query.cleanQuery(query) before executing it?
@@ -415,6 +448,16 @@ export default class Question {
         }
     }
 
+    async apiCreate() {
+        const createdCard = await CardApi.create(this.card());
+        return this.setCard(createdCard);
+    }
+
+    async apiUpdate() {
+        const updatedCard = await CardApi.update(this.card());
+        return this.setCard(updatedCard);
+    }
+
     // TODO: Fix incorrect Flow signature
     parameters(): ParameterObject[] {
         return getParametersWithExtras(this.card(), this._parameterValues);
@@ -465,20 +508,13 @@ export default class Question {
     }
 
     // Internal methods
-
     _serializeForUrl({ includeOriginalCardId = true } = {}) {
-        // TODO Atte Keinänen 5/31/17: Remove code mutation and unnecessary copying
-        const dataset_query = Utils.copy(this._card.dataset_query);
-        if (dataset_query.query) {
-            dataset_query.query = Query_DEPRECATED.cleanQuery(
-                dataset_query.query
-            );
-        }
+        const cleanedQuery = this.query().clean();
 
         const cardCopy = {
             name: this._card.name,
             description: this._card.description,
-            dataset_query: dataset_query,
+            dataset_query: cleanedQuery.datasetQuery(),
             display: this._card.display,
             parameters: this._card.parameters,
             visualization_settings: this._card.visualization_settings,
diff --git a/frontend/src/metabase-lib/lib/queries/NativeQuery.js b/frontend/src/metabase-lib/lib/queries/NativeQuery.js
index 976d4711d9b634bc393f66a4d720494301f3eacb..4bdaeaf1c3105b0f09481b4910b125603dabcc7b 100644
--- a/frontend/src/metabase-lib/lib/queries/NativeQuery.js
+++ b/frontend/src/metabase-lib/lib/queries/NativeQuery.js
@@ -190,6 +190,10 @@ export default class NativeQuery extends AtomicQuery {
         return getIn(this.datasetQuery(), ["native", "template_tags"]) || {};
     }
 
+    setDatasetQuery(datasetQuery: DatasetQuery): NativeQuery {
+        return new NativeQuery(this._originalQuestion, datasetQuery);
+    }
+
     /**
      * special handling for NATIVE cards to automatically detect parameters ... {{varname}}
      */
diff --git a/frontend/src/metabase-lib/lib/queries/Query.js b/frontend/src/metabase-lib/lib/queries/Query.js
index 8edc0521592db32d17060d918cec800a62b5609a..0babf8175b80bc0ce4823e34196c7a01ef77a273 100644
--- a/frontend/src/metabase-lib/lib/queries/Query.js
+++ b/frontend/src/metabase-lib/lib/queries/Query.js
@@ -43,6 +43,10 @@ export default class Query {
         }
     }
 
+    clean(): Query {
+        return this;
+    }
+
     /**
      * Convenience method for accessing the global metadata
      */
@@ -64,7 +68,12 @@ export default class Query {
         return this._datasetQuery;
     }
 
+    setDatasetQuery(datasetQuery: DatasetQuery): Query {
+        return this;
+    }
+
     /**
+     *
      * Query is considered empty, i.e. it is in a plain state with no properties / query clauses set
      */
     isEmpty(): boolean {
diff --git a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js
index 12b78b08e8466770c7cd3554dfc77699ba8a5c0c..9960bcf6100512e9396db3e7b0c0289d4ec6d92f 100644
--- a/frontend/src/metabase-lib/lib/queries/StructuredQuery.js
+++ b/frontend/src/metabase-lib/lib/queries/StructuredQuery.js
@@ -47,6 +47,7 @@ import type { TableId } from "metabase/meta/types/Table";
 import AtomicQuery from "./AtomicQuery";
 import AggregationWrapper from "./Aggregation";
 import AggregationOption from "metabase-lib/lib/metadata/AggregationOption";
+import Utils from "metabase/lib/utils";
 
 export const STRUCTURED_QUERY_TEMPLATE = {
     database: null,
@@ -224,6 +225,20 @@ export default class StructuredQuery extends AtomicQuery {
         return this.table();
     }
 
+    clean() {
+        const datasetQuery = this.datasetQuery();
+        if (datasetQuery.query) {
+            const query = Utils.copy(datasetQuery.query);
+
+            return this.setDatasetQuery({
+                ...datasetQuery,
+                query: Q_deprecated.cleanQuery(query)
+            });
+        } else {
+            return this;
+        }
+    }
+
     // AGGREGATIONS
 
     /**
diff --git a/frontend/src/metabase/containers/SaveQuestionModal.jsx b/frontend/src/metabase/containers/SaveQuestionModal.jsx
index 87a44b12ef369129ae272896e719b1fe161cbd5e..3dd6bffdea50e59a823366559ab9152d5b8e990d 100644
--- a/frontend/src/metabase/containers/SaveQuestionModal.jsx
+++ b/frontend/src/metabase/containers/SaveQuestionModal.jsx
@@ -11,7 +11,6 @@ import Button from "metabase/components/Button";
 import CollectionList from "metabase/questions/containers/CollectionList";
 
 import Query from "metabase/lib/query";
-import { cancelable } from "metabase/lib/promise";
 import { t } from 'c-3po';
 import "./SaveQuestionModal.css";
 import ButtonWithStatus from "metabase/components/ButtonWithStatus";
@@ -53,12 +52,6 @@ export default class SaveQuestionModal extends Component {
         this.validateForm();
     }
 
-    componentWillUnmount() {
-        if (this.requestPromise) {
-            this.requestPromise.cancel();
-        }
-    }
-
     validateForm() {
         let { details } = this.state;
 
@@ -85,6 +78,14 @@ export default class SaveQuestionModal extends Component {
             }
 
             let { details } = this.state;
+            // TODO Atte Keinäenn 31/1/18 Refactor this
+            // I think that the primary change should be that
+            // SaveQuestionModal uses Question objects instead of directly modifying card objects –
+            // but that is something that doesn't need to be done first)
+            // question
+            //     .setDisplayName(details.name.trim())
+            //     .setDescription(details.description ? details.description.trim() : null)
+            //     .setCollectionId(details.collection_id)
             let { card, originalCard, createFn, saveFn } = this.props;
 
             card = {
@@ -102,14 +103,12 @@ export default class SaveQuestionModal extends Component {
             };
 
             if (details.saveType === "create") {
-                this.requestPromise = cancelable(createFn(card));
+                await createFn(card);
             } else if (details.saveType === "overwrite") {
                 card.id = this.props.originalCard.id;
-                this.requestPromise = cancelable(saveFn(card));
+                await saveFn(card);
             }
 
-            await this.requestPromise;
-            this.requestPromise = null;
             this.props.onClose();
         } catch (error) {
             if (error && !error.isCanceled) {
diff --git a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx
index a56c4cec1fb5e1f59b63c044d2cee0f9cee0b526..33f692a241a9b33c7fa3fb740c89038305fef049 100644
--- a/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx
+++ b/frontend/src/metabase/dashboard/components/AddSeriesModal.jsx
@@ -212,7 +212,7 @@ export default class AddSeriesModal extends Component {
                     <div className="flex-full ml2 mr1 relative">
                         <Visualization
                             className="spread"
-                            series={series}
+                            rawSeries={series}
                             showTitle
                             isDashboard
                             isMultiseries
diff --git a/frontend/src/metabase/dashboard/components/DashCard.jsx b/frontend/src/metabase/dashboard/components/DashCard.jsx
index 43a731109f5ac78b325e54e1e6cd9be340dfec60..af45d5b4fbf893563636522cde0addf30cc259b0 100644
--- a/frontend/src/metabase/dashboard/components/DashCard.jsx
+++ b/frontend/src/metabase/dashboard/components/DashCard.jsx
@@ -109,7 +109,7 @@ export default class DashCard extends Component {
                     errorIcon={errorIcon}
                     isSlow={isSlow}
                     expectedDuration={expectedDuration}
-                    series={series}
+                    rawSeries={series}
                     showTitle
                     isDashboard
                     isEditing={isEditing}
diff --git a/frontend/src/metabase/public/containers/PublicQuestion.jsx b/frontend/src/metabase/public/containers/PublicQuestion.jsx
index 1978c7c98dedb5d2b4acf6040fe739e85080f221..fef4dd0294e8eba7de368bac569a3eb5a0a937f0 100644
--- a/frontend/src/metabase/public/containers/PublicQuestion.jsx
+++ b/frontend/src/metabase/public/containers/PublicQuestion.jsx
@@ -158,7 +158,7 @@ export default class PublicQuestion extends Component {
                 <LoadingAndErrorWrapper loading={!result}>
                 { () =>
                     <Visualization
-                        series={[{ card: card, data: result && result.data }]}
+                        rawSeries={[{ card: card, data: result && result.data }]}
                         className="full flex-full"
                         onUpdateVisualizationSettings={(settings) =>
                             this.setState({
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 8c89352d766cba55a55bcc2c773f50aea78b9202..f7c24e3e648b0522c16b945b9a79ee893480010e 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -31,7 +31,9 @@ import {
     getOriginalQuestion,
     getOriginalCard,
     getIsEditing,
-    getIsShowingDataReference
+    getIsShowingDataReference,
+    getTransformedSeries,
+    getResultsMetadata,
 } from "./selectors";
 
 import { getDatabases, getTables, getDatabasesList, getMetadata } from "metabase/selectors/metadata";
@@ -47,6 +49,8 @@ import {getCardAfterVisualizationClick} from "metabase/visualizations/lib/utils"
 import type { Card } from "metabase/meta/types/Card";
 import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery";
 import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
+import { getPersistableDefaultSettings } from "metabase/visualizations/lib/settings";
+import { clearRequestState } from "metabase/redux/requests";
 
 type UiControls = {
     isEditing?: boolean,
@@ -525,30 +529,6 @@ export const setParameterValue = createAction(SET_PARAMETER_VALUE, (parameterId,
     return { id: parameterId, value };
 });
 
-// Used after a question is successfully created in QueryHeader component code
-export const NOTIFY_CARD_CREATED = "metabase/qb/NOTIFY_CARD_CREATED";
-export const notifyCardCreatedFn = createThunkAction(NOTIFY_CARD_CREATED, (card) => {
-    return (dispatch, getState) => {
-        dispatch(updateUrl(card, { dirty: false }));
-
-        MetabaseAnalytics.trackEvent("QueryBuilder", "Create Card", card.dataset_query.type);
-
-        return card;
-    }
-});
-
-// Used after a question is successfully updated in QueryHeader component code
-export const NOTIFY_CARD_UPDATED = "metabase/qb/NOTIFY_CARD_UPDATED";
-export const notifyCardUpdatedFn = createThunkAction(NOTIFY_CARD_UPDATED, (card) => {
-    return (dispatch, getState) => {
-        dispatch(updateUrl(card, { dirty: false }));
-
-        MetabaseAnalytics.trackEvent("QueryBuilder", "Update Card", card.dataset_query.type);
-
-        return card;
-    }
-});
-
 // reloadCard
 export const RELOAD_CARD = "metabase/qb/RELOAD_CARD";
 export const reloadCard = createThunkAction(RELOAD_CARD, () => {
@@ -648,9 +628,70 @@ export const updateQuestion = (newQuestion, { doNotClearNameAndId } = {}) => {
         } else if (newTagCount === 0 && !getIsShowingDataReference(getState())) {
             dispatch(setIsShowingTemplateTagsEditor(false));
         }
+
     };
 };
 
+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
+
+        let resultsMetadata = getResultsMetadata(getState())
+        const createdQuestion = await (
+            questionWithVizSettings
+                .setQuery(question.query().clean())
+                .setResultsMetadata(resultsMetadata)
+                .apiCreate()
+        )
+
+        // 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(clearRequestState({ statePath: ["metadata", "databases"] }));
+
+        dispatch(updateUrl(createdQuestion.card(), { dirty: false }));
+        MetabaseAnalytics.trackEvent("QueryBuilder", "Create Card", createdQuestion.query().datasetQuery().type);
+
+        dispatch.action(API_CREATE_QUESTION, createdQuestion.card())
+    }
+}
+
+export const API_UPDATE_QUESTION = "metabase/qb/API_UPDATE_QUESTION";
+export const apiUpdateQuestion = (question) => {
+    return async (dispatch, 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
+
+        let resultsMetadata = getResultsMetadata(getState())
+        const updatedQuestion = await (
+            questionWithVizSettings
+                .setQuery(question.query().clean())
+                .setResultsMetadata(resultsMetadata)
+                .apiUpdate()
+        )
+
+        // 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(clearRequestState({ statePath: ["metadata", "databases"] }));
+
+        dispatch(updateUrl(updatedQuestion.card(), { dirty: false }));
+        MetabaseAnalytics.trackEvent("QueryBuilder", "Update Card", updatedQuestion.query().datasetQuery().type);
+
+        dispatch.action(API_UPDATE_QUESTION, updatedQuestion.card())
+    }
+}
+
 // setDatasetQuery
 // TODO Atte Keinänen 6/1/17: Deprecated, superseded by updateQuestion
 export const SET_DATASET_QUERY = "metabase/qb/SET_DATASET_QUERY";
@@ -983,7 +1024,7 @@ export const runQuestionQuery = ({
         const startTime = new Date();
         const cancelQueryDeferred = defer();
 
-        question.getResults({ cancelDeferred: cancelQueryDeferred, isDirty: cardIsDirty })
+        question.apiGetResults({ cancelDeferred: cancelQueryDeferred, isDirty: cardIsDirty })
             .then((queryResults) => dispatch(queryCompleted(question.card(), queryResults)))
             .catch((error) => dispatch(queryErrored(startTime, error)));
 
@@ -1029,15 +1070,34 @@ export const getDisplayTypeForCard = (card, queryResults) => {
 };
 
 export const QUERY_COMPLETED = "metabase/qb/QUERY_COMPLETED";
-export const queryCompleted = createThunkAction(QUERY_COMPLETED, (card, queryResults) => {
+export const queryCompleted = (card, queryResults) => {
     return async (dispatch, getState) => {
-        return {
+        dispatch.action(QUERY_COMPLETED, {
             card,
             cardDisplay: getDisplayTypeForCard(card, queryResults),
             queryResults
-        }
+        })
     };
-});
+};
+
+/**
+ * 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.visualizationSettings()
+    const newVizSettings = { ...getPersistableDefaultSettings(series), ...oldVizSettings }
+
+    // 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.setVisualizationSettings(newVizSettings)
+    } else {
+        return question
+    }
+}
 
 export const QUERY_ERRORED = "metabase/qb/QUERY_ERRORED";
 export const queryErrored = createThunkAction(QUERY_ERRORED, (startTime, error) => {
diff --git a/frontend/src/metabase/query_builder/components/AlertModals.jsx b/frontend/src/metabase/query_builder/components/AlertModals.jsx
index d46c5828aa9bcbcd999f96a9d6c91918e34e92ae..de4efa3c33be4cb6f607ae35beeffde8814d4f3b 100644
--- a/frontend/src/metabase/query_builder/components/AlertModals.jsx
+++ b/frontend/src/metabase/query_builder/components/AlertModals.jsx
@@ -5,7 +5,7 @@ import { connect } from "react-redux";
 import { createAlert, deleteAlert, updateAlert } from "metabase/alert/alert";
 import ModalContent from "metabase/components/ModalContent";
 import { getUser, getUserIsAdmin } from "metabase/selectors/user";
-import { getQuestion } from "metabase/query_builder/selectors";
+import { getQuestion, getVisualizationSettings } from "metabase/query_builder/selectors";
 import _ from "underscore";
 import PulseEditChannels from "metabase/pulse/components/PulseEditChannels";
 import { fetchPulseFormInput, fetchUsers } from "metabase/pulse/actions";
@@ -28,6 +28,7 @@ import MetabaseCookies from "metabase/lib/cookies";
 import cxs from 'cxs';
 import ChannelSetupModal from "metabase/components/ChannelSetupModal";
 import ButtonWithStatus from "metabase/components/ButtonWithStatus";
+import { apiUpdateQuestion } from "metabase/query_builder/actions";
 
 const getScheduleFromChannel = (channel) =>
     _.pick(channel, "schedule_day", "schedule_frame", "schedule_hour", "schedule_type")
@@ -37,12 +38,13 @@ const classes = cxs ({
 
 @connect((state) => ({
     question: getQuestion(state),
+    visualizationSettings: getVisualizationSettings(state),
     isAdmin: getUserIsAdmin(state),
     user: getUser(state),
     hasLoadedChannelInfo: hasLoadedChannelInfoSelector(state),
     hasConfiguredAnyChannel: hasConfiguredAnyChannelSelector(state),
     hasConfiguredEmailChannel: hasConfiguredEmailChannelSelector(state)
-}), { createAlert, fetchPulseFormInput })
+}), { createAlert, fetchPulseFormInput, apiUpdateQuestion })
 export class CreateAlertModalContent extends Component {
     props: {
         onCancel: () => void,
@@ -52,11 +54,11 @@ export class CreateAlertModalContent extends Component {
     constructor(props) {
         super()
 
-        const { question, user } = props
+        const { question, user, visualizationSettings } = props
 
         this.state = {
             hasSeenEducationalScreen: MetabaseCookies.getHasSeenAlertSplash(),
-            alert: getDefaultAlert(question, user)
+            alert: getDefaultAlert(question, user, visualizationSettings)
         }
     }
 
@@ -83,10 +85,14 @@ export class CreateAlertModalContent extends Component {
     onAlertChange = (alert) => this.setState({ alert })
 
     onCreateAlert = async () => {
-        const { createAlert, onAlertCreated } = this.props
+        const { createAlert, apiUpdateQuestion, onAlertCreated } = this.props
         const { alert } = this.state
 
+        // Resave the question here (for persisting the x/y axes; see #6749)
+        await apiUpdateQuestion()
+
         await createAlert(alert)
+
         // should close be triggered manually like this
         // but the creation notification would appear automatically ...?
         // OR should the modal visibility be part of QB redux state
@@ -102,6 +108,7 @@ export class CreateAlertModalContent extends Component {
     render() {
         const {
             question,
+            visualizationSettings,
             onCancel,
             hasConfiguredAnyChannel,
             hasConfiguredEmailChannel,
@@ -140,7 +147,7 @@ export class CreateAlertModalContent extends Component {
                 <div className="PulseEdit ml-auto mr-auto mb4" style={{maxWidth: "550px"}}>
                     <AlertModalTitle text={t`Let's set up your alert`} />
                     <AlertEditForm
-                        alertType={question.alertType()}
+                        alertType={question.alertType(visualizationSettings)}
                         alert={alert}
                         onAlertChange={this.onAlertChange}
                     />
@@ -199,7 +206,8 @@ export class AlertEducationalScreen extends Component {
     user: getUser(state),
     isAdmin: getUserIsAdmin(state),
     question: getQuestion(state),
-}), { updateAlert, deleteAlert })
+    visualizationSettings: getVisualizationSettings(state)
+}), { apiUpdateQuestion, updateAlert, deleteAlert })
 export class UpdateAlertModalContent extends Component {
     props: {
         alert: any,
@@ -220,8 +228,12 @@ export class UpdateAlertModalContent extends Component {
     onAlertChange = (modifiedAlert) => this.setState({ modifiedAlert })
 
     onUpdateAlert = async () => {
-        const { updateAlert, onAlertUpdated } = this.props
+        const { apiUpdateQuestion, updateAlert, onAlertUpdated } = this.props
         const { modifiedAlert } = this.state
+
+        // Resave the question here (for persisting the x/y axes; see #6749)
+        await apiUpdateQuestion()
+
         await updateAlert(modifiedAlert)
         onAlertUpdated()
     }
@@ -233,7 +245,7 @@ export class UpdateAlertModalContent extends Component {
     }
 
     render() {
-        const { onCancel, question, alert, user, isAdmin } = this.props
+        const { onCancel, question, visualizationSettings, alert, user, isAdmin } = this.props
         const { modifiedAlert } = this.state
 
         const isCurrentUser = alert.creator.id === user.id
@@ -246,7 +258,7 @@ export class UpdateAlertModalContent extends Component {
                 <div className="PulseEdit ml-auto mr-auto mb4" style={{maxWidth: "550px"}}>
                     <AlertModalTitle text={title} />
                     <AlertEditForm
-                        alertType={question.alertType()}
+                        alertType={question.alertType(visualizationSettings)}
                         alert={modifiedAlert}
                         onAlertChange={this.onAlertChange}
                     />
@@ -494,12 +506,27 @@ export class AlertEditChannels extends Component {
 }
 
 // TODO: Not sure how to translate text with formatting properly
-export const RawDataAlertTip = () =>
-    <div className="border-row-divider p3 flex align-center">
-        <div className="circle flex align-center justify-center bg-grey-0 p2 mr2 text-grey-3">
-            <Icon name="lightbulb" size="20" />
-        </div>
-        <div>
-            {jt`${<strong>Tip:</strong>} This kind of alert is most useful when your saved question doesn’t ${<em>usually</em>} return any results, but you want to know when it does.`}
-        </div>
-    </div>
+@connect((state) => ({ question: getQuestion(state), visualizationSettings: getVisualizationSettings(state) }))
+export class RawDataAlertTip extends Component {
+    render() {
+        const display = this.props.question.display()
+        const vizSettings = this.props.visualizationSettings
+        const goalEnabled = vizSettings["graph.show_goal"]
+        const isLineAreaBar = display === "line" || display === "area" || display === "bar"
+        const isMultiSeries =
+            isLineAreaBar && vizSettings["graph.metrics"] && vizSettings["graph.metrics"].length > 1
+        const showMultiSeriesGoalAlert = goalEnabled && isMultiSeries
+
+        return (
+            <div className="border-row-divider p3 flex align-center">
+                <div className="circle flex align-center justify-center bg-grey-0 p2 mr2 text-grey-3">
+                    <Icon name="lightbulb" size="20" />
+                </div>
+                { showMultiSeriesGoalAlert ? <MultiSeriesAlertTip /> : <NormalAlertTip /> }
+            </div>
+        )
+    }
+}
+
+export const MultiSeriesAlertTip = () => <div>{jt`${<strong>Heads up:</strong>} Goal-based alerts aren't yet supported for charts with more than one line, so this alert will be sent whenever the chart has ${<em>results</em>}.`}</div>
+export const NormalAlertTip  = () => <div>{jt`${<strong>Tip:</strong>} This kind of alert is most useful when your saved question doesn’t ${<em>usually</em>} return any results, but you want to know when it does.`}</div>
diff --git a/frontend/src/metabase/query_builder/components/QueryHeader.jsx b/frontend/src/metabase/query_builder/components/QueryHeader.jsx
index 9a55aa3263512aa4fa961e87624b59783d7f734e..ceeb7204d7676f162a5b2b44881d00d7014afc00 100644
--- a/frontend/src/metabase/query_builder/components/QueryHeader.jsx
+++ b/frontend/src/metabase/query_builder/components/QueryHeader.jsx
@@ -25,23 +25,21 @@ import { clearRequestState } from "metabase/redux/requests";
 import { CardApi, RevisionApi } from "metabase/services";
 
 import MetabaseAnalytics from "metabase/lib/analytics";
-import Query from "metabase/lib/query";
-import { cancelable } from "metabase/lib/promise";
 import * as Urls from "metabase/lib/urls";
 
 import cx from "classnames";
 import _ from "underscore";
 import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
-import Utils from "metabase/lib/utils";
 import EntityMenu from "metabase/components/EntityMenu";
 import { CreateAlertModalContent } from "metabase/query_builder/components/AlertModals";
 import { AlertListPopoverContent } from "metabase/query_builder/components/AlertListPopoverContent";
-import { getQuestionAlerts } from "metabase/query_builder/selectors";
+import { getQuestionAlerts, getVisualizationSettings } from "metabase/query_builder/selectors";
 import { getUser } from "metabase/home/selectors";
 import { fetchAlertsForQuestion } from "metabase/alert/alert";
 
 const mapStateToProps = (state, props) => ({
     questionAlerts: getQuestionAlerts(state),
+    visualizationSettings: getVisualizationSettings(state),
     user: getUser(state)
 })
 
@@ -86,9 +84,6 @@ export default class QueryHeader extends Component {
 
     componentWillUnmount() {
         clearTimeout(this.timeout);
-        if (this.requestPromise) {
-            this.requestPromise.cancel();
-        }
     }
 
     resetStateOnTimeout() {
@@ -99,99 +94,31 @@ export default class QueryHeader extends Component {
         , 5000);
     }
 
-    _getCleanedCard(card) {
-        if (card.dataset_query.query) {
-            const query = Utils.copy(card.dataset_query.query);
-            return {
-                ...card,
-                dataset_query: {
-                    ...card.dataset_query,
-                    query: Query.cleanQuery(query)
-                }
-            }
-        } else {
-            return card
-        }
-    }
-
-    /// Add result_metadata and metadata_checksum columns to card as expected by the endpoints used for saving
-    /// and updating Cards. These values are returned as part of Query Processor results and fetched from there
-    addResultMetadata(card) {
-        let metadata = this.props.result && this.props.result.data && this.props.result.data.results_metadata;
-        let metadataChecksum = metadata && metadata.checksum;
-        let metadataColumns = metadata && metadata.columns;
-
-        card.result_metadata = metadataColumns;
-        card.metadata_checksum = metadataChecksum;
-    }
-
-    /// 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 elligable 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
-    clearQBDatabases() {
-        this.props.clearRequestState({ statePath: ["metadata", "databases"] });
-    }
-
-    onCreate(card, showSavedModal = true) {
-        // MBQL->NATIVE
-        // if we are a native query with an MBQL query definition, remove the old MBQL stuff (happens when going from mbql -> native)
-        // if (card.dataset_query.type === "native" && card.dataset_query.query) {
-        //     delete card.dataset_query.query;
-        // } else if (card.dataset_query.type === "query" && card.dataset_query.native) {
-        //     delete card.dataset_query.native;
-        // }
-
-        const cleanedCard = this._getCleanedCard(card);
-        this.addResultMetadata(cleanedCard);
+    onCreate = async (card, showSavedModal = true) => {
+        const { question, apiCreateQuestion } = this.props
+        const questionWithUpdatedCard = question.setCard(card)
+        await apiCreateQuestion(questionWithUpdatedCard)
 
-        // TODO: reduxify
-        this.requestPromise = cancelable(CardApi.create(cleanedCard));
-        return this.requestPromise.then(newCard => {
-            this.clearQBDatabases();
-
-            this.props.notifyCardCreatedFn(newCard);
-
-            this.setState({
-                recentlySaved: "created",
-                ...(showSavedModal ? { modal: "saved" } : {})
-            }, this.resetStateOnTimeout);
-        });
+        this.setState({
+            recentlySaved: "created",
+            ...(showSavedModal ? { modal: "saved" } : {})
+        }, this.resetStateOnTimeout);
     }
 
     onSave = async (card, showSavedModal = true) => {
-        // MBQL->NATIVE
-        // if we are a native query with an MBQL query definition, remove the old MBQL stuff (happens when going from mbql -> native)
-        // if (card.dataset_query.type === "native" && card.dataset_query.query) {
-        //     delete card.dataset_query.query;
-        // } else if (card.dataset_query.type === "query" && card.dataset_query.native) {
-        //     delete card.dataset_query.native;
-        // }
-        const { fetchAlertsForQuestion } = this.props
-
-        const cleanedCard = this._getCleanedCard(card);
-        this.addResultMetadata(cleanedCard);
-
-        // TODO: reduxify
-        this.requestPromise = cancelable(CardApi.update(cleanedCard));
-        return this.requestPromise.then(async updatedCard => {
-            // reload the question alerts for the current question
-            // (some of the old alerts might be removed during update)
-            await fetchAlertsForQuestion(updatedCard.id)
-
-            this.clearQBDatabases();
+        const { question, apiUpdateQuestion } = this.props
+        const questionWithUpdatedCard = question.setCard(card)
+        await apiUpdateQuestion(questionWithUpdatedCard)
 
-            if (this.props.fromUrl) {
-                this.onGoBack();
-                return;
-            }
-
-            this.props.notifyCardUpdatedFn(updatedCard);
+        if (this.props.fromUrl) {
+            this.onGoBack();
+            return;
+        }
 
-            this.setState({
-                recentlySaved: "updated",
-                ...(showSavedModal ? { modal: "saved" } : {})
-            }, this.resetStateOnTimeout);
-        });
+        this.setState({
+            recentlySaved: "updated",
+            ...(showSavedModal ? { modal: "saved" } : {})
+        }, this.resetStateOnTimeout);
     }
 
     onBeginEditing() {
@@ -242,7 +169,7 @@ export default class QueryHeader extends Component {
     }
 
     getHeaderButtons() {
-        const { question, questionAlerts, card ,isNew, isDirty, isEditing, tableMetadata, databases } = this.props;
+        const { question, questionAlerts, visualizationSettings, card ,isNew, isDirty, isEditing, tableMetadata, databases } = this.props;
         const database = _.findWhere(databases, { id: card && card.dataset_query && card.dataset_query.database });
 
         var buttonSections = [];
@@ -454,7 +381,7 @@ export default class QueryHeader extends Component {
             </Tooltip>
         ]);
 
-        if (!isEditing && card && question.alertType() !== null) {
+        if (!isEditing && card && question.alertType(visualizationSettings) !== null) {
             const createAlertItem = {
                 title: t`Get alerts about this`,
                 icon: "alert",
diff --git a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
index a359275c421af01e90aa68803b600746a6b6c9d8..68b4901269163a20513427cd59ee61cdc2791215 100644
--- a/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
+++ b/frontend/src/metabase/query_builder/components/VisualizationResult.jsx
@@ -17,7 +17,8 @@ type Props = {
     results: any[],
     isDirty: boolean,
     lastRunDatasetQuery: DatasetQuery,
-    navigateToNewCardInsideQB: (any) => void
+    navigateToNewCardInsideQB: (any) => void,
+    rawSeries: any
 }
 
 export default class VisualizationResult extends Component {
@@ -35,7 +36,7 @@ export default class VisualizationResult extends Component {
     }
 
     render() {
-        const { question, isDirty, isObjectDetail, lastRunDatasetQuery, navigateToNewCardInsideQB, result, results, ...props } = this.props
+        const { question, isDirty, navigateToNewCardInsideQB, result, rawSeries, ...props } = this.props
         const { showCreateAlertModal } = this.state
 
         const noResults = datasetContainsNoResults(result.data);
@@ -66,21 +67,9 @@ export default class VisualizationResult extends Component {
                 </Modal> }
             </div>
         } else {
-            // we want to provide the visualization with a card containing the latest
-            // "display", "visualization_settings", etc, (to ensure the correct visualization is shown)
-            // BUT the last executed "dataset_query" (to ensure data matches the query)
-            const series = question.atomicQueries().map((metricQuery, index) => ({
-                card: {
-                    ...question.card(),
-                    display: isObjectDetail ? "object" : question.card().display,
-                    dataset_query: lastRunDatasetQuery
-                },
-                data: results[index] && results[index].data
-            }));
-
             return (
                 <Visualization
-                    series={series}
+                    rawSeries={rawSeries}
                     onChangeCardAndRun={navigateToNewCardInsideQB}
                     isEditing={true}
                     card={question.card()}
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index a76ae538e5f31b6ebff7c3223d73c72392d05ef0..5435be343fb6e1e6642b193e524d2094ab07160b 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -47,7 +47,8 @@ import {
     getQuery,
     getQuestion,
     getOriginalQuestion,
-    getSettings
+    getSettings,
+    getRawSeries
 } from "../selectors";
 
 import { getMetadata, getDatabasesList } from "metabase/selectors/metadata";
@@ -98,6 +99,7 @@ const mapStateToProps = (state, props) => {
 
         result:                    getQueryResult(state),
         results:                   getQueryResults(state),
+        rawSeries:                 getRawSeries(state),
 
         isDirty:                   getIsDirty(state),
         isNew:                     getIsNew(state),
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index 904f3b1f2be56dd8de0d169b3150b276a9c62b96..ee66cb5c1957faaaf0c525ee86940181c0ebe633 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -16,8 +16,8 @@ import {
     LOAD_TABLE_METADATA,
     LOAD_DATABASE_FIELDS,
     RELOAD_CARD,
-    NOTIFY_CARD_CREATED,
-    NOTIFY_CARD_UPDATED,
+    API_CREATE_QUESTION,
+    API_UPDATE_QUESTION,
     SET_CARD_AND_RUN,
     SET_CARD_ATTRIBUTE,
     SET_CARD_VISUALIZATION,
@@ -58,7 +58,7 @@ export const uiControls = handleActions({
 
     [BEGIN_EDITING]: { next: (state, { payload }) => ({ ...state, isEditing: true }) },
     [CANCEL_EDITING]: { next: (state, { payload }) => ({ ...state, isEditing: false }) },
-    [NOTIFY_CARD_UPDATED]: { next: (state, { payload }) => ({ ...state, isEditing: false }) },
+    [API_UPDATE_QUESTION]: { next: (state, { payload }) => ({ ...state, isEditing: false }) },
     [RELOAD_CARD]: { next: (state, { payload }) => ({ ...state, isEditing: false })},
 
     [RUN_QUERY]: (state) => ({ ...state, isRunning: true }),
@@ -82,8 +82,8 @@ export const card = handleActions({
     [RELOAD_CARD]: { next: (state, { payload }) => payload },
     [CANCEL_EDITING]: { next: (state, { payload }) => payload },
     [SET_CARD_AND_RUN]: { next: (state, { payload }) => payload.card },
-    [NOTIFY_CARD_CREATED]: { next: (state, { payload }) => payload },
-    [NOTIFY_CARD_UPDATED]: { next: (state, { payload }) => payload },
+    [API_CREATE_QUESTION]: { next: (state, { payload }) => payload },
+    [API_UPDATE_QUESTION]: { next: (state, { payload }) => payload },
 
     [SET_CARD_ATTRIBUTE]: { next: (state, { payload }) => ({...state, [payload.attr]: payload.value }) },
     [SET_CARD_VISUALIZATION]: { next: (state, { payload }) => payload },
@@ -115,8 +115,8 @@ export const originalCard = handleActions({
     [RELOAD_CARD]: { next: (state, { payload }) => payload.id ? Utils.copy(payload) : null },
     [CANCEL_EDITING]: { next: (state, { payload }) => payload.id ? Utils.copy(payload) : null },
     [SET_CARD_AND_RUN]: { next: (state, { payload }) => payload.originalCard ? Utils.copy(payload.originalCard) : null },
-    [NOTIFY_CARD_CREATED]: { next: (state, { payload }) => Utils.copy(payload) },
-    [NOTIFY_CARD_UPDATED]: { next: (state, { payload }) => Utils.copy(payload) },
+    [API_CREATE_QUESTION]: { next: (state, { payload }) => Utils.copy(payload) },
+    [API_UPDATE_QUESTION]: { next: (state, { payload }) => Utils.copy(payload) },
 }, null);
 
 export const tableForeignKeys = handleActions({
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index 790bfc2aeb8d9fde010dc098d34e8e99e3ebea84..af6786662abcce7064c54958105d489b02c0537d 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -2,6 +2,12 @@
 import { createSelector } from "reselect";
 import _ from "underscore";
 
+// Needed due to wrong dependency resolution order
+// eslint-disable-next-line no-unused-vars
+import Visualization from "metabase/visualizations/components/Visualization";
+
+import { getSettings as _getVisualizationSettings } from "metabase/visualizations/lib/settings";
+
 import { getParametersWithExtras } from "metabase/meta/Card";
 
 import { isCardDirty } from "metabase/lib/card";
@@ -97,6 +103,7 @@ export const getDatabaseFields = createSelector(
 
 import { getMode as getMode_ } from "metabase/qb/lib/modes";
 import { getAlerts } from "metabase/alert/selectors";
+import { extractRemappings, getVisualizationTransformed } from "metabase/visualizations";
 
 export const getMode = createSelector(
     [getLastRunCard, getTableMetadata],
@@ -162,3 +169,46 @@ export const getQuestionAlerts = createSelector(
     [getAlerts, getCard],
     (alerts, card) => card && card.id && _.pick(alerts, (alert) => alert.card.id === card.id) || {}
 )
+
+export const getResultsMetadata = createSelector(
+    [getQueryResult],
+    (result) => result && result.data && result.data.results_metadata
+)
+
+/**
+ * Returns the card and query results data in a format that `Visualization.jsx` expects
+ */
+export const getRawSeries = createSelector(
+    [getQuestion, getQueryResults, getIsObjectDetail, getLastRunDatasetQuery],
+    (question, results, isObjectDetail, lastRunDatasetQuery) => {
+        // we want to provide the visualization with a card containing the latest
+        // "display", "visualization_settings", etc, (to ensure the correct visualization is shown)
+        // BUT the last executed "dataset_query" (to ensure data matches the query)
+        return results && question.atomicQueries().map((metricQuery, index) => ({
+            card: {
+                ...question.card(),
+                display: isObjectDetail ? "object" : question.card().display,
+                dataset_query: lastRunDatasetQuery
+            },
+            data: results[index] && results[index].data
+        }))
+    }
+)
+
+/**
+ * Returns the final series data that all visualization (starting from the root-level
+ * `Visualization.jsx` component) code uses for rendering visualizations.
+ */
+export const getTransformedSeries = createSelector(
+    [getRawSeries],
+    (rawSeries) => rawSeries && getVisualizationTransformed(extractRemappings(rawSeries)).series
+)
+
+/**
+ * Returns complete visualization settings (including default values for those settings which aren't explicitly set)
+ */
+export const getVisualizationSettings = createSelector(
+    [getTransformedSeries],
+    (series) => series && _getVisualizationSettings(series)
+)
+
diff --git a/frontend/src/metabase/visualizations/components/ChartSettings.jsx b/frontend/src/metabase/visualizations/components/ChartSettings.jsx
index 24b5d576000be7067f75810062fecd0e2094b710..443ff02219273761371af9fb52b43289db17ac93 100644
--- a/frontend/src/metabase/visualizations/components/ChartSettings.jsx
+++ b/frontend/src/metabase/visualizations/components/ChartSettings.jsx
@@ -139,7 +139,7 @@ class ChartSettings extends Component {
                         <div className="flex-full relative">
                             <Visualization
                                 className="spread"
-                                series={series}
+                                rawSeries={series}
                                 isEditing
                                 showTitle
                                 isDashboard
diff --git a/frontend/src/metabase/visualizations/components/Visualization.jsx b/frontend/src/metabase/visualizations/components/Visualization.jsx
index 91495ce8e3378f6d619b7b96d19e4b44f42a0861..6dd16ae2963cafb20b6ed84583bd82fa848eab49 100644
--- a/frontend/src/metabase/visualizations/components/Visualization.jsx
+++ b/frontend/src/metabase/visualizations/components/Visualization.jsx
@@ -35,7 +35,7 @@ import type { HoverObject, ClickObject, Series, OnChangeCardAndRun } from "metab
 import Metadata from "metabase-lib/lib/metadata/Metadata";
 
 type Props = {
-    series: Series,
+    rawSeries: Series,
 
     className: string,
 
@@ -125,7 +125,7 @@ export default class Visualization extends Component {
     }
 
     componentWillReceiveProps(newProps) {
-        if (!isSameSeries(newProps.series, this.props.series) || !Utils.equals(newProps.settings, this.props.settings)) {
+        if (!isSameSeries(newProps.rawSeries, this.props.rawSeries) || !Utils.equals(newProps.settings, this.props.settings)) {
             this.transform(newProps);
         }
     }
@@ -145,7 +145,7 @@ export default class Visualization extends Component {
         let warnings = state.warnings || [];
         // don't warn about truncated data for table since we show a warning in the row count
         if (state.series[0].card.display !== "table") {
-            warnings = warnings.concat(props.series
+            warnings = warnings.concat(props.rawSeries
                 .filter(s => s.data && s.data.rows_truncated != null)
                 .map(s => t`Data truncated to ${formatNumber(s.data.rows_truncated)} rows.`));
         }
@@ -165,7 +165,7 @@ export default class Visualization extends Component {
             error: null,
             warnings: [],
             yAxisSplit: null,
-            ...getVisualizationTransformed(extractRemappings(newProps.series))
+            ...getVisualizationTransformed(extractRemappings(newProps.rawSeries))
         });
     }
 
@@ -199,9 +199,9 @@ export default class Visualization extends Component {
             return [];
         }
         // TODO: push this logic into Question?
-        const { series, metadata } = this.props;
+        const { rawSeries, metadata } = this.props;
         const seriesIndex = clicked.seriesIndex || 0;
-        const card = series[seriesIndex].card;
+        const card = rawSeries[seriesIndex].card;
         const question = new Question(metadata, card);
         const mode = question.mode();
         return mode ? mode.actionsForClick(clicked, {}) : [];
diff --git a/frontend/src/metabase/visualizations/lib/settings.js b/frontend/src/metabase/visualizations/lib/settings.js
index 8deb2e068dd3376c32bf7d23cb83dd36136abe91..93a10b8d7fab05eee6429d8ee095917bc5be3c9e 100644
--- a/frontend/src/metabase/visualizations/lib/settings.js
+++ b/frontend/src/metabase/visualizations/lib/settings.js
@@ -189,7 +189,9 @@ function getSetting(settingDefs, id, vizSettings, series) {
         }
 
         if (settingDef.getDefault) {
-            return vizSettings[id] = settingDef.getDefault(series, vizSettings);
+            const defaultValue = settingDef.getDefault(series, vizSettings)
+
+            return vizSettings[id] = defaultValue;
         }
 
         if ("default" in settingDef) {
@@ -214,6 +216,26 @@ function getSettingDefintionsForSeries(series) {
     return definitions;
 }
 
+export function getPersistableDefaultSettings(series) {
+    // A complete set of settings (not only defaults) is loaded because
+    // some persistable default settings need other settings as dependency for calculating the default value
+    const completeSettings = getSettings(series)
+
+    let persistableDefaultSettings = {};
+    let settingsDefs = getSettingDefintionsForSeries(series);
+
+    for (let id in settingsDefs) {
+        const settingDef = settingsDefs[id]
+        const seriesForSettingsDef = settingDef.useRawSeries && series._raw ? series._raw : series
+
+        if (settingDef.persistDefault) {
+            persistableDefaultSettings[id] = settingDef.getDefault(seriesForSettingsDef, completeSettings)
+        }
+    }
+
+    return persistableDefaultSettings;
+}
+
 export function getSettings(series) {
     let vizSettings = {};
     let settingsDefs = getSettingDefintionsForSeries(series);
diff --git a/frontend/src/metabase/visualizations/lib/settings/graph.js b/frontend/src/metabase/visualizations/lib/settings/graph.js
index 32687c7235b7838cc6494fead692524c4ee0a2be..2d5c73172c0b4e751a68cb87f9caf9b625af5807 100644
--- a/frontend/src/metabase/visualizations/lib/settings/graph.js
+++ b/frontend/src/metabase/visualizations/lib/settings/graph.js
@@ -34,6 +34,7 @@ export const GRAPH_DATA_SETTINGS = {
           columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]),
       getDefault: (series, vizSettings) =>
           getDefaultColumns(series).dimensions,
+      persistDefault: true,
       getProps: ([{ card, data }], vizSettings) => {
           const value = vizSettings["graph.dimensions"];
           const options = data.cols.filter(vizSettings["graph._dimension_filter"]).map(getOptionFromColumn);
@@ -57,6 +58,7 @@ export const GRAPH_DATA_SETTINGS = {
           columnsAreValid(card.visualization_settings["graph.metrics"], data, vizSettings["graph._metric_filter"]),
       getDefault: (series, vizSettings) =>
           getDefaultColumns(series).metrics,
+      persistDefault: true,
       getProps: ([{ card, data }], vizSettings) => {
           const value = vizSettings["graph.dimensions"];
           const options = data.cols.filter(vizSettings["graph._metric_filter"]).map(getOptionFromColumn);
diff --git a/frontend/src/metabase/xray/Histogram.jsx b/frontend/src/metabase/xray/Histogram.jsx
index ae5a4b0399ec898c1bad2e0b3e183dd1cb1f5030..1d53d0b3e72a1ed4edbb7fec3e16493459a181be 100644
--- a/frontend/src/metabase/xray/Histogram.jsx
+++ b/frontend/src/metabase/xray/Histogram.jsx
@@ -6,7 +6,7 @@ import { normal } from 'metabase/lib/colors'
 const Histogram = ({ histogram, color, showAxis }) =>
     <Visualization
         className="full-height"
-        series={[
+        rawSeries={[
             {
                 card: {
                     display: "bar",
diff --git a/frontend/src/metabase/xray/components/XRayComparison.jsx b/frontend/src/metabase/xray/components/XRayComparison.jsx
index 0f49fad3e786cc0c9c7b8a318c589bd6d39d90fa..150c934eb919f4fb2125f1f3ab4b096af63e6fd6 100644
--- a/frontend/src/metabase/xray/components/XRayComparison.jsx
+++ b/frontend/src/metabase/xray/components/XRayComparison.jsx
@@ -110,7 +110,7 @@ const CompareHistograms = ({ itemA, itemAColor, itemB, itemBColor, showAxis = fa
         <div className="flex-full">
             <Visualization
                 className="full-height"
-                series={[
+                rawSeries={[
                     {
                         card: {
                             display: "bar",
diff --git a/frontend/src/metabase/xray/containers/CardXRay.jsx b/frontend/src/metabase/xray/containers/CardXRay.jsx
index e77086592bb81ac0016629ba1b4aa9eb8caec752..25d2ae9e79f868eb74ddfc45f4108484322caeae 100644
--- a/frontend/src/metabase/xray/containers/CardXRay.jsx
+++ b/frontend/src/metabase/xray/containers/CardXRay.jsx
@@ -122,7 +122,7 @@ class CardXRay extends Component {
                             <div className="full">
                                 <div className="py1 px2" style={{ height: 320}}>
                                     <Visualization
-                                        series={[
+                                        rawSeries={[
                                             {
                                                 card: xray.features.model,
                                                 data: xray.features.series
@@ -148,7 +148,7 @@ class CardXRay extends Component {
                         <div className="full">
                             <div className="bg-white bordered rounded shadowed" style={{ height: 220}}>
                                 <Visualization
-                                    series={[
+                                    rawSeries={[
                                         {
                                             card: {
                                                 display: 'line',
@@ -179,7 +179,7 @@ class CardXRay extends Component {
                         <div className="full">
                             <div className="bg-white bordered rounded shadowed" style={{ height: 220}}>
                                 <Visualization
-                                    series={[
+                                    rawSeries={[
                                         {
                                             card: {
                                                 display: 'line',
diff --git a/frontend/test/__support__/integrated_tests.js b/frontend/test/__support__/integrated_tests.js
index ecadc99f53947651b55d9d9c63ce4d171590cafb..b64767f4272d2fb2aa16263b7055b16acaf45a64 100644
--- a/frontend/test/__support__/integrated_tests.js
+++ b/frontend/test/__support__/integrated_tests.js
@@ -10,7 +10,7 @@ import "./mocks";
 
 import { format as urlFormat } from "url";
 import api from "metabase/lib/api";
-import { CardApi, DashboardApi, SessionApi } from "metabase/services";
+import { DashboardApi, SessionApi } from "metabase/services";
 import { METABASE_SESSION_COOKIE } from "metabase/lib/cookies";
 import normalReducers from 'metabase/reducers-main';
 import publicReducers from 'metabase/reducers-public';
@@ -347,9 +347,8 @@ const testStoreEnhancer = (createStore, history, getRoutes) => {
 // Commonly used question helpers that are temporarily here
 // TODO Atte Keinänen 6/27/17: Put all metabase-lib -related test helpers to one file
 export const createSavedQuestion = async (unsavedQuestion) => {
-    const savedCard = await CardApi.create(unsavedQuestion.card())
-    const savedQuestion = unsavedQuestion.setCard(savedCard);
-    savedQuestion._card = { ...savedQuestion._card, original_card_id: savedQuestion.id() }
+    const savedQuestion = await unsavedQuestion.apiCreate()
+    savedQuestion._card = { ...savedQuestion.card(), original_card_id: savedQuestion.id() }
     return savedQuestion
 }
 
diff --git a/frontend/test/alert/alert.integ.spec.js b/frontend/test/alert/alert.integ.spec.js
index e80b44cd78886877db2bd0fda8c1d237a40ad417..69c9daa731136f245e67ae488e4d729c365bb0f9 100644
--- a/frontend/test/alert/alert.integ.spec.js
+++ b/frontend/test/alert/alert.integ.spec.js
@@ -24,7 +24,10 @@ import {
     AlertEducationalScreen,
     AlertSettingToggle,
     CreateAlertModalContent,
-    RawDataAlertTip, UpdateAlertModalContent
+    MultiSeriesAlertTip,
+    NormalAlertTip,
+    RawDataAlertTip,
+    UpdateAlertModalContent
 } from "metabase/query_builder/components/AlertModals";
 import Button from "metabase/components/Button";
 import {
@@ -72,6 +75,7 @@ describe("Alerts", () => {
     let rawDataQuestion = null;
     let timeSeriesQuestion = null;
     let timeSeriesWithGoalQuestion = null;
+    let timeMultiSeriesWithGoalQuestion = null;
     let progressBarQuestion = null;
 
     beforeAll(async () => {
@@ -95,9 +99,13 @@ describe("Alerts", () => {
             Question.create({databaseId: 1, tableId: 1, metadata })
                 .query()
                 .addAggregation(["count"])
-                .addBreakout(["datetime-field", ["field-id", 1], "day"])
+                .addBreakout(["datetime-field", ["field-id", 1], "month"])
                 .question()
                 .setDisplay("line")
+                .setVisualizationSettings({
+                    "graph.dimensions": ["CREATED_AT"],
+                    "graph.metrics": ["count"]
+                })
                 .setDisplayName("Time series line")
         )
 
@@ -105,13 +113,34 @@ describe("Alerts", () => {
             Question.create({databaseId: 1, tableId: 1, metadata })
                 .query()
                 .addAggregation(["count"])
-                .addBreakout(["datetime-field", ["field-id", 1], "day"])
+                .addBreakout(["datetime-field", ["field-id", 1], "month"])
                 .question()
                 .setDisplay("line")
-                .setVisualizationSettings({ "graph.show_goal": true, "graph.goal_value": 10 })
+                .setVisualizationSettings({
+                    "graph.show_goal": true,
+                    "graph.goal_value": 10,
+                    "graph.dimensions": ["CREATED_AT"],
+                    "graph.metrics": ["count"]
+                })
                 .setDisplayName("Time series line with goal")
         )
 
+        timeMultiSeriesWithGoalQuestion = await createSavedQuestion(
+            Question.create({databaseId: 1, tableId: 1, metadata })
+                .query()
+                .addAggregation(["count"])
+                .addAggregation(["sum", ["field-id", 6]])
+                .addBreakout(["datetime-field", ["field-id", 1], "month"])
+                .question()
+                .setDisplay("line")
+                .setVisualizationSettings({
+                    "graph.show_goal": true,
+                    "graph.goal_value": 10,
+                    "graph.dimensions": ["CREATED_AT"],
+                    "graph.metrics": ["count", "sum"]
+                })
+                .setDisplayName("Time multiseries line with goal")
+        )
         progressBarQuestion = await createSavedQuestion(
             Question.create({databaseId: 1, tableId: 1, metadata })
                 .query()
@@ -127,6 +156,7 @@ describe("Alerts", () => {
         await CardApi.delete({cardId: rawDataQuestion.id()})
         await CardApi.delete({cardId: timeSeriesQuestion.id()})
         await CardApi.delete({cardId: timeSeriesWithGoalQuestion.id()})
+        await CardApi.delete({cardId: timeMultiSeriesWithGoalQuestion.id()})
         await CardApi.delete({cardId: progressBarQuestion.id()})
     })
 
@@ -260,6 +290,7 @@ describe("Alerts", () => {
             const alertModal = app.find(QueryHeader).find(".test-modal")
             const creationScreen = alertModal.find(CreateAlertModalContent)
             expect(creationScreen.find(RawDataAlertTip).length).toBe(1)
+            expect(creationScreen.find(NormalAlertTip).length).toBe(1)
             expect(creationScreen.find(AlertSettingToggle).length).toBe(0)
 
             clickButton(creationScreen.find(".Button.Button--primary"))
@@ -307,6 +338,22 @@ describe("Alerts", () => {
             expect(alert.alert_above_goal).toBe(false)
             expect(alert.alert_first_only).toBe(true)
         })
+
+        it("should fall back to raw data alert and show a warning for time-multiseries questions with a set goal", async () => {
+            useSharedNormalLogin()
+            const { app, store } = await initQbWithAlertMenuItemClicked(timeMultiSeriesWithGoalQuestion)
+
+            await store.waitForActions([FETCH_PULSE_FORM_INPUT])
+            const alertModal = app.find(QueryHeader).find(".test-modal")
+            const creationScreen = alertModal.find(CreateAlertModalContent)
+            // console.log(creationScreen.debug())
+            expect(creationScreen.find(RawDataAlertTip).length).toBe(1)
+            expect(creationScreen.find(MultiSeriesAlertTip).length).toBe(1)
+            expect(creationScreen.find(AlertSettingToggle).length).toBe(0)
+
+            clickButton(creationScreen.find(".Button.Button--primary"))
+            await store.waitForActions([CREATE_ALERT])
+        })
     })
 
     describe("alert list for a question", () => {
@@ -316,7 +363,6 @@ describe("Alerts", () => {
             // as a recipient.
             useSharedAdminLogin()
             const adminUser = await UserApi.current();
-            // TODO TODO TODO THIS ALERT HAZ A COMP-LETELY WRONG TYPE!
             await AlertApi.create(getDefaultAlert(timeSeriesWithGoalQuestion, adminUser))
 
             useSharedNormalLogin()
diff --git a/frontend/test/metabase-lib/Question.integ.spec.js b/frontend/test/metabase-lib/Question.integ.spec.js
index 63262360645426c12b0e6904f0807d359f65c042..25c1ba3b87db905285de618d3f54b1f1efdb46be 100644
--- a/frontend/test/metabase-lib/Question.integ.spec.js
+++ b/frontend/test/metabase-lib/Question.integ.spec.js
@@ -40,11 +40,11 @@ describe("Question", () => {
             });
 
             // Without a template tag the query should fail
-            const results1 = await question.getResults({ ignoreCache: true });
+            const results1 = await question.apiGetResults({ ignoreCache: true });
             expect(results1[0].status).toBe("failed");
 
             question._parameterValues = { [templateTagId]: "5" };
-            const results2 = await question.getResults({ ignoreCache: true });
+            const results2 = await question.apiGetResults({ ignoreCache: true });
             expect(results2[0]).toBeDefined();
             expect(results2[0].data.rows[0][0]).toEqual(116.35497575401975);
         });
@@ -72,12 +72,12 @@ describe("Question", () => {
                 }
             });
 
-            const results1 = await question.getResults({ ignoreCache: true });
+            const results1 = await question.apiGetResults({ ignoreCache: true });
             expect(results1[0]).toBeDefined();
             expect(results1[0].data.rows.length).toEqual(10000);
 
             question._parameterValues = { [templateTagId]: "5" };
-            const results2 = await question.getResults({ ignoreCache: true });
+            const results2 = await question.apiGetResults({ ignoreCache: true });
             expect(results2[0]).toBeDefined();
             expect(results2[0].data.rows[0][0]).toEqual(116.35497575401975);
         });
diff --git a/frontend/test/modes/drills/PivotByCategoryDrill.integ.spec.js b/frontend/test/modes/drills/PivotByCategoryDrill.integ.spec.js
index be7318bbb8b0ef348a347a93c17e3a4567e08047..a50344065156df2ebf882dedc453aa33d6909b6f 100644
--- a/frontend/test/modes/drills/PivotByCategoryDrill.integ.spec.js
+++ b/frontend/test/modes/drills/PivotByCategoryDrill.integ.spec.js
@@ -26,7 +26,7 @@ describe("PivotByCategoryDrill", () => {
 
         const pivotedQuestion = question.pivot([["field-id", 4]]);
 
-        const results = await pivotedQuestion.getResults();
+        const results = await pivotedQuestion.apiGetResults();
         expect(results[0]).toBeDefined();
     });
 });
diff --git a/frontend/test/parameters/parameters.integ.spec.js b/frontend/test/parameters/parameters.integ.spec.js
index 0766f28da2d930eae6748b6c870c4b0911c41a1d..a148284a225cbf3507b59ce74e68dc7db56f5130 100644
--- a/frontend/test/parameters/parameters.integ.spec.js
+++ b/frontend/test/parameters/parameters.integ.spec.js
@@ -21,7 +21,7 @@ import EmbeddingLegalese from "metabase/admin/settings/components/widgets/Embedd
 import {
     CREATE_PUBLIC_LINK,
     INITIALIZE_QB,
-    NOTIFY_CARD_CREATED,
+    API_CREATE_QUESTION,
     QUERY_COMPLETED,
     RUN_QUERY,
     SET_QUERY_MODE,
@@ -141,6 +141,7 @@ describe("parameters", () => {
             expect(fieldFilterVarType.text()).toBe("Field Filter");
             click(fieldFilterVarType);
 
+            // there's an async error here for some reason
             await store.waitForActions([UPDATE_TEMPLATE_TAG]);
 
             await delay(500);
@@ -166,6 +167,7 @@ describe("parameters", () => {
             // close the template variable sidebar
             click(tagEditorSidebar.find(".Icon-close"));
 
+
             // test without the parameter
             click(app.find(RunButton));
             await store.waitForActions([RUN_QUERY, QUERY_COMPLETED])
@@ -186,7 +188,8 @@ describe("parameters", () => {
             setInputValue(app.find(SaveQuestionModal).find("input[name='name']"), "sql parametrized");
 
             clickButton(app.find(SaveQuestionModal).find("button").last());
-            await store.waitForActions([NOTIFY_CARD_CREATED]);
+            await store.waitForActions([API_CREATE_QUESTION]);
+            await delay(100)
 
             click(app.find('#QuestionSavedModal .Button[children="Not now"]'))
             // wait for modal to close :'(
diff --git a/frontend/test/query_builder/components/VisualizationSettings.integ.spec.js b/frontend/test/query_builder/components/VisualizationSettings.integ.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..98b18b885b5a30df87d3bd010dc33f88fa1f4f46
--- /dev/null
+++ b/frontend/test/query_builder/components/VisualizationSettings.integ.spec.js
@@ -0,0 +1,87 @@
+import {
+    useSharedAdminLogin,
+    createTestStore
+} from "__support__/integrated_tests";
+import {
+    click,
+} from "__support__/enzyme_utils"
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    INITIALIZE_QB,
+    QUERY_COMPLETED,
+    setQueryDatabase,
+    setQuerySourceTable,
+} from "metabase/query_builder/actions";
+
+import {
+    FETCH_TABLE_METADATA,
+} from "metabase/redux/metadata";
+
+import CheckBox from "metabase/components/CheckBox";
+import RunButton from "metabase/query_builder/components/RunButton";
+
+import VisualizationSettings from "metabase/query_builder/components/VisualizationSettings";
+import TableSimple from "metabase/visualizations/components/TableSimple";
+import * as Urls from "metabase/lib/urls";
+
+const initQbWithDbAndTable = (dbId, tableId) => {
+    return async () => {
+        const store = await createTestStore()
+        store.pushPath(Urls.plainQuestion());
+        const qb = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB]);
+
+        // Use Products table
+        store.dispatch(setQueryDatabase(dbId));
+        store.dispatch(setQuerySourceTable(tableId));
+        await store.waitForActions([FETCH_TABLE_METADATA]);
+
+        return { store, qb }
+    }
+}
+
+const initQBWithReviewsTable = initQbWithDbAndTable(1, 4)
+
+describe("QueryBuilder", () => {
+    beforeAll(async () => {
+        useSharedAdminLogin()
+    })
+
+    describe("visualization settings", () => {
+        it("lets you hide a field for a raw data table", async () => {
+            const { store, qb } = await initQBWithReviewsTable();
+
+            // Run the raw data query
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            const vizSettings = qb.find(VisualizationSettings);
+            click(vizSettings.find(".Icon-gear"));
+
+            const settingsModal = vizSettings.find(".test-modal")
+            const table = settingsModal.find(TableSimple);
+
+            expect(table.find('div[children="Created At"]').length).toBe(1);
+
+            const doneButton = settingsModal.find(".Button--primary")
+            expect(doneButton.length).toBe(1)
+
+            const fieldsToIncludeCheckboxes = settingsModal.find(CheckBox)
+            expect(fieldsToIncludeCheckboxes.length).toBe(7)
+
+            click(fieldsToIncludeCheckboxes.filterWhere((checkbox) => checkbox.parent().find("span").text() === "Created At"))
+
+            expect(table.find('div[children="Created At"]').length).toBe(0);
+
+            // Save the settings
+            click(doneButton);
+            expect(vizSettings.find(".test-modal").length).toBe(0);
+
+            // Don't test the contents of actual table visualization here as react-virtualized doesn't seem to work
+            // very well together with Enzyme
+        })
+    })
+})
diff --git a/frontend/test/query_builder/qb_drillthrough.integ.spec.js b/frontend/test/query_builder/qb_drillthrough.integ.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d25b2cc6531825daf55240ae984b8b64e8b75c2d
--- /dev/null
+++ b/frontend/test/query_builder/qb_drillthrough.integ.spec.js
@@ -0,0 +1,193 @@
+import {
+    useSharedAdminLogin,
+    createTestStore
+} from "__support__/integrated_tests";
+import {
+    click,
+} from "__support__/enzyme_utils"
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    INITIALIZE_QB,
+    QUERY_COMPLETED,
+    setQueryDatabase,
+    setQuerySourceTable,
+    setDatasetQuery,
+    NAVIGATE_TO_NEW_CARD,
+    UPDATE_URL,
+} from "metabase/query_builder/actions";
+
+import QueryHeader from "metabase/query_builder/components/QueryHeader";
+import {
+    FETCH_TABLE_METADATA,
+} from "metabase/redux/metadata";
+
+import RunButton from "metabase/query_builder/components/RunButton";
+
+import BreakoutWidget from "metabase/query_builder/components/BreakoutWidget";
+import { getCard } from "metabase/query_builder/selectors";
+import { TestTable } from "metabase/visualizations/visualizations/Table";
+import ChartClickActions from "metabase/visualizations/components/ChartClickActions";
+
+import { delay } from "metabase/lib/promise";
+import * as Urls from "metabase/lib/urls";
+
+const initQbWithDbAndTable = (dbId, tableId) => {
+    return async () => {
+        const store = await createTestStore()
+        store.pushPath(Urls.plainQuestion());
+        const qb = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB]);
+
+        // Use Products table
+        store.dispatch(setQueryDatabase(dbId));
+        store.dispatch(setQuerySourceTable(tableId));
+        await store.waitForActions([FETCH_TABLE_METADATA]);
+
+        return { store, qb }
+    }
+}
+
+const initQbWithOrdersTable = initQbWithDbAndTable(1, 1)
+
+describe("QueryBuilder", () => {
+    beforeAll(async () => {
+        useSharedAdminLogin()
+    })
+
+    describe("drill-through", () => {
+        describe("Zoom In action for broken out fields", () => {
+            it("works for Count of rows aggregation and Subtotal 50 Bins breakout", async () => {
+                const {store, qb} = await initQbWithOrdersTable();
+                await store.dispatch(setDatasetQuery({
+                    database: 1,
+                    type: 'query',
+                    query: {
+                        source_table: 1,
+                        breakout: [['binning-strategy', ['field-id', 6], 'num-bins', 50]],
+                        aggregation: [['count']]
+                    }
+                }));
+
+
+                click(qb.find(RunButton));
+                await store.waitForActions([QUERY_COMPLETED]);
+
+                const table = qb.find(TestTable);
+                const firstRowCells = table.find("tbody tr").first().find("td");
+                expect(firstRowCells.length).toBe(2);
+
+                expect(firstRowCells.first().text()).toBe("4  –  6");
+
+                const countCell = firstRowCells.last();
+                expect(countCell.text()).toBe("2");
+                click(countCell.children().first());
+
+                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
+                await delay(150);
+
+                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
+
+                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
+
+                // Should reset to auto binning
+                const breakoutWidget = qb.find(BreakoutWidget).first();
+                expect(breakoutWidget.text()).toBe("Total: Auto binned");
+
+                // Expecting to see the correct lineage (just a simple sanity check)
+                const title = qb.find(QueryHeader).find("h1")
+                expect(title.text()).toBe("New question")
+            })
+
+            it("works for Count of rows aggregation and FK State breakout", async () => {
+                const {store, qb} = await initQbWithOrdersTable();
+                await store.dispatch(setDatasetQuery({
+                    database: 1,
+                    type: 'query',
+                    query: {
+                        source_table: 1,
+                        breakout: [['fk->', 7, 19]],
+                        aggregation: [['count']]
+                    }
+                }));
+
+                click(qb.find(RunButton));
+                await store.waitForActions([QUERY_COMPLETED]);
+
+                const table = qb.find(TestTable);
+                const firstRowCells = table.find("tbody tr").first().find("td");
+                expect(firstRowCells.length).toBe(2);
+
+                expect(firstRowCells.first().text()).toBe("AA");
+
+                const countCell = firstRowCells.last();
+                expect(countCell.text()).toBe("233");
+                click(countCell.children().first());
+
+                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
+                await delay(150);
+
+                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
+
+                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
+
+                // Should reset to auto binning
+                const breakoutWidgets = qb.find(BreakoutWidget);
+                expect(breakoutWidgets.length).toBe(3);
+                expect(breakoutWidgets.at(0).text()).toBe("Latitude: 1°");
+                expect(breakoutWidgets.at(1).text()).toBe("Longitude: 1°");
+
+                // Should have visualization type set to Pin map (temporary workaround until we have polished heat maps)
+                const card = getCard(store.getState())
+                expect(card.display).toBe("map");
+                expect(card.visualization_settings).toEqual({ "map.type": "pin" });
+            });
+
+            it("works for Count of rows aggregation and FK Latitude Auto binned breakout", async () => {
+                const {store, qb} = await initQbWithOrdersTable();
+                await store.dispatch(setDatasetQuery({
+                    database: 1,
+                    type: 'query',
+                    query: {
+                        source_table: 1,
+                        breakout: [["binning-strategy", ['fk->', 7, 14], "default"]],
+                        aggregation: [['count']]
+                    }
+                }));
+
+                click(qb.find(RunButton));
+                await store.waitForActions([QUERY_COMPLETED]);
+
+                const table = qb.find(TestTable);
+                const firstRowCells = table.find("tbody tr").first().find("td");
+                expect(firstRowCells.length).toBe(2);
+
+                expect(firstRowCells.first().text()).toBe("90° S  –  80° S");
+
+                const countCell = firstRowCells.last();
+                expect(countCell.text()).toBe("701");
+                click(countCell.children().first());
+
+                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
+                await delay(150);
+
+                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
+
+                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
+
+                // Should reset to auto binning
+                const breakoutWidgets = qb.find(BreakoutWidget);
+                expect(breakoutWidgets.length).toBe(2);
+
+                // Default location binning strategy currently has a bin width of 10° so
+                expect(breakoutWidgets.at(0).text()).toBe("Latitude: 1°");
+
+                // Should have visualization type set to the previous visualization
+                const card = getCard(store.getState())
+                expect(card.display).toBe("bar");
+            });
+        })
+    })
+});
diff --git a/frontend/test/query_builder/qb_editor_bar.integ.spec.js b/frontend/test/query_builder/qb_editor_bar.integ.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..e5b9bed5f610dffaca08ad9a62193c17e05ecd75
--- /dev/null
+++ b/frontend/test/query_builder/qb_editor_bar.integ.spec.js
@@ -0,0 +1,397 @@
+import {
+    useSharedAdminLogin,
+    createTestStore
+} from "__support__/integrated_tests";
+import {
+    click,
+    clickButton, setInputValue
+} from "__support__/enzyme_utils"
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    INITIALIZE_QB,
+    QUERY_COMPLETED,
+    SET_DATASET_QUERY,
+    setQueryDatabase,
+    setQuerySourceTable,
+} from "metabase/query_builder/actions";
+
+import {
+    FETCH_TABLE_METADATA,
+} from "metabase/redux/metadata";
+
+import FieldList, { DimensionPicker } from "metabase/query_builder/components/FieldList";
+import FilterPopover from "metabase/query_builder/components/filters/FilterPopover";
+
+import CheckBox from "metabase/components/CheckBox";
+import FilterWidget from "metabase/query_builder/components/filters/FilterWidget";
+import FieldName from "metabase/query_builder/components/FieldName";
+import RunButton from "metabase/query_builder/components/RunButton";
+
+import OperatorSelector from "metabase/query_builder/components/filters/OperatorSelector";
+import BreakoutWidget from "metabase/query_builder/components/BreakoutWidget";
+import { getQueryResults } from "metabase/query_builder/selectors";
+
+import * as Urls from "metabase/lib/urls";
+
+const initQbWithDbAndTable = (dbId, tableId) => {
+    return async () => {
+        const store = await createTestStore()
+        store.pushPath(Urls.plainQuestion());
+        const qb = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB]);
+
+        // Use Products table
+        store.dispatch(setQueryDatabase(dbId));
+        store.dispatch(setQuerySourceTable(tableId));
+        await store.waitForActions([FETCH_TABLE_METADATA]);
+
+        return { store, qb }
+    }
+}
+
+const initQbWithOrdersTable = initQbWithDbAndTable(1, 1)
+const initQBWithReviewsTable = initQbWithDbAndTable(1, 4)
+
+describe("QueryBuilder editor bar", () => {
+    beforeAll(async () => {
+        useSharedAdminLogin()
+    })
+
+    describe("for filtering by Rating category field in Reviews table", () =>  {
+        let store = null;
+        let qb = null;
+        beforeAll(async () => {
+            ({ store, qb } = await initQBWithReviewsTable());
+        })
+
+        // NOTE: Sequential tests; these may fail in a cascading way but shouldn't affect other tests
+
+        it("lets you add Rating field as a filter", async () => {
+            // TODO Atte Keinänen 7/13/17: Extracting GuiQueryEditor's contents to smaller React components
+            // would make testing with selectors more natural
+            const filterSection = qb.find('.GuiBuilder-filtered-by');
+            const addFilterButton = filterSection.find('.AddButton');
+            click(addFilterButton);
+
+            const filterPopover = filterSection.find(FilterPopover);
+
+            const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="Rating"]')
+            expect(ratingFieldButton.length).toBe(1);
+            click(ratingFieldButton);
+        })
+
+        it("lets you see its field values in filter popover", () => {
+            // Same as before applies to FilterPopover too: individual list items could be in their own components
+            const filterPopover = qb.find(FilterPopover);
+            const fieldItems = filterPopover.find('li');
+            expect(fieldItems.length).toBe(5);
+
+            // should be in alphabetical order
+            expect(fieldItems.first().text()).toBe("1")
+            expect(fieldItems.last().text()).toBe("5")
+        })
+
+        it("lets you set 'Rating is 5' filter", async () => {
+            const filterPopover = qb.find(FilterPopover);
+            const fieldItems = filterPopover.find('li');
+            const widgetFieldItem = fieldItems.last();
+            const widgetCheckbox = widgetFieldItem.find(CheckBox);
+
+            expect(widgetCheckbox.props().checked).toBe(false);
+            click(widgetFieldItem.children().first());
+            expect(widgetCheckbox.props().checked).toBe(true);
+
+            const addFilterButton = filterPopover.find('button[children="Add filter"]')
+            clickButton(addFilterButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            expect(qb.find(FilterPopover).length).toBe(0);
+            const filterWidget = qb.find(FilterWidget);
+            expect(filterWidget.length).toBe(1);
+            expect(filterWidget.text()).toBe("Rating is equal to5");
+        })
+
+        it("lets you set 'Rating is 5 or 4' filter", async () => {
+            // reopen the filter popover by clicking filter widget
+            const filterWidget = qb.find(FilterWidget);
+            click(filterWidget.find(FieldName));
+
+            const filterPopover = qb.find(FilterPopover);
+            const fieldItems = filterPopover.find('li');
+            const widgetFieldItem = fieldItems.at(3);
+            const gadgetCheckbox = widgetFieldItem.find(CheckBox);
+
+            expect(gadgetCheckbox.props().checked).toBe(false);
+            click(widgetFieldItem.children().first());
+            expect(gadgetCheckbox.props().checked).toBe(true);
+
+            const addFilterButton = filterPopover.find('button[children="Update filter"]')
+            clickButton(addFilterButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            expect(qb.find(FilterPopover).length).toBe(0);
+            expect(filterWidget.text()).toBe("Rating is equal to2 selections");
+        })
+
+        it("lets you remove the added filter", async () => {
+            const filterWidget = qb.find(FilterWidget);
+            click(filterWidget.find(".Icon-close"))
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            expect(qb.find(FilterWidget).length).toBe(0);
+        })
+    })
+
+    describe("for filtering by ID number field in Reviews table", () => {
+        let store = null;
+        let qb = null;
+        beforeAll(async () => {
+            ({ store, qb } = await initQBWithReviewsTable());
+        })
+
+        it("lets you add ID field as a filter", async () => {
+            const filterSection = qb.find('.GuiBuilder-filtered-by');
+            const addFilterButton = filterSection.find('.AddButton');
+            click(addFilterButton);
+
+            const filterPopover = filterSection.find(FilterPopover);
+
+            const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="ID"]')
+            expect(ratingFieldButton.length).toBe(1);
+            click(ratingFieldButton)
+        })
+
+        it("lets you see a correct number of operators in filter popover", () => {
+            const filterPopover = qb.find(FilterPopover);
+
+            const operatorSelector = filterPopover.find(OperatorSelector);
+            const moreOptionsIcon = operatorSelector.find(".Icon-chevrondown");
+            click(moreOptionsIcon);
+
+            expect(operatorSelector.find("button").length).toBe(9)
+        })
+
+        it("lets you set 'ID is 10' filter", async () => {
+            const filterPopover = qb.find(FilterPopover);
+            const filterInput = filterPopover.find("textarea");
+            setInputValue(filterInput, "10")
+
+            const addFilterButton = filterPopover.find('button[children="Add filter"]')
+            clickButton(addFilterButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            expect(qb.find(FilterPopover).length).toBe(0);
+            const filterWidget = qb.find(FilterWidget);
+            expect(filterWidget.length).toBe(1);
+            expect(filterWidget.text()).toBe("ID is equal to10");
+        })
+
+        it("lets you update the filter to 'ID is 10 or 11'", async () => {
+            const filterWidget = qb.find(FilterWidget);
+            click(filterWidget.find(FieldName))
+
+            const filterPopover = qb.find(FilterPopover);
+            const filterInput = filterPopover.find("textarea");
+
+            // Intentionally use a value with lots of extra spaces
+            setInputValue(filterInput, "  10,      11")
+
+            const addFilterButton = filterPopover.find('button[children="Update filter"]')
+            clickButton(addFilterButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            expect(qb.find(FilterPopover).length).toBe(0);
+            expect(filterWidget.text()).toBe("ID is equal to2 selections");
+        });
+
+        it("lets you update the filter to 'ID is between 1 or 100'", async () => {
+            const filterWidget = qb.find(FilterWidget);
+            click(filterWidget.find(FieldName))
+
+            const filterPopover = qb.find(FilterPopover);
+            const operatorSelector = filterPopover.find(OperatorSelector);
+            clickButton(operatorSelector.find('button[children="Between"]'));
+
+            const betweenInputs = filterPopover.find("textarea");
+            expect(betweenInputs.length).toBe(2);
+
+            expect(betweenInputs.at(0).props().value).toBe("10, 11");
+
+            setInputValue(betweenInputs.at(1), "asdasd")
+            const updateFilterButton = filterPopover.find('button[children="Update filter"]')
+            expect(updateFilterButton.props().className).toMatch(/disabled/);
+
+            setInputValue(betweenInputs.at(0), "1")
+            setInputValue(betweenInputs.at(1), "100")
+
+            clickButton(updateFilterButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+            expect(qb.find(FilterPopover).length).toBe(0);
+            expect(filterWidget.text()).toBe("ID between1100");
+        });
+    })
+
+    describe("for grouping by Total in Orders table", async () => {
+        let store = null;
+        let qb = null;
+        beforeAll(async () => {
+            ({ store, qb } = await initQbWithOrdersTable());
+        })
+
+        it("lets you group by Total with the default binning option", async () => {
+            const breakoutSection = qb.find('.GuiBuilder-groupedBy');
+            const addBreakoutButton = breakoutSection.find('.AddButton');
+            click(addBreakoutButton);
+
+            const breakoutPopover = breakoutSection.find("#BreakoutPopover")
+            const subtotalFieldButton = breakoutPopover.find(FieldList).find('h4[children="Total"]')
+            expect(subtotalFieldButton.length).toBe(1);
+            click(subtotalFieldButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            const breakoutWidget = qb.find(BreakoutWidget).first();
+            expect(breakoutWidget.text()).toBe("Total: Auto binned");
+        });
+        it("produces correct results for default binning option", async () => {
+            // Run the raw data query
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            // We can use the visible row count as we have a low number of result rows
+            expect(qb.find(".ShownRowCount").text()).toBe("Showing 14 rows");
+
+            // Get the binning
+            const results = getQueryResults(store.getState())[0]
+            const breakoutBinningInfo = results.data.cols[0].binning_info;
+            expect(breakoutBinningInfo.binning_strategy).toBe("num-bins");
+            expect(breakoutBinningInfo.bin_width).toBe(20);
+            expect(breakoutBinningInfo.num_bins).toBe(8);
+        })
+        it("lets you change the binning strategy to 100 bins", async () => {
+            const breakoutWidget = qb.find(BreakoutWidget).first();
+            click(breakoutWidget.find(FieldName).children().first())
+            const breakoutPopover = qb.find("#BreakoutPopover")
+
+            const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="Auto binned"]')
+            expect(subtotalFieldButton.length).toBe(1);
+            click(subtotalFieldButton)
+
+            click(qb.find(DimensionPicker).find('a[children="100 bins"]'));
+
+            await store.waitForActions([SET_DATASET_QUERY])
+            expect(breakoutWidget.text()).toBe("Total: 100 bins");
+        });
+        it("produces correct results for 100 bins", async () => {
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            expect(qb.find(".ShownRowCount").text()).toBe("Showing 253 rows");
+            const results = getQueryResults(store.getState())[0]
+            const breakoutBinningInfo = results.data.cols[0].binning_info;
+            expect(breakoutBinningInfo.binning_strategy).toBe("num-bins");
+            expect(breakoutBinningInfo.bin_width).toBe(1);
+            expect(breakoutBinningInfo.num_bins).toBe(100);
+        })
+        it("lets you disable the binning", async () => {
+            const breakoutWidget = qb.find(BreakoutWidget).first();
+            click(breakoutWidget.find(FieldName).children().first())
+            const breakoutPopover = qb.find("#BreakoutPopover")
+
+            const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="100 bins"]')
+            expect(subtotalFieldButton.length).toBe(1);
+            click(subtotalFieldButton);
+
+            click(qb.find(DimensionPicker).find('a[children="Don\'t bin"]'));
+        });
+        it("produces the expected count of rows when no binning", async () => {
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            // We just want to see that there are a lot more rows than there would be if a binning was active
+            expect(qb.find(".ShownRowCount").text()).toBe("Showing first 2,000 rows");
+
+            const results = getQueryResults(store.getState())[0]
+            expect(results.data.cols[0].binning_info).toBe(undefined);
+        });
+    })
+
+    describe("for grouping by Latitude location field through Users FK in Orders table", async () => {
+        let store = null;
+        let qb = null;
+        beforeAll(async () => {
+            ({ store, qb } = await initQbWithOrdersTable());
+        })
+
+        it("lets you group by Latitude with the default binning option", async () => {
+            const breakoutSection = qb.find('.GuiBuilder-groupedBy');
+            const addBreakoutButton = breakoutSection.find('.AddButton');
+            click(addBreakoutButton);
+
+            const breakoutPopover = breakoutSection.find("#BreakoutPopover")
+
+            const userSectionButton = breakoutPopover.find(FieldList).find('h3[children="User"]')
+            expect(userSectionButton.length).toBe(1);
+            click(userSectionButton);
+
+            const subtotalFieldButton = breakoutPopover.find(FieldList).find('h4[children="Latitude"]')
+            expect(subtotalFieldButton.length).toBe(1);
+            click(subtotalFieldButton);
+
+            await store.waitForActions([SET_DATASET_QUERY])
+
+            const breakoutWidget = qb.find(BreakoutWidget).first();
+            expect(breakoutWidget.text()).toBe("Latitude: Auto binned");
+        });
+
+        it("produces correct results for default binning option", async () => {
+            // Run the raw data query
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            expect(qb.find(".ShownRowCount").text()).toBe("Showing 18 rows");
+
+            const results = getQueryResults(store.getState())[0]
+            const breakoutBinningInfo = results.data.cols[0].binning_info;
+            expect(breakoutBinningInfo.binning_strategy).toBe("bin-width");
+            expect(breakoutBinningInfo.bin_width).toBe(10);
+            expect(breakoutBinningInfo.num_bins).toBe(18);
+        })
+
+        it("lets you group by Latitude with the 'Bin every 1 degree'", async () => {
+            const breakoutWidget = qb.find(BreakoutWidget).first();
+            click(breakoutWidget.find(FieldName).children().first())
+            const breakoutPopover = qb.find("#BreakoutPopover")
+
+            const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="Auto binned"]')
+            expect(subtotalFieldButton.length).toBe(1);
+            click(subtotalFieldButton);
+
+            click(qb.find(DimensionPicker).find('a[children="Bin every 1 degree"]'));
+
+            await store.waitForActions([SET_DATASET_QUERY])
+            expect(breakoutWidget.text()).toBe("Latitude: 1°");
+        });
+        it("produces correct results for 'Bin every 1 degree'", async () => {
+            // Run the raw data query
+            click(qb.find(RunButton));
+            await store.waitForActions([QUERY_COMPLETED]);
+
+            expect(qb.find(".ShownRowCount").text()).toBe("Showing 180 rows");
+
+            const results = getQueryResults(store.getState())[0]
+            const breakoutBinningInfo = results.data.cols[0].binning_info;
+            expect(breakoutBinningInfo.binning_strategy).toBe("bin-width");
+            expect(breakoutBinningInfo.bin_width).toBe(1);
+            expect(breakoutBinningInfo.num_bins).toBe(180);
+        })
+    });
+});
diff --git a/frontend/test/query_builder/qb_question_states.integ.spec.js b/frontend/test/query_builder/qb_question_states.integ.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..f3db44c242b2f648cf52a2900962480396b16868
--- /dev/null
+++ b/frontend/test/query_builder/qb_question_states.integ.spec.js
@@ -0,0 +1,173 @@
+import {
+    useSharedAdminLogin,
+    whenOffline,
+    createSavedQuestion,
+    createTestStore
+} from "__support__/integrated_tests";
+import {
+    click,
+    clickButton
+} from "__support__/enzyme_utils"
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    INITIALIZE_QB,
+    QUERY_COMPLETED,
+    QUERY_ERRORED,
+    RUN_QUERY,
+    CANCEL_QUERY,
+    API_UPDATE_QUESTION
+} from "metabase/query_builder/actions";
+import { SET_ERROR_PAGE } from "metabase/redux/app";
+
+import QueryHeader from "metabase/query_builder/components/QueryHeader";
+import { VisualizationEmptyState } from "metabase/query_builder/components/QueryVisualization";
+
+import RunButton from "metabase/query_builder/components/RunButton";
+
+import Visualization from "metabase/visualizations/components/Visualization";
+
+import {
+    ORDERS_TOTAL_FIELD_ID,
+    unsavedOrderCountQuestion
+} from "__support__/sample_dataset_fixture";
+import VisualizationError from "metabase/query_builder/components/VisualizationError";
+import SaveQuestionModal from "metabase/containers/SaveQuestionModal";
+import Radio from "metabase/components/Radio";
+import QuestionSavedModal from "metabase/components/QuestionSavedModal";
+
+describe("QueryBuilder", () => {
+    beforeAll(async () => {
+        useSharedAdminLogin()
+    })
+    describe("for saved questions", async () => {
+        let savedQuestion = null;
+        beforeAll(async () => {
+            savedQuestion = await createSavedQuestion(unsavedOrderCountQuestion)
+        })
+
+        it("renders normally on page load", async () => {
+            const store = await createTestStore()
+            store.pushPath(savedQuestion.getUrl(savedQuestion));
+            const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+
+            await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
+            expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe(savedQuestion.displayName())
+        });
+        it("shows an error page if the server is offline", async () => {
+            const store = await createTestStore()
+
+            await whenOffline(async () => {
+                store.pushPath(savedQuestion.getUrl());
+                mount(store.connectContainer(<QueryBuilder />));
+                // only test here that the error page action is dispatched
+                // (it is set on the root level of application React tree)
+                await store.waitForActions([INITIALIZE_QB, SET_ERROR_PAGE]);
+            })
+        })
+        it("doesn't execute the query if user cancels it", async () => {
+            const store = await createTestStore()
+            store.pushPath(savedQuestion.getUrl());
+            const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+            await store.waitForActions([INITIALIZE_QB, RUN_QUERY]);
+
+            const runButton = qbWrapper.find(RunButton);
+            expect(runButton.text()).toBe("Cancel");
+            click(runButton);
+
+            await store.waitForActions([CANCEL_QUERY, QUERY_ERRORED]);
+            expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe(savedQuestion.displayName())
+            expect(qbWrapper.find(VisualizationEmptyState).length).toBe(1)
+        })
+    });
+
+    describe("for dirty questions", async () => {
+        describe("without original saved question", () => {
+            it("renders normally on page load", async () => {
+                const store = await createTestStore()
+                store.pushPath(unsavedOrderCountQuestion.getUrl());
+                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
+
+                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
+                expect(qbWrapper.find(Visualization).length).toBe(1)
+            });
+            it("fails with a proper error message if the query is invalid", async () => {
+                const invalidQuestion = unsavedOrderCountQuestion.query()
+                    .addBreakout(["datetime-field", ["field-id", 12345], "day"])
+                    .question();
+
+                const store = await createTestStore()
+                store.pushPath(invalidQuestion.getUrl());
+                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
+
+                // TODO: How to get rid of the delay? There is asynchronous initialization in some of VisualizationError parent components
+                // Making the delay shorter causes Jest test runner to crash, see https://stackoverflow.com/a/44075568
+                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
+                expect(qbWrapper.find(VisualizationError).length).toBe(1)
+                expect(qbWrapper.find(VisualizationError).text().includes("There was a problem with your question")).toBe(true)
+            });
+            it("fails with a proper error message if the server is offline", async () => {
+                const store = await createTestStore()
+
+                await whenOffline(async () => {
+                    store.pushPath(unsavedOrderCountQuestion.getUrl());
+                    const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+                    await store.waitForActions([INITIALIZE_QB, QUERY_ERRORED]);
+
+                    expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
+                    expect(qbWrapper.find(VisualizationError).length).toBe(1)
+                    expect(qbWrapper.find(VisualizationError).text().includes("We're experiencing server issues")).toBe(true)
+                })
+            })
+            it("doesn't execute the query if user cancels it", async () => {
+                const store = await createTestStore()
+                store.pushPath(unsavedOrderCountQuestion.getUrl());
+                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+                await store.waitForActions([INITIALIZE_QB, RUN_QUERY]);
+
+                const runButton = qbWrapper.find(RunButton);
+                expect(runButton.text()).toBe("Cancel");
+                click(runButton);
+
+                await store.waitForActions([CANCEL_QUERY, QUERY_ERRORED]);
+                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
+                expect(qbWrapper.find(VisualizationEmptyState).length).toBe(1)
+            })
+        })
+        describe("with original saved question", () => {
+            it("should let you replace the original question", async () => {
+                const store = await createTestStore()
+                const savedQuestion = await createSavedQuestion(unsavedOrderCountQuestion);
+
+                const dirtyQuestion = savedQuestion
+                    .query()
+                    .addBreakout(["field-id", ORDERS_TOTAL_FIELD_ID])
+                    .question()
+
+                store.pushPath(dirtyQuestion.getUrl(savedQuestion));
+                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
+                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
+
+                const title = qbWrapper.find(QueryHeader).find("h1")
+                expect(title.text()).toBe("New question")
+                expect(title.parent().children().at(1).text()).toBe(`started from ${savedQuestion.displayName()}`)
+
+                // Click "SAVE" button
+                click(qbWrapper.find(".Header-buttonSection a").first().find("a"))
+
+                expect(qbWrapper.find(SaveQuestionModal).find(Radio).prop("value")).toBe("overwrite")
+                // Click Save in "Save question" dialog
+                clickButton(qbWrapper.find(SaveQuestionModal).find("button").last());
+                await store.waitForActions([API_UPDATE_QUESTION])
+
+                // Should not show a "add to dashboard" dialog in this case
+                // This is included because of regression #6541
+                expect(qbWrapper.find(QuestionSavedModal).length).toBe(0)
+            });
+        });
+    });
+});
diff --git a/frontend/test/query_builder/qb_remapping.js b/frontend/test/query_builder/qb_remapping.js
new file mode 100644
index 0000000000000000000000000000000000000000..0befbca831f478e715b97c103c20cc78ffb8937d
--- /dev/null
+++ b/frontend/test/query_builder/qb_remapping.js
@@ -0,0 +1,184 @@
+import {
+    useSharedAdminLogin,
+    createTestStore
+} from "__support__/integrated_tests";
+import {
+    click,
+    clickButton
+} from "__support__/enzyme_utils"
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    INITIALIZE_QB,
+    QUERY_COMPLETED,
+    SET_DATASET_QUERY,
+    setQueryDatabase,
+    setQuerySourceTable,
+} from "metabase/query_builder/actions";
+
+import {
+    deleteFieldDimension,
+    updateFieldDimension,
+    updateFieldValues,
+    FETCH_TABLE_METADATA,
+} from "metabase/redux/metadata";
+
+import FieldList  from "metabase/query_builder/components/FieldList";
+import FilterPopover from "metabase/query_builder/components/filters/FilterPopover";
+
+import CheckBox from "metabase/components/CheckBox";
+import FilterWidget from "metabase/query_builder/components/filters/FilterWidget";
+import RunButton from "metabase/query_builder/components/RunButton";
+
+import { TestTable } from "metabase/visualizations/visualizations/Table";
+
+import * as Urls from "metabase/lib/urls";
+
+const REVIEW_PRODUCT_ID = 32;
+const REVIEW_RATING_ID = 33;
+const PRODUCT_TITLE_ID = 27;
+
+const initQbWithDbAndTable = (dbId, tableId) => {
+    return async () => {
+        const store = await createTestStore()
+        store.pushPath(Urls.plainQuestion());
+        const qb = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB]);
+
+        // Use Products table
+        store.dispatch(setQueryDatabase(dbId));
+        store.dispatch(setQuerySourceTable(tableId));
+        await store.waitForActions([FETCH_TABLE_METADATA]);
+
+        return { store, qb }
+    }
+}
+
+const initQBWithReviewsTable = initQbWithDbAndTable(1, 4)
+
+describe("QueryBuilder", () => {
+    beforeAll(async () => {
+        useSharedAdminLogin()
+    })
+
+    describe("remapping", () => {
+        beforeAll(async () => {
+            // add remappings
+            const store = await createTestStore()
+
+            // NOTE Atte Keinänen 8/7/17:
+            // We test here the full dimension functionality which lets you enter a dimension name that differs
+            // from the field name. This is something that field settings UI doesn't let you to do yet.
+
+            await store.dispatch(updateFieldDimension(REVIEW_PRODUCT_ID, {
+                type: "external",
+                name: "Product Name",
+                human_readable_field_id: PRODUCT_TITLE_ID
+            }));
+
+            await store.dispatch(updateFieldDimension(REVIEW_RATING_ID, {
+                type: "internal",
+                name: "Rating Description",
+                human_readable_field_id: null
+            }));
+            await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [
+                [1, 'Awful'], [2, 'Unpleasant'], [3, 'Meh'], [4, 'Enjoyable'], [5, 'Perfecto']
+            ]));
+        })
+
+        describe("for Rating category field with custom field values", () => {
+            // The following test case is very similar to earlier filter tests but in this case we use remapped values
+            it("lets you add 'Rating is Perfecto' filter", async () => {
+                const { store, qb } = await initQBWithReviewsTable();
+
+                // open filter popover
+                const filterSection = qb.find('.GuiBuilder-filtered-by');
+                const newFilterButton = filterSection.find('.AddButton');
+                click(newFilterButton);
+
+                // choose the field to be filtered
+                const filterPopover = filterSection.find(FilterPopover);
+                const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="Rating Description"]')
+                expect(ratingFieldButton.length).toBe(1);
+                click(ratingFieldButton)
+
+                // check that field values seem correct
+                const fieldItems = filterPopover.find('li');
+                expect(fieldItems.length).toBe(5);
+                expect(fieldItems.first().text()).toBe("Awful")
+                expect(fieldItems.last().text()).toBe("Perfecto")
+
+                // select the last item (Perfecto)
+                const widgetFieldItem = fieldItems.last();
+                const widgetCheckbox = widgetFieldItem.find(CheckBox);
+                expect(widgetCheckbox.props().checked).toBe(false);
+                click(widgetFieldItem.children().first());
+                expect(widgetCheckbox.props().checked).toBe(true);
+
+                // add the filter
+                const addFilterButton = filterPopover.find('button[children="Add filter"]')
+                clickButton(addFilterButton);
+
+                await store.waitForActions([SET_DATASET_QUERY])
+
+                // validate the filter text value
+                expect(qb.find(FilterPopover).length).toBe(0);
+                const filterWidget = qb.find(FilterWidget);
+                expect(filterWidget.length).toBe(1);
+                expect(filterWidget.text()).toBe("Rating Description is equal toPerfecto");
+            })
+
+            it("shows remapped value correctly in Raw Data query with Table visualization", async () => {
+                const { store, qb } = await initQBWithReviewsTable();
+
+                clickButton(qb.find(RunButton));
+                await store.waitForActions([QUERY_COMPLETED]);
+
+                const table = qb.find(TestTable);
+                const headerCells = table.find("thead tr").first().find("th");
+                const firstRowCells = table.find("tbody tr").first().find("td");
+
+                expect(headerCells.length).toBe(6)
+                expect(headerCells.at(4).text()).toBe("Rating Description")
+
+                expect(firstRowCells.length).toBe(6);
+
+                expect(firstRowCells.at(4).text()).toBe("Perfecto");
+            })
+        });
+
+        describe("for Product ID FK field with a FK remapping", () => {
+            it("shows remapped values correctly in Raw Data query with Table visualization", async () => {
+                const { store, qb } = await initQBWithReviewsTable();
+
+                clickButton(qb.find(RunButton));
+                await store.waitForActions([QUERY_COMPLETED]);
+
+                const table = qb.find(TestTable);
+                const headerCells = table.find("thead tr").first().find("th");
+                const firstRowCells = table.find("tbody tr").first().find("td");
+
+                expect(headerCells.length).toBe(6)
+                expect(headerCells.at(3).text()).toBe("Product Name")
+
+                expect(firstRowCells.length).toBe(6);
+
+                expect(firstRowCells.at(3).text()).toBe("Awesome Wooden Pants");
+            })
+        });
+
+        afterAll(async () => {
+            const store = await createTestStore()
+
+            await store.dispatch(deleteFieldDimension(REVIEW_PRODUCT_ID));
+            await store.dispatch(deleteFieldDimension(REVIEW_RATING_ID));
+
+            await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [
+                [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5']
+            ]));
+        })
+
+    })
+});
diff --git a/frontend/test/query_builder/qb_visualizations.integ.spec.js b/frontend/test/query_builder/qb_visualizations.integ.spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..214526a514af74aec5ba242b888736a453f8b71a
--- /dev/null
+++ b/frontend/test/query_builder/qb_visualizations.integ.spec.js
@@ -0,0 +1,104 @@
+import {
+    useSharedAdminLogin,
+    createTestStore, createSavedQuestion
+} from "__support__/integrated_tests";
+import { click, clickButton, setInputValue } from "__support__/enzyme_utils";
+
+import React from 'react';
+import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
+import { mount } from "enzyme";
+import {
+    API_CREATE_QUESTION,
+    API_UPDATE_QUESTION,
+    INITIALIZE_QB,
+    QUERY_COMPLETED, SET_CARD_VISUALIZATION,
+} from "metabase/query_builder/actions";
+
+import Question from "metabase-lib/lib/Question";
+import { getCard, getQuestion } from "metabase/query_builder/selectors";
+import SaveQuestionModal from "metabase/containers/SaveQuestionModal";
+import Radio from "metabase/components/Radio";
+import { LOAD_COLLECTIONS } from "metabase/questions/collections";
+import { CardApi } from "metabase/services";
+import * as Urls from "metabase/lib/urls";
+import VisualizationSettings from "metabase/query_builder/components/VisualizationSettings";
+import { TestPopover } from "metabase/components/Popover";
+
+const timeBreakoutQuestion = Question.create({databaseId: 1, tableId: 1, metadata: null})
+    .query()
+    .addAggregation(["count"])
+    .addBreakout(["datetime-field", ["field-id", 1], "day"])
+    .question()
+    .setDisplay("line")
+    .setDisplayName("Time breakout question")
+
+describe("Query Builder visualization logic", () => {
+    let questionId = null
+    let savedTimeBreakoutQuestion = null
+
+    beforeAll(async () => {
+        useSharedAdminLogin()
+        savedTimeBreakoutQuestion = await createSavedQuestion(timeBreakoutQuestion)
+    })
+
+    it("should save the default x axis and y axis to `visualization_settings` when saving a new question in QB", async () => {
+        const store = await createTestStore()
+        store.pushPath(timeBreakoutQuestion.getUrl());
+        const app = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB]);
+
+        expect(getCard(store.getState()).visualization_settings).toEqual({})
+
+        await store.waitForActions([QUERY_COMPLETED]);
+        expect(getCard(store.getState()).visualization_settings).toEqual({})
+
+        // Click "SAVE" button
+        click(app.find(".Header-buttonSection a").first().find("a"))
+
+        await store.waitForActions([LOAD_COLLECTIONS]);
+
+        setInputValue(app.find(SaveQuestionModal).find("input[name='name']"), "test visualization question");
+        clickButton(app.find(SaveQuestionModal).find("button").last());
+        await store.waitForActions([API_CREATE_QUESTION]);
+
+        expect(getCard(store.getState()).visualization_settings).toEqual({
+            "graph.dimensions": ["CREATED_AT"],
+            "graph.metrics": ["count"]
+        })
+
+        questionId = getQuestion(store.getState()).id()
+    });
+
+    it("should save the default x axis and y axis to `visualization_settings` when saving a new question in QB", async () => {
+        const store = await createTestStore()
+        store.pushPath(Urls.question(savedTimeBreakoutQuestion.id()));
+        const app = mount(store.connectContainer(<QueryBuilder />));
+        await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
+        expect(getCard(store.getState()).visualization_settings).toEqual({})
+
+        // modify the question in the UI by switching visualization type
+        const vizSettings = app.find(VisualizationSettings)
+        const vizSettingsTrigger = vizSettings.find("a").first()
+        click(vizSettingsTrigger)
+        const areaChartOption = vizSettings.find(TestPopover).find('span').filterWhere((elem) => /Area/.test(elem.text()))
+        click(areaChartOption)
+        await store.waitForActions([SET_CARD_VISUALIZATION])
+
+        click(app.find(".Header-buttonSection a").first().find("a"))
+        expect(app.find(SaveQuestionModal).find(Radio).prop("value")).toBe("overwrite")
+        // Click Save in "Save question" dialog
+        clickButton(app.find(SaveQuestionModal).find("button").last());
+        await store.waitForActions([API_UPDATE_QUESTION])
+
+        expect(getCard(store.getState()).visualization_settings).toEqual({
+            "graph.dimensions": ["CREATED_AT"],
+            "graph.metrics": ["count"]
+        })
+    });
+    afterAll(async () => {
+        if (questionId) {
+            await CardApi.delete({cardId: questionId})
+            await CardApi.delete({cardId: savedTimeBreakoutQuestion.id()})
+        }
+    })
+});
diff --git a/frontend/test/query_builder/query_builder.integ.spec.js b/frontend/test/query_builder/query_builder.integ.spec.js
deleted file mode 100644
index 91c5d50aedeba6c6b71afbabf1ea9481094c5dad..0000000000000000000000000000000000000000
--- a/frontend/test/query_builder/query_builder.integ.spec.js
+++ /dev/null
@@ -1,851 +0,0 @@
-import {
-    useSharedAdminLogin,
-    whenOffline,
-    createSavedQuestion,
-    createTestStore
-} from "__support__/integrated_tests";
-import {
-    click,
-    clickButton, setInputValue
-} from "__support__/enzyme_utils"
-
-import React from 'react';
-import QueryBuilder from "metabase/query_builder/containers/QueryBuilder";
-import { mount } from "enzyme";
-import {
-    INITIALIZE_QB,
-    QUERY_COMPLETED,
-    QUERY_ERRORED,
-    RUN_QUERY,
-    CANCEL_QUERY,
-    SET_DATASET_QUERY,
-    setQueryDatabase,
-    setQuerySourceTable,
-    setDatasetQuery,
-    NAVIGATE_TO_NEW_CARD,
-    UPDATE_URL,
-    NOTIFY_CARD_UPDATED
-} from "metabase/query_builder/actions";
-import { SET_ERROR_PAGE } from "metabase/redux/app";
-
-import QueryHeader from "metabase/query_builder/components/QueryHeader";
-import { VisualizationEmptyState } from "metabase/query_builder/components/QueryVisualization";
-import {
-    deleteFieldDimension,
-    updateFieldDimension,
-    updateFieldValues,
-    FETCH_TABLE_METADATA,
-} from "metabase/redux/metadata";
-
-import FieldList, { DimensionPicker } from "metabase/query_builder/components/FieldList";
-import FilterPopover from "metabase/query_builder/components/filters/FilterPopover";
-
-import CheckBox from "metabase/components/CheckBox";
-import FilterWidget from "metabase/query_builder/components/filters/FilterWidget";
-import FieldName from "metabase/query_builder/components/FieldName";
-import RunButton from "metabase/query_builder/components/RunButton";
-
-import VisualizationSettings from "metabase/query_builder/components/VisualizationSettings";
-import Visualization from "metabase/visualizations/components/Visualization";
-import TableSimple from "metabase/visualizations/components/TableSimple";
-
-import {
-    ORDERS_TOTAL_FIELD_ID,
-    unsavedOrderCountQuestion
-} from "__support__/sample_dataset_fixture";
-import VisualizationError from "metabase/query_builder/components/VisualizationError";
-import OperatorSelector from "metabase/query_builder/components/filters/OperatorSelector";
-import BreakoutWidget from "metabase/query_builder/components/BreakoutWidget";
-import { getCard, getQueryResults } from "metabase/query_builder/selectors";
-import { TestTable } from "metabase/visualizations/visualizations/Table";
-import ChartClickActions from "metabase/visualizations/components/ChartClickActions";
-
-import { delay } from "metabase/lib/promise";
-import * as Urls from "metabase/lib/urls";
-import SaveQuestionModal from "metabase/containers/SaveQuestionModal";
-import Radio from "metabase/components/Radio";
-import QuestionSavedModal from "metabase/components/QuestionSavedModal";
-
-const REVIEW_PRODUCT_ID = 32;
-const REVIEW_RATING_ID = 33;
-const PRODUCT_TITLE_ID = 27;
-
-const initQbWithDbAndTable = (dbId, tableId) => {
-    return async () => {
-        const store = await createTestStore()
-        store.pushPath(Urls.plainQuestion());
-        const qb = mount(store.connectContainer(<QueryBuilder />));
-        await store.waitForActions([INITIALIZE_QB]);
-
-        // Use Products table
-        store.dispatch(setQueryDatabase(dbId));
-        store.dispatch(setQuerySourceTable(tableId));
-        await store.waitForActions([FETCH_TABLE_METADATA]);
-
-        return { store, qb }
-    }
-}
-
-const initQbWithOrdersTable = initQbWithDbAndTable(1, 1)
-const initQBWithReviewsTable = initQbWithDbAndTable(1, 4)
-
-describe("QueryBuilder", () => {
-    beforeAll(async () => {
-        useSharedAdminLogin()
-    })
-
-    describe("visualization settings", () => {
-        it("lets you hide a field for a raw data table", async () => {
-            const { store, qb } = await initQBWithReviewsTable();
-
-            // Run the raw data query
-            click(qb.find(RunButton));
-            await store.waitForActions([QUERY_COMPLETED]);
-
-            const vizSettings = qb.find(VisualizationSettings);
-            click(vizSettings.find(".Icon-gear"));
-
-            const settingsModal = vizSettings.find(".test-modal")
-            const table = settingsModal.find(TableSimple);
-
-            expect(table.find('div[children="Created At"]').length).toBe(1);
-
-            const doneButton = settingsModal.find(".Button--primary")
-            expect(doneButton.length).toBe(1)
-
-            const fieldsToIncludeCheckboxes = settingsModal.find(CheckBox)
-            expect(fieldsToIncludeCheckboxes.length).toBe(7)
-
-            click(fieldsToIncludeCheckboxes.filterWhere((checkbox) => checkbox.parent().find("span").text() === "Created At"))
-
-            expect(table.find('div[children="Created At"]').length).toBe(0);
-
-            // Save the settings
-            click(doneButton);
-            expect(vizSettings.find(".test-modal").length).toBe(0);
-
-            // Don't test the contents of actual table visualization here as react-virtualized doesn't seem to work
-            // very well together with Enzyme
-        })
-    })
-
-    describe("for saved questions", async () => {
-        let savedQuestion = null;
-        beforeAll(async () => {
-            savedQuestion = await createSavedQuestion(unsavedOrderCountQuestion)
-        })
-
-        it("renders normally on page load", async () => {
-            const store = await createTestStore()
-            store.pushPath(savedQuestion.getUrl(savedQuestion));
-            const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-
-            await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
-            expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe(savedQuestion.displayName())
-        });
-        it("shows an error page if the server is offline", async () => {
-            const store = await createTestStore()
-
-            await whenOffline(async () => {
-                store.pushPath(savedQuestion.getUrl());
-                mount(store.connectContainer(<QueryBuilder />));
-                // only test here that the error page action is dispatched
-                // (it is set on the root level of application React tree)
-                await store.waitForActions([INITIALIZE_QB, SET_ERROR_PAGE]);
-            })
-        })
-        it("doesn't execute the query if user cancels it", async () => {
-            const store = await createTestStore()
-            store.pushPath(savedQuestion.getUrl());
-            const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-            await store.waitForActions([INITIALIZE_QB, RUN_QUERY]);
-
-            const runButton = qbWrapper.find(RunButton);
-            expect(runButton.text()).toBe("Cancel");
-            click(runButton);
-
-            await store.waitForActions([CANCEL_QUERY, QUERY_ERRORED]);
-            expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe(savedQuestion.displayName())
-            expect(qbWrapper.find(VisualizationEmptyState).length).toBe(1)
-        })
-    });
-
-
-    describe("for dirty questions", async () => {
-        describe("without original saved question", () => {
-            it("renders normally on page load", async () => {
-                const store = await createTestStore()
-                store.pushPath(unsavedOrderCountQuestion.getUrl());
-                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
-
-                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
-                expect(qbWrapper.find(Visualization).length).toBe(1)
-            });
-            it("fails with a proper error message if the query is invalid", async () => {
-                const invalidQuestion = unsavedOrderCountQuestion.query()
-                    .addBreakout(["datetime-field", ["field-id", 12345], "day"])
-                    .question();
-
-                const store = await createTestStore()
-                store.pushPath(invalidQuestion.getUrl());
-                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
-
-                // TODO: How to get rid of the delay? There is asynchronous initialization in some of VisualizationError parent components
-                // Making the delay shorter causes Jest test runner to crash, see https://stackoverflow.com/a/44075568
-                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
-                expect(qbWrapper.find(VisualizationError).length).toBe(1)
-                expect(qbWrapper.find(VisualizationError).text().includes("There was a problem with your question")).toBe(true)
-            });
-            it("fails with a proper error message if the server is offline", async () => {
-                const store = await createTestStore()
-
-                await whenOffline(async () => {
-                    store.pushPath(unsavedOrderCountQuestion.getUrl());
-                    const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-                    await store.waitForActions([INITIALIZE_QB, QUERY_ERRORED]);
-
-                    expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
-                    expect(qbWrapper.find(VisualizationError).length).toBe(1)
-                    expect(qbWrapper.find(VisualizationError).text().includes("We're experiencing server issues")).toBe(true)
-                })
-            })
-            it("doesn't execute the query if user cancels it", async () => {
-                const store = await createTestStore()
-                store.pushPath(unsavedOrderCountQuestion.getUrl());
-                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-                await store.waitForActions([INITIALIZE_QB, RUN_QUERY]);
-
-                const runButton = qbWrapper.find(RunButton);
-                expect(runButton.text()).toBe("Cancel");
-                click(runButton);
-
-                await store.waitForActions([CANCEL_QUERY, QUERY_ERRORED]);
-                expect(qbWrapper.find(QueryHeader).find("h1").text()).toBe("New question")
-                expect(qbWrapper.find(VisualizationEmptyState).length).toBe(1)
-            })
-        })
-        describe("with original saved question", () => {
-            it("should let you replace the original question", async () => {
-                const store = await createTestStore()
-                const savedQuestion = await createSavedQuestion(unsavedOrderCountQuestion);
-
-                const dirtyQuestion = savedQuestion
-                    .query()
-                    .addBreakout(["field-id", ORDERS_TOTAL_FIELD_ID])
-                    .question()
-
-                store.pushPath(dirtyQuestion.getUrl(savedQuestion));
-                const qbWrapper = mount(store.connectContainer(<QueryBuilder />));
-                await store.waitForActions([INITIALIZE_QB, QUERY_COMPLETED]);
-
-                const title = qbWrapper.find(QueryHeader).find("h1")
-                expect(title.text()).toBe("New question")
-                expect(title.parent().children().at(1).text()).toBe(`started from ${savedQuestion.displayName()}`)
-
-                // Click "SAVE" button
-                click(qbWrapper.find(".Header-buttonSection a").first().find("a"))
-
-                expect(qbWrapper.find(SaveQuestionModal).find(Radio).prop("value")).toBe("overwrite")
-                // Click Save in "Save question" dialog
-                clickButton(qbWrapper.find(SaveQuestionModal).find("button").last());
-                await store.waitForActions([NOTIFY_CARD_UPDATED])
-
-                // Should not show a "add to dashboard" dialog in this case
-                // This is included because of regression #6541
-                expect(qbWrapper.find(QuestionSavedModal).length).toBe(0)
-            });
-        });
-    });
-
-    describe("editor bar", async() => {
-        describe("for filtering by Rating category field in Reviews table", () =>  {
-            let store = null;
-            let qb = null;
-            beforeAll(async () => {
-                ({ store, qb } = await initQBWithReviewsTable());
-            })
-
-            // NOTE: Sequential tests; these may fail in a cascading way but shouldn't affect other tests
-
-            it("lets you add Rating field as a filter", async () => {
-                // TODO Atte Keinänen 7/13/17: Extracting GuiQueryEditor's contents to smaller React components
-                // would make testing with selectors more natural
-                const filterSection = qb.find('.GuiBuilder-filtered-by');
-                const addFilterButton = filterSection.find('.AddButton');
-                click(addFilterButton);
-
-                const filterPopover = filterSection.find(FilterPopover);
-
-                const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="Rating"]')
-                expect(ratingFieldButton.length).toBe(1);
-                click(ratingFieldButton);
-            })
-
-            it("lets you see its field values in filter popover", () => {
-                // Same as before applies to FilterPopover too: individual list items could be in their own components
-                const filterPopover = qb.find(FilterPopover);
-                const fieldItems = filterPopover.find('li');
-                expect(fieldItems.length).toBe(5);
-
-                // should be in alphabetical order
-                expect(fieldItems.first().text()).toBe("1")
-                expect(fieldItems.last().text()).toBe("5")
-            })
-
-            it("lets you set 'Rating is 5' filter", async () => {
-                const filterPopover = qb.find(FilterPopover);
-                const fieldItems = filterPopover.find('li');
-                const widgetFieldItem = fieldItems.last();
-                const widgetCheckbox = widgetFieldItem.find(CheckBox);
-
-                expect(widgetCheckbox.props().checked).toBe(false);
-                click(widgetFieldItem.children().first());
-                expect(widgetCheckbox.props().checked).toBe(true);
-
-                const addFilterButton = filterPopover.find('button[children="Add filter"]')
-                clickButton(addFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                expect(qb.find(FilterPopover).length).toBe(0);
-                const filterWidget = qb.find(FilterWidget);
-                expect(filterWidget.length).toBe(1);
-                expect(filterWidget.text()).toBe("Rating is equal to5");
-            })
-
-            it("lets you set 'Rating is 5 or 4' filter", async () => {
-                // reopen the filter popover by clicking filter widget
-                const filterWidget = qb.find(FilterWidget);
-                click(filterWidget.find(FieldName));
-
-                const filterPopover = qb.find(FilterPopover);
-                const fieldItems = filterPopover.find('li');
-                const widgetFieldItem = fieldItems.at(3);
-                const gadgetCheckbox = widgetFieldItem.find(CheckBox);
-
-                expect(gadgetCheckbox.props().checked).toBe(false);
-                click(widgetFieldItem.children().first());
-                expect(gadgetCheckbox.props().checked).toBe(true);
-
-                const addFilterButton = filterPopover.find('button[children="Update filter"]')
-                clickButton(addFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                expect(qb.find(FilterPopover).length).toBe(0);
-                expect(filterWidget.text()).toBe("Rating is equal to2 selections");
-            })
-
-            it("lets you remove the added filter", async () => {
-                const filterWidget = qb.find(FilterWidget);
-                click(filterWidget.find(".Icon-close"))
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                expect(qb.find(FilterWidget).length).toBe(0);
-            })
-        })
-
-        describe("for filtering by ID number field in Reviews table", () => {
-            let store = null;
-            let qb = null;
-            beforeAll(async () => {
-                ({ store, qb } = await initQBWithReviewsTable());
-            })
-
-            it("lets you add ID field as a filter", async () => {
-                const filterSection = qb.find('.GuiBuilder-filtered-by');
-                const addFilterButton = filterSection.find('.AddButton');
-                click(addFilterButton);
-
-                const filterPopover = filterSection.find(FilterPopover);
-
-                const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="ID"]')
-                expect(ratingFieldButton.length).toBe(1);
-                click(ratingFieldButton)
-            })
-
-            it("lets you see a correct number of operators in filter popover", () => {
-                const filterPopover = qb.find(FilterPopover);
-
-                const operatorSelector = filterPopover.find(OperatorSelector);
-                const moreOptionsIcon = operatorSelector.find(".Icon-chevrondown");
-                click(moreOptionsIcon);
-
-                expect(operatorSelector.find("button").length).toBe(9)
-            })
-
-            it("lets you set 'ID is 10' filter", async () => {
-                const filterPopover = qb.find(FilterPopover);
-                const filterInput = filterPopover.find("textarea");
-                setInputValue(filterInput, "10")
-
-                const addFilterButton = filterPopover.find('button[children="Add filter"]')
-                clickButton(addFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                expect(qb.find(FilterPopover).length).toBe(0);
-                const filterWidget = qb.find(FilterWidget);
-                expect(filterWidget.length).toBe(1);
-                expect(filterWidget.text()).toBe("ID is equal to10");
-            })
-
-            it("lets you update the filter to 'ID is 10 or 11'", async () => {
-                const filterWidget = qb.find(FilterWidget);
-                click(filterWidget.find(FieldName))
-
-                const filterPopover = qb.find(FilterPopover);
-                const filterInput = filterPopover.find("textarea");
-
-                // Intentionally use a value with lots of extra spaces
-                setInputValue(filterInput, "  10,      11")
-
-                const addFilterButton = filterPopover.find('button[children="Update filter"]')
-                clickButton(addFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                expect(qb.find(FilterPopover).length).toBe(0);
-                expect(filterWidget.text()).toBe("ID is equal to2 selections");
-            });
-
-            it("lets you update the filter to 'ID is between 1 or 100'", async () => {
-                const filterWidget = qb.find(FilterWidget);
-                click(filterWidget.find(FieldName))
-
-                const filterPopover = qb.find(FilterPopover);
-                const operatorSelector = filterPopover.find(OperatorSelector);
-                clickButton(operatorSelector.find('button[children="Between"]'));
-
-                const betweenInputs = filterPopover.find("textarea");
-                expect(betweenInputs.length).toBe(2);
-
-                expect(betweenInputs.at(0).props().value).toBe("10, 11");
-
-                setInputValue(betweenInputs.at(1), "asdasd")
-                const updateFilterButton = filterPopover.find('button[children="Update filter"]')
-                expect(updateFilterButton.props().className).toMatch(/disabled/);
-
-                setInputValue(betweenInputs.at(0), "1")
-                setInputValue(betweenInputs.at(1), "100")
-
-                clickButton(updateFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-                expect(qb.find(FilterPopover).length).toBe(0);
-                expect(filterWidget.text()).toBe("ID between1100");
-            });
-        })
-
-        describe("for grouping by Total in Orders table", async () => {
-            let store = null;
-            let qb = null;
-            beforeAll(async () => {
-                ({ store, qb } = await initQbWithOrdersTable());
-            })
-
-            it("lets you group by Total with the default binning option", async () => {
-                const breakoutSection = qb.find('.GuiBuilder-groupedBy');
-                const addBreakoutButton = breakoutSection.find('.AddButton');
-                click(addBreakoutButton);
-
-                const breakoutPopover = breakoutSection.find("#BreakoutPopover")
-                const subtotalFieldButton = breakoutPopover.find(FieldList).find('h4[children="Total"]')
-                expect(subtotalFieldButton.length).toBe(1);
-                click(subtotalFieldButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                expect(breakoutWidget.text()).toBe("Total: Auto binned");
-            });
-            it("produces correct results for default binning option", async () => {
-                // Run the raw data query
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                // We can use the visible row count as we have a low number of result rows
-                expect(qb.find(".ShownRowCount").text()).toBe("Showing 14 rows");
-
-                // Get the binning
-                const results = getQueryResults(store.getState())[0]
-                const breakoutBinningInfo = results.data.cols[0].binning_info;
-                expect(breakoutBinningInfo.binning_strategy).toBe("num-bins");
-                expect(breakoutBinningInfo.bin_width).toBe(20);
-                expect(breakoutBinningInfo.num_bins).toBe(8);
-            })
-            it("lets you change the binning strategy to 100 bins", async () => {
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                click(breakoutWidget.find(FieldName).children().first())
-                const breakoutPopover = qb.find("#BreakoutPopover")
-
-                const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="Auto binned"]')
-                expect(subtotalFieldButton.length).toBe(1);
-                click(subtotalFieldButton)
-
-                click(qb.find(DimensionPicker).find('a[children="100 bins"]'));
-
-                await store.waitForActions([SET_DATASET_QUERY])
-                expect(breakoutWidget.text()).toBe("Total: 100 bins");
-            });
-            it("produces correct results for 100 bins", async () => {
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                expect(qb.find(".ShownRowCount").text()).toBe("Showing 253 rows");
-                const results = getQueryResults(store.getState())[0]
-                const breakoutBinningInfo = results.data.cols[0].binning_info;
-                expect(breakoutBinningInfo.binning_strategy).toBe("num-bins");
-                expect(breakoutBinningInfo.bin_width).toBe(1);
-                expect(breakoutBinningInfo.num_bins).toBe(100);
-            })
-            it("lets you disable the binning", async () => {
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                click(breakoutWidget.find(FieldName).children().first())
-                const breakoutPopover = qb.find("#BreakoutPopover")
-
-                const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="100 bins"]')
-                expect(subtotalFieldButton.length).toBe(1);
-                click(subtotalFieldButton);
-
-                click(qb.find(DimensionPicker).find('a[children="Don\'t bin"]'));
-            });
-            it("produces the expected count of rows when no binning", async () => {
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                // We just want to see that there are a lot more rows than there would be if a binning was active
-                expect(qb.find(".ShownRowCount").text()).toBe("Showing first 2,000 rows");
-
-                const results = getQueryResults(store.getState())[0]
-                expect(results.data.cols[0].binning_info).toBe(undefined);
-            });
-        })
-
-        describe("for grouping by Latitude location field through Users FK in Orders table", async () => {
-            let store = null;
-            let qb = null;
-            beforeAll(async () => {
-                ({ store, qb } = await initQbWithOrdersTable());
-            })
-
-            it("lets you group by Latitude with the default binning option", async () => {
-                const breakoutSection = qb.find('.GuiBuilder-groupedBy');
-                const addBreakoutButton = breakoutSection.find('.AddButton');
-                click(addBreakoutButton);
-
-                const breakoutPopover = breakoutSection.find("#BreakoutPopover")
-
-                const userSectionButton = breakoutPopover.find(FieldList).find('h3[children="User"]')
-                expect(userSectionButton.length).toBe(1);
-                click(userSectionButton);
-
-                const subtotalFieldButton = breakoutPopover.find(FieldList).find('h4[children="Latitude"]')
-                expect(subtotalFieldButton.length).toBe(1);
-                click(subtotalFieldButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                expect(breakoutWidget.text()).toBe("Latitude: Auto binned");
-            });
-
-            it("produces correct results for default binning option", async () => {
-                // Run the raw data query
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                expect(qb.find(".ShownRowCount").text()).toBe("Showing 18 rows");
-
-                const results = getQueryResults(store.getState())[0]
-                const breakoutBinningInfo = results.data.cols[0].binning_info;
-                expect(breakoutBinningInfo.binning_strategy).toBe("bin-width");
-                expect(breakoutBinningInfo.bin_width).toBe(10);
-                expect(breakoutBinningInfo.num_bins).toBe(18);
-            })
-
-            it("lets you group by Latitude with the 'Bin every 1 degree'", async () => {
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                click(breakoutWidget.find(FieldName).children().first())
-                const breakoutPopover = qb.find("#BreakoutPopover")
-
-                const subtotalFieldButton = breakoutPopover.find(FieldList).find('.List-item--selected h4[children="Auto binned"]')
-                expect(subtotalFieldButton.length).toBe(1);
-                click(subtotalFieldButton);
-
-                click(qb.find(DimensionPicker).find('a[children="Bin every 1 degree"]'));
-
-                await store.waitForActions([SET_DATASET_QUERY])
-                expect(breakoutWidget.text()).toBe("Latitude: 1°");
-            });
-            it("produces correct results for 'Bin every 1 degree'", async () => {
-                // Run the raw data query
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                expect(qb.find(".ShownRowCount").text()).toBe("Showing 180 rows");
-
-                const results = getQueryResults(store.getState())[0]
-                const breakoutBinningInfo = results.data.cols[0].binning_info;
-                expect(breakoutBinningInfo.binning_strategy).toBe("bin-width");
-                expect(breakoutBinningInfo.bin_width).toBe(1);
-                expect(breakoutBinningInfo.num_bins).toBe(180);
-            })
-        });
-    })
-
-    describe("drill-through", () => {
-        describe("Zoom In action for broken out fields", () => {
-            it("works for Count of rows aggregation and Subtotal 50 Bins breakout", async () => {
-                const {store, qb} = await initQbWithOrdersTable();
-                await store.dispatch(setDatasetQuery({
-                    database: 1,
-                    type: 'query',
-                    query: {
-                        source_table: 1,
-                        breakout: [['binning-strategy', ['field-id', 6], 'num-bins', 50]],
-                        aggregation: [['count']]
-                    }
-                }));
-
-
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                const table = qb.find(TestTable);
-                const firstRowCells = table.find("tbody tr").first().find("td");
-                expect(firstRowCells.length).toBe(2);
-
-                expect(firstRowCells.first().text()).toBe("4  –  6");
-
-                const countCell = firstRowCells.last();
-                expect(countCell.text()).toBe("2");
-                click(countCell.children().first());
-
-                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
-                await delay(150);
-
-                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
-
-                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
-
-                // Should reset to auto binning
-                const breakoutWidget = qb.find(BreakoutWidget).first();
-                expect(breakoutWidget.text()).toBe("Total: Auto binned");
-
-                // Expecting to see the correct lineage (just a simple sanity check)
-                const title = qb.find(QueryHeader).find("h1")
-                expect(title.text()).toBe("New question")
-            })
-
-            it("works for Count of rows aggregation and FK State breakout", async () => {
-                const {store, qb} = await initQbWithOrdersTable();
-                await store.dispatch(setDatasetQuery({
-                    database: 1,
-                    type: 'query',
-                    query: {
-                        source_table: 1,
-                        breakout: [['fk->', 7, 19]],
-                        aggregation: [['count']]
-                    }
-                }));
-
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                const table = qb.find(TestTable);
-                const firstRowCells = table.find("tbody tr").first().find("td");
-                expect(firstRowCells.length).toBe(2);
-
-                expect(firstRowCells.first().text()).toBe("AA");
-
-                const countCell = firstRowCells.last();
-                expect(countCell.text()).toBe("233");
-                click(countCell.children().first());
-
-                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
-                await delay(150);
-
-                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
-
-                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
-
-                // Should reset to auto binning
-                const breakoutWidgets = qb.find(BreakoutWidget);
-                expect(breakoutWidgets.length).toBe(3);
-                expect(breakoutWidgets.at(0).text()).toBe("Latitude: 1°");
-                expect(breakoutWidgets.at(1).text()).toBe("Longitude: 1°");
-
-                // Should have visualization type set to Pin map (temporary workaround until we have polished heat maps)
-                const card = getCard(store.getState())
-                expect(card.display).toBe("map");
-                expect(card.visualization_settings).toEqual({ "map.type": "pin" });
-            });
-
-            it("works for Count of rows aggregation and FK Latitude Auto binned breakout", async () => {
-                const {store, qb} = await initQbWithOrdersTable();
-                await store.dispatch(setDatasetQuery({
-                    database: 1,
-                    type: 'query',
-                    query: {
-                        source_table: 1,
-                        breakout: [["binning-strategy", ['fk->', 7, 14], "default"]],
-                        aggregation: [['count']]
-                    }
-                }));
-
-                click(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                const table = qb.find(TestTable);
-                const firstRowCells = table.find("tbody tr").first().find("td");
-                expect(firstRowCells.length).toBe(2);
-
-                expect(firstRowCells.first().text()).toBe("90° S  –  80° S");
-
-                const countCell = firstRowCells.last();
-                expect(countCell.text()).toBe("701");
-                click(countCell.children().first());
-
-                // Drill-through is delayed in handleVisualizationClick of Visualization.jsx by 100ms
-                await delay(150);
-
-                click(qb.find(ChartClickActions).find('div[children="Zoom in"]'));
-
-                store.waitForActions([NAVIGATE_TO_NEW_CARD, UPDATE_URL, QUERY_COMPLETED]);
-
-                // Should reset to auto binning
-                const breakoutWidgets = qb.find(BreakoutWidget);
-                expect(breakoutWidgets.length).toBe(2);
-
-                // Default location binning strategy currently has a bin width of 10° so
-                expect(breakoutWidgets.at(0).text()).toBe("Latitude: 1°");
-
-                // Should have visualization type set to the previous visualization
-                const card = getCard(store.getState())
-                expect(card.display).toBe("bar");
-            });
-        })
-    })
-
-    describe("remapping", () => {
-        beforeAll(async () => {
-            // add remappings
-            const store = await createTestStore()
-
-            // NOTE Atte Keinänen 8/7/17:
-            // We test here the full dimension functionality which lets you enter a dimension name that differs
-            // from the field name. This is something that field settings UI doesn't let you to do yet.
-
-            await store.dispatch(updateFieldDimension(REVIEW_PRODUCT_ID, {
-                type: "external",
-                name: "Product Name",
-                human_readable_field_id: PRODUCT_TITLE_ID
-            }));
-
-            await store.dispatch(updateFieldDimension(REVIEW_RATING_ID, {
-                type: "internal",
-                name: "Rating Description",
-                human_readable_field_id: null
-            }));
-            await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [
-                [1, 'Awful'], [2, 'Unpleasant'], [3, 'Meh'], [4, 'Enjoyable'], [5, 'Perfecto']
-            ]));
-        })
-
-        describe("for Rating category field with custom field values", () => {
-            // The following test case is very similar to earlier filter tests but in this case we use remapped values
-            it("lets you add 'Rating is Perfecto' filter", async () => {
-                const { store, qb } = await initQBWithReviewsTable();
-
-                // open filter popover
-                const filterSection = qb.find('.GuiBuilder-filtered-by');
-                const newFilterButton = filterSection.find('.AddButton');
-                click(newFilterButton);
-
-                // choose the field to be filtered
-                const filterPopover = filterSection.find(FilterPopover);
-                const ratingFieldButton = filterPopover.find(FieldList).find('h4[children="Rating Description"]')
-                expect(ratingFieldButton.length).toBe(1);
-                click(ratingFieldButton)
-
-                // check that field values seem correct
-                const fieldItems = filterPopover.find('li');
-                expect(fieldItems.length).toBe(5);
-                expect(fieldItems.first().text()).toBe("Awful")
-                expect(fieldItems.last().text()).toBe("Perfecto")
-
-                // select the last item (Perfecto)
-                const widgetFieldItem = fieldItems.last();
-                const widgetCheckbox = widgetFieldItem.find(CheckBox);
-                expect(widgetCheckbox.props().checked).toBe(false);
-                click(widgetFieldItem.children().first());
-                expect(widgetCheckbox.props().checked).toBe(true);
-
-                // add the filter
-                const addFilterButton = filterPopover.find('button[children="Add filter"]')
-                clickButton(addFilterButton);
-
-                await store.waitForActions([SET_DATASET_QUERY])
-
-                // validate the filter text value
-                expect(qb.find(FilterPopover).length).toBe(0);
-                const filterWidget = qb.find(FilterWidget);
-                expect(filterWidget.length).toBe(1);
-                expect(filterWidget.text()).toBe("Rating Description is equal toPerfecto");
-            })
-
-            it("shows remapped value correctly in Raw Data query with Table visualization", async () => {
-                const { store, qb } = await initQBWithReviewsTable();
-
-                clickButton(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                const table = qb.find(TestTable);
-                const headerCells = table.find("thead tr").first().find("th");
-                const firstRowCells = table.find("tbody tr").first().find("td");
-
-                expect(headerCells.length).toBe(6)
-                expect(headerCells.at(4).text()).toBe("Rating Description")
-
-                expect(firstRowCells.length).toBe(6);
-
-                expect(firstRowCells.at(4).text()).toBe("Perfecto");
-            })
-        });
-
-        describe("for Product ID FK field with a FK remapping", () => {
-            it("shows remapped values correctly in Raw Data query with Table visualization", async () => {
-                const { store, qb } = await initQBWithReviewsTable();
-
-                clickButton(qb.find(RunButton));
-                await store.waitForActions([QUERY_COMPLETED]);
-
-                const table = qb.find(TestTable);
-                const headerCells = table.find("thead tr").first().find("th");
-                const firstRowCells = table.find("tbody tr").first().find("td");
-
-                expect(headerCells.length).toBe(6)
-                expect(headerCells.at(3).text()).toBe("Product Name")
-
-                expect(firstRowCells.length).toBe(6);
-
-                expect(firstRowCells.at(3).text()).toBe("Awesome Wooden Pants");
-            })
-        });
-
-        afterAll(async () => {
-            const store = await createTestStore()
-
-            await store.dispatch(deleteFieldDimension(REVIEW_PRODUCT_ID));
-            await store.dispatch(deleteFieldDimension(REVIEW_RATING_ID));
-
-            await store.dispatch(updateFieldValues(REVIEW_RATING_ID, [
-                [1, '1'], [2, '2'], [3, '3'], [4, '4'], [5, '5']
-            ]));
-        })
-
-    })
-});
diff --git a/frontend/test/visualizations/components/ObjectDetail.unit.test.js b/frontend/test/visualizations/components/ObjectDetail.unit.spec.js
similarity index 86%
rename from frontend/test/visualizations/components/ObjectDetail.unit.test.js
rename to frontend/test/visualizations/components/ObjectDetail.unit.spec.js
index 4a12ce01708cc958b99de34a27d645f7e7b235ab..a0d793654b7e9b1cd34441c69ec0b0a5c9f38e30 100644
--- a/frontend/test/visualizations/components/ObjectDetail.unit.test.js
+++ b/frontend/test/visualizations/components/ObjectDetail.unit.spec.js
@@ -1,7 +1,11 @@
 import React from 'react'
 import { mount } from 'enzyme'
-import { ObjectDetail } from 'metabase/visualizations/visualizations/ObjectDetail'
 
+// Needed due to wrong dependency resolution order
+// eslint-disable-next-line no-unused-vars
+import "metabase/visualizations/components/Visualization";
+
+import { ObjectDetail } from 'metabase/visualizations/visualizations/ObjectDetail'
 import { TYPE } from "metabase/lib/types";
 
 const objectDetailCard = {
@@ -25,6 +29,7 @@ const objectDetailCard = {
 describe('ObjectDetail', () => {
     describe('json field rendering', () => {
         it('should properly display JSON special type data as JSON', () => {
+
             const detail = mount(
                 <ObjectDetail
                     data={objectDetailCard.data}
diff --git a/frontend/test/visualizations/components/Visualization.integ.spec.js b/frontend/test/visualizations/components/Visualization.integ.spec.js
index da9db7dbb7617bb67cbd6251cab4327e6e1d595b..5fec943175502b44bee22ab46600b39423289462 100644
--- a/frontend/test/visualizations/components/Visualization.integ.spec.js
+++ b/frontend/test/visualizations/components/Visualization.integ.spec.js
@@ -30,22 +30,22 @@ describe("Visualization", () => {
     describe("not in dashboard", () => {
         describe("scalar card", () => {
             it("should not render title", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo")] });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo")] });
                 expect(getScalarTitles(viz)).toEqual([]);
             });
         });
 
         describe("line card", () => {
             it("should not render card title", () => {
-                let viz = renderVisualization({ series: [LineCard("Foo")] });
+                let viz = renderVisualization({ rawSeries: [LineCard("Foo")] });
                 expect(getTitles(viz)).toEqual([]);
             });
             it("should not render setting title", () => {
-                let viz = renderVisualization({ series: [LineCard("Foo", { card: { visualization_settings: { "card.title": "Foo_title" }}})] });
+                let viz = renderVisualization({ rawSeries: [LineCard("Foo", { card: { visualization_settings: { "card.title": "Foo_title" }}})] });
                 expect(getTitles(viz)).toEqual([]);
             });
             it("should render breakout multiseries titles", () => {
-                let viz = renderVisualization({ series: [MultiseriesLineCard("Foo")] });
+                let viz = renderVisualization({ rawSeries: [MultiseriesLineCard("Foo")] });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_cat1", "Foo_cat2"]
                 ]);
@@ -56,28 +56,28 @@ describe("Visualization", () => {
     describe("in dashboard", () => {
         describe("scalar card", () => {
             it("should render a scalar title, not a legend title", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo")], showTitle: true, isDashboard: true });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo")], showTitle: true, isDashboard: true });
                 expect(getTitles(viz)).toEqual([]);
                 expect(getScalarTitles(viz).length).toEqual(1);
             });
             it("should render title when loading", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo", { data: null })], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo", { data: null })], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name"]
                 ]);
             });
             it("should render title when there's an error", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo")], showTitle: true, error: "oops" });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo")], showTitle: true, error: "oops" });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name"]
                 ]);
             });
             it("should not render scalar title", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo")], showTitle: true });
                 expect(getTitles(viz)).toEqual([]);
             });
             it("should render multi scalar titles", () => {
-                let viz = renderVisualization({ series: [ScalarCard("Foo"), ScalarCard("Bar")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [ScalarCard("Foo"), ScalarCard("Bar")], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name", "Bar_name"]
                 ]);
@@ -86,26 +86,26 @@ describe("Visualization", () => {
 
         describe("line card", () => {
             it("should render normal title", () => {
-                let viz = renderVisualization({ series: [LineCard("Foo")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [LineCard("Foo")], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name"]
                 ]);
             });
             it("should render normal title and breakout multiseries titles", () => {
-                let viz = renderVisualization({ series: [MultiseriesLineCard("Foo")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [MultiseriesLineCard("Foo")], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name"],
                     ["Foo_cat1", "Foo_cat2"]
                 ]);
             });
             it("should render dashboard multiseries titles", () => {
-                let viz = renderVisualization({ series: [LineCard("Foo"), LineCard("Bar")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [LineCard("Foo"), LineCard("Bar")], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name", "Bar_name"]
                 ]);
             });
             it("should render dashboard multiseries titles and chart setting title", () => {
-                let viz = renderVisualization({ series: [
+                let viz = renderVisualization({ rawSeries: [
                     LineCard("Foo", { card: { visualization_settings: { "card.title": "Foo_title" }}}),
                     LineCard("Bar")
                 ], showTitle: true });
@@ -115,7 +115,7 @@ describe("Visualization", () => {
                 ]);
             });
             it("should render multiple breakout multiseries titles (with both card titles and breakout values)", () => {
-                let viz = renderVisualization({ series: [MultiseriesLineCard("Foo"), MultiseriesLineCard("Bar")], showTitle: true });
+                let viz = renderVisualization({ rawSeries: [MultiseriesLineCard("Foo"), MultiseriesLineCard("Bar")], showTitle: true });
                 expect(getTitles(viz)).toEqual([
                     ["Foo_name: Foo_cat1", "Foo_name: Foo_cat2", "Bar_name: Bar_cat1", "Bar_name: Bar_cat2"]
                 ]);
@@ -125,7 +125,7 @@ describe("Visualization", () => {
         describe("text card", () => {
             describe("when not editing", () => {
                 it("should not render edit and preview actions", () => {
-                    let viz = renderVisualization({ series: [TextCard("Foo")], isEditing: false});
+                    let viz = renderVisualization({ rawSeries: [TextCard("Foo")], isEditing: false});
                     expect(viz.find(".Icon-editdocument").length).toEqual(0);
                     expect(viz.find(".Icon-eye").length).toEqual(0);
                 });
@@ -139,7 +139,7 @@ describe("Visualization", () => {
                             }
                         },
                     });
-                    let viz = renderVisualization({ series: [textCard], isEditing: false});
+                    let viz = renderVisualization({ rawSeries: [textCard], isEditing: false});
                     expect(viz.find("textarea").length).toEqual(0);
                     expect(viz.find(".text-card-markdown").find("h1").length).toEqual(1);
                     expect(viz.find(".text-card-markdown").text()).toEqual("Foobar");
@@ -148,19 +148,19 @@ describe("Visualization", () => {
 
             describe("when editing", () => {
                 it("should render edit and preview actions", () => {
-                    let viz = renderVisualization({ series: [TextCard("Foo")], isEditing: true});
+                    let viz = renderVisualization({ rawSeries: [TextCard("Foo")], isEditing: true});
                     expect(viz.find(".Icon-editdocument").length).toEqual(1);
                     expect(viz.find(".Icon-eye").length).toEqual(1);
                 });
 
                 it("should render in the edit mode", () => {
-                    let viz = renderVisualization({ series: [TextCard("Foo")], isEditing: true});
+                    let viz = renderVisualization({ rawSeries: [TextCard("Foo")], isEditing: true});
                     expect(viz.find("textarea").length).toEqual(1);
                 });
 
                 describe("toggling edit/preview modes", () => {
                     it("should switch between rendered markdown and textarea input", () => {
-                        let viz = renderVisualization({ series: [TextCard("Foo")], isEditing: true});
+                        let viz = renderVisualization({ rawSeries: [TextCard("Foo")], isEditing: true});
                         expect(viz.find("textarea").length).toEqual(1);
                         click(viz.find(".Icon-eye"));
                         expect(viz.find("textarea").length).toEqual(0);
diff --git a/frontend/test/visualizations/drillthroughs.integ.spec.js b/frontend/test/visualizations/drillthroughs.integ.spec.js
index b4de8bcf5545b39f1e823fb95326cc441a40c0da..c4365aa9d05239ab2103830c05918d4d15d49e9d 100644
--- a/frontend/test/visualizations/drillthroughs.integ.spec.js
+++ b/frontend/test/visualizations/drillthroughs.integ.spec.js
@@ -23,7 +23,7 @@ const store = createTestStore()
 const getVisualization = (question, results, onChangeCardAndRun) =>
     store.connectContainer(
         <Visualization
-            series={[{card: question.card(), data: results[0].data}]}
+            rawSeries={[{card: question.card(), data: results[0].data}]}
             onChangeCardAndRun={navigateToNewCardInsideQB}
             metadata={metadata}
         />
@@ -52,7 +52,7 @@ describe('Visualization drill-through', () => {
                 // (we are intentionally simplifying things by not rendering the QB but just focusing the redux state instead)
                 await store.dispatch(initializeQB(urlParse(question.getUrl()), {}))
 
-                const results = await question.getResults();
+                const results = await question.apiGetResults();
                 const viz = shallow(getVisualization(question, results, navigateToNewCardInsideQB));
                 const clickActions = viz.find(ChartClickActions).dive();
 
diff --git a/src/metabase/pulse.clj b/src/metabase/pulse.clj
index c15dc50b6926ccafe116b3638de1993cd391394b..c0d0d3ed0537a30c0d85ac28e3ba192b809d35d5 100644
--- a/src/metabase/pulse.clj
+++ b/src/metabase/pulse.clj
@@ -97,19 +97,19 @@
   (every? is-card-empty? results))
 
 (defn- goal-met? [{:keys [alert_above_goal] :as pulse} results]
-  (let [first-result    (first results)
-        goal-comparison (if alert_above_goal <= >=)
-        comparison-col-index (ui/goal-comparison-column first-result)
-        goal-val (ui/find-goal-value first-result)]
+  (let [first-result         (first results)
+        goal-comparison      (if alert_above_goal <= >=)
+        goal-val             (ui/find-goal-value first-result)
+        comparison-col-rowfn (ui/make-goal-comparison-rowfn (:card first-result)
+                                                            (get-in first-result [:result :data]))]
 
-    (when-not (and goal-val comparison-col-index)
+    (when-not (and goal-val comparison-col-rowfn)
       (throw (Exception. (str (tru "Unable to compare results to goal for alert.")
-                              (tru "Question ID is '{0}' with visualization settings '{1}'"
+                              (tru "Question ID is ''{0}'' with visualization settings ''{1}''"
                                    (get-in results [:card :id])
                                    (pr-str (get-in results [:card :visualization_settings])))))))
-
     (some (fn [row]
-            (goal-comparison goal-val (nth row comparison-col-index)))
+            (goal-comparison goal-val (comparison-col-rowfn row)))
           (get-in first-result [:result :data :rows]))))
 
 (defn- alert-or-pulse [pulse]
diff --git a/src/metabase/pulse/render.clj b/src/metabase/pulse/render.clj
index e899bc286d22ec301129003155fe3e9f5ae6975d..7c5e9f575bc62ead6afe714cabc16c5c560aba96 100644
--- a/src/metabase/pulse/render.clj
+++ b/src/metabase/pulse/render.clj
@@ -10,7 +10,9 @@
             [clojure.tools.logging :as log]
             [hiccup.core :refer [h html]]
             [metabase.util :as u]
-            [metabase.util.urls :as urls]
+            [metabase.util
+             [ui-logic :as ui-logic]
+             [urls :as urls]]
             [puppetlabs.i18n.core :refer [tru trs]]
             [schema.core :as s])
   (:import cz.vutbr.web.css.MediaSpec
@@ -495,30 +497,35 @@
     :attachment {content-id image-url}
     :inline     nil))
 
+(defn- graphing-columns [card {:keys [cols] :as data}]
+  [(or (ui-logic/x-axis-rowfn card data)
+       first)
+   (or (ui-logic/y-axis-rowfn card data)
+       second)])
+
 (s/defn ^:private render:sparkline :- RenderedPulseCard
-  [render-type timezone card {:keys [rows cols]}]
-  (let [ft-row (if (datetime-field? (first cols))
+  [render-type timezone card {:keys [rows cols] :as data}]
+  (let [[x-axis-rowfn y-axis-rowfn] (graphing-columns card data)
+        ft-row (if (datetime-field? (x-axis-rowfn cols))
                  #(.getTime ^Date (u/->Timestamp %))
                  identity)
-        rows   (if (> (ft-row (ffirst rows))
-                      (ft-row (first (last rows))))
+        rows   (if (> (ft-row (x-axis-rowfn (first rows)))
+                      (ft-row (x-axis-rowfn (last rows))))
                  (reverse rows)
                  rows)
-        xs     (for [row  rows
-                     :let [x (first row)]]
-                 (ft-row x))
+        xs     (map (comp ft-row x-axis-rowfn) rows)
         xmin   (apply min xs)
         xmax   (apply max xs)
         xrange (- xmax xmin)
         xs'    (map #(/ (double (- % xmin)) xrange) xs)
-        ys     (map second rows)
+        ys     (map y-axis-rowfn rows)
         ymin   (apply min ys)
         ymax   (apply max ys)
         yrange (max 1 (- ymax ymin))                    ; `(max 1 ...)` so we don't divide by zero
         ys'    (map #(/ (double (- % ymin)) yrange) ys) ; cast to double to avoid "Non-terminating decimal expansion" errors
         rows'  (reverse (take-last 2 rows))
-        values (map (comp format-number second) rows')
-        labels (format-timestamp-pair timezone (map first rows') (first cols))
+        values (map (comp format-number y-axis-rowfn) rows')
+        labels (format-timestamp-pair timezone (map x-axis-rowfn rows') (x-axis-rowfn cols))
         image-bundle (make-image-bundle render-type (render-sparkline-to-png xs' ys' 524 130))]
 
     {:attachments (when image-bundle
@@ -594,11 +601,12 @@
 (defn detect-pulse-card-type
   "Determine the pulse (visualization) type of a CARD, e.g. `:scalar` or `:bar`."
   [card data]
-  (let [col-count (-> data :cols count)
-        row-count (-> data :rows count)
-        col-1 (-> data :cols first)
-        col-2 (-> data :cols second)
-        aggregation (-> card :dataset_query :query :aggregation first)]
+  (let [col-count                 (-> data :cols count)
+        row-count                 (-> data :rows count)
+        [col-1-rowfn col-2-rowfn] (graphing-columns card data)
+        col-1                     (col-1-rowfn (:cols data))
+        col-2                     (col-2-rowfn (:cols data))
+        aggregation               (-> card :dataset_query :query :aggregation first)]
     (cond
       (or (zero? row-count)
           ;; Many aggregations result in [[nil]] if there are no rows to aggregate after filters
diff --git a/src/metabase/util/ui_logic.clj b/src/metabase/util/ui_logic.clj
index 981b04e0b13a9e56a59176206fd867ae918b9cb5..b7dbc35c3ba82d8ddd3583769cd8b6002e27c5c2 100644
--- a/src/metabase/util/ui_logic.clj
+++ b/src/metabase/util/ui_logic.clj
@@ -27,11 +27,8 @@
   "For graphs with goals, this function returns the index of the default column that should be used to compare against
   the goal. This follows the frontend code getDefaultLineAreaBarColumns closely with a slight change (detailed in the
   code)"
-  [results]
-  (let [graph-type (get-in results [:card :display])
-        [col-1 col-2 col-3 :as all-cols] (get-in results [:result :data :cols])
-        cols-count (count all-cols)]
-
+  [{graph-type :display :as card} {[col-1 col-2 col-3 :as all-cols] :cols :as result}]
+  (let [cols-count (count all-cols)]
     (cond
       ;; Progress goals return a single row and column, compare that
       (= :progress graph-type)
@@ -63,19 +60,39 @@
 
 (defn- column-name->index
   "The results seq is seq of vectors, this function returns the index in that vector of the given `COLUMN-NAME`"
-  [results ^String column-name]
-  (when column-name
-    (first (map-indexed (fn [idx column]
-                          (when (.equalsIgnoreCase column-name (:name column))
-                            idx))
-                        (get-in results [:result :data :cols])))))
-
-(defn goal-comparison-column
+  [^String column-name {:keys [cols] :as result}]
+  (first (remove nil? (map-indexed (fn [idx column]
+                                     (when (.equalsIgnoreCase column-name (:name column))
+                                       idx))
+                                   cols))))
+
+(defn- graph-column-index [viz-kwd card results]
+  (when-let [metrics-col-index (some-> card
+                                       (get-in [:visualization_settings viz-kwd])
+                                       first
+                                       (column-name->index results))]
+    (fn [row]
+      (nth row metrics-col-index))))
+
+(defn y-axis-rowfn
+  "This is used as the Y-axis column in the UI"
+  [card results]
+  (graph-column-index :graph.metrics card results))
+
+(defn x-axis-rowfn
+  "This is used as the X-axis column in the UI"
+  [card results]
+  (graph-column-index :graph.dimensions card results))
+
+(defn make-goal-comparison-rowfn
   "For a given resultset, return the index of the column that should be used for the goal comparison. This can come
   from the visualization settings if the column is specified, or from our default column logic"
-  [result]
-  (or (column-name->index result (get-in result [:card :visualization_settings :graph.metrics]))
-      (default-goal-column-index result)))
+  [card result]
+  (if-let [user-specified-rowfn (y-axis-rowfn card result)]
+    user-specified-rowfn
+    (when-let [default-col-index (default-goal-column-index card result)]
+      (fn [row]
+        (nth row default-col-index)))))
 
 (defn find-goal-value
   "The goal value can come from a progress goal or a graph goal_value depending on it's type"
diff --git a/test/metabase/pulse_test.clj b/test/metabase/pulse_test.clj
index a8b244a64d0192f22ad6d3eb071b9fa178cc5003..7c4b06ecfaeddabe54957b1af6a5cd3758befd7e 100644
--- a/test/metabase/pulse_test.clj
+++ b/test/metabase/pulse_test.clj
@@ -228,6 +228,34 @@
      (send-pulse! (retrieve-pulse-or-alert pulse-id))
      (et/summarize-multipart-email #"Test card.*has reached its goal"))))
 
+;; Native query with user-specified x and y axis
+(expect
+  (rasta-alert-email "Metabase alert: Test card has reached its goal"
+                     [{"Test card.*has reached its goal" true}, png-attachment])
+  (tt/with-temp* [Card                  [{card-id :id}  {:name          "Test card"
+                                                         :dataset_query {:database (data/id)
+                                                                         :type     :native
+                                                                         :native   {:query (str "select count(*) as total_per_day, date as the_day "
+                                                                                                "from checkins "
+                                                                                                "group by date")}}
+                                                         :display :line
+                                                         :visualization_settings {:graph.show_goal true
+                                                                                  :graph.goal_value 5.9
+                                                                                  :graph.dimensions ["the_day"]
+                                                                                  :graph.metrics ["total_per_day"]}}]
+                  Pulse                 [{pulse-id :id} {:alert_condition  "goal"
+                                                         :alert_first_only false
+                                                         :alert_above_goal true}]
+                  PulseCard             [_             {:pulse_id pulse-id
+                                                        :card_id  card-id
+                                                        :position 0}]
+                  PulseChannel          [{pc-id :id}   {:pulse_id pulse-id}]
+                  PulseChannelRecipient [_             {:user_id          (rasta-id)
+                                                        :pulse_channel_id pc-id}]]
+    (email-test-setup
+     (send-pulse! (retrieve-pulse-or-alert pulse-id))
+     (et/summarize-multipart-email #"Test card.*has reached its goal"))))
+
 ;; Above goal alert, with no data above goal
 (expect
   {}