From 4495d93f77ee9b83546fd0130ef364257546dab9 Mon Sep 17 00:00:00 2001
From: Paul Rosenzweig <paulrosenzweig@users.noreply.github.com>
Date: Mon, 25 Nov 2019 09:42:49 -0500
Subject: [PATCH] Update auto-viz selection (#11368)

---
 frontend/src/metabase-lib/lib/Question.js     | 54 +++++++++++++
 .../src/metabase/query_builder/actions.js     | 75 +++++++------------
 .../view/sidebars/ChartTypeSidebar.jsx        |  4 +-
 .../src/metabase/query_builder/reducers.js    |  4 +-
 frontend/src/metabase/visualizations/index.js |  6 ++
 .../visualizations/visualizations/Map.jsx     |  5 +-
 .../metabase-lib/lib/Question.unit.spec.js    | 19 +++++
 .../scenarios/query_builder.e2e.spec.js       | 23 +++---
 8 files changed, 130 insertions(+), 60 deletions(-)

diff --git a/frontend/src/metabase-lib/lib/Question.js b/frontend/src/metabase-lib/lib/Question.js
index 26c04217026..e1c10c4f6d4 100644
--- a/frontend/src/metabase-lib/lib/Question.js
+++ b/frontend/src/metabase-lib/lib/Question.js
@@ -276,7 +276,61 @@ export default class Question {
     return this.setCard(assoc(this.card(), "display", display));
   }
 
+  // The selected display is set when the user explicitly chooses a
+  // visualization type. Having it set prevents auto selecting a new type,
+  // unless the selected type isn't sensible.
+  setSelectedDisplay(display): Question {
+    return this.setCard(
+      assoc(this.card(), "selectedDisplay", display),
+    ).setDisplay(display);
+  }
+  selectedDisplay(): string {
+    return this._card && this._card.selectedDisplay;
+  }
+
+  // This feels a bit hacky because it stores result-dependent info on card. We
+  // use the list of sensible displays to override a user-selected display if it
+  // no longer makes sense for the data.
+  setSensibleDisplays(displays): Question {
+    return this.setCard(assoc(this.card(), "sensibleDisplays", displays));
+  }
+  sensibleDisplays(): string[] {
+    return (this._card && this._card.sensibleDisplays) || [];
+  }
+
+  // This determines whether `setDefaultDisplay` should replace the current
+  // display. If we have a list of sensibleDisplays and the user-selected
+  // display is one of them, we won't overwrite it in `setDefaultDisplay`. If
+  // the user hasn't selected a display or `sensibleDisplays` hasn't been set,
+  // we can let `setDefaultDisplay` choose a display type.
+  shouldNotSetDisplay(): boolean {
+    return this.sensibleDisplays().includes(this.selectedDisplay());
+  }
+
+  // Switches display based on data shape. For 1x1 data, we show a scalar. If
+  // our display was a 1x1 type, but the data isn't 1x1, we show a table.
+  switchTableScalar({ rows = [], cols }): Question {
+    const display = this.display();
+    const isScalar = ["scalar", "progress", "gauge"].includes(display);
+    const isOneByOne = rows.length === 1 && cols.length === 1;
+
+    const newDisplay =
+      !isScalar && isOneByOne
+        ? // if we have a 1x1 data result then this should always be viewed as a scalar
+          "scalar"
+        : isScalar && !isOneByOne
+        ? // any time we were a scalar and now have more than 1x1 data switch to table view
+          "table"
+        : // otherwise leave the display unchanged
+          display;
+
+    return this.setDisplay(newDisplay);
+  }
+
   setDefaultDisplay(): Question {
+    if (this.shouldNotSetDisplay()) {
+      return this;
+    }
     const query = this.query();
     if (query instanceof StructuredQuery) {
       // TODO: move to StructuredQuery?
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 824f7053a9b..3ec457cc102 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -53,6 +53,7 @@ import querystring from "querystring";
 
 import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery";
 import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
+import { getSensibleDisplays } from "metabase/visualizations";
 import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/utils";
 import { getPersistableDefaultSettingsForSeries } from "metabase/visualizations/lib/settings/visualization";
 
@@ -450,6 +451,14 @@ export const initializeQB = (location, params) => {
       await dispatch(loadMetadataForCard(card));
     }
 
+    let question = card && new Question(card, getMetadata(getState()));
+    if (params.cardId) {
+      // loading a saved question prevents auto-viz selection
+      question = question && question.setSelectedDisplay(question.display());
+    }
+
+    card = question && question.card();
+
     // Update the question to Redux state together with the initial state of UI controls
     dispatch.action(INITIALIZE_QB, {
       card,
@@ -457,8 +466,6 @@ export const initializeQB = (location, params) => {
       uiControls,
     });
 
-    const question = card && new Question(card, getMetadata(getState()));
-
     // if we have loaded up a card that we can run then lets kick that off as well
     // but don't bother for "notebook" mode
     if (question && uiControls.queryBuilderMode !== "notebook") {
@@ -829,7 +836,15 @@ export const apiCreateQuestion = question => {
       createdQuestion.query().datasetQuery().type,
     );
 
-    dispatch.action(API_CREATE_QUESTION, createdQuestion.card());
+    // Saving a card, locks in the current display as though it had been
+    // selected in the UI. We also copy over `sensibleDisplays` since those were
+    // not persisted onto `createdQuestion`.
+    const card = createdQuestion
+      .setSensibleDisplays(question.sensibleDisplays())
+      .setSelectedDisplay(question.display())
+      .card();
+
+    dispatch.action(API_CREATE_QUESTION, card);
   };
 };
 
@@ -921,9 +936,7 @@ export const runQuestionQuery = ({
         ignoreCache: ignoreCache,
         isDirty: cardIsDirty,
       })
-      .then(queryResults =>
-        dispatch(queryCompleted(question.card(), queryResults)),
-      )
+      .then(queryResults => dispatch(queryCompleted(question, queryResults)))
       .catch(error => dispatch(queryErrored(startTime, error)));
 
     MetabaseAnalytics.trackEvent(
@@ -945,50 +958,16 @@ export const runQuestionQuery = ({
 export const CLEAR_QUERY_RESULT = "metabase/query_builder/CLEAR_QUERY_RESULT";
 export const clearQueryResult = createAction(CLEAR_QUERY_RESULT);
 
-const getDisplayTypeForCard = (card, queryResults) => {
-  // TODO Atte Keinänen 6/1/17: Make a holistic decision based on all queryResults, not just one
-  // This method seems to has been a candidate for a rewrite anyway
-  const queryResult = queryResults[0];
-
-  let cardDisplay = card.display;
-
-  // try a little logic to pick a smart display for the data
-  // TODO: less hard-coded rules for picking chart type
-  const isScalarVisualization =
-    card.display === "scalar" ||
-    card.display === "progress" ||
-    card.display === "gauge";
-  if (
-    !isScalarVisualization &&
-    queryResult.data.rows &&
-    queryResult.data.rows.length === 1 &&
-    queryResult.data.cols.length === 1
-  ) {
-    // if we have a 1x1 data result then this should always be viewed as a scalar
-    cardDisplay = "scalar";
-  } else if (
-    isScalarVisualization &&
-    queryResult.data.rows &&
-    (queryResult.data.rows.length > 1 || queryResult.data.cols.length > 1)
-  ) {
-    // any time we were a scalar and now have more than 1x1 data switch to table view
-    cardDisplay = "table";
-  } else if (!card.display) {
-    // if our query aggregation is "rows" then ALWAYS set the display to "table"
-    cardDisplay = "table";
-  }
-
-  return cardDisplay;
-};
-
 export const QUERY_COMPLETED = "metabase/qb/QUERY_COMPLETED";
-export const queryCompleted = (card, queryResults) => {
+export const queryCompleted = (question, queryResults) => {
   return async (dispatch, getState) => {
-    dispatch.action(QUERY_COMPLETED, {
-      card,
-      cardDisplay: getDisplayTypeForCard(card, queryResults),
-      queryResults,
-    });
+    const [{ data }] = queryResults;
+    const card = question
+      .setSensibleDisplays(getSensibleDisplays(data))
+      .setDefaultDisplay()
+      .switchTableScalar(data)
+      .card();
+    dispatch.action(QUERY_COMPLETED, { card, queryResults });
   };
 };
 
diff --git a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar.jsx b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar.jsx
index 73d5b9ef2f4..96915c8d6e7 100644
--- a/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar.jsx
+++ b/frontend/src/metabase/query_builder/components/view/sidebars/ChartTypeSidebar.jsx
@@ -60,7 +60,9 @@ const ChartTypeSidebar = ({
                     visualization.isSensible(result.data)
                   }
                   onClick={() => {
-                    question.setDisplay(type).update(null, { reload: false });
+                    question
+                      .setSelectedDisplay(type)
+                      .update(null, { reload: false });
                     onOpenChartSettings({ section: t`Data` });
                     setUIControls({ isShowingRawTable: false });
                   }}
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index fd807908bae..884e4872ee8 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -199,7 +199,9 @@ export const card = handleActions(
     [QUERY_COMPLETED]: {
       next: (state, { payload }) => ({
         ...state,
-        display: payload.cardDisplay,
+        sensibleDisplays: payload.card.sensibleDisplays,
+        selectedDisplay: payload.card.selectedDisplay,
+        display: payload.card.display,
       }),
     },
 
diff --git a/frontend/src/metabase/visualizations/index.js b/frontend/src/metabase/visualizations/index.js
index 01f32feedae..e48272017c1 100644
--- a/frontend/src/metabase/visualizations/index.js
+++ b/frontend/src/metabase/visualizations/index.js
@@ -28,6 +28,12 @@ visualizations.get = function(key) {
   return Map.prototype.get.call(this, key) || aliases.get(key) || Table;
 };
 
+export function getSensibleDisplays(data) {
+  return Array.from(visualizations)
+    .filter(([, viz]) => viz.isSensible && viz.isSensible(data))
+    .map(([display]) => display);
+}
+
 export function registerVisualization(visualization) {
   if (visualization == null) {
     throw new Error(t`Visualization is null`);
diff --git a/frontend/src/metabase/visualizations/visualizations/Map.jsx b/frontend/src/metabase/visualizations/visualizations/Map.jsx
index 44782555bc9..e37b6476df9 100644
--- a/frontend/src/metabase/visualizations/visualizations/Map.jsx
+++ b/frontend/src/metabase/visualizations/visualizations/Map.jsx
@@ -45,7 +45,10 @@ export default class Map extends Component {
   static minSize = { width: 4, height: 4 };
 
   static isSensible({ cols, rows }) {
-    return true;
+    return (
+      PinMap.isSensible({ cols, rows }) ||
+      ChoroplethMap.isSensible({ cols, rows })
+    );
   }
 
   static placeholderSeries = [
diff --git a/frontend/test/metabase-lib/lib/Question.unit.spec.js b/frontend/test/metabase-lib/lib/Question.unit.spec.js
index 774b5580847..cf3c1578c6d 100644
--- a/frontend/test/metabase-lib/lib/Question.unit.spec.js
+++ b/frontend/test/metabase-lib/lib/Question.unit.spec.js
@@ -267,6 +267,25 @@ describe("Question", () => {
         expect(scalarQuestion.display()).toBe("scalar");
       });
     });
+    describe("setDefaultDisplay", () => {
+      it("sets display to 'scalar' for order count", () => {
+        const question = new Question(
+          orders_count_card,
+          metadata,
+        ).setDefaultDisplay();
+
+        expect(question.display()).toBe("scalar");
+      });
+
+      it("should not set the display to scalar table was selected", () => {
+        const question = new Question(orders_count_card, metadata)
+          .setSelectedDisplay("table")
+          .setSensibleDisplays(["table", "scalar"])
+          .setDefaultDisplay();
+
+        expect(question.display()).toBe("table");
+      });
+    });
   });
 
   // TODO: These are mode-dependent and should probably be tied to modes
diff --git a/frontend/test/metabase/scenarios/query_builder.e2e.spec.js b/frontend/test/metabase/scenarios/query_builder.e2e.spec.js
index 28273b2f99b..5341043dd1f 100644
--- a/frontend/test/metabase/scenarios/query_builder.e2e.spec.js
+++ b/frontend/test/metabase/scenarios/query_builder.e2e.spec.js
@@ -31,9 +31,7 @@ describe("query builder", () => {
       await app.async.findByText("Simple question").click();
 
       await waitForAllRequestsToComplete(); // race condition in DataSelector with loading metadata
-      try {
-        await app.async.findByText("Sample Dataset").click(); // not needed if there's only one database
-      } catch (e) {}
+      await maybeClickSampleDataset(app);
       await app.async.findByText("Orders").click();
       await app.async.findByText("37.65");
     });
@@ -47,9 +45,7 @@ describe("query builder", () => {
       await app.async.findByText("Custom question").click();
 
       await waitForAllRequestsToComplete(); // race condition in DataSelector with loading metadata
-      try {
-        await app.async.findByText("Sample Dataset").click(); // not needed if there's only one database
-      } catch (e) {}
+      await maybeClickSampleDataset(app);
       await app.async.findByText("Orders").click();
       await app.async.findByText("Visualize").click();
       await app.async.findByText("37.65");
@@ -62,9 +58,7 @@ describe("query builder", () => {
       await app.async.findByText("Custom question").click();
 
       await waitForAllRequestsToComplete(); // race condition in DataSelector with loading metadata
-      try {
-        await app.async.findByText("Sample Dataset").click(); // not needed if there's only one database
-      } catch (e) {}
+      await maybeClickSampleDataset(app);
       await app.async.findByText("Orders").click();
       await app.async.findByText("Pick the metric you want to see").click();
       await app.async.findByText("Count of rows").click();
@@ -103,3 +97,14 @@ describe("query builder", () => {
     });
   });
 });
+
+// This isn't needed if there's only one db. In that case, clicking "Sample
+// Dataset" will actually take you back to select a db again.
+async function maybeClickSampleDataset(app) {
+  try {
+    const sampleDataset = await app.async.findByText("Sample Dataset");
+    if (sampleDataset.hasClass("List-section-title")) {
+      sampleDataset.click();
+    }
+  } catch (e) {}
+}
-- 
GitLab