Pārlūkot izejas kodu

Add unit tests and stories for all React components

You can run the tests using `yarn test`
You can start the Storybook app using `yarn storybook`
Sergiu Miclea 8 gadi atpakaļ
vecāks
revīzija
25f86721b3
96 mainītis faili ar 4298 papildinājumiem un 56 dzēšanām
  1. 1 0
      .eslintrc
  2. 1 1
      package.json
  3. 1 0
      private/storybook/Decorator.jsx
  4. 6 4
      src/components/atoms/ProgressBar/story.jsx
  5. 49 0
      src/components/molecules/LoginOptions/test.jsx
  6. 2 2
      src/components/molecules/MainListFilter/MainListFilter.jsx
  7. 64 0
      src/components/molecules/MainListFilter/story.jsx
  8. 65 0
      src/components/molecules/MainListFilter/test.jsx
  9. 1 1
      src/components/molecules/MainListItem/MainListItem.jsx
  10. 41 0
      src/components/molecules/MainListItem/story.jsx
  11. 51 0
      src/components/molecules/MainListItem/test.jsx
  12. 24 0
      src/components/molecules/Modal/story.jsx
  13. 32 0
      src/components/molecules/Modal/test.jsx
  14. 22 0
      src/components/molecules/NewItemDropdown/story.jsx
  15. 36 0
      src/components/molecules/NewItemDropdown/test.jsx
  16. 11 10
      src/components/molecules/NotificationDropdown/NotificationDropdown.jsx
  17. 49 0
      src/components/molecules/NotificationDropdown/story.jsx
  18. 67 0
      src/components/molecules/NotificationDropdown/test.jsx
  19. 2 2
      src/components/molecules/PropertiesTable/PropertiesTable.jsx
  20. 57 0
      src/components/molecules/PropertiesTable/story.jsx
  21. 48 0
      src/components/molecules/PropertiesTable/test.jsx
  22. 1 1
      src/components/molecules/SearchInput/SearchInput.jsx
  23. 22 0
      src/components/molecules/SearchInput/story.jsx
  24. 31 0
      src/components/molecules/SearchInput/test.jsx
  25. 22 0
      src/components/molecules/SideMenu/story.jsx
  26. 31 0
      src/components/molecules/SideMenu/test.jsx
  27. 48 0
      src/components/molecules/Table/story.jsx
  28. 38 0
      src/components/molecules/Table/test.jsx
  29. 2 2
      src/components/molecules/TaskItem/TaskItem.jsx
  30. 89 0
      src/components/molecules/TaskItem/story.jsx
  31. 45 0
      src/components/molecules/TaskItem/test.jsx
  32. 4 0
      src/components/molecules/Timeline/Timeline.jsx
  33. 79 0
      src/components/molecules/Timeline/story.jsx
  34. 51 0
      src/components/molecules/Timeline/test.jsx
  35. 40 0
      src/components/molecules/UserDropdown/story.jsx
  36. 44 0
      src/components/molecules/UserDropdown/test.jsx
  37. 2 2
      src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx
  38. 32 0
      src/components/molecules/WizardBreadcrumbs/story.jsx
  39. 38 0
      src/components/molecules/WizardBreadcrumbs/test.jsx
  40. 40 11
      src/components/molecules/WizardOptionsField/story.jsx
  41. 70 0
      src/components/molecules/WizardOptionsField/test.jsx
  42. 42 0
      src/components/molecules/WizardType/story.jsx
  43. 34 0
      src/components/molecules/WizardType/test.jsx
  44. 50 0
      src/components/organisms/AlertModal/story.jsx
  45. 53 0
      src/components/organisms/AlertModal/test.jsx
  46. 30 0
      src/components/organisms/ChooseProvider/story.jsx
  47. 49 0
      src/components/organisms/ChooseProvider/test.jsx
  48. 2 3
      src/components/organisms/DetailsContentHeader/DetailsContentHeader.jsx
  49. 81 0
      src/components/organisms/DetailsContentHeader/story.jsx
  50. 101 0
      src/components/organisms/DetailsContentHeader/test.jsx
  51. 24 0
      src/components/organisms/DetailsPageHeader/story.jsx
  52. 38 0
      src/components/organisms/DetailsPageHeader/test.jsx
  53. 1 1
      src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.jsx
  54. 38 0
      src/components/organisms/EndpointDetailsContent/story.jsx
  55. 90 0
      src/components/organisms/EndpointDetailsContent/test.jsx
  56. 31 0
      src/components/organisms/EndpointValidation/story.jsx
  57. 43 0
      src/components/organisms/EndpointValidation/test.jsx
  58. 1 1
      src/components/organisms/Executions/Executions.jsx
  59. 60 0
      src/components/organisms/Executions/story.jsx
  60. 82 0
      src/components/organisms/Executions/test.jsx
  61. 3 3
      src/components/organisms/FilterList/FilterList.jsx
  62. 69 0
      src/components/organisms/FilterList/story.jsx
  63. 76 0
      src/components/organisms/FilterList/test.jsx
  64. 1 1
      src/components/organisms/LoginForm/LoginForm.jsx
  65. 31 0
      src/components/organisms/LoginForm/story.jsx
  66. 43 0
      src/components/organisms/LoginForm/test.jsx
  67. 3 3
      src/components/organisms/MainDetails/MainDetails.jsx
  68. 47 0
      src/components/organisms/MainDetails/story.jsx
  69. 74 0
      src/components/organisms/MainDetails/test.jsx
  70. 84 0
      src/components/organisms/MainList/test.jsx
  71. 73 0
      src/components/organisms/MigrationDetailsContent/story.jsx
  72. 70 0
      src/components/organisms/MigrationDetailsContent/test.jsx
  73. 22 0
      src/components/organisms/Navigation/story.jsx
  74. 32 0
      src/components/organisms/Navigation/test.jsx
  75. 22 0
      src/components/organisms/PageHeader/story.jsx
  76. 86 0
      src/components/organisms/ReplicaDetailsContent/story.jsx
  77. 97 0
      src/components/organisms/ReplicaDetailsContent/test.jsx
  78. 22 0
      src/components/organisms/ReplicaExecutionOptions/story.jsx
  79. 58 0
      src/components/organisms/ReplicaExecutionOptions/test.jsx
  80. 22 0
      src/components/organisms/ReplicaMigrationOptions/story.jsx
  81. 35 0
      src/components/organisms/ReplicaMigrationOptions/test.jsx
  82. 1 1
      src/components/organisms/Schedule/Schedule.jsx
  83. 4 7
      src/components/organisms/Schedule/story.jsx
  84. 106 0
      src/components/organisms/Schedule/test.jsx
  85. 84 0
      src/components/organisms/Tasks/story.jsx
  86. 102 0
      src/components/organisms/Tasks/test.jsx
  87. 30 0
      src/components/organisms/WizardEndpointList/story.jsx
  88. 76 0
      src/components/organisms/WizardEndpointList/test.jsx
  89. 90 0
      src/components/organisms/WizardInstances/story.jsx
  90. 117 0
      src/components/organisms/WizardInstances/test.jsx
  91. 64 0
      src/components/organisms/WizardNetworks/story.jsx
  92. 82 0
      src/components/organisms/WizardNetworks/test.jsx
  93. 103 0
      src/components/organisms/WizardOptions/story.jsx
  94. 101 0
      src/components/organisms/WizardOptions/test.jsx
  95. 71 0
      src/components/organisms/WizardSummary/story.jsx
  96. 88 0
      src/components/organisms/WizardSummary/test.jsx

+ 1 - 0
.eslintrc

@@ -24,6 +24,7 @@
   "rules": {
     "semi": [2, "never"],
     "comma-dangle": [2, "always-multiline"],
+    "newline-per-chained-call": 0,
     "class-methods-use-this": 0,
     "max-len": 0,
     "prefer-const": 0,

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
     "start": "npm run env:dev && node server.js --dev",
     "env:dev": "cross-env NODE_ENV=development",
     "env:prod": "cross-env NODE_ENV=production",
-    "test": "jest --runInBand",
+    "test": "jest",
     "storybook": "start-storybook -p 9001 -c private/storybook",
     "lint": "eslint src private webpack.config.js --ext js,jsx",
     "build:clean": "rimraf \"dist/!(.git*|Procfile)**\"",

+ 1 - 0
private/storybook/Decorator.jsx

@@ -18,6 +18,7 @@ import PropTypes from 'prop-types'
 import Palette from '../../src/components/styleUtils/Palette'
 
 const Wrapper = styled.div`
+  display: inline-block;
   background: ${Palette.grayscale[7]};
   padding: 32px;
 `

+ 6 - 4
src/components/atoms/ProgressBar/story.jsx

@@ -17,16 +17,18 @@ import { storiesOf } from '@storybook/react'
 
 import ProgressBar from './ProgressBar'
 
+let Wrapper = props => <div style={{ width: '800px' }}><ProgressBar {...props} /></div>
+
 storiesOf('ProgressBar', module)
   .add('default 100%', () => (
-    <ProgressBar />
+    <Wrapper />
   ))
   .add('50%', () => (
-    <ProgressBar progress={50} />
+    <Wrapper progress={50} />
   ))
   .add('10%', () => (
-    <ProgressBar progress={10} />
+    <Wrapper progress={10} />
   ))
   .add('0%', () => (
-    <ProgressBar progress={0} />
+    <Wrapper progress={0} />
   ))

+ 49 - 0
src/components/molecules/LoginOptions/test.jsx

@@ -0,0 +1,49 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import LoginOptions from './LoginOptions'
+
+const wrap = props => shallow(<LoginOptions {...props} />)
+
+let buttons = [
+  {
+    name: 'Google',
+    id: 'google',
+    url: '',
+  },
+  {
+    name: 'Microsoft',
+    id: 'microsoft',
+    url: '',
+  },
+  {
+    name: 'Facebook',
+    id: 'facebook',
+    url: '',
+  },
+  {
+    name: 'GitHub',
+    id: 'github',
+    url: '',
+  },
+]
+
+it('renders with given buttons', () => {
+  let wrapper = wrap({ buttons })
+  expect(wrapper.children().length).toBe(4)
+  expect(wrapper.childAt(2).prop('id')).toBe('facebook')
+  expect(wrapper.childAt(1).html().indexOf('Sign in with Microsoft')).toBeGreaterThan(-1)
+})

+ 2 - 2
src/components/molecules/MainListFilter/MainListFilter.jsx

@@ -72,9 +72,9 @@ class MainListFilter extends React.Component {
     onActionChange: PropTypes.func,
     actions: PropTypes.array,
     selectedValue: PropTypes.string,
-    selectionInfo: PropTypes.object,
+    selectionInfo: PropTypes.object.isRequired,
     selectAllSelected: PropTypes.bool,
-    items: PropTypes.array,
+    items: PropTypes.array.isRequired,
   }
 
   renderFilterGroup() {

+ 64 - 0
src/components/molecules/MainListFilter/story.jsx

@@ -0,0 +1,64 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import MainListFilter from './MainListFilter'
+
+let items = [
+  { label: 'Item 1', value: 'item-1' },
+  { label: 'Item 2', value: 'item-2' },
+  { label: 'Item 3', value: 'item-3' },
+]
+
+let actions = [
+  { label: 'Action 1', value: 'action-1' },
+  { label: 'Action 2', value: 'action-2' },
+]
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+    this.state = { selectedValue: 'item-1', selectAllSelected: false }
+  }
+
+  handleChange(selectedValue) {
+    this.setState({ selectedValue })
+  }
+
+  handleSelectAllChange(selectAllSelected) {
+    this.setState({ selectAllSelected })
+  }
+
+  render() {
+    return (
+      <MainListFilter
+        {...this.props}
+        selectedValue={this.state.selectedValue}
+        selectAllSelected={this.state.selectAllSelected}
+        onFilterItemClick={item => { this.handleChange(item.value) }}
+        onSelectAllChange={checked => { this.handleSelectAllChange(checked) }}
+      />
+    )
+  }
+}
+
+storiesOf('MainListFilter', module)
+  .add('default', () => (
+    <Wrapper
+      items={items}
+      actions={actions}
+      selectionInfo={{ selected: 2, total: 7, label: 'items' }}
+    />
+  ))

+ 65 - 0
src/components/molecules/MainListFilter/test.jsx

@@ -0,0 +1,65 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import MainListFilter from './MainListFilter'
+
+const wrap = props => shallow(<MainListFilter {...props} />)
+
+let items = [
+  { label: 'Item 1', value: 'item-1' },
+  { label: 'Item 2', value: 'item-2' },
+  { label: 'Item 3', value: 'item-3' },
+]
+
+let actions = [
+  { label: 'Action 1', value: 'action-1' },
+  { label: 'Action 2', value: 'action-2' },
+]
+
+let selectionInfo = { selected: 2, total: 7, label: 'items' }
+
+it('renders given items', () => {
+  let wrapper = wrap({ items, actions, selectionInfo })
+  expect(wrapper.childAt(0).childAt(1).children().length).toBe(3)
+  expect(wrapper.childAt(0).childAt(1).childAt(1).html().indexOf('Item 2')).toBeGreaterThan(-1)
+})
+
+it('renders given actions', () => {
+  let wrapper = wrap({ items, actions, selectionInfo })
+  expect(wrapper.find('Dropdown').prop('items').length).toBe(2)
+  expect(wrapper.find('Dropdown').prop('items')[1].value).toBe('action-2')
+})
+
+it('renders selection info', () => {
+  let wrapper = wrap({ items, actions, selectionInfo })
+  expect(wrapper.childAt(1).childAt(0).html().indexOf('2 of 7 items(s) selected')).toBeGreaterThan(-1)
+})
+
+it('handles reload click', () => {
+  let onReloadButtonClick = sinon.spy()
+  let wrapper = wrap({ items, actions, selectionInfo, onReloadButtonClick }).find('ReloadButton')
+  wrapper.simulate('click')
+  expect(onReloadButtonClick.calledOnce).toBe(true)
+})
+
+it('handles item click with correct args', () => {
+  let onFilterItemClick = sinon.spy()
+  let wrapper = wrap({ items, actions, selectionInfo, onFilterItemClick })
+  let item = wrapper.childAt(0).childAt(1).childAt(2)
+  item.simulate('click')
+  expect(onFilterItemClick.args[0][0].value).toBe('item-3')
+})

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

@@ -101,7 +101,7 @@ const TasksRemaining = styled.div`
 
 class MainListItem extends React.Component {
   static propTypes = {
-    item: PropTypes.object,
+    item: PropTypes.object.isRequired,
     onClick: PropTypes.func,
     selected: PropTypes.bool,
     image: PropTypes.string,

+ 41 - 0
src/components/molecules/MainListItem/story.jsx

@@ -0,0 +1,41 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import MainListItem from './MainListItem'
+
+let item = {
+  origin_endpoint_id: 'openstack',
+  destination_endpoint_id: 'azure',
+  instances: ['instance name'],
+  executions: [{ status: 'COMPLETED', created_at: new Date() }],
+}
+let endpointType = id => id
+
+storiesOf('MainListItem', module)
+  .add('completed', () => (
+    <MainListItem item={item} endpointType={endpointType} />
+  ))
+  .add('running', () => (
+    <MainListItem
+      item={{
+        origin_endpoint_id: 'aws',
+        destination_endpoint_id: 'opc',
+        instances: ['instance name'],
+        executions: [{ status: 'RUNNING', created_at: new Date() }],
+      }}
+      endpointType={endpointType}
+    />
+  ))

+ 51 - 0
src/components/molecules/MainListItem/test.jsx

@@ -0,0 +1,51 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import MainListItem from './MainListItem'
+
+const wrap = props => shallow(<MainListItem {...props} />)
+
+let item = {
+  origin_endpoint_id: 'openstack',
+  destination_endpoint_id: 'azure',
+  instances: ['instance name'],
+  executions: [{ status: 'COMPLETED', created_at: new Date() }],
+}
+let endpointType = id => id
+
+it('renders with given status', () => {
+  let wrapper = wrap({ item, endpointType })
+  expect(wrapper.find('StatusPill').prop('status')).toBe('COMPLETED')
+})
+
+it('renders with given endpoints', () => {
+  let wrapper = wrap({ item, endpointType })
+  expect(wrapper.find('EndpointLogos').at(0).prop('endpoint')).toBe('openstack')
+  expect(wrapper.find('EndpointLogos').at(1).prop('endpoint')).toBe('azure')
+})
+
+it('renders with selected', () => {
+  let wrapper = wrap({ item, endpointType, selected: true })
+  expect(wrapper.find('Styled(Checkbox)').prop('checked')).toBe(true)
+})
+
+it('dispatched item click', () => {
+  let onClick = sinon.spy()
+  let wrapper = wrap({ item, endpointType, onClick })
+  wrapper.childAt(1).simulate('click')
+  expect(onClick.calledOnce).toBe(true)
+})

+ 24 - 0
src/components/molecules/Modal/story.jsx

@@ -0,0 +1,24 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Modal from './Modal'
+
+storiesOf('Modal', module)
+  .add('default', () => (
+    <Modal isOpen title="Modal title">
+      <div>Modal content</div>
+    </Modal>
+  ))

+ 32 - 0
src/components/molecules/Modal/test.jsx

@@ -0,0 +1,32 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import Modal from './Modal'
+
+const wrap = props => shallow(<Modal {...props} />)
+
+it('renders open with title', () => {
+  let wrapper = wrap({ isOpen: true, children: <div>Modal</div>, title: 'title' })
+  expect(wrapper.childAt(0).contains('title')).toBe(true)
+  expect(wrapper.prop('contentLabel')).toBe('title')
+  expect(wrapper.prop('isOpen')).toBe(true)
+})
+
+it('renders children and add resize handler', () => {
+  let wrapper = wrap({ isOpen: true, children: <div>Modal</div>, title: 'title' })
+  expect(wrapper.childAt(1).html().indexOf('Modal')).toBeGreaterThan(-1)
+  expect(wrapper.childAt(1).prop('onResizeUpdate')).toBeTruthy()
+})

+ 22 - 0
src/components/molecules/NewItemDropdown/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import NewItemDropdown from './NewItemDropdown'
+
+storiesOf('NewItemDropdown', module)
+  .add('default', () => (
+    <div style={{ marginLeft: '100px' }}><NewItemDropdown /></div>
+  ))

+ 36 - 0
src/components/molecules/NewItemDropdown/test.jsx

@@ -0,0 +1,36 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import NewItemDropdown from './NewItemDropdown'
+
+const wrap = props => shallow(<NewItemDropdown {...props} />)
+
+it('opens list on click', () => {
+  let wrapper = wrap()
+  expect(wrapper.children().length).toBe(1)
+  wrapper.childAt(0).simulate('click')
+  expect(wrapper.children().length).toBe(2)
+  expect(wrapper.childAt(1).children().length).toBe(3)
+})
+
+it('dispatches change on item click with correct args', () => {
+  let onChange = sinon.spy()
+  let wrapper = wrap({ onChange })
+  wrapper.childAt(0).simulate('click')
+  wrapper.childAt(1).childAt(1).simulate('click')
+  expect(onChange.args[0][0].value).toBe('replica')
+})

+ 11 - 10
src/components/molecules/NotificationDropdown/NotificationDropdown.jsx

@@ -137,6 +137,7 @@ class NotificationDropdown extends React.Component {
   static propTypes = {
     onItemClick: PropTypes.func,
     white: PropTypes.bool,
+    items: PropTypes.array,
   }
 
   constructor() {
@@ -173,8 +174,12 @@ class NotificationDropdown extends React.Component {
     window.removeEventListener('mousedown', this.handlePageClick, false)
   }
 
-  handleItemClick() {
+  handleItemClick(item) {
     this.setState({ showDropdownList: false })
+
+    if (this.props.onItemClick) {
+      this.props.onItemClick(item)
+    }
   }
 
   handlePageClick() {
@@ -185,14 +190,10 @@ class NotificationDropdown extends React.Component {
 
   handleButtonClick() {
     this.setState({ showDropdownList: !this.state.showDropdownList })
-
-    if (this.props.onItemClick) {
-      this.props.onItemClick()
-    }
   }
 
   renderNoItems() {
-    if (!this.state.showDropdownList || (this.state.items && this.state.items.length > 0)) {
+    if (!this.state.showDropdownList || (this.props.items && this.props.items.length > 0)) {
       return null
     }
 
@@ -209,13 +210,13 @@ class NotificationDropdown extends React.Component {
   }
 
   renderList() {
-    if (!this.state.showDropdownList || !this.state.items || this.state.items.length === 0) {
+    if (!this.state.showDropdownList || !this.props.items || this.props.items.length === 0) {
       return null
     }
 
     let list = (
       <List>
-        {this.state.items.map(item => {
+        {this.props.items.map(item => {
           return (
             <ListItem
               key={item.title}
@@ -238,9 +239,9 @@ class NotificationDropdown extends React.Component {
     return list
   }
   renderBell() {
-    let badge = this.state.items && this.state.items.length > 1 ? (
+    let badge = this.props.items && this.props.items.length > 1 ? (
       <Badge>
-        <BadgeLabel>{this.state.items.length}</BadgeLabel>
+        <BadgeLabel>{this.props.items.length}</BadgeLabel>
       </Badge>
     ) : null
 

+ 49 - 0
src/components/molecules/NotificationDropdown/story.jsx

@@ -0,0 +1,49 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import NotificationDropdown from './NotificationDropdown'
+
+storiesOf('NotificationDropdown', module)
+  .add('default', () => (
+    <div style={{ marginLeft: '200px' }}><NotificationDropdown /></div>
+  ))
+  .add('white', () => (
+    <div style={{ marginLeft: '200px' }}><NotificationDropdown white /></div>
+  ))
+  .add('notification types', () => (
+    <div style={{ marginLeft: '200px' }}>
+      <NotificationDropdown
+        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 },
+          },
+        ]}
+      />
+    </div>
+  ))

+ 67 - 0
src/components/molecules/NotificationDropdown/test.jsx

@@ -0,0 +1,67 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import NotificationDropdown from './NotificationDropdown'
+
+const wrap = props => shallow(<NotificationDropdown {...props} />)
+
+let 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 },
+  },
+]
+
+it('renders no items message on click', () => {
+  let wrapper = wrap()
+  expect(wrapper.children().length).toBe(1)
+  wrapper.childAt(0).simulate('click')
+  expect(wrapper.childAt(1).html().indexOf('There are no notifications')).toBeGreaterThan(-1)
+})
+
+it('renders items correctly', () => {
+  let wrapper = wrap({ items })
+  expect(wrapper.children().length).toBe(1)
+  wrapper.childAt(0).simulate('click')
+  let itemsWrapper = wrapper.childAt(1)
+  expect(itemsWrapper.findWhere(w => w.prop('success')).length).toBe(1)
+  expect(itemsWrapper.findWhere(w => w.prop('info')).length).toBe(1)
+  expect(itemsWrapper.findWhere(w => w.prop('error')).length).toBe(1)
+  expect(itemsWrapper.childAt(1).html().indexOf('Incrementally replicate virtual machines')).toBeGreaterThan(-1)
+})
+
+it('dispatches item click', () => {
+  let onItemClick = sinon.spy()
+  let wrapper = wrap({ items, onItemClick })
+  expect(wrapper.children().length).toBe(1)
+  wrapper.childAt(0).simulate('click')
+  let itemsWrapper = wrapper.childAt(1)
+  itemsWrapper.childAt(2).simulate('click')
+  expect(onItemClick.args[0][0].title).toBe('Endpoint')
+})

+ 2 - 2
src/components/molecules/PropertiesTable/PropertiesTable.jsx

@@ -56,9 +56,9 @@ const Row = styled.div`
 
 class PropertiesTable extends React.Component {
   static propTypes = {
-    properties: PropTypes.array,
+    properties: PropTypes.array.isRequired,
     onChange: PropTypes.func,
-    valueCallback: PropTypes.func,
+    valueCallback: PropTypes.func.isRequired,
   }
 
   renderSwitch(prop) {

+ 57 - 0
src/components/molecules/PropertiesTable/story.jsx

@@ -0,0 +1,57 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import PropertiesTable from './PropertiesTable'
+
+let properties = [
+  { type: 'boolean', name: 'prop-1', label: 'Property 1' },
+  { type: 'boolean', name: 'prop-2', label: 'Property 2' },
+]
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+    this.state = {}
+  }
+
+  handleChange(prop, value) {
+    let state = this.state
+    state[prop.name] = value
+    this.setState({ ...state })
+  }
+
+  valueCallback(prop) {
+    return this.state[prop.name]
+  }
+
+  render() {
+    return (
+      <div style={{ width: '200px' }}>
+        <PropertiesTable
+          {...this.props}
+          properties={properties}
+          valueCallback={prop => this.valueCallback(prop)}
+          onChange={(prop, value) => { this.handleChange(prop, value) }}
+        />
+      </div>
+    )
+  }
+}
+
+storiesOf('PropertiesTable', module)
+  .add('default', () => (
+    <Wrapper />
+  ))

+ 48 - 0
src/components/molecules/PropertiesTable/test.jsx

@@ -0,0 +1,48 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import PropertiesTable from './PropertiesTable'
+
+const wrap = props => shallow(<PropertiesTable {...props} />)
+
+let properties = [
+  { type: 'boolean', name: 'prop-1', value: true },
+  { type: 'boolean', name: 'prop-2', value: false },
+]
+
+it('renders boolean properties with correct labels', () => {
+  let wrapper = wrap({
+    properties,
+    valueCallback: prop => properties.find(p => p.name === prop.name).value,
+  })
+  expect(wrapper.children().length).toBe(2)
+  let item1 = wrapper.childAt(0)
+  let item2 = wrapper.childAt(1)
+  expect(item1.childAt(0).html().indexOf('Prop-1')).toBeGreaterThan(-1)
+  expect(item2.childAt(0).html().indexOf('Prop-2')).toBeGreaterThan(-1)
+})
+
+it('renders boolean properties with Switch components', () => {
+  let wrapper = wrap({
+    properties,
+    valueCallback: prop => properties.find(p => p.name === prop.name).value,
+  })
+  expect(wrapper.children().length).toBe(2)
+  let item1 = wrapper.childAt(0)
+  let item2 = wrapper.childAt(1)
+  expect(item1.find('Switch').prop('checked')).toBe(true)
+  expect(item2.find('Switch').prop('checked')).toBe(false)
+})

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

@@ -86,7 +86,7 @@ class SearchInput extends React.Component {
   }
 
   handleSearchButtonClick() {
-    this.input.focus()
+    this.input && this.input.focus()
     this.setState({ open: !this.state.open })
   }
 

+ 22 - 0
src/components/molecules/SearchInput/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import SearchInput from './SearchInput'
+
+storiesOf('SearchInput', module)
+  .add('default', () => (
+    <SearchInput />
+  ))

+ 31 - 0
src/components/molecules/SearchInput/test.jsx

@@ -0,0 +1,31 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import SearchInput from './SearchInput'
+
+const wrap = props => shallow(<SearchInput {...props} />)
+
+it('opens on button click', () => {
+  let wrapper = wrap()
+  expect(wrapper.prop('open')).toBe(undefined)
+  wrapper.find('Styled(SearchButton)').simulate('click')
+  expect(wrapper.prop('open')).toBe(true)
+})
+
+it('has loading state', () => {
+  let wrapper = wrap({ loading: true })
+  expect(wrapper.find('Styled(StatusIcon)').prop('status')).toBe('RUNNING')
+})

+ 22 - 0
src/components/molecules/SideMenu/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import SideMenu from './SideMenu'
+
+storiesOf('SideMenu', module)
+  .add('default', () => (
+    <SideMenu />
+  ))

+ 31 - 0
src/components/molecules/SideMenu/test.jsx

@@ -0,0 +1,31 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import SideMenu from './SideMenu'
+
+const wrap = props => shallow(<SideMenu {...props} />)
+
+it('opens menu on click', () => {
+  let wrapper = wrap()
+  expect(wrapper.childAt(1).prop('open')).toBe(false)
+  wrapper.childAt(0).simulate('click')
+  expect(wrapper.childAt(1).prop('open')).toBe(true)
+})
+
+it('renders at least one item in the list', () => {
+  let wrapper = wrap()
+  expect(wrapper.childAt(1).children().length).toBeGreaterThan(0)
+})

+ 48 - 0
src/components/molecules/Table/story.jsx

@@ -0,0 +1,48 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import { css } from 'styled-components'
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+import Table from './Table'
+
+let items = [
+  ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
+  ['item-6', 'item-7', 'item-8', 'item-9', 'item-10'],
+]
+let header = ['Header 1', 'Header 2', 'Header 3', 'Header 4', 'Header 5']
+
+storiesOf('Table', module)
+  .add('default', () => (
+    <div style={{ width: '300px' }}>
+      <Table
+        header={header}
+        items={items}
+      />
+    </div>
+  ))
+  .add('styled column', () => (
+    <div style={{ width: '300px' }}>
+      <Table
+        header={header}
+        items={items}
+        columnsStyle={[
+          css`font-weight: ${StyleProps.fontWeights.medium};`,
+          css`color: ${Palette.alert};`,
+        ]}
+      />
+    </div>
+  ))

+ 38 - 0
src/components/molecules/Table/test.jsx

@@ -0,0 +1,38 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import Table from './Table'
+
+const wrap = props => shallow(<Table {...props} />)
+
+let items = [
+  ['item-1', 'item-2', 'item-3', 'item-4', 'item-5'],
+  ['item-6', 'item-7', 'item-8', 'item-9', 'item-10'],
+]
+let headerItems = ['Header 1', 'Header 2', 'Header 3', 'Header 4', 'Header 5']
+
+it('renders header', () => {
+  let wrapper = wrap({ items, header: headerItems })
+  let header = wrapper.childAt(0)
+  expect(header.children().length).toBe(5)
+  expect(header.childAt(3).html().indexOf('Header 4')).toBeGreaterThan(-1)
+})
+
+it('renders header with calculated widths', () => {
+  let wrapper = wrap({ items, header: headerItems })
+  let header = wrapper.childAt(0)
+  expect(header.childAt(3).prop('width')).toBe('20%')
+})

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

@@ -108,8 +108,8 @@ const ProgressUpdateValue = styled.div`
 
 class TaskItem extends React.Component {
   static propTypes = {
-    columnWidths: PropTypes.array,
-    item: PropTypes.object,
+    columnWidths: PropTypes.array.isRequired,
+    item: PropTypes.object.isRequired,
     open: PropTypes.bool,
   }
 

+ 89 - 0
src/components/molecules/TaskItem/story.jsx

@@ -0,0 +1,89 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import TaskItem from './TaskItem'
+
+let item = {
+  progress_updates: [
+    { message: 'the task has a progress of 50%', created_at: new Date() },
+    { message: 'the task is almost done', created_at: new Date() },
+  ],
+  exception_details: 'Exception details',
+  status: 'RUNNING',
+  created_at: new Date(),
+  depends_on: ['depends on id'],
+  id: 'item-id',
+  task_type: 'Task name',
+}
+let columnWidths = ['26%', '18%', '36%', '20%']
+
+storiesOf('TaskItem', module)
+  .add('running', () => (
+    <div style={{ width: '800px' }}>
+      <TaskItem
+        item={item}
+        columnWidths={columnWidths}
+        open
+      />
+    </div>
+  ))
+  .add('closed', () => (
+    <div style={{ width: '800px' }}>
+      <TaskItem
+        item={item}
+        columnWidths={columnWidths}
+      />
+    </div>
+  ))
+  .add('completed', () => {
+    let newItem = { ...item }
+    newItem.status = 'COMPLETED'
+    return (
+      <div style={{ width: '800px' }}>
+        <TaskItem
+          item={newItem}
+          columnWidths={columnWidths}
+          open
+        />
+      </div>
+    )
+  })
+  .add('canceled', () => {
+    let newItem = { ...item }
+    newItem.status = 'CANCELED'
+    return (
+      <div style={{ width: '800px' }}>
+        <TaskItem
+          item={newItem}
+          columnWidths={columnWidths}
+          open
+        />
+      </div>
+    )
+  })
+  .add('error', () => {
+    let newItem = { ...item }
+    newItem.status = 'ERROR'
+    return (
+      <div style={{ width: '800px' }}>
+        <TaskItem
+          item={newItem}
+          columnWidths={columnWidths}
+          open
+        />
+      </div>
+    )
+  })

+ 45 - 0
src/components/molecules/TaskItem/test.jsx

@@ -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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import TaskItem from './TaskItem'
+
+const wrap = props => shallow(<TaskItem {...props} />)
+
+let item = {
+  progress_updates: [
+    { message: 'the task has a progress of 50%', created_at: new Date() },
+    { message: 'the task is almost done', created_at: new Date() },
+  ],
+  exception_details: 'Exception details',
+  status: 'RUNNING',
+  created_at: new Date(),
+  depends_on: ['depends on id'],
+  id: 'item-id',
+  task_type: 'Task name',
+}
+let columnWidths = ['26%', '18%', '36%', '20%']
+
+it('renders progress updates', () => {
+  let wrapper = wrap({ item, columnWidths, open: true })
+  let collapse = wrapper.find('Collapse')
+  expect(collapse.html().indexOf('the task has a progress of 50%')).toBeGreaterThan(-1)
+  expect(collapse.html().indexOf('the task is almost done')).toBeGreaterThan(-1)
+})
+
+it('renders progress bar', () => {
+  let wrapper = wrap({ item, columnWidths, open: true })
+  expect(wrapper.find('ProgressBar').prop('progress')).toBe(50)
+})

+ 4 - 0
src/components/molecules/Timeline/Timeline.jsx

@@ -105,6 +105,10 @@ class Timeline extends React.Component {
   }
 
   moveToSelectedItem() {
+    if (!this.progressLineRef || !this.endLineRef) {
+      return
+    }
+
     if (!this.itemRef || !this.props.selectedItem || !this.itemsRef) {
       this.progressLineRef.style.width = 0
       this.endLineRef.style.width = '100%'

+ 79 - 0
src/components/molecules/Timeline/story.jsx

@@ -0,0 +1,79 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Timeline from './Timeline'
+
+let items = [
+  { id: 'item-1', status: 'ERROR', created_at: new Date() },
+  { id: 'item-2', status: 'COMPLETED', created_at: new Date() },
+  { id: 'item-3', status: 'RUNNING', created_at: new Date() },
+]
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+    this.state = { selectedItem: items[2] }
+  }
+
+  handleItemClick(selectedItem) {
+    this.setState({ selectedItem })
+  }
+
+  handlePreviousClick() {
+    let selectedIndex = items.findIndex(e => e.id === this.state.selectedItem.id)
+
+    if (selectedIndex === 0) {
+      return
+    }
+
+    this.setState({ selectedItem: items[selectedIndex - 1] })
+  }
+
+  handleNextClick() {
+    let selectedIndex = items.findIndex(e => e.id === this.state.selectedItem.id)
+
+    if (selectedIndex >= items.length - 1) {
+      return
+    }
+
+    this.setState({ selectedItem: items[selectedIndex + 1] })
+  }
+
+  render() {
+    return (
+      <Timeline
+        {...this.props}
+        onPreviousClick={() => { this.handlePreviousClick() }}
+        onNextClick={() => { this.handleNextClick() }}
+        onItemClick={item => { this.handleItemClick(item) }}
+        selectedItem={this.state.selectedItem}
+        items={items}
+      />
+    )
+  }
+}
+
+storiesOf('Timeline', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}>
+      <Timeline />
+    </div>
+  ))
+  .add('with items', () => (
+    <div style={{ width: '800px' }}>
+      <Wrapper />
+    </div>
+  ))

+ 51 - 0
src/components/molecules/Timeline/test.jsx

@@ -0,0 +1,51 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import Timeline from './Timeline'
+
+const wrap = props => shallow(<Timeline {...props} />)
+
+let items = [
+  { id: 'item-1', status: 'ERROR', created_at: new Date(2017, 1, 2) },
+  { id: 'item-2', status: 'COMPLETED', created_at: new Date(2017, 2, 3) },
+  { id: 'item-3', status: 'RUNNING', created_at: new Date(2017, 3, 4) },
+]
+
+it('renders with correct dates', () => {
+  let wrapper = wrap({ items, selectedItem: items[2] })
+  let itemsWrapper = wrapper.childAt(2).childAt(0)
+  expect(itemsWrapper.childAt(0).html().indexOf('02 Feb 2017')).toBeGreaterThan(-1)
+  expect(itemsWrapper.childAt(1).html().indexOf('03 Mar 2017')).toBeGreaterThan(-1)
+  expect(itemsWrapper.childAt(2).html().indexOf('04 Apr 2017')).toBeGreaterThan(-1)
+})
+
+it('dispatches item click', () => {
+  let onItemClick = sinon.spy()
+  let wrapper = wrap({ items, selectedItem: items[2], onItemClick })
+  wrapper.childAt(2).childAt(0).childAt(1).simulate('click')
+  expect(onItemClick.args[0][0].id).toBe('item-2')
+})
+
+it('dispatches next and previous click', () => {
+  let onPreviousClick = sinon.spy()
+  let onNextClick = sinon.spy()
+  let wrapper = wrap({ items, selectedItem: items[2], onPreviousClick, onNextClick })
+  wrapper.find('Styled(Arrow)').at(0).simulate('click')
+  wrapper.find('Styled(Arrow)').at(1).simulate('click')
+  expect(onPreviousClick.calledOnce).toBe(true)
+  expect(onNextClick.calledOnce).toBe(true)
+})

+ 40 - 0
src/components/molecules/UserDropdown/story.jsx

@@ -0,0 +1,40 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import UserDropdown from './UserDropdown'
+
+storiesOf('UserDropdown', module)
+  .add('default', () => (
+    <div style={{ marginLeft: '100px' }}>
+      <UserDropdown
+        user={{
+          name: 'User name',
+          email: 'email@email.com',
+        }}
+      />
+    </div>
+  ))
+  .add('white', () => (
+    <div style={{ marginLeft: '100px' }}>
+      <UserDropdown
+        white
+        user={{
+          name: 'User name',
+          email: 'email@email.com',
+        }}
+      />
+    </div>
+  ))

+ 44 - 0
src/components/molecules/UserDropdown/test.jsx

@@ -0,0 +1,44 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import UserDropdown from './UserDropdown'
+
+const wrap = props => shallow(<UserDropdown {...props} />)
+
+let user = { name: 'User name', email: 'email@email.com' }
+
+it('opens dropdown on click', () => {
+  let wrapper = wrap({ user })
+  wrapper.childAt(0).simulate('click')
+  expect(wrapper.childAt(1).children().length).toBe(2)
+})
+
+it('renders user info', () => {
+  let wrapper = wrap({ user })
+  wrapper.childAt(0).simulate('click')
+  expect(wrapper.childAt(1).html().indexOf('User name')).toBeGreaterThan(-1)
+  expect(wrapper.childAt(1).html().indexOf('email@email.com')).toBeGreaterThan(-1)
+})
+
+it('dispatches item click', () => {
+  let onItemClick = sinon.spy()
+  let wrapper = wrap({ user, onItemClick })
+  wrapper.childAt(0).simulate('click')
+  let signout = wrapper.findWhere(w => w.prop('onClick') && w.html().indexOf('Sign Out') > -1)
+  signout.simulate('click')
+  expect(onItemClick.args[0][0].value).toBe('signout')
+})

+ 2 - 2
src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx

@@ -41,8 +41,8 @@ const Name = styled.div`
 
 class WizardBreadcrumbs extends React.Component {
   static propTypes = {
-    selected: PropTypes.object,
-    wizardType: PropTypes.string,
+    selected: PropTypes.object.isRequired,
+    wizardType: PropTypes.string.isRequired,
   }
 
   render() {

+ 32 - 0
src/components/molecules/WizardBreadcrumbs/story.jsx

@@ -0,0 +1,32 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardBreadcrumbs from './WizardBreadcrumbs'
+import { wizardConfig } from '../../../config'
+
+storiesOf('WizardBreadcrumbs', module)
+  .add('replica', () => (
+    <WizardBreadcrumbs
+      selected={{ ...wizardConfig.pages[0] }}
+      wizardType="replica"
+    />
+  ))
+  .add('migration', () => (
+    <WizardBreadcrumbs
+      selected={{ ...wizardConfig.pages[0] }}
+      wizardType="migration"
+    />
+  ))

+ 38 - 0
src/components/molecules/WizardBreadcrumbs/test.jsx

@@ -0,0 +1,38 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardBreadcrumbs from './WizardBreadcrumbs'
+import { wizardConfig } from '../../../config'
+
+const wrap = props => shallow(<WizardBreadcrumbs {...props} />)
+
+it('renders correct number of crumbs for replica', () => {
+  let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'replica' })
+  let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'replica')
+  expect(wrapper.children().length).toBe(pages.length)
+})
+
+it('renders correct number of crumbs for migration', () => {
+  let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'migration' })
+  let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'migration')
+  expect(wrapper.children().length).toBe(pages.length)
+})
+
+it('has correct page selected', () => {
+  let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'migration')
+  let wrapper = wrap({ selected: pages[2], wizardType: 'migration' })
+  expect(wrapper.findWhere(w => w.prop('selected')).html().indexOf(pages[2].breadcrumb)).toBeGreaterThan(-1)
+})

+ 40 - 11
src/components/molecules/WizardOptionsField/story.jsx

@@ -14,8 +14,14 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import React from 'react'
 import { storiesOf } from '@storybook/react'
+import styled from 'styled-components'
 import WizardOptionsField from './WizardOptionsField'
 
+const WizardOptionsFieldStyled = styled(WizardOptionsField) `
+  width: 319px;
+  justify-content: space-between;
+`
+
 class Wrapper extends React.Component {
   constructor() {
     super()
@@ -28,28 +34,51 @@ class Wrapper extends React.Component {
 
   render() {
     return (
-      <WizardOptionsField
-        {...this.props}
-        value={this.state.value}
-        onChange={value => { this.handleChange(value) }}
-      />
+      <div style={{ width: '800px' }}>
+        <WizardOptionsFieldStyled
+          {...this.props}
+          value={this.state.value}
+          onChange={value => { this.handleChange(value) }}
+        />
+      </div>
     )
   }
 }
 
 storiesOf('WizardOptionsField', module)
+  .add('string', () => (
+    <Wrapper
+      name="String input"
+      type="string"
+    />
+  ))
+  .add('switch with boolean', () => (
+    <Wrapper
+      name="Switch"
+      type="boolean"
+    />
+  ))
+  .add('switch with strict-boolean', () => (
+    <Wrapper
+      name="Switch"
+      type="strict-boolean"
+    />
+  ))
   .add('enum dropdown', () => (
-    <WizardOptionsField
+    <Wrapper
       type="string"
       name="Port Reuse"
-      value="keep_mac"
       enum={['keep_mac', 'reuse_ports', 'replace_mac']}
     />
   ))
-  .add('switch with strict-boolean', () => (
+  .add('object table', () => (
     <Wrapper
-      name="Switch"
-      type="boolean"
-      value={undefined}
+      type="object"
+      name="Object table"
+      properties={[
+        { type: 'boolean', name: 'prop-1', label: 'Property 1' },
+        { type: 'boolean', name: 'prop-2', label: 'Property 2' },
+      ]}
+      valueCallback={prop => prop.name === 'prop-2'}
     />
   ))

+ 70 - 0
src/components/molecules/WizardOptionsField/test.jsx

@@ -0,0 +1,70 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardOptionsField from './WizardOptionsField'
+
+const wrap = props => shallow(<WizardOptionsField {...props} />)
+
+it('renders label', () => {
+  let wrapper = wrap({ name: 'test string', type: 'string', value: 'input-value' })
+  expect(wrapper.childAt(0).html().indexOf('Test string')).toBeGreaterThan(-1)
+})
+
+it('renders string input with correct value', () => {
+  let wrapper = wrap({ name: 'test string', type: 'string', value: 'input-value' })
+  expect(wrapper.find('TextInput').prop('value')).toBe('input-value')
+})
+
+it('renders required string input', () => {
+  let wrapper = wrap({ name: 'test string', type: 'string', value: 'input-value', required: true })
+  expect(wrapper.find('TextInput').prop('required')).toBe(true)
+})
+
+it('renders strict boolean with correct value', () => {
+  let wrapper = wrap({ name: 'test string', type: 'strict-boolean', value: true })
+  expect(wrapper.find('Switch').prop('triState')).toBe(false)
+  expect(wrapper.find('Switch').prop('checked')).toBe(true)
+})
+
+it('renders boolean with correct value', () => {
+  let wrapper = wrap({ name: 'test string', type: 'boolean', value: true })
+  expect(wrapper.find('Switch').prop('triState')).toBe(true)
+  expect(wrapper.find('Switch').prop('checked')).toBe(true)
+})
+
+it('renders enum string', () => {
+  let wrapper = wrap({
+    name: 'test string',
+    type: 'string',
+    value: 'reuse_ports',
+    enum: ['keep_mac', 'reuse_ports', 'replace_mac'],
+  })
+  expect(wrapper.find('Dropdown').prop('selectedItem')).toBe('Reuse Existing Ports')
+  expect(wrapper.find('Dropdown').prop('items')[3].value).toBe('replace_mac')
+})
+
+it('renders object table', () => {
+  let wrapper = wrap({
+    name: 'test',
+    type: 'object',
+    properties: [
+      { type: 'boolean', name: 'prop-1', label: 'Property 1' },
+      { type: 'boolean', name: 'prop-2', label: 'Property 2' },
+    ],
+    valueCallback: prop => prop.name === 'prop-2',
+  })
+  expect(wrapper.find('PropertiesTable').prop('properties')[1].name).toBe('prop-2')
+})

+ 42 - 0
src/components/molecules/WizardType/story.jsx

@@ -0,0 +1,42 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardType from './WizardType'
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+    this.state = { isReplica: false }
+  }
+
+  handleChange(isReplica) {
+    this.setState({ isReplica })
+  }
+
+  render() {
+    return (
+      <WizardType
+        selected={this.state.isReplica ? 'replica' : 'migration'}
+        onChange={isReplica => { this.handleChange(isReplica) }}
+      />
+    )
+  }
+}
+
+storiesOf('WizardType', module)
+  .add('default', () => (
+    <Wrapper />
+  ))

+ 34 - 0
src/components/molecules/WizardType/test.jsx

@@ -0,0 +1,34 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import WizardType from './WizardType'
+
+const wrap = props => shallow(<WizardType {...props} />)
+
+it('renders with the correct type selected', () => {
+  let wrapper = wrap({ selected: 'migration' })
+  expect(wrapper.find('Switch').prop('checked')).toBe(false)
+  wrapper = wrap({ selected: 'replica' })
+  expect(wrapper.find('Switch').prop('checked')).toBe(true)
+})
+
+it('dispatches change', () => {
+  let onChange = sinon.spy()
+  let wrapper = wrap({ selected: 'replica', onChange })
+  wrapper.find('Switch').simulate('change', { passed: true })
+  expect(onChange.args[0][0].passed).toBe(true)
+})

+ 50 - 0
src/components/organisms/AlertModal/story.jsx

@@ -0,0 +1,50 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import AlertModal from './AlertModal'
+
+storiesOf('AlertModal', module)
+  .add('confirmation', () => (
+    <AlertModal
+      isOpen
+      title="Alert title"
+      message="Alert message"
+      extraMessage="Extra message"
+      onConfirmation={() => { }}
+      onRequestClose={() => { }}
+    />
+  ))
+  .add('error', () => (
+    <AlertModal
+      isOpen
+      type="error"
+      title="Alert title"
+      message="Alert message"
+      extraMessage="Extra message"
+      onConfirmation={() => { }}
+      onRequestClose={() => { }}
+    />
+  ))
+  .add('loading', () => (
+    <AlertModal
+      isOpen
+      type="loading"
+      title="Alert title"
+      message="Loading message"
+      onConfirmation={() => { }}
+      onRequestClose={() => { }}
+    />
+  ))

+ 53 - 0
src/components/organisms/AlertModal/test.jsx

@@ -0,0 +1,53 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import AlertModal from './AlertModal'
+
+const wrap = props => shallow(<AlertModal {...props} />)
+
+it('renders confirmation as default', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra' })
+  expect(wrapper.findWhere(w => w.prop('type') === 'confirmation')).toBeTruthy()
+})
+
+it('renders message and extra message', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra' })
+  expect(wrapper.childAt(0).html().indexOf('alert-message')).toBeGreaterThan(-1)
+  expect(wrapper.childAt(0).html().indexOf('alert-extra')).toBeGreaterThan(-1)
+})
+
+it('has correct buttons for confirmation', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra' })
+  expect(wrapper.find('Button').at(0).prop('secondary')).toBe(true)
+  expect(wrapper.find('Button').at(0).html().indexOf('No')).toBeGreaterThan(-1)
+  expect(wrapper.find('Button').at(1).html().indexOf('Yes')).toBeGreaterThan(-1)
+})
+
+it('has correct button for error', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra', type: 'error' })
+  expect(wrapper.find('Button').prop('secondary')).toBe(true)
+  expect(wrapper.find('Button').html().indexOf('Dismiss')).toBeGreaterThan(-1)
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra', type: 'loading' })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})
+
+it('renders loading with no buttons', () => {
+  let wrapper = wrap({ message: 'alert-message', extraMessage: 'alert-extra', type: 'loading' })
+  expect(wrapper.find('Button').length).toBe(0)
+})

+ 30 - 0
src/components/organisms/ChooseProvider/story.jsx

@@ -0,0 +1,30 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import ChooseProvider from './ChooseProvider'
+
+let providers = {
+  azure: { },
+  openstack: { },
+  opc: { },
+  oracle_vm: { },
+  vmware_vsphere: { },
+  aws: { },
+}
+storiesOf('ChooseProvider', module)
+  .add('all', () => (
+    <ChooseProvider providers={providers} />
+  ))

+ 49 - 0
src/components/organisms/ChooseProvider/test.jsx

@@ -0,0 +1,49 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import ChooseProvider from './ChooseProvider'
+
+const wrap = props => shallow(<ChooseProvider {...props} />)
+
+let providers = {
+  azure: {},
+  openstack: {},
+  opc: {},
+  oracle_vm: {},
+  vmware_vsphere: {},
+  aws: {},
+}
+
+it('renders all given providers', () => {
+  let wrapper = wrap({ providers })
+  expect(wrapper.find('Styled(EndpointLogos)').length).toBe(Object.keys(providers).length)
+})
+
+it('dispatches provider click', () => {
+  let onProviderClick = sinon.spy()
+  let wrapper = wrap({ providers, onProviderClick })
+  wrapper.find('Styled(EndpointLogos)').at(2).simulate('click')
+  expect(onProviderClick.calledOnce).toBe(true)
+  expect(onProviderClick.args[0][0]).toBe('opc')
+})
+
+it('dispatches cancel click', () => {
+  let onCancelClick = sinon.spy()
+  let wrapper = wrap({ providers, onCancelClick })
+  wrapper.find('Button').simulate('click')
+  expect(onCancelClick.calledOnce).toBe(true)
+})

+ 2 - 3
src/components/organisms/DetailsContentHeader/DetailsContentHeader.jsx

@@ -75,8 +75,7 @@ class DetailsContentHeader extends React.Component {
     onCancelClick: PropTypes.func,
     typeImage: PropTypes.string,
     buttonLabel: PropTypes.string,
-    description: PropTypes.string,
-    item: PropTypes.object,
+    item: PropTypes.object.isRequired,
     alertInfoPill: PropTypes.bool,
     primaryInfoPill: PropTypes.bool,
     alertButton: PropTypes.bool,
@@ -117,7 +116,7 @@ class DetailsContentHeader extends React.Component {
           alert={this.props.alertInfoPill}
           primary={this.props.primaryInfoPill}
         />
-        {<StatusPill status={this.getStatus()} />}
+        <StatusPill status={this.getStatus()} />
       </StatusPills>
     )
   }

+ 81 - 0
src/components/organisms/DetailsContentHeader/story.jsx

@@ -0,0 +1,81 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import DetailsContentHeader from './DetailsContentHeader'
+
+let item = {
+  origin_endpoint_id: 'openstack',
+  destination_endpoint_id: 'azure',
+  instances: ['The instance title'],
+  executions: [{ status: 'COMPLETED', created_at: new Date() }],
+}
+
+storiesOf('DetailsContentHeader', module)
+  .add('default', () => (
+    <DetailsContentHeader
+      item={item}
+    />
+  ))
+  .add('action button', () => (
+    <DetailsContentHeader
+      item={item}
+      buttonLabel="Action"
+      onActionButtonClick={() => { }}
+    />
+  ))
+  .add('running', () => (
+    <DetailsContentHeader
+      item={{ ...item, executions: [{ ...item.executions[0], status: 'RUNNING' }] }}
+      buttonLabel="Action"
+      onActionButtonClick={() => { }}
+    />
+  ))
+  .add('description', () => (
+    <DetailsContentHeader
+      item={{ ...item, executions: null, description: 'Description text' }}
+    />
+  ))
+  .add('alert pill', () => (
+    <DetailsContentHeader
+      item={item}
+      alertInfoPill
+    />
+  ))
+  .add('alert action button', () => (
+    <DetailsContentHeader
+      item={item}
+      alertButton
+      onActionButtonClick={() => { }}
+      buttonLabel="Alert button"
+    />
+  ))
+  .add('alert hollow action button', () => (
+    <DetailsContentHeader
+      item={item}
+      alertButton
+      hollowButton
+      onActionButtonClick={() => { }}
+      buttonLabel="Alert button"
+    />
+  ))
+  .add('disabled action button', () => (
+    <DetailsContentHeader
+      item={item}
+      actionButtonDisabled
+      onActionButtonClick={() => { }}
+      buttonLabel="Button"
+    />
+  ))

+ 101 - 0
src/components/organisms/DetailsContentHeader/test.jsx

@@ -0,0 +1,101 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import DetailsContentHeader from './DetailsContentHeader'
+
+const wrap = props => shallow(<DetailsContentHeader {...props} />)
+
+let item = {
+  origin_endpoint_id: 'openstack',
+  destination_endpoint_id: 'azure',
+  instances: ['The instance title'],
+  type: 'item type',
+  executions: [{ status: 'COMPLETED', created_at: new Date() }],
+}
+
+it('renders title', () => {
+  let wrapper = wrap({ item })
+  expect(wrapper.html().indexOf('The instance title')).toBeGreaterThan(-1)
+})
+
+it('renders with no action button', () => {
+  let wrapper = wrap({ item })
+  expect(wrapper.find('Button').length).toBe(0)
+})
+
+it('renders with action button, if there\'s action button handler', () => {
+  let wrapper = wrap({ item, buttonLabel: 'action button', onActionButtonClick: () => { } })
+  expect(wrapper.find('Button').length).toBe(1)
+})
+
+it('dispatches action button click', () => {
+  let onActionButtonClick = sinon.spy()
+  let wrapper = wrap({ item, buttonLabel: 'action button', onActionButtonClick })
+  wrapper.find('Button').simulate('click')
+  expect(onActionButtonClick.calledOnce).toBe(true)
+})
+
+it('dispatches back button click', () => {
+  let onBackButonClick = sinon.spy()
+  let wrapper = wrap({ item, onBackButonClick })
+  wrapper.childAt(0).simulate('click')
+  expect(onBackButonClick.called).toBe(true)
+})
+
+it('renders cancel button if status is running', () => {
+  let wrapper = wrap({
+    item: { ...item, executions: [{ ...item.executions[0], status: 'RUNNING' }] },
+  })
+  expect(wrapper.find('Button').html().indexOf('Cancel')).toBeGreaterThan(-1)
+})
+
+it('dispatches cancel click', () => {
+  let onCancelClick = sinon.spy()
+  let wrapper = wrap({
+    item: { ...item, executions: [{ ...item.executions[0], status: 'RUNNING' }] },
+    onCancelClick,
+  })
+  wrapper.find('Button').simulate('click')
+  expect(onCancelClick.args[0][0].status).toBe('RUNNING')
+})
+
+it('renders action button label', () => {
+  let wrapper = wrap({ item, buttonLabel: 'action button', onActionButtonClick: () => { } })
+  expect(wrapper.find('Button').html().indexOf('action button')).toBeGreaterThan(-1)
+})
+
+it('renders correct INFO pill', () => {
+  let wrapper = wrap({ item, primaryInfoPill: true })
+  expect(wrapper.findWhere(w => w.name() === 'StatusPill' && w.prop('status') === 'INFO').prop('primary')).toBe(true)
+  expect(wrapper.findWhere(w => w.name() === 'StatusPill' && w.prop('status') === 'INFO').prop('label')).toBe('ITEM TYPE')
+  wrapper = wrap({ item, alertInfoPill: true })
+  expect(wrapper.findWhere(w => w.name() === 'StatusPill' && w.prop('status') === 'INFO').prop('alert')).toBe(true)
+})
+
+it('renders correct STATUS pill', () => {
+  let wrapper = wrap({ item })
+  expect(wrapper.findWhere(w => w.name() === 'StatusPill' && w.prop('status') === 'COMPLETED').length).toBe(1)
+  let newItem = { ...item, executions: [...item.executions] }
+  newItem.executions.push({ status: 'RUNNING', created_at: new Date() })
+  wrapper = wrap({ item: newItem })
+  expect(wrapper.findWhere(w => w.name() === 'StatusPill' && w.prop('status') === 'RUNNING').length).toBe(1)
+})
+
+it('renders item description', () => {
+  let wrapper = wrap({ item: { ...item, description: 'item description' } })
+  expect(wrapper.html().indexOf('item description')).toBeGreaterThan(-1)
+})

+ 24 - 0
src/components/organisms/DetailsPageHeader/story.jsx

@@ -0,0 +1,24 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import DetailsPageHeader from './DetailsPageHeader'
+
+storiesOf('DetailsPageHeader', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}>
+      <DetailsPageHeader user={{ name: 'Name', email: 'email@email.com' }} />
+    </div>
+  ))

+ 38 - 0
src/components/organisms/DetailsPageHeader/test.jsx

@@ -0,0 +1,38 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import DetailsPageHeader from './DetailsPageHeader'
+
+const wrap = props => shallow(<DetailsPageHeader {...props} />)
+
+let user = {
+  name: 'User name',
+  email: 'email@email.com',
+}
+
+it('renders with given user', () => {
+  let wrapper = wrap({ user })
+  expect(wrapper.find('Styled(UserDropdown)').prop('user').name).toBe(user.name)
+  expect(wrapper.find('Styled(UserDropdown)').prop('user').email).toBe(user.email)
+})
+
+it('dispatches user item click', () => {
+  let onUserItemClick = sinon.spy()
+  let wrapper = wrap({ user, onUserItemClick })
+  wrapper.find('Styled(UserDropdown)').simulate('itemClick')
+  expect(onUserItemClick.called).toBe(true)
+})

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

@@ -66,7 +66,7 @@ const LoadingWrapper = styled.div`
 
 class EndpointDetailsContent extends React.Component {
   static propTypes = {
-    item: PropTypes.object,
+    item: PropTypes.object.isRequired,
     connectionInfo: PropTypes.object,
     loading: PropTypes.bool,
     onDeleteClick: PropTypes.func,

+ 38 - 0
src/components/organisms/EndpointDetailsContent/story.jsx

@@ -0,0 +1,38 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import EndpointDetailsContent from './EndpointDetailsContent'
+
+let item = {
+  name: 'Name',
+  type: 'openstack',
+  description: 'Description',
+  created_at: new Date(),
+}
+
+let connectionInfo = {
+  username: 'username',
+  password: 'password123',
+  details: 'other details',
+}
+
+storiesOf('EndpointDetailsContent', module)
+  .add('connection info loading', () => (
+    <EndpointDetailsContent item={item} loading />
+  ))
+  .add('with connection info', () => (
+    <EndpointDetailsContent item={item} connectionInfo={connectionInfo} />
+  ))

+ 90 - 0
src/components/organisms/EndpointDetailsContent/test.jsx

@@ -0,0 +1,90 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import EndpointDetailsContent from './EndpointDetailsContent'
+
+const wrap = props => shallow(<EndpointDetailsContent {...props} />)
+
+let item = {
+  name: 'endpoint_name',
+  type: 'openstack',
+  description: 'endpoint_description',
+  created_at: new Date(2017, 10, 24, 13, 56),
+}
+
+let connectionInfo = {
+  username: 'username',
+  password: 'password123',
+  details: 'other details',
+  boolean_true: true,
+  boolean_false: false,
+  nested: {
+    nested_1: 'nested_first',
+    nested_2: 'nested_second',
+  },
+}
+
+it('renders endpoint details', () => {
+  let wrapper = wrap({ item })
+  expect(wrapper.html().indexOf('endpoint_name')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('openstack')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('endpoint_description')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('24/11/2017 15:56')).toBeGreaterThan(-1)
+})
+
+it('renders connection info loading', () => {
+  let wrapper = wrap({ item, loading: true })
+  expect(wrapper.html().indexOf('endpoint_name')).toBeGreaterThan(-1)
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})
+
+it('renders simple connection info', () => {
+  let wrapper = wrap({ item, connectionInfo })
+  expect(wrapper.html().indexOf('username')).toBeGreaterThan(-1)
+  expect(wrapper.find('PasswordValue').prop('value')).toBe('password123')
+  expect(wrapper.html().indexOf('other details')).toBeGreaterThan(-1)
+})
+
+it('renders boolean as Yes and No', () => {
+  let wrapper = wrap({ item, connectionInfo })
+  let yesResults = wrapper.findWhere(w => w.html().indexOf('Boolean True') > -1)
+  expect(yesResults.at(yesResults.length - 2).html().indexOf('Yes')).toBeGreaterThan(-1)
+  let noResults = wrapper.findWhere(w => w.html().indexOf('Boolean False') > -1)
+  expect(noResults.at(noResults.length - 2).html().indexOf('No')).toBeGreaterThan(-1)
+})
+
+it('renders nested connection info', () => {
+  let wrapper = wrap({ item, connectionInfo })
+  expect(wrapper.html().indexOf('Nested 1')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('nested_first')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('nested_second')).toBeGreaterThan(-1)
+  expect(wrapper.html().indexOf('Nested 2')).toBeGreaterThan(-1)
+})
+
+it('dispatches button clicks', () => {
+  let onDeleteClick = sinon.spy()
+  let onValidateClick = sinon.spy()
+  let onEditClick = sinon.spy()
+
+  let wrapper = wrap({ item, onDeleteClick, onValidateClick, onEditClick })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Edit') > -1).simulate('click')
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Validate') > -1).simulate('click')
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Delete') > -1).simulate('click')
+  expect(onEditClick.calledOnce).toBe(true)
+  expect(onValidateClick.calledOnce).toBe(true)
+  expect(onDeleteClick.calledOnce).toBe(true)
+})

+ 31 - 0
src/components/organisms/EndpointValidation/story.jsx

@@ -0,0 +1,31 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import EndpointValidation from './EndpointValidation'
+
+storiesOf('EndpointValidation', module)
+  .add('validating', () => (
+    <div style={{ width: '526px' }}><EndpointValidation loading /></div>
+  ))
+  .add('valid', () => (
+    <div style={{ width: '526px' }}><EndpointValidation validation={{ valid: true }} /></div>
+  ))
+  .add('failed', () => (
+    <div style={{ width: '526px' }}><EndpointValidation validation={{ }} /></div>
+  ))
+  .add('failed custom message', () => (
+    <div style={{ width: '526px' }}><EndpointValidation validation={{ message: 'Failed because of reasons' }} /></div>
+  ))

+ 43 - 0
src/components/organisms/EndpointValidation/test.jsx

@@ -0,0 +1,43 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import EndpointValidation from './EndpointValidation'
+
+const wrap = props => shallow(<EndpointValidation {...props} />)
+
+it('renders loading', () => {
+  let wrapper = wrap({ loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+  expect(wrapper.html().indexOf('Validating')).toBeGreaterThan(-1)
+})
+
+it('renders valid', () => {
+  let wrapper = wrap({ validation: { valid: true } })
+  expect(wrapper.find('StatusImage').prop('status')).toBe('COMPLETED')
+  expect(wrapper.html().indexOf('Endpoint is Valid')).toBeGreaterThan(-1)
+})
+
+it('renders failed with default message', () => {
+  let wrapper = wrap({ validation: { } })
+  expect(wrapper.find('StatusImage').prop('status')).toBe('ERROR')
+  expect(wrapper.html().indexOf('An unexpected error occurred.')).toBeGreaterThan(-1)
+})
+
+it('renders failed with custom message', () => {
+  let wrapper = wrap({ validation: { message: 'custom_message' } })
+  expect(wrapper.find('StatusImage').prop('status')).toBe('ERROR')
+  expect(wrapper.html().indexOf('custom_message')).toBeGreaterThan(-1)
+})

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

@@ -70,7 +70,7 @@ const NoExecutionText = styled.div`
 
 class Executions extends React.Component {
   static propTypes = {
-    item: PropTypes.object,
+    item: PropTypes.object.isRequired,
     onCancelExecutionClick: PropTypes.func,
     onDeleteExecutionClick: PropTypes.func,
     onExecuteClick: PropTypes.func,

+ 60 - 0
src/components/organisms/Executions/story.jsx

@@ -0,0 +1,60 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Executions from './Executions'
+
+let tasks = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 10%', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'COMPLETED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-1',
+    task_type: 'Task name 1',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+]
+
+let item = {
+  executions: [
+    { id: 'execution-1', status: 'ERROR', created_at: new Date() },
+    { id: 'execution-2', status: 'COMPLETED', created_at: new Date() },
+    { id: 'execution-2-1', status: 'CANCELED', created_at: new Date() },
+    { id: 'execution-3', status: 'RUNNING', created_at: new Date(), tasks },
+  ],
+}
+
+storiesOf('Executions', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}><Executions item={item} /></div>
+  ))
+  .add('no executions', () => (
+    <div style={{ width: '800px' }}><Executions item={{}} /></div>
+  ))

+ 82 - 0
src/components/organisms/Executions/test.jsx

@@ -0,0 +1,82 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import Executions from './Executions'
+
+const wrap = props => shallow(<Executions {...props} />)
+
+let item = {
+  executions: [
+    { id: 'execution-1', number: 1, status: 'ERROR', created_at: new Date() },
+    { id: 'execution-2', number: 2, status: 'COMPLETED', created_at: new Date() },
+    { id: 'execution-3', number: 3, status: 'CANCELED', created_at: new Date() },
+    { id: 'execution-4', number: 4, status: 'RUNNING', created_at: new Date() },
+  ],
+}
+
+it('selects last execution by default', () => {
+  let wrapper = wrap({ item })
+  expect(wrapper.html().indexOf('Execution #4')).toBeGreaterThan(-1)
+})
+
+it('selects previous execution on previous click', () => {
+  let wrapper = wrap({ item })
+  wrapper.find('Timeline').simulate('previousClick')
+  expect(wrapper.html().indexOf('Execution #3')).toBeGreaterThan(-1)
+  wrapper.find('Timeline').simulate('previousClick')
+  expect(wrapper.html().indexOf('Execution #2')).toBeGreaterThan(-1)
+})
+
+it('selects next execution on next click', () => {
+  let wrapper = wrap({ item })
+  wrapper.find('Timeline').simulate('previousClick')
+  wrapper.find('Timeline').simulate('previousClick')
+  wrapper.find('Timeline').simulate('nextClick')
+})
+
+it('doesn\'t select next execution on next click if not possible', () => {
+  let wrapper = wrap({ item })
+  wrapper.find('Timeline').simulate('nextClick')
+  expect(wrapper.html().indexOf('Execution #4')).toBeGreaterThan(-1)
+})
+
+it('dispatches cancel click', () => {
+  let onCancelExecutionClick = sinon.spy()
+  let wrapper = wrap({ item, onCancelExecutionClick })
+  wrapper.find('Button').simulate('click')
+  expect(onCancelExecutionClick.calledOnce).toBe(true)
+})
+
+it('dispatches delete click', () => {
+  let onDeleteExecutionClick = sinon.spy()
+  let wrapper = wrap({ item, onDeleteExecutionClick })
+  wrapper.find('Timeline').simulate('previousClick')
+  wrapper.find('Button').simulate('click')
+  expect(onDeleteExecutionClick.calledOnce).toBe(true)
+})
+
+it('renders no executions', () => {
+  let wrapper = wrap({ item: {} })
+  expect(wrapper.html().indexOf('It looks like there are no executions in this replica')).toBeGreaterThan(-1)
+})
+
+it('dispatches execute click', () => {
+  let onExecuteClick = sinon.spy()
+  let wrapper = wrap({ item: {}, onExecuteClick })
+  wrapper.find('Button').simulate('click')
+  expect(onExecuteClick.calledOnce).toBe(true)
+})

+ 3 - 3
src/components/organisms/FilterList/FilterList.jsx

@@ -23,7 +23,7 @@ const Wrapper = styled.div`
 
 class FilterList extends React.Component {
   static propTypes = {
-    items: PropTypes.array,
+    items: PropTypes.array.isRequired,
     actions: PropTypes.array,
     loading: PropTypes.bool,
     onReloadButtonClick: PropTypes.func,
@@ -31,8 +31,8 @@ class FilterList extends React.Component {
     onActionChange: PropTypes.func,
     selectionLabel: PropTypes.string,
     renderItemComponent: PropTypes.func,
-    itemFilterFunction: PropTypes.func,
-    filterItems: PropTypes.array,
+    itemFilterFunction: PropTypes.func.isRequired,
+    filterItems: PropTypes.array.isRequired,
     emptyListImage: PropTypes.string,
     emptyListMessage: PropTypes.string,
     emptyListExtraMessage: PropTypes.string,

+ 69 - 0
src/components/organisms/FilterList/story.jsx

@@ -0,0 +1,69 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import FilterList from './FilterList'
+
+let items = [
+  { id: 'item-1', label: 'Item 1' },
+  { id: 'item-2', label: 'Item 2' },
+  { id: 'item-3', label: 'Item 3' },
+  { id: 'item-3-a', label: 'Item 3-a' },
+]
+
+let filterItems = [
+  { label: 'All', value: 'all' },
+  { label: 'Items 1', value: 'item-1' },
+  { label: 'Items 2', value: 'item-2' },
+  { label: 'Items 3', value: 'item-3' },
+]
+
+let actions = [{ label: 'action', value: 'action' }]
+
+let itemFilterFunction = (item, filterStatus, filterText) => {
+  if (
+    (filterStatus !== 'all' && item.id.indexOf(filterStatus) === -1) ||
+    (item.label.toLowerCase().indexOf(filterText) === -1)
+  ) {
+    return false
+  }
+
+  return true
+}
+
+storiesOf('FilterList', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}>
+      <FilterList
+        items={items}
+        actions={actions}
+        filterItems={filterItems}
+        renderItemComponent={options => <div {...options}>{options.item.label}</div>}
+        itemFilterFunction={(...args) => itemFilterFunction(...args)}
+      />
+    </div>
+  ))
+  .add('empty list', () => (
+    <div style={{ width: '800px' }}>
+      <FilterList
+        items={[]}
+        filterItems={filterItems}
+        itemFilterFunction={(...args) => itemFilterFunction(...args)}
+        emptyListMessage="Empty list message"
+        emptyListExtraMessage="Empty list extra message"
+        emptyListButtonLabel="Create"
+      />
+    </div>
+  ))

+ 76 - 0
src/components/organisms/FilterList/test.jsx

@@ -0,0 +1,76 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import FilterList from './FilterList'
+
+const wrap = props => shallow(<FilterList {...props} />)
+
+let items = [
+  { id: 'item-1', label: 'Item 1' },
+  { id: 'item-2', label: 'Item 2' },
+  { id: 'item-3', label: 'Item 3' },
+  { id: 'item-3-a', label: 'Item 3-a' },
+]
+
+let filterItems = [
+  { label: 'All', value: 'all' },
+  { label: 'Items 1', value: 'item-1' },
+  { label: 'Items 2', value: 'item-2' },
+  { label: 'Items 3', value: 'item-3' },
+]
+
+let actions = [{ label: 'action', value: 'action' }]
+
+let itemFilterFunction = (item, filterStatus, filterText) => {
+  if (
+    (filterStatus !== 'all' && item.id.indexOf(filterStatus) === -1) ||
+    (item.label.toLowerCase().indexOf(filterText) === -1)
+  ) {
+    return false
+  }
+
+  return true
+}
+
+it('renders with all items', () => {
+  let wrapper = wrap({ items, filterItems, itemFilterFunction, actions })
+  expect(wrapper.find('MainList').prop('items').length).toBe(4)
+})
+
+it('handles filter item click', () => {
+  let wrapper = wrap({ items, filterItems, itemFilterFunction, actions })
+  wrapper.find('MainListFilter').simulate('filterItemClick', { ...filterItems[2] })
+  expect(wrapper.find('MainList').prop('items').length).toBe(1)
+  expect(wrapper.find('MainList').prop('items')[0].id).toBe('item-2')
+})
+
+it('handles search change', () => {
+  let wrapper = wrap({ items, filterItems, itemFilterFunction, actions })
+  wrapper.find('MainListFilter').simulate('searchChange', 'item 3')
+  expect(wrapper.find('MainList').prop('items').length).toBe(2)
+  expect(wrapper.find('MainList').prop('items')[0].id).toBe('item-3')
+  expect(wrapper.find('MainList').prop('items')[1].id).toBe('item-3-a')
+})
+
+it('dispaches action for all selected items', () => {
+  let onActionChange = sinon.spy()
+  let wrapper = wrap({ items, filterItems, itemFilterFunction, actions, onActionChange })
+  wrapper.find('MainListFilter').simulate('selectAllChange', true)
+  wrapper.find('MainListFilter').simulate('actionChange', { ...actions[0] })
+  expect(onActionChange.args[0][0].length).toBe(4)
+  expect(onActionChange.args[0][1].value).toBe('action')
+})

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

@@ -170,7 +170,7 @@ class LoginForm extends React.Component {
           <LoginFormField
             label="Username"
             value={this.state.username}
-            name="password"
+            name="username"
             onChange={this.handleUsernameChange}
           />
           <LoginFormField

+ 31 - 0
src/components/organisms/LoginForm/story.jsx

@@ -0,0 +1,31 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import LoginForm from './LoginForm'
+
+storiesOf('LoginForm', module)
+  .add('default', () => (
+    <LoginForm />
+  ))
+  .add('loading', () => (
+    <LoginForm loading />
+  ))
+  .add('incorrect credentials', () => (
+    <LoginForm loginFailedResponse={{ status: 401 }} />
+  ))
+  .add('server error', () => (
+    <LoginForm loginFailedResponse={{}} />
+  ))

+ 43 - 0
src/components/organisms/LoginForm/test.jsx

@@ -0,0 +1,43 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import LoginForm from './LoginForm'
+
+const wrap = props => shallow(<LoginForm {...props} />)
+
+it('renders incorrect credentials', () => {
+  let wrapper = wrap({ loginFailedResponse: { status: 401 } })
+  expect(wrapper.html().indexOf('The username or password did not match. Please try again.')).toBeGreaterThan(-1)
+})
+
+it('renders server error', () => {
+  let wrapper = wrap({ loginFailedResponse: {} })
+  expect(wrapper.html().indexOf('Request failed, there might be a problem with the connection to the server.')).toBeGreaterThan(-1)
+})
+
+it('submits correct info', () => {
+  let onFormSubmit = sinon.spy()
+  let wrapper = wrap({ onFormSubmit })
+  wrapper.findWhere(w => w.name() === 'LoginFormField' && w.prop('name') === 'username')
+    .simulate('change', { target: { value: 'usr' } })
+  wrapper.findWhere(w => w.name() === 'LoginFormField' && w.prop('name') === 'password')
+    .simulate('change', { target: { value: 'pswd' } })
+
+  wrapper.simulate('submit', { preventDefault: () => { } })
+  expect(onFormSubmit.args[0][0].username).toBe('usr')
+  expect(onFormSubmit.args[0][0].password).toBe('pswd')
+})

+ 3 - 3
src/components/organisms/MainDetails/MainDetails.jsx

@@ -79,8 +79,8 @@ const Loading = styled.div`
 
 class MainDetails extends React.Component {
   static propTypes = {
-    item: PropTypes.object,
-    endpoints: PropTypes.array,
+    item: PropTypes.object.isRequired,
+    endpoints: PropTypes.array.isRequired,
     bottomControls: PropTypes.node,
     loading: PropTypes.bool,
   }
@@ -218,7 +218,7 @@ class MainDetails extends React.Component {
           <Row marginBottom>
             <Field>
               <Label>Created</Label>
-              <Value>{DateUtils.getLocalTime(this.props.item.created_at).format('YYYY-MM-DD HH:mm:ss')}</Value>
+              <Value>{this.props.item.created_at ? DateUtils.getLocalTime(this.props.item.created_at).format('YYYY-MM-DD HH:mm:ss') : '-'}</Value>
             </Field>
           </Row>
           <Row>

+ 47 - 0
src/components/organisms/MainDetails/story.jsx

@@ -0,0 +1,47 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import MainDetails from './MainDetails'
+
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  info: { instance: { export_info: { devices: { nics: [{ network_name: 'map_1' }] } } } },
+  destination_environment: {
+    description: 'A description',
+    network_map: {
+      map_1: 'Mapping 1',
+    },
+  },
+  type: 'Replica',
+}
+
+storiesOf('MainDetails', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}><MainDetails endpoints={[]} item={{}} /></div>
+  ))
+  .add('loading', () => (
+    <div style={{ width: '800px' }}><MainDetails loading endpoints={[]} item={{}} /></div>
+  ))
+  .add('openstack -> azure', () => (
+    <div style={{ width: '800px' }}><MainDetails endpoints={endpoints} item={item} /></div>
+  ))

+ 74 - 0
src/components/organisms/MainDetails/test.jsx

@@ -0,0 +1,74 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import MainDetails from './MainDetails'
+
+const wrap = props => shallow(<MainDetails {...props} />)
+
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  info: { instance: { export_info: { devices: { nics: [{ network_name: 'map_1' }] } } } },
+  destination_environment: {
+    description: 'A description',
+    network_map: {
+      map_1: 'Mapping 1',
+    },
+  },
+  type: 'Replica',
+}
+
+it('renders with endpoint missing', () => {
+  let wrapper = wrap({ item: {}, endpoints: [] })
+  expect(wrapper.html().indexOf('Endpoint is missing') > -1).toBe(true)
+})
+
+it('renders endpoint info', () => {
+  let wrapper = wrap({ item, endpoints })
+  expect(wrapper.find('CopyValue').prop('value')).toBe('item-id')
+  expect(wrapper.html().indexOf('2017-11-24 18:15:00') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Endpoint OPS') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Endpoint AZURE') > -1).toBe(true)
+  expect(wrapper.html().indexOf('A description') > -1).toBe(true)
+})
+
+it('renders endpoints logos', () => {
+  let wrapper = wrap({ item, endpoints })
+  expect(wrapper.find('EndpointLogos').at(0).prop('endpoint')).toBe('openstack')
+  expect(wrapper.find('EndpointLogos').at(1).prop('endpoint')).toBe('azure')
+})
+
+it('renders network_map', () => {
+  let wrapper = wrap({ item, endpoints })
+  let tableItems = wrapper.find('Styled(Table)').prop('items')
+  expect(tableItems.length).toBe(1)
+  expect(tableItems[0].length).toBe(4)
+  expect(tableItems[0][0]).toBe('map_1')
+  expect(tableItems[0][1][0]).toBe('instance')
+  expect(tableItems[0][2]).toBe('Mapping 1')
+  expect(tableItems[0][3]).toBe('Existing network')
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ item: {}, endpoints: [], loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})

+ 84 - 0
src/components/organisms/MainList/test.jsx

@@ -0,0 +1,84 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import MainList from './MainList'
+
+const wrap = props => shallow(<MainList {...props} />)
+
+let items = [
+  { id: 'item-1', label: 'Item 1' },
+  { id: 'item-2', label: 'Item 2' },
+  { id: 'item-3', label: 'Item 3' },
+  { id: 'item-3-a', label: 'Item 3-a' },
+]
+
+let selectedItems = [{ ...items[1] }, { ...items[2] }]
+let renderItemComponent = options => <div {...options}>{options.item.label}</div>
+
+it('renders all items', () => {
+  let wrapper = wrap({ items, selectedItems, renderItemComponent })
+  let itemsWrapper = wrapper.findWhere(w => w.prop('item'))
+  expect(itemsWrapper.length).toBe(4)
+  expect(itemsWrapper.at(0).html().indexOf('Item 1') > -1).toBe(true)
+  expect(itemsWrapper.at(1).html().indexOf('Item 2') > -1).toBe(true)
+  expect(itemsWrapper.at(2).html().indexOf('Item 3') > -1).toBe(true)
+  expect(itemsWrapper.at(3).html().indexOf('Item 3-a') > -1).toBe(true)
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ items, selectedItems, renderItemComponent, loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})
+
+it('renders selected items', () => {
+  let wrapper = wrap({ items, selectedItems, renderItemComponent })
+  let itemsWrapper = wrapper.findWhere(w => w.prop('item'))
+  expect(itemsWrapper.length).toBe(4)
+  expect(itemsWrapper.at(0).prop('selected')).toBe(false)
+  expect(itemsWrapper.at(1).prop('selected')).toBe(true)
+  expect(itemsWrapper.at(2).prop('selected')).toBe(true)
+  expect(itemsWrapper.at(3).prop('selected')).toBe(false)
+})
+
+it('renders empty list', () => {
+  let wrapper = wrap({
+    items,
+    selectedItems,
+    renderItemComponent,
+    showEmptyList: true,
+    emptyListMessage: 'empty-list-message',
+    emptyListExtraMessage: 'empty-list-extra-message',
+    emptyListButtonLabel: 'empty-list-button-label',
+  })
+
+  expect(wrapper.html().indexOf('empty-list-message') > -1).toBe(true)
+  expect(wrapper.html().indexOf('empty-list-extra-message') > -1).toBe(true)
+  expect(wrapper.html().indexOf('empty-list-button-label') > -1).toBe(true)
+})
+
+it('dispaches empty list button click', () => {
+  let onEmptyListButtonClick = sinon.spy()
+  let wrapper = wrap({
+    items,
+    selectedItems,
+    renderItemComponent,
+    showEmptyList: true,
+    onEmptyListButtonClick,
+  })
+  wrapper.find('Button').simulate('click')
+  expect(onEmptyListButtonClick.calledOnce).toBe(true)
+})

+ 73 - 0
src/components/organisms/MigrationDetailsContent/story.jsx

@@ -0,0 +1,73 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import MigrationDetailsContent from './MigrationDetailsContent'
+
+let tasks = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 10%', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'COMPLETED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-1',
+    task_type: 'Task name 1',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+]
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  info: { instance: { export_info: { devices: { nics: [{ network_name: 'map_1' }] } } } },
+  tasks,
+  destination_environment: {
+    description: 'A description',
+    network_map: {
+      map_1: 'Mapping 1',
+    },
+  },
+  type: 'Migration',
+}
+
+storiesOf('MigrationDetailsContent', module)
+  .add('default', () => (
+    <MigrationDetailsContent item={item} endpoints={endpoints} page="" />
+  ))
+  .add('details loading', () => (
+    <MigrationDetailsContent item={item} endpoints={endpoints} page="" detailsLoading />
+  ))
+  .add('tasks', () => (
+    <MigrationDetailsContent item={item} endpoints={endpoints} page="tasks" />
+  ))

+ 70 - 0
src/components/organisms/MigrationDetailsContent/test.jsx

@@ -0,0 +1,70 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import MigrationDetailsContent from './MigrationDetailsContent'
+
+const wrap = props => shallow(<MigrationDetailsContent {...props} />)
+
+let tasks = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+]
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  tasks,
+  destination_environment: { description: 'A description' },
+  type: 'Migration',
+}
+
+it('renders main details page', () => {
+  let wrapper = wrap({ endpoints, item, page: '' })
+  expect(wrapper.find('MainDetails').prop('item').id).toBe('item-id')
+})
+
+it('renders tasks page', () => {
+  let wrapper = wrap({ endpoints, item, page: 'tasks' })
+  expect(wrapper.find('Tasks').prop('items')[0].id).toBe('task-2')
+})
+
+it('renders details loading', () => {
+  let wrapper = wrap({ endpoints, item, page: '', detailsLoading: true })
+  expect(wrapper.find('MainDetails').prop('loading')).toBe(true)
+})
+
+it('dispatches delete click', () => {
+  let onDeleteMigrationClick = sinon.spy()
+  let wrapper = wrap({ endpoints, item, page: '', onDeleteMigrationClick })
+  wrapper.find('MainDetails').prop('bottomControls').props.children.props.onClick()
+  expect(onDeleteMigrationClick.calledOnce).toBe(true)
+})

+ 22 - 0
src/components/organisms/Navigation/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Navigation from './Navigation'
+
+storiesOf('Navigation', module)
+  .add('default', () => (
+    <div style={{ width: '320px' }}><Navigation currentPage="replicas" /></div>
+  ))

+ 32 - 0
src/components/organisms/Navigation/test.jsx

@@ -0,0 +1,32 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import Navigation from './Navigation'
+
+const wrap = props => shallow(<Navigation {...props} />)
+
+it('renders all items', () => {
+  let wrapper = wrap()
+  let links = wrapper.findWhere(w => w.name() === 'styled.a')
+  expect(links.length).toBeGreaterThan(2)
+  expect(links.at(1).prop('href')).toBe('/#/migrations')
+})
+
+it('selects the current page', () => {
+  let wrapper = wrap({ currentPage: 'endpoints' })
+  let links = wrapper.findWhere(w => w.name() === 'styled.a' && w.prop('selected'))
+  expect(links.prop('href')).toBe('/#/endpoints')
+})

+ 22 - 0
src/components/organisms/PageHeader/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import PageHeader from './PageHeader'
+
+storiesOf('PageHeader', module)
+  .add('default', () => (
+    <div style={{ width: '1000px' }}><PageHeader title="Page title" /></div>
+  ))

+ 86 - 0
src/components/organisms/ReplicaDetailsContent/story.jsx

@@ -0,0 +1,86 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import ReplicaDetailsContent from './ReplicaDetailsContent'
+
+let tasks = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 10%', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'COMPLETED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-1',
+    task_type: 'Task name 1',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+]
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  info: { instance: { export_info: { devices: { nics: [{ network_name: 'map_1' }] } } } },
+  destination_environment: {
+    description: 'A description',
+    network_map: {
+      map_1: 'Mapping 1',
+    },
+  },
+  type: 'Replica',
+  executions: [
+    { id: 'execution-1', status: 'ERROR', created_at: new Date() },
+    { id: 'execution-2', status: 'COMPLETED', created_at: new Date() },
+    { id: 'execution-2-1', status: 'CANCELED', created_at: new Date() },
+    { id: 'execution-3', status: 'RUNNING', created_at: new Date(), tasks },
+  ],
+}
+
+storiesOf('ReplicaDetailsContent', module)
+  .add('default', () => (
+    <ReplicaDetailsContent item={item} endpoints={endpoints} page="" />
+  ))
+  .add('details loading', () => (
+    <ReplicaDetailsContent item={item} endpoints={endpoints} page="" detailsLoading />
+  ))
+  .add('executions', () => (
+    <ReplicaDetailsContent item={item} endpoints={endpoints} page="executions" />
+  ))
+  .add('schedule', () => (
+    <ReplicaDetailsContent
+      item={item}
+      endpoints={endpoints}
+      page="schedule"
+      scheduleStore={{ schedules: [] }}
+    />
+  ))

+ 97 - 0
src/components/organisms/ReplicaDetailsContent/test.jsx

@@ -0,0 +1,97 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import ReplicaDetailsContent from './ReplicaDetailsContent'
+
+const wrap = props => shallow(<ReplicaDetailsContent {...props} />)
+
+let endpoints = [
+  { id: 'endpoint-1', name: 'Endpoint OPS', type: 'openstack' },
+  { id: 'endpoint-2', name: 'Endpoint AZURE', type: 'azure' },
+]
+let item = {
+  origin_endpoint_id: 'endpoint-1',
+  destination_endpoint_id: 'endpoint-2',
+  id: 'item-id',
+  created_at: new Date(2017, 10, 24, 16, 15),
+  destination_environment: { description: 'A description' },
+  type: 'Replica',
+  executions: [
+    { id: 'execution-1', status: 'ERROR', created_at: new Date() },
+    { id: 'execution-2', status: 'COMPLETED', created_at: new Date() },
+    { id: 'execution-2-1', status: 'CANCELED', created_at: new Date() },
+    { id: 'execution-3', status: 'RUNNING', created_at: new Date() },
+  ],
+}
+
+it('renders main details page', () => {
+  let wrapper = wrap({ endpoints, item, page: '' })
+  expect(wrapper.find('MainDetails').prop('item').id).toBe('item-id')
+})
+
+it('renders executions page', () => {
+  let wrapper = wrap({ endpoints, item, page: 'executions' })
+  expect(wrapper.find('Executions').prop('item').executions[1].id).toBe('execution-2')
+})
+
+it('renders details loading', () => {
+  let wrapper = wrap({ endpoints, item, page: '', detailsLoading: true })
+  expect(wrapper.find('MainDetails').prop('loading')).toBe(true)
+})
+
+it('renders schedule page', () => {
+  let wrapper = wrap({ endpoints, item, page: 'schedule', scheduleStore: { schedules: [] } })
+  expect(wrapper.find('Schedule').prop('schedules').length).toBe(0)
+})
+
+it('has `Create migration` button disabled if the last status is not completed', () => {
+  let wrapper = wrap({ endpoints, item, page: '' })
+  expect((wrapper.find('MainDetails').prop('bottomControls').props.children[0].props.disabled)).toBe(true)
+})
+
+it('has `Create migration` button enabled if the last status is completed', () => {
+  let newItem = {
+    ...item,
+    executions: [...item.executions, { id: 'execution-4', status: 'COMPLETED', created_at: new Date() }],
+  }
+  let wrapper = wrap({ endpoints, item: newItem, page: '' })
+  expect((wrapper.find('MainDetails').prop('bottomControls').props.children[0].props.disabled)).toBe(false)
+})
+
+it('dispaches create migration click', () => {
+  let onCreateMigrationClick = sinon.spy()
+  let wrapper = wrap({ endpoints, item, page: '', onCreateMigrationClick })
+  wrapper.find('MainDetails').prop('bottomControls').props.children[0].props.onClick()
+  expect(onCreateMigrationClick.calledOnce).toBe(true)
+})
+
+it('has `Create migration` button disabled if endpoint is missing and last status is completed', () => {
+  let newItem = {
+    ...item,
+    origin_endpoint_id: 'missing',
+    executions: [...item.executions, { id: 'execution-4', status: 'COMPLETED', created_at: new Date() }],
+  }
+  let wrapper = wrap({ endpoints, item: newItem, page: '' })
+  expect((wrapper.find('MainDetails').prop('bottomControls').props.children[0].props.disabled)).toBe(true)
+})
+
+it('dispatches delete click', () => {
+  let onDeleteReplicaClick = sinon.spy()
+  let wrapper = wrap({ endpoints, item, page: '', onDeleteReplicaClick })
+  wrapper.find('MainDetails').prop('bottomControls').props.children[1].props.onClick()
+  expect(onDeleteReplicaClick.calledOnce).toBe(true)
+})

+ 22 - 0
src/components/organisms/ReplicaExecutionOptions/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import ReplicaExecutionOptions from './ReplicaExecutionOptions'
+
+storiesOf('ReplicaExecutionOptions', module)
+  .add('default', () => (
+    <div style={{ width: '562px' }}><ReplicaExecutionOptions /></div>
+  ))

+ 58 - 0
src/components/organisms/ReplicaExecutionOptions/test.jsx

@@ -0,0 +1,58 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import ReplicaExecutionOptions from './ReplicaExecutionOptions'
+
+import { executionOptions } from '../../../config'
+
+const wrap = props => shallow(<ReplicaExecutionOptions {...props} />)
+
+
+it('renders executionOptions from config', () => {
+  let wrapper = wrap()
+  expect(wrapper.find('Styled(WizardOptionsField)').length).toBe(executionOptions.length)
+  expect(wrapper.find('Styled(WizardOptionsField)').at(0).prop('name')).toBe(executionOptions[0].name)
+})
+
+it('renders executionOptions with default values', () => {
+  let wrapper = wrap()
+  expect(wrapper.find('Styled(WizardOptionsField)').at(0).prop('value')).toBe(executionOptions[0].value)
+})
+
+it('renders executionOptions with given values', () => {
+  let wrapper = wrap({ options: { shutdown_instances: true } })
+  expect(wrapper.find('Styled(WizardOptionsField)').at(0).prop('value')).toBe(true)
+})
+
+it('dispaches cancel click', () => {
+  let onCancelClick = sinon.spy()
+  let wrapper = wrap({ onCancelClick })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Cancel') > -1).simulate('click')
+  expect(onCancelClick.calledOnce).toBe(true)
+})
+
+it('renders custom execution button label', () => {
+  let wrapper = wrap({ executionLabel: 'custom_exec' })
+  expect(wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('custom_exec') > -1).length).toBe(1)
+})
+
+it('dispaches execution click', () => {
+  let onExecuteClick = sinon.spy()
+  let wrapper = wrap({ onExecuteClick })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Execute') > -1).simulate('click')
+  expect(onExecuteClick.args[0][0][0].name).toBe(executionOptions[0].name)
+})

+ 22 - 0
src/components/organisms/ReplicaMigrationOptions/story.jsx

@@ -0,0 +1,22 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import ReplicaMigrationOptions from './ReplicaMigrationOptions'
+
+storiesOf('ReplicaMigrationOptions', module)
+  .add('default', () => (
+    <div style={{ width: '562px' }}><ReplicaMigrationOptions /></div>
+  ))

+ 35 - 0
src/components/organisms/ReplicaMigrationOptions/test.jsx

@@ -0,0 +1,35 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import ReplicaMigrationOptions from './ReplicaMigrationOptions'
+
+const wrap = props => shallow(<ReplicaMigrationOptions {...props} />)
+
+it('dispatches cancel click', () => {
+  let onCancelClick = sinon.spy()
+  let wrapper = wrap({ onCancelClick })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Cancel') > -1).simulate('click')
+  expect(onCancelClick.calledOnce).toBe(true)
+})
+
+it('dispatches migrate click', () => {
+  let onMigrateClick = sinon.spy()
+  let wrapper = wrap({ onMigrateClick })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Migrate') > -1).simulate('click')
+  expect(onMigrateClick.args[0][0][0].name).toBe('clone_disks')
+  expect(onMigrateClick.args[0][0][0].value).toBe(true)
+})

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

@@ -21,7 +21,6 @@ import {
   Switch,
   Dropdown,
   Button,
-  DatetimePicker,
   ReplicaExecutionOptions,
   Modal,
   DropdownLink,
@@ -29,6 +28,7 @@ import {
   AlertModal,
 } from 'components'
 
+import DatetimePicker from '../../molecules/DatetimePicker/DatetimePicker'
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 import NotificationActions from '../../../actions/NotificationActions'

+ 4 - 7
src/components/organisms/Schedule/story.jsx

@@ -30,15 +30,12 @@ storiesOf('Schedule', module)
   .add('no schedules secondary', () => (
     <Wrapper><Schedule secondaryEmpty /></Wrapper>
   ))
-  .add('enabled/disabled schedules', () => (
-    <Wrapper><Schedule
-      schedules={[{}, { enabled: true }]}
-    /></Wrapper>
-  ))
-  .add('some date values schedules', () => (
+  .add('some values', () => (
     <Wrapper><Schedule
+      onChange={() => { }}
       schedules={[
         { schedule: { dom: 2, dow: 3, month: 2, hour: 13, minute: 29 }, expiration_date: new Date() },
-        { enabled: true, schedule: { dom: 2, dow: 3, month: 2, hour: 13, minute: 29 }, expiration_date: new Date() }]}
+        { enabled: true, schedule: { dom: 2, dow: 3, month: 2, hour: 13, minute: 29 }, expiration_date: new Date() },
+      ]}
     /></Wrapper>
   ))

+ 106 - 0
src/components/organisms/Schedule/test.jsx

@@ -0,0 +1,106 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import Schedule from './Schedule'
+
+const wrap = props => shallow(<Schedule {...props} />)
+
+let schedules = [
+  { schedule: { dom: 4, dow: 3, month: 2, hour: 13, minute: 29 }, expiration_date: new Date(2017, 10, 27, 17, 19) },
+  { enabled: true, schedule: { dom: 2, dow: 3, month: 2, hour: 13, minute: 29 }, expiration_date: new Date() },
+]
+
+it('renders no schedules', () => {
+  let wrapper = wrap()
+  expect(wrapper.html().indexOf('This Replica has no Schedules.') > -1).toBe(true)
+})
+
+it('dispaches no schedules `Add schedule` click', () => {
+  let onAddScheduleClick = sinon.spy()
+  let wrapper = wrap({ onAddScheduleClick })
+  wrapper.find('Button').simulate('click')
+  expect(onAddScheduleClick.calledOnce).toBe(true)
+})
+
+it('renders correct number of schedules', () => {
+  let wrapper = wrap({ schedules })
+  expect(wrapper.findWhere(w => w.name() === 'Switch' && w.prop('noLabel')).length).toBe(schedules.length)
+})
+
+it('renders correct enabled / disabled', () => {
+  let wrapper = wrap({ schedules })
+  let enabledSwitches = wrapper.findWhere(w => w.name() === 'Switch' && w.prop('noLabel'))
+  expect(enabledSwitches.at(0).prop('checked')).toBe(false)
+  expect(enabledSwitches.at(1).prop('checked')).toBe(true)
+})
+
+it('renders correct month, day of month, day of week, hour, minute and expiration date', () => {
+  let wrapper = wrap({ schedules })
+  expect(wrapper.find('Styled(Dropdown)').at(0).prop('selectedItem').value).toBe(2)
+  expect(wrapper.find('Styled(Dropdown)').at(1).prop('selectedItem').value).toBe(4)
+  expect(wrapper.find('Styled(Dropdown)').at(2).prop('selectedItem').value).toBe(3)
+  expect(wrapper.find('Styled(Dropdown)').at(3).prop('selectedItem').value).toBe(13)
+  expect(wrapper.find('Styled(Dropdown)').at(4).prop('selectedItem').value).toBe(29)
+  expect(wrapper.find('DatetimePicker').at(0).prop('value').toString()).toBe('Mon Nov 27 2017 17:19:00 GMT+0200')
+})
+
+it('renders correct hour with local timezone', () => {
+  let wrapper = wrap({ schedules, timezone: 'local' })
+  expect(wrapper.find('Styled(Dropdown)').at(3).prop('selectedItem').value).toBe(15)
+})
+
+it('renders correct timezone in footer', () => {
+  let wrapper = wrap({ schedules, timezone: 'utc' })
+  expect(wrapper.find('DropdownLink').prop('selectedItem')).toBe('utc')
+})
+
+it('dispatches timezone change', () => {
+  let onTimezoneChange = sinon.spy()
+  let wrapper = wrap({ schedules, onTimezoneChange })
+  wrapper.find('DropdownLink').simulate('change')
+  expect(onTimezoneChange.calledOnce).toBe(true)
+})
+
+it('dispatches Add schedule click from list of schedules with local timezone', () => {
+  let onAddScheduleClick = sinon.spy()
+  let wrapper = wrap({ schedules, onAddScheduleClick, timezone: 'local' })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Add Schedule') > -1).simulate('click')
+  expect(onAddScheduleClick.args[0][0].schedule.hour).toBe(22)
+})
+
+it('dispatches Add schedule click from list of schedules with UTC timezone', () => {
+  let onAddScheduleClick = sinon.spy()
+  let wrapper = wrap({ schedules, onAddScheduleClick, timezone: 'utc' })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Add Schedule') > -1).simulate('click')
+  expect(onAddScheduleClick.args[0][0].schedule.hour).toBe(0)
+})
+
+it('shows options modal', () => {
+  let wrapper = wrap({ schedules })
+  wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('•••') > -1).at(0).simulate('click')
+  expect(wrapper.find('Modal').prop('isOpen')).toBe(true)
+})
+
+it('has add button disabled while adding a schedule', () => {
+  let wrapper = wrap({ schedules, adding: true })
+  expect(wrapper.findWhere(w => w.name() === 'Button' && w.html().indexOf('Add Schedule') > -1).prop('disabled')).toBe(true)
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ schedules: [], loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})

+ 84 - 0
src/components/organisms/Tasks/story.jsx

@@ -0,0 +1,84 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Tasks from './Tasks'
+
+let items = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 10%', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'COMPLETED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-1',
+    task_type: 'Task name 1',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'CANCELED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'ERROR',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-3',
+    task_type: 'Task name 3',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-4',
+    task_type: 'Task name 4',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'PENDING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-5',
+    task_type: 'Task name 5',
+  },
+]
+
+storiesOf('Tasks', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}><Tasks items={items} /></div>
+  ))

+ 102 - 0
src/components/organisms/Tasks/test.jsx

@@ -0,0 +1,102 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import Tasks from './Tasks'
+
+const wrap = props => shallow(<Tasks {...props} />)
+
+let items = [
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 10%', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'COMPLETED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-1',
+    task_type: 'Task name 1',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'CANCELED',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-2',
+    task_type: 'Task name 2',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'ERROR',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-3',
+    task_type: 'Task name 3',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'RUNNING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-4',
+    task_type: 'Task name 4',
+  },
+  {
+    progress_updates: [
+      { message: 'the task has a progress of 50%', created_at: new Date() },
+      { message: 'the task is almost done', created_at: new Date() },
+    ],
+    exception_details: 'Exception details',
+    status: 'PENDING',
+    created_at: new Date(),
+    depends_on: ['depends on id'],
+    id: 'task-5',
+    task_type: 'Task name 5',
+  },
+]
+
+it('renders correct number of task items', () => {
+  let wrapper = wrap({ items })
+  expect(wrapper.find('TaskItem').length).toBe(items.length)
+})
+
+it('renders only running task opened', () => {
+  let wrapper = wrap({ items })
+  expect(wrapper.find('TaskItem').at(0).prop('open')).toBe(false)
+  expect(wrapper.find('TaskItem').at(1).prop('open')).toBe(false)
+  expect(wrapper.find('TaskItem').at(2).prop('open')).toBe(false)
+  expect(wrapper.find('TaskItem').at(3).prop('open')).toBe(true)
+  expect(wrapper.find('TaskItem').at(4).prop('open')).toBe(false)
+})
+
+it('renders correct info in task item', () => {
+  let wrapper = wrap({ items })
+  expect(wrapper.find('TaskItem').at(2).prop('item').id).toBe('task-3')
+  expect(wrapper.find('TaskItem').at(4).prop('item').task_type).toBe('Task name 5')
+  expect(wrapper.find('TaskItem').at(0).prop('item').status).toBe('COMPLETED')
+})

+ 30 - 0
src/components/organisms/WizardEndpointList/story.jsx

@@ -0,0 +1,30 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardEndpointList from './WizardEndpointList'
+
+let providers = ['openstack', 'azure', 'aws', 'opc', 'oracle_vm', 'vmware_vsphere']
+
+let endpoints = [
+  { id: 'e-1', name: 'An endpoint', type: 'openstack' },
+  { id: 'e-2', name: 'Another endpoint', type: 'azure' },
+  { id: 'e-3', name: 'Yet another endpoint', type: 'azure' },
+]
+
+storiesOf('WizardEndpointList', module)
+  .add('default', () => (
+    <WizardEndpointList providers={providers} endpoints={endpoints} />
+  ))

+ 76 - 0
src/components/organisms/WizardEndpointList/test.jsx

@@ -0,0 +1,76 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardEndpointList from './WizardEndpointList'
+
+const wrap = props => shallow(<WizardEndpointList {...props} />)
+
+let providers = ['openstack', 'azure', 'aws', 'opc', 'oracle_vm', 'vmware_vsphere']
+
+let endpoints = [
+  { id: 'e-1', name: 'An endpoint', type: 'openstack' },
+  { id: 'e-2', name: 'Another endpoint', type: 'azure' },
+  { id: 'e-3', name: 'Yet another endpoint', type: 'azure' },
+]
+
+it('renders correct number of providers', () => {
+  let wrapper = wrap({ endpoints, providers })
+  expect(wrapper.find('Styled(EndpointLogos)').length).toBe(providers.length)
+})
+
+it('renders correct providers type', () => {
+  let wrapper = wrap({ endpoints, providers })
+  expect(wrapper.find('Styled(EndpointLogos)').at(0).prop('endpoint')).toBe(providers[0])
+  expect(wrapper.find('Styled(EndpointLogos)').at(1).prop('endpoint')).toBe(providers[1])
+  expect(wrapper.find('Styled(EndpointLogos)').at(2).prop('endpoint')).toBe(providers[2])
+  expect(wrapper.find('Styled(EndpointLogos)').at(3).prop('endpoint')).toBe(providers[3])
+  expect(wrapper.find('Styled(EndpointLogos)').at(4).prop('endpoint')).toBe(providers[4])
+  expect(wrapper.find('Styled(EndpointLogos)').at(5).prop('endpoint')).toBe(providers[5])
+})
+
+it('has providers with correct enpoints available', () => {
+  let wrapper = wrap({ endpoints, providers })
+  expect(wrapper.find('Dropdown').at(0).prop('items').length).toBe(2)
+  expect(wrapper.find('Dropdown').at(0).prop('items')[0].id).toBe('e-1')
+  expect(wrapper.find('Dropdown').at(1).prop('items').length).toBe(3)
+  expect(wrapper.find('Dropdown').at(1).prop('items')[0].id).toBe('e-2')
+  expect(wrapper.find('Dropdown').at(1).prop('items')[1].id).toBe('e-3')
+})
+
+it('renders add new', () => {
+  let wrapper = wrap({ endpoints, providers })
+  expect(wrapper.find('Dropdown').at(2).prop('items').length).toBe(1)
+  expect(wrapper.find('Dropdown').at(2).prop('items')[0].id).toBe('addNew')
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ endpoints, providers, loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})
+
+it('renders dropdown as primary if endpoint is selected', () => {
+  let wrapper = wrap({ endpoints, providers, selectedEndpoint: { ...endpoints[1] } })
+  expect(wrapper.find('Dropdown').at(1).prop('primary')).toBe(true)
+  expect(wrapper.find('Dropdown').at(0).prop('primary')).toBe(false)
+  expect(wrapper.find('Dropdown').at(2).prop('primary')).toBe(false)
+})
+
+it('doesn\'t render endpoint if another endpoint is supplied', () => {
+  let wrapper = wrap({ endpoints, providers, otherEndpoint: { ...endpoints[1] } })
+  expect(wrapper.find('Dropdown').at(1).prop('items').length).toBe(2)
+  expect(wrapper.find('Dropdown').at(1).prop('items')[0].id).toBe('e-3')
+  expect(wrapper.find('Dropdown').at(1).prop('items')[1].id).toBe('addNew')
+})

+ 90 - 0
src/components/organisms/WizardInstances/story.jsx

@@ -0,0 +1,90 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardInstances from './WizardInstances'
+
+let instances = [
+  { id: 'i-1', flavor_name: 'Flavor name', instance_name: 'Instance name 1', num_cpu: 3, memory_mb: 1024 },
+  { id: 'i-2', flavor_name: 'Flavor name', instance_name: 'Instance name 2', num_cpu: 3, memory_mb: 1024 },
+  { id: 'i-3', flavor_name: 'Flavor name', instance_name: 'Instance name 3', num_cpu: 3, memory_mb: 1024 },
+]
+
+storiesOf('WizardInstances', module)
+  .add('default', () => (
+    <div style={{ width: '800px' }}><WizardInstances instances={instances} currentPage={1} /></div>
+  ))
+  .add('some selection', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={instances}
+        currentPage={1}
+        selectedInstances={[{ ...instances[1] }]}
+      />
+    </div>
+  ))
+  .add('searching', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={instances}
+        currentPage={1}
+        searching
+      />
+    </div>
+  ))
+  .add('loading page', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={instances}
+        currentPage={1}
+        loadingPage
+      />
+    </div>
+  ))
+  .add('loading', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={instances}
+        currentPage={1}
+        loading
+      />
+    </div>
+  ))
+  .add('reloading', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={instances}
+        currentPage={1}
+        reloading
+      />
+    </div>
+  ))
+  .add('no instances', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={[]}
+        currentPage={1}
+      />
+    </div>
+  ))
+  .add('search no found', () => (
+    <div style={{ width: '800px' }}>
+      <WizardInstances
+        instances={[]}
+        currentPage={1}
+        searchNotFound
+      />
+    </div>
+  ))

+ 117 - 0
src/components/organisms/WizardInstances/test.jsx

@@ -0,0 +1,117 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import sinon from 'sinon'
+import WizardInstances from './WizardInstances'
+
+const wrap = props => shallow(<WizardInstances {...props} />)
+
+let instances = [
+  { id: 'i-1', flavor_name: 'Flavor name', instance_name: 'Instance name 1', num_cpu: 3, memory_mb: 1024 },
+  { id: 'i-2', flavor_name: 'Flavor name', instance_name: 'Instance name 2', num_cpu: 3, memory_mb: 1024 },
+  { id: 'i-3', flavor_name: 'Flavor name', instance_name: 'Instance name 3', num_cpu: 3, memory_mb: 1024 },
+]
+
+it('has correct number of instances', () => {
+  let wrapper = wrap({ instances, currentPage: 1 })
+  expect(wrapper.find('Styled(Checkbox)').length).toBe(instances.length)
+})
+
+it('has correct instances info', () => {
+  let wrapper = wrap({ instances, currentPage: 1 })
+  expect(wrapper.html().indexOf('Flavor name') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Instance name 1') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Instance name 2') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Instance name 3') > -1).toBe(true)
+  expect(wrapper.html().indexOf('1024 MB') > -1).toBe(true)
+})
+
+it('renders selected instances', () => {
+  let wrapper = wrap({
+    instances,
+    currentPage: 1,
+    selectedInstances: [
+      { ...instances[0] },
+      { ...instances[2] },
+    ],
+  })
+  expect(wrapper.html().indexOf('2 instances selected') > -1).toBe(true)
+  expect(wrapper.find('Styled(Checkbox)').at(0).prop('checked')).toBe(true)
+  expect(wrapper.find('Styled(Checkbox)').at(1).prop('checked')).toBe(false)
+  expect(wrapper.find('Styled(Checkbox)').at(2).prop('checked')).toBe(true)
+})
+
+it('renders current page', () => {
+  let wrapper = wrap({ instances, currentPage: 2 })
+  expect(wrapper.findWhere(w => w.name() === 'styled.div' && w.prop('number')).html().indexOf('2') > -1).toBe(true)
+})
+
+it('renders previous page disabled if page is 1', () => {
+  let wrapper = wrap({ instances, currentPage: 1 })
+  expect(wrapper.find('Arrow').at(0).prop('disabled')).toBe(true)
+})
+
+it('renders previous page disabled if page is greater than 1', () => {
+  let wrapper = wrap({ instances, currentPage: 3 })
+  expect(wrapper.find('Arrow').at(0).prop('disabled')).toBeFalsy()
+})
+
+it('renders loading', () => {
+  let wrapper = wrap({ instances, currentPage: 1, loading: true })
+  expect(wrapper.find('StatusImage').prop('loading')).toBe(true)
+})
+
+it('renders searching', () => {
+  let wrapper = wrap({ instances, currentPage: 1, searching: true })
+  expect(wrapper.find('SearchInput').prop('loading')).toBe(true)
+})
+
+it('renders search not found', () => {
+  let wrapper = wrap({ instances: [], currentPage: 1, searchNotFound: true })
+  expect(wrapper.html().indexOf('Your search returned no results') > -1).toBe(true)
+})
+
+it('renders loading page', () => {
+  let wrapper = wrap({ instances, currentPage: 1, loadingPage: true })
+  expect(wrapper.findWhere(w => w.name() === 'styled.div' && w.prop('number')).childAt(0).prop('status')).toBe('RUNNING')
+})
+
+it('enabled next page', () => {
+  let wrapper = wrap({ instances, currentPage: 1, hasNextPage: true })
+  expect(wrapper.find('Arrow').at(1).prop('disabled')).toBeFalsy()
+})
+
+it('dispatches next and previous page click, if enabled', () => {
+  let onNextPageClick = sinon.spy()
+  let onPreviousPageClick = sinon.spy()
+  let wrapper = wrap({ instances, currentPage: 1, onNextPageClick, onPreviousPageClick })
+  wrapper.findWhere(w => w.prop('previous') === true).simulate('click')
+  wrapper.findWhere(w => w.prop('next') === true).simulate('click')
+  expect(onPreviousPageClick.called).toBe(false)
+  expect(onPreviousPageClick.called).toBe(false)
+  wrapper = wrap({ instances, currentPage: 2, onNextPageClick, onPreviousPageClick, hasNextPage: true })
+  wrapper.findWhere(w => w.prop('previous') === true).simulate('click')
+  wrapper.findWhere(w => w.prop('next') === true).simulate('click')
+  expect(onPreviousPageClick.called).toBe(true)
+  expect(onPreviousPageClick.called).toBe(true)
+})
+
+it('dispaches reload click', () => {
+  let onReloadClick = sinon.spy()
+  let wrapper = wrap({ instances, currentPage: 1, onReloadClick })
+  wrapper.find('ReloadButton').simulate('click')
+  expect(onReloadClick.calledOnce).toBe(true)
+})

+ 64 - 0
src/components/organisms/WizardNetworks/story.jsx

@@ -0,0 +1,64 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardNetworks from './WizardNetworks'
+
+let networks = [
+  { name: 'network 1', value: 'n-1' },
+  { name: 'network 2', value: 'n-2' },
+]
+
+let instancesDetails = [
+  {
+    devices: { nics: [{ network_name: 'network 1', id: 'n-1' }] },
+    instance_name: 'Instance name 1',
+  },
+  {
+    devices: { nics: [{ network_name: 'network 2', id: 'n-2' }] },
+    instance_name: 'Instance name 2',
+  },
+]
+
+let selectedNetworks = [
+  {
+    sourceNic: { id: 'n-2' },
+    targetNetwork: { name: 'network 1' },
+  },
+]
+
+storiesOf('WizardNetworks', module)
+  .add('default', () => (
+    <WizardNetworks
+      networks={networks}
+      instancesDetails={instancesDetails}
+      selectedNetworks={selectedNetworks}
+    />
+  ))
+  .add('loading', () => (
+    <WizardNetworks
+      networks={networks}
+      instancesDetails={instancesDetails}
+      selectedNetworks={selectedNetworks}
+      loading
+    />
+  ))
+  .add('render no nics', () => (
+    <WizardNetworks
+      networks={networks}
+      instancesDetails={[{ ...instancesDetails[0], devices: { nics: [] } }]}
+      selectedNetworks={selectedNetworks}
+    />
+  ))

+ 82 - 0
src/components/organisms/WizardNetworks/test.jsx

@@ -0,0 +1,82 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardNetworks from './WizardNetworks'
+
+const wrap = props => shallow(<WizardNetworks {...props} />)
+
+let networks = [
+  { name: 'network 1', value: 'n-1' },
+  { name: 'network 2', value: 'n-2' },
+]
+
+let instancesDetails = [
+  {
+    devices: { nics: [{ network_name: 'network 1', id: 'n-1' }] },
+    instance_name: 'Instance name 1',
+  },
+  {
+    devices: { nics: [{ network_name: 'network 2', id: 'n-2' }] },
+    instance_name: 'Instance name 2',
+  },
+  {
+    devices: { nics: [{ network_name: 'network 3', id: 'n-3' }] },
+    instance_name: 'Instance name 3',
+  },
+]
+
+let selectedNetworks = [
+  {
+    sourceNic: { id: 'n-2' },
+    targetNetwork: { name: 'network 1' },
+  },
+]
+
+it('renders correct number of instance details', () => {
+  let wrapper = wrap({ networks, instancesDetails })
+  expect(wrapper.find('Dropdown').length).toBe(instancesDetails.length)
+})
+
+it('renders correct info for instance details', () => {
+  let wrapper = wrap({ networks, instancesDetails })
+  expect(wrapper.html().indexOf('Connected to Instance name 1') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Connected to Instance name 2') > -1).toBe(true)
+  expect(wrapper.html().indexOf('network 1') > -1).toBe(true)
+  expect(wrapper.html().indexOf('network 2') > -1).toBe(true)
+})
+
+it('has dropdown with correct number of networks', () => {
+  let wrapper = wrap({ networks, instancesDetails })
+  expect(wrapper.find('Dropdown').at(0).prop('items').length).toBe(networks.length)
+})
+
+it('has dropdown with correct networks info', () => {
+  let wrapper = wrap({ networks, instancesDetails })
+  expect(wrapper.find('Dropdown').at(0).prop('items')[0].name).toBe('network 1')
+  expect(wrapper.find('Dropdown').at(0).prop('items')[1].name).toBe('network 2')
+})
+
+it('renders selected networks', () => {
+  let wrapper = wrap({ networks, instancesDetails, selectedNetworks })
+  expect(wrapper.find('Dropdown').at(0).prop('selectedItem')).toBeFalsy()
+  expect(wrapper.find('Dropdown').at(1).prop('selectedItem')).toBe('network 1')
+  expect(wrapper.find('Dropdown').at(2).prop('selectedItem')).toBeFalsy()
+})
+
+it('renders no nics message', () => {
+  let wrapper = wrap({ networks, instancesDetails: [{ ...instancesDetails[0], devices: { nics: [] } }] })
+  expect(wrapper.html().indexOf('No networks were found') > -1).toBe(true)
+})

+ 103 - 0
src/components/organisms/WizardOptions/story.jsx

@@ -0,0 +1,103 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardOptions from './WizardOptions'
+
+let fields = [
+  {
+    name: 'string_field',
+    type: 'string',
+  },
+  {
+    name: 'string_field_with_default',
+    type: 'string',
+    default: 'default',
+  },
+  {
+    required: true,
+    name: 'required_string_field',
+    type: 'string',
+  },
+  {
+    name: 'enum_field',
+    type: 'string',
+    enum: ['enum 1', 'enum 2', 'enum 3'],
+  },
+  {
+    name: 'boolean_field',
+    type: 'boolean',
+  },
+  {
+    name: 'boolean_field_2',
+    type: 'boolean',
+  },
+  {
+    name: 'strict_boolean_field',
+    type: 'strict-boolean',
+  },
+]
+
+class Wrapper extends React.Component {
+  constructor() {
+    super()
+    this.state = {
+      useAdvancedOptions: true,
+      data: {},
+    }
+  }
+
+  handleChange(field, value) {
+    let data = { ...this.state.data }
+    data[field.name] = value
+    this.setState({ data })
+  }
+
+  render() {
+    return (
+      <div style={{ width: '800px', display: 'flex', justifyContent: 'center' }}>
+        <WizardOptions
+          {...this.props}
+          data={this.state.data}
+          onChange={(field, value) => { this.handleChange(field, value) }}
+          useAdvancedOptions={this.state.useAdvancedOptions}
+          onAdvancedOptionsToggle={isAdvanced => { this.setState({ useAdvancedOptions: isAdvanced }) }}
+        />
+      </div>
+    )
+  }
+}
+
+storiesOf('WizardOptions', module)
+  .add('replica', () => (
+    <Wrapper
+      fields={fields}
+      selectedInstances={[]}
+      wizardType="replica"
+    />
+  ))
+  .add('migration', () => (
+    <Wrapper
+      fields={fields}
+      selectedInstances={[]}
+      wizardType="migration"
+    />
+  ))
+  .add('multiple instances', () => (
+    <Wrapper
+      fields={fields}
+      selectedInstances={[{}, {}]}
+    />
+  ))

+ 101 - 0
src/components/organisms/WizardOptions/test.jsx

@@ -0,0 +1,101 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardOptions from './WizardOptions'
+
+const wrap = props => shallow(<WizardOptions {...props} />)
+
+let fields = [
+  {
+    name: 'string_field',
+    type: 'string',
+  },
+  {
+    name: 'string_field_with_default',
+    type: 'string',
+    default: 'default',
+  },
+  {
+    required: true,
+    name: 'required_string_field',
+    type: 'string',
+  },
+  {
+    name: 'enum_field',
+    type: 'string',
+    enum: ['enum 1', 'enum 2', 'enum 3'],
+  },
+  {
+    name: 'boolean_field',
+    type: 'boolean',
+  },
+  {
+    name: 'strict_boolean_field',
+    type: 'strict-boolean',
+  },
+]
+
+it('has description and required field in simple tab', () => {
+  let wrapper = wrap({ fields, selectedInstances: [] })
+  let optionsFields = wrapper.find('Styled(WizardOptionsField)')
+  expect(optionsFields.length).toBe(2)
+  expect(optionsFields.at(0).prop('name')).toBe('description')
+  expect(optionsFields.at(1).prop('name')).toBe('required_string_field')
+})
+
+it('renders execute now for replica', () => {
+  let wrapper = wrap({ fields, selectedInstances: [], wizardType: 'replica' })
+  expect(wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === 'execute_now').length).toBe(1)
+  expect(wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === 'execute_now_options').length).toBe(1)
+})
+
+it('renders skip os morphing for migration', () => {
+  let wrapper = wrap({ fields, selectedInstances: [], wizardType: 'migration' })
+  expect(wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === 'skip_os_morphing').length).toBe(1)
+})
+
+it('renders separate / vm if multiple instances are selected', () => {
+  let wrapper = wrap({ fields, selectedInstances: [{}, {}] })
+  expect(wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === 'separate_vm').length).toBe(1)
+})
+
+it('renders correct number of fields in advanced tab', () => {
+  let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true })
+  let optionsFields = wrapper.find('Styled(WizardOptionsField)')
+  expect(optionsFields.length).toBe(fields.length + 1)
+})
+
+it('renders correct field info', () => {
+  let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true })
+  let findField = name => {
+    let field = wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === name)
+    expect(field.length).toBe(1)
+    return field
+  }
+  expect(findField('description').prop('type')).toBe('string')
+  expect(findField('required_string_field').prop('required')).toBe(true)
+  expect(findField('string_field').prop('type')).toBe('string')
+  expect(findField('string_field_with_default').prop('value')).toBe('default')
+  expect(findField('enum_field').prop('enum')[0]).toBe('enum 1')
+  expect(findField('enum_field').prop('enum')[1]).toBe('enum 2')
+  expect(findField('boolean_field').prop('type')).toBe('boolean')
+  expect(findField('strict_boolean_field').prop('type')).toBe('strict-boolean')
+})
+
+it('renders data into field', () => {
+  let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true, data: { string_field: 'new data' } })
+  expect(wrapper.findWhere(w => w.name() === 'Styled(WizardOptionsField)' && w.prop('name') === 'string_field').prop('value')).toBe('new data')
+})

+ 71 - 0
src/components/organisms/WizardSummary/story.jsx

@@ -0,0 +1,71 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import WizardSummary from './WizardSummary'
+
+let data = {
+  options: {
+    description: 'A description',
+    field_name: 'Field name value',
+  },
+  selectedInstances: [
+    { flavor_name: 'flavor name', id: 'i-1', name: 'name', num_cpu: 2, memory_mb: 1024 },
+  ],
+  networks: [
+    {
+      sourceNic: { id: 's-1', network_name: 'n-1' },
+      targetNetwork: { name: 'target network' },
+    },
+  ],
+  source: {
+    type: 'openstack',
+    name: 'source name',
+  },
+  target: {
+    type: 'azure',
+    name: 'target name',
+  },
+  schedules: [
+    {
+      id: 's-1',
+      schedule: {
+        month: 2,
+        dom: 14,
+        dow: 3,
+        minute: 0,
+        hour: 17,
+      },
+    },
+  ],
+}
+
+storiesOf('WizardSummary', module)
+  .add('replica', () => (
+    <div style={{ width: '800px' }}>
+      <WizardSummary
+        wizardType="replica"
+        data={data}
+      />
+    </div>
+  ))
+  .add('migration', () => (
+    <div style={{ width: '800px' }}>
+      <WizardSummary
+        wizardType="migration"
+        data={data}
+      />
+    </div>
+  ))

+ 88 - 0
src/components/organisms/WizardSummary/test.jsx

@@ -0,0 +1,88 @@
+/*
+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/>.
+*/
+
+import React from 'react'
+import { shallow } from 'enzyme'
+import WizardSummary from './WizardSummary'
+
+const wrap = props => shallow(<WizardSummary {...props} />)
+
+let data = {
+  options: {
+    description: 'A description',
+    field_name: 'Field name value',
+  },
+  selectedInstances: [
+    { flavor_name: 'flavor_name', id: 'i-1', name: 'name', num_cpu: 2, memory_mb: 1024 },
+  ],
+  networks: [
+    {
+      sourceNic: { id: 's-1', network_name: 'n-1' },
+      targetNetwork: { name: 'target network' },
+    },
+  ],
+  source: {
+    type: 'openstack',
+    name: 'source name',
+  },
+  target: {
+    type: 'azure',
+    name: 'target name',
+  },
+  schedules: [
+    {
+      id: 's-1',
+      schedule: {
+        month: 2,
+        dom: 14,
+        dow: 3,
+        minute: 0,
+        hour: 17,
+      },
+    },
+  ],
+}
+
+it('renders overview section', () => {
+  let wrapper = wrap({ data, wizardType: 'replica' })
+  expect(wrapper.html().indexOf('source name') > -1).toBe(true)
+  expect(wrapper.find('StatusPill').at(0).prop('label')).toBe('OPENSTACK')
+  expect(wrapper.html().indexOf('target name') > -1).toBe(true)
+  expect(wrapper.find('StatusPill').at(1).prop('label')).toBe('AZURE')
+  expect(wrapper.find('StatusPill').at(2).prop('label')).toBe('REPLICA')
+})
+
+it('renders instances section', () => {
+  let wrapper = wrap({ data, wizardType: 'replica' })
+  expect(wrapper.html().indexOf('flavor_name') > -1).toBe(true)
+})
+
+it('renders networks section', () => {
+  let wrapper = wrap({ data, wizardType: 'replica' })
+  expect(wrapper.html().indexOf('target network') > -1).toBe(true)
+  expect(wrapper.html().indexOf('n-1') > -1).toBe(true)
+})
+
+it('renders options section', () => {
+  let wrapper = wrap({ data, wizardType: 'replica' })
+  expect(wrapper.html().indexOf('Description') > -1).toBe(true)
+  expect(wrapper.html().indexOf('A description') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Field Name') > -1).toBe(true)
+  expect(wrapper.html().indexOf('Field name value') > -1).toBe(true)
+})
+
+it('renders schedule section', () => {
+  let wrapper = wrap({ data, wizardType: 'replica' })
+  expect(wrapper.html().indexOf('Every February, every 14th, every Wednesday, at 17:00') > -1).toBe(true)
+})