Skip to content
Snippets Groups Projects
Unverified Commit 3567cea7 authored by Nick Fitzpatrick's avatar Nick Fitzpatrick Committed by GitHub
Browse files

Dashboard / Question Toaster and web notifications (#21414)

Dashboard / Question Toaster and web notifications
parent 1ac14b25
No related merge requests found
Showing with 335 additions and 42 deletions
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");
},
};
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)};
}
`;
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;
export { default } from "./Toaster";
/* 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);
......@@ -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 =>
......
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]);
}
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];
}
......@@ -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>
);
}
......
/* 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}
/>
);
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment