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 0000000000000000000000000000000000000000..2accc0ce798c6b7a39dbf2dd41dd1d2a2fa07b6f --- /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 0000000000000000000000000000000000000000..e78e841f10fdb53cb78274d5953409ac2c6ea373 --- /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 0000000000000000000000000000000000000000..c5f4bb0f2097589ba75a9907730028aac577b1ca --- /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 0000000000000000000000000000000000000000..fb156d44a25ed99f1cdf5f87616ca92bddbb11a5 --- /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 da582fcb91aa24b003800fbb6c7e55e8490ba55d..7a5b93546b93f12160735a00016af891aa2d3c64 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 b8a4e30ed6493ee13a82810262f961e0530b1aea..158ea6a0af58aff3473bfc349334ba905f6c3ac5 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 0000000000000000000000000000000000000000..9be44b663123e224f3a666f62088dba0659342fb --- /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 0000000000000000000000000000000000000000..51a6b7950c296e0a30276cb48dd136725fe7d628 --- /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 56defd6d35bdb8bc1a3ecba3be22a74bcdfe3e8b..f2e8ebf2f4c5ee5d0e1cc7ab12c8ce334c19af85 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 b6f34b6f3aa9b2c52eeaf19a5ec16d0dc949f2e2..3f82b6f2bba3fc63e21b3b3dcc0ec6d3d57c3164 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} /> ); }