From 6152fb7706f044199cf812644a4125410c74c3b4 Mon Sep 17 00:00:00 2001
From: Ben Wheeler <wheeler.benjamin@gmail.com>
Date: Wed, 14 Nov 2018 15:14:18 -0500
Subject: [PATCH] menu bar save messaging; self-dismissing alerts

---
 package.json                              |  1 +
 src/components/alerts/alert.jsx           | 28 +++++-----
 src/components/alerts/alerts.jsx          |  1 +
 src/components/alerts/inline-message.css  | 27 ++++++++++
 src/components/alerts/inline-message.jsx  | 39 ++++++++++++++
 src/components/menu-bar/menu-bar.jsx      |  4 ++
 src/components/spinner/spinner.css        | 12 ++++-
 src/components/spinner/spinner.jsx        |  9 ++--
 src/containers/alert.jsx                  |  3 ++
 src/containers/alerts.jsx                 |  7 ++-
 src/containers/inline-messages.jsx        | 55 ++++++++++++++++++++
 src/css/colors.css                        |  2 +
 src/lib/alerts/index.jsx                  | 29 ++++++++---
 src/lib/app-state-hoc.jsx                 | 12 +++--
 src/lib/project-saver-hoc.jsx             | 21 ++++----
 src/reducers/alerts.js                    | 63 ++++++++++++++++++++---
 test/unit/reducers/alerts-reducer.test.js | 23 +++++++--
 17 files changed, 288 insertions(+), 48 deletions(-)
 create mode 100644 src/components/alerts/inline-message.css
 create mode 100644 src/components/alerts/inline-message.jsx
 create mode 100644 src/containers/inline-messages.jsx

diff --git a/package.json b/package.json
index aa891d5be..5334feda6 100644
--- a/package.json
+++ b/package.json
@@ -97,6 +97,7 @@
     "react-tooltip": "3.8.0",
     "react-virtualized": "9.20.1",
     "redux": "3.7.2",
+    "redux-thunk": "2.0.1",
     "redux-mock-store": "^1.2.3",
     "redux-throttle": "0.1.1",
     "rimraf": "^2.6.1",
diff --git a/src/components/alerts/alert.jsx b/src/components/alerts/alert.jsx
index 89d7f6b90..04ea672c4 100644
--- a/src/components/alerts/alert.jsx
+++ b/src/components/alerts/alert.jsx
@@ -17,6 +17,7 @@ const closeButtonColors = {
 
 const AlertComponent = ({
     content,
+    closeButton,
     iconSpinner,
     iconURL,
     level,
@@ -44,7 +45,7 @@ const AlertComponent = ({
                 {content}
             </div>
         </div>
-        {showReconnect ? (
+        {showReconnect && (
             <button
                 className={styles.connectionButton}
                 onClick={onReconnect}
@@ -55,21 +56,24 @@ const AlertComponent = ({
                     id="gui.connection.reconnect"
                 />
             </button>
-        ) : null}
-        <Box
-            className={styles.alertCloseButtonContainer}
-        >
-            <CloseButton
-                className={classNames(styles.alertCloseButton)}
-                color={closeButtonColors[level]}
-                size={CloseButton.SIZE_LARGE}
-                onClick={onCloseAlert}
-            />
-        </Box>
+        )}
+        {closeButton && (
+            <Box
+                className={styles.alertCloseButtonContainer}
+            >
+                <CloseButton
+                    className={classNames(styles.alertCloseButton)}
+                    color={closeButtonColors[level]}
+                    size={CloseButton.SIZE_LARGE}
+                    onClick={onCloseAlert}
+                />
+            </Box>
+        )}
     </Box>
 );
 
 AlertComponent.propTypes = {
+    closeButton: PropTypes.bool,
     content: PropTypes.element,
     iconSpinner: PropTypes.bool,
     iconURL: PropTypes.string,
diff --git a/src/components/alerts/alerts.jsx b/src/components/alerts/alerts.jsx
index 115f28c9f..0ae89dfb0 100644
--- a/src/components/alerts/alerts.jsx
+++ b/src/components/alerts/alerts.jsx
@@ -15,6 +15,7 @@ const AlertsComponent = ({
     >
         {alertsList.map((a, index) => (
             <Alert
+                closeButton={a.closeButton}
                 content={a.content}
                 extensionId={a.extensionId}
                 iconSpinner={a.iconSpinner}
diff --git a/src/components/alerts/inline-message.css b/src/components/alerts/inline-message.css
new file mode 100644
index 000000000..07bba4e4e
--- /dev/null
+++ b/src/components/alerts/inline-message.css
@@ -0,0 +1,27 @@
+@import "../../css/colors.css";
+@import "../../css/units.css";
+
+.inline-message {
+    color: $ui-white;
+    font-family: "Helvetica Neue";
+    display: flex;
+    justify-content: end;
+    align-items: center;
+    font-size: .8125rem;
+}
+
+.success {
+    color: $ui-white-dim;
+}
+
+.info {
+    color: $ui-white;
+}
+
+.warn {
+    color: $error-light;
+}
+
+.spinner {
+    margin-right: $space;
+}
diff --git a/src/components/alerts/inline-message.jsx b/src/components/alerts/inline-message.jsx
new file mode 100644
index 000000000..8a7330f79
--- /dev/null
+++ b/src/components/alerts/inline-message.jsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import Spinner from '../spinner/spinner.jsx';
+import {AlertLevels} from '../../lib/alerts/index.jsx';
+
+import styles from './inline-message.css';
+
+const InlineMessageComponent = ({
+    content,
+    iconSpinner,
+    level
+}) => (
+    <div
+        className={classNames(styles.inlineMessage, styles[level])}
+    >
+        {/* TODO: implement Rtl handling */}
+        {iconSpinner && (
+            <Spinner
+                small
+                className={styles.spinner}
+            />
+        )}
+        {content}
+    </div>
+);
+
+InlineMessageComponent.propTypes = {
+    content: PropTypes.element,
+    iconSpinner: PropTypes.bool,
+    level: PropTypes.string
+};
+
+InlineMessageComponent.defaultProps = {
+    level: AlertLevels.INFO
+};
+
+export default InlineMessageComponent;
diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx
index 12fe0c273..1102e619b 100644
--- a/src/components/menu-bar/menu-bar.jsx
+++ b/src/components/menu-bar/menu-bar.jsx
@@ -12,6 +12,7 @@ import ShareButton from './share-button.jsx';
 import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
 import Divider from '../divider/divider.jsx';
 import LanguageSelector from '../../containers/language-selector.jsx';
+import InlineMessages from '../../containers/inline-messages.jsx';
 import SBFileUploader from '../../containers/sb-file-uploader.jsx';
 import ProjectWatcher from '../../containers/project-watcher.jsx';
 import MenuBarMenu from './menu-bar-menu.jsx';
@@ -531,6 +532,9 @@ class MenuBar extends React.Component {
                 {/* show the proper UI in the account menu, given whether the user is
                 logged in, and whether a session is available to log in with */}
                 <div className={styles.accountInfoGroup}>
+                    <div className={styles.menuBarItem}>
+                        <InlineMessages />
+                    </div>
                     {this.props.sessionExists ? (
                         this.props.username ? (
                             // ************ user is logged in ************
diff --git a/src/components/spinner/spinner.css b/src/components/spinner/spinner.css
index 753cb2056..a20e79b13 100644
--- a/src/components/spinner/spinner.css
+++ b/src/components/spinner/spinner.css
@@ -11,7 +11,7 @@
     border-color: $ui-white-transparent;
 }
 
-.spinner::after, .spinner::before {
+.spinner::before, .spinner::after {
     width: 1rem;
     height: 1rem;
     content: '';
@@ -28,6 +28,16 @@
     animation: spin 1.5s cubic-bezier(0.4, 0.1, 0.4, 1) infinite;
 }
 
+.small {
+    width: .5rem;
+    height: .5rem;
+}
+
+.small::before, .small::after {
+    width: .5rem;
+    height: .5rem;
+}
+
 @keyframes spin {
   0% {
     transform: rotate(0deg);
diff --git a/src/components/spinner/spinner.jsx b/src/components/spinner/spinner.jsx
index 08685ccb3..bb82fbf51 100644
--- a/src/components/spinner/spinner.jsx
+++ b/src/components/spinner/spinner.jsx
@@ -6,19 +6,22 @@ import styles from './spinner.css';
 
 const SpinnerComponent = function (props) {
     const {
-        className
+        className,
+        small
     } = props;
     return (
         <div
             className={classNames(
+                className,
                 styles.spinner,
-                className
+                {[styles.small]: small}
             )}
         />
     );
 };
 SpinnerComponent.propTypes = {
-    className: PropTypes.string
+    className: PropTypes.string,
+    small: PropTypes.bool
 };
 SpinnerComponent.defaultProps = {
     className: ''
diff --git a/src/containers/alert.jsx b/src/containers/alert.jsx
index f22cf06fb..9235d8d8a 100644
--- a/src/containers/alert.jsx
+++ b/src/containers/alert.jsx
@@ -24,6 +24,7 @@ class Alert extends React.Component {
     }
     render () {
         const {
+            closeButton,
             content,
             index, // eslint-disable-line no-unused-vars
             level,
@@ -34,6 +35,7 @@ class Alert extends React.Component {
         } = this.props;
         return (
             <AlertComponent
+                closeButton={closeButton}
                 content={content}
                 iconSpinner={iconSpinner}
                 iconURL={iconURL}
@@ -57,6 +59,7 @@ const mapDispatchToProps = dispatch => ({
 });
 
 Alert.propTypes = {
+    closeButton: PropTypes.bool,
     content: PropTypes.element,
     extensionId: PropTypes.string,
     iconSpinner: PropTypes.bool,
diff --git a/src/containers/alerts.jsx b/src/containers/alerts.jsx
index 21a17700a..5ac3f0a7a 100644
--- a/src/containers/alerts.jsx
+++ b/src/containers/alerts.jsx
@@ -1,6 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import {connect} from 'react-redux';
+import {AlertTypes} from '../lib/alerts/index.jsx';
 
 import {
     closeAlert
@@ -14,7 +15,11 @@ const Alerts = ({
     onCloseAlert
 }) => (
     <AlertsComponent
-        alertsList={alertsList}
+        // only display standard and extension alerts here
+        alertsList={alertsList.filter(curAlert => (
+            curAlert.alertType === AlertTypes.STANDARD ||
+            curAlert.alertType === AlertTypes.EXTENSION
+        ))}
         className={className}
         onCloseAlert={onCloseAlert}
     />
diff --git a/src/containers/inline-messages.jsx b/src/containers/inline-messages.jsx
new file mode 100644
index 000000000..a55bc8997
--- /dev/null
+++ b/src/containers/inline-messages.jsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {connect} from 'react-redux';
+import {AlertTypes} from '../lib/alerts/index.jsx';
+
+import InlineMessageComponent from '../components/alerts/inline-message.jsx';
+
+const InlineMessages = ({
+    alertsList,
+    className
+}) => {
+    if (!alertsList) {
+        return null;
+    }
+    // only display inline alerts here
+    const inlineAlerts = alertsList.filter(curAlert => (
+        curAlert.alertType === AlertTypes.INLINE
+    ));
+    if (!inlineAlerts || !inlineAlerts.length) {
+        return null;
+    }
+
+    // get first alert
+    const firstInlineAlert = inlineAlerts[0];
+    const {
+        content,
+        iconSpinner,
+        level
+    } = firstInlineAlert;
+
+    return (
+        <InlineMessageComponent
+            className={className}
+            content={content}
+            iconSpinner={iconSpinner}
+            level={level}
+        />
+    );
+};
+
+InlineMessages.propTypes = {
+    alertsList: PropTypes.arrayOf(PropTypes.object),
+    className: PropTypes.string
+};
+
+const mapStateToProps = state => ({
+    alertsList: state.scratchGui.alerts.alertsList
+});
+
+const mapDispatchToProps = () => ({});
+
+export default connect(
+    mapStateToProps,
+    mapDispatchToProps
+)(InlineMessages);
diff --git a/src/css/colors.css b/src/css/colors.css
index fd930ed62..9caf8430d 100644
--- a/src/css/colors.css
+++ b/src/css/colors.css
@@ -5,6 +5,7 @@ $ui-tertiary: hsla(215, 50%, 90%, 1); /* #D9E3F2 */
 $ui-modal-overlay: hsla(215, 100%, 65%, 0.9); /* 90% transparent version of motion-primary */
 
 $ui-white: hsla(0, 100%, 100%, 1); /* #FFFFFF */
+$ui-white-dim: hsla(0, 100%, 100%, 0.75); /* 25% transparent version of ui-white */
 $ui-white-transparent: hsla(0, 100%, 100%, 0.25); /* 25% transparent version of ui-white */
 $ui-transparent: hsla(0, 100%, 100%, 0); /* 25% transparent version of ui-white */
 
@@ -32,6 +33,7 @@ $pen-primary: hsla(163, 85%, 40%, 1); /* #0FBD8C */
 $pen-transparent: hsla(163, 85%, 40%, 0.25); /* #0FBD8C */
 
 $error-primary: hsla(30, 100%, 55%, 1); /* #FF8C1A */
+$error-light: hsla(30, 100%, 70%, 1); /* #FFB366 */
 $error-transparent: hsla(30, 100%, 55%, 0.25); /* #FF8C1A */
 
 $extensions-primary: hsla(163, 85%, 40%, 1); /* #0FBD8C */
diff --git a/src/lib/alerts/index.jsx b/src/lib/alerts/index.jsx
index bb4d60206..aec4134f2 100644
--- a/src/lib/alerts/index.jsx
+++ b/src/lib/alerts/index.jsx
@@ -1,17 +1,26 @@
 import React from 'react';
 import {FormattedMessage} from 'react-intl';
+import keyMirror from 'keymirror';
 
 import successImage from '../assets/icon--success.svg';
 
+const AlertTypes = keyMirror({
+    STANDARD: null,
+    EXTENSION: null,
+    INLINE: null
+});
+
 const AlertLevels = {
     SUCCESS: 'success',
+    INFO: 'info',
     WARN: 'warn'
 };
 
 const alerts = [
     {
         alertId: 'createSuccess',
-        clearList: ['creating'],
+        alertType: AlertTypes.STANDARD,
+        clearList: ['createSuccess, creating, saveSuccess, saving'],
         content: (
             <FormattedMessage
                 defaultMessage="Successfully created."
@@ -20,10 +29,13 @@ const alerts = [
             />
         ),
         iconURL: successImage,
-        level: AlertLevels.SUCCESS
+        level: AlertLevels.SUCCESS,
+        maxDisplaySecs: 5
     },
     {
         alertId: 'creating',
+        alertType: AlertTypes.STANDARD,
+        clearList: ['createSuccess, creating, saveSuccess, saving'],
         content: (
             <FormattedMessage
                 defaultMessage="Creating..."
@@ -58,7 +70,8 @@ const alerts = [
     },
     {
         alertId: 'saveSuccess',
-        clearList: ['saving'],
+        alertType: AlertTypes.INLINE,
+        clearList: ['createSuccess, creating, saveSuccess, saving'],
         content: (
             <FormattedMessage
                 defaultMessage="Successfully saved."
@@ -67,10 +80,13 @@ const alerts = [
             />
         ),
         iconURL: successImage,
-        level: AlertLevels.SUCCESS
+        level: AlertLevels.SUCCESS,
+        maxDisplaySecs: 5
     },
     {
         alertId: 'saving',
+        alertType: AlertTypes.INLINE,
+        clearList: ['createSuccess, creating, saveSuccess, saving'],
         content: (
             <FormattedMessage
                 defaultMessage="Saving..."
@@ -79,11 +95,12 @@ const alerts = [
             />
         ),
         iconSpinner: true,
-        level: AlertLevels.SUCCESS
+        level: AlertLevels.INFO
     }
 ];
 
 export {
     alerts as default,
-    AlertLevels
+    AlertLevels,
+    AlertTypes
 };
diff --git a/src/lib/app-state-hoc.jsx b/src/lib/app-state-hoc.jsx
index 9177c5219..4439d277b 100644
--- a/src/lib/app-state-hoc.jsx
+++ b/src/lib/app-state-hoc.jsx
@@ -1,7 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import {Provider} from 'react-redux';
-import {createStore, combineReducers, compose} from 'redux';
+import {applyMiddleware, createStore, combineReducers, compose} from 'redux';
+import thunk from 'redux-thunk';
 import ConnectedIntlProvider from './connected-intl-provider.jsx';
 
 import localesReducer, {initLocale, localesInitialState} from '../reducers/locales';
@@ -45,7 +46,9 @@ const AppStateHOC = function (WrappedComponent, localesOnly) {
                 // browser modal
                 reducers = {locales: localesReducer};
                 initialState = {locales: initializedLocales};
-                enhancer = composeEnhancers();
+                enhancer = composeEnhancers(
+                    applyMiddleware(thunk)
+                );
             } else {
                 // You are right, this is gross. But it's necessary to avoid
                 // importing unneeded code that will crash unsupported browsers.
@@ -85,7 +88,10 @@ const AppStateHOC = function (WrappedComponent, localesOnly) {
                     locales: initializedLocales,
                     scratchGui: initializedGui
                 };
-                enhancer = composeEnhancers(guiMiddleware);
+                enhancer = composeEnhancers(
+                    guiMiddleware,
+                    applyMiddleware(thunk)
+                );
             }
             const reducer = combineReducers(reducers);
             this.store = createStore(
diff --git a/src/lib/project-saver-hoc.jsx b/src/lib/project-saver-hoc.jsx
index 631fc7798..9389d3c6d 100644
--- a/src/lib/project-saver-hoc.jsx
+++ b/src/lib/project-saver-hoc.jsx
@@ -5,7 +5,10 @@ import VM from 'scratch-vm';
 
 import log from '../lib/log';
 import storage from '../lib/storage';
-import {showStandardAlert} from '../reducers/alerts';
+import {
+    showAlertWithTimeout,
+    showStandardAlert
+} from '../reducers/alerts';
 import {
     LoadingStates,
     autoUpdateProject,
@@ -73,14 +76,10 @@ const ProjectSaverHOC = function (WrappedComponent) {
             }
         }
         updateProjectToStorage () {
-            if (this.props.isManualUpdating) {
-                this.props.onShowSavingAlert();
-            }
+            this.props.onShowSavingAlert();
             return this.storeProject(this.props.reduxProjectId)
                 .then(() => {
-                    if (this.props.isManualUpdating) {
-                        this.props.onShowSaveSuccessAlert();
-                    }
+                    this.props.onShowSaveSuccessAlert();
                     // there is nothing we expect to find in response that we need to check here
                     this.props.onUpdatedProject(this.props.loadingState);
                 })
@@ -260,10 +259,10 @@ const ProjectSaverHOC = function (WrappedComponent) {
         onManualUpdateProject: () => dispatch(manualUpdateProject()),
         onProjectError: error => dispatch(projectError(error)),
         onShowAlert: alertType => dispatch(showStandardAlert(alertType)),
-        onShowCreateSuccessAlert: () => dispatch(showStandardAlert('createSuccess')),
-        onShowCreatingAlert: () => dispatch(showStandardAlert('creating')),
-        onShowSaveSuccessAlert: () => dispatch(showStandardAlert('saveSuccess')),
-        onShowSavingAlert: () => dispatch(showStandardAlert('saving')),
+        onShowCreateSuccessAlert: () => dispatch(showAlertWithTimeout('createSuccess')),
+        onShowCreatingAlert: () => dispatch(showAlertWithTimeout('creating')),
+        onShowSaveSuccessAlert: () => dispatch(showAlertWithTimeout('saveSuccess')),
+        onShowSavingAlert: () => dispatch(showAlertWithTimeout('saving')),
         onUpdatedProject: (projectId, loadingState) => dispatch(doneUpdatingProject(projectId, loadingState))
     });
     // Allow incoming props to override redux-provided props. Used to mock in tests.
diff --git a/src/reducers/alerts.js b/src/reducers/alerts.js
index d542c4037..793c47d39 100644
--- a/src/reducers/alerts.js
+++ b/src/reducers/alerts.js
@@ -1,26 +1,30 @@
 import alertsData from '../lib/alerts/index.jsx';
-import {AlertLevels} from '../lib/alerts/index.jsx';
+import {AlertTypes, AlertLevels} from '../lib/alerts/index.jsx';
 import extensionData from '../lib/libraries/extensions/index.jsx';
 
 const SHOW_STANDARD_ALERT = 'scratch-gui/alerts/SHOW_STANDARD_ALERT';
 const SHOW_EXTENSION_ALERT = 'scratch-gui/alerts/SHOW_EXTENSION_ALERT';
 const CLOSE_ALERT = 'scratch-gui/alerts/CLOSE_ALERT';
+const CLOSE_ALERTS_WITH_ID = 'scratch-gui/alerts/CLOSE_ALERTS_WITH_ID';
 
 const initialState = {
     visible: true,
     // list of alerts, each with properties:
+    // * alert type (required): one of AlertTypes
+    // * closeButton (optional): bool indicating that we should show close button
     // * content (optional): react element (a <FormattedMessage />)
     // * extentionId (optional): id string that identifies the extension
     // * iconURL (optional): string
     // * level (required): string, one of AlertLevels
     // * message (optional): string
+    // * showReconnect (optional): bool
     alertsList: []
 };
 
 const reducer = function (state, action) {
     if (typeof state === 'undefined') state = initialState;
     switch (action.type) {
-    case SHOW_STANDARD_ALERT: {
+    case SHOW_STANDARD_ALERT: { // also will show inline alerts
         const alertId = action.alertId;
         if (alertId) {
             const newAlert = {
@@ -29,20 +33,21 @@ const reducer = function (state, action) {
             };
             const alertData = alertsData.find(thisAlertData => thisAlertData.alertId === alertId);
             if (alertData) {
-                let newList = state.alertsList.slice();
-                newList = newList.filter(curAlert => (
+                const newList = state.alertsList.filter(curAlert => (
                     !alertData.clearList || alertData.clearList.indexOf(curAlert.alertId)
                 ));
                 if (action.data && action.data.message) {
                     newAlert.message = action.data.message;
                 }
 
+                newAlert.alertType = alertData.alertType || AlertTypes.STANDARD;
+                newAlert.closeButton = alertData.closeButton;
                 newAlert.content = alertData.content;
                 newAlert.iconURL = alertData.iconURL;
                 newAlert.iconSpinner = alertData.iconSpinner;
                 newAlert.level = alertData.level;
-                newList.push(newAlert);
 
+                newList.push(newAlert);
                 return Object.assign({}, state, {
                     alertsList: newList
                 });
@@ -53,6 +58,7 @@ const reducer = function (state, action) {
     case SHOW_EXTENSION_ALERT: {
         const newList = state.alertsList.slice();
         const newAlert = {
+            alertType: AlertTypes.EXTENSION,
             message: action.data.message,
             level: AlertLevels.WARN
         };
@@ -69,6 +75,7 @@ const reducer = function (state, action) {
                 if (extension.smallPeripheralImage) {
                     newAlert.iconURL = extension.smallPeripheralImage;
                 }
+                newAlert.closeButton = true;
             }
         }
         newList.push(newAlert);
@@ -83,13 +90,20 @@ const reducer = function (state, action) {
             alertsList: newList
         });
     }
+    case CLOSE_ALERTS_WITH_ID: {
+        return Object.assign({}, state, {
+            alertsList: state.alertsList.filter(curAlert => (
+                curAlert.alertId !== action.alertId
+            ))
+        });
+    }
     default:
         return state;
     }
 };
 
 /**
- * Function to close an alert with the given index.
+ * Action creator to close an alert with the given index.
  *
  * @param {object} index - the index of the alert to close.
  * @return {object} - an object to be passed to the reducer.
@@ -102,7 +116,20 @@ const closeAlert = function (index) {
 };
 
 /**
- * Function to show an alert with the given alertId.
+ * Action creator to close all alerts with a given ID.
+ *
+ * @param {string} alertId - id string of the alert to close
+ * @return {object} - an object to be passed to the reducer.
+ */
+const closeAlertsWithId = function (alertId) {
+    return {
+        type: CLOSE_ALERTS_WITH_ID,
+        alertId
+    };
+};
+
+/**
+ * Action creator to show an alert with the given alertId.
  *
  * @param {string} alertId - id string of the alert to show
  * @return {object} - an object to be passed to the reducer.
@@ -115,7 +142,7 @@ const showStandardAlert = function (alertId) {
 };
 
 /**
- * Function to show an alert with the given input data.
+ * Action creator to show an alert with the given input data.
  *
  * @param {object} data - data for the alert
  * @param {string} data.message - message for the alert
@@ -129,10 +156,30 @@ const showExtensionAlert = function (data) {
     };
 };
 
+/**
+ * Function to dispatch showing an alert, with optional
+ * timeout to make it close/go away.
+ *
+ * @param {object} alertId - the ID of the alert
+ * @return {null} - do not return a value
+ */
+const showAlertWithTimeout = alertId => (dispatch => {
+    const alertData = alertsData.find(thisAlertData => thisAlertData.alertId === alertId);
+    if (alertData) {
+        dispatch(showStandardAlert(alertId));
+        if (alertData.maxDisplaySecs) {
+            setTimeout(() => {
+                dispatch(closeAlertsWithId(alertId));
+            }, alertData.maxDisplaySecs * 1000);
+        }
+    }
+});
+
 export {
     reducer as default,
     initialState as alertsInitialState,
     closeAlert,
+    showAlertWithTimeout,
     showExtensionAlert,
     showStandardAlert
 };
diff --git a/test/unit/reducers/alerts-reducer.test.js b/test/unit/reducers/alerts-reducer.test.js
index b289813e3..21c1de015 100644
--- a/test/unit/reducers/alerts-reducer.test.js
+++ b/test/unit/reducers/alerts-reducer.test.js
@@ -1,7 +1,7 @@
 // TODO: add tests of extension alerts
 
 /* eslint-env jest */
-import {AlertLevels} from '../../../src/lib/alerts/index.jsx';
+import {AlertTypes, AlertLevels} from '../../../src/lib/alerts/index.jsx';
 import alertsReducer from '../../../src/reducers/alerts';
 import {
     closeAlert,
@@ -18,10 +18,11 @@ test('initialState', () => {
 
 test('create one standard alert', () => {
     let defaultState;
-    const action = showStandardAlert('saving');
+    const action = showStandardAlert('creating');
     const resultState = alertsReducer(defaultState, action);
     expect(resultState.alertsList.length).toBe(1);
-    expect(resultState.alertsList[0].alertId).toBe('saving');
+    expect(resultState.alertsList[0].alertId).toBe('creating');
+    expect(resultState.alertsList[0].alertType).toBe(AlertTypes.STANDARD);
     expect(resultState.alertsList[0].level).toBe(AlertLevels.SUCCESS);
 });
 
@@ -31,6 +32,7 @@ test('add several standard alerts', () => {
         alertsList: [
             {
                 alertId: 'saving',
+                alertType: AlertTypes.INLINE,
                 level: AlertLevels.SUCCESS,
                 content: null,
                 iconURL: '/no_image_here.jpg'
@@ -42,18 +44,31 @@ test('add several standard alerts', () => {
     resultState = alertsReducer(resultState, action);
     resultState = alertsReducer(resultState, action);
     expect(resultState.alertsList.length).toBe(4);
+    expect(resultState.alertsList[0].alertType).toBe(AlertTypes.INLINE);
     expect(resultState.alertsList[0].iconURL).toBe('/no_image_here.jpg');
+    expect(resultState.alertsList[1].alertType).toBe(AlertTypes.STANDARD);
     expect(resultState.alertsList[1].alertId).toBe('creating');
     expect(resultState.alertsList[2].alertId).toBe('creating');
     expect(resultState.alertsList[3].alertId).toBe('creating');
 });
 
+test('create one inline alert message', () => {
+    let defaultState;
+    const action = showStandardAlert('saving');
+    const resultState = alertsReducer(defaultState, action);
+    expect(resultState.alertsList.length).toBe(1);
+    expect(resultState.alertsList[0].alertId).toBe('saving');
+    expect(resultState.alertsList[0].alertType).toBe(AlertTypes.INLINE);
+    expect(resultState.alertsList[0].level).toBe(AlertLevels.INFO);
+});
+
 test('can close alerts by index', () => {
     const initialState = {
         visible: true,
         alertsList: [
             {
                 alertId: 'saving',
+                alertType: AlertTypes.INLINE,
                 level: AlertLevels.SUCCESS,
                 content: null,
                 iconURL: '/no_image_here.jpg'
@@ -78,12 +93,14 @@ test('related alerts can clear each other', () => {
         alertsList: [
             {
                 alertId: 'saving',
+                alertType: AlertTypes.INLINE,
                 level: AlertLevels.SUCCESS,
                 content: null,
                 iconURL: '/no_image_here.jpg'
             },
             {
                 alertId: 'creating',
+                alertType: AlertTypes.STANDARD,
                 level: AlertLevels.SUCCESS,
                 content: null,
                 iconURL: '/no_image_here.jpg'
-- 
GitLab