From 3567cea7947fdb6c1d5703375bbf10893e00abf8 Mon Sep 17 00:00:00 2001
From: Nick Fitzpatrick <nick@metabase.com>
Date: Mon, 11 Apr 2022 15:02:11 -0300
Subject: [PATCH] Dashboard / Question Toaster and web notifications (#21414)

Dashboard / Question Toaster and web notifications
---
 .../components/Toaster/Toaster.stories.tsx    |  24 ++++
 .../components/Toaster/Toaster.styled.tsx     |  56 ++++++++
 .../metabase/components/Toaster/Toaster.tsx   |  57 ++++++++
 .../src/metabase/components/Toaster/index.ts  |   1 +
 .../dashboard/containers/DashboardApp.jsx     | 129 ++++++++++++------
 frontend/src/metabase/dashboard/selectors.js  |   4 +
 .../src/metabase/hooks/use-loading-timer.ts   |  20 +++
 .../metabase/hooks/use-web-notification.ts    |  17 +++
 .../query_builder/components/view/View.jsx    |  12 ++
 .../query_builder/containers/QueryBuilder.jsx |  57 +++++++-
 10 files changed, 335 insertions(+), 42 deletions(-)
 create mode 100644 frontend/src/metabase/components/Toaster/Toaster.stories.tsx
 create mode 100644 frontend/src/metabase/components/Toaster/Toaster.styled.tsx
 create mode 100644 frontend/src/metabase/components/Toaster/Toaster.tsx
 create mode 100644 frontend/src/metabase/components/Toaster/index.ts
 create mode 100644 frontend/src/metabase/hooks/use-loading-timer.ts
 create mode 100644 frontend/src/metabase/hooks/use-web-notification.ts

diff --git a/frontend/src/metabase/components/Toaster/Toaster.stories.tsx b/frontend/src/metabase/components/Toaster/Toaster.stories.tsx
new file mode 100644
index 00000000000..2accc0ce798
--- /dev/null
+++ b/frontend/src/metabase/components/Toaster/Toaster.stories.tsx
@@ -0,0 +1,24 @@
+import React from "react";
+import { ComponentStory } from "@storybook/react";
+import Toaster from "./Toaster";
+
+export default {
+  title: "Dashboard/Toaster",
+  component: Toaster,
+};
+
+const Template: ComponentStory<typeof Toaster> = args => {
+  return <Toaster {...args} />;
+};
+
+export const Default = Template.bind({});
+Default.args = {
+  message: "Would you like to be notified when this dashboard is done loading?",
+  isShown: true,
+  onConfirm: () => {
+    alert("Confirmed");
+  },
+  onDismiss: () => {
+    alert("Dismissed");
+  },
+};
diff --git a/frontend/src/metabase/components/Toaster/Toaster.styled.tsx b/frontend/src/metabase/components/Toaster/Toaster.styled.tsx
new file mode 100644
index 00000000000..e78e841f10f
--- /dev/null
+++ b/frontend/src/metabase/components/Toaster/Toaster.styled.tsx
@@ -0,0 +1,56 @@
+import styled from "@emotion/styled";
+
+import { alpha, color } from "metabase/lib/colors";
+
+interface ToasterContainerProps {
+  show: boolean;
+  fixed?: boolean;
+}
+
+export const ToasterContainer = styled.div<ToasterContainerProps>`
+  display: flex;
+  flex-direction: row;
+  overflow-x: hidden;
+  max-width: 388px;
+  background-color: ${color("text-dark")};
+  padding: 16px;
+  border-radius: 6px;
+  ${props =>
+    props.fixed
+      ? `position: fixed;
+       bottom: ${props.show ? "20px" : "10px"};
+       left: 20px;`
+      : `position: relative;
+       bottom: ${props.show ? "0px" : "-10px"};`}
+  opacity: ${props => (props.show ? 1 : 0)};
+  transition: all 200ms ease-out;
+  column-gap: 16px;
+  align-items: center;
+  z-index: 100;
+`;
+
+export const ToasterMessage = styled.p`
+  color: ${color("white")};
+  width: 250px;
+  line-height: 24px;
+  font-size: 14px;
+  margin: 0px;
+`;
+
+export const ToasterButton = styled.button`
+  display: flex;
+  padding: 7px 18px;
+  background-color: ${alpha(color("bg-white"), 0.1)};
+  border-radius: 6px;
+  color: ${color("white")};
+  width: 90px;
+  height: fit-content;
+  font-size: 14px;
+  font-weight: bold;
+  transition: background 200ms ease;
+
+  &:hover {
+    cursor: pointer;
+    background-color: ${alpha(color("bg-white"), 0.3)};
+  }
+`;
diff --git a/frontend/src/metabase/components/Toaster/Toaster.tsx b/frontend/src/metabase/components/Toaster/Toaster.tsx
new file mode 100644
index 00000000000..c5f4bb0f209
--- /dev/null
+++ b/frontend/src/metabase/components/Toaster/Toaster.tsx
@@ -0,0 +1,57 @@
+import React, { useState, useEffect, HTMLAttributes } from "react";
+import { t } from "ttag";
+import Icon from "metabase/components/Icon";
+import { color } from "metabase/lib/colors";
+
+import {
+  ToasterContainer,
+  ToasterMessage,
+  ToasterButton,
+} from "./Toaster.styled";
+
+export interface ToasterProps extends HTMLAttributes<HTMLAnchorElement> {
+  message: string;
+  confirmText?: string;
+  isShown: boolean;
+  fixed?: boolean;
+  className: string;
+  onConfirm: () => void;
+  onDismiss: () => void;
+}
+
+const Toaster = ({
+  message,
+  confirmText = t`Turn on`,
+  isShown,
+  fixed,
+  onConfirm,
+  onDismiss,
+  className,
+}: ToasterProps): JSX.Element | null => {
+  const [open, setOpen] = useState(false);
+  const [render, setRender] = useState(false);
+
+  useEffect(() => {
+    if (isShown) {
+      setRender(true);
+      setTimeout(() => {
+        setOpen(true);
+      }, 100);
+    } else {
+      setOpen(false);
+      setTimeout(() => {
+        setRender(false);
+      }, 300);
+    }
+  }, [isShown]);
+
+  return render ? (
+    <ToasterContainer show={open} fixed={fixed} className={className}>
+      <ToasterMessage>{message}</ToasterMessage>
+      <ToasterButton onClick={onConfirm}>{confirmText}</ToasterButton>
+      <Icon name="close" color={color("bg-dark")} onClick={onDismiss} />
+    </ToasterContainer>
+  ) : null;
+};
+
+export default Toaster;
diff --git a/frontend/src/metabase/components/Toaster/index.ts b/frontend/src/metabase/components/Toaster/index.ts
new file mode 100644
index 00000000000..fb156d44a25
--- /dev/null
+++ b/frontend/src/metabase/components/Toaster/index.ts
@@ -0,0 +1 @@
+export { default } from "./Toaster";
diff --git a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
index da582fcb91a..7a5b93546b9 100644
--- a/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
+++ b/frontend/src/metabase/dashboard/containers/DashboardApp.jsx
@@ -1,13 +1,21 @@
 /* eslint-disable react/prop-types */
-import React, { Component } from "react";
+import React, { useCallback, useEffect, useState } from "react";
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
+import _ from "underscore";
+
+import { t } from "ttag";
 
 import title from "metabase/hoc/Title";
 import favicon from "metabase/hoc/Favicon";
 import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime";
 
 import Dashboard from "metabase/dashboard/components/Dashboard/Dashboard";
+import Toaster from "metabase/components/Toaster";
+
+import { useLoadingTimer } from "metabase/hooks/use-loading-timer";
+import { useWebNotification } from "metabase/hooks/use-web-notification";
+import { useOnUnmount } from "metabase/hooks/use-on-unmount";
 
 import { fetchDatabaseMetadata } from "metabase/redux/metadata";
 import { getIsNavbarOpen, setErrorPage } from "metabase/redux/app";
@@ -31,6 +39,7 @@ import {
   getShowAddQuestionSidebar,
   getFavicon,
   getDocumentTitle,
+  getIsRunning,
 } from "../selectors";
 import { getDatabases, getMetadata } from "metabase/selectors/metadata";
 import {
@@ -71,6 +80,7 @@ const mapStateToProps = (state, props) => {
     showAddQuestionSidebar: getShowAddQuestionSidebar(state),
     pageFavicon: getFavicon(state),
     documentTitle: getDocumentTitle(state),
+    isRunning: getIsRunning(state),
   };
 };
 
@@ -82,47 +92,84 @@ const mapDispatchToProps = {
   onChangeLocation: push,
 };
 
-@connect(mapStateToProps, mapDispatchToProps)
-@favicon(({ pageFavicon }) => pageFavicon)
-@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 {
-  state = {
-    addCardOnLoad: null,
-  };
+const DashboardApp = props => {
+  const options = parseHashOptions(window.location.hash);
+
+  const { isRunning, dashboard } = props;
+
+  const [editingOnLoad] = useState(options.edit);
+  const [addCardOnLoad] = useState(options.add && parseInt(options.add));
+
+  const [shouldSendNotification, setShouldSendNotification] = useState(false);
+  const [isShowingToaster, setIsShowingToaster] = useState(false);
+
+  const onTimeout = useCallback(() => {
+    setIsShowingToaster(true);
+  }, []);
+
+  useLoadingTimer(isRunning, {
+    timer: 15000,
+    onTimeout,
+  });
 
-  UNSAFE_componentWillMount() {
-    const options = parseHashOptions(window.location.hash);
+  const [requestPermission, showNotification] = useWebNotification();
 
-    if (options) {
-      this.setState({
-        editingOnLoad: options.edit,
-        addCardOnLoad: options.add && parseInt(options.add),
-      });
+  useOnUnmount(props.reset);
+
+  useEffect(() => {
+    if (!isRunning) {
+      setIsShowingToaster(false);
+    }
+    if (!isRunning && shouldSendNotification) {
+      if (document.hidden) {
+        showNotification(
+          t`All Set! ${dashboard.name} is ready.`,
+          t`All questions loaded`,
+        );
+      }
+      setShouldSendNotification(false);
+    }
+  }, [isRunning, shouldSendNotification, showNotification, dashboard?.name]);
+
+  const onConfirmToast = useCallback(async () => {
+    const result = await requestPermission();
+    if (result === "granted") {
+      setIsShowingToaster(false);
+      setShouldSendNotification(true);
     }
-  }
-
-  componentWillUnmount() {
-    this.props.reset();
-  }
-
-  render() {
-    const { editingOnLoad, addCardOnLoad } = this.state;
-
-    return (
-      <div className="shrink-below-content-size full-height">
-        <Dashboard
-          editingOnLoad={editingOnLoad}
-          addCardOnLoad={addCardOnLoad}
-          {...this.props}
-        />
-        {/* For rendering modal urls */}
-        {this.props.children}
-      </div>
-    );
-  }
-}
+  }, [requestPermission]);
+
+  const onDismissToast = useCallback(() => {
+    setIsShowingToaster(false);
+  }, []);
+
+  return (
+    <div className="shrink-below-content-size full-height">
+      <Dashboard
+        editingOnLoad={editingOnLoad}
+        addCardOnLoad={addCardOnLoad}
+        {...props}
+      />
+      {/* For rendering modal urls */}
+      {props.children}
+      <Toaster
+        message={t`Would you like to be notified when this dashboard is done loading?`}
+        isShown={isShowingToaster}
+        onDismiss={onDismissToast}
+        onConfirm={onConfirmToast}
+        fixed
+      />
+    </div>
+  );
+};
+
+export default _.compose(
+  connect(mapStateToProps, mapDispatchToProps),
+  favicon(({ pageFavicon }) => pageFavicon),
+  title(({ dashboard, documentTitle }) => ({
+    title: documentTitle || dashboard?.name,
+    titleIndex: 1,
+  })),
+  titleWithLoadingTime("loadingStartTime"),
+)(DashboardApp);
diff --git a/frontend/src/metabase/dashboard/selectors.js b/frontend/src/metabase/dashboard/selectors.js
index b8a4e30ed64..158ea6a0af5 100644
--- a/frontend/src/metabase/dashboard/selectors.js
+++ b/frontend/src/metabase/dashboard/selectors.js
@@ -32,6 +32,10 @@ export const getFavicon = state =>
   state.dashboard.loadingControls?.showLoadCompleteFavicon
     ? LOAD_COMPLETE_FAVICON
     : null;
+
+export const getIsRunning = state =>
+  state.dashboard.loadingDashCards.loadingIds > 0;
+
 export const getLoadingStartTime = state =>
   state.dashboard.loadingDashCards.startTime;
 export const getIsAddParameterPopoverOpen = state =>
diff --git a/frontend/src/metabase/hooks/use-loading-timer.ts b/frontend/src/metabase/hooks/use-loading-timer.ts
new file mode 100644
index 00000000000..9be44b66312
--- /dev/null
+++ b/frontend/src/metabase/hooks/use-loading-timer.ts
@@ -0,0 +1,20 @@
+import { useEffect, useState } from "react";
+
+interface LoadingTimerProps {
+  timer: number;
+  onTimeout: () => void;
+}
+
+export function useLoadingTimer(isLoading: boolean, props: LoadingTimerProps) {
+  const { onTimeout, timer } = props;
+  useEffect(() => {
+    if (isLoading) {
+      const timeoutId = setTimeout(() => {
+        if (isLoading) {
+          onTimeout();
+        }
+      }, timer);
+      return () => clearTimeout(timeoutId);
+    }
+  }, [isLoading, timer, onTimeout]);
+}
diff --git a/frontend/src/metabase/hooks/use-web-notification.ts b/frontend/src/metabase/hooks/use-web-notification.ts
new file mode 100644
index 00000000000..51a6b7950c2
--- /dev/null
+++ b/frontend/src/metabase/hooks/use-web-notification.ts
@@ -0,0 +1,17 @@
+import React, { useCallback } from "react";
+
+export function useWebNotification() {
+  const requestPermission = useCallback(async () => {
+    const permission = await Notification.requestPermission();
+    return permission;
+  }, []);
+
+  const showNotification = useCallback((title: string, body: string) => {
+    new Notification(title, {
+      body,
+      icon: "app/assets/img/favicon-32x32.png",
+    });
+  }, []);
+
+  return [requestPermission, showNotification];
+}
diff --git a/frontend/src/metabase/query_builder/components/view/View.jsx b/frontend/src/metabase/query_builder/components/view/View.jsx
index 56defd6d35b..f2e8ebf2f4c 100644
--- a/frontend/src/metabase/query_builder/components/view/View.jsx
+++ b/frontend/src/metabase/query_builder/components/view/View.jsx
@@ -2,12 +2,14 @@
 import React from "react";
 import { Motion, spring } from "react-motion";
 import _ from "underscore";
+import { t } from "ttag";
 
 import ExplicitSize from "metabase/components/ExplicitSize";
 import Popover from "metabase/components/Popover";
 import QueryValidationError from "metabase/query_builder/components/QueryValidationError";
 import { SIDEBAR_SIZES } from "metabase/query_builder/constants";
 import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper";
+import Toaster from "metabase/components/Toaster";
 
 import NativeQuery from "metabase-lib/lib/queries/NativeQuery";
 import StructuredQuery from "metabase-lib/lib/queries/StructuredQuery";
@@ -431,6 +433,9 @@ export default class View extends React.Component {
       queryBuilderMode,
       fitClassNames,
       closeQbNewbModal,
+      onDismissToast,
+      onConfirmToast,
+      isShowingToaster,
     } = this.props;
 
     // if we don't have a card at all or no databases then we are initializing, so keep it simple
@@ -496,6 +501,13 @@ export default class View extends React.Component {
 
         {isStructured && this.renderAggregationPopover()}
         {isStructured && this.renderBreakoutPopover()}
+        <Toaster
+          message={t`Would you like to be notified when this question is done loading?`}
+          isShown={isShowingToaster}
+          onDismiss={onDismissToast}
+          onConfirm={onConfirmToast}
+          fixed
+        />
       </div>
     );
   }
diff --git a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
index b6f34b6f3aa..3f82b6f2bba 100644
--- a/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
+++ b/frontend/src/metabase/query_builder/containers/QueryBuilder.jsx
@@ -1,5 +1,11 @@
 /* eslint-disable react/prop-types */
-import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import React, {
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from "react";
 import { connect } from "react-redux";
 import { push } from "react-router-redux";
 import { t } from "ttag";
@@ -21,6 +27,8 @@ import { useForceUpdate } from "metabase/hooks/use-force-update";
 import { useOnMount } from "metabase/hooks/use-on-mount";
 import { useOnUnmount } from "metabase/hooks/use-on-unmount";
 import { usePrevious } from "metabase/hooks/use-previous";
+import { useLoadingTimer } from "metabase/hooks/use-loading-timer";
+import { useWebNotification } from "metabase/hooks/use-web-notification";
 
 import title from "metabase/hoc/Title";
 import titleWithLoadingTime from "metabase/hoc/TitleWithLoadingTime";
@@ -213,6 +221,7 @@ function QueryBuilder(props) {
     deleteBookmark,
     allLoaded,
     showTimelinesForCollection,
+    card,
   } = props;
 
   const forceUpdate = useForceUpdate();
@@ -356,6 +365,49 @@ function QueryBuilder(props) {
     }
   });
 
+  const { isRunning } = uiControls;
+
+  const [shouldSendNotification, setShouldSendNotification] = useState(false);
+  const [isShowingToaster, setIsShowingToaster] = useState(false);
+
+  const onTimeout = useCallback(() => {
+    setIsShowingToaster(true);
+  }, []);
+
+  useLoadingTimer(isRunning, {
+    timer: 15000,
+    onTimeout,
+  });
+
+  const [requestPermission, showNotification] = useWebNotification();
+
+  useEffect(() => {
+    if (!isRunning) {
+      setIsShowingToaster(false);
+    }
+    if (!isRunning && shouldSendNotification) {
+      if (document.hidden) {
+        showNotification(
+          t`All Set! Your question is ready.`,
+          t`${card.name} is loaded.`,
+        );
+      }
+      setShouldSendNotification(false);
+    }
+  }, [isRunning, shouldSendNotification, showNotification, card?.name]);
+
+  const onConfirmToast = useCallback(async () => {
+    const result = await requestPermission();
+    if (result === "granted") {
+      setIsShowingToaster(false);
+      setShouldSendNotification(true);
+    }
+  }, [requestPermission]);
+
+  const onDismissToast = useCallback(() => {
+    setIsShowingToaster(false);
+  }, []);
+
   return (
     <View
       {...props}
@@ -368,6 +420,9 @@ function QueryBuilder(props) {
       onCreate={handleCreate}
       handleResize={forceUpdateDebounced}
       toggleBookmark={onClickBookmark}
+      onDismissToast={onDismissToast}
+      onConfirmToast={onConfirmToast}
+      isShowingToaster={isShowingToaster}
     />
   );
 }
-- 
GitLab