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

Update Bulk Actions button in lists pages

Replicas List page now has more actions available. To support this the
whole list bulk operation has been refactored.

All list pages have been updated to use the new actions button layout.
Sergiu Miclea 7 лет назад
Родитель
Сommit
63afe7feec

+ 12 - 3
src/components/molecules/ActionDropdown/ActionDropdown.jsx

@@ -88,9 +88,19 @@ class ActionDropdown extends React.Component<Props, State> {
   listRef: HTMLElement
   listRef: HTMLElement
   tipRef: HTMLElement
   tipRef: HTMLElement
   buttonRef: HTMLElement
   buttonRef: HTMLElement
+  buttonRect: ClientRect
 
 
   componentDidMount() {
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
     window.addEventListener('mousedown', this.handlePageClick, false)
+    if (this.buttonRef) {
+      this.buttonRect = this.buttonRef.getBoundingClientRect()
+    }
+  }
+
+  componentWillUpdate() {
+    if (this.buttonRef) {
+      this.buttonRect = this.buttonRef.getBoundingClientRect()
+    }
   }
   }
 
 
   componentDidUpdate() {
   componentDidUpdate() {
@@ -107,7 +117,6 @@ class ActionDropdown extends React.Component<Props, State> {
     }
     }
     let tipHeight = this.tipRef.offsetHeight / 2
     let tipHeight = this.tipRef.offsetHeight / 2
     let topOffset = 6
     let topOffset = 6
-    let buttonRect = this.buttonRef.getBoundingClientRect()
 
 
     // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
     // If a modal is opened, body scroll is removed and body top is set to replicate scroll position
     let scrollOffset = 0
     let scrollOffset = 0
@@ -115,8 +124,8 @@ class ActionDropdown extends React.Component<Props, State> {
       scrollOffset = -parseInt(document.body && document.body.style.top, 10)
       scrollOffset = -parseInt(document.body && document.body.style.top, 10)
     }
     }
 
 
-    this.listRef.style.top = `${buttonRect.top + buttonRect.height + tipHeight + topOffset + (window.pageYOffset || scrollOffset)}px`
-    this.listRef.style.left = `${buttonRect.left + window.pageXOffset}px`
+    this.listRef.style.top = `${this.buttonRect.top + this.buttonRect.height + tipHeight + topOffset + (window.pageYOffset || scrollOffset)}px`
+    this.listRef.style.left = `${this.buttonRect.left + window.pageXOffset}px`
   }
   }
 
 
   @autobind
   @autobind

+ 13 - 11
src/components/molecules/MainListFilter/MainListFilter.jsx

@@ -20,12 +20,14 @@ import styled from 'styled-components'
 
 
 import Checkbox from '../../atoms/Checkbox'
 import Checkbox from '../../atoms/Checkbox'
 import SearchInput from '../SearchInput'
 import SearchInput from '../SearchInput'
-import Dropdown from '../Dropdown'
+import ActionDropdown from '../../molecules/ActionDropdown'
 import ReloadButton from '../../atoms/ReloadButton'
 import ReloadButton from '../../atoms/ReloadButton'
 
 
 import Palette from '../../styleUtils/Palette'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 
 
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
+
 const Wrapper = styled.div`
 const Wrapper = styled.div`
   display: flex;
   display: flex;
   align-items: center;
   align-items: center;
@@ -82,14 +84,13 @@ type Props = {
   onSearchChange: (value: string) => void,
   onSearchChange: (value: string) => void,
   searchValue: string,
   searchValue: string,
   onSelectAllChange: (checked: boolean) => void,
   onSelectAllChange: (checked: boolean) => void,
-  onActionChange: (action: string) => void,
-  actions?: DictItem[],
   selectedValue: string,
   selectedValue: string,
   selectionInfo: { total: number, selected: number, label: string },
   selectionInfo: { total: number, selected: number, label: string },
   selectAllSelected: ?boolean,
   selectAllSelected: ?boolean,
   items: DictItem[],
   items: DictItem[],
   customFilterComponent?: React.Node,
   customFilterComponent?: React.Node,
   searchValue?: string,
   searchValue?: string,
+  dropdownActions: ?DropdownAction[],
 }
 }
 @observer
 @observer
 class MainListFilter extends React.Component<Props> {
 class MainListFilter extends React.Component<Props> {
@@ -102,7 +103,7 @@ class MainListFilter extends React.Component<Props> {
     }
     }
 
 
     return (
     return (
-      <FilterGroup noMargin={!this.props.actions || this.props.actions.length === 0}>
+      <FilterGroup noMargin={!this.props.dropdownActions || this.props.dropdownActions.length === 0}>
         {renderCustomComponent()}
         {renderCustomComponent()}
         {this.props.items.map(item => {
         {this.props.items.map(item => {
           return (
           return (
@@ -130,19 +131,20 @@ class MainListFilter extends React.Component<Props> {
           {this.props.selectionInfo.selected} of {this.props.selectionInfo.total}&nbsp;
           {this.props.selectionInfo.selected} of {this.props.selectionInfo.total}&nbsp;
           {this.props.selectionInfo.label}(s) selected
           {this.props.selectionInfo.label}(s) selected
         </SelectionText>
         </SelectionText>
-        <Dropdown
-          data-test-id="mainListFilter-dropdown"
-          noSelectionMessage="Select an action"
-          items={this.props.actions}
-          onChange={item => { this.props.onActionChange(item.value) }}
-        />
+        {this.props.dropdownActions && this.props.dropdownActions.length ? (
+          <ActionDropdown
+            actions={this.props.dropdownActions}
+            style={{ marginLeft: '8px' }}
+            data-test-id="mainListFilter-actionButton"
+          />
+        ) : null}
       </Selection>
       </Selection>
     )
     )
   }
   }
 
 
   render() {
   render() {
     let renderCheckbox = () => {
     let renderCheckbox = () => {
-      if (this.props.actions && this.props.actions.length > 0) {
+      if (this.props.dropdownActions && this.props.dropdownActions.length > 0) {
         return (
         return (
           <Checkbox
           <Checkbox
             onChange={checked => { this.props.onSelectAllChange(checked) }}
             onChange={checked => { this.props.onSelectAllChange(checked) }}

+ 4 - 17
src/components/molecules/MainListFilter/test.jsx

@@ -31,45 +31,32 @@ let items = [
   { label: 'Item 3', value: 'item-3' },
   { label: 'Item 3', value: 'item-3' },
 ]
 ]
 
 
-let actions = [
-  { label: 'Action 1', value: 'action-1' },
-  { label: 'Action 2', value: 'action-2' },
-]
-
 let selectionInfo = { selected: 2, total: 7, label: 'items' }
 let selectionInfo = { selected: 2, total: 7, label: 'items' }
 
 
 describe('MainListFilter Component', () => {
 describe('MainListFilter Component', () => {
   it('renders given items', () => {
   it('renders given items', () => {
-    let wrapper = wrap({ items, actions, selectionInfo })
+    let wrapper = wrap({ items, selectionInfo })
     expect(wrapper.findPartialId('filterItem').length).toBe(items.length)
     expect(wrapper.findPartialId('filterItem').length).toBe(items.length)
     items.forEach(item => {
     items.forEach(item => {
       expect(wrapper.findText(`filterItem-${item.value}`)).toBe(item.label)
       expect(wrapper.findText(`filterItem-${item.value}`)).toBe(item.label)
     })
     })
   })
   })
 
 
-  it('renders given actions', () => {
-    let wrapper = wrap({ items, actions, selectionInfo })
-    expect(wrapper.find('dropdown').prop('items').length).toBe(actions.length)
-    actions.forEach((action, i) => {
-      expect(wrapper.find('dropdown').prop('items')[i].value).toBe(action.value)
-    })
-  })
-
   it('renders selection info', () => {
   it('renders selection info', () => {
-    let wrapper = wrap({ items, actions, selectionInfo })
+    let wrapper = wrap({ items, selectionInfo })
     expect(wrapper.findText('selectionText')).toBe('2 of 7 items(s) selected')
     expect(wrapper.findText('selectionText')).toBe('2 of 7 items(s) selected')
   })
   })
 
 
   it('handles reload click', () => {
   it('handles reload click', () => {
     let onReloadButtonClick = sinon.spy()
     let onReloadButtonClick = sinon.spy()
-    let wrapper = wrap({ items, actions, selectionInfo, onReloadButtonClick })
+    let wrapper = wrap({ items, selectionInfo, onReloadButtonClick })
     wrapper.find('reloadButton').simulate('click')
     wrapper.find('reloadButton').simulate('click')
     expect(onReloadButtonClick.calledOnce).toBe(true)
     expect(onReloadButtonClick.calledOnce).toBe(true)
   })
   })
 
 
   it('handles item click with correct args', () => {
   it('handles item click with correct args', () => {
     let onFilterItemClick = sinon.spy()
     let onFilterItemClick = sinon.spy()
-    let wrapper = wrap({ items, actions, selectionInfo, onFilterItemClick })
+    let wrapper = wrap({ items, selectionInfo, onFilterItemClick })
     wrapper.find(`filterItem-${items[2].value}`).simulate('click')
     wrapper.find(`filterItem-${items[2].value}`).simulate('click')
     expect(onFilterItemClick.args[0][0].value).toBe(items[2].value)
     expect(onFilterItemClick.args[0][0].value).toBe(items[2].value)
   })
   })

+ 9 - 8
src/components/organisms/FilterList/FilterList.jsx

@@ -20,6 +20,8 @@ import styled from 'styled-components'
 
 
 import type { MainItem } from '../../../types/MainItem'
 import type { MainItem } from '../../../types/MainItem'
 import MainListFilter from '../../molecules/MainListFilter'
 import MainListFilter from '../../molecules/MainListFilter'
+
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 import type { ItemComponentProps } from '../../organisms/MainList'
 import type { ItemComponentProps } from '../../organisms/MainList'
 import MainList from '../../organisms/MainList'
 import MainList from '../../organisms/MainList'
 
 
@@ -33,14 +35,14 @@ const Wrapper = styled.div`
 type DictItem = { value: string, label: string }
 type DictItem = { value: string, label: string }
 type Props = {
 type Props = {
   items: any[],
   items: any[],
-  actions?: DictItem[],
+  dropdownActions?: DropdownAction[],
   loading: boolean,
   loading: boolean,
   onReloadButtonClick: () => void,
   onReloadButtonClick: () => void,
   onItemClick: (item: any) => void,
   onItemClick: (item: any) => void,
-  onActionChange?: (selectedItems: any[], actionValue: string) => void,
   selectionLabel: string,
   selectionLabel: string,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   itemFilterFunction: (item: any, filterStatus?: ?string, filterState?: string) => boolean,
   itemFilterFunction: (item: any, filterStatus?: ?string, filterState?: string) => boolean,
+  onSelectedItemsChange?: (items: any[]) => void,
   filterItems: DictItem[],
   filterItems: DictItem[],
   emptyListImage?: ?string,
   emptyListImage?: ?string,
   emptyListMessage?: string,
   emptyListMessage?: string,
@@ -78,6 +80,7 @@ class FilterList extends React.Component<Props, State> {
         filterText: '',
         filterText: '',
         selectedItems: [],
         selectedItems: [],
       })
       })
+      this.props.onSelectedItemsChange && this.props.onSelectedItemsChange([])
       return
       return
     }
     }
 
 
@@ -101,6 +104,7 @@ class FilterList extends React.Component<Props, State> {
       filterStatus: item.value,
       filterStatus: item.value,
       items,
       items,
     })
     })
+    this.props.onSelectedItemsChange && this.props.onSelectedItemsChange(selectedItems)
   }
   }
 
 
   handleSearchChange(text: string) {
   handleSearchChange(text: string) {
@@ -118,6 +122,7 @@ class FilterList extends React.Component<Props, State> {
     }
     }
 
 
     this.setState({ selectedItems, selectAllSelected: false })
     this.setState({ selectedItems, selectAllSelected: false })
+    this.props.onSelectedItemsChange && this.props.onSelectedItemsChange(selectedItems)
   }
   }
 
 
   handleSelectAllChange(selected: boolean) {
   handleSelectAllChange(selected: boolean) {
@@ -127,10 +132,7 @@ class FilterList extends React.Component<Props, State> {
     }
     }
 
 
     this.setState({ selectedItems, selectAllSelected: selected })
     this.setState({ selectedItems, selectAllSelected: selected })
-  }
-
-  handleActionChange(actionValue: string) {
-    if (this.props.onActionChange) this.props.onActionChange(this.state.selectedItems, actionValue)
+    this.props.onSelectedItemsChange && this.props.onSelectedItemsChange(selectedItems)
   }
   }
 
 
   filterItems(items: MainItem[], filterStatus?: ?string, filterText?: string): MainItem[] {
   filterItems(items: MainItem[], filterStatus?: ?string, filterText?: string): MainItem[] {
@@ -161,8 +163,7 @@ class FilterList extends React.Component<Props, State> {
             label: this.props.selectionLabel,
             label: this.props.selectionLabel,
           }}
           }}
           items={this.props.filterItems}
           items={this.props.filterItems}
-          actions={this.props.actions}
-          onActionChange={action => { this.handleActionChange(action) }}
+          dropdownActions={this.props.dropdownActions || []}
           data-test-id="filterList-filter"
           data-test-id="filterList-filter"
         />
         />
         <MainList
         <MainList

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

@@ -16,7 +16,6 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
 import React from 'react'
 import React from 'react'
 import { shallow } from 'enzyme'
 import { shallow } from 'enzyme'
-import sinon from 'sinon'
 import TW from '../../../utils/TestWrapper'
 import TW from '../../../utils/TestWrapper'
 import FilterList from '.'
 import FilterList from '.'
 
 
@@ -43,9 +42,7 @@ let actions = [{ label: 'action', value: 'action' }]
 
 
 let itemFilterFunction = (item, filterStatus, filterText) => {
 let itemFilterFunction = (item, filterStatus, filterText) => {
   if (
   if (
-    // $FlowIgnore
     (filterStatus !== 'all' && item.id.indexOf(filterStatus) === -1) ||
     (filterStatus !== 'all' && item.id.indexOf(filterStatus) === -1) ||
-    // $FlowIgnore
     (item.label.toLowerCase().indexOf(filterText) === -1)
     (item.label.toLowerCase().indexOf(filterText) === -1)
   ) {
   ) {
     return false
     return false
@@ -74,13 +71,4 @@ describe('FilterList Component', () => {
     expect(wrapper.find('mainList').prop('items')[0].id).toBe('item-3')
     expect(wrapper.find('mainList').prop('items')[0].id).toBe('item-3')
     expect(wrapper.find('mainList').prop('items')[1].id).toBe('item-3-a')
     expect(wrapper.find('mainList').prop('items')[1].id).toBe('item-3-a')
   })
   })
-
-  it('dispaches action for all selected items', () => {
-    let onActionChange = sinon.spy()
-    let wrapper = wrap({ items, filterItems, itemFilterFunction, actions, onActionChange })
-    wrapper.find('filter').simulate('selectAllChange', true)
-    wrapper.find('filter').simulate('actionChange', { ...actions[0] })
-    expect(onActionChange.args[0][0].length).toBe(4)
-    expect(onActionChange.args[0][1].value).toBe('action')
-  })
 })
 })

+ 53 - 74
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -37,33 +37,28 @@ import endpointStore from '../../../stores/EndpointStore'
 import migrationStore from '../../../stores/MigrationStore'
 import migrationStore from '../../../stores/MigrationStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import providerStore from '../../../stores/ProviderStore'
 import providerStore from '../../../stores/ProviderStore'
+import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
+
 import LabelDictionary from '../../../utils/LabelDictionary'
 import LabelDictionary from '../../../utils/LabelDictionary'
 import configLoader from '../../../utils/Config'
 import configLoader from '../../../utils/Config'
-import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
+import Palette from '../../styleUtils/Palette'
 
 
 const Wrapper = styled.div``
 const Wrapper = styled.div``
 
 
-const BulkActions = [
-  { label: 'Delete', value: 'delete' },
-  { label: 'Duplicate', value: 'duplicate' },
-]
-
 type State = {
 type State = {
-  showDeleteEndpointsConfirmation: boolean,
-  confirmationItems: ?EndpointType[],
+  selectedEndpoints: EndpointType[],
   showChooseProviderModal: boolean,
   showChooseProviderModal: boolean,
   showEndpointModal: boolean,
   showEndpointModal: boolean,
   providerType: ?string,
   providerType: ?string,
   showEndpointsInUseModal: boolean,
   showEndpointsInUseModal: boolean,
   modalIsOpen: boolean,
   modalIsOpen: boolean,
+  showDeleteEndpointsModal: boolean,
   showDuplicateModal: boolean,
   showDuplicateModal: boolean,
   duplicating: boolean,
   duplicating: boolean,
 }
 }
 @observer
 @observer
 class EndpointsPage extends React.Component<{ history: any }, State> {
 class EndpointsPage extends React.Component<{ history: any }, State> {
   state = {
   state = {
-    showDeleteEndpointsConfirmation: false,
-    confirmationItems: null,
     showChooseProviderModal: false,
     showChooseProviderModal: false,
     showEndpointModal: false,
     showEndpointModal: false,
     providerType: null,
     providerType: null,
@@ -71,6 +66,8 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     modalIsOpen: false,
     modalIsOpen: false,
     showDuplicateModal: false,
     showDuplicateModal: false,
     duplicating: false,
     duplicating: false,
+    showDeleteEndpointsModal: false,
+    selectedEndpoints: [],
   }
   }
 
 
   pollTimeout: TimeoutID
   pollTimeout: TimeoutID
@@ -101,11 +98,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     return types
     return types
   }
   }
 
 
-  getEndpointUsage(endpoint: EndpointType) {
+  getEndpointUsage(endpointId: string) {
     let replicasCount = replicaStore.replicas.filter(
     let replicasCount = replicaStore.replicas.filter(
-      r => r.origin_endpoint_id === endpoint.id || r.destination_endpoint_id === endpoint.id).length
+      r => r.origin_endpoint_id === endpointId || r.destination_endpoint_id === endpointId).length
     let migrationsCount = migrationStore.migrations.filter(
     let migrationsCount = migrationStore.migrations.filter(
-      r => r.origin_endpoint_id === endpoint.id || r.destination_endpoint_id === endpoint.id).length
+      r => r.origin_endpoint_id === endpointId || r.destination_endpoint_id === endpointId).length
 
 
     return { migrationsCount, replicasCount }
     return { migrationsCount, replicasCount }
   }
   }
@@ -127,41 +124,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     this.props.history.push(`/endpoint/${item.id}`)
     this.props.history.push(`/endpoint/${item.id}`)
   }
   }
 
 
-  handleActionChange(items: EndpointType[], action: string) {
-    switch (action) {
-      case 'delete': {
-        let endpointsInUse = items.filter(endpoint => {
-          const endpointUsage = this.getEndpointUsage(endpoint)
-          return endpointUsage.migrationsCount > 0 || endpointUsage.replicasCount > 0
-        })
-
-        if (endpointsInUse.length > 0) {
-          this.setState({ showEndpointsInUseModal: true })
-        } else {
-          this.setState({
-            showDeleteEndpointsConfirmation: true,
-            confirmationItems: items,
-          })
-        }
-        break
-      }
-      case 'duplicate': {
-        this.setState({
-          confirmationItems: items,
-          showDuplicateModal: true,
-          modalIsOpen: true,
-        })
-        break
-      }
-      default: break
-    }
-  }
-
-  handleDuplicate(projectId: string) {
+  duplicate(projectId: string) {
     this.setState({ modalIsOpen: false, duplicating: true })
     this.setState({ modalIsOpen: false, duplicating: true })
 
 
     let shouldSwitchProject = projectId !== (userStore.loggedUser ? userStore.loggedUser.project.id : '')
     let shouldSwitchProject = projectId !== (userStore.loggedUser ? userStore.loggedUser.project.id : '')
-    let endpoints = this.state.confirmationItems || []
+    let endpoints = endpointStore.endpoints.filter(e => this.state.selectedEndpoints.find(se => se.id === e.id))
 
 
     endpointStore.duplicate({
     endpointStore.duplicate({
       shouldSwitchProject,
       shouldSwitchProject,
@@ -175,20 +142,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     })
     })
   }
   }
 
 
-  handleCloseDeleteEndpointsConfirmation() {
-    this.setState({
-      showDeleteEndpointsConfirmation: false,
-      confirmationItems: null,
+  deleteSelectedEndpoints() {
+    this.state.selectedEndpoints.forEach(endpoint => {
+      endpointStore.delete(endpoint)
     })
     })
-  }
-
-  handleDeleteEndpointsConfirmation() {
-    if (this.state.confirmationItems) {
-      this.state.confirmationItems.forEach(endpoint => {
-        endpointStore.delete(endpoint)
-      })
-    }
-    this.handleCloseDeleteEndpointsConfirmation()
+    this.setState({ showDeleteEndpointsModal: false })
   }
   }
 
 
   handleEmptyListButtonClick() {
   handleEmptyListButtonClick() {
@@ -222,6 +180,19 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     })
     })
   }
   }
 
 
+  handleDeleteAction() {
+    let endpointsInUse = this.state.selectedEndpoints.filter(endpoint => {
+      const endpointUsage = this.getEndpointUsage(endpoint.id)
+      return endpointUsage.migrationsCount > 0 || endpointUsage.replicasCount > 0
+    })
+
+    if (endpointsInUse.length > 0) {
+      this.setState({ showEndpointsInUseModal: true })
+    } else {
+      this.setState({ showDeleteEndpointsModal: true })
+    }
+  }
+
   pollData(showLoading?: boolean = false) {
   pollData(showLoading?: boolean = false) {
     if (this.state.modalIsOpen || this.stopPolling) {
     if (this.state.modalIsOpen || this.stopPolling) {
       return
       return
@@ -248,6 +219,16 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
   render() {
   render() {
     let items: any = endpointStore.endpoints
     let items: any = endpointStore.endpoints
     let selectedProjectId = userStore.loggedUser ? userStore.loggedUser.project.id : ''
     let selectedProjectId = userStore.loggedUser ? userStore.loggedUser.project.id : ''
+    const BulkActions = [{
+      label: 'Duplicate',
+      action: () => { this.setState({ showDuplicateModal: true, modalIsOpen: true }) },
+
+    }, {
+      label: 'Delete Endpoint',
+      color: Palette.alert,
+      action: () => { this.handleDeleteAction() },
+    }]
+
     return (
     return (
       <Wrapper>
       <Wrapper>
         <MainTemplate
         <MainTemplate
@@ -263,18 +244,14 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
                 let endpoint: EndpointType = anyItem
                 let endpoint: EndpointType = anyItem
                 this.handleItemClick(endpoint)
                 this.handleItemClick(endpoint)
               }}
               }}
+              dropdownActions={BulkActions}
+              onSelectedItemsChange={selectedEndpoints => { this.setState({ selectedEndpoints }) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
-              actions={BulkActions}
-              onActionChange={(items, action) => {
-                let anyItems: any = items
-                let endpoints: EndpointType[] = anyItems
-                this.handleActionChange(endpoints, action)
-              }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               renderItemComponent={options =>
               renderItemComponent={options =>
                 (<EndpointListItem
                 (<EndpointListItem
                   {...options}
                   {...options}
-                  getUsage={endpoint => this.getEndpointUsage(endpoint)}
+                  getUsage={endpoint => this.getEndpointUsage(endpoint.id)}
                 />)
                 />)
               }
               }
               emptyListImage={endpointImage}
               emptyListImage={endpointImage}
@@ -293,14 +270,16 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
             />
             />
           }
           }
         />
         />
-        <AlertModal
-          isOpen={this.state.showDeleteEndpointsConfirmation}
-          title="Delete Endpoints?"
-          message="Are you sure you want to delete the selected endpoints?"
-          extraMessage="Deleting a Coriolis Cloud Endpoint is permanent!"
-          onConfirmation={() => { this.handleDeleteEndpointsConfirmation() }}
-          onRequestClose={() => { this.handleCloseDeleteEndpointsConfirmation() }}
-        />
+        {this.state.showDeleteEndpointsModal ? (
+          <AlertModal
+            isOpen
+            title="Delete Endpoints?"
+            message="Are you sure you want to delete the selected endpoints?"
+            extraMessage="Deleting a Coriolis Cloud Endpoint is permanent!"
+            onConfirmation={() => { this.deleteSelectedEndpoints() }}
+            onRequestClose={() => { this.setState({ showDeleteEndpointsModal: false }) }}
+          />
+        ) : null}
         <Modal
         <Modal
           isOpen={this.state.showChooseProviderModal}
           isOpen={this.state.showChooseProviderModal}
           title="New Cloud Endpoint"
           title="New Cloud Endpoint"
@@ -342,7 +321,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
               projects={projectStore.projects}
               projects={projectStore.projects}
               selectedProjectId={selectedProjectId}
               selectedProjectId={selectedProjectId}
               onCancelClick={() => { this.setState({ showDuplicateModal: false }) }}
               onCancelClick={() => { this.setState({ showDuplicateModal: false }) }}
-              onDuplicateClick={projectId => { this.handleDuplicate(projectId) }}
+              onDuplicateClick={projectId => { this.duplicate(projectId) }}
             />
             />
           </Modal>
           </Modal>
         ) : null}
         ) : null}

+ 57 - 68
src/components/pages/MigrationsPage/MigrationsPage.jsx

@@ -35,25 +35,22 @@ import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 import notificationStore from '../../../stores/NotificationStore'
 import configLoader from '../../../utils/Config'
 import configLoader from '../../../utils/Config'
 
 
-const Wrapper = styled.div``
+import Palette from '../../styleUtils/Palette'
 
 
-const BulkActions = [
-  { label: 'Cancel', value: 'cancel' },
-  { label: 'Delete', value: 'delete' },
-]
+const Wrapper = styled.div``
 
 
 type State = {
 type State = {
-  showDeleteMigrationConfirmation: boolean,
-  showCancelMigrationConfirmation: boolean,
-  confirmationItems: ?MainItem[],
+  selectedMigrations: MainItem[],
   modalIsOpen: boolean,
   modalIsOpen: boolean,
+  showDeleteMigrationModal: boolean,
+  showCancelMigrationModal: boolean,
 }
 }
 @observer
 @observer
 class MigrationsPage extends React.Component<{ history: any }, State> {
 class MigrationsPage extends React.Component<{ history: any }, State> {
   state = {
   state = {
-    showDeleteMigrationConfirmation: false,
-    showCancelMigrationConfirmation: false,
-    confirmationItems: null,
+    showDeleteMigrationModal: false,
+    showCancelMigrationModal: false,
+    selectedMigrations: [],
     modalIsOpen: false,
     modalIsOpen: false,
   }
   }
 
 
@@ -107,53 +104,26 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
     }
     }
   }
   }
 
 
-  handleActionChange(confirmationItems: MainItem[], action: string) {
-    if (action === 'cancel') {
-      this.setState({
-        showCancelMigrationConfirmation: true,
-        confirmationItems,
-      })
-    } else if (action === 'delete') {
-      this.setState({
-        showDeleteMigrationConfirmation: true,
-        confirmationItems,
-      })
-    }
-  }
-
-  handleCancelMigrationConfirmation() {
-    if (!this.state.confirmationItems) {
-      return
-    }
-    this.state.confirmationItems.forEach(migration => {
-      migrationStore.cancel(migration.id)
-    })
-    notificationStore.alert('Canceling migrations')
-    this.handleCloseCancelMigration()
-  }
-
-  handleCloseCancelMigration() {
-    this.setState({
-      showCancelMigrationConfirmation: false,
-      confirmationItems: null,
-    })
+  getStatus(migrationId: string): string {
+    let migration = migrationStore.migrations.find(m => m.id === migrationId)
+    return migration ? migration.status : ''
   }
   }
 
 
-  handleCloseDeleteMigrationConfirmation() {
-    this.setState({
-      showDeleteMigrationConfirmation: false,
-      confirmationItems: null,
+  deleteSelectedMigrations() {
+    this.state.selectedMigrations.forEach(migration => {
+      migrationStore.delete(migration.id)
     })
     })
+    this.setState({ showDeleteMigrationModal: false })
   }
   }
 
 
-  handleDeleteMigrationConfirmation() {
-    if (!this.state.confirmationItems) {
-      return
-    }
-    this.state.confirmationItems.forEach(migration => {
-      migrationStore.delete(migration.id)
+  cancelSelectedMigrations() {
+    this.state.selectedMigrations.forEach(migration => {
+      if (this.getStatus(migration.id) === 'RUNNING') {
+        migrationStore.cancel(migration.id)
+      }
     })
     })
-    this.handleCloseDeleteMigrationConfirmation()
+    notificationStore.alert('Canceling migrations')
+    this.setState({ showCancelMigrationModal: false })
   }
   }
 
 
   handleEmptyListButtonClick() {
   handleEmptyListButtonClick() {
@@ -208,19 +178,19 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
   }
   }
 
 
   render() {
   render() {
-    const renderAlert = () => {
-      const isDelete = this.state.showDeleteMigrationConfirmation
-      const props = {
-        isOpen: this.state.showCancelMigrationConfirmation || this.state.showDeleteMigrationConfirmation,
-        title: `${isDelete ? 'Delete' : 'Cancel'} Migrations?`,
-        message: `Are you sure you want to ${isDelete ? 'delete' : 'cancel'} the selected migrations?`,
-        extraMessage: `${isDelete ? 'Deleting' : 'Canceling'} a Coriolis Migration is permanent!`,
-        onConfirmation: () => { isDelete ? this.handleDeleteMigrationConfirmation() : this.handleCancelMigrationConfirmation() },
-        onRequestClose: () => { isDelete ? this.handleCloseDeleteMigrationConfirmation() : this.handleCloseCancelMigration() },
-      }
-
-      return <AlertModal {...props} />
-    }
+    let atLeaseOneIsRunning = false
+    this.state.selectedMigrations.forEach(migration => {
+      atLeaseOneIsRunning = atLeaseOneIsRunning || this.getStatus(migration.id) === 'RUNNING'
+    })
+    const BulkActions = [{
+      label: 'Cancel',
+      disabled: !atLeaseOneIsRunning,
+      action: () => { this.setState({ showCancelMigrationModal: true }) },
+    }, {
+      label: 'Delete Migration',
+      color: Palette.alert,
+      action: () => { this.setState({ showDeleteMigrationModal: true }) },
+    }]
 
 
     return (
     return (
       <Wrapper>
       <Wrapper>
@@ -234,9 +204,9 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
               items={migrationStore.migrations}
               items={migrationStore.migrations}
               onItemClick={item => { this.handleItemClick(item) }}
               onItemClick={item => { this.handleItemClick(item) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
-              actions={BulkActions}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
-              onActionChange={(items, action) => { this.handleActionChange(items, action) }}
+              onSelectedItemsChange={selectedMigrations => { this.setState({ selectedMigrations }) }}
+              dropdownActions={BulkActions}
               renderItemComponent={options =>
               renderItemComponent={options =>
                 (<MainListItem
                 (<MainListItem
                   {...options}
                   {...options}
@@ -270,7 +240,26 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
             />
             />
           }
           }
         />
         />
-        {renderAlert()}
+        {this.state.showDeleteMigrationModal ? (
+          <AlertModal
+            isOpen
+            title="Delete Selected Migrations?"
+            message="Are you sure you want to delete the selected migrations?"
+            extraMessage="Deleting a Coriolis Migration is permanent!"
+            onConfirmation={() => { this.deleteSelectedMigrations() }}
+            onRequestClose={() => { this.setState({ showDeleteMigrationModal: false }) }}
+          />
+        ) : null}
+        {this.state.showCancelMigrationModal ? (
+          <AlertModal
+            isOpen
+            title="Cancel Selected Migrations?"
+            message="Are you sure you want to cancel the selected migrations?"
+            extraMessage="Canceling a Coriolis Migration is permanent!"
+            onConfirmation={() => { this.cancelSelectedMigrations() }}
+            onRequestClose={() => { this.setState({ showCancelMigrationModal: false }) }}
+          />
+        ) : null}
       </Wrapper>
       </Wrapper>
     )
     )
   }
   }

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

@@ -26,8 +26,8 @@ import Modal from '../../molecules/Modal'
 import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
 import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
 import AlertModal from '../../organisms/AlertModal'
 import AlertModal from '../../organisms/AlertModal'
 import EditReplica from '../../organisms/EditReplica'
 import EditReplica from '../../organisms/EditReplica'
-
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
 import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
+
 import type { MainItem } from '../../../types/MainItem'
 import type { MainItem } from '../../../types/MainItem'
 import type { Execution } from '../../../types/Execution'
 import type { Execution } from '../../../types/Execution'
 import type { Schedule } from '../../../types/Schedule'
 import type { Schedule } from '../../../types/Schedule'

+ 163 - 40
src/components/pages/ReplicasPage/ReplicasPage.jsx

@@ -24,38 +24,50 @@ import FilterList from '../../organisms/FilterList'
 import PageHeader from '../../organisms/PageHeader'
 import PageHeader from '../../organisms/PageHeader'
 import AlertModal from '../../organisms/AlertModal'
 import AlertModal from '../../organisms/AlertModal'
 import MainListItem from '../../molecules/MainListItem'
 import MainListItem from '../../molecules/MainListItem'
+import Modal from '../../molecules/Modal'
+import ReplicaExecutionOptions from '../../organisms/ReplicaExecutionOptions'
+import ReplicaMigrationOptions from '../../organisms/ReplicaMigrationOptions'
+
 import type { MainItem } from '../../../types/MainItem'
 import type { MainItem } from '../../../types/MainItem'
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
+import type { Field } from '../../../types/Field'
 
 
 import replicaItemImage from './images/replica.svg'
 import replicaItemImage from './images/replica.svg'
 import replicaLargeImage from './images/replica-large.svg'
 import replicaLargeImage from './images/replica-large.svg'
 
 
 import projectStore from '../../../stores/ProjectStore'
 import projectStore from '../../../stores/ProjectStore'
 import replicaStore from '../../../stores/ReplicaStore'
 import replicaStore from '../../../stores/ReplicaStore'
+import migrationStore from '../../../stores/MigrationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import endpointStore from '../../../stores/EndpointStore'
 import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 import notificationStore from '../../../stores/NotificationStore'
+
+import Palette from '../../styleUtils/Palette'
 import configLoader from '../../../utils/Config'
 import configLoader from '../../../utils/Config'
 
 
 const Wrapper = styled.div``
 const Wrapper = styled.div``
 
 
 const SCHEDULE_POLL_TIMEOUT = 10000
 const SCHEDULE_POLL_TIMEOUT = 10000
 
 
-const BulkActions = [
-  { label: 'Execute', value: 'execute' },
-  { label: 'Delete', value: 'delete' },
-]
-
 type State = {
 type State = {
-  showDeleteReplicaConfirmation: boolean,
-  confirmationItems: ?MainItem[],
   modalIsOpen: boolean,
   modalIsOpen: boolean,
+  selectedReplicas: MainItem[],
+  showCancelExecutionModal: boolean,
+  showExecutionOptionsModal: boolean,
+  showCreateMigrationsModal: boolean,
+  showDeleteDisksModal: boolean,
+  showDeleteReplicasModal: boolean,
 }
 }
 @observer
 @observer
 class ReplicasPage extends React.Component<{ history: any }, State> {
 class ReplicasPage extends React.Component<{ history: any }, State> {
   state = {
   state = {
-    showDeleteReplicaConfirmation: false,
-    confirmationItems: null,
     modalIsOpen: false,
     modalIsOpen: false,
+    selectedReplicas: [],
+    showCancelExecutionModal: false,
+    showCreateMigrationsModal: false,
+    showExecutionOptionsModal: false,
+    showDeleteDisksModal: false,
+    showDeleteReplicasModal: false,
   }
   }
 
 
   pollTimeout: TimeoutID
   pollTimeout: TimeoutID
@@ -119,35 +131,71 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
     }
     }
   }
   }
 
 
-  handleActionChange(items: MainItem[], action: string) {
-    if (action === 'execute') {
-      items.forEach(replica => {
-        replicaStore.execute(replica.id)
-      })
-      notificationStore.alert('Executing replicas')
-    } else if (action === 'delete') {
-      this.setState({
-        showDeleteReplicaConfirmation: true,
-        confirmationItems: items,
-      })
-    }
+  executeSelectedReplicas(fields: Field[]) {
+    this.state.selectedReplicas.forEach(replica => {
+      let actualReplica = replicaStore.replicas.find(r => r.id === replica.id)
+      if (actualReplica && this.isExecuteEnabled(actualReplica)) {
+        replicaStore.execute(replica.id, fields)
+      }
+    })
+    notificationStore.alert('Executing selected replicas')
+    this.setState({ showExecutionOptionsModal: false })
   }
   }
 
 
-  handleCloseDeleteReplicaConfirmation() {
-    this.setState({
-      showDeleteReplicaConfirmation: false,
-      confirmationItems: null,
+  migrateSelectedReplicas(fields: Field[]) {
+    notificationStore.alert('Creating migrations from selected replicas')
+    Promise.all(this.state.selectedReplicas.map(replica => migrationStore.migrateReplica(replica.id, fields))).then(() => {
+      notificationStore.alert('Migrations successfully created from replicas.', 'success')
+      this.props.history.push('/migrations')
     })
     })
+    this.setState({ showCreateMigrationsModal: false })
   }
   }
 
 
-  handleDeleteReplicaConfirmation() {
-    if (!this.state.confirmationItems) {
-      return
+  deleteSelectedReplicasDisks() {
+    this.state.selectedReplicas.forEach(replica => {
+      replicaStore.deleteDisks(replica.id)
+    })
+    this.setState({ showDeleteDisksModal: false })
+    notificationStore.alert('Deleting selected replicas\' disks')
+  }
+
+  cancelExecutions() {
+    this.state.selectedReplicas.forEach(replica => {
+      let actualReplica = replicaStore.replicas.find(r => r.id === replica.id)
+      let lastExecution = actualReplica && actualReplica.executions[actualReplica.executions.length - 1]
+      if (actualReplica && lastExecution && lastExecution.status === 'RUNNING') {
+        replicaStore.cancelExecution(replica.id, lastExecution.id)
+      }
+    })
+    this.setState({ showCancelExecutionModal: false })
+  }
+
+  getStatus(replica: ?MainItem): string {
+    if (!replica) {
+      return ''
+    }
+    let usableReplica = replica
+    if (usableReplica.executions && usableReplica.executions.length) {
+      return usableReplica.executions[usableReplica.executions.length - 1].status
     }
     }
-    this.state.confirmationItems.forEach(replica => {
+    return ''
+  }
+
+  isExecuteEnabled(replica: ?MainItem): boolean {
+    if (!replica) {
+      return false
+    }
+    let usableReplica = replica
+    let originEndpoint = endpointStore.endpoints.find(e => e.id === usableReplica.origin_endpoint_id)
+    let targetEndpoint = endpointStore.endpoints.find(e => e.id === usableReplica.destination_endpoint_id)
+    return Boolean(originEndpoint && targetEndpoint && this.getStatus(usableReplica) !== 'RUNNING')
+  }
+
+  deleteSelectedReplicas() {
+    this.state.selectedReplicas.forEach(replica => {
       replicaStore.delete(replica.id)
       replicaStore.delete(replica.id)
     })
     })
-    this.handleCloseDeleteReplicaConfirmation()
+    this.setState({ showDeleteReplicasModal: false })
   }
   }
 
 
   handleEmptyListButtonClick() {
   handleEmptyListButtonClick() {
@@ -226,6 +274,35 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
   }
   }
 
 
   render() {
   render() {
+    let atLeastOneHasExecuteEnabled = false
+    let atLeaseOneIsRunning = false
+    this.state.selectedReplicas.forEach(replica => {
+      let storeReplica = replicaStore.replicas.find(r => r.id === replica.id)
+      atLeastOneHasExecuteEnabled = atLeastOneHasExecuteEnabled || this.isExecuteEnabled(storeReplica)
+      atLeaseOneIsRunning = atLeaseOneIsRunning || this.getStatus(storeReplica) === 'RUNNING'
+    })
+
+    const BulkActions: DropdownAction[] = [{
+      label: 'Execute',
+      action: () => { this.setState({ showExecutionOptionsModal: true }) },
+      disabled: !atLeastOneHasExecuteEnabled,
+    }, {
+      label: 'Cancel',
+      disabled: !atLeaseOneIsRunning,
+      action: () => { this.setState({ showCancelExecutionModal: true }) },
+    }, {
+      label: 'Create Migrations',
+      color: Palette.primary,
+      action: () => { this.setState({ showCreateMigrationsModal: true }) },
+    }, {
+      label: 'Delete Disks',
+      action: () => { this.setState({ showDeleteDisksModal: true }) },
+    }, {
+      label: 'Delete Replicas',
+      color: Palette.alert,
+      action: () => { this.setState({ showDeleteReplicasModal: true }) },
+    }]
+
     return (
     return (
       <Wrapper>
       <Wrapper>
         <MainTemplate
         <MainTemplate
@@ -236,11 +313,11 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
               selectionLabel="replica"
               selectionLabel="replica"
               loading={replicaStore.loading}
               loading={replicaStore.loading}
               items={replicaStore.replicas}
               items={replicaStore.replicas}
+              dropdownActions={BulkActions}
               onItemClick={item => { this.handleItemClick(item) }}
               onItemClick={item => { this.handleItemClick(item) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
-              actions={BulkActions}
-              onActionChange={(items, action) => { this.handleActionChange(items, action) }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              onSelectedItemsChange={selectedReplicas => { this.setState({ selectedReplicas }) }}
               renderItemComponent={options =>
               renderItemComponent={options =>
                 (<MainListItem
                 (<MainListItem
                   {...options}
                   {...options}
@@ -274,14 +351,60 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
             />
             />
           }
           }
         />
         />
-        <AlertModal
-          isOpen={this.state.showDeleteReplicaConfirmation}
-          title="Delete Replicas?"
-          message="Are you sure you want to delete the selected replicas?"
-          extraMessage="Deleting a Coriolis Replica is permanent!"
-          onConfirmation={() => { this.handleDeleteReplicaConfirmation() }}
-          onRequestClose={() => { this.handleCloseDeleteReplicaConfirmation() }}
-        />
+        {this.state.showDeleteReplicasModal ? (
+          <AlertModal
+            isOpen
+            title="Delete Selected Replicas?"
+            message="Are you sure you want to delete the selected replicas?"
+            extraMessage="Deleting a Coriolis Replica is permanent!"
+            onConfirmation={() => { this.deleteSelectedReplicas() }}
+            onRequestClose={() => { this.setState({ showDeleteReplicasModal: false }) }}
+          />
+        ) : null}
+        {this.state.showCancelExecutionModal ? (
+          <AlertModal
+            isOpen
+            title="Cancel Executions?"
+            message="Are you sure you want to cancel the selected replicas executions?"
+            extraMessage=" "
+            onConfirmation={() => { this.cancelExecutions() }}
+            onRequestClose={() => { this.setState({ showCancelExecutionModal: false }) }}
+          />
+        ) : null}
+        {this.state.showExecutionOptionsModal ? (
+          <Modal
+            isOpen
+            title="New Executions for Selected Replicas"
+            onRequestClose={() => { this.setState({ showExecutionOptionsModal: false }) }}
+          >
+            <ReplicaExecutionOptions
+              onCancelClick={() => { this.setState({ showExecutionOptionsModal: false }) }}
+              onExecuteClick={fields => { this.executeSelectedReplicas(fields) }}
+            />
+          </Modal>
+        ) : null}
+        {this.state.showCreateMigrationsModal ? (
+          <Modal
+            isOpen
+            title="Create Migrations from Selected Replicas"
+            onRequestClose={() => { this.setState({ showCreateMigrationsModal: false }) }}
+          >
+            <ReplicaMigrationOptions
+              onCancelClick={() => { this.setState({ showCreateMigrationsModal: false }) }}
+              onMigrateClick={options => { this.migrateSelectedReplicas(options) }}
+            />
+          </Modal>
+        ) : null}
+        {this.state.showDeleteDisksModal ? (
+          <AlertModal
+            isOpen
+            title="Delete Selected Replicas Disks?"
+            message="Are you sure you want to delete the selected replicas' disks?"
+            extraMessage="Deleting Coriolis Replica Disks is permanent!"
+            onConfirmation={() => { this.deleteSelectedReplicasDisks() }}
+            onRequestClose={() => { this.setState({ showDeleteDisksModal: false }) }}
+          />
+        ) : null}
       </Wrapper>
       </Wrapper>
     )
     )
   }
   }