From 348281fc848422fb6ecad7e35def79ace421bab5 Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Tue, 5 Apr 2022 17:20:39 -0300
Subject: [PATCH] Document Title and favicon updates for loading questions
 (#21425)

* Document Title and favicon updates for loading questions
* Refactoring Dashboard Title and Favicon logic
---
 frontend/src/metabase/dashboard/actions.js    | 71 +++++++++++++------
 .../dashboard/containers/DashboardApp.jsx     | 39 ++--------
 frontend/src/metabase/dashboard/reducers.js   | 36 ++++------
 .../metabase/dashboard/reducers.unit.spec.js  |  3 +-
 frontend/src/metabase/dashboard/selectors.js  | 20 +++---
 .../src/metabase/query_builder/actions.js     | 62 ++++++++++++++++
 .../query_builder/containers/QueryBuilder.jsx | 11 ++-
 .../src/metabase/query_builder/reducers.js    | 27 +++++++
 .../src/metabase/query_builder/selectors.js   | 21 ++++++
 9 files changed, 198 insertions(+), 92 deletions(-)

diff --git a/frontend/src/metabase/dashboard/actions.js b/frontend/src/metabase/dashboard/actions.js
index 070820cb721..a508b7d593b 100644
--- a/frontend/src/metabase/dashboard/actions.js
+++ b/frontend/src/metabase/dashboard/actions.js
@@ -2,6 +2,8 @@
 import { assoc, assocIn, dissocIn, getIn } from "icepick";
 import _ from "underscore";
 
+import { t } from "ttag";
+
 import { createAction, createThunkAction } from "metabase/lib/redux";
 import { defer } from "metabase/lib/promise";
 import { normalize, schema } from "normalizr";
@@ -57,6 +59,7 @@ import {
   getDashboardComplete,
   getParameterValues,
   getDashboardParameterValuesSearchCache,
+  getLoadingDashCards,
 } from "./selectors";
 import { getMetadata } from "metabase/selectors/metadata";
 import { getCardAfterVisualizationClick } from "metabase/visualizations/lib/utils";
@@ -139,9 +142,13 @@ export const FETCH_DASHBOARD_PARAMETER_FIELD_VALUES =
 export const SET_SIDEBAR = "metabase/dashboard/SET_SIDEBAR";
 export const CLOSE_SIDEBAR = "metabase/dashboard/CLOSE_SIDEBAR";
 
-export const SET_DASHBOARD_SEEN = "metabase/dashboard/SET_SEEN";
 export const SET_SHOW_LOADING_COMPLETE_FAVICON =
   "metabase/dashboard/SET_SHOW_LOADING_COMPLETE_FAVICON";
+export const SET_DOCUMENT_TITLE = "metabase/dashboard/SET_DOCUMENT_TITLE";
+const setDocumentTitle = createAction(SET_DOCUMENT_TITLE);
+
+export const SET_LOADING_DASHCARDS_COMPLETE =
+  "metabase/dashboard/SET_LOADING_DASHCARDS_COMPLETE";
 
 export const initialize = createAction(INITIALIZE);
 export const reset = createAction(RESET);
@@ -150,7 +157,6 @@ export const setEditingDashboard = createAction(SET_EDITING_DASHBOARD);
 export const setSidebar = createAction(SET_SIDEBAR);
 export const closeSidebar = createAction(CLOSE_SIDEBAR);
 
-export const setHasSeenLoadedDashboard = createAction(SET_DASHBOARD_SEEN);
 export const setShowLoadingCompleteFavicon = createAction(
   SET_SHOW_LOADING_COMPLETE_FAVICON,
 );
@@ -456,37 +462,50 @@ export const fetchDashboardCardData = createThunkAction(
   FETCH_DASHBOARD_CARD_DATA,
   options => (dispatch, getState) => {
     const dashboard = getDashboardComplete(getState());
+
     const promises = getAllDashboardCards(dashboard)
       .map(({ card, dashcard }) => {
         if (!isVirtualDashCard(dashcard)) {
-          return dispatch(fetchCardData(card, dashcard, options));
+          return dispatch(fetchCardData(card, dashcard, options)).then(() => {
+            return dispatch(updateLoadingTitle());
+          });
         }
       })
       .filter(p => !!p);
 
+    dispatch(setDocumentTitle(t`0/${promises.length} loaded`));
+
     Promise.all(promises).then(() => {
-      dispatch(setShowLoadingCompleteFavicon(true));
-      if (!document.hidden) {
-        dispatch(setHasSeenLoadedDashboard());
-        setTimeout(() => {
-          dispatch(setShowLoadingCompleteFavicon(false));
-        }, 3000);
-      } else {
-        document.addEventListener(
-          "visibilitychange",
-          () => {
-            dispatch(setHasSeenLoadedDashboard());
-            setTimeout(() => {
-              dispatch(setShowLoadingCompleteFavicon(false));
-            }, 3000);
-          },
-          { once: true },
-        );
-      }
+      dispatch(loadingComplete());
     });
   },
 );
 
+const loadingComplete = createThunkAction(
+  SET_LOADING_DASHCARDS_COMPLETE,
+  () => dispatch => {
+    dispatch(setShowLoadingCompleteFavicon(true));
+    if (!document.hidden) {
+      dispatch(setDocumentTitle(""));
+      setTimeout(() => {
+        dispatch(setShowLoadingCompleteFavicon(false));
+      }, 3000);
+    } else {
+      dispatch(setDocumentTitle(t`Your dashboard is ready`));
+      document.addEventListener(
+        "visibilitychange",
+        () => {
+          dispatch(setDocumentTitle(""));
+          setTimeout(() => {
+            dispatch(setShowLoadingCompleteFavicon(false));
+          }, 3000);
+        },
+        { once: true },
+      );
+    }
+  },
+);
+
 export const cancelFetchDashboardCardData = createThunkAction(
   CANCEL_FETCH_DASHBOARD_CARD_DATA,
   () => (dispatch, getState) => {
@@ -673,6 +692,16 @@ export const fetchCardData = createThunkAction(FETCH_CARD_DATA, function(
   };
 });
 
+const updateLoadingTitle = createThunkAction(
+  SET_DOCUMENT_TITLE,
+  () => (dispatch, getState) => {
+    const loadingDashCards = getLoadingDashCards(getState());
+    const totalCards = loadingDashCards.dashcardIds.length;
+    const loadingComplete = totalCards - loadingDashCards.loadingIds.length;
+    return `${loadingComplete}/${totalCards} loaded`;
+  },
+);
+
 export const markCardAsSlow = createAction(MARK_CARD_AS_SLOW, card => ({
   id: card.id,
   result: true,
diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
index 379261c20a3..da582fcb91a 100644
--- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
+++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
@@ -2,7 +2,6 @@
 import React, { Component } from "react";
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
-import { t } from "ttag";
 
 import title from "metabase/hoc/Title";
 import favicon from "metabase/hoc/Favicon";
@@ -30,12 +29,8 @@ import {
   getIsAddParameterPopoverOpen,
   getSidebar,
   getShowAddQuestionSidebar,
-  getIsLoadingDashCards,
-  getTotalCards,
-  getCardsLoaded,
-  getHasSeenLoadedDashboard,
-  getIsLoadingDashCardsComplete,
   getFavicon,
+  getDocumentTitle,
 } from "../selectors";
 import { getDatabases, getMetadata } from "metabase/selectors/metadata";
 import {
@@ -74,12 +69,8 @@ const mapStateToProps = (state, props) => {
     isAddParameterPopoverOpen: getIsAddParameterPopoverOpen(state),
     sidebar: getSidebar(state),
     showAddQuestionSidebar: getShowAddQuestionSidebar(state),
-    isLoadingDashCards: getIsLoadingDashCards(state),
-    isLoadingDashCardsComplete: getIsLoadingDashCardsComplete(state),
-    totalDashCards: getTotalCards(state),
-    cardsLoaded: getCardsLoaded(state),
-    hasSeenLoadedDashboard: getHasSeenLoadedDashboard(state),
     pageFavicon: getFavicon(state),
+    documentTitle: getDocumentTitle(state),
   };
 };
 
@@ -93,28 +84,10 @@ const mapDispatchToProps = {
 
 @connect(mapStateToProps, mapDispatchToProps)
 @favicon(({ pageFavicon }) => pageFavicon)
-@title(
-  ({
-    dashboard,
-    isLoadingDashCards,
-    totalDashCards,
-    cardsLoaded,
-    hasSeenLoadedDashboard,
-    isLoadingDashCardsComplete,
-  }) => {
-    if (isLoadingDashCards) {
-      return {
-        title: t`${cardsLoaded}/${totalDashCards} loaded`,
-        titleIndex: 1,
-      };
-    }
-    if (isLoadingDashCardsComplete && !hasSeenLoadedDashboard) {
-      return t`Your dashboard is ready`;
-    } else {
-      return dashboard?.name;
-    }
-  },
-)
+@title(({ dashboard, documentTitle }) => ({
+  title: documentTitle || dashboard?.name,
+  titleIndex: 1,
+}))
 @titleWithLoadingTime("loadingStartTime")
 // NOTE: should use DashboardControls and DashboardData HoCs here?
 export default class DashboardApp extends Component {
diff --git a/frontend/src/metabase/dashboard/reducers.js b/frontend/src/metabase/dashboard/reducers.js
index c4bb2b563a1..cfbad20b771 100644
--- a/frontend/src/metabase/dashboard/reducers.js
+++ b/frontend/src/metabase/dashboard/reducers.js
@@ -32,7 +32,7 @@ import {
   CLOSE_SIDEBAR,
   FETCH_DASHBOARD_PARAMETER_FIELD_VALUES,
   SAVE_DASHBOARD_AND_CARDS,
-  SET_DASHBOARD_SEEN,
+  SET_DOCUMENT_TITLE,
   SET_SHOW_LOADING_COMPLETE_FAVICON,
   RESET,
 } from "./actions";
@@ -61,28 +61,19 @@ const isEditing = handleActions(
   null,
 );
 
-const hasSeenLoadedDashboard = handleActions(
+const loadingControls = handleActions(
   {
-    [INITIALIZE]: { next: state => false },
-    [FETCH_DASHBOARD]: { next: state => false },
-    [SET_DASHBOARD_SEEN]: {
-      next: state => true,
-    },
-    [RESET]: { next: state => false },
-  },
-  false,
-);
-
-const showLoadingCompleteFavicon = handleActions(
-  {
-    [INITIALIZE]: { next: state => false },
-    [FETCH_DASHBOARD]: { next: state => false },
-    [SET_SHOW_LOADING_COMPLETE_FAVICON]: {
-      next: (state, { payload }) => payload,
-    },
-    [RESET]: { next: state => false },
+    [SET_DOCUMENT_TITLE]: (state, { payload }) => ({
+      ...state,
+      documentTitle: payload,
+    }),
+    [SET_SHOW_LOADING_COMPLETE_FAVICON]: (state, { payload }) => ({
+      ...state,
+      showLoadCompleteFavicon: payload,
+    }),
+    [RESET]: { next: state => ({}) },
   },
-  false,
+  {},
 );
 
 function newDashboard(before, after, isDirty) {
@@ -402,8 +393,7 @@ const sidebar = handleActions(
 export default combineReducers({
   dashboardId,
   isEditing,
-  hasSeenLoadedDashboard,
-  showLoadingCompleteFavicon,
+  loadingControls,
   dashboards,
   dashcards,
   dashcardData,
diff --git a/frontend/src/metabase/dashboard/reducers.unit.spec.js b/frontend/src/metabase/dashboard/reducers.unit.spec.js
index 13ca69ff5e3..110b630a60b 100644
--- a/frontend/src/metabase/dashboard/reducers.unit.spec.js
+++ b/frontend/src/metabase/dashboard/reducers.unit.spec.js
@@ -32,8 +32,7 @@ describe("dashboard reducers", () => {
       parameterValuesSearchCache: {},
       sidebar: { props: {} },
       slowCards: {},
-      hasSeenLoadedDashboard: false,
-      showLoadingCompleteFavicon: false,
+      loadingControls: {},
     });
   });
 
diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js
index 6f7ba953860..b8a4e30ed64 100644
--- a/frontend/src/metabase/dashboard/selectors.js
+++ b/frontend/src/metabase/dashboard/selectors.js
@@ -28,19 +28,10 @@ export const getDashcards = state => state.dashboard.dashcards;
 export const getCardData = state => state.dashboard.dashcardData;
 export const getSlowCards = state => state.dashboard.slowCards;
 export const getParameterValues = state => state.dashboard.parameterValues;
-export const getIsLoadingDashCards = state =>
-  state.dashboard.loadingDashCards.loadingIds.length > 0;
-export const getCardsLoaded = state =>
-  state.dashboard.loadingDashCards.dashcardIds.length -
-  state.dashboard.loadingDashCards.loadingIds.length;
-export const getTotalCards = state =>
-  state.dashboard.loadingDashCards.dashcardIds.length;
-export const getHasSeenLoadedDashboard = state =>
-  state.dashboard.hasSeenLoadedDashboard;
-export const getIsLoadingDashCardsComplete = state =>
-  state.dashboard.loadingDashCards.isLoadingComplete;
 export const getFavicon = state =>
-  state.dashboard.showLoadingCompleteFavicon ? LOAD_COMPLETE_FAVICON : null;
+  state.dashboard.loadingControls?.showLoadCompleteFavicon
+    ? LOAD_COMPLETE_FAVICON
+    : null;
 export const getLoadingStartTime = state =>
   state.dashboard.loadingDashCards.startTime;
 export const getIsAddParameterPopoverOpen = state =>
@@ -62,6 +53,8 @@ export const getDashboard = createSelector(
   (dashboardId, dashboards) => dashboards[dashboardId],
 );
 
+export const getLoadingDashCards = state => state.dashboard.loadingDashCards;
+
 export const getDashboardComplete = createSelector(
   [getDashboard, getDashcards],
   (dashboard, dashcards) =>
@@ -73,6 +66,9 @@ export const getDashboardComplete = createSelector(
     },
 );
 
+export const getDocumentTitle = state =>
+  state.dashboard.loadingControls.documentTitle;
+
 export const getIsBookmarked = (state, props) =>
   props.bookmarks.some(
     bookmark =>
diff --git a/frontend/src/metabase/query_builder/actions.js b/frontend/src/metabase/query_builder/actions.js
index 11470876a0f..d31d5708969 100644
--- a/frontend/src/metabase/query_builder/actions.js
+++ b/frontend/src/metabase/query_builder/actions.js
@@ -65,6 +65,7 @@ import {
   getTransformedSeries,
   getZoomedObjectId,
   isBasedOnExistingQuestion,
+  getTimeoutId,
 } from "./selectors";
 import { trackNewQuestionSaved } from "./analytics";
 
@@ -101,6 +102,26 @@ export const setUIControls = createAction(SET_UI_CONTROLS);
 export const RESET_UI_CONTROLS = "metabase/qb/RESET_UI_CONTROLS";
 export const resetUIControls = createAction(RESET_UI_CONTROLS);
 
+export const SET_DOCUMENT_TITLE = "metabase/qb/SET_DOCUMENT_TITLE";
+const setDocumentTitle = createAction(SET_DOCUMENT_TITLE);
+
+export const SET_SHOW_LOADING_COMPLETE_FAVICON =
+  "metabase/qb/SET_SHOW_LOADING_COMPLETE_FAVICON";
+const showLoadingCompleteFavicon = createAction(
+  SET_SHOW_LOADING_COMPLETE_FAVICON,
+  () => true,
+);
+const hideLoadingCompleteFavicon = createAction(
+  SET_SHOW_LOADING_COMPLETE_FAVICON,
+  () => false,
+);
+
+const LOAD_COMPLETE_UI_CONTROLS = "metabase/qb/LOAD_COMPLETE_UI_CONTROLS";
+const LOAD_START_UI_CONTROLS = "metabase/qb/LOAD_START_UI_CONTROLS";
+export const SET_DOCUMENT_TITLE_TIMEOUT_ID =
+  "metabase/qb/SET_DOCUMENT_TITLE_TIMEOUT_ID";
+const setDocumentTitleTimeoutId = createAction(SET_DOCUMENT_TITLE_TIMEOUT_ID);
+
 export const setQueryBuilderMode = (
   queryBuilderMode,
   { shouldUpdateUrl = true, datasetEditorTab = "query" } = {},
@@ -1276,6 +1297,7 @@ export const runQuestionQuery = ({
   overrideWithCard,
 } = {}) => {
   return async (dispatch, getState) => {
+    dispatch(loadStartUIControls());
     const questionFromCard = card =>
       card && new Question(card, getMetadata(getState()));
 
@@ -1329,6 +1351,7 @@ export const runQuestionQuery = ({
             duration,
           ),
         );
+        // clearTimeout(timeoutId);
         return dispatch(queryCompleted(question, queryResults));
       })
       .catch(error => dispatch(queryErrored(startTime, error)));
@@ -1343,6 +1366,17 @@ export const runQuestionQuery = ({
   };
 };
 
+const loadStartUIControls = createThunkAction(
+  LOAD_START_UI_CONTROLS,
+  () => (dispatch, getState) => {
+    dispatch(setDocumentTitle(t`Doing Science...`));
+    const timeoutId = setTimeout(() => {
+      dispatch(setDocumentTitle(t`Still Here...`));
+    }, 10000);
+    dispatch(setDocumentTitleTimeoutId(timeoutId));
+  },
+);
+
 export const CLEAR_QUERY_RESULT = "metabase/query_builder/CLEAR_QUERY_RESULT";
 export const clearQueryResult = createAction(CLEAR_QUERY_RESULT);
 
@@ -1383,9 +1417,37 @@ export const queryCompleted = (question, queryResults) => {
     }
 
     dispatch.action(QUERY_COMPLETED, { card, queryResults });
+    dispatch(loadCompleteUIControls());
   };
 };
 
+const loadCompleteUIControls = createThunkAction(
+  LOAD_COMPLETE_UI_CONTROLS,
+  () => (dispatch, getState) => {
+    const timeoutId = getTimeoutId(getState());
+    clearTimeout(timeoutId);
+    dispatch(showLoadingCompleteFavicon());
+    if (document.hidden) {
+      dispatch(setDocumentTitle(t`Your question is ready!`));
+      document.addEventListener(
+        "visibilitychange",
+        () => {
+          dispatch(setDocumentTitle(""));
+          setTimeout(() => {
+            dispatch(hideLoadingCompleteFavicon());
+          }, 3000);
+        },
+        { once: true },
+      );
+    } else {
+      dispatch(setDocumentTitle(""));
+      setTimeout(() => {
+        dispatch(hideLoadingCompleteFavicon());
+      }, 3000);
+    }
+  },
+);
+
 /**
  * Saves to `visualization_settings` property of a question those visualization settings that
  * 1) don't have a value yet and 2) have `persistDefault` flag enabled.
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index cb339d285ae..885634a4d22 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -24,6 +24,7 @@ import { usePrevious } from "metabase/hooks/use-previous";
 
 import title from "metabase/hoc/Title";
 import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime";
+import favicon from "metabase/hoc/Favicon";
 
 import View from "../components/view/View";
 
@@ -74,6 +75,8 @@ import {
   getFilteredTimelines,
   getTimeseriesXDomain,
   getIsAnySidebarOpen,
+  getDocumentTitle,
+  getPageFavicon,
 } from "../selectors";
 import * as actions from "../actions";
 
@@ -171,6 +174,8 @@ const mapStateToProps = (state, props) => {
     nativeEditorSelectedText: getNativeEditorSelectedText(state),
     modalSnippet: getModalSnippet(state),
     snippetCollectionId: getSnippetCollectionId(state),
+    documentTitle: getDocumentTitle(state),
+    pageFavicon: getPageFavicon(state),
   };
 };
 
@@ -369,6 +374,10 @@ export default _.compose(
   Bookmark.loadList(),
   Timelines.loadList(timelineProps),
   connect(mapStateToProps, mapDispatchToProps),
-  title(({ card }) => card?.name ?? t`Question`),
+  favicon(({ pageFavicon }) => pageFavicon),
+  title(({ card, documentTitle }) => ({
+    title: documentTitle || card?.name || t`Question`,
+    titleIndex: 1,
+  })),
   titleWithLoadingTime("queryStartTime"),
 )(QueryBuilder);
diff --git a/frontend/src/metabase/query_builder/reducers.js b/frontend/src/metabase/query_builder/reducers.js
index 88980980e4c..2df809e6093 100644
--- a/frontend/src/metabase/query_builder/reducers.js
+++ b/frontend/src/metabase/query_builder/reducers.js
@@ -61,6 +61,9 @@ import {
   HIDE_TIMELINES,
   SELECT_TIMELINE_EVENTS,
   DESELECT_TIMELINE_EVENTS,
+  SET_DOCUMENT_TITLE,
+  SET_SHOW_LOADING_COMPLETE_FAVICON,
+  SET_DOCUMENT_TITLE_TIMEOUT_ID,
 } from "./actions";
 
 const DEFAULT_UI_CONTROLS = {
@@ -84,6 +87,12 @@ const DEFAULT_UI_CONTROLS = {
   datasetEditorTab: "query", // "query" / "metadata"
 };
 
+const DEFAULT_LOADING_CONTROLS = {
+  showLoadCompleteFavicon: false,
+  documentTitle: "",
+  timeoutId: "",
+};
+
 const UI_CONTROLS_SIDEBAR_DEFAULTS = {
   isShowingSummarySidebar: false,
   isShowingFilterSidebar: false,
@@ -295,6 +304,24 @@ export const uiControls = handleActions(
   DEFAULT_UI_CONTROLS,
 );
 
+export const loadingControls = handleActions(
+  {
+    [SET_DOCUMENT_TITLE]: (state, { payload }) => ({
+      ...state,
+      documentTitle: payload,
+    }),
+    [SET_SHOW_LOADING_COMPLETE_FAVICON]: (state, { payload }) => ({
+      ...state,
+      showLoadCompleteFavicon: payload,
+    }),
+    [SET_DOCUMENT_TITLE_TIMEOUT_ID]: (state, { payload }) => ({
+      ...state,
+      timeoutId: payload,
+    }),
+  },
+  DEFAULT_LOADING_CONTROLS,
+);
+
 export const zoomedRowObjectId = handleActions(
   {
     [INITIALIZE_QB]: {
diff --git a/frontend/src/metabase/query_builder/selectors.js b/frontend/src/metabase/query_builder/selectors.js
index a077453e2d0..e01a79fa6dd 100644
--- a/frontend/src/metabase/query_builder/selectors.js
+++ b/frontend/src/metabase/query_builder/selectors.js
@@ -34,7 +34,10 @@ import {
 import Mode from "metabase-lib/lib/Mode";
 import ObjectMode from "metabase/modes/components/modes/ObjectMode";
 
+import { LOAD_COMPLETE_FAVICON } from "metabase/hoc/Favicon";
+
 export const getUiControls = state => state.qb.uiControls;
+const getLoadingControls = state => state.qb.loadingControls;
 
 export const getIsShowingTemplateTagsEditor = state =>
   getUiControls(state).isShowingTemplateTagsEditor;
@@ -820,3 +823,21 @@ export const isBasedOnExistingQuestion = createSelector(
     return originalQuestion != null;
   },
 );
+
+export const getDocumentTitle = createSelector(
+  [getLoadingControls],
+  loadingControls => loadingControls?.documentTitle,
+);
+
+export const getPageFavicon = createSelector(
+  [getLoadingControls],
+  loadingControls =>
+    loadingControls?.showLoadCompleteFavicon
+      ? LOAD_COMPLETE_FAVICON
+      : undefined,
+);
+
+export const getTimeoutId = createSelector(
+  [getLoadingControls],
+  loadingControls => loadingControls.timeoutId,
+);
-- 
GitLab