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

Merge pull request #262 from smiclea/notifications

Redesign and improve persisted notification system
Dorin Paslaru 7 лет назад
Родитель
Сommit
47ab25ef3e
44 измененных файлов с 497 добавлено и 278 удалено
  1. 1 1
      src/components/atoms/CopyMultilineValue/CopyMultilineValue.jsx
  2. 2 2
      src/components/atoms/CopyValue/CopyValue.jsx
  3. 5 2
      src/components/atoms/StatusIcon/StatusIcon.jsx
  4. 20 0
      src/components/atoms/StatusIcon/images/error-hollow.svg
  5. 19 0
      src/components/atoms/StatusIcon/images/success-hollow.svg
  6. 6 0
      src/components/atoms/StatusIcon/story.jsx
  7. 93 61
      src/components/molecules/NotificationDropdown/NotificationDropdown.jsx
  8. 14 32
      src/components/molecules/NotificationDropdown/images/bell.js
  9. 0 14
      src/components/molecules/NotificationDropdown/images/error.svg
  10. 0 14
      src/components/molecules/NotificationDropdown/images/info.svg
  11. 10 0
      src/components/molecules/NotificationDropdown/images/loading.js
  12. 0 13
      src/components/molecules/NotificationDropdown/images/success.svg
  13. 50 25
      src/components/molecules/NotificationDropdown/story.jsx
  14. 1 1
      src/components/molecules/ScheduleItem/ScheduleItem.jsx
  15. 1 1
      src/components/molecules/TaskItem/TaskItem.jsx
  16. 11 7
      src/components/molecules/UserDropdown/images/user-white.svg
  17. 9 10
      src/components/molecules/UserDropdown/images/user.svg
  18. 29 6
      src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx
  19. 1 1
      src/components/organisms/DetailsPageHeader/story.jsx
  20. 1 1
      src/components/organisms/DetailsPageHeader/test.jsx
  21. 5 5
      src/components/organisms/Endpoint/Endpoint.jsx
  22. 2 2
      src/components/organisms/EndpointValidation/EndpointValidation.jsx
  23. 1 1
      src/components/organisms/LoginForm/LoginForm.jsx
  24. 6 6
      src/components/organisms/Notifications/Notifications.jsx
  25. 32 4
      src/components/organisms/PageHeader/PageHeader.jsx
  26. 2 2
      src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx
  27. 1 1
      src/components/pages/EndpointDetailsPage/EndpointDetailsPage.jsx
  28. 3 3
      src/components/pages/EndpointsPage/EndpointsPage.jsx
  29. 9 3
      src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx
  30. 1 1
      src/components/pages/MigrationsPage/MigrationsPage.jsx
  31. 2 2
      src/components/pages/ProjectDetailsPage/ProjectDetailsPage.jsx
  32. 8 1
      src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx
  33. 1 1
      src/components/pages/ReplicasPage/ReplicasPage.jsx
  34. 1 1
      src/components/pages/UserDetailsPage/UserDetailsPage.jsx
  35. 6 6
      src/components/pages/WizardPage/WizardPage.jsx
  36. 1 1
      src/sources/AssessmentSource.js
  37. 109 17
      src/sources/NotificationSource.js
  38. 1 1
      src/sources/WizardSource.js
  39. 1 1
      src/stores/MigrationStore.js
  40. 11 18
      src/stores/NotificationStore.js
  41. 1 1
      src/stores/ReplicaStore.js
  42. 5 5
      src/stores/UserStore.js
  43. 12 1
      src/types/NotificationItem.js
  44. 3 3
      src/utils/ApiCaller.js

+ 1 - 1
src/components/atoms/CopyMultilineValue/CopyMultilineValue.jsx

@@ -46,7 +46,7 @@ class CopyMultineValue extends React.Component<Props> {
     if (this.props.onCopy) this.props.onCopy(this.props.value)
 
     if (succesful) {
-      notificationStore.notify('The message has been copied to clipboard.')
+      notificationStore.alert('The message has been copied to clipboard.')
     }
   }
 

+ 2 - 2
src/components/atoms/CopyValue/CopyValue.jsx

@@ -55,9 +55,9 @@ class CopyValue extends React.Component<Props> {
     if (this.props.onCopy) this.props.onCopy(this.props.value)
 
     if (succesful) {
-      notificationStore.notify('The value has been copied to clipboard.')
+      notificationStore.alert('The value has been copied to clipboard.')
     } else {
-      notificationStore.notify('The value couldn\'t be copied', 'error')
+      notificationStore.alert('The value couldn\'t be copied', 'error')
     }
   }
 

+ 5 - 2
src/components/atoms/StatusIcon/StatusIcon.jsx

@@ -27,10 +27,13 @@ import progressImage from './images/progress.js'
 import successImage from './images/success.svg'
 import warningImage from './images/warning.svg'
 import pendingImage from './images/pending.svg'
+import successHollowImage from './images/success-hollow.svg'
+import errorHollowImage from './images/error-hollow.svg'
 
 type Props = {
   status: string,
   useBackground?: boolean,
+  hollow?: boolean,
 }
 
 const getRunningImageUrl = (props: Props) => {
@@ -46,14 +49,14 @@ const getRunningImageUrl = (props: Props) => {
 const statuses = props => {
   return {
     COMPLETED: css`
-      background-image: url('${successImage}');
+      background-image: url('${props.hollow ? successHollowImage : successImage}');
     `,
     RUNNING: css`
       background-image: ${getRunningImageUrl(props)};
       ${StyleProps.animations.rotation}
     `,
     ERROR: css`
-      background-image: url('${errorImage}');
+      background-image: url('${props.hollow ? errorHollowImage : errorImage}');
     `,
     WARNING: css`
       background-image: url('${warningImage}');

+ 20 - 0
src/components/atoms/StatusIcon/images/error-hollow.svg

@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Progress/Default</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Nav/PNBU/Menu-Notifications" transform="translate(-8.000000, -226.000000)" stroke="#F91661" stroke-width="1.5">
+            <g id="Line-Copy-2-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-2-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-3-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-4-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-5-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-6-Mask">
+                <g id="Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-6" transform="translate(0.000000, 218.000000)">
+                    <g id="Icon/Error/Red-28" transform="translate(8.000000, 8.000000)">
+                        <circle id="Oval-2" cx="8" cy="8" r="7.25"></circle>
+                        <path d="M11,5 L5,11" id="Line"></path>
+                        <path d="M11,11 L5,5" id="Line-Copy"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 19 - 0
src/components/atoms/StatusIcon/images/success-hollow.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/Progress/Default</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Nav/PNBU/Menu-Notifications" transform="translate(-8.000000, -70.000000)" stroke="#39DA55" stroke-width="1.5">
+            <g id="Line-Copy-2-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-2-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-3-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-4-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-5-+-Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-6-Mask">
+                <g id="Nav/PNBU/Item-Menu-Notification-NEW-1-Copy-3" transform="translate(0.000000, 62.000000)">
+                    <g id="Icon/Ok/Green-28" transform="translate(8.000000, 8.000000)">
+                        <circle id="Oval-2" cx="8" cy="8" r="7.25"></circle>
+                        <polyline id="Stroke-3" stroke-linecap="round" stroke-linejoin="round" points="11.5 5.83333333 7.19768992 10.1666667 4.5 7.57011203"></polyline>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 6 - 0
src/components/atoms/StatusIcon/story.jsx

@@ -20,12 +20,18 @@ storiesOf('StatusIcon', module)
   .add('completed', () => (
     <StatusIcon status="COMPLETED" />
   ))
+  .add('completed hollow', () => (
+    <StatusIcon status="COMPLETED" hollow />
+  ))
   .add('running', () => (
     <StatusIcon status="RUNNING" />
   ))
   .add('error', () => (
     <StatusIcon status="ERROR" />
   ))
+  .add('error hollow', () => (
+    <StatusIcon status="ERROR" hollow />
+  ))
   .add('warning', () => (
     <StatusIcon status="WARNING" />
   ))

+ 93 - 61
src/components/molecules/NotificationDropdown/NotificationDropdown.jsx

@@ -16,17 +16,15 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import React from 'react'
 import { observer } from 'mobx-react'
-import styled from 'styled-components'
-import moment from 'moment'
+import styled, { css } from 'styled-components'
 
-import type { NotificationItem } from '../../../types/NotificationItem'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
+import type { NotificationItemData } from '../../../types/NotificationItem'
+import StatusIcon from '../../atoms/StatusIcon'
 
-import bellImage from './images/bell.js'
-import errorImage from './images/error.svg'
-import infoImage from './images/info.svg'
-import successImage from './images/success.svg'
+import bellImage from './images/bell'
+import loadingImage from './images/loading'
 
 const Wrapper = styled.div`
   cursor: pointer;
@@ -35,44 +33,51 @@ const Wrapper = styled.div`
 const Icon = styled.div`
   position: relative;
   transition: all ${StyleProps.animations.swift};
+  width: 32px;
+  height: 32px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
 
   &:hover {
     opacity: 0.9;
   }
 `
-const BellIcon = styled.div``
-const Badge = styled.div`
+const BellIcon = styled.div`
+  width: 12px;
+  height: 17px;
+`
+const bellBadgePostion = css`
   position: absolute;
-  top: 0;
-  right: 0;
+  top: 6px;
+  right: 9px;
+`
+const Badge = styled.div`
+  ${props => props.isBellBadge ? bellBadgePostion : ''}
   background: ${Palette.primary};
   border-radius: 50%;
-  width: 14px;
-  height: 14px;
+  width: 6px;
+  height: 6px;
   text-align: center;
 `
-const BadgeLabel = styled.div`
-  margin-top: 2px;
-  font-size: 10px;
-  color: white;
-  font-weight: ${StyleProps.fontWeights.medium};
-`
 const List = styled.div`
   cursor: pointer;
   background: ${Palette.grayscale[1]};
   border-radius: ${StyleProps.borderRadius};
-  width: 224px;
+  width: 272px;
   position: absolute;
   right: 0;
   top: 45px;
   z-index: 10;
 `
-const ListItem = styled.div`
+const ListItem = styled.a`
   display: flex;
   border-bottom: 1px solid ${Palette.grayscale[0]};
-  flex-direction: column;
   padding: 8px;
   transition: all ${StyleProps.animations.swift};
+  justify-content: space-between;
+  text-decoration: none;
+  color: inherit;
 
   &:hover {
     background: ${Palette.grayscale[0]};
@@ -109,37 +114,63 @@ const ListItem = styled.div`
     border-bottom-right-radius: ${StyleProps.borderRadius};
   }
 `
-const Title = styled.div`
+const InfoColumn = styled.div`
+  display: flex;
+  flex-direction: column;
+`
+const BadgeColumn = styled.div`
   display: flex;
   align-items: center;
-  margin-bottom: 14px;
+  margin: 0 8px;
 `
+const MainItemInfo = styled.div`
+  display: flex;
+  align-items: center;
+  margin-right: -8px;
 
-const getTypeIcon = level => {
-  if (level === 'success') {
-    return successImage
+  & > div {
+    margin-right: 8px;
   }
-  if (level === 'error') {
-    return errorImage
-  }
-  return infoImage
-}
-const TypeIcon = styled.div`
-  width: 16px;
-  height: 16px;
-  background: url('${props => getTypeIcon(props.level)}') no-repeat center;
-  margin-right: 8px;
 `
-const TitleLabel = styled.div`flex-grow: 1;`
-const Time = styled.div`color: ${Palette.grayscale[4]};`
-const Description = styled.div``
+const ItemReplicaBadge = styled.div`
+  background: 'white';
+  color: #7F8795;
+  font-size: 9px;
+  ${StyleProps.exactWidth('13px')}
+  ${StyleProps.exactHeight('10px')}
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  font-weight: 500;
+  border-radius: 2px;
+  border: 1px solid #7F8795;
+`
+const ItemTitle = styled.div``
+const ItemDescription = styled.div`
+  color: ${Palette.grayscale[5]};
+  font-size: 10px;
+  margin-top: 8px;
+`
 const NoItems = styled.div`
   text-align: center;
+  width: 100%;
+`
+const Loading = styled.div`
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 32px;
+  height: 32px;
+  animation: rotate 3s linear infinite;
+  @keyframes rotate {
+    from {transform: rotate(0deg);}
+    to {transform: rotate(360deg);}
+  }
 `
-const baseId = 'notificationDropdown'
+
 type Props = {
   white?: boolean,
-  items: NotificationItem[],
+  items: NotificationItemData[],
   onClose: () => void,
 }
 type State = {
@@ -152,14 +183,14 @@ class NotificationDropdown extends React.Component<Props, State> {
   constructor() {
     super()
 
-    this.state = {
-      showDropdownList: false,
-    }
-
     // $FlowIssue
     this.handlePageClick = this.handlePageClick.bind(this)
   }
 
+  state = {
+    showDropdownList: false,
+  }
+
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
   }
@@ -215,23 +246,25 @@ class NotificationDropdown extends React.Component<Props, State> {
     let list = (
       <List>
         {this.props.items.map(item => {
-          let title = (item.options && item.options.persistInfo && item.options.persistInfo.title) || item.message
-          let message = title === item.message ? '' : item.message
+          let executionsHref = item.status === 'RUNNING' ? item.type === 'replica' ? '/executions' : item.type === 'migration' ? '/tasks' : '' : ''
 
           return (
             <ListItem
-              data-test-id={`${baseId}-item-${item.id || new Date().getTime().toString()}`}
               key={item.id}
               onMouseDown={() => { this.itemMouseDown = true }}
               onMouseUp={() => { this.itemMouseDown = false }}
               onClick={() => { this.handleItemClick() }}
+              href={`/#/${item.type}${executionsHref}/${item.id}`}
             >
-              <Title>
-                <TypeIcon data-test-id={`${baseId}-itemLevel`} level={item.level} />
-                <TitleLabel data-test-id={`${baseId}-itemTitle`}>{title}</TitleLabel>
-                <Time data-test-id={`${baseId}-itemTime`}>{moment(Number(item.id)).format('HH:mm')}</Time>
-              </Title>
-              <Description data-test-id={`${baseId}-itemDescription`}>{message}</Description>
+              <InfoColumn>
+                <MainItemInfo>
+                  <StatusIcon status={item.status} hollow />
+                  <ItemReplicaBadge type={item.type}>{item.type === 'replica' ? 'RE' : 'MI'}</ItemReplicaBadge>
+                  <ItemTitle>{item.name}</ItemTitle>
+                </MainItemInfo>
+                <ItemDescription>{item.description}</ItemDescription>
+              </InfoColumn>
+              {item.unseen ? <BadgeColumn><Badge /></BadgeColumn> : null}
             </ListItem>
           )
         })}
@@ -241,11 +274,7 @@ class NotificationDropdown extends React.Component<Props, State> {
     return list
   }
   renderBell() {
-    let badge = this.props.items && this.props.items.length >= 1 ? (
-      <Badge>
-        <BadgeLabel>{this.props.items.length}</BadgeLabel>
-      </Badge>
-    ) : null
+    let isLoading = Boolean(this.props.items.find(i => i.status === 'RUNNING'))
 
     return (
       <Icon
@@ -257,7 +286,10 @@ class NotificationDropdown extends React.Component<Props, State> {
         <BellIcon
           dangerouslySetInnerHTML={{ __html: bellImage(this.props.white ? 'white' : Palette.grayscale[2]) }}
         />
-        {badge}
+        {this.props.items.find(i => i.unseen) ? <Badge isBellBadge /> : null}
+        {isLoading ? <Loading
+          dangerouslySetInnerHTML={{ __html: loadingImage(this.props.white) }}
+        /> : null}
       </Icon>
     )
   }

+ 14 - 32
src/components/molecules/NotificationDropdown/images/bell.js

@@ -1,39 +1,21 @@
-/*
-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/>.
-*/
-
-const bell = color => `<?xml version="1.0" encoding="UTF-8"?>
-<svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" 
-xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47 (45396) - http://www.bohemiancoding.com/sketch -->
-    <title>Icon/Notification/Normal</title>
+export default color => `<?xml version="1.0" encoding="UTF-8"?>
+<svg width="12px" height="17px" viewBox="0 0 12 17" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Bell</title>
     <desc>Created with Sketch.</desc>
     <defs>
-        <rect id="path-1" x="12" y="24" width="8" height="4"></rect>
+        <rect id="path-1" x="3" y="14.0434783" width="6" height="2.95652174"></rect>
     </defs>
     <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Icon/Notification/Normal">
-            <path
-            d="M16,5 L16,5 L16,5 C20.418278,5 24,8.581722 24,13 
-            L24,23 L8,23 L8,13 L8,13 C8,8.581722 11.581722,5 16,5 Z"
-            id="Rectangle-9" fill="${color}"></path>
-            <mask id="mask-2" fill="white">
-                <use xlink:href="#path-1"></use>
-            </mask>
-            <g id="Mask"></g>
-            <circle id="Oval" fill="${color}" mask="url(#mask-2)" cx="16" cy="25" r="3"></circle>
+        <g id="Icon/Notification/Positive/Idle-Default" transform="translate(-10.000000, -8.000000)">
+            <g id="Bell" transform="translate(10.000000, 8.000000)">
+                <path d="M6,0 L6,0 C9.3137085,-6.08718376e-16 12,2.6862915 12,6 L12,13.3043478 L0,13.3043478 L0,6 C-4.05812251e-16,2.6862915 2.6862915,6.08718376e-16 6,0 Z" id="Rectangle-9" fill="${color}"></path>
+                <mask id="mask-2" fill="white">
+                    <use xlink:href="#path-1"></use>
+                </mask>
+                <g id="Mask"></g>
+                <ellipse id="Oval" fill="${color}" mask="url(#mask-2)" cx="6" cy="14.7826087" rx="2.25" ry="2.2173913"></ellipse>
+            </g>
         </g>
     </g>
 </svg>`
-
-export default bell

+ 0 - 14
src/components/molecules/NotificationDropdown/images/error.svg

@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47 (45396) - http://www.bohemiancoding.com/sketch -->
-    <title>Icon/Error/White</title>
-    <desc>Created with Sketch.</desc>
-    <defs></defs>
-    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Icon/Error/White">
-            <circle id="Oval-2" fill="#FFFFFF" cx="8" cy="8" r="8"></circle>
-            <path d="M11.4285714,4.57142857 L4.57142857,11.4285714" id="Line" stroke="#F91661" stroke-linecap="round"></path>
-            <path d="M11.4285714,11.4285714 L4.57142857,4.57142857" id="Line-Copy" stroke="#F91661" stroke-linecap="round"></path>
-        </g>
-    </g>
-</svg>

+ 0 - 14
src/components/molecules/NotificationDropdown/images/info.svg

@@ -1,14 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47 (45396) - http://www.bohemiancoding.com/sketch -->
-    <title>Icon/Info/White</title>
-    <desc>Created with Sketch.</desc>
-    <defs></defs>
-    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Icon/Info/White">
-            <path d="M8.00033862,0 C3.58196825,0 0,3.58129101 0,7.99966138 C0,12.4180317 3.58196825,16 8.00033862,16 C12.4180317,16 16,12.4180317 16,7.99966138 C16,3.58129101 12.4180317,0 8.00033862,0 L8.00033862,0 Z" id="Fill-1-Copy" fill="#FFFFFF"></path>
-            <path d="M9.5749418,5.03128042 C9.33655026,5.25409524 9.04804233,5.36448677 8.71144974,5.36448677 C8.37553439,5.36448677 8.08567196,5.25409524 7.84389418,5.03128042 C7.6048254,4.80914286 7.48292063,4.53959788 7.48292063,4.224 C7.48292063,3.90840212 7.6048254,3.6368254 7.84389418,3.41333333 C8.08567196,3.18848677 8.37553439,3.07674074 8.71144974,3.07674074 C9.04804233,3.07674074 9.33655026,3.18848677 9.5749418,3.41333333 C9.81401058,3.6368254 9.9338836,3.90840212 9.9338836,4.224 C9.9338836,4.53959788 9.81401058,4.80914286 9.5749418,5.03128042 L9.5749418,5.03128042 Z" id="Path" fill="#0044CA"></path>
-            <path d="M9.66569312,12.3983915 C9.25324868,12.5616085 8.92613757,12.6841905 8.6802963,12.7695238 C8.43445503,12.8548571 8.15001058,12.8982011 7.82560847,12.8982011 C7.32715344,12.8982011 6.93908995,12.7762963 6.66277249,12.5324868 C6.38645503,12.2893545 6.24897354,11.9812063 6.24897354,11.6066878 C6.24897354,11.4617566 6.25913228,11.3127619 6.27944974,11.160381 C6.30044444,11.0093545 6.33362963,10.8380106 6.37900529,10.6470265 L6.89439153,8.82590476 C6.9397672,8.6511746 6.97904762,8.48592593 7.01020106,8.33083598 C7.0413545,8.17439153 7.05625397,8.03149206 7.05625397,7.90213757 C7.05625397,7.67051852 7.00816931,7.50797884 6.91267725,7.41722751 C6.81650794,7.32512169 6.63365079,7.27974603 6.36275132,7.27974603 C6.23001058,7.27974603 6.0925291,7.30006349 5.95233862,7.34137566 C5.81350265,7.38336508 5.69362963,7.4226455 5.59407407,7.46057143 L5.73020106,6.89913228 C6.0674709,6.76165079 6.39051852,6.64448677 6.69866667,6.54696296 C7.00681481,6.4487619 7.29803175,6.4 7.57231746,6.4 C8.06738624,6.4 8.4493545,6.51919577 8.71822222,6.75826455 C8.98573545,6.99733333 9.12050794,7.30819048 9.12050794,7.69083598 C9.12050794,7.77007407 9.11102646,7.90891005 9.09274074,8.10869841 C9.07445503,8.30848677 9.03991534,8.49066667 8.98979894,8.65726984 L8.47712169,10.4722963 C8.43513228,10.6172275 8.3978836,10.7845079 8.36402116,10.9714286 C8.33083598,11.1569947 8.31458201,11.2992169 8.31458201,11.394709 C8.31458201,11.6364868 8.36808466,11.800381 8.47644444,11.887746 C8.58344974,11.9751111 8.77104762,12.0191323 9.0365291,12.0191323 C9.16182011,12.0191323 9.30201058,11.9967831 9.46048677,11.9534392 C9.61828571,11.909418 9.73138624,11.8714921 9.8031746,11.8383069 L9.66569312,12.3983915 Z" id="Path" fill="#0044CA"></path>
-        </g>
-    </g>
-</svg>

+ 10 - 0
src/components/molecules/NotificationDropdown/images/loading.js

@@ -0,0 +1,10 @@
+// @flow
+
+export default (whiteTheme?: boolean) => `
+  <svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <g>
+      <circle fill="none" stroke="${whiteTheme ? 'white' : '#C8CCD7'}" stroke-width="1.5"  cx="16" cy="16" r="15"></circle>
+      <path d="M 31 16 A 15 15 0 0 0 16 1" fill="none" stroke="#0044CB" stroke-width="1.5" />
+    </g>
+  </svg>
+`

+ 0 - 13
src/components/molecules/NotificationDropdown/images/success.svg

@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="16px" height="16px" viewBox="0 0 16 16" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47 (45396) - http://www.bohemiancoding.com/sketch -->
-    <title>Icon/Ok/White-Green</title>
-    <desc>Created with Sketch.</desc>
-    <defs></defs>
-    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Icon/Ok/White-Green">
-            <circle id="Oval-2" fill="#FFFFFF" cx="8" cy="8" r="8"></circle>
-            <polyline id="Stroke-3" stroke="#39DA55" stroke-linecap="round" stroke-linejoin="round" points="12 6 7.08307419 11 4 8.00397542"></polyline>
-        </g>
-    </g>
-</svg>

+ 50 - 25
src/components/molecules/NotificationDropdown/story.jsx

@@ -12,40 +12,65 @@ 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/>.
 */
 
+// @flow
+
 import React from 'react'
 import { storiesOf } from '@storybook/react'
+
+import type { NotificationItemData } from '../../../types/NotificationItem'
 import NotificationDropdown from '.'
 
+const items: NotificationItemData[] = [
+  {
+    id: '1111',
+    name: 'ubtuntu-1804-bionic',
+    type: 'replica',
+    status: 'COMPLETED',
+    unseen: true,
+    description: 'This is a description',
+  },
+  {
+    id: '2222',
+    name: 'centos7-8VCPU',
+    type: 'migration',
+    status: 'ERROR',
+    description: 'This is a description',
+  },
+]
+
+const itemsWithLoading: NotificationItemData[] = [
+  ...items,
+  {
+    id: '3333',
+    name: 'ubuntu-1804-bionic',
+    type: 'replica',
+    status: 'RUNNING',
+    description: 'This is a description',
+  },
+]
+
 storiesOf('NotificationDropdown', module)
   .add('default', () => (
-    <div style={{ marginLeft: '200px' }}><NotificationDropdown /></div>
+    <div style={{ marginLeft: '200px' }}>
+      <NotificationDropdown
+        items={items}
+        onClose={() => { }}
+      />
+    </div>
   ))
   .add('white', () => (
-    <div style={{ marginLeft: '200px' }}><NotificationDropdown white /></div>
+    <div style={{ marginLeft: '200px' }}>
+      <NotificationDropdown
+        white
+        items={itemsWithLoading}
+        onClose={() => { }}
+      /></div>
   ))
-  .add('notification types', () => (
+  .add('loading', () => (
     <div style={{ marginLeft: '200px' }}>
       <NotificationDropdown
-        items={[
-          {
-            id: new Date().getTime(),
-            message: 'A full VM migration between two clouds',
-            level: 'success',
-            options: { persistInfo: { title: 'Migration' } },
-          },
-          {
-            id: new Date().getTime(),
-            message: 'Incrementally replicate virtual machines',
-            level: 'error',
-            options: { persistInfo: { title: 'Replica' } },
-          },
-          {
-            id: new Date().getTime(),
-            message: 'A conection to a public or private cloud',
-            level: 'info',
-            options: { persistInfo: { title: 'Endpoint' } },
-          },
-        ]}
-      />
-    </div>
+        showBadge
+        items={itemsWithLoading}
+        onClose={() => { }}
+      /></div>
   ))

+ 1 - 1
src/components/molecules/ScheduleItem/ScheduleItem.jsx

@@ -145,7 +145,7 @@ class ScheduleItem extends React.Component<Props> {
   handleExpirationDateChange(date: Date) {
     let newDate = moment(date)
     if (newDate.diff(new Date(), 'minutes') < 60) {
-      notificationStore.notify('Please select a further expiration date.', 'error')
+      notificationStore.alert('Please select a further expiration date.', 'error')
       return
     }
 

+ 1 - 1
src/components/molecules/TaskItem/TaskItem.jsx

@@ -161,7 +161,7 @@ class TaskItem extends React.Component<Props> {
     let succesful = DomUtils.copyTextToClipboard(exceptionText)
 
     if (succesful) {
-      notificationStore.notify('The message has been copied to clipboard.')
+      notificationStore.alert('The message has been copied to clipboard.')
     }
   }
 

+ 11 - 7
src/components/molecules/UserDropdown/images/user-white.svg

@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
     <title>Icon/User/Menu-White</title>
     <desc>Created with Sketch.</desc>
     <defs>
@@ -8,12 +8,16 @@
     </defs>
     <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
         <g id="Nav/Menu/Header" transform="translate(-1392.000000, -16.000000)">
-            <g id="Icon/User/Menu-White" transform="translate(1392.000000, 16.000000)">
-                <mask id="mask-2" fill="white">
-                    <use xlink:href="#path-1"></use>
-                </mask>
-                <path stroke="#FFFFFF" stroke-width="1" d="M16,31.5 C24.5604136,31.5 31.5,24.5604136 31.5,16 C31.5,7.43958638 24.5604136,0.5 16,0.5 C7.43958638,0.5 0.5,7.43958638 0.5,16 C0.5,24.5604136 7.43958638,31.5 16,31.5 Z"></path>
-                <path d="M3.5,31.5 L28.5,31.5 L28.5,31.372093 C28.5,28.1259242 26.6002264,26.5720998 22.1841273,24.8037022 L22.1851985,24.8041298 C20.0606159,23.9590364 19.4,23.6128065 19.4,22.8953488 L19.4,19.8165687 L19.6040994,19.6667255 C21.1020937,18.5669458 22,16.8321715 22,15.0465116 L22,11.2790698 C22,8.10566664 19.3026525,5.5 16,5.5 C12.6973475,5.5 10,8.10566664 10,11.2790698 L10,15.0465116 C10,16.8535663 10.914268,18.6360097 12.3854978,19.6592909 L12.6,19.8084831 L12.6,22.8953488 C12.6,23.4681068 12.1423646,23.7458894 10.6899496,24.4096972 C10.5969773,24.452189 10.5345772,24.4804342 10.3789006,24.550583 C10.1031859,24.6743704 9.96405989,24.7369128 9.8159118,24.8036866 C5.39938368,26.5726873 3.5,28.1260975 3.5,31.372093 L3.5,31.5 Z" id="Fill-1" stroke="#FFFFFF" stroke-width="1" mask="url(#mask-2)"></path>
+            <g id="Group" transform="translate(1347.000000, 16.000000)">
+                <g id="Icon/User/Menu-White" transform="translate(45.000000, 0.000000)">
+                    <g id="Pat-Benetar">
+                        <path d="M4.63358711,27.260759 C1.76914441,24.3696092 0,20.3914075 0,16 C0,7.163444 7.163444,0 16,0 C24.836556,0 32,7.163444 32,16 C32,20.3913572 30.2308961,24.3695181 27.3665113,27.2606597 C25.970445,25.7970172 23.9438599,24.9697766 22.37,24.3395349 C21.265,23.9 19.9,23.3342558 19.9,22.8953488 L19.9,20.0697674 C21.525,18.8767442 22.5,16.9930233 22.5,15.0465116 L22.5,11.2790698 C22.5,7.8255814 19.575,5 16,5 C12.425,5 9.5,7.8255814 9.5,11.2790698 L9.5,15.0465116 C9.5,16.9930233 10.475,18.9395349 12.1,20.0697674 L12.1,22.8953488 C12.1,23.272093 10.735,23.8372093 9.63,24.3395349 C8.05648832,24.9697909 6.02977773,25.7970546 4.63358711,27.260759 Z" id="Combined-Shape" fill="#FFFFFF"></path>
+                        <mask id="mask-2" fill="white">
+                            <use xlink:href="#path-1"></use>
+                        </mask>
+                        <path stroke="#FFFFFF" stroke-width="2" d="M16,31 C24.2842712,31 31,24.2842712 31,16 C31,7.71572875 24.2842712,1 16,1 C7.71572875,1 1,7.71572875 1,16 C1,24.2842712 7.71572875,31 16,31 Z"></path>
+                    </g>
+                </g>
             </g>
         </g>
     </g>

+ 9 - 10
src/components/molecules/UserDropdown/images/user.svg

@@ -1,22 +1,21 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <svg width="32px" height="32px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
-    <title>User-Icon-A</title>
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon/User/Menu-Grey</title>
     <desc>Created with Sketch.</desc>
     <defs>
         <path d="M16,32 C24.836556,32 32,24.836556 32,16 C32,7.163444 24.836556,0 16,0 C7.163444,0 0,7.163444 0,16 C0,24.836556 7.163444,32 16,32 Z" id="path-1"></path>
     </defs>
     <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
-        <g id="Nav/PNBU/Line" transform="translate(-432.000000, 0.000000)">
-            <g id="User-Icon-A" transform="translate(432.000000, 0.000000)">
-                <mask id="mask-2" fill="white">
-                    <use xlink:href="#path-1"></use>
-                </mask>
+        <g id="Nav/PNBU/Line" transform="translate(-496.000000, 0.000000)">
+            <g id="Icon/User/Menu-Grey" transform="translate(496.000000, 0.000000)">
                 <g id="Pat-Benetar">
-                    <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
-                    <path stroke="#A4AAB5" stroke-width="1" d="M16,31.5 C24.5604136,31.5 31.5,24.5604136 31.5,16 C31.5,7.43958638 24.5604136,0.5 16,0.5 C7.43958638,0.5 0.5,7.43958638 0.5,16 C0.5,24.5604136 7.43958638,31.5 16,31.5 Z"></path>
+                    <path d="M4.63358711,27.260759 C1.76914441,24.3696092 0,20.3914075 0,16 C0,7.163444 7.163444,0 16,0 C24.836556,0 32,7.163444 32,16 C32,20.3913572 30.2308961,24.3695181 27.3665113,27.2606597 C25.970445,25.7970172 23.9438599,24.9697766 22.37,24.3395349 C21.265,23.9 19.9,23.3342558 19.9,22.8953488 L19.9,20.0697674 C21.525,18.8767442 22.5,16.9930233 22.5,15.0465116 L22.5,11.2790698 C22.5,7.8255814 19.575,5 16,5 C12.425,5 9.5,7.8255814 9.5,11.2790698 L9.5,15.0465116 C9.5,16.9930233 10.475,18.9395349 12.1,20.0697674 L12.1,22.8953488 C12.1,23.272093 10.735,23.8372093 9.63,24.3395349 C8.05648832,24.9697909 6.02977773,25.7970546 4.63358711,27.260759 Z" id="Combined-Shape" fill="#C8CCD7"></path>
+                    <mask id="mask-2" fill="white">
+                        <use xlink:href="#path-1"></use>
+                    </mask>
+                    <path stroke="#C8CCD7" stroke-width="2" d="M16,31 C24.2842712,31 31,24.2842712 31,16 C31,7.71572875 24.2842712,1 16,1 C7.71572875,1 1,7.71572875 1,16 C1,24.2842712 7.71572875,31 16,31 Z"></path>
                 </g>
-                <path d="M22.37,24.3395349 C21.265,23.9 19.9,23.3342558 19.9,22.8953488 L19.9,20.0697674 C21.525,18.8767442 22.5,16.9930233 22.5,15.0465116 L22.5,11.2790698 C22.5,7.8255814 19.575,5 16,5 C12.425,5 9.5,7.8255814 9.5,11.2790698 L9.5,15.0465116 C9.5,16.9930233 10.475,18.9395349 12.1,20.0697674 L12.1,22.8953488 C12.1,23.272093 10.735,23.8372093 9.63,24.3395349 C6.965,25.4069767 3,27.0395349 3,31.372093 L3,32 L29,32 L29,31.372093 C29,27.0395349 25.03565,25.4069767 22.37,24.3395349" id="Fill-1" stroke="#A4AAB5" mask="url(#mask-2)"></path>
             </g>
         </g>
     </g>

+ 29 - 6
src/components/organisms/DetailsPageHeader/DetailsPageHeader.jsx

@@ -53,23 +53,42 @@ const User = styled.div`
   display: flex;
   align-items: center;
 `
-
 type Props = {
   user?: ?UserType,
   onUserItemClick: (userItem: { label: string, value: string }) => void,
   testMode?: boolean,
 }
+
 @observer
-export class DetailsPageHeader extends React.Component<Props> {
-  componentDidMount() {
+class DetailsPageHeader extends React.Component<Props, {}> {
+  pollTimeout: TimeoutID
+  stopPolling: boolean
+
+  componentWillMount() {
     if (this.props.testMode) {
       return
     }
-    notificationStore.loadNotifications()
+    this.stopPolling = false
+    this.pollData()
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
   }
 
   handleNotificationsClose() {
-    notificationStore.clearNotifications()
+    notificationStore.saveSeen()
+  }
+
+  pollData() {
+    if (this.stopPolling) {
+      return
+    }
+
+    notificationStore.loadData().then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
+    })
   }
 
   render() {
@@ -80,7 +99,11 @@ export class DetailsPageHeader extends React.Component<Props> {
           <Logo href="/#/replicas" />
         </Menu>
         <User>
-          <NotificationDropdown white items={notificationStore.persistedNotifications} onClose={() => this.handleNotificationsClose()} />
+          <NotificationDropdown
+            white
+            items={notificationStore.notificationItems}
+            onClose={() => this.handleNotificationsClose()}
+          />
           <UserDropdownStyled
             white
             user={this.props.user}

+ 1 - 1
src/components/organisms/DetailsPageHeader/story.jsx

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import React from 'react'
 import { storiesOf } from '@storybook/react'
-import { DetailsPageHeader } from '.'
+import DetailsPageHeader from '.'
 
 storiesOf('DetailsPageHeader', module)
   .add('default', () => (

+ 1 - 1
src/components/organisms/DetailsPageHeader/test.jsx

@@ -19,7 +19,7 @@ import { shallow } from 'enzyme'
 import sinon from 'sinon'
 import TW from '../../../utils/TestWrapper'
 import type { User } from '../../../types/User'
-import { DetailsPageHeader } from '.'
+import DetailsPageHeader from '.'
 
 type Props = {
   user?: ?User,

+ 5 - 5
src/components/organisms/Endpoint/Endpoint.jsx

@@ -235,7 +235,7 @@ class Endpoint extends React.Component<Props, State> {
     if (!this.highlightRequired()) {
       this.setState({ validating: true })
 
-      notificationStore.notify('Saving endpoint ...')
+      notificationStore.alert('Saving endpoint ...')
       endpointStore.clearValidation()
 
       if (this.state.isNew) {
@@ -244,7 +244,7 @@ class Endpoint extends React.Component<Props, State> {
         this.update()
       }
     } else {
-      notificationStore.notify('Please fill all the required fields', 'error')
+      notificationStore.alert('Please fill all the required fields', 'error')
     }
   }
 
@@ -262,7 +262,7 @@ class Endpoint extends React.Component<Props, State> {
     let succesful = DomUtils.copyTextToClipboard(endpointStore.validation.message)
 
     if (succesful) {
-      notificationStore.notify('The message has been copied to clipboard.')
+      notificationStore.alert('The message has been copied to clipboard.')
     }
   }
 
@@ -285,7 +285,7 @@ class Endpoint extends React.Component<Props, State> {
     }
 
     endpointStore.update(this.state.endpoint).then(() => {
-      notificationStore.notify('Validating endpoint ...')
+      notificationStore.alert('Validating endpoint ...')
       // $FlowIssue
       endpointStore.validate(this.state.endpoint)
     })
@@ -299,7 +299,7 @@ class Endpoint extends React.Component<Props, State> {
     endpointStore.add(this.state.endpoint).then(() => {
       let endpoint = endpointStore.endpoints[0]
       this.setState({ isNew: false, endpoint: ObjectUtils.flatten(endpoint) })
-      notificationStore.notify('Validating endpoint ...')
+      notificationStore.alert('Validating endpoint ...')
       endpointStore.validate(endpoint)
     })
   }

+ 2 - 2
src/components/organisms/EndpointValidation/EndpointValidation.jsx

@@ -85,9 +85,9 @@ class EndpointValidation extends React.Component<Props> {
     let succesful = DomUtils.copyTextToClipboard(message)
 
     if (succesful) {
-      notificationStore.notify('The value has been copied to clipboard.')
+      notificationStore.alert('The value has been copied to clipboard.')
     } else {
-      notificationStore.notify('The value couldn\'t be copied', 'error')
+      notificationStore.alert('The value couldn\'t be copied', 'error')
     }
   }
 

+ 1 - 1
src/components/organisms/LoginForm/LoginForm.jsx

@@ -120,7 +120,7 @@ class LoginForm extends React.Component<Props, State> {
     e.preventDefault()
 
     if (this.state.username.length === 0 || this.state.password.length === 0) {
-      notificationStore.notify('Please fill in all fields')
+      notificationStore.alert('Please fill in all fields')
     } else {
       this.props.onFormSubmit({ username: this.state.username, password: this.state.password })
     }

+ 6 - 6
src/components/organisms/Notifications/Notifications.jsx

@@ -21,7 +21,7 @@ import NotificationSystem from 'react-notification-system'
 import { observe } from 'mobx'
 
 import notificationStore from '../../../stores/NotificationStore'
-import type { NotificationItem } from '../../../types/NotificationItem'
+import type { AlertInfo } from '../../../types/NotificationItem'
 
 import NotificationsStyle from './style.js'
 
@@ -42,17 +42,17 @@ class Notifications extends React.Component<{}> {
   }
 
   componentDidMount() {
-    observe(notificationStore.notifications, change => {
+    observe(notificationStore.alerts, change => {
       this.handleStoreChange(change.object)
     })
   }
 
-  handleStoreChange(notifications: NotificationItem[]) {
-    if (!notifications.length || notifications.length <= this.notificationsCount) {
+  handleStoreChange(alerts: AlertInfo[]) {
+    if (!alerts.length || alerts.length <= this.notificationsCount) {
       return
     }
 
-    let lastNotification = notifications[notifications.length - 1]
+    let lastNotification = alerts[alerts.length - 1]
     this.notificationSystem.addNotification({
       title: lastNotification.title || lastNotification.message,
       message: lastNotification.title ? lastNotification.message : null,
@@ -62,7 +62,7 @@ class Notifications extends React.Component<{}> {
       action: lastNotification.options ? lastNotification.options.action : null,
     })
 
-    this.notificationsCount = notifications.length
+    this.notificationsCount = alerts.length
   }
 
   render() {

+ 32 - 4
src/components/organisms/PageHeader/PageHeader.jsx

@@ -75,6 +75,9 @@ type State = {
 }
 @observer
 class PageHeader extends React.Component<Props, State> {
+  pollTimeout: TimeoutID
+  stopPolling: boolean
+
   constructor() {
     super()
 
@@ -86,8 +89,14 @@ class PageHeader extends React.Component<Props, State> {
     }
   }
 
-  componentDidMount() {
-    notificationStore.loadNotifications()
+  componentWillMount() {
+    this.stopPolling = false
+    this.pollData()
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
   }
 
   getCurrentProject() {
@@ -138,7 +147,7 @@ class PageHeader extends React.Component<Props, State> {
   }
 
   handleNotificationsClose() {
-    notificationStore.clearNotifications()
+    notificationStore.saveSeen()
   }
 
   handleCloseChooseProviderModal() {
@@ -209,6 +218,22 @@ class PageHeader extends React.Component<Props, State> {
     })
   }
 
+  pollData() {
+    if (
+      this.stopPolling ||
+      this.state.showChooseProviderModal ||
+      this.state.showEndpointModal ||
+      this.state.showProjectModal ||
+      this.state.showUserModal
+    ) {
+      return
+    }
+
+    notificationStore.loadData().then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, 5000)
+    })
+  }
+
   render() {
     return (
       <Wrapper>
@@ -222,7 +247,10 @@ class PageHeader extends React.Component<Props, State> {
             labelField="name"
           />
           <NewItemDropdown onChange={item => { this.handleNewItem(item) }} />
-          <NotificationDropdown items={notificationStore.persistedNotifications} onClose={() => this.handleNotificationsClose()} />
+          <NotificationDropdown
+            items={notificationStore.notificationItems}
+            onClose={() => this.handleNotificationsClose()}
+          />
           <UserDropdown user={userStore.loggedUser} onItemClick={item => { this.handleUserItemClick(item) }} />
         </Controls>
         <Modal

+ 2 - 2
src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx

@@ -19,7 +19,7 @@ import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import AssessmentDetailsContent from '../../organisms/AssessmentDetailsContent'
 import Modal from '../../molecules/Modal'
@@ -237,7 +237,7 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
       this.setState({ showMigrationOptions: false })
       let useReplicaOption = options.find(o => o.name === 'use_replica')
       let type = useReplicaOption && useReplicaOption.value ? 'Replica' : 'Migration'
-      notificationStore.notify(`${type} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${type} created` } })
+      notificationStore.alert(`${type} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${type} created` } })
 
       if (type === 'Replica') {
         assessmentStore.migrations.forEach(replica => {

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

@@ -19,7 +19,7 @@ import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import EndpointDetailsContent from '../../organisms/EndpointDetailsContent'
 import AlertModal from '../../organisms/AlertModal'

+ 3 - 3
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -192,16 +192,16 @@ class EndpointsPage extends React.Component<{}, State> {
       }).map(p => p.catch(e => e))).then(results => {
         let internalServerErrors = results.filter(r => r.status && r.status === 500)
         if (internalServerErrors.length > 0) {
-          notificationStore.notify(`There was a problem duplicating ${internalServerErrors.length} endpoint${internalServerErrors.length > 1 ? 's' : ''}`, 'error')
+          notificationStore.alert(`There was a problem duplicating ${internalServerErrors.length} endpoint${internalServerErrors.length > 1 ? 's' : ''}`, 'error')
         }
         let forbiddenErrors = results.filter(r => r.status && r.status === 403)
         if (forbiddenErrors.length > 0 && forbiddenErrors[0].data && forbiddenErrors[0].data.description) {
-          notificationStore.notify(String(forbiddenErrors[0].data.description), 'error')
+          notificationStore.alert(String(forbiddenErrors[0].data.description), 'error')
         }
       })
     }).catch(e => {
       if (e.data && e.data.description) {
-        notificationStore.notify(e.data.description, 'error')
+        notificationStore.alert(e.data.description, 'error')
       }
     }).then(() => {
       this.pollData(true)

+ 9 - 3
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx

@@ -19,7 +19,7 @@ import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import MigrationDetailsContent from '../../organisms/MigrationDetailsContent'
 import AlertModal from '../../organisms/AlertModal'
@@ -62,6 +62,12 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     this.pollInterval = setInterval(() => { this.pollData() }, requestPollTimeout)
   }
 
+  componentWillReceiveProps(newProps: any) {
+    if (newProps.match.params.id !== this.props.match.params.id) {
+      migrationStore.getMigration(newProps.match.params.id, true)
+    }
+  }
+
   componentWillUnmount() {
     migrationStore.clearDetails()
     clearInterval(this.pollInterval)
@@ -114,9 +120,9 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     }
     migrationStore.cancel(migrationStore.migrationDetails.id).then(() => {
       if (migrationStore.canceling === false) {
-        notificationStore.notify('Canceled', 'success')
+        notificationStore.alert('Canceled', 'success')
       } else {
-        notificationStore.notify('The migration couldn\'t be canceled', 'error')
+        notificationStore.alert('The migration couldn\'t be canceled', 'error')
       }
     })
   }

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

@@ -132,7 +132,7 @@ class MigrationsPage extends React.Component<{}, State> {
     this.state.confirmationItems.forEach(migration => {
       migrationStore.cancel(migration.id)
     })
-    notificationStore.notify('Canceling migrations')
+    notificationStore.alert('Canceling migrations')
     this.handleCloseCancelMigration()
   }
 

+ 2 - 2
src/components/pages/ProjectDetailsPage/ProjectDetailsPage.jsx

@@ -21,7 +21,7 @@ import { observer } from 'mobx-react'
 import type { User } from '../../../types/User'
 import type { Project, Role } from '../../../types/Project'
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import ProjectDetailsContent from '../../organisms/ProjectDetailsContent'
 import ProjectModal from '../../organisms/ProjectModal'
@@ -145,7 +145,7 @@ class ProjectDetailsPage extends React.Component<Props, State> {
       Promise.all(roles.map(r => {
         return userStore.assignUserToProjectWithRole(userId, this.props.match.params.id, r.id)
       })).catch(e => {
-        notificationStore.notify('Error while assigning role to user', 'error')
+        notificationStore.alert('Error while assigning role to user', 'error')
         console.error(e)
       }).then(() => {
         this.loadData()

+ 8 - 1
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx

@@ -19,7 +19,7 @@ import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import ReplicaDetailsContent from '../../organisms/ReplicaDetailsContent'
 import Modal from '../../molecules/Modal'
@@ -81,6 +81,13 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.pollData(true)
   }
 
+  componentWillReceiveProps(newProps: any) {
+    if (newProps.match.params.id !== this.props.match.params.id) {
+      replicaStore.getReplica(newProps.match.params.id)
+      scheduleStore.getSchedules(newProps.match.params.id)
+    }
+  }
+
   componentWillUnmount() {
     replicaStore.clearDetails()
     scheduleStore.clearUnsavedSchedules()

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

@@ -122,7 +122,7 @@ class ReplicasPage extends React.Component<{}, State> {
       items.forEach(replica => {
         replicaStore.execute(replica.id)
       })
-      notificationStore.notify('Executing replicas')
+      notificationStore.alert('Executing replicas')
     } else if (action === 'delete') {
       this.setState({
         showDeleteReplicaConfirmation: true,

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

@@ -20,7 +20,7 @@ import { observer } from 'mobx-react'
 
 import type { User } from '../../../types/User'
 import DetailsTemplate from '../../templates/DetailsTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import DetailsContentHeader from '../../organisms/DetailsContentHeader'
 import UserDetailsContent from '../../organisms/UserDetailsContent'
 import UserModal from '../../organisms/UserModal'

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

@@ -19,7 +19,7 @@ import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
 import WizardTemplate from '../../templates/WizardTemplate'
-import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsPageHeader from '../../organisms/DetailsPageHeader'
 import WizardPageContent from '../../organisms/WizardPageContent'
 import Modal from '../../molecules/Modal'
 import Endpoint from '../../organisms/Endpoint'
@@ -102,7 +102,7 @@ class WizardPage extends React.Component<Props, State> {
 
   handleCreationSuccess(items: MainItem[]) {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
-    notificationStore.notify(`${typeLabel} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${typeLabel} created` } })
+    notificationStore.alert(`${typeLabel} was succesfully created`, 'success', { persist: true, persistInfo: { title: `${typeLabel} created` } })
     let schedulePromise = Promise.resolve()
 
     if (this.state.type === 'replica') {
@@ -360,11 +360,11 @@ class WizardPage extends React.Component<Props, State> {
 
   createMultiple() {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
-    notificationStore.notify(`Creating ${typeLabel}s ...`)
+    notificationStore.alert(`Creating ${typeLabel}s ...`)
     wizardStore.createMultiple(this.state.type, wizardStore.data).then(() => {
       let items = wizardStore.createdItems
       if (!items) {
-        notificationStore.notify(`${typeLabel}s couldn't be created`, 'error')
+        notificationStore.alert(`${typeLabel}s couldn't be created`, 'error')
         this.setState({ nextButtonDisabled: false })
         return
       }
@@ -374,11 +374,11 @@ class WizardPage extends React.Component<Props, State> {
 
   createSingle() {
     let typeLabel = this.state.type.charAt(0).toUpperCase() + this.state.type.substr(1)
-    notificationStore.notify(`Creating ${typeLabel} ...`)
+    notificationStore.alert(`Creating ${typeLabel} ...`)
     wizardStore.create(this.state.type, wizardStore.data).then(() => {
       let item = wizardStore.createdItem
       if (!item) {
-        notificationStore.notify(`${typeLabel} couldn't be created`, 'error')
+        notificationStore.alert(`${typeLabel} couldn't be created`, 'error')
         this.setState({ nextButtonDisabled: false })
         return
       }

+ 1 - 1
src/sources/AssessmentSource.js

@@ -71,7 +71,7 @@ class AssessmentSource {
       let newData = { ...data }
       newData.selectedInstances = [instance]
       return this.migrate(newData).catch(() => {
-        notificationStore.notify(`Error while migrating instance ${instance.name}`, 'error', {
+        notificationStore.alert(`Error while migrating instance ${instance.name}`, 'error', {
           persist: true,
           persistInfo: { title: 'Migration creation error' },
         })

+ 109 - 17
src/sources/NotificationSource.js

@@ -14,30 +14,122 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import type { NotificationItem } from '../types/NotificationItem'
+import moment from 'moment'
 
+import { servicesUrl } from '../config'
+import Api from '../utils/ApiCaller'
+import type { NotificationItemData } from '../types/NotificationItem'
 
-class NotificationSource {
-  static notify(message: string, level?: $PropertyType<NotificationItem, 'level'>, options?: $PropertyType<NotificationItem, 'options'>): Promise<NotificationItem> {
-    let notifications = JSON.parse(localStorage.getItem('notifications') || '[]')
-    let newItem = {
-      id: new Date().getTime().toString(),
-      message,
-      level,
-      options,
+
+class NotificationStorage {
+  static loadSeen(): ?NotificationItemData[] {
+    let notifications = localStorage.getItem('seen-notifications')
+    return notifications ? JSON.parse(notifications) : null
+  }
+
+  static saveSeen(notificationItems: NotificationItemData[]) {
+    localStorage.setItem('seen-notifications', JSON.stringify(notificationItems))
+  }
+
+  static clean(notificationItems: NotificationItemData[]) {
+    let storageData = this.loadSeen()
+    if (!storageData) {
+      return
+    }
+    storageData = storageData.filter(i => notificationItems.find(j => i.id === j.id))
+    this.saveSeen(storageData)
+  }
+}
+
+class DataUtils {
+  static getMainInfo(item: any) {
+    if (item.type === 'migration') {
+      return item
+    }
+    if (item.executions && item.executions.length) {
+      let availableExecutions = item.executions.filter(i => !i.deleted_at)
+      if (availableExecutions.length) {
+        availableExecutions.sort((a, b) => b.number - a.number)
+        return availableExecutions[0]
+      }
     }
-    notifications.push(newItem)
-    localStorage.setItem('notifications', JSON.stringify(notifications))
-    return Promise.resolve(newItem)
+
+    return item
+  }
+
+  static getUpdatedAt(item: any) {
+    let info = this.getMainInfo(item)
+    return info.updated_at || info.created_at
+  }
+
+  static getItemDescription(item: any) {
+    let type = item.type === 'replica' ? 'Replica' : 'Migration'
+    let mainInfo = this.getMainInfo(item)
+    let description = ''
+    let id = `${mainInfo.id.substr(0, 7)}...`
+    switch (mainInfo.status) {
+      case 'COMPLETED':
+        description = `${type} execution ${id} completed successfully`
+        break
+      case 'ERROR':
+        description = `${type} execution ${id} failed`
+        break
+      case 'RUNNING':
+        description = `${type} execution ${id} running`
+        break
+      default:
+        break
+    }
+    return description
   }
+}
+
+class NotificationSource {
+  static loadData(): Promise<NotificationItemData[]> {
+    return Promise.all([
+      Api.get(`${servicesUrl.coriolis}/${Api.projectId}/migrations`),
+      Api.get(`${servicesUrl.coriolis}/${Api.projectId}/replicas/detail`),
+    ]).then(([migrationsResponse, replicasResponse]) => {
+      let migrations = migrationsResponse.data.migrations
+      let replicas = replicasResponse.data.replicas
+      let apiData = [...migrations, ...replicas]
+      apiData.sort((a, b) => moment(DataUtils.getUpdatedAt(b)).diff(DataUtils.getUpdatedAt(a)))
+
+      let notificationItems: NotificationItemData[] = apiData.map(item => {
+        let mainInfo = DataUtils.getMainInfo(item)
+
+        let newItem: NotificationItemData = {
+          id: item.id,
+          status: mainInfo.status,
+          type: item.type,
+          name: item.instances.join(', '),
+          updatedAt: mainInfo.updated_at,
+          description: DataUtils.getItemDescription(item),
+        }
+        return newItem
+      }).filter(item => item.status).filter((item, i) => i < 10)
 
-  static loadNotifications(): Promise<NotificationItem[]> {
-    return Promise.resolve(JSON.parse(localStorage.getItem('notifications') || '[]'))
+      let storageData = NotificationStorage.loadSeen()
+      if (!storageData) {
+        NotificationStorage.saveSeen(notificationItems)
+        storageData = NotificationStorage.loadSeen() || []
+      }
+      notificationItems.forEach(item => {
+        item.unseen = true
+        // $FlowIgnore
+        storageData.forEach(storageItem => {
+          if (storageItem.id === item.id && storageItem.status === item.status && storageItem.updatedAt === item.updatedAt) {
+            item.unseen = false
+          }
+        })
+      })
+      NotificationStorage.clean(notificationItems)
+      return notificationItems
+    })
   }
 
-  static clearNotifications(): Promise<void> {
-    localStorage.setItem('notifications', '[]')
-    return Promise.resolve()
+  static saveSeen(notificationItems: NotificationItemData[]) {
+    NotificationStorage.saveSeen(notificationItems)
   }
 }
 

+ 1 - 1
src/sources/WizardSource.js

@@ -54,7 +54,7 @@ class WizardSource {
       let newData = { ...data }
       newData.selectedInstances = [instance]
       return WizardSource.create(type, newData).catch(() => {
-        notificationStore.notify(`Error while creating ${type} for instance ${instance.name}`, 'error', {
+        notificationStore.alert(`Error while creating ${type} for instance ${instance.name}`, 'error', {
           persist: true,
           persistInfo: { title: `${type} creation error` },
         })

+ 1 - 1
src/stores/MigrationStore.js

@@ -84,7 +84,7 @@ class MigrationStore {
         ...this.migrations,
       ]
 
-      notificationStore.notify('Migration successfully created from replica.', 'success', {
+      notificationStore.alert('Migration successfully created from replica.', 'success', {
         action: {
           label: 'View Migration Status',
           callback: () => {

+ 11 - 18
src/stores/NotificationStore.js

@@ -16,18 +16,18 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import { observable, action } from 'mobx'
 
-import type { NotificationItem } from '../types/NotificationItem'
+import type { AlertInfo, NotificationItemData } from '../types/NotificationItem'
 import NotificationSource from '../sources/NotificationSource'
 
 class NotificationStore {
-  @observable notifications: NotificationItem[] = []
-  @observable persistedNotifications: NotificationItem[] = []
+  @observable alerts: AlertInfo[] = []
+  @observable notificationItems: NotificationItemData[] = []
 
   visibleErrors: string[] = []
 
-  @action notify(message: string, level?: $PropertyType<NotificationItem, 'level'>, options?: $PropertyType<NotificationItem, 'options'>): Promise<void> {
+  @action alert(message: string, level?: $PropertyType<AlertInfo, 'level'>, options?: $PropertyType<AlertInfo, 'options'>): Promise<void> {
     if (!this.visibleErrors.find(e => e === message)) {
-      this.notifications.push({ message, level, options })
+      this.alerts.push({ message, level, options })
 
       if (level === 'error') {
         this.visibleErrors.push(message)
@@ -35,25 +35,18 @@ class NotificationStore {
       }
     }
 
-    if (options && options.persist) {
-      return NotificationSource.notify(message, level, options).then((notification: NotificationItem) => {
-        this.persistedNotifications.push(notification)
-      })
-    }
-
     return Promise.resolve()
   }
 
-  @action loadNotifications(): Promise<void> {
-    return NotificationSource.loadNotifications().then((notifications: NotificationItem[]) => {
-      this.persistedNotifications = notifications
+  @action loadData(): Promise<void> {
+    return NotificationSource.loadData().then(data => {
+      this.notificationItems = data
     })
   }
 
-  @action clearNotifications(): Promise<void> {
-    return NotificationSource.clearNotifications().then(() => {
-      this.persistedNotifications = []
-    })
+  @action saveSeen() {
+    this.notificationItems = this.notificationItems.map(item => { return { ...item, unseen: false } })
+    NotificationSource.saveSeen(this.notificationItems)
   }
 }
 

+ 1 - 1
src/stores/ReplicaStore.js

@@ -115,7 +115,7 @@ class ReplicaStore {
 
   @action cancelExecution(replicaId: string, executionId: string): Promise<void> {
     return ReplicaSource.cancelExecution(replicaId, executionId).then(() => {
-      notificationStore.notify('Cancelled', 'success')
+      notificationStore.alert('Cancelled', 'success')
     })
   }
 

+ 5 - 5
src/stores/UserStore.js

@@ -57,7 +57,7 @@ class UserStore {
     }).then(() => {
       this.loading = false
       this.loggedIn = true
-      notificationStore.notify('Signed in', 'success')
+      notificationStore.alert('Signed in', 'success')
     }).catch((reason) => {
       this.loading = false
       this.loginFailedResponse = reason
@@ -100,7 +100,7 @@ class UserStore {
 
     return UserSource.tokenLogin().then(user => {
       this.loggedUser = { ...this.loggedUser, ...user }
-      notificationStore.notify('Signed in', 'success')
+      notificationStore.alert('Signed in', 'success')
       return this.getLoggedUserInfo()
     }).then(() => {
       return this.isAdmin()
@@ -113,14 +113,14 @@ class UserStore {
   }
 
   @action switchProject(projectId: string): Promise<void> {
-    notificationStore.notify('Switching projects')
+    notificationStore.alert('Switching projects')
     return UserSource.switchProject().then(() => {
       return this.loginScoped(projectId)
     }).then(() => {
       return this.isAdmin()
     }).catch(reason => {
       console.error('Error switching projects', reason)
-      notificationStore.notify('Error switching projects')
+      notificationStore.alert('Error switching projects')
       this.logout()
     })
   }
@@ -130,7 +130,7 @@ class UserStore {
 
     return UserSource.logout().catch(reason => {
       console.log('Error logging out', reason)
-      notificationStore.notify('Error logging out')
+      notificationStore.alert('Error logging out')
     })
   }
 

+ 12 - 1
src/types/NotificationItem.js

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-export type NotificationItem = {
+export type AlertInfo = {
   options?: {
     persist?: boolean,
     persistInfo?: { title: string },
@@ -27,3 +27,14 @@ export type NotificationItem = {
   id?: string,
   level?: 'success' | 'error' | 'info',
 }
+
+export type NotificationItemData = {
+  id: string,
+  name: string,
+  description: string,
+  type: string,
+  status: string,
+  unseen?: boolean,
+  updatedAt?: string,
+}
+

+ 3 - 3
src/utils/ApiCaller.js

@@ -102,7 +102,7 @@ class ApiCaller {
             let data = error.response.data
             let message = (data.error && data.error.message) || data.description
             if (message) {
-              notificationStore.notify(message, 'error')
+              notificationStore.alert(message, 'error')
             }
           }
 
@@ -116,7 +116,7 @@ class ApiCaller {
           // The request was made but no response was received
           // `error.request` is an instance of XMLHttpRequest
           if (window.location.hash !== loginUrl) {
-            notificationStore.notify('Request failed, there might be a problem with the connection to the server.', 'error')
+            notificationStore.alert('Request failed, there might be a problem with the connection to the server.', 'error')
           }
           console.log(`%cError No Response: ${axiosOptions.url}`, 'color: #D0021B')
           reject({})
@@ -128,7 +128,7 @@ class ApiCaller {
           }
 
           // Something happened in setting up the request that triggered an Error
-          notificationStore.notify('Request failed, there might be a problem with the connection to the server.', 'error')
+          notificationStore.alert('Request failed, there might be a problem with the connection to the server.', 'error')
           console.log(`%cError Something happened in setting up the request: ${axiosOptions.url}`, 'color: #D0021B')
         }
       })