Просмотр исходного кода

Add persisted notifications support

Some notifications are persisted in browser's local storage
They are cleared when notification's dropdown is closed
Sergiu Miclea 8 лет назад
Родитель
Сommit
4ad9de8d65

+ 51 - 0
src/actions/NotificationActions.js

@@ -14,10 +14,61 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import alt from '../alt'
 
+import NotificationSource from '../sources/NotificationSource'
+
 class NotificationActions {
   notify(message, level, options) {
+    if (options && options.persist) {
+      NotificationSource.notify(message, level, options).then(
+        notification => { this.notifySuccess(notification) },
+        response => { this.notifyFailed(response) }
+      )
+    }
+
     return { message, level, ...options }
   }
+
+  notifySuccess(notification) {
+    return notification
+  }
+
+  notifyFailed(response) {
+    return response || true
+  }
+
+  loadNotifications() {
+    NotificationSource.loadNotifications().then(
+      notifications => { this.loadNotificationsSuccess(notifications) },
+      response => { this.loadNotificationsFailed(response) }
+    )
+
+    return true
+  }
+
+  loadNotificationsSuccess(notifications) {
+    return notifications
+  }
+
+  loadNotificationsFailed(response) {
+    return response || true
+  }
+
+  clearNotifications() {
+    NotificationSource.clearNotifications().then(
+      () => { this.clearNotificationsSuccess() },
+      response => { this.clearNotificationsFailed(response) }
+    )
+
+    return true
+  }
+
+  clearNotificationsSuccess() {
+    return true
+  }
+
+  clearNotificationsFailed(response) {
+    return response || true
+  }
 }
 
 export default alt.createActions(NotificationActions)

+ 23 - 31
src/components/molecules/NotificationDropdown/NotificationDropdown.jsx

@@ -15,6 +15,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import React from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
+import moment from 'moment'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
@@ -111,11 +112,11 @@ const Title = styled.div`
   margin-bottom: 14px;
 `
 
-const getTypeIcon = props => {
-  if (props.success) {
+const getTypeIcon = level => {
+  if (level === 'success') {
     return successImage
   }
-  if (props.error) {
+  if (level === 'error') {
     return errorImage
   }
   return infoImage
@@ -123,7 +124,7 @@ const getTypeIcon = props => {
 const TypeIcon = styled.div`
   width: 16px;
   height: 16px;
-  background: url('${props => getTypeIcon(props)}') no-repeat center;
+  background: url('${props => getTypeIcon(props.level)}') no-repeat center;
   margin-right: 8px;
 `
 const TitleLabel = styled.div`flex-grow: 1;`
@@ -135,9 +136,9 @@ const NoItems = styled.div`
 
 class NotificationDropdown extends React.Component {
   static propTypes = {
-    onItemClick: PropTypes.func,
     white: PropTypes.bool,
     items: PropTypes.array,
+    onClose: PropTypes.func,
   }
 
   constructor() {
@@ -145,22 +146,6 @@ class NotificationDropdown extends React.Component {
 
     this.state = {
       showDropdownList: false,
-      // items: [{
-      //   title: 'Migration',
-      //   time: '12:53 PM',
-      //   description: 'A full VM migration between two clouds',
-      //   icon: { info: true },
-      // }, {
-      //   title: 'Replica',
-      //   time: '12:53 PM',
-      //   description: 'Incrementally replicate virtual machines',
-      //   icon: { error: true },
-      // }, {
-      //   title: 'Endpoint',
-      //   time: '12:53 PM',
-      //   description: 'A conection to a public or private cloud',
-      //   icon: { success: true },
-      // }],
     }
 
     this.handlePageClick = this.handlePageClick.bind(this)
@@ -174,21 +159,25 @@ class NotificationDropdown extends React.Component {
     window.removeEventListener('mousedown', this.handlePageClick, false)
   }
 
-  handleItemClick(item) {
+  handleItemClick() {
     this.setState({ showDropdownList: false })
-
-    if (this.props.onItemClick) {
-      this.props.onItemClick(item)
-    }
+    this.props.onClose()
   }
 
   handlePageClick() {
     if (!this.itemMouseDown) {
+      if (this.state.showDropdownList) {
+        this.props.onClose()
+      }
       this.setState({ showDropdownList: false })
     }
   }
 
   handleButtonClick() {
+    if (this.state.showDropdownList) {
+      this.props.onClose()
+    }
+
     this.setState({ showDropdownList: !this.state.showDropdownList })
   }
 
@@ -217,6 +206,9 @@ class NotificationDropdown extends React.Component {
     let list = (
       <List>
         {this.props.items.map(item => {
+          let title = (item.options.persistInfo && item.options.persistInfo.title) || item.message
+          let message = title === item.message ? '' : item.message
+
           return (
             <ListItem
               key={item.title}
@@ -225,11 +217,11 @@ class NotificationDropdown extends React.Component {
               onClick={() => { this.handleItemClick(item) }}
             >
               <Title>
-                <TypeIcon {...item.icon} />
-                <TitleLabel>{item.title}</TitleLabel>
-                <Time>{item.time}</Time>
+                <TypeIcon level={item.level} />
+                <TitleLabel>{title}</TitleLabel>
+                <Time>{moment(item.id).format('HH:mm')}</Time>
               </Title>
-              <Description>{item.description}</Description>
+              <Description>{message}</Description>
             </ListItem>
           )
         })}
@@ -239,7 +231,7 @@ class NotificationDropdown extends React.Component {
     return list
   }
   renderBell() {
-    let badge = this.props.items && this.props.items.length > 1 ? (
+    let badge = this.props.items && this.props.items.length >= 1 ? (
       <Badge>
         <BadgeLabel>{this.props.items.length}</BadgeLabel>
       </Badge>

+ 25 - 2
src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx

@@ -15,9 +15,13 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import React from 'react'
 import PropTypes from 'prop-types'
 import styled from 'styled-components'
+import connectToStores from 'alt-utils/lib/connectToStores'
 
 import { SideMenu, NotificationDropdown, UserDropdown } from 'components'
 
+import NotificationActions from '../../../actions/NotificationActions'
+import NotificationStore from '../../../stores/NotificationStore'
+
 import backgroundImage from './images/star-bg.jpg'
 import logoImage from './images/logo.svg'
 
@@ -51,6 +55,25 @@ class DetailsPageHeader extends React.Component {
   static propTypes = {
     user: PropTypes.object,
     onUserItemClick: PropTypes.func,
+    notificationStore: PropTypes.object,
+  }
+
+  static getStores() {
+    return [NotificationStore]
+  }
+
+  static getPropsFromStores() {
+    return {
+      notificationStore: NotificationStore.getState(),
+    }
+  }
+
+  componentDidMount() {
+    NotificationActions.loadNotifications()
+  }
+
+  handleNotificationsClose() {
+    NotificationActions.clearNotifications()
   }
 
   render() {
@@ -61,7 +84,7 @@ class DetailsPageHeader extends React.Component {
           <Logo href="/#/replicas" />
         </Menu>
         <User>
-          <NotificationDropdown white />
+          <NotificationDropdown white items={this.props.notificationStore.persistedNotifications} onClose={() => this.handleNotificationsClose()} />
           <UserDropdownStyled
             white
             user={this.props.user}
@@ -73,4 +96,4 @@ class DetailsPageHeader extends React.Component {
   }
 }
 
-export default DetailsPageHeader
+export default connectToStores(DetailsPageHeader)

+ 4 - 3
src/components/organisms/Notifications/Notifications.jsx

@@ -29,8 +29,7 @@ const Wrapper = styled.div``
 class Notifications extends React.Component {
   constructor() {
     super()
-
-    this.state = NotificationStore.getState()
+    this.notificationsCount = 0
   }
 
   componentDidMount() {
@@ -42,7 +41,7 @@ class Notifications extends React.Component {
   }
 
   onStoreChange(state) {
-    if (!state.notifications.length) {
+    if (!state.notifications.length || state.notifications.length <= this.notificationsCount) {
       return
     }
 
@@ -55,6 +54,8 @@ class Notifications extends React.Component {
       autoDismiss: 10,
       action: lastNotification.action,
     })
+
+    this.notificationsCount = state.notifications.length
   }
 
   render() {

+ 1 - 1
src/components/organisms/Notifications/NotificationsStyle.js

@@ -50,7 +50,7 @@ const NotificationsStyle = css`
     font-size: 16px !important;
     top: 8px !important;
     right: 8px !important;
-    text-indent: -2000px;
+    text-indent: -100000px;
     background: url('${closeImage}') center no-repeat !important;
   }
   .notification-title {

+ 14 - 2
src/components/organisms/PageHeader/PageHeader.jsx

@@ -30,6 +30,8 @@ import {
 import ProjectStore from '../../../stores/ProjectStore'
 import UserStore from '../../../stores/UserStore'
 import UserActions from '../../../actions/UserActions'
+import NotificationActions from '../../../actions/NotificationActions'
+import NotificationStore from '../../../stores/NotificationStore'
 import ProviderActions from '../../../actions/ProviderActions'
 import ProviderStore from '../../../stores/ProviderStore'
 import Palette from '../../styleUtils/Palette'
@@ -64,10 +66,11 @@ class PageHeader extends React.Component {
     projectStore: PropTypes.object,
     userStore: PropTypes.object,
     providerStore: PropTypes.object,
+    notificationStore: PropTypes.object,
   }
 
   static getStores() {
-    return [UserStore, ProjectStore, ProviderStore]
+    return [UserStore, ProjectStore, ProviderStore, NotificationStore]
   }
 
   static getPropsFromStores() {
@@ -75,6 +78,7 @@ class PageHeader extends React.Component {
       userStore: UserStore.getState(),
       projectStore: ProjectStore.getState(),
       providerStore: ProviderStore.getState(),
+      notificationStore: NotificationStore.getState(),
     }
   }
 
@@ -87,6 +91,10 @@ class PageHeader extends React.Component {
     }
   }
 
+  componentDidMount() {
+    NotificationActions.loadNotifications()
+  }
+
   getCurrentProject() {
     if (this.props.userStore.user && this.props.userStore.user.project) {
       return this.props.projectStore.projects.find(p => p.id === this.props.userStore.user.project.id)
@@ -123,6 +131,10 @@ class PageHeader extends React.Component {
     }
   }
 
+  handleNotificationsClose() {
+    NotificationActions.clearNotifications()
+  }
+
   handleCloseChooseProviderModal() {
     this.setState({ showChooseProviderModal: false })
   }
@@ -152,7 +164,7 @@ class PageHeader extends React.Component {
             labelField="name"
           />
           <NewItemDropdown onChange={item => { this.handleNewItem(item) }} />
-          <NotificationDropdown />
+          <NotificationDropdown items={this.props.notificationStore.persistedNotifications} onClose={() => this.handleNotificationsClose()} />
           <UserDropdown user={this.props.userStore.user} onItemClick={item => { this.handleUserItemClick(item) }} />
         </Controls>
         <Modal

+ 1 - 1
src/components/pages/WizardPage/WizardPage.jsx

@@ -203,7 +203,7 @@ class WizardPage extends React.Component {
 
   handleCreationSuccess(items) {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
-    NotificationActions.notify(`${typeLabel} was succesfully created`, 'success')
+    NotificationActions.notify(`${typeLabel} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${typeLabel} created` } })
 
     if (this.state.type === 'replica') {
       items.forEach(replica => {

+ 45 - 0
src/sources/NotificationSource.js

@@ -0,0 +1,45 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+class NotificationSource {
+  static notify(message, level, options) {
+    return new Promise(resolve => {
+      let notifications = JSON.parse(localStorage.getItem('notifications') || '[]')
+      let newItem = {
+        id: new Date().getTime(),
+        message,
+        level,
+        options,
+      }
+      notifications.push(newItem)
+      localStorage.setItem('notifications', JSON.stringify(notifications))
+      resolve(newItem)
+    })
+  }
+
+  static loadNotifications() {
+    return new Promise(resolve => {
+      resolve(JSON.parse(localStorage.getItem('notifications') || '[]'))
+    })
+  }
+
+  static clearNotifications() {
+    return new Promise(resolve => {
+      localStorage.setItem('notifications', '[]')
+      resolve()
+    })
+  }
+}
+
+export default NotificationSource

+ 4 - 1
src/sources/WizardSource.js

@@ -93,7 +93,10 @@ class WizardSource {
           }
         }, () => {
           count += 1
-          NotificationActions.notify(`Error while creating ${type} for instance ${instance.name}`, 'error')
+          NotificationActions.notify(`Error while creating ${type} for instance ${instance.name}`, 'error', {
+            persist: true,
+            persistInfo: { title: `${type} creation error` },
+          })
         })
       })
     })

+ 2 - 0
src/stores/MigrationStore.js

@@ -93,6 +93,8 @@ class MigrationStore {
             window.location.href = `/#/migration/tasks/${migration.id}`
           },
         },
+        persist: true,
+        persistInfo: { title: 'Migration created' },
       })
     }, 0)
   }

+ 16 - 0
src/stores/NotificationStore.js

@@ -19,9 +19,13 @@ class NotificationStore {
   constructor() {
     this.bindListeners({
       notify: NotificationActions.NOTIFY,
+      notifySuccess: NotificationActions.NOTIFY_SUCCESS,
+      loadNotificationsSuccess: NotificationActions.LOAD_NOTIFICATIONS_SUCCESS,
+      clearNotificationsSuccess: NotificationActions.CLEAR_NOTIFICATIONS_SUCCESS,
     })
 
     this.notifications = []
+    this.persistedNotifications = []
   }
 
   notify(options) {
@@ -31,6 +35,18 @@ class NotificationStore {
 
     this.notifications = this.notifications.concat(newItem)
   }
+
+  notifySuccess(notification) {
+    this.persistedNotifications = this.persistedNotifications.concat([notification])
+  }
+
+  loadNotificationsSuccess(notifications) {
+    this.persistedNotifications = notifications
+  }
+
+  clearNotificationsSuccess() {
+    this.persistedNotifications = []
+  }
 }
 
 export default alt.createStore(NotificationStore)