Explorar o código

Merge pull request #366 from smiclea/bulk-actions

Update Bulk Actions button in lists pages
Dorin Paslaru %!s(int64=7) %!d(string=hai) anos
pai
achega
ec5026b632

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

@@ -88,9 +88,19 @@ class ActionDropdown extends React.Component<Props, State> {
   listRef: HTMLElement
   tipRef: HTMLElement
   buttonRef: HTMLElement
+  buttonRect: ClientRect
 
   componentDidMount() {
     window.addEventListener('mousedown', this.handlePageClick, false)
+    if (this.buttonRef) {
+      this.buttonRect = this.buttonRef.getBoundingClientRect()
+    }
+  }
+
+  componentWillUpdate() {
+    if (this.buttonRef) {
+      this.buttonRect = this.buttonRef.getBoundingClientRect()
+    }
   }
 
   componentDidUpdate() {
@@ -107,7 +117,6 @@ class ActionDropdown extends React.Component<Props, State> {
     }
     let tipHeight = this.tipRef.offsetHeight / 2
     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
     let scrollOffset = 0
@@ -115,8 +124,8 @@ class ActionDropdown extends React.Component<Props, State> {
       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

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

@@ -20,12 +20,14 @@ import styled from 'styled-components'
 
 import Checkbox from '../../atoms/Checkbox'
 import SearchInput from '../SearchInput'
-import Dropdown from '../Dropdown'
+import ActionDropdown from '../../molecules/ActionDropdown'
 import ReloadButton from '../../atoms/ReloadButton'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
+
 const Wrapper = styled.div`
   display: flex;
   align-items: center;
@@ -82,14 +84,13 @@ type Props = {
   onSearchChange: (value: string) => void,
   searchValue: string,
   onSelectAllChange: (checked: boolean) => void,
-  onActionChange: (action: string) => void,
-  actions?: DictItem[],
   selectedValue: string,
   selectionInfo: { total: number, selected: number, label: string },
   selectAllSelected: ?boolean,
   items: DictItem[],
   customFilterComponent?: React.Node,
   searchValue?: string,
+  dropdownActions: ?DropdownAction[],
 }
 @observer
 class MainListFilter extends React.Component<Props> {
@@ -102,7 +103,7 @@ class MainListFilter extends React.Component<Props> {
     }
 
     return (
-      <FilterGroup noMargin={!this.props.actions || this.props.actions.length === 0}>
+      <FilterGroup noMargin={!this.props.dropdownActions || this.props.dropdownActions.length === 0}>
         {renderCustomComponent()}
         {this.props.items.map(item => {
           return (
@@ -130,19 +131,20 @@ class MainListFilter extends React.Component<Props> {
           {this.props.selectionInfo.selected} of {this.props.selectionInfo.total}&nbsp;
           {this.props.selectionInfo.label}(s) selected
         </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>
     )
   }
 
   render() {
     let renderCheckbox = () => {
-      if (this.props.actions && this.props.actions.length > 0) {
+      if (this.props.dropdownActions && this.props.dropdownActions.length > 0) {
         return (
           <Checkbox
             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' },
 ]
 
-let actions = [
-  { label: 'Action 1', value: 'action-1' },
-  { label: 'Action 2', value: 'action-2' },
-]
-
 let selectionInfo = { selected: 2, total: 7, label: 'items' }
 
 describe('MainListFilter Component', () => {
   it('renders given items', () => {
-    let wrapper = wrap({ items, actions, selectionInfo })
+    let wrapper = wrap({ items, selectionInfo })
     expect(wrapper.findPartialId('filterItem').length).toBe(items.length)
     items.forEach(item => {
       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', () => {
-    let wrapper = wrap({ items, actions, selectionInfo })
+    let wrapper = wrap({ items, selectionInfo })
     expect(wrapper.findText('selectionText')).toBe('2 of 7 items(s) selected')
   })
 
   it('handles reload click', () => {
     let onReloadButtonClick = sinon.spy()
-    let wrapper = wrap({ items, actions, selectionInfo, onReloadButtonClick })
+    let wrapper = wrap({ items, selectionInfo, onReloadButtonClick })
     wrapper.find('reloadButton').simulate('click')
     expect(onReloadButtonClick.calledOnce).toBe(true)
   })
 
   it('handles item click with correct args', () => {
     let onFilterItemClick = sinon.spy()
-    let wrapper = wrap({ items, actions, selectionInfo, onFilterItemClick })
+    let wrapper = wrap({ items, selectionInfo, onFilterItemClick })
     wrapper.find(`filterItem-${items[2].value}`).simulate('click')
     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 MainListFilter from '../../molecules/MainListFilter'
+
+import type { Action as DropdownAction } from '../../molecules/ActionDropdown'
 import type { ItemComponentProps } from '../../organisms/MainList'
 import MainList from '../../organisms/MainList'
 
@@ -33,14 +35,14 @@ const Wrapper = styled.div`
 type DictItem = { value: string, label: string }
 type Props = {
   items: any[],
-  actions?: DictItem[],
+  dropdownActions?: DropdownAction[],
   loading: boolean,
   onReloadButtonClick: () => void,
   onItemClick: (item: any) => void,
-  onActionChange?: (selectedItems: any[], actionValue: string) => void,
   selectionLabel: string,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   itemFilterFunction: (item: any, filterStatus?: ?string, filterState?: string) => boolean,
+  onSelectedItemsChange?: (items: any[]) => void,
   filterItems: DictItem[],
   emptyListImage?: ?string,
   emptyListMessage?: string,
@@ -78,6 +80,7 @@ class FilterList extends React.Component<Props, State> {
         filterText: '',
         selectedItems: [],
       })
+      this.props.onSelectedItemsChange && this.props.onSelectedItemsChange([])
       return
     }
 
@@ -101,6 +104,7 @@ class FilterList extends React.Component<Props, State> {
       filterStatus: item.value,
       items,
     })
+    this.props.onSelectedItemsChange && this.props.onSelectedItemsChange(selectedItems)
   }
 
   handleSearchChange(text: string) {
@@ -118,6 +122,7 @@ class FilterList extends React.Component<Props, State> {
     }
 
     this.setState({ selectedItems, selectAllSelected: false })
+    this.props.onSelectedItemsChange && this.props.onSelectedItemsChange(selectedItems)
   }
 
   handleSelectAllChange(selected: boolean) {
@@ -127,10 +132,7 @@ class FilterList extends React.Component<Props, State> {
     }
 
     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[] {
@@ -161,8 +163,7 @@ class FilterList extends React.Component<Props, State> {
             label: this.props.selectionLabel,
           }}
           items={this.props.filterItems}
-          actions={this.props.actions}
-          onActionChange={action => { this.handleActionChange(action) }}
+          dropdownActions={this.props.dropdownActions || []}
           data-test-id="filterList-filter"
         />
         <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 { shallow } from 'enzyme'
-import sinon from 'sinon'
 import TW from '../../../utils/TestWrapper'
 import FilterList from '.'
 
@@ -43,9 +42,7 @@ let actions = [{ label: 'action', value: 'action' }]
 
 let itemFilterFunction = (item, filterStatus, filterText) => {
   if (
-    // $FlowIgnore
     (filterStatus !== 'all' && item.id.indexOf(filterStatus) === -1) ||
-    // $FlowIgnore
     (item.label.toLowerCase().indexOf(filterText) === -1)
   ) {
     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')[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 replicaStore from '../../../stores/ReplicaStore'
 import providerStore from '../../../stores/ProviderStore'
+import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
+
 import LabelDictionary from '../../../utils/LabelDictionary'
 import configLoader from '../../../utils/Config'
-import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
+import Palette from '../../styleUtils/Palette'
 
 const Wrapper = styled.div``
 
-const BulkActions = [
-  { label: 'Delete', value: 'delete' },
-  { label: 'Duplicate', value: 'duplicate' },
-]
-
 type State = {
-  showDeleteEndpointsConfirmation: boolean,
-  confirmationItems: ?EndpointType[],
+  selectedEndpoints: EndpointType[],
   showChooseProviderModal: boolean,
   showEndpointModal: boolean,
   providerType: ?string,
   showEndpointsInUseModal: boolean,
   modalIsOpen: boolean,
+  showDeleteEndpointsModal: boolean,
   showDuplicateModal: boolean,
   duplicating: boolean,
 }
 @observer
 class EndpointsPage extends React.Component<{ history: any }, State> {
   state = {
-    showDeleteEndpointsConfirmation: false,
-    confirmationItems: null,
     showChooseProviderModal: false,
     showEndpointModal: false,
     providerType: null,
@@ -71,6 +66,8 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     modalIsOpen: false,
     showDuplicateModal: false,
     duplicating: false,
+    showDeleteEndpointsModal: false,
+    selectedEndpoints: [],
   }
 
   pollTimeout: TimeoutID
@@ -101,11 +98,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     return types
   }
 
-  getEndpointUsage(endpoint: EndpointType) {
+  getEndpointUsage(endpointId: string) {
     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(
-      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 }
   }
@@ -127,41 +124,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     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 })
 
     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({
       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() {
@@ -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) {
     if (this.state.modalIsOpen || this.stopPolling) {
       return
@@ -248,6 +219,16 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
   render() {
     let items: any = endpointStore.endpoints
     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 (
       <Wrapper>
         <MainTemplate
@@ -263,18 +244,14 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
                 let endpoint: EndpointType = anyItem
                 this.handleItemClick(endpoint)
               }}
+              dropdownActions={BulkActions}
+              onSelectedItemsChange={selectedEndpoints => { this.setState({ selectedEndpoints }) }}
               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)}
               renderItemComponent={options =>
                 (<EndpointListItem
                   {...options}
-                  getUsage={endpoint => this.getEndpointUsage(endpoint)}
+                  getUsage={endpoint => this.getEndpointUsage(endpoint.id)}
                 />)
               }
               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
           isOpen={this.state.showChooseProviderModal}
           title="New Cloud Endpoint"
@@ -342,7 +321,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
               projects={projectStore.projects}
               selectedProjectId={selectedProjectId}
               onCancelClick={() => { this.setState({ showDuplicateModal: false }) }}
-              onDuplicateClick={projectId => { this.handleDuplicate(projectId) }}
+              onDuplicateClick={projectId => { this.duplicate(projectId) }}
             />
           </Modal>
         ) : null}

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

@@ -35,25 +35,22 @@ import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
 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 = {
-  showDeleteMigrationConfirmation: boolean,
-  showCancelMigrationConfirmation: boolean,
-  confirmationItems: ?MainItem[],
+  selectedMigrations: MainItem[],
   modalIsOpen: boolean,
+  showDeleteMigrationModal: boolean,
+  showCancelMigrationModal: boolean,
 }
 @observer
 class MigrationsPage extends React.Component<{ history: any }, State> {
   state = {
-    showDeleteMigrationConfirmation: false,
-    showCancelMigrationConfirmation: false,
-    confirmationItems: null,
+    showDeleteMigrationModal: false,
+    showCancelMigrationModal: false,
+    selectedMigrations: [],
     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() {
@@ -208,19 +178,19 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
   }
 
   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 (
       <Wrapper>
@@ -234,9 +204,9 @@ class MigrationsPage extends React.Component<{ history: any }, State> {
               items={migrationStore.migrations}
               onItemClick={item => { this.handleItemClick(item) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
-              actions={BulkActions}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
-              onActionChange={(items, action) => { this.handleActionChange(items, action) }}
+              onSelectedItemsChange={selectedMigrations => { this.setState({ selectedMigrations }) }}
+              dropdownActions={BulkActions}
               renderItemComponent={options =>
                 (<MainListItem
                   {...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>
     )
   }

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

@@ -26,8 +26,8 @@ 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'
 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 AlertModal from '../../organisms/AlertModal'
 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 { Action as DropdownAction } from '../../molecules/ActionDropdown'
+import type { Field } from '../../../types/Field'
 
 import replicaItemImage from './images/replica.svg'
 import replicaLargeImage from './images/replica-large.svg'
 
 import projectStore from '../../../stores/ProjectStore'
 import replicaStore from '../../../stores/ReplicaStore'
+import migrationStore from '../../../stores/MigrationStore'
 import scheduleStore from '../../../stores/ScheduleStore'
 import endpointStore from '../../../stores/EndpointStore'
 import notificationStore from '../../../stores/NotificationStore'
+
+import Palette from '../../styleUtils/Palette'
 import configLoader from '../../../utils/Config'
 
 const Wrapper = styled.div``
 
 const SCHEDULE_POLL_TIMEOUT = 10000
 
-const BulkActions = [
-  { label: 'Execute', value: 'execute' },
-  { label: 'Delete', value: 'delete' },
-]
-
 type State = {
-  showDeleteReplicaConfirmation: boolean,
-  confirmationItems: ?MainItem[],
   modalIsOpen: boolean,
+  selectedReplicas: MainItem[],
+  showCancelExecutionModal: boolean,
+  showExecutionOptionsModal: boolean,
+  showCreateMigrationsModal: boolean,
+  showDeleteDisksModal: boolean,
+  showDeleteReplicasModal: boolean,
 }
 @observer
 class ReplicasPage extends React.Component<{ history: any }, State> {
   state = {
-    showDeleteReplicaConfirmation: false,
-    confirmationItems: null,
     modalIsOpen: false,
+    selectedReplicas: [],
+    showCancelExecutionModal: false,
+    showCreateMigrationsModal: false,
+    showExecutionOptionsModal: false,
+    showDeleteDisksModal: false,
+    showDeleteReplicasModal: false,
   }
 
   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)
     })
-    this.handleCloseDeleteReplicaConfirmation()
+    this.setState({ showDeleteReplicasModal: false })
   }
 
   handleEmptyListButtonClick() {
@@ -226,6 +274,35 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
   }
 
   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 (
       <Wrapper>
         <MainTemplate
@@ -236,11 +313,11 @@ class ReplicasPage extends React.Component<{ history: any }, State> {
               selectionLabel="replica"
               loading={replicaStore.loading}
               items={replicaStore.replicas}
+              dropdownActions={BulkActions}
               onItemClick={item => { this.handleItemClick(item) }}
               onReloadButtonClick={() => { this.handleReloadButtonClick() }}
-              actions={BulkActions}
-              onActionChange={(items, action) => { this.handleActionChange(items, action) }}
               itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              onSelectedItemsChange={selectedReplicas => { this.setState({ selectedReplicas }) }}
               renderItemComponent={options =>
                 (<MainListItem
                   {...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>
     )
   }