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

Merge pull request #327 from smiclea/replica-update

Add ability to update a replica
Dorin Paslaru 7 лет назад
Родитель
Сommit
583fa4847f
54 измененных файлов с 1537 добавлено и 408 удалено
  1. 1 0
      package.json
  2. 5 3
      src/components/atoms/Button/Button.jsx
  3. 24 4
      src/components/atoms/DropdownButton/DropdownButton.jsx
  4. 3 0
      src/components/atoms/DropdownButton/story.jsx
  5. 1 1
      src/components/atoms/EndpointLogos/EndpointLogos.jsx
  6. 2 0
      src/components/atoms/ToggleButtonBar/ToggleButtonBar.jsx
  7. 208 0
      src/components/molecules/ActionDropdown/ActionDropdown.jsx
  8. 6 0
      src/components/molecules/ActionDropdown/package.json
  9. 40 0
      src/components/molecules/ActionDropdown/story.jsx
  10. 10 8
      src/components/molecules/DropdownLink/DropdownLink.jsx
  11. 2 2
      src/components/molecules/MainDetailsTable/MainDetailsTable.jsx
  12. 19 10
      src/components/molecules/Modal/Modal.jsx
  13. BIN
      src/components/molecules/Modal/images/header-background-wide.png
  14. BIN
      src/components/molecules/Modal/images/header-background.png
  15. 90 0
      src/components/molecules/Panel/Panel.jsx
  16. 6 0
      src/components/molecules/Panel/package.json
  17. 38 0
      src/components/molecules/Panel/story.jsx
  18. 2 2
      src/components/molecules/PropertiesTable/PropertiesTable.jsx
  19. 3 1
      src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx
  20. 9 6
      src/components/molecules/WizardBreadcrumbs/test.jsx
  21. 7 3
      src/components/molecules/WizardOptionsField/WizardOptionsField.jsx
  22. 7 28
      src/components/organisms/DetailsContentHeader/DetailsContentHeader.jsx
  23. 2 27
      src/components/organisms/DetailsContentHeader/test.jsx
  24. 423 0
      src/components/organisms/EditReplica/EditReplica.jsx
  25. 6 0
      src/components/organisms/EditReplica/package.json
  26. 10 12
      src/components/organisms/MainDetails/MainDetails.jsx
  27. 14 18
      src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.jsx
  28. 5 6
      src/components/organisms/WizardNetworks/WizardNetworks.jsx
  29. 50 27
      src/components/organisms/WizardOptions/WizardOptions.jsx
  30. 5 5
      src/components/organisms/WizardOptions/test.jsx
  31. 63 50
      src/components/organisms/WizardPageContent/WizardPageContent.jsx
  32. 3 4
      src/components/organisms/WizardStorage/WizardStorage.jsx
  33. 38 9
      src/components/organisms/WizardSummary/WizardSummary.jsx
  34. 1 1
      src/components/organisms/WizardSummary/test.jsx
  35. 4 5
      src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx
  36. 0 1
      src/components/pages/EndpointDetailsPage/EndpointDetailsPage.jsx
  37. 0 1
      src/components/pages/EndpointsPage/EndpointsPage.jsx
  38. 16 1
      src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx
  39. 94 19
      src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx
  40. 63 61
      src/components/pages/WizardPage/WizardPage.jsx
  41. 1 1
      src/components/styleUtils/Palette.js
  42. 16 4
      src/config.js
  43. 33 30
      src/plugins/endpoint/default/OptionsSchemaPlugin.js
  44. 11 1
      src/sources/ProviderSource.js
  45. 30 1
      src/sources/ReplicaSource.js
  46. 9 5
      src/sources/WizardSource.js
  47. 6 0
      src/stores/EndpointStore.js
  48. 96 14
      src/stores/ProviderStore.js
  49. 13 8
      src/stores/ReplicaStore.js
  50. 23 16
      src/stores/WizardStore.js
  51. 1 0
      src/types/Field.js
  52. 14 10
      src/types/MainItem.js
  53. 3 2
      src/types/WizardData.js
  54. 1 1
      src/utils/LabelDictionary.js

+ 1 - 0
package.json

@@ -8,6 +8,7 @@
     "env:prod": "cross-env NODE_ENV=production",
     "cypress": "cypress open",
     "test": "jest",
+    "testc": "jest --runInBand -t 'WizardOptions Component'",
     "storybook": "start-storybook -p 9001 -c private/storybook",
     "lint": "eslint src private webpack.config.js --ext js,jsx",
     "build:clean": "rimraf \"dist/!(.git*|Procfile)**\"",

+ 5 - 3
src/components/atoms/Button/Button.jsx

@@ -44,12 +44,14 @@ const hoverBackgroundColor = (props) => {
     return Palette.grayscale[7]
   }
 
+  if (props.secondary) {
+    return Palette.grayscale[8]
+  }
+
   if (props.hoverPrimary) {
     return Palette.primary
   }
-  if (props.secondary) {
-    return Palette.grayscale[3]
-  }
+
   if (props.alert) {
     return Palette.alert
   }

+ 24 - 4
src/components/atoms/DropdownButton/DropdownButton.jsx

@@ -27,7 +27,7 @@ const getLabelColor = props => {
     return Palette.grayscale[3]
   }
 
-  if (props.primary) {
+  if (props.primary || props.secondary) {
     return 'white'
   }
 
@@ -49,6 +49,10 @@ const getBackgroundColor = props => {
     return Palette.grayscale[0]
   }
 
+  if (props.secondary) {
+    return Palette.secondaryLight
+  }
+
   if (props.primary) {
     return Palette.primary
   }
@@ -60,7 +64,7 @@ const getArrowColor = props => {
     return Palette.grayscale[3]
   }
 
-  if (props.primary) {
+  if (props.primary || props.secondary) {
     return 'white'
   }
 
@@ -85,8 +89,21 @@ const borderColor = props => {
   if (props.primary) {
     return Palette.primary
   }
+  if (props.secondary) {
+    return Palette.secondaryLight
+  }
   return Palette.grayscale[3]
 }
+const backgroundHover = props => {
+  if (props.disabled || props.embedded) {
+    return ''
+  }
+  if (props.secondary) {
+    return Palette.secondaryLight
+  }
+  return Palette.primary
+}
+
 const Wrapper = styled.div`
   display: flex;
   align-items: center;
@@ -108,7 +125,7 @@ const Wrapper = styled.div`
   #dropdown-arrow-image {stroke: ${props => getArrowColor(props)};}
   &:hover {
     #dropdown-arrow-image {stroke: ${props => props.disabled ? '' : props.embedded ? '' : 'white'};}
-    background: ${props => props.disabled ? '' : props.embedded ? '' : Palette.primary};
+    background: ${props => backgroundHover(props)};
   }
 
   &:hover ${Label} {
@@ -130,11 +147,14 @@ type Props = {
   onClick?: (event: Event) => void,
   customRef?: (ref: HTMLElement) => void,
   innerRef?: (ref: HTMLElement) => void,
+  arrowRef?: (ref: HTMLElement) => void,
   className?: string,
   disabled?: boolean,
   'data-test-id'?: string,
   embedded?: boolean,
   highlight?: boolean,
+  secondary?: boolean,
+  centered?: boolean,
 }
 class DropdownButton extends React.Component<Props> {
   render() {
@@ -162,7 +182,7 @@ class DropdownButton extends React.Component<Props> {
         </Label>
         <Arrow
           {...this.props}
-          innerRef={() => { }}
+          innerRef={ref => { if (this.props.arrowRef) this.props.arrowRef(ref) }}
           onClick={() => { }}
           data-test-id=""
           disabled={this.props.disabled}

+ 3 - 0
src/components/atoms/DropdownButton/story.jsx

@@ -26,3 +26,6 @@ storiesOf('DropdownButton', module)
   .add('disabled', () => (
     <DropdownButton disabled value="Dropdown Button" onClick={action('clicked')} />
   ))
+  .add('secondary centered', () => (
+    <DropdownButton secondary centered value="Dropdown Button" onClick={action('clicked')} />
+  ))

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

@@ -152,7 +152,7 @@ const widthHeights = [
 ]
 
 type Props = {
-  endpoint?: string,
+  endpoint?: ?string,
   height: number,
   disabled?: boolean,
   'data-test-id'?: string,

+ 2 - 0
src/components/atoms/ToggleButtonBar/ToggleButtonBar.jsx

@@ -58,6 +58,7 @@ type Props = {
   onChange?: (item: ItemType) => void,
   className?: string,
   'data-test-id'?: string,
+  style?: { [string]: mixed },
 }
 @observer
 class ToggleButtonBar extends React.Component<Props> {
@@ -70,6 +71,7 @@ class ToggleButtonBar extends React.Component<Props> {
       <Wrapper
         data-test-id={this.props['data-test-id'] || 'toggleButtonBar-wrapper'}
         className={this.props.className}
+        style={this.props.style}
       >
         {this.props.items.map(item => {
           return (

+ 208 - 0
src/components/molecules/ActionDropdown/ActionDropdown.jsx

@@ -0,0 +1,208 @@
+
+/*
+Copyright (C) 2019  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/>.
+*/
+
+// @flow
+
+import React from 'react'
+import ReactDOM from 'react-dom'
+import { observer } from 'mobx-react'
+import styled, { css } from 'styled-components'
+import autobind from 'autobind-decorator'
+
+import DropdownButton from '../../atoms/DropdownButton/DropdownButton'
+import { List, ListItems, Tip } from '../DropdownLink/DropdownLink'
+
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  position: relative;
+`
+
+const ListItem = styled.div`
+  color: ${props => props.disabled ? Palette.grayscale[2] : props.color || Palette.black};
+  height: 32px;
+  padding: 0 16px;
+  cursor: ${props => props.disabled ? 'default' : 'pointer'};
+  display: flex;
+  align-items: center;
+  transition: all ${StyleProps.animations.swift};
+  &:hover {
+    ${props => props.disabled ? '' : css`background: ${Palette.grayscale[0]};`}
+  }
+  &:first-child {
+    border-top-left-radius: ${StyleProps.borderRadius};
+    border-top-right-radius: ${StyleProps.borderRadius};
+  }
+  &:last-child {
+    border-bottom-left-radius: ${StyleProps.borderRadius};
+    border-bottom-right-radius: ${StyleProps.borderRadius};
+  }
+`
+export type Action = {
+  label: string,
+  color?: string,
+  action: () => void,
+  disabled?: boolean,
+  hidden?: boolean,
+}
+const ListStyle = css`
+  box-shadow: 0 0 8px 0px rgba(111, 114, 118, 0.51);
+  border: none;
+`
+type Props = {
+  label: string,
+  actions: Action[],
+  style: any,
+  'data-test-id'?: string,
+}
+
+type State = {
+  showDropdownList: boolean,
+}
+
+@observer
+class ActionDropdown extends React.Component<Props, State> {
+  static defaultProps: $Shape<Props> = {
+    label: 'Actions',
+  }
+
+  state = {
+    showDropdownList: false,
+  }
+
+  itemMouseDown: boolean
+  listRef: HTMLElement
+  arrowRef: HTMLElement
+  tipRef: HTMLElement
+
+  componentDidMount() {
+    window.addEventListener('mousedown', this.handlePageClick, false)
+  }
+
+  componentDidUpdate() {
+    this.updateListPosition()
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('mousedown', this.handlePageClick, false)
+  }
+
+  updateListPosition() {
+    if (!this.state.showDropdownList || !this.listRef || !this.arrowRef || !this.tipRef) {
+      return
+    }
+
+    let listWidth = this.listRef.offsetWidth
+    let arrowWidth = this.arrowRef.offsetWidth
+    let arrowHeight = this.arrowRef.offsetHeight
+    let tipHeight = this.tipRef.offsetHeight
+    const tipLeftOffset = 6
+    const tipTopOffset = 6
+    let arrowOffset = this.arrowRef.getBoundingClientRect()
+
+    // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
+    let scrollOffset = 0
+    if (document.body && parseInt(document.body.style.top, 10) < 0) {
+      scrollOffset = -parseInt(document.body && document.body.style.top, 10)
+    }
+
+    this.listRef.style.top = `${arrowOffset.top + (window.pageYOffset || scrollOffset) + arrowHeight + tipHeight + tipTopOffset}px`
+    this.listRef.style.left = `${arrowOffset.left + tipLeftOffset + (arrowWidth - listWidth)}px`
+  }
+
+  @autobind
+  handlePageClick() {
+    if (!this.itemMouseDown) {
+      this.setState({ showDropdownList: false })
+    }
+  }
+
+  handleButtonClick() {
+    this.setState({ showDropdownList: !this.state.showDropdownList })
+  }
+
+  handleItemMouseHover(action: Action, index: number, isEnter: boolean) {
+    if (!this.tipRef || index !== 0 || action.disabled) {
+      return
+    }
+    this.tipRef.style.background = isEnter ? Palette.grayscale[0] : Palette.grayscale[1]
+  }
+
+  handleItemClick(action: Action) {
+    if (action.disabled) {
+      return
+    }
+    action.action()
+    this.setState({ showDropdownList: false })
+  }
+
+  renderListItems() {
+    return (
+      <ListItems>
+        {this.props.actions.filter(a => !a.hidden).map((action, i) => (
+          <ListItem
+            onMouseEnter={() => { this.handleItemMouseHover(action, i, true) }}
+            onMouseLeave={() => { this.handleItemMouseHover(action, i, false) }}
+            onMouseDown={() => { this.itemMouseDown = true }}
+            onMouseUp={() => { this.itemMouseDown = false }}
+            key={action.label}
+            onClick={() => { this.handleItemClick(action) }}
+            color={action.color}
+            disabled={action.disabled}
+          >
+            {action.label}
+          </ListItem>
+        ))}
+      </ListItems>
+    )
+  }
+
+  renderList() {
+    if (!this.state.showDropdownList) {
+      return null
+    }
+
+    let body: any = document.body
+    return ReactDOM.createPortal((
+      <List
+        innerRef={list => { this.listRef = list }}
+        width={`${StyleProps.inputSizes.regular.width - 4}px`}
+        padding={0}
+        customStyle={ListStyle}
+      >
+        <Tip innerRef={ref => { this.tipRef = ref }} borderColor={'rgba(111, 114, 118, 0.2)'} />
+        {this.renderListItems()}
+      </List>
+    ), body)
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style} data-test-id={this.props['data-test-id']}>
+        <DropdownButton
+          secondary
+          centered
+          value={this.props.label}
+          onClick={() => { this.handleButtonClick() }}
+          arrowRef={ref => { this.arrowRef = ref }}
+        />
+        {this.renderList()}
+      </Wrapper>
+    )
+  }
+}
+
+export default ActionDropdown

+ 6 - 0
src/components/molecules/ActionDropdown/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "ActionDropdown",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./ActionDropdown.jsx"
+}

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

@@ -0,0 +1,40 @@
+/*
+Copyright (C) 2019  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/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import ActionDropdown from '.'
+
+import Palette from '../../styleUtils/Palette'
+
+let actions = [{
+  label: 'Execute',
+  color: Palette.primary,
+  action: () => { console.log('execute clicked') },
+  disabled: true,
+}, {
+  label: 'Edit',
+  action: () => { console.log('Edit clicked') },
+}, {
+  label: 'Delete',
+  color: Palette.alert,
+  action: () => { console.log('Delete clicked') },
+}]
+
+storiesOf('ActionDropdown', module)
+  .add('default', () => (
+    <ActionDropdown actions={actions} />
+  ))

+ 10 - 8
src/components/molecules/DropdownLink/DropdownLink.jsx

@@ -38,10 +38,10 @@ const LinkButton = styled.div`
   align-items: center;
   cursor: ${props => props.disabled ? 'default' : 'pointer'};
 `
-const List = styled.div`
+export const List = styled.div`
   position: absolute;
   z-index: 1001;
-  padding: 8px;
+  padding: ${props => props.padding != null ? props.padding : 8}px;
   background: ${Palette.grayscale[1]};
   border-radius: 4px;
   border: 1px solid ${Palette.grayscale[0]};
@@ -49,26 +49,28 @@ const List = styled.div`
     min-width: 132px;
     max-width: 160px;
   `}
+  ${props => props.customStyle || ''}
 `
-const Tip = styled.div`
+export const Tip = styled.div`
   position: absolute;
   top: -6px;
   right: 8px;
   width: 10px;
   height: 10px;
   background: ${Palette.grayscale[1]};
-  border-top: 1px solid ${Palette.grayscale[0]};
-  border-left: 1px solid ${Palette.grayscale[0]};
+  border-top: 1px solid ${props => props.borderColor || Palette.grayscale[0]};
+  border-left: 1px solid ${props => props.borderColor || Palette.grayscale[0]};
   border-bottom: 1px solid transparent;
   border-right: 1px solid transparent;
   transform: rotate(45deg);
+  transition: all ${StyleProps.animations.swift};
 `
-const ListItems = styled.div`
+export const ListItems = styled.div`
   max-height: 400px;
   overflow: auto;
   ${props => props.searchable ? 'margin-top: 8px;' : ''}
 `
-const ListItem = styled.div`
+export const ListItem = styled.div`
   padding-top: 13px;
   color: ${props => props.selected ? Palette.primary : Palette.grayscale[4]};
   cursor: pointer;
@@ -79,7 +81,7 @@ const ListItem = styled.div`
     padding-top: 0;
   }
 `
-const ListItemLabel = styled.div`
+export const ListItemLabel = styled.div`
   word-break: break-all;
   word-break: break-word;
   ${props => props.highlighted ? `font-weight: ${StyleProps.fontWeights.medium};` : ''}

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

@@ -268,8 +268,8 @@ class MainDetailsTable extends React.Component<Props, State> {
 
   renderNetworks(instance: Instance) {
     let destinationNetworkMap = null
-    if (this.props.item && this.props.item.destination_environment.network_map) {
-      destinationNetworkMap = this.props.item.destination_environment.network_map
+    if (this.props.item && this.props.item.network_map) {
+      destinationNetworkMap = this.props.item.network_map
     }
     if (destinationNetworkMap == null) {
       return null

+ 19 - 10
src/components/molecules/Modal/Modal.jsx

@@ -20,17 +20,22 @@ import styled from 'styled-components'
 import Modal from 'react-modal'
 import autobind from 'autobind-decorator'
 
-import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 import KeyboardManager from '../../../utils/KeyboardManager'
 
+import headerBackground from './images/header-background.png'
+import headerBackgroundWide from './images/header-background-wide.png'
+
+let headerHeight = 48
+
 const Title = styled.div`
-  height: 48px;
+  height: ${headerHeight}px;
   font-size: 24px;
   font-weight: ${StyleProps.fontWeights.light};
-  background: ${Palette.grayscale[1]};
   text-align: center;
   line-height: 48px;
+  color: white;
+  background: url('${props => props.wide ? headerBackgroundWide : headerBackground}') center/contain no-repeat;
 `
 
 type Props = {
@@ -42,6 +47,8 @@ type Props = {
   topBottomMargin: number,
   title: string,
   componentRef?: (ref: any) => void,
+  onScrollableRef?: () => HTMLElement,
+  fixedHeight?: number,
 }
 @observer
 class NewModal extends React.Component<Props> {
@@ -124,12 +131,14 @@ class NewModal extends React.Component<Props> {
     if (!contentNode || !pageNode) {
       return
     }
-    let scrollableNode = this.scrollableRef || contentNode
+    let scrollableRef = this.props.onScrollableRef && this.props.onScrollableRef()
+    let scrollableNode = scrollableRef || this.scrollableRef || contentNode
     let scrollTop = scrollableNode.scrollTop
     contentNode.style.height = 'auto'
+    let contentDesiredHeight = this.props.fixedHeight ? this.props.fixedHeight + headerHeight : contentNode.offsetHeight
     let left = (pageNode.offsetWidth / 2) - (contentNode.offsetWidth / 2)
-    let top = (pageNode.offsetHeight / 2) - (contentNode.offsetHeight / 2)
-    let height = 'auto'
+    let top = (pageNode.offsetHeight / 2) - (contentDesiredHeight / 2)
+    let height = this.props.fixedHeight ? `${this.props.fixedHeight + headerHeight}px` : 'auto'
 
     if (top < this.props.topBottomMargin) {
       top = this.props.topBottomMargin
@@ -144,12 +153,12 @@ class NewModal extends React.Component<Props> {
     scrollableNode.scrollTop = scrollTop + scrollOffset
   }
 
-  renderTitle() {
+  renderTitle(contentWidth: string) {
     if (!this.props.title) {
       return null
     }
 
-    return <Title data-test-id="modal-title">{this.props.title}</Title>
+    return <Title data-test-id="modal-title" wide={contentWidth === '800px'}>{this.props.title}</Title>
   }
 
   render() {
@@ -161,7 +170,7 @@ class NewModal extends React.Component<Props> {
         left: 0,
         right: 0,
         bottom: 0,
-        backgroundColor: 'rgba(164, 170, 181, 0.69)',
+        backgroundColor: 'rgba(73, 76, 81, 0.69)',
       },
       content: {
         padding: 0,
@@ -200,7 +209,7 @@ class NewModal extends React.Component<Props> {
         onRequestClose={this.props.onRequestClose}
         onAfterOpen={() => { this.handleModalOpen() }}
       >
-        {this.renderTitle()}
+        {this.renderTitle(modalStyle.content.width)}
         {children}
       </Modal>
     )

BIN
src/components/molecules/Modal/images/header-background-wide.png


BIN
src/components/molecules/Modal/images/header-background.png


+ 90 - 0
src/components/molecules/Panel/Panel.jsx

@@ -0,0 +1,90 @@
+/*
+Copyright (C) 2019  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/>.
+*/
+
+// @flow
+
+import * as React from 'react'
+import styled, { css } from 'styled-components'
+import { observer } from 'mobx-react'
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  display: flex;
+  min-height: 0;
+  flex-grow: 1;
+`
+const Navigation = styled.div`
+  width: 224px;
+  background-image: linear-gradient(rgba(200, 204, 215, 0.54), rgba(164, 170, 181, 0.54));
+`
+const NavigationItemDiv = styled.div`
+  height: 47px;
+  border-bottom: 1px solid ${Palette.grayscale[2]};
+  color: black;
+  display: flex;
+  align-items: center;
+  padding: 0 24px;
+  font-size: 18px;
+  cursor: pointer;
+  ${props => props.selected ? css`
+    color: ${Palette.primary};
+    background: ${Palette.grayscale[2]};
+    cursor: default;
+  ` : ''}
+`
+const Content = styled.div`
+  width: 576px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+
+export type NavigationItem = {
+  label: string,
+  value: string,
+}
+
+type Props = {
+  navigationItems: NavigationItem[],
+  content: React.Node,
+  selectedValue: string,
+  onChange: (item: NavigationItem) => void,
+  style?: any,
+}
+
+@observer
+class Panel extends React.Component<Props> {
+  handleItemClick(item: NavigationItem) {
+    if (item.value !== this.props.selectedValue) {
+      this.props.onChange(item)
+    }
+  }
+
+  render() {
+    return (
+      <Wrapper style={this.props.style}>
+        <Navigation>{this.props.navigationItems.map(item => (
+          <NavigationItemDiv
+            key={item.value}
+            selected={this.props.selectedValue === item.value}
+            onClick={() => { this.handleItemClick(item) }}
+          >{item.label}</NavigationItemDiv>
+        ))}</Navigation>
+        <Content>{this.props.content}</Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default Panel

+ 6 - 0
src/components/molecules/Panel/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "Panel",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./Panel.jsx"
+}

+ 38 - 0
src/components/molecules/Panel/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/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import Panel from '.'
+
+const navigationItems = [
+  { value: 'dest_options', label: 'Destination Options' },
+  { value: 'network', label: 'Network Mapping' },
+  { value: 'storage', label: 'Storage Mapping' },
+]
+
+storiesOf('Panel', module)
+  .add('default', () => (
+    <div style={{ width: '800px', height: '560px' }}>
+      <Panel
+        content={<div>Content</div>}
+        navigationItems={navigationItems}
+        selectedValue="network"
+        onChange={item => console.log(item, 'clicked')}
+      />
+    </div>
+  ))
+

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

@@ -34,11 +34,11 @@ const Wrapper = styled.div`
   border-radius: ${StyleProps.borderRadius};
 `
 const Column = styled.div`
-  ${StyleProps.exactWidth('calc(50% - 16px)')}
+  ${StyleProps.exactWidth('calc(50% - 32px)')}
   height: 32px;
+  padding: 0 16px;
   display: flex;
   align-items: center;
-  padding-left: 16px;
   ${props => props.header ? css`
     color: ${Palette.grayscale[4]};
     background: ${Palette.grayscale[7]};

+ 3 - 1
src/components/molecules/WizardBreadcrumbs/WizardBreadcrumbs.jsx

@@ -45,13 +45,15 @@ type Props = {
   selected: { id: string },
   wizardType: 'migration' | 'replica',
   destinationProvider: ?string,
+  sourceProvider: ?string,
 }
 @observer
 class WizardBreadcrumbs extends React.Component<Props> {
   render() {
     let pages = wizardConfig.pages
       .filter(p => !p.excludeFrom || p.excludeFrom !== this.props.wizardType)
-      .filter(p => !p.filter || (this.props.destinationProvider && p.filter(this.props.destinationProvider)))
+      .filter(p => !p.targetFilter || (this.props.destinationProvider && p.targetFilter(this.props.destinationProvider)))
+      .filter(p => !p.sourceFilter || (this.props.sourceProvider && p.sourceFilter(this.props.sourceProvider)))
 
     return (
       <Wrapper>

+ 9 - 6
src/components/molecules/WizardBreadcrumbs/test.jsx

@@ -20,30 +20,33 @@ import WizardBreadcrumbs from '.'
 import TW from '../../../utils/TestWrapper'
 import { wizardConfig } from '../../../config'
 
-const wrap = props => new TW(shallow(<WizardBreadcrumbs destinationProvider="oci" {...props} />), 'wBreadCrumbs')
+const wrap = props => new TW(
+  shallow(<WizardBreadcrumbs destinationProvider="oci" sourceProvider="vmware_vsphere" {...props} />),
+  'wBreadCrumbs'
+)
 
 describe('WizardBreadcrumbs Component', () => {
   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.find('name-', true).length).toBe(pages.length - 1)
+    expect(wrapper.find('name-', true).length).toBe(pages.length - 2)
   })
 
   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.find('name-', true).length).toBe(pages.length - 1)
+    expect(wrapper.find('name-', true).length).toBe(pages.length - 2)
   })
 
   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.findText(`name-${pages[2].id}`)).toBe(pages[2].breadcrumb)
+    let wrapper = wrap({ selected: pages[1], wizardType: 'migration' })
+    expect(wrapper.findText(`name-${pages[1].id}`)).toBe(pages[1].breadcrumb)
   })
 
   it('renders correct number of crumbs for Openstack', () => {
     let wrapper = wrap({ selected: wizardConfig.pages[2], wizardType: 'migration', destinationProvider: 'openstack' })
     let pages = wizardConfig.pages.filter(p => !p.excludeFrom || p.excludeFrom !== 'migration')
-    expect(wrapper.find('name-', true).length).toBe(pages.length)
+    expect(wrapper.find('name-', true).length).toBe(pages.length - 1)
   })
 })

+ 7 - 3
src/components/molecules/WizardOptionsField/WizardOptionsField.jsx

@@ -46,10 +46,11 @@ const Wrapper = styled.div`
 `
 const Label = styled.div`
   font-weight: ${StyleProps.fontWeights.medium};
+  margin-bottom: 8px;
   ${props => getDirection(props) === 'column' ? 'margin-bottom: 8px;' : ''}
 `
 const LabelText = styled.span`
-  margin-right: 24px;
+  margin-right: ${props => props.noMargin ? 0 : 24}px;
 `
 const Asterisk = styled.div`
   ${StyleProps.exactSize('16px')}
@@ -71,6 +72,7 @@ type Props = {
   width?: number,
   skipNullValue?: boolean,
   'data-test-id'?: string,
+  style?: { [string]: mixed },
 }
 @observer
 class WizardOptionsField extends React.Component<Props> {
@@ -79,6 +81,7 @@ class WizardOptionsField extends React.Component<Props> {
       <Switch
         width="112px"
         justifyContent="flex-end"
+        height={16}
         triState={propss.triState}
         checked={this.props.value}
         onChange={checked => { this.props.onChange(checked) }}
@@ -92,7 +95,7 @@ class WizardOptionsField extends React.Component<Props> {
   renderTextInput() {
     return (
       <TextInput
-        width={`${StyleProps.inputSizes.wizard.width}px`}
+        width={`${this.props.width || StyleProps.inputSizes.wizard.width}px`}
         value={this.props.value}
         onChange={e => { this.props.onChange(e.target.value) }}
         placeholder={LabelDictionary.get(this.props.name)}
@@ -219,7 +222,7 @@ class WizardOptionsField extends React.Component<Props> {
     let description = LabelDictionary.getDescription(this.props.name)
     return (
       <Label>
-        <LabelText data-test-id="wOptionsField-label">
+        <LabelText data-test-id="wOptionsField-label" noMargin={!description && !this.props.required}>
           {LabelDictionary.get(this.props.name)}
         </LabelText>
         {description ? <InfoIcon text={description} marginLeft={-20} /> : null}
@@ -240,6 +243,7 @@ class WizardOptionsField extends React.Component<Props> {
         data-test-id={this.props['data-test-id'] || 'wOptionsField-wrapper'}
         type={this.props.type}
         className={this.props.className}
+        style={this.props.style}
       >
         {this.renderLabel()}
         {field}

+ 7 - 28
src/components/organisms/DetailsContentHeader/DetailsContentHeader.jsx

@@ -21,7 +21,8 @@ import styled from 'styled-components'
 import type { MainItem } from '../../../types/MainItem'
 import type { Execution } from '../../../types/Execution'
 import StatusPill from '../../atoms/StatusPill'
-import Button from '../../atoms/Button'
+import ActionDropdown from '../../molecules/ActionDropdown'
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
@@ -82,17 +83,12 @@ const MockButton = styled.div`
 
 type Props = {
   onBackButonClick: () => void,
-  onActionButtonClick?: () => void,
-  onCancelClick?: (?Execution | ?MainItem) => void,
+  dropdownActions?: DropdownAction[],
   typeImage?: string,
-  buttonLabel?: string,
   statusLabel?: string,
   item: ?any,
   alertInfoPill?: boolean,
   primaryInfoPill?: boolean,
-  alertButton?: boolean,
-  hollowButton?: boolean,
-  actionButtonDisabled?: boolean,
 }
 @observer
 class DetailsContentHeader extends React.Component<Props> {
@@ -142,33 +138,16 @@ class DetailsContentHeader extends React.Component<Props> {
   }
 
   renderButton() {
-    if (!this.props.onActionButtonClick && this.getStatus() !== 'RUNNING') {
+    if (!this.props.dropdownActions) {
       return <MockButton />
     }
 
-    if (this.getStatus() === 'RUNNING') {
-      return (
-        <Button
-          secondary
-          onClick={() => {
-            // $FlowIssue
-            if (this.props.onCancelClick) this.props.onCancelClick(this.getLastExecution())
-          }}
-          data-test-id="dcHeader-cancelButton"
-        >Cancel</Button>
-      )
-    }
-
     return (
-      <Button
-        secondary={!this.props.alertButton}
-        alert={this.props.alertButton}
-        hollow={this.props.hollowButton}
-        onClick={this.props.onActionButtonClick}
-        disabled={this.props.actionButtonDisabled}
+      <ActionDropdown
+        actions={this.props.dropdownActions}
         style={{ marginLeft: '32px' }}
         data-test-id="dcHeader-actionButton"
-      >{this.props.buttonLabel}</Button>
+      />
     )
   }
 

+ 2 - 27
src/components/organisms/DetailsContentHeader/test.jsx

@@ -45,17 +45,9 @@ describe('DetailsContentHeader Component', () => {
     expect(wrapper.find('cancelButton').length).toBe(0)
   })
 
-  it('renders with action button, if there\'s action button handler', () => {
-    let wrapper = wrap({ item, buttonLabel: 'action button', onActionButtonClick: () => { } })
+  it('renders with action button, if there are dropdown actions', () => {
+    let wrapper = wrap({ item, dropdownActions: [] })
     expect(wrapper.find('actionButton').length).toBe(1)
-    expect(wrapper.find('actionButton').shallow.dive().dive().text()).toBe('action button')
-  })
-
-  it('dispatches action button click', () => {
-    let onActionButtonClick = sinon.spy()
-    let wrapper = wrap({ item, buttonLabel: 'action button', onActionButtonClick })
-    wrapper.find('actionButton').simulate('click')
-    expect(onActionButtonClick.calledOnce).toBe(true)
   })
 
   it('dispatches back button click', () => {
@@ -65,23 +57,6 @@ describe('DetailsContentHeader Component', () => {
     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('cancelButton').length).toBe(1)
-  })
-
-  it('dispatches cancel click', () => {
-    let onCancelClick = sinon.spy()
-    let wrapper = wrap({
-      item: { ...item, executions: [{ ...item.executions[0], status: 'RUNNING' }] },
-      onCancelClick,
-    })
-    wrapper.find('cancelButton').simulate('click')
-    expect(onCancelClick.args[0][0].status).toBe('RUNNING')
-  })
-
   it('renders correct INFO pill', () => {
     let wrapper = wrap({ item, primaryInfoPill: true })
     expect(wrapper.find('infoPill').prop('primary')).toBe(true)

+ 423 - 0
src/components/organisms/EditReplica/EditReplica.jsx

@@ -0,0 +1,423 @@
+/*
+Copyright (C) 2019  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/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
+import replicaStore from '../../../stores/ReplicaStore'
+import endpointStore from '../../../stores/EndpointStore'
+
+import Button from '../../atoms/Button'
+import StatusImage from '../../atoms/StatusImage'
+import Modal from '../../molecules/Modal'
+import Panel from '../../molecules/Panel'
+import { isOptionsPageValid } from '../../organisms/WizardPageContent'
+import WizardNetworks from '../../organisms/WizardNetworks'
+import WizardOptions from '../../organisms/WizardOptions'
+import WizardStorage from '../WizardStorage/WizardStorage'
+
+import type { MainItem } from '../../../types/MainItem'
+import type { NavigationItem } from '../../molecules/Panel'
+import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
+import type { Field } from '../../../types/Field'
+import type { Instance, Nic, Disk } from '../../../types/Instance'
+import type { Network, NetworkMap } from '../../../types/Network'
+
+// import { storageProviders } from '../../../config'
+import StyleProps from '../../styleUtils/StyleProps'
+
+const PanelContent = styled.div`
+  padding: 32px;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+  flex-grow: 1;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin: 32px 0;
+`
+const LoadingText = styled.div`
+  font-size: 18px;
+  margin-top: 32px;
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  flex-shrink: 0;
+  justify-content: space-between;
+`
+
+type Props = {
+  isOpen: boolean,
+  onRequestClose: () => void,
+  replica: MainItem,
+  destinationEndpoint: Endpoint,
+  instancesDetails: Instance[],
+  instancesDetailsLoading: boolean,
+  networks: Network[],
+  networksLoading: boolean,
+}
+type State = {
+  selectedPanel: string,
+  destinationData: any,
+  updateDisabled: boolean,
+  selectedNetworks: NetworkMap[],
+  storageMap: StorageMap[],
+}
+
+@observer
+class EditReplica extends React.Component<Props, State> {
+  state = {
+    selectedPanel: 'dest_options',
+    destinationData: {},
+    updateDisabled: false,
+    selectedNetworks: [],
+    storageMap: [],
+  }
+
+  scrollableRef: HTMLElement
+
+  componentWillMount() {
+    if (this.hasStorageMap()) {
+      endpointStore.loadStorage(this.props.destinationEndpoint.id, {})
+    }
+
+    providerStore.loadDestinationSchema(this.props.destinationEndpoint.type, 'replica').then(() => {
+      return providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type, undefined, true)
+    }).then(() => {
+      this.loadEnvDestinationOptions()
+    })
+  }
+
+  hasStorageMap() {
+    return false
+    // return Boolean(storageProviders.find(p => p === this.props.destinationEndpoint.type))
+  }
+
+  isUpdateDisabled() {
+    let isLoadingDestOptions = this.state.selectedPanel === 'dest_options'
+      && (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading)
+    let isLoadingNetwork = this.state.selectedPanel === 'network_mapping' && this.props.instancesDetailsLoading
+    let isLoadingStorage = this.state.selectedPanel === 'storage_mapping'
+      && (this.props.instancesDetailsLoading || endpointStore.storageLoading)
+    return this.state.updateDisabled || isLoadingDestOptions || isLoadingNetwork || isLoadingStorage
+  }
+
+  parseReplicaData() {
+    let data = {}
+    let destEnv = this.props.replica.destination_environment
+    if (!destEnv) {
+      return data
+    }
+    Object.keys(destEnv).forEach(key => {
+      if (destEnv[key] && typeof destEnv[key] === 'object') {
+        Object.keys(destEnv[key]).forEach(subkey => {
+          let destParent: any = destEnv[key]
+          if (destParent[subkey]) {
+            data[`${key}/${subkey}`] = destParent[subkey]
+          }
+        })
+      } else {
+        data[key] = destEnv[key]
+      }
+    })
+    return data
+  }
+
+  loadEnvDestinationOptions(field?: Field) {
+    let envData = getFieldChangeDestOptions({
+      provider: this.props.destinationEndpoint.type,
+      destSchema: providerStore.destinationSchema,
+      data: {
+        ...this.parseReplicaData(),
+        ...this.state.destinationData,
+      },
+      field,
+    })
+
+    if (envData) {
+      providerStore.getDestinationOptions(this.props.destinationEndpoint.id, this.props.destinationEndpoint.type, envData, true)
+    }
+  }
+
+  validateDestinationOptions() {
+    let isValid = isOptionsPageValid({
+      ...this.parseReplicaData(),
+      ...this.state.destinationData,
+    }, providerStore.destinationSchema)
+
+    this.setState({ updateDisabled: !isValid })
+  }
+
+  handlePanelChange(panel: string) {
+    this.setState({ selectedPanel: panel })
+  }
+
+  handleDestinationFieldChange(field: Field, value: any) {
+    let destinationData = { ...this.state.destinationData }
+    if (field.type === 'array') {
+      let oldValues: string[] = destinationData[field.name] || []
+      if (oldValues.find(v => v === value)) {
+        destinationData[field.name] = oldValues.filter(v => v !== value)
+      } else {
+        destinationData[field.name] = [...oldValues, value]
+      }
+    } else {
+      destinationData[field.name] = value
+    }
+
+    this.setState({ destinationData }, () => {
+      if (field.type !== 'string' || field.enum) {
+        this.loadEnvDestinationOptions(field)
+      }
+
+      this.validateDestinationOptions()
+    })
+  }
+
+  handleUpdateClick() {
+    this.setState({ updateDisabled: true })
+
+    replicaStore.update(this.props.replica, this.props.destinationEndpoint, {
+      destination: this.state.destinationData,
+      network: this.state.selectedNetworks.length > 0 ? this.getSelectedNetworks() : [],
+      storage: this.state.destinationData.default_storage || this.state.storageMap.length > 0 ? this.getStorageMap() : [],
+    }).then(() => {
+      window.location.href = `/#/replica/executions/${this.props.replica.id}`
+      this.props.onRequestClose()
+    })
+  }
+
+  handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
+    let networkMap = this.state.selectedNetworks.filter(n => n.sourceNic.network_name !== sourceNic.network_name)
+    this.setState({
+      selectedNetworks: [...networkMap, { sourceNic, targetNetwork }],
+    })
+  }
+
+  handleStorageChange(source: Disk, target: StorageBackend, type: 'backend' | 'disk') {
+    let diskFieldName = type === 'backend' ? 'storage_backend_identifier' : 'id'
+    let storageMap = this.state.storageMap
+      .filter(n => n.type !== type || n.source[diskFieldName] !== source[diskFieldName])
+    storageMap.push({ source, target, type })
+
+    this.setState({ storageMap })
+  }
+
+  getFieldValue(fieldName: string, defaultValue: any) {
+    if (this.state.destinationData[fieldName] === undefined) {
+      let replicaData = this.parseReplicaData()
+      if (replicaData[fieldName] !== undefined) {
+        return replicaData[fieldName]
+      }
+      return defaultValue
+    }
+    return this.state.destinationData[fieldName]
+  }
+
+  getSelectedNetworks(): NetworkMap[] {
+    let selectedNetworks: NetworkMap[] = []
+    let networkMap = this.props.replica.network_map
+
+    if (networkMap) {
+      Object.keys(networkMap).forEach(sourceNetworkName => {
+        let network = this.props.networks.find(n => n.name === networkMap[sourceNetworkName] || n.id === networkMap[sourceNetworkName])
+        if (!network) {
+          return
+        }
+        selectedNetworks.push({
+          sourceNic: { id: '', network_name: sourceNetworkName, mac_address: '', network_id: '' },
+          targetNetwork: network,
+        })
+      })
+    }
+    selectedNetworks = selectedNetworks.map(mapping => {
+      let updatedMapping = this.state.selectedNetworks.find(m => m.sourceNic.network_name === mapping.sourceNic.network_name)
+      return updatedMapping || mapping
+    })
+    return selectedNetworks
+  }
+
+  getStorageMap(): StorageMap[] {
+    let storageMap: StorageMap[] = []
+    let currentStorage = this.props.replica.storage_mappings || {}
+    let buildStorageMap = (type: 'backend' | 'disk', mapping: any) => {
+      return {
+        type,
+        source: { storage_backend_identifier: mapping.source, id: mapping.disk_id },
+        target: { name: mapping.destination, id: mapping.destination },
+      }
+    }
+    let backendMappings = currentStorage.backend_mappings || []
+    backendMappings.forEach(mapping => {
+      storageMap.push(buildStorageMap('backend', mapping))
+    })
+
+    let diskMappings = currentStorage.disk_mappings || []
+    diskMappings.forEach(mapping => {
+      storageMap.push(buildStorageMap('disk', mapping))
+    })
+
+    this.state.storageMap.forEach(mapping => {
+      let fieldName = mapping.type === 'backend' ? 'storage_backend_identifier' : 'id'
+      let existingMapping = storageMap.find(m => m.type === mapping.type &&
+        // $FlowIgnore
+        m[fieldName] === mapping[fieldName]
+      )
+      if (existingMapping) {
+        existingMapping.target = mapping.target
+      } else {
+        storageMap.push(mapping)
+      }
+    })
+
+    return storageMap
+  }
+
+  renderDestinationOptions() {
+    if (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading) {
+      return this.renderLoading('Loading target options ...')
+    }
+
+    return (
+      <WizardOptions
+        wizardType="replica-dest-options-edit"
+        getFieldValue={(f, d) => this.getFieldValue(f, d)}
+        fields={providerStore.destinationSchema.filter(f => !f.readOnly)}
+        hasStorageMap={this.hasStorageMap()}
+        onChange={(f, v) => { this.handleDestinationFieldChange(f, v) }}
+        storageBackends={endpointStore.storageBackends}
+        useAdvancedOptions
+        columnStyle={{ marginRight: 0 }}
+        fieldWidth={StyleProps.inputSizes.large.width}
+        onScrollableRef={ref => { this.scrollableRef = ref }}
+      />
+    )
+  }
+
+  renderStorageMapping() {
+    if (this.props.instancesDetailsLoading) {
+      return this.renderLoading('Loading instances details ...')
+    }
+    if (endpointStore.storageLoading) {
+      return this.renderLoading('Loading storage ...')
+    }
+
+    return (
+      <WizardStorage
+        storageBackends={endpointStore.storageBackends}
+        instancesDetails={this.props.instancesDetails}
+        storageMap={this.getStorageMap()}
+        defaultStorage={this.getFieldValue('default_storage')}
+        onChange={(s, t, type) => { this.handleStorageChange(s, t, type) }}
+      />
+    )
+  }
+
+  renderNetworkMapping() {
+    return (
+      <WizardNetworks
+        instancesDetails={this.props.instancesDetails}
+        loadingInstancesDetails={this.props.instancesDetailsLoading}
+        networks={this.props.networks}
+        loading={this.props.networksLoading}
+        onChange={(nic, network) => { this.handleNetworkChange(nic, network) }}
+        selectedNetworks={this.getSelectedNetworks()}
+      />
+    )
+  }
+
+  renderContent() {
+    let content = null
+    switch (this.state.selectedPanel) {
+      case 'dest_options':
+        content = this.renderDestinationOptions()
+        break
+      case 'network_mapping':
+        content = this.renderNetworkMapping()
+        break
+      case 'storage_mapping':
+        content = this.renderStorageMapping()
+        break
+      default:
+        content = null
+    }
+    return (
+      <PanelContent>
+        {content}
+        <Buttons>
+          <Button
+            large
+            onClick={this.props.onRequestClose}
+            secondary
+          >Cancel</Button>
+          <Button
+            large
+            onClick={() => { this.handleUpdateClick() }}
+            disabled={this.isUpdateDisabled()}
+          >Update</Button>
+        </Buttons>
+      </PanelContent>
+    )
+  }
+
+  renderLoading(message: string) {
+    let loadingMessage = message || 'Loading ...'
+
+    return (
+      <LoadingWrapper>
+        <StatusImage loading />
+        <LoadingText>{loadingMessage}</LoadingText>
+      </LoadingWrapper>
+    )
+  }
+
+  render() {
+    const navigationItems: NavigationItem[] = [
+      { value: 'dest_options', label: 'Target Options' },
+      { value: 'network_mapping', label: 'Network Mapping' },
+    ]
+
+    if (this.hasStorageMap()) {
+      navigationItems.push({ value: 'storage_mapping', label: 'Storage Mapping' })
+    }
+
+    return (
+      <Modal
+        isOpen={this.props.isOpen}
+        title="Edit Replica"
+        onRequestClose={this.props.onRequestClose}
+        contentStyle={{ width: '800px' }}
+        onScrollableRef={() => this.scrollableRef}
+        fixedHeight={512}
+      >
+        <Panel
+          navigationItems={navigationItems}
+          content={this.renderContent()}
+          onChange={navItem => { this.handlePanelChange(navItem.value) }}
+          selectedValue={this.state.selectedPanel}
+        />
+      </Modal>
+    )
+  }
+}
+
+export default EditReplica

+ 6 - 0
src/components/organisms/EditReplica/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "EditReplica",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./EditReplica.jsx"
+}

+ 10 - 12
src/components/organisms/MainDetails/MainDetails.jsx

@@ -162,25 +162,26 @@ class MainDetails extends React.Component<Props> {
   }
 
   getNetworks() {
-    if (!this.props.item || !this.props.item.destination_environment || !this.props.item.destination_environment.network_map) {
+    let networkMap = this.props.item && this.props.item.network_map
+    if (!networkMap) {
       return null
     }
     let networks = []
-    Object.keys(this.props.item.destination_environment.network_map).forEach(key => {
+    Object.keys(networkMap).forEach(key => {
       let newItem
-      if (this.props.item && typeof this.props.item.destination_environment.network_map[key] === 'object') {
+      if (typeof networkMap[key] === 'string') {
         newItem = [
-          this.props.item.destination_environment.network_map[key].source_network,
+          key,
           this.getConnectedVms(key),
-          // $FlowIssue
-          this.props.item.destination_environment.network_map[key].destination_network,
+          networkMap[key],
           'Existing network',
         ]
       } else {
         newItem = [
-          key,
+          networkMap[key].source_network,
           this.getConnectedVms(key),
-          this.props.item ? this.props.item.destination_environment.network_map[key] : '-',
+          // $FlowIssue
+          networkMap[key].destination_network,
           'Existing network',
         ]
       }
@@ -262,10 +263,7 @@ class MainDetails extends React.Component<Props> {
 
     return (
       <PropertiesTable>
-        {properties.map(prop => {
-          if (prop == null) {
-            return null
-          }
+        {properties.filter(Boolean).filter(p => p.value != null && p.value !== '').map(prop => {
           return (
             <PropertyRow key={prop.label}>
               <PropertyName>{prop.label}</PropertyName>

+ 14 - 18
src/components/organisms/ReplicaDetailsContent/ReplicaDetailsContent.jsx

@@ -41,14 +41,13 @@ const Buttons = styled.div`
   display: flex;
   justify-content: space-between;
 `
-const LeftButtons = styled.div``
-const RightButtons = styled.div`
+const ButtonColumn = styled.div`
   display: flex;
+  flex-direction: column;
   button {
-    margin-right: 32px;
-
-    &:last-child {
-      margin-right: 0;
+    margin-top: 16px;
+    &:first-child {
+      margin-top: 0;
     }
   }
 `
@@ -85,7 +84,6 @@ type Props = {
   onExecuteClick: () => void,
   onCreateMigrationClick: () => void,
   onDeleteReplicaClick: () => void,
-  onDeleteReplicaDisksClick: () => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
   onScheduleChange: (scheduleId: ?string, data: ScheduleType, forceSave?: boolean) => void,
   onScheduleRemove: (scheduleId: ?string) => void,
@@ -124,29 +122,27 @@ class ReplicaDetailsContent extends React.Component<Props, State> {
   renderBottomControls() {
     return (
       <Buttons>
-        <LeftButtons>
+        <ButtonColumn>
+          <Button
+            secondary
+            disabled={this.getStatus() === 'RUNNING'}
+            onClick={this.props.onExecuteClick}
+          >Execute Replica</Button>
           <Button
             primary
             disabled={this.isEndpointMissing()}
             onClick={this.props.onCreateMigrationClick}
             data-test-id="rdContent-createButton"
           >Create Migration</Button>
-        </LeftButtons>
-        <RightButtons>
-          <Button
-            alert
-            hollow
-            secondary
-            onClick={this.props.onDeleteReplicaDisksClick}
-            disabled={!this.props.item || !this.props.item.executions || this.props.item.executions.length === 0}
-          >Delete Replica Disks</Button>
+        </ButtonColumn>
+        <ButtonColumn>
           <Button
             alert
             hollow
             onClick={this.props.onDeleteReplicaClick}
             data-test-id="rdContent-deleteButton"
           >Delete Replica</Button>
-        </RightButtons>
+        </ButtonColumn>
       </Buttons>
     )
   }

+ 5 - 6
src/components/organisms/WizardNetworks/WizardNetworks.jsx

@@ -61,8 +61,7 @@ const Nic = styled.div`
   }
 `
 const NetworkImage = styled.div`
-  width: 48px;
-  height: 48px;
+  ${StyleProps.exactSize('48px')}
   background: url('${networkImage}') center no-repeat;
   margin-right: 16px;
 `
@@ -78,8 +77,8 @@ const NetworkSubtitle = styled.div`
   margin-top: 1px;
 `
 const ArrowImage = styled.div`
-  width: 32px;
-  height: 16px;
+  min-width: 32px;
+  ${StyleProps.exactHeight('16px')}
   background: url('${arrowImage}') center no-repeat;
   flex-grow: 1;
   margin-right: 16px;
@@ -116,7 +115,7 @@ type Props = {
 @observer
 class WizardNetworks extends React.Component<Props> {
   isLoading() {
-    return this.props.loadingInstancesDetails || this.props.loading
+    return this.props.loadingInstancesDetails
   }
 
   renderLoading() {
@@ -188,7 +187,7 @@ class WizardNetworks extends React.Component<Props> {
                 large
                 centered
                 noSelectionMessage="Select ..."
-                noItemsMessage="No networks found"
+                noItemsMessage={this.props.loading ? 'Loading ...' : 'No networks found'}
                 selectedItem={selectedNetwork ? selectedNetwork.targetNetwork : null}
                 items={this.props.networks}
                 labelField="name"

+ 50 - 27
src/components/organisms/WizardOptions/WizardOptions.jsx

@@ -30,20 +30,30 @@ import type { StorageBackend } from '../../../types/Endpoint'
 
 import { executionOptions } from '../../../config'
 
-const Wrapper = styled.div``
-const Options = styled.div``
+const Wrapper = styled.div`
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+`
+const Options = styled.div`
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
 const Fields = styled.div`
-  margin-top: 46px;
   display: flex;
+  overflow: auto;
+  justify-content: space-between;
 `
 const OneColumn = styled.div``
 const Column = styled.div`
   ${props => props.left ? 'margin-right: 160px;' : ''}
+  margin-top: -16px;
 `
 const WizardOptionsFieldStyled = styled(WizardOptionsField)`
-  width: ${StyleProps.inputSizes.wizard.width}px;
+  width: ${props => props.width || StyleProps.inputSizes.wizard.width}px;
   justify-content: space-between;
-  margin-bottom: 39px;
+  margin-top: 16px;
 `
 const LoadingWrapper = styled.div`
   margin-top: 32px;
@@ -56,17 +66,27 @@ const LoadingText = styled.div`
   font-size: 18px;
 `
 
+export const shouldRenderField = (field: Field) => {
+  return (field.type !== 'array' || (field.enum && field.enum.length && field.enum.length > 0)) &&
+    (field.type !== 'integer' || (field.minimum && field.maximum)) &&
+    (field.type !== 'object' || field.properties)
+}
+
 type Props = {
   fields: Field[],
-  selectedInstances: ?Instance[],
-  data: ?{ [string]: mixed },
+  selectedInstances?: ?Instance[],
+  data?: ?{ [string]: mixed },
+  getFieldValue?: (fieldName: string, defaultValue: any) => any,
   onChange: (field: Field, value: any) => void,
-  useAdvancedOptions: boolean,
+  useAdvancedOptions?: boolean,
   hasStorageMap: boolean,
-  storageBackends: StorageBackend[],
-  onAdvancedOptionsToggle: (showAdvanced: boolean) => void,
+  storageBackends?: StorageBackend[],
+  onAdvancedOptionsToggle?: (showAdvanced: boolean) => void,
   wizardType: string,
-  loading: boolean,
+  loading?: boolean,
+  columnStyle?: { [string]: mixed },
+  fieldWidth?: number,
+  onScrollableRef?: (ref: HTMLElement) => void,
 }
 @observer
 class WizardOptions extends React.Component<Props> {
@@ -83,6 +103,10 @@ class WizardOptions extends React.Component<Props> {
   }
 
   getFieldValue(fieldName: string, defaultValue: any) {
+    if (this.props.getFieldValue) {
+      return this.props.getFieldValue(fieldName, defaultValue)
+    }
+
     if (!this.props.data || this.props.data[fieldName] === undefined) {
       return defaultValue
     }
@@ -91,9 +115,11 @@ class WizardOptions extends React.Component<Props> {
   }
 
   getDefaultFieldsSchema() {
-    let fieldsSchema = [
-      { name: 'description', type: 'string' },
-    ]
+    let fieldsSchema = []
+
+    if (this.props.wizardType === 'migration' || this.props.wizardType === 'replica') {
+      fieldsSchema.push({ name: 'description', type: 'string' })
+    }
 
     if (this.props.wizardType === 'migration') {
       fieldsSchema.unshift({ name: 'skip_os_morphing', type: 'strict-boolean', default: false })
@@ -118,7 +144,7 @@ class WizardOptions extends React.Component<Props> {
       }
     }
 
-    if (this.props.hasStorageMap && this.props.useAdvancedOptions && this.props.storageBackends.length > 0) {
+    if (this.props.hasStorageMap && this.props.useAdvancedOptions && this.props.storageBackends && this.props.storageBackends.length > 0) {
       fieldsSchema.push({ name: 'default_storage', type: 'string', enum: this.props.storageBackends.map(s => s.name) })
     }
 
@@ -130,12 +156,6 @@ class WizardOptions extends React.Component<Props> {
     this.setState({})
   }
 
-  shouldRenderField(field: Field) {
-    return (field.type !== 'array' || (field.enum && field.enum.length && field.enum.length > 0)) &&
-      (field.type !== 'integer' || (field.minimum && field.maximum)) &&
-      (field.type !== 'object' || field.properties)
-  }
-
   renderOptionsField(field: Field) {
     let additionalProps
     if (field.type === 'object' && field.properties) {
@@ -158,6 +178,7 @@ class WizardOptions extends React.Component<Props> {
         enum={field.enum}
         required={field.required}
         data-test-id={`wOptions-field-${field.name}`}
+        width={this.props.fieldWidth}
         {...additionalProps}
       />
     )
@@ -173,7 +194,7 @@ class WizardOptions extends React.Component<Props> {
     }
 
     let executeNowColumn
-    let fields = fieldsSchema.filter(f => this.shouldRenderField(f)).map((field, i) => {
+    let fields = fieldsSchema.filter(f => shouldRenderField(f)).map((field, i) => {
       let column = i % 2 === 0 ? 'left' : 'right'
       if (field.name === 'execute_now') {
         executeNowColumn = column
@@ -200,8 +221,8 @@ class WizardOptions extends React.Component<Props> {
     }
 
     return (
-      <Fields>
-        <Column left>
+      <Fields innerRef={this.props.onScrollableRef}>
+        <Column left style={this.props.columnStyle}>
           {fields.map(f => f.column === 'left' && f.component)}
         </Column>
         <Column right>
@@ -230,13 +251,15 @@ class WizardOptions extends React.Component<Props> {
       return null
     }
 
+    let onAdvancedOptionsToggle = this.props.onAdvancedOptionsToggle
     return (
       <Options>
-        <ToggleButtonBar
+        {onAdvancedOptionsToggle ? <ToggleButtonBar
+          style={{ marginBottom: '46px' }}
           items={[{ label: 'Simple', value: 'simple' }, { label: 'Advanced', value: 'advanced' }]}
           selectedValue={this.props.useAdvancedOptions ? 'advanced' : 'simple'}
-          onChange={item => { this.props.onAdvancedOptionsToggle(item.value === 'advanced') }}
-        />
+          onChange={item => { onAdvancedOptionsToggle(item.value === 'advanced') }}
+        /> : null}
         {this.renderOptionsFields()}
       </Options>
     )

+ 5 - 5
src/components/organisms/WizardOptions/test.jsx

@@ -57,8 +57,8 @@ let fields = [
 
 describe('WizardOptions Component', () => {
   it('has description and required field in simple tab', () => {
-    let wrapper = wrap({ fields, selectedInstances: [] })
-    expect(wrapper.find('field-', true).length).toBe(2)
+    let wrapper = wrap({ fields, selectedInstances: [], wizardType: 'migration' })
+    expect(wrapper.find('field-', true).length).toBe(3)
     expect(wrapper.find('field-description').length).toBe(1)
     expect(wrapper.find('field-required_string_field').length).toBe(1)
   })
@@ -80,12 +80,12 @@ describe('WizardOptions Component', () => {
   })
 
   it('renders correct number of fields in advanced tab', () => {
-    let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true })
-    expect(wrapper.find('field-', true).length).toBe(fields.length + 1)
+    let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true, wizardType: 'migration' })
+    expect(wrapper.find('field-', true).length).toBe(fields.length + 2)
   })
 
   it('renders correct field info', () => {
-    let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true })
+    let wrapper = wrap({ fields, selectedInstances: [], useAdvancedOptions: true, wizardType: 'migration' })
 
     expect(wrapper.find('field-description').prop('type')).toBe('string')
     expect(wrapper.find('field-required_string_field').prop('required')).toBe(true)

+ 63 - 50
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -92,6 +92,43 @@ const WizardTypeIcon = styled.div`
   align-items: center;
   margin: 0 32px;
 `
+export const isOptionsPageValid = (data: ?any, schema: Field[]) => {
+  const isValid = (field: Field): boolean => {
+    if (data) {
+      let fieldValue = data[field.name]
+      if (fieldValue === null) {
+        return false
+      }
+      if (fieldValue === undefined) {
+        return field.default != null
+      }
+      return Boolean(fieldValue)
+    }
+    return field.default != null
+  }
+
+  if (schema && schema.length > 0) {
+    let required = schema.filter(f => f.required && f.type !== 'object')
+    schema.forEach(f => {
+      if (f.type === 'object' && f.properties && f.properties.filter && f.properties.filter(p => isValid(p)).length > 0) {
+        required = required.concat(f.properties.filter(p => p.required))
+      }
+    })
+
+    let validFieldsCount = 0
+    required.forEach(f => {
+      if (isValid(f)) {
+        validFieldsCount += 1
+      }
+    })
+
+    if (validFieldsCount === required.length) {
+      return true
+    }
+  }
+
+  return false
+}
 type Props = {
   page: { id: string, title: string },
   type: 'replica' | 'migration',
@@ -114,7 +151,8 @@ type Props = {
   onInstancesReloadClick: () => void,
   onInstanceClick: (instance: Instance) => void,
   onInstancePageClick: (page: number) => void,
-  onOptionsChange: (field: Field, value: any) => void,
+  onDestOptionsChange: (field: Field, value: any) => void,
+  onSourceOptionsChange: (field: Field, value: any) => void,
   onNetworkChange: (nic: Nic, network: Network) => void,
   onStorageChange: (sourceStorage: Disk, targetStorage: StorageBackend, type: 'backend' | 'disk') => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
@@ -195,45 +233,6 @@ class WizardPageContent extends React.Component<Props, State> {
     return false
   }
 
-  isOptionsPageValid() {
-    const isValid = (field: Field): boolean => {
-      if (this.props.wizardData.options) {
-        let fieldValue = this.props.wizardData.options[field.name]
-        if (fieldValue === null) {
-          return false
-        }
-        if (fieldValue === undefined) {
-          return field.default != null
-        }
-        return Boolean(fieldValue)
-      }
-      return field.default != null
-    }
-
-    let schema = this.props.providerStore.optionsSchema
-    if (schema && schema.length > 0) {
-      let required = schema.filter(f => f.required && f.type !== 'object')
-      schema.forEach(f => {
-        if (f.type === 'object' && f.properties && f.properties.filter && f.properties.filter(p => isValid(p)).length > 0) {
-          required = required.concat(f.properties.filter(p => p.required))
-        }
-      })
-
-      let validFieldsCount = 0
-      required.forEach(f => {
-        if (isValid(f)) {
-          validFieldsCount += 1
-        }
-      })
-
-      if (validFieldsCount === required.length) {
-        return true
-      }
-    }
-
-    return false
-  }
-
   isNextButtonDisabled() {
     if (this.props.nextButtonDisabled) {
       return true
@@ -246,8 +245,8 @@ class WizardPageContent extends React.Component<Props, State> {
         return !this.props.wizardData.target
       case 'vms':
         return !this.props.wizardData.selectedInstances || !this.props.wizardData.selectedInstances.length
-      case 'options':
-        return !this.isOptionsPageValid()
+      case 'dest-options':
+        return !isOptionsPageValid(this.props.wizardData.destOptions, this.props.providerStore.destinationSchema)
       case 'networks':
         return !this.isNetworksPageValid()
       default:
@@ -331,14 +330,27 @@ class WizardPageContent extends React.Component<Props, State> {
           />
         )
         break
-      case 'options':
+      case 'source-options':
+        body = (
+          <WizardOptions
+            loading={this.props.providerStore.sourceSchemaLoading}
+            fields={this.props.providerStore.sourceSchema}
+            onChange={this.props.onSourceOptionsChange}
+            data={this.props.wizardData.sourceOptions}
+            useAdvancedOptions
+            hasStorageMap={false}
+            wizardType={`${this.props.type}-source-options`}
+          />
+        )
+        break
+      case 'dest-options':
         body = (
           <WizardOptions
-            loading={this.props.providerStore.optionsSchemaLoading || this.props.providerStore.destinationOptionsLoading}
+            loading={this.props.providerStore.destinationSchemaLoading || this.props.providerStore.destinationOptionsLoading}
             selectedInstances={this.props.wizardData.selectedInstances}
-            fields={this.props.providerStore.optionsSchema}
-            onChange={this.props.onOptionsChange}
-            data={this.props.wizardData.options}
+            fields={this.props.providerStore.destinationSchema}
+            onChange={this.props.onDestOptionsChange}
+            data={this.props.wizardData.destOptions}
             useAdvancedOptions={this.state.useAdvancedOptions}
             hasStorageMap={this.props.hasStorageMap}
             storageBackends={this.props.endpointStore.storageBackends}
@@ -365,7 +377,7 @@ class WizardPageContent extends React.Component<Props, State> {
             storageBackends={this.props.endpointStore.storageBackends}
             instancesDetails={this.props.instanceStore.instancesDetails}
             storageMap={this.props.storageMap}
-            defaultStorage={String(this.props.wizardData.options ? this.props.wizardData.options.default_storage : '')}
+            defaultStorage={String(this.props.wizardData.destOptions ? this.props.wizardData.destOptions.default_storage : '')}
             onChange={this.props.onStorageChange}
           />
         )
@@ -393,8 +405,8 @@ class WizardPageContent extends React.Component<Props, State> {
             instancesDetails={this.props.instanceStore.instancesDetails}
             defaultStorage={
               this.props.endpointStore.storageBackends.find(
-                s => this.props.wizardData.options ?
-                  s.name === this.props.wizardData.options.default_storage :
+                s => this.props.wizardData.destOptions ?
+                  s.name === this.props.wizardData.destOptions.default_storage :
                   false
               )
             }
@@ -449,6 +461,7 @@ class WizardPageContent extends React.Component<Props, State> {
             selected={this.props.page}
             wizardType={this.props.type}
             destinationProvider={this.props.wizardData.target ? this.props.wizardData.target.type : null}
+            sourceProvider={this.props.wizardData.source ? this.props.wizardData.source.type : null}
           />
         </Footer>
       </Wrapper>

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

@@ -75,8 +75,7 @@ const StorageItem = styled.div`
   }
 `
 const StorageImage = styled.div`
-  width: 48px;
-  height: 48px;
+  ${StyleProps.exactSize('48px')}
   background: url('${props => props.backend ? backendImage : diskImage}') center no-repeat;
   margin-right: 16px;
 `
@@ -92,8 +91,8 @@ const StorageSubtitle = styled.div`
   margin-top: 1px;
 `
 const ArrowImage = styled.div`
-  width: 32px;
-  height: 16px;
+  min-width: 32px;
+  ${StyleProps.exactHeight('16px')}
   background: url('${arrowImage}') center no-repeat;
   flex-grow: 1;
   margin-right: 16px;

+ 38 - 9
src/components/organisms/WizardSummary/WizardSummary.jsx

@@ -149,7 +149,7 @@ type Props = {
 @observer
 class WizardSummary extends React.Component<Props> {
   getDefaultOption(fieldName: string) {
-    if (this.props.data.options && this.props.data.options[fieldName] === false) {
+    if (this.props.data.destOptions && this.props.data.destOptions[fieldName] === false) {
       return false
     }
 
@@ -237,7 +237,36 @@ class WizardSummary extends React.Component<Props> {
     return value
   }
 
-  renderOptionsSection() {
+  renderSourceOptionsSection() {
+    let data = this.props.data
+    let type = this.props.wizardType.charAt(0).toUpperCase() + this.props.wizardType.substr(1)
+
+    return (
+      <Section>
+        <SectionTitle>{type} Source Options</SectionTitle>
+        <OptionsList>
+          {data.sourceOptions ? Object.keys(data.sourceOptions).map(optionName => {
+            if (!data.sourceOptions || data.sourceOptions[optionName] == null) {
+              return null
+            }
+
+            return (
+              <Option key={optionName}>
+                <OptionLabel>
+                  {optionName.split('/').map(n => LabelDictionary.get(n)).join(' - ')}
+                </OptionLabel>
+                <OptionValue>{
+                  this.renderOptionValue(data.sourceOptions && data.sourceOptions[optionName])
+                }</OptionValue>
+              </Option>
+            )
+          }) : null}
+        </OptionsList>
+      </Section>
+    )
+  }
+
+  renderTargetOptionsSection() {
     let data = this.props.data
     let type = this.props.wizardType.charAt(0).toUpperCase() + this.props.wizardType.substr(1)
 
@@ -257,16 +286,16 @@ class WizardSummary extends React.Component<Props> {
 
     return (
       <Section>
-        <SectionTitle>{type} Options</SectionTitle>
+        <SectionTitle>{type} Target Options</SectionTitle>
         <OptionsList>
           {this.props.wizardType === 'replica' ? executeNowOption : null}
           {this.props.data.selectedInstances && this.props.data.selectedInstances.length > 1 ? separateVmOption : null}
-          {data.options ? Object.keys(data.options).map(optionName => {
+          {data.destOptions ? Object.keys(data.destOptions).map(optionName => {
             if (
               optionName === 'execute_now' ||
               optionName === 'separate_vm' ||
-              optionName === 'default_stoage' ||
-              !data.options || data.options[optionName] == null
+              optionName === 'default_storage' ||
+              !data.destOptions || data.destOptions[optionName] == null
             ) {
               return null
             }
@@ -277,8 +306,7 @@ class WizardSummary extends React.Component<Props> {
                   {optionName.split('/').map(n => LabelDictionary.get(n)).join(' - ')}
                 </OptionLabel>
                 <OptionValue data-test-id={`wSummary-optionValue-${optionName}`}>{
-                  // $FlowIssue
-                  this.renderOptionValue(data.options[optionName])
+                  this.renderOptionValue(data.destOptions && data.destOptions[optionName])
                 }</OptionValue>
               </Option>
             )
@@ -432,7 +460,8 @@ class WizardSummary extends React.Component<Props> {
           {this.renderNetworksSection()}
         </Column>
         <Column>
-          {this.renderOptionsSection()}
+          {this.renderSourceOptionsSection()}
+          {this.renderTargetOptionsSection()}
           {this.renderStorageSection('backend')}
           {this.renderStorageSection('disk')}
           {this.renderScheduleSection()}

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

@@ -38,7 +38,7 @@ let schedules = [
 ]
 
 let data = {
-  options: {
+  destOptions: {
     description: 'A description',
     field_name: 'Field name value',
   },

+ 4 - 5
src/components/pages/AssessmentDetailsPage/AssessmentDetailsPage.jsx

@@ -249,11 +249,11 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
 
   handleMigrateClick() {
     let endpointType = this.getLocalData().endpoint.type
-    providerStore.loadOptionsSchema(endpointType, 'replica').then(() => {
-      this.setState({ replicaSchema: providerStore.optionsSchema })
-      return providerStore.loadOptionsSchema(endpointType, 'migration')
+    providerStore.loadDestinationSchema(endpointType, 'replica').then(() => {
+      this.setState({ replicaSchema: providerStore.destinationSchema })
+      return providerStore.loadDestinationSchema(endpointType, 'migration')
     }).then(() => {
-      this.setState({ migrationSchema: providerStore.optionsSchema })
+      this.setState({ migrationSchema: providerStore.destinationSchema })
     })
     this.setState({ showMigrationOptions: true })
   }
@@ -450,7 +450,6 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
           />}
           contentHeaderComponent={<DetailsContentHeader
             item={
-              // $FlowIgnore
               {
                 ...details,
                 type: 'Azure Migrate',

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

@@ -191,7 +191,6 @@ class EndpointDetailsPage extends React.Component<Props, State> {
           contentHeaderComponent={<DetailsContentHeader
             item={(endpoint: any)}
             onBackButonClick={() => { this.handleBackButtonClick() }}
-            onCancelClick={() => { }}
             typeImage={endpointImage}
             description={endpoint ? endpoint.description : ''}
           />}

+ 0 - 1
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -303,7 +303,6 @@ class EndpointsPage extends React.Component<{}, State> {
               }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               renderItemComponent={options =>
-                // $FlowIssue
                 (<EndpointListItem
                   {...options}
                   getUsage={endpoint => this.getEndpointUsage(endpoint)}

+ 16 - 1
src/components/pages/MigrationDetailsPage/MigrationDetailsPage.jsx

@@ -32,6 +32,7 @@ import instanceStore from '../../../stores/InstanceStore'
 import { requestPollTimeout } from '../../../config'
 
 import migrationImage from './images/migration.svg'
+import Palette from '../../styleUtils/Palette'
 
 const Wrapper = styled.div``
 
@@ -141,7 +142,21 @@ class MigrationDetailsPage extends React.Component<Props, State> {
     migrationStore.getMigration(this.props.match.params.id, false)
   }
 
+  getStatus() {
+    return migrationStore.migrationDetails && migrationStore.migrationDetails.status
+  }
+
   render() {
+    let dropdownActions = [{
+      label: 'Cancel',
+      disabled: this.getStatus() !== 'RUNNING',
+      action: () => { this.handleCancelMigrationClick() },
+    }, {
+      label: 'Delete Migration',
+      color: Palette.alert,
+      action: () => { this.handleDeleteMigrationClick() },
+    }]
+
     return (
       <Wrapper>
         <DetailsTemplate
@@ -153,8 +168,8 @@ class MigrationDetailsPage extends React.Component<Props, State> {
             item={migrationStore.migrationDetails}
             onBackButonClick={() => { this.handleBackButtonClick() }}
             typeImage={migrationImage}
+            dropdownActions={dropdownActions}
             primaryInfoPill
-            onCancelClick={() => { this.handleCancelMigrationClick() }}
           />}
           contentComponent={<MigrationDetailsContent
             item={migrationStore.migrationDetails}

+ 94 - 19
src/components/pages/ReplicaDetailsPage/ReplicaDetailsPage.jsx

@@ -25,6 +25,8 @@ import ReplicaDetailsContent from '../../organisms/ReplicaDetailsContent'
 import Modal from '../../molecules/Modal'
 import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
 import AlertModal from '../../organisms/AlertModal'
+import EditReplica from '../../organisms/EditReplica'
+
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import type { MainItem } from '../../../types/MainItem'
 import type { Execution } from '../../../types/Execution'
@@ -41,6 +43,7 @@ import networkStore from '../../../stores/NetworkStore'
 import { requestPollTimeout } from '../../../config'
 
 import replicaImage from './images/replica.svg'
+import Palette from '../../styleUtils/Palette'
 
 const Wrapper = styled.div``
 
@@ -50,6 +53,7 @@ type Props = {
 type State = {
   showOptionsModal: boolean,
   showMigrationModal: boolean,
+  showEditModal: boolean,
   showDeleteExecutionConfirmation: boolean,
   showDeleteReplicaConfirmation: boolean,
   showDeleteReplicaDisksConfirmation: boolean,
@@ -61,6 +65,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   state = {
     showOptionsModal: false,
     showMigrationModal: false,
+    showEditModal: false,
     showDeleteExecutionConfirmation: false,
     showDeleteReplicaConfirmation: false,
     showDeleteReplicaDisksConfirmation: false,
@@ -99,7 +104,6 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
         return
       }
       networkStore.loadNetworks(details.destination_endpoint_id, details.destination_environment, {
-        useLocalStorage: true,
         quietError: true,
       })
       instanceStore.loadInstancesDetails(
@@ -111,14 +115,24 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     })
   }
 
-  isActionButtonDisabled() {
+  getLastExecution() {
+    if (replicaStore.replicaDetails && replicaStore.replicaDetails.executions && replicaStore.replicaDetails.executions.length) {
+      return replicaStore.replicaDetails.executions[replicaStore.replicaDetails.executions.length - 1]
+    }
+
+    return null
+  }
+
+  getStatus() {
+    let lastExecution = this.getLastExecution()
+    return lastExecution && lastExecution.status
+  }
+
+  isExecuteDisabled() {
     let originEndpoint = endpointStore.endpoints.find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.origin_endpoint_id)
     let targetEndpoint = endpointStore.endpoints.find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.destination_endpoint_id)
-    let lastExecution = replicaStore.replicaDetails && replicaStore.replicaDetails.executions && replicaStore.replicaDetails.executions.length
-      && replicaStore.replicaDetails.executions[replicaStore.replicaDetails.executions.length - 1]
-    let status = lastExecution && lastExecution.status
 
-    return Boolean(!originEndpoint || !targetEndpoint || status === 'RUNNING')
+    return Boolean(!originEndpoint || !targetEndpoint || this.getStatus() === 'RUNNING')
   }
 
   handleUserItemClick(item: { value: string }) {
@@ -137,7 +151,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     window.location.href = '/#/replicas'
   }
 
-  handleActionButtonClick() {
+  handleExecuteClick() {
     this.setState({ showOptionsModal: true })
   }
 
@@ -203,6 +217,10 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     this.setState({ showMigrationModal: true })
   }
 
+  handleReplicaEditClick() {
+    this.setState({ showEditModal: true })
+  }
+
   handleAddScheduleClick(schedule: Schedule) {
     scheduleStore.addSchedule(this.props.match.params.id, schedule)
   }
@@ -228,7 +246,11 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
     }
   }
 
-  handleCancelExecutionClick(confirmationItem: ?Execution) {
+  handleCancelLastExecutionClick() {
+    this.handleCancelExecution(this.getLastExecution())
+  }
+
+  handleCancelExecution(confirmationItem: ?Execution) {
     this.setState({ confirmationItem, showCancelConfirmation: true })
   }
 
@@ -256,12 +278,72 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
   }
 
   pollData(showLoading: boolean) {
+    if (this.state.showEditModal) {
+      return
+    }
+
+    if (!this.props.match.params.page) {
+      replicaStore.getReplica(this.props.match.params.id, showLoading)
+    }
+
     replicaStore.getReplicaExecutions(this.props.match.params.id, showLoading).then(() => {
       this.pollTimeout = setTimeout(() => { this.pollData(false) }, requestPollTimeout)
     })
   }
 
+  closeEditModal() {
+    this.setState({ showEditModal: false }, () => {
+      this.pollData(false)
+    })
+  }
+
+  renderEditReplica() {
+    let destinationEndpoint = endpointStore.endpoints
+      .find(e => replicaStore.replicaDetails && e.id === replicaStore.replicaDetails.destination_endpoint_id)
+
+    if (!this.state.showEditModal || !replicaStore.replicaDetails || !destinationEndpoint) {
+      return null
+    }
+
+    return (
+      <EditReplica
+        isOpen
+        onRequestClose={() => { this.closeEditModal() }}
+        replica={replicaStore.replicaDetails}
+        destinationEndpoint={destinationEndpoint}
+        instancesDetails={instanceStore.instancesDetails}
+        instancesDetailsLoading={instanceStore.loadingInstancesDetails}
+        networks={networkStore.networks}
+        networksLoading={networkStore.loading}
+      />
+    )
+  }
+
   render() {
+    let dropdownActions = [{
+      label: 'Execute',
+      action: () => { this.handleExecuteClick() },
+      hidden: this.isExecuteDisabled(),
+    }, {
+      label: 'Cancel',
+      hidden: this.getStatus() !== 'RUNNING',
+      action: () => { this.handleCancelLastExecutionClick() },
+    }, {
+      label: 'Create Migration',
+      color: Palette.primary,
+      action: () => { this.handleCreateMigrationClick() },
+    }, {
+      label: 'Edit',
+      action: () => { this.handleReplicaEditClick() },
+    }, {
+      label: 'Delete Disks',
+      action: () => { this.handleDeleteReplicaDisksClick() },
+    }, {
+      label: 'Delete Replica',
+      color: Palette.alert,
+      action: () => { this.handleDeleteReplicaClick() },
+    }]
+
     return (
       <Wrapper>
         <DetailsTemplate
@@ -272,16 +354,9 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
           contentHeaderComponent={<DetailsContentHeader
             item={replicaStore.replicaDetails}
             onBackButonClick={() => { this.handleBackButtonClick() }}
-            onActionButtonClick={() => { this.handleActionButtonClick() }}
-            onCancelClick={item => {
-              let any: any = item
-              let execution: Execution = any
-              this.handleCancelExecutionClick(execution)
-            }}
-            actionButtonDisabled={this.isActionButtonDisabled()}
+            dropdownActions={dropdownActions}
             typeImage={replicaImage}
             alertInfoPill
-            buttonLabel="Execute Now"
           />}
           contentComponent={<ReplicaDetailsContent
             item={replicaStore.replicaDetails}
@@ -293,12 +368,11 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
             detailsLoading={replicaStore.detailsLoading || endpointStore.loading}
             executionsLoading={replicaStore.executionsLoading}
             page={this.props.match.params.page || ''}
-            onCancelExecutionClick={execution => { this.handleCancelExecutionClick(execution) }}
+            onCancelExecutionClick={execution => { this.handleCancelExecution(execution) }}
             onDeleteExecutionClick={execution => { this.handleDeleteExecutionClick(execution) }}
-            onExecuteClick={() => { this.handleActionButtonClick() }}
+            onExecuteClick={() => { this.handleExecuteClick() }}
             onCreateMigrationClick={() => { this.handleCreateMigrationClick() }}
             onDeleteReplicaClick={() => { this.handleDeleteReplicaClick() }}
-            onDeleteReplicaDisksClick={() => { this.handleDeleteReplicaDisksClick() }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}
             onScheduleChange={(scheduleId, data, forceSave) => { this.handleScheduleChange(scheduleId, data, forceSave) }}
             onScheduleRemove={scheduleId => { this.handleScheduleRemove(scheduleId) }}
@@ -357,6 +431,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
           onConfirmation={() => { this.handleCancelConfirmation() }}
           onRequestClose={() => { this.handleCloseCancelConfirmation() }}
         />
+        {this.renderEditReplica()}
       </Wrapper>
     )
   }

+ 63 - 61
src/components/pages/WizardPage/WizardPage.jsx

@@ -26,7 +26,7 @@ import Modal from '../../molecules/Modal'
 import Endpoint from '../../organisms/Endpoint'
 
 import userStore from '../../../stores/UserStore'
-import providerStore from '../../../stores/ProviderStore'
+import providerStore, { getFieldChangeDestOptions } from '../../../stores/ProviderStore'
 import endpointStore from '../../../stores/EndpointStore'
 import wizardStore from '../../../stores/WizardStore'
 import instanceStore from '../../../stores/InstanceStore'
@@ -35,7 +35,8 @@ import notificationStore from '../../../stores/NotificationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import KeyboardManager from '../../../utils/KeyboardManager'
-import { wizardConfig, executionOptions, providersWithExtraOptions } from '../../../config'
+import { wizardConfig, executionOptions } from '../../../config'
+
 import type { MainItem } from '../../../types/MainItem'
 import type { Endpoint as EndpointType, StorageBackend } from '../../../types/Endpoint'
 import type { Instance, Nic, Disk } from '../../../types/Instance'
@@ -79,7 +80,8 @@ class WizardPage extends React.Component<Props, State> {
   get pages() {
     return wizardConfig.pages
       .filter(p => !p.excludeFrom || p.excludeFrom !== this.state.type)
-      .filter(p => !p.filter || (wizardStore.data.target && p.filter(wizardStore.data.target.type)))
+      .filter(p => !p.targetFilter || (wizardStore.data.target && p.targetFilter(wizardStore.data.target.type)))
+      .filter(p => !p.sourceFilter || (wizardStore.data.source && p.sourceFilter(wizardStore.data.source.type)))
   }
 
   componentWillMount() {
@@ -164,6 +166,16 @@ class WizardPage extends React.Component<Props, State> {
   }
 
   handleTypeChange(isReplica: ?boolean) {
+    wizardStore.updateData({
+      target: null,
+      networks: null,
+      destOptions: null,
+      sourceOptions: null,
+      selectedInstances: null,
+      source: null,
+    })
+    wizardStore.clearStorageMap()
+    wizardStore.setPermalink(wizardStore.data)
     this.setState({ type: isReplica ? 'replica' : 'migration' })
   }
 
@@ -194,29 +206,33 @@ class WizardPage extends React.Component<Props, State> {
   }
 
   handleSourceEndpointChange(source: ?EndpointType) {
-    wizardStore.updateData({ source, selectedInstances: null, networks: null })
+    wizardStore.updateData({ source, selectedInstances: null, networks: null, sourceOptions: null })
     wizardStore.clearStorageMap()
     wizardStore.setPermalink(wizardStore.data)
 
-    if (source) {
-      // Check if user has permission for this endpoint
-      endpointStore.getConnectionInfo(source).then(() => {
-        if (source) {
-          // Preload instances for 'vms' page
-          instanceStore.loadInstancesInChunks(source.id, this.instancesChunkSize)
-        }
-      }).catch(() => {
-        this.handleSourceEndpointChange(null)
-      })
+    if (!source) {
+      return
     }
+
+    // Check if user has permission for this endpoint
+    endpointStore.getConnectionInfo(source).then(() => {
+      if (source) {
+        // Preload instances for 'vms' page
+        instanceStore.loadInstancesInChunks(source.id, this.instancesChunkSize)
+      }
+    }).catch(() => {
+      this.handleSourceEndpointChange(null)
+    })
+
+    providerStore.loadSourceSchema(source.type, this.state.type === 'replica')
   }
 
   handleTargetEndpointChange(target: EndpointType) {
-    wizardStore.updateData({ target, networks: null, options: null })
+    wizardStore.updateData({ target, networks: null, destOptions: null })
     wizardStore.clearStorageMap()
     wizardStore.setPermalink(wizardStore.data)
     // Preload destination options schema
-    providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
+    providerStore.loadDestinationSchema(target.type, this.state.type).then(() => {
       // Preload destination options values
       return providerStore.getDestinationOptions(target.id, target.type)
     })
@@ -272,10 +288,10 @@ class WizardPage extends React.Component<Props, State> {
     instanceStore.updateChunkSize(chunkSize)
   }
 
-  handleOptionsChange(field: Field, value: any) {
+  handleDestOptionsChange(field: Field, value: any) {
     wizardStore.updateData({ networks: null })
     wizardStore.clearStorageMap()
-    wizardStore.updateOptions({ field, value })
+    wizardStore.updateDestOptions({ field, value })
     // If the field is a string and doesn't have an enum property,
     // we can't call destination options on "change" since too many calls will be made,
     // it also means a potential problem with the server not populating the "enum" prop.
@@ -285,6 +301,11 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.setPermalink(wizardStore.data)
   }
 
+  handleSourceOptionsChange(field: Field, value: any) {
+    wizardStore.updateSourceOptions({ field, value })
+    wizardStore.setPermalink(wizardStore.data)
+  }
+
   handleNetworkChange(sourceNic: Nic, targetNetwork: Network) {
     wizardStore.updateNetworks({ sourceNic, targetNetwork })
     wizardStore.setPermalink(wizardStore.data)
@@ -316,41 +337,15 @@ class WizardPage extends React.Component<Props, State> {
 
   loadEnvDestinationOptions(field?: Field) {
     let provider = wizardStore.data.target && wizardStore.data.target.type
-    let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p !== 'string' && p.name === provider)
-    if (provider && providerWithExtraOptions && typeof providerWithExtraOptions !== 'string' && providerWithExtraOptions.envRequiredFields) {
-      let findFieldInSchema = (name: string) => providerStore.optionsSchema.find(f => f.name === name)
-      let validFields = providerWithExtraOptions.envRequiredFields.filter(fn => {
-        let schemaField = findFieldInSchema(fn)
-        if (wizardStore.data.options) {
-          if (wizardStore.data.options[fn] === null) {
-            return false
-          }
-          if (wizardStore.data.options[fn] === undefined && schemaField && schemaField.default) {
-            return true
-          }
-          return wizardStore.data.options[fn]
-        }
-        return false
-      })
-      let currentFieldValied = field ? validFields.find(fn => field ? fn === field.name : false) : true
-      if (
-        validFields.length === providerWithExtraOptions.envRequiredFields.length &&
-        currentFieldValied
-      ) {
-        let envData = {}
-        validFields.forEach(fn => {
-          envData[fn] = wizardStore.data.options ? wizardStore.data.options[fn] : null
-          if (envData[fn] == null) {
-            let schemaField = findFieldInSchema(fn)
-            if (schemaField && schemaField.default) {
-              envData[fn] = schemaField.default
-            }
-          }
-        })
-        if (wizardStore.data.target) {
-          providerStore.getDestinationOptions(wizardStore.data.target.id, provider, envData)
-        }
-      }
+    let envData = getFieldChangeDestOptions({
+      provider: wizardStore.data.target && wizardStore.data.target.type,
+      destSchema: providerStore.destinationSchema,
+      data: wizardStore.data.destOptions,
+      field,
+    })
+
+    if (provider && envData && wizardStore.data.target) {
+      providerStore.getDestinationOptions(wizardStore.data.target.id, provider, envData)
     }
   }
 
@@ -361,7 +356,13 @@ class WizardPage extends React.Component<Props, State> {
         endpointStore.getEndpoints()
         // Preload instances if data is set from 'Permalink'
         let source = wizardStore.data.source
-        if (instanceStore.instances.length === 0 && source) {
+        if (!source) {
+          return
+        }
+
+        providerStore.loadSourceSchema(source.type, this.state.type === 'replica')
+
+        if (instanceStore.instances.length === 0) {
           // Check if user has permission for this endpoint
           endpointStore.getConnectionInfo(source).then(() => {
             // Preload instances for 'vms' page
@@ -379,8 +380,8 @@ class WizardPage extends React.Component<Props, State> {
           endpointStore.loadStorage(target.id, {})
         }
         // Preload destination options schema
-        if (providerStore.optionsSchema.length === 0 && target) {
-          providerStore.loadOptionsSchema(target.type, this.state.type).then(() => {
+        if (providerStore.destinationSchema.length === 0 && target) {
+          providerStore.loadDestinationSchema(target.type, this.state.type).then(() => {
             // Preload destination options if data is set from 'Permalink'
             if (providerStore.destinationOptions.length === 0 && target) {
               providerStore.getDestinationOptions(target.id, target.type).then(() => {
@@ -397,7 +398,7 @@ class WizardPage extends React.Component<Props, State> {
         }
         if (wizardStore.data.target) {
           let id = wizardStore.data.target.id
-          networkStore.loadNetworks(id, wizardStore.data.options)
+          networkStore.loadNetworks(id, wizardStore.data.destOptions)
         }
         break
       default:
@@ -438,8 +439,8 @@ class WizardPage extends React.Component<Props, State> {
     let data = wizardStore.data
     let separateVms = true
 
-    if (data.options && data.options.separate_vm != null) {
-      separateVms = data.options.separate_vm
+    if (data.destOptions && data.destOptions.separate_vm != null) {
+      separateVms = data.destOptions.separate_vm
     }
 
     if (data.selectedInstances && data.selectedInstances.length === 1) {
@@ -467,7 +468,7 @@ class WizardPage extends React.Component<Props, State> {
   }
 
   executeCreatedReplica(replica: MainItem) {
-    let options = wizardStore.data.options
+    let options = wizardStore.data.destOptions
     let executeNow = true
     if (options && options.execute_now != null) {
       executeNow = options.execute_now
@@ -517,7 +518,8 @@ class WizardPage extends React.Component<Props, State> {
             onInstanceClick={instance => { this.handleInstanceClick(instance) }}
             onInstancePageClick={page => { this.handleInstancePageClick(page) }}
             onInstanceChunkSizeUpdate={chunkSize => { this.handleInstanceChunkSizeUpdate(chunkSize) }}
-            onOptionsChange={(field, value) => { this.handleOptionsChange(field, value) }}
+            onDestOptionsChange={(field, value) => { this.handleDestOptionsChange(field, value) }}
+            onSourceOptionsChange={(field, value) => { this.handleSourceOptionsChange(field, value) }}
             onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
             onStorageChange={(source, target, type) => { this.handleStorageChange(source, target, type) }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}

+ 1 - 1
src/components/styleUtils/Palette.js

@@ -18,7 +18,7 @@ const Palette = {
   primary: '#0044CB',
   primaryLight: '#000EA9',
   secondary: '#D9DCE3',
-  secondaryLight: '#7F8795',
+  secondaryLight: '#777A8B',
   black: '#202134',
   alert: '#F91661',
   success: '#4CD964',

+ 16 - 4
src/config.js

@@ -51,6 +51,11 @@ export const navigationMenu = [
 
 export const requestPollTimeout = 5000
 
+// https://github.com/cloudbase/coriolis/blob/master/coriolis/constants.py
+// PROVIDER_TYPE_IMPORT = 1 // migration target schema
+// PROVIDER_TYPE_EXPORT = 2 // migration source schema
+// PROVIDER_TYPE_REPLICA_IMPORT = 4 // replica target schema
+// PROVIDER_TYPE_REPLICA_EXPORT = 8 // replica source schema
 export const providerTypes = {
   TARGET_MIGRATION: 1,
   SOURCE_MIGRATION: 2,
@@ -81,21 +86,28 @@ export const executionOptions = [
   },
 ]
 
-export const storageProviders = ['openstack']
+export const storageProviders = ['openstack', 'azure']
+export const sourceOptionsProviders = ['aws']
 
 export const wizardConfig = {
   pages: [
     { id: 'type', title: 'New', breadcrumb: 'Type' },
     { id: 'source', title: 'Select your source cloud', breadcrumb: 'Source Cloud' },
-    { id: 'target', title: 'Select your target cloud', breadcrumb: 'Target Cloud' },
+    {
+      id: 'source-options',
+      title: 'Source options',
+      breadcrumb: 'Source Options',
+      sourceFilter: (p: string) => sourceOptionsProviders.find(s => s === p),
+    },
     { id: 'vms', title: 'Select instances', breadcrumb: 'Select VMs' },
-    { id: 'options', title: 'Options', breadcrumb: 'Options' },
+    { id: 'target', title: 'Select your target cloud', breadcrumb: 'Target Cloud' },
+    { id: 'dest-options', title: 'Target options', breadcrumb: 'Target Options' },
     { id: 'networks', title: 'Networks', breadcrumb: 'Networks' },
     {
       id: 'storage',
       title: 'Storage Mapping',
       breadcrumb: 'Storage',
-      filter: (p: string) => storageProviders.find(s => s === p),
+      targetFilter: (p: string) => storageProviders.find(s => s === p),
     },
     { id: 'schedule', title: 'Schedule', breadcrumb: 'Schedule', excludeFrom: 'migration' },
     { id: 'summary', title: 'Summary', breadcrumb: 'Summary' },

+ 33 - 30
src/plugins/endpoint/default/OptionsSchemaPlugin.js

@@ -62,43 +62,46 @@ export const defaultFillMigrationImageMapValues = (field: Field, option: Destina
   return false
 }
 
-export const defaultGetDestinationEnv = (data: WizardData): any => {
+export const defaultGetDestinationEnv = (options: ?{ [string]: mixed }, oldOptions?: ?{ [string]: mixed }): any => {
   let env = {}
   let specialOptions = ['execute_now', 'separate_vm', 'skip_os_morphing', 'default_storage', 'description']
     .concat(executionOptions.map(o => o.name))
     .concat(migrationImageOsTypes.map(o => `${o}_os_image`))
 
 
-  if (data.options) {
-    Object.keys(data.options).forEach(optionName => {
-      if (specialOptions.find(o => o === optionName) || !data.options || data.options[optionName] == null) {
-        return
-      }
-      if (optionName.indexOf('/') > 0) {
-        let parentName = optionName.substr(0, optionName.lastIndexOf('/'))
-        if (!env[parentName]) {
-          env[parentName] = {}
-        }
-        env[parentName][optionName.substr(optionName.lastIndexOf('/') + 1)] = data.options ? data.options[optionName] : null
-      } else {
-        env[optionName] = data.options ? data.options[optionName] : null
-      }
-    })
+  if (!options) {
+    return env
   }
+  Object.keys(options).forEach(optionName => {
+    if (specialOptions.find(o => o === optionName) || !options || options[optionName] == null) {
+      return
+    }
+
+    if (optionName.indexOf('/') > 0) {
+      let parentName = optionName.substr(0, optionName.lastIndexOf('/'))
+      if (!env[parentName]) {
+        env[parentName] = oldOptions ? oldOptions[parentName] || {} : {}
+      }
+      env[parentName][optionName.substr(optionName.lastIndexOf('/') + 1)] = options ? options[optionName] : null
+    } else {
+      env[optionName] = options ? options[optionName] : null
+    }
+  })
   return env
 }
 
-export const defaultGetMigrationImageMap = (data: WizardData) => {
+export const defaultGetMigrationImageMap = (options: ?{ [string]: mixed }) => {
   let env = {}
-  if (data.options) {
+  if (options) {
     migrationImageOsTypes.forEach(os => {
-      if (data.options && data.options[`${os}_os_image`]) {
-        if (!env.migr_image_map) {
-          env.migr_image_map = {}
-        }
-
-        env.migr_image_map[os] = data.options[`${os}_os_image`]
+      if (!options || !options[`${os}_os_image`]) {
+        return
       }
+      if (!env.migr_image_map) {
+        env.migr_image_map = {}
+      }
+
+      env.migr_image_map[os] = options[`${os}_os_image`]
     })
   }
 
@@ -116,10 +119,10 @@ export default class OptionsSchemaParser {
     }
   }
 
-  static getDestinationEnv(data: WizardData) {
+  static getDestinationEnv(options: ?{ [string]: mixed }, oldOptions?: any) {
     let env = {
-      ...defaultGetDestinationEnv(data),
-      ...defaultGetMigrationImageMap(data),
+      ...defaultGetDestinationEnv(options, oldOptions),
+      ...defaultGetMigrationImageMap(options),
     }
     return env
   }
@@ -134,10 +137,10 @@ export default class OptionsSchemaParser {
     return payload
   }
 
-  static getStorageMap(data: WizardData, storageMap: StorageMap[]) {
+  static getStorageMap(data: any, storageMap: StorageMap[]) {
     let payload = {}
-    if (data.options && data.options.default_storage) {
-      payload.default = data.options.default_storage
+    if (data && data.default_storage) {
+      payload.default = data.default_storage
     }
 
     storageMap.forEach(mapping => {

+ 11 - 1
src/sources/ProviderSource.js

@@ -35,7 +35,7 @@ class ProviderSource {
       .then(response => response.data.providers)
   }
 
-  static loadOptionsSchema(providerName: string, schemaType: string): Promise<Field[]> {
+  static loadDestinationSchema(providerName: string, schemaType: string): Promise<Field[]> {
     let schemaTypeInt = schemaType === 'migration' ? providerTypes.TARGET_MIGRATION : providerTypes.TARGET_REPLICA
 
     return Api.get(`${servicesUrl.coriolis}/${Api.projectId}/providers/${providerName}/schemas/${schemaTypeInt}`).then(response => {
@@ -45,6 +45,16 @@ class ProviderSource {
     })
   }
 
+  static loadSourceSchema(providerName: string, isReplica: boolean): Promise<Field[]> {
+    let schemaTypeInt = isReplica ? providerTypes.SOURCE_REPLICA : providerTypes.SOURCE_MIGRATION
+
+    return Api.get(`${servicesUrl.coriolis}/${Api.projectId}/providers/${providerName}/schemas/${schemaTypeInt}`).then(response => {
+      let schema = { oneOf: [response.data.schemas.source_environment_schema] }
+      let fields = SchemaParser.optionsSchemaToFields(providerName, schema)
+      return fields
+    })
+  }
+
   static getDestinationOptions(endpointId: string, envData: ?{ [string]: mixed }): Promise<DestinationOption[]> {
     let envString = ''
     if (envData) {

+ 30 - 1
src/sources/ReplicaSource.js

@@ -17,10 +17,12 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import moment from 'moment'
 
 import Api from '../utils/ApiCaller'
+import { OptionsSchemaPlugin } from '../plugins/endpoint'
 
 import { servicesUrl } from '../config'
-import type { MainItem } from '../types/MainItem'
+import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Execution } from '../types/Execution'
+import type { Endpoint } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 
 class ReplicaSourceUtils {
@@ -157,6 +159,33 @@ class ReplicaSource {
       data: { 'delete-disks': null },
     }).then(response => response.data.execution)
   }
+
+  static update(replica: MainItem, destinationEndpoint: Endpoint, updateData: UpdateData): Promise<Execution> {
+    const parser = OptionsSchemaPlugin[destinationEndpoint.type] || OptionsSchemaPlugin.default
+    let payload = { replica: {} }
+
+    if (updateData.network.length > 0) {
+      let networkMap = {}
+      updateData.network.forEach(mapping => {
+        networkMap[mapping.sourceNic.network_name] = mapping.targetNetwork.name
+      })
+      payload.replica.network_map = networkMap
+    }
+
+    if (Object.keys(updateData.destination).length > 0) {
+      payload.replica.destination_environment = parser.getDestinationEnv(updateData.destination, replica.destination_environment)
+    }
+
+    if (updateData.storage.length > 0) {
+      payload.replica.storage_mappings = parser.getStorageMap(updateData.destination, updateData.storage)
+    }
+
+    return Api.send({
+      url: `${servicesUrl.coriolis}/${Api.projectId}/replicas/${replica.id}`,
+      method: 'PUT',
+      data: payload,
+    }).then(response => response.data)
+  }
 }
 
 export default ReplicaSource

+ 9 - 5
src/sources/WizardSource.js

@@ -30,15 +30,19 @@ class WizardSource {
     payload[type] = {
       origin_endpoint_id: data.source ? data.source.id : 'null',
       destination_endpoint_id: data.target ? data.target.id : 'null',
-      destination_environment: parser.getDestinationEnv(data),
+      destination_environment: parser.getDestinationEnv(data.destOptions),
       network_map: parser.getNetworkMap(data),
       instances: data.selectedInstances ? data.selectedInstances.map(i => i.instance_name) : 'null',
-      storage_mappings: parser.getStorageMap(data, storageMap),
-      notes: data.options ? data.options.description || '' : '',
+      storage_mappings: parser.getStorageMap(data.destOptions, storageMap),
+      notes: data.destOptions ? data.destOptions.description || '' : '',
     }
 
-    if (data.options && data.options.skip_os_morphing != null) {
-      payload[type].skip_os_morphing = data.options.skip_os_morphing
+    if (data.destOptions && data.destOptions.skip_os_morphing != null) {
+      payload[type].skip_os_morphing = data.destOptions.skip_os_morphing
+    }
+
+    if (data.sourceOptions) {
+      payload[type].source_environment = parser.getDestinationEnv(data.sourceOptions)
     }
 
     return Api.send({

+ 6 - 0
src/stores/EndpointStore.js

@@ -39,6 +39,7 @@ class EndpointStore {
   @observable connectionInfoLoading = false
   @observable connectionsInfoLoading = false
   @observable storageBackends: StorageBackend[] = []
+  @observable storageLoading: boolean = false
 
   @action getEndpoints(options?: { showLoading: boolean }) {
     if (options && options.showLoading) {
@@ -136,8 +137,13 @@ class EndpointStore {
 
   @action loadStorage(endpointId: string, data: any): Promise<void> {
     this.storageBackends = []
+    this.storageLoading = true
     return EndpointSource.loadStorage(endpointId, data).then(storage => {
       this.storageBackends = storage.storage_backends
+      this.storageLoading = false
+    }).catch(ex => {
+      this.storageLoading = false
+      throw ex
     })
   }
 }

+ 96 - 14
src/stores/ProviderStore.js

@@ -23,17 +23,66 @@ import type { DestinationOption } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 import type { Providers } from '../types/Providers'
 
+export const getFieldChangeDestOptions = (options: {
+  provider: ?string,
+  destSchema: Field[],
+  data: any,
+  field: ?Field,
+}) => {
+  let { provider, destSchema, data, field } = options
+  let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p !== 'string' && p.name === provider)
+  if (!provider || !providerWithExtraOptions || typeof providerWithExtraOptions === 'string' || !providerWithExtraOptions.envRequiredFields) {
+    return null
+  }
+
+  let findFieldInSchema = (name: string) => destSchema.find(f => f.name === name)
+
+  let validFields = providerWithExtraOptions.envRequiredFields.filter(fn => {
+    let schemaField = findFieldInSchema(fn)
+    if (data) {
+      if (data[fn] === null) {
+        return false
+      }
+      if (data[fn] === undefined && schemaField && schemaField.default) {
+        return true
+      }
+      return data[fn]
+    }
+    return false
+  })
+
+  let isCurrentFieldValid = field ? validFields.find(fn => field ? fn === field.name : false) : true
+  if (validFields.length !== providerWithExtraOptions.envRequiredFields.length || !isCurrentFieldValid) {
+    return null
+  }
+
+  let envData = {}
+  validFields.forEach(fn => {
+    envData[fn] = data ? data[fn] : null
+    if (envData[fn] == null) {
+      let schemaField = findFieldInSchema(fn)
+      if (schemaField && schemaField.default) {
+        envData[fn] = schemaField.default
+      }
+    }
+  })
+
+  return envData
+}
+
 class ProviderStore {
   @observable connectionInfoSchema: Field[] = []
   @observable connectionSchemaLoading: boolean = false
   @observable providers: ?Providers
   @observable providersLoading: boolean = false
-  @observable optionsSchema: Field[] = []
-  @observable optionsSchemaLoading: boolean = false
+  @observable destinationSchema: Field[] = []
+  @observable destinationSchemaLoading: boolean = false
   @observable destinationOptions: DestinationOption[] = []
   @observable destinationOptionsLoading: boolean = false
+  @observable sourceSchema: Field[] = []
+  @observable sourceSchemaLoading: boolean = false
 
-  lastOptionsSchemaType: string = ''
+  lastDestinationSchemaType: string = ''
 
   @action getConnectionInfoSchema(providerName: string): Promise<void> {
     this.connectionSchemaLoading = true
@@ -62,44 +111,77 @@ class ProviderStore {
     })
   }
 
-  @action loadOptionsSchema(providerName: string, schemaType: string): Promise<void> {
-    this.optionsSchemaLoading = true
-    this.lastOptionsSchemaType = schemaType
+  @action loadDestinationSchema(providerName: string, schemaType: string): Promise<void> {
+    this.destinationSchemaLoading = true
+    this.lastDestinationSchemaType = schemaType
 
-    return ProviderSource.loadOptionsSchema(providerName, schemaType).then((fields: Field[]) => {
-      this.optionsSchemaLoading = false
-      this.optionsSchema = fields
+    return ProviderSource.loadDestinationSchema(providerName, schemaType).then((fields: Field[]) => {
+      this.destinationSchemaLoading = false
+      this.destinationSchema = fields
     }).catch(() => {
-      this.optionsSchemaLoading = false
+      this.destinationSchemaLoading = false
     })
   }
 
-  @action getDestinationOptions(endpointId: string, provider: string, envData?: { [string]: mixed }): Promise<DestinationOption[]> {
+  @action loadSourceSchema(providerName: string, isReplica: boolean): Promise<void> {
+    this.sourceSchemaLoading = true
+
+    return ProviderSource.loadSourceSchema(providerName, isReplica).then((fields: Field[]) => {
+      this.sourceSchemaLoading = false
+      this.sourceSchema = fields
+    }).catch(() => { this.sourceSchemaLoading = false })
+  }
+
+  cache: { key: string, data: DestinationOption[] }[] = []
+
+  @action getDestinationOptions(endpointId: string, provider: string, envData?: { [string]: mixed }, useCache?: boolean): Promise<DestinationOption[]> {
     let providerWithExtraOptions = providersWithExtraOptions.find(p => typeof p === 'string' ? p === provider : p.name === provider)
     if (!providerWithExtraOptions) {
       return Promise.resolve([])
     }
 
+    if (useCache) {
+      let key = `${endpointId}-${provider}-${JSON.stringify(envData)}`
+      let cacheItem = this.cache.find(c => c.key === key)
+      if (cacheItem) {
+        this.destinationSchema.forEach(field => {
+          const parser = OptionsSchemaPlugin[provider] || OptionsSchemaPlugin.default
+          parser.fillFieldValues(field, cacheItem.data)
+        })
+        this.destinationSchema = [...this.destinationSchema]
+        this.destinationOptions = cacheItem.data
+        return Promise.resolve(cacheItem.data)
+      }
+    }
+
     this.destinationOptionsLoading = true
     this.destinationOptions = []
     let destOptions = []
 
     return ProviderSource.getDestinationOptions(endpointId, envData).then(options => {
-      this.optionsSchema.forEach(field => {
+      this.destinationSchema.forEach(field => {
         const parser = OptionsSchemaPlugin[provider] || OptionsSchemaPlugin.default
         parser.fillFieldValues(field, options)
       })
       this.destinationOptions = options
       destOptions = options
       this.destinationOptionsLoading = false
+
+      if (useCache) {
+        let key = `${endpointId}-${provider}-${JSON.stringify(envData)}`
+        if (this.cache.length > 20) {
+          this.cache.splice(0)
+        }
+        this.cache.push({ key, data: options })
+      }
     }).catch(err => {
       console.error(err)
       if (envData) {
-        return this.loadOptionsSchema(provider, this.lastOptionsSchemaType).then(() => {
+        return this.loadDestinationSchema(provider, this.lastDestinationSchemaType).then(() => {
           return this.getDestinationOptions(endpointId, provider)
         })
       }
-      return this.loadOptionsSchema(provider, this.lastOptionsSchemaType)
+      return this.loadDestinationSchema(provider, this.lastDestinationSchemaType)
     }).then(() => {
       this.destinationOptionsLoading = false
       return destOptions

+ 13 - 8
src/stores/ReplicaStore.js

@@ -18,11 +18,12 @@ import { observable, action } from 'mobx'
 
 import notificationStore from '../stores/NotificationStore'
 import ReplicaSource from '../sources/ReplicaSource'
-import type { MainItem } from '../types/MainItem'
+import type { MainItem, UpdateData } from '../types/MainItem'
 import type { Execution } from '../types/Execution'
+import type { Endpoint } from '../types/Endpoint'
 import type { Field } from '../types/Field'
 
-class replicaStoreUtils {
+class ReplicaStoreUtils {
   static getNewReplica(replicaDetails: MainItem, execution: Execution): MainItem {
     if (replicaDetails.executions) {
       return {
@@ -87,8 +88,8 @@ class ReplicaStore {
     }).catch(() => { this.executionsLoading = false })
   }
 
-  @action getReplica(replicaId: string): Promise<void> {
-    this.detailsLoading = true
+  @action getReplica(replicaId: string, showLoading: boolean = true): Promise<void> {
+    this.detailsLoading = showLoading
 
     return ReplicaSource.getReplica(replicaId).then(replica => {
       this.detailsLoading = false
@@ -101,13 +102,13 @@ class ReplicaStore {
   @action execute(replicaId: string, fields?: Field[]): Promise<void> {
     return ReplicaSource.execute(replicaId, fields).then(execution => {
       if (this.replicaDetails && this.replicaDetails.id === replicaId) {
-        this.replicaDetails = replicaStoreUtils.getNewReplica(this.replicaDetails, execution)
+        this.replicaDetails = ReplicaStoreUtils.getNewReplica(this.replicaDetails, execution)
       }
 
       let replicasItemIndex = this.replicas ? this.replicas.findIndex(r => r.id === replicaId) : -1
 
       if (replicasItemIndex > -1) {
-        const updatedReplica = replicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
+        const updatedReplica = ReplicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
         this.replicas[replicasItemIndex] = updatedReplica
       }
     })
@@ -145,13 +146,13 @@ class ReplicaStore {
   @action deleteDisks(replicaId: string) {
     return ReplicaSource.deleteDisks(replicaId).then(execution => {
       if (this.replicaDetails && this.replicaDetails.id === replicaId) {
-        this.replicaDetails = replicaStoreUtils.getNewReplica(this.replicaDetails, execution)
+        this.replicaDetails = ReplicaStoreUtils.getNewReplica(this.replicaDetails, execution)
       }
 
       let replicasItemIndex = this.replicas ? this.replicas.findIndex(r => r.id === replicaId) : -1
 
       if (replicasItemIndex > -1) {
-        const updatedReplica = replicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
+        const updatedReplica = ReplicaStoreUtils.getNewReplica(this.replicas[replicasItemIndex], execution)
         this.replicas[replicasItemIndex] = updatedReplica
       }
     })
@@ -161,6 +162,10 @@ class ReplicaStore {
     this.detailsLoading = true
     this.replicaDetails = null
   }
+
+  @action update(replica: MainItem, destinationEndpoint: Endpoint, updateData: UpdateData) {
+    return ReplicaSource.update(replica, destinationEndpoint, updateData)
+  }
 }
 
 export default new ReplicaStore()

+ 23 - 16
src/stores/WizardStore.js

@@ -26,6 +26,21 @@ import type { Schedule } from '../types/Schedule'
 import { wizardConfig } from '../config'
 import Source from '../sources/WizardSource'
 
+const updateOptions = (oldOptions: ?{ [string]: mixed }, data: { field: Field, value: any }) => {
+  let options = { ...oldOptions }
+  if (data.field.type === 'array') {
+    let oldValues: string[] = options[data.field.name] || []
+    if (oldValues.find(v => v === data.value)) {
+      options[data.field.name] = oldValues.filter(v => v !== data.value)
+    } else {
+      options[data.field.name] = [...oldValues, data.value]
+    }
+  } else {
+    options[data.field.name] = data.value
+  }
+  return options
+}
+
 class WizardStore {
   @observable data: WizardData = {}
   @observable schedules: Schedule[] = []
@@ -64,22 +79,14 @@ class WizardStore {
     this.currentPage = page
   }
 
-  @action updateOptions(data: { field: Field, value: any }) {
-    this.data.options = {
-      ...this.data.options,
-    }
-    if (data.field.type === 'array') {
-      let oldValues: string[] = this.data.options[data.field.name] || []
-      if (oldValues.find(v => v === data.value)) {
-        // $FlowIssue
-        this.data.options[data.field.name] = oldValues.filter(v => v !== data.value)
-      } else {
-        // $FlowIssue
-        this.data.options[data.field.name] = [...oldValues, data.value]
-      }
-    } else {
-      this.data.options[data.field.name] = data.value
-    }
+  @action updateSourceOptions(data: { field: Field, value: any }) {
+    this.data = { ...this.data }
+    this.data.sourceOptions = updateOptions(this.data.sourceOptions, data)
+  }
+
+  @action updateDestOptions(data: { field: Field, value: any }) {
+    this.data = { ...this.data }
+    this.data.destOptions = updateOptions(this.data.destOptions, data)
   }
 
   @action updateNetworks(network: NetworkMap) {

+ 1 - 0
src/types/Field.js

@@ -30,4 +30,5 @@ export type Field = {
   properties?: Field[],
   required?: boolean,
   useTextArea?: boolean,
+  readOnly?: boolean,
 }

+ 14 - 10
src/types/MainItem.js

@@ -17,6 +17,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import type { Execution } from './Execution'
 import type { Task } from './Task'
 import type { Instance } from './Instance'
+import type { NetworkMap } from './Network'
+import type { StorageMap } from './Endpoint'
 
 export type MainItemInfo = {
   export_info: {
@@ -28,14 +30,10 @@ export type MainItemInfo = {
   },
 }
 
-export type DestinationEnvInfo = {
-  network_map: {
-    [string]: {
-      source_network: string,
-      destination_network: string,
-    } | 'string'
-  },
-  [string]: mixed,
+export type UpdateData = {
+  destination: any,
+  network: NetworkMap[],
+  storage: StorageMap[],
 }
 
 export type MainItem = {
@@ -52,9 +50,9 @@ export type MainItem = {
   instances: string[],
   type: string,
   info: { [string]: MainItemInfo },
-  destination_environment: DestinationEnvInfo,
+  destination_environment: { [string]: mixed },
   transfer_result: ?{ [string]: Instance },
-  storage_mappings: ?{
+  storage_mappings?: ?{
     backend_mappings: ?{
       destination: string,
       source: string,
@@ -65,4 +63,10 @@ export type MainItem = {
       disk_id: string,
     }[],
   },
+  network_map?: {
+    [string]: {
+      source_network: string,
+      destination_network: string,
+    } | 'string'
+  }
 }

+ 3 - 2
src/types/WizardData.js

@@ -19,11 +19,12 @@ import type { NetworkMap } from './Network'
 import type { Endpoint } from './Endpoint'
 
 export type WizardData = {
-  options?: ?{ [string]: mixed },
+  destOptions?: ?{ [string]: mixed },
+  sourceOptions?: ?{ [string]: mixed },
   selectedInstances?: ?Instance[],
   networks?: ?NetworkMap[],
   source?: ?Endpoint,
-  target?: Endpoint,
+  target?: ?Endpoint,
 }
 
 export type WizardPage = {

+ 1 - 1
src/utils/LabelDictionary.js

@@ -16,7 +16,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 class LabelDictionary {
   // The word will be uppercased
-  static acronyms = ['id', 'api', 'url', 'vm', 'os', 'dhcp', 'sql', 'oci']
+  static acronyms = ['id', 'api', 'url', 'vm', 'os', 'dhcp', 'sql', 'oci', 'sku']
 
   // The word will be replaced
   static abbreviations = {