فهرست منبع

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 سال پیش
والد
کامیت
63afe7feec

+ 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>
     )
   }