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

Merge pull request #304 from smiclea/wizard-instances

Load instances in chunks in the background
Dorin Paslaru 7 лет назад
Родитель
Сommit
b1b96207d0

+ 5 - 1
src/components/atoms/Arrow/Arrow.jsx

@@ -22,6 +22,7 @@ import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
 import arrowImage from './images/arrow.js'
+import arrowThickImage from './images/arrow-thick.js'
 
 const getOrientation = props => `
   ${props.orientation === 'left' ? 'transform: rotate(180deg);' : ''}
@@ -47,6 +48,8 @@ type Props = {
   orientation: 'left' | 'down' | 'up' | 'right',
   opacity: number,
   disabled?: boolean,
+  color?: string,
+  thick?: boolean,
 }
 
 @observer
@@ -58,12 +61,13 @@ class Arrow extends React.Component<Props> {
 
   render() {
     let color = this.props.primary ? Palette.primary : Palette.grayscale[4]
+    color = this.props.color || color
     color = this.props.disabled ? Palette.grayscale[0] : color
     return (
       <Wrapper
         {...this.props}
         dangerouslySetInnerHTML={
-          { __html: arrowImage(color) }
+          { __html: this.props.thick ? arrowThickImage(color) : arrowImage(color) }
         }
       />
     )

+ 11 - 0
src/components/atoms/Arrow/images/arrow-thick.js

@@ -0,0 +1,11 @@
+export default color => `<?xml version="1.0" encoding="UTF-8"?>
+<svg width="8px" height="16px" viewBox="0 0 8 13" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 52.3 (67297) - http://www.bohemiancoding.com/sketch -->
+    <title>Rectangle Copy</title>
+    <desc>Created with Sketch.</desc>
+    <g id="Symbols" transform="translateY(-1px)" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
+        <g id="Icon/Chevron/Black" transform="translate(-5.000000, -1.000000)" stroke="${color}" stroke-width="1.5">
+            <polyline id="Rectangle-Copy" transform="translate(6.000000, 7.500000) rotate(315.000000) translate(-6.000000, -7.500000) " points="9.8890873 3.6109127 9.8890873 11.3890873 2.1109127 11.3890873"></polyline>
+        </g>
+    </g>
+</svg>`

+ 6 - 6
src/components/atoms/Arrow/images/arrow.js

@@ -13,20 +13,20 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
 const arrow = color => `<?xml version="1.0" encoding="UTF-8"?>
-<svg width="7px" height="12px" viewBox="0 0 7 12" 
+<svg width="7px" height="12px" viewBox="0 0 7 12"
 version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
     <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
     <title>Chevron-Blue Copy</title>
     <desc>Created with Sketch.</desc>
     <defs></defs>
     <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"
-     stroke-linecap="round" stroke-linejoin="round">
+     stroke-linecap="round" stroke-linejoin="round" transform="rotate(90deg)">
         <g id="202-Replica-Executions" transform="translate(-1197.000000, -194.000000)" stroke="${color}">
-            <g id="Icon/Chevron/Grey" 
-            transform="translate(1200.000000, 200.000000) rotate(-90.000000) 
+            <g id="Icon/Chevron/Grey"
+            transform="translate(1200.000000, 200.000000) rotate(-90.000000)
             translate(-1200.000000, -200.000000) translate(1192.000000, 192.000000)">
-                <polyline id="Rectangle-Copy" transform="translate(8.000000, 5.500000) 
-                rotate(-315.000000) translate(-8.000000, -5.500000) " 
+                <polyline id="Rectangle-Copy" transform="translate(8.000000, 5.500000)
+                rotate(-315.000000) translate(-8.000000, -5.500000) "
                 points="11.8890873 1.6109127 11.8890873 9.3890873 4.1109127 9.3890873"></polyline>
             </g>
         </g>

+ 52 - 0
src/components/atoms/HorizontalLoading/HorizontalLoading.jsx

@@ -0,0 +1,52 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  position: relative;
+  height: 2px;
+  overflow: hidden;
+`
+const Loader = styled.div`
+  width: 8px;
+  height: 2px;
+  background: ${Palette.primary};
+  position: absolute;
+  animation: move 1s linear infinite;
+  @keyframes move {
+    0% {left: -8px;}
+    100% {left: 100%;}
+  }
+`
+type Props = {
+  style?: any,
+  'data-test-id'?: string,
+}
+class HorizontalLoading extends React.Component<Props> {
+  render() {
+    return (
+      <Wrapper style={this.props.style} data-test-id={this.props['data-test-id'] || 'horizontalLoading'}>
+        <Loader />
+      </Wrapper>
+    )
+  }
+}
+
+export default HorizontalLoading

+ 6 - 0
src/components/atoms/HorizontalLoading/package.json

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

+ 24 - 0
src/components/atoms/HorizontalLoading/story.jsx

@@ -0,0 +1,24 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { storiesOf } from '@storybook/react'
+import HorizontalLoading from './HorizontalLoading'
+
+storiesOf('HorizontalLoading', module)
+  .add('default', () => (
+    <HorizontalLoading style={{ width: '52px' }} />
+  ))

+ 73 - 58
src/components/organisms/WizardInstances/WizardInstances.jsx

@@ -21,7 +21,7 @@ import styled, { css } from 'styled-components'
 import Checkbox from '../../atoms/Checkbox'
 import ReloadButton from '../../atoms/ReloadButton'
 import Arrow from '../../atoms/Arrow'
-import StatusIcon from '../../atoms/StatusIcon'
+import HorizontalLoading from '../../atoms/HorizontalLoading'
 import StatusImage from '../../atoms/StatusImage'
 import Button from '../../atoms/Button'
 import SearchInput from '../../molecules/SearchInput'
@@ -125,31 +125,38 @@ const Pagination = styled.div`
   margin: 32px 0 16px 0;
   flex-shrink: 0;
 `
-const Page = styled.div`
-  width: 30px;
-  height: 30px;
+const pageStyle = css`
   display: flex;
   justify-content: center;
   align-items: center;
-  border: 1px solid ${Palette.grayscale[3]};
+  background: ${Palette.grayscale[1]};
+`
+const pageButtonStyle = css`
+  width: 32px;
+  height: 30px;
   cursor: ${props => props.disabled ? 'default' : 'pointer'};
-  ${props => props.previous ? css`
-    border-top-left-radius: ${StyleProps.borderRadius};
-    border-bottom-left-radius: ${StyleProps.borderRadius};
-    padding-top: 2px;
-    height: 28px;
-  ` : ''}
-  ${props => props.number ? css`
-    border-top: 1px solid ${Palette.grayscale[3]};
-    border-bottom: 1px solid ${Palette.grayscale[3]};
-    border-left: 1px solid white;
-    border-right: 1px solid white;
-    cursor: default;
-  ` : ''}
-  ${props => props.next ? css`
-    border-top-right-radius: ${StyleProps.borderRadius};
-    border-bottom-right-radius: ${StyleProps.borderRadius};
-  ` : ''}
+  padding-top: 2px;
+`
+
+const PagePrevious = styled.div`
+  border-top-left-radius: ${StyleProps.borderRadius};
+  border-bottom-left-radius: ${StyleProps.borderRadius};
+  ${pageStyle}
+  ${pageButtonStyle}
+`
+const PageNext = styled.div`
+  border-top-right-radius: ${StyleProps.borderRadius};
+  border-bottom-right-radius: ${StyleProps.borderRadius};
+  ${pageStyle}
+  ${pageButtonStyle}
+`
+const PageNumber = styled.div`
+  width: 64px;
+  height: 29px;
+  flex-direction: column;
+  margin: 0 1px;
+  padding-top: 3px;
+  ${pageStyle}
 `
 const Reloading = styled.div`
   margin: 32px auto 0 auto;
@@ -183,33 +190,33 @@ type Props = {
   instances: InstanceType[],
   selectedInstances: ?InstanceType[],
   currentPage: number,
+  chunkSize: number,
   loading: boolean,
+  chunksLoading: boolean,
   searching: boolean,
   searchNotFound: boolean,
-  loadingPage: boolean,
-  hasNextPage: boolean,
   reloading: boolean,
   onSearchInputChange: (value: string) => void,
-  onNextPageClick: (searchText: string) => void,
-  onPreviousPageClick: () => void,
-  onReloadClick: (searchText: string) => void,
+  onReloadClick: () => void,
   onInstanceClick: (instance: InstanceType) => void,
+  onPageClick: (page: number) => void,
 }
-
 type State = {
   searchText: string,
 }
+
 @observer
 class WizardInstances extends React.Component<Props, State> {
   state = {
     searchText: '',
   }
-
   timeout: TimeoutID
 
-  handleSeachInputChange(searchText: string) {
-    this.setState({ searchText })
+  componentWillUnmount() {
+    this.props.onSearchInputChange('')
+  }
 
+  handleSeachInputChange(searchText: string) {
     clearTimeout(this.timeout)
     this.setState({ searchText })
     this.timeout = setTimeout(() => {
@@ -217,8 +224,17 @@ class WizardInstances extends React.Component<Props, State> {
     }, 500)
   }
 
+  handlePreviousPageClick() {
+    this.props.onPageClick(this.props.currentPage - 1)
+  }
+
+  handleNextPageClick() {
+    this.props.onPageClick(this.props.currentPage + 1)
+  }
+
   areNoInstances() {
-    return !this.props.loading && !this.props.searchNotFound && !this.props.reloading && this.props.instances.length === 0
+    return !this.props.loading && !this.props.searchNotFound && !this.props.reloading
+      && this.props.instances.length === 0 && !this.props.searching
   }
 
   renderNoInstances() {
@@ -231,7 +247,7 @@ class WizardInstances extends React.Component<Props, State> {
         <BigInstanceImage />
         <SearchNotFoundText>It seems like you don’t have any Instances in this Endpoint</SearchNotFoundText>
         <SearchNotFoundSubtitle>You can retry the search or choose another Endpoint</SearchNotFoundSubtitle>
-        <Button hollow onClick={() => { this.props.onReloadClick(this.state.searchText) }}>Retry Search</Button>
+        <Button hollow onClick={() => { this.props.onReloadClick() }}>Retry Search</Button>
       </SearchNotFound>
     )
   }
@@ -245,7 +261,7 @@ class WizardInstances extends React.Component<Props, State> {
       <SearchNotFound>
         <StatusImage status="ERROR" />
         <SearchNotFoundText data-test-id="wInstances-notFoundText">Your search returned no results</SearchNotFoundText>
-        <Button hollow onClick={() => { this.props.onReloadClick(this.state.searchText) }}>Retry</Button>
+        <Button hollow onClick={() => { this.props.onReloadClick() }}>Retry</Button>
       </SearchNotFound>
     )
   }
@@ -279,10 +295,13 @@ class WizardInstances extends React.Component<Props, State> {
     if (this.props.loading || this.props.searchNotFound || this.props.reloading || this.areNoInstances()) {
       return null
     }
+    let startIdx = (this.props.currentPage - 1) * this.props.chunkSize
+    let endIdx = startIdx + (this.props.chunkSize - 1)
+    let filteredInstances = this.props.instances.filter((i, idx) => idx >= startIdx && idx <= endIdx)
 
     return (
       <InstancesWrapper>
-        {this.props.instances.map(instance => {
+        {filteredInstances.map(instance => {
           let selected = Boolean(this.props.selectedInstances && this.props.selectedInstances.find(i => i.id === instance.id))
           let flavorName = instance.flavor_name ? ` | ${instance.flavor_name}` : ''
           return (
@@ -327,7 +346,7 @@ class WizardInstances extends React.Component<Props, State> {
           <SelectionInfo data-test-id="wInstances-selInfo">{count} instance{plural} selected</SelectionInfo>
           <FilterSeparator>|</FilterSeparator>
           <ReloadButton
-            onClick={() => { this.props.onReloadClick(this.state.searchText) }}
+            onClick={() => { this.props.onReloadClick() }}
             data-test-id="wInstances-reloadButton"
           />
         </FilterInfo>
@@ -340,37 +359,33 @@ class WizardInstances extends React.Component<Props, State> {
       return null
     }
 
-    let areAllDisabled = this.props.searching || this.props.loadingPage
+    let hasNextPage = this.props.currentPage * this.props.chunkSize < this.props.instances.length
+    let areAllDisabled = this.props.searching
     let isPreviousDisabled = this.props.currentPage === 1 || areAllDisabled
-    let isNextDisabled = !this.props.hasNextPage || areAllDisabled
+    let isNextDisabled = !hasNextPage || areAllDisabled
 
     return (
-      <Pagination>
-        <Page
-          previous
+      <Pagination onMouseDown={e => { e.preventDefault() }}>
+        <PagePrevious
           disabled={isPreviousDisabled}
-          onClick={() => { if (!isPreviousDisabled) { this.props.onPreviousPageClick() } }}
+          onClick={() => { if (!isPreviousDisabled) { this.handlePreviousPageClick() } }}
           data-test-id="wInstances-prevPageButton"
         >
-          <Arrow orientation="left" disabled={isPreviousDisabled} />
-        </Page>
-        <Page number data-test-id="wInstances-currentPage">
-          {this.props.loadingPage ? (
-            <StatusIcon
-              status="RUNNING"
-              secondary
-              data-test-id="wInstances-pageLoadingStatus"
-            />
-          ) : this.props.currentPage}
-        </Page>
-        <Page
-          next
-          onClick={() => { if (!isNextDisabled) { this.props.onNextPageClick(this.state.searchText) } }}
+          <Arrow orientation="left" disabled={isPreviousDisabled} color={Palette.black} thick />
+        </PagePrevious>
+        <PageNumber data-test-id="wInstances-currentPage">
+          {this.props.currentPage} of {Math.ceil(this.props.instances.length / this.props.chunkSize)}
+          {this.props.chunksLoading ? (
+            <HorizontalLoading style={{ width: '100%', top: '3px' }} data-test-id="wInstances-loadingChunks" />
+          ) : null}
+        </PageNumber>
+        <PageNext
+          onClick={() => { if (!isNextDisabled) { this.handleNextPageClick() } }}
           disabled={isNextDisabled}
           data-test-id="wInstances-nextPageButton"
         >
-          <Arrow disabled={isNextDisabled} />
-        </Page>
+          <Arrow disabled={isNextDisabled} color={Palette.black} thick />
+        </PageNext>
       </Pagination>
     )
   }

+ 14 - 16
src/components/organisms/WizardInstances/test.jsx

@@ -22,7 +22,7 @@ import WizardInstances from '.'
 
 const wrap = props => new TW(shallow(
   // $FlowIgnore
-  <WizardInstances {...props} />
+  <WizardInstances chunkSize={6} {...props} />
 ), 'wInstances')
 
 let instances = [
@@ -61,8 +61,8 @@ describe('WizardInstances Component', () => {
   })
 
   it('renders current page', () => {
-    let wrapper = wrap({ instances, currentPage: 2 })
-    expect(wrapper.findText('currentPage')).toBe('2')
+    let wrapper = wrap({ instances, currentPage: 2, chunkSize: 2 })
+    expect(wrapper.findText('currentPage')).toBe('2 of 2')
   })
 
   it('renders previous page disabled if page is 1', () => {
@@ -89,33 +89,31 @@ describe('WizardInstances Component', () => {
   it('renders search not found', () => {
     let wrapper = wrap({ instances: [], currentPage: 1, searchNotFound: true })
     expect(wrapper.findText('notFoundText')).toBe('Your search returned no results')
-    expect(wrapper.find('pageLoadingStatus').length).toBe(0)
+    expect(wrapper.find('loadingChunks').length).toBe(0)
   })
 
   it('renders loading page', () => {
-    let wrapper = wrap({ instances, currentPage: 1, loadingPage: true })
-    expect(wrapper.find('pageLoadingStatus').length).toBe(1)
+    let wrapper = wrap({ instances, currentPage: 1, chunksLoading: true })
+    expect(wrapper.find('loadingChunks').length).toBe(1)
   })
 
   it('enabled next page', () => {
-    let wrapper = wrap({ instances, currentPage: 1, hasNextPage: true })
+    let wrapper = wrap({ instances, currentPage: 1 })
+    expect(wrapper.find('nextPageButton').prop('disabled')).toBe(true)
+    wrapper = wrap({ instances, currentPage: 1, chunkSize: 2 })
     expect(wrapper.find('nextPageButton').prop('disabled')).toBeFalsy()
   })
 
   it('dispatches next and previous page click, if enabled', () => {
-    let onNextPageClick = sinon.spy()
-    let onPreviousPageClick = sinon.spy()
-    let wrapper = wrap({ instances, currentPage: 1, onNextPageClick, onPreviousPageClick })
+    let onPageClick = sinon.spy()
+    let wrapper = wrap({ instances, currentPage: 1, onPageClick })
     wrapper.find('nextPageButton').click()
     wrapper.find('prevPageButton').click()
-    expect(onPreviousPageClick.called).toBe(false)
-    expect(onPreviousPageClick.called).toBe(false)
-
-    wrapper = wrap({ instances, currentPage: 2, onNextPageClick, onPreviousPageClick, hasNextPage: true })
+    expect(onPageClick.callCount).toBe(0)
+    wrapper = wrap({ instances, currentPage: 2, onPageClick, chunkSize: 1 })
     wrapper.find('nextPageButton').click()
     wrapper.find('prevPageButton').click()
-    expect(onPreviousPageClick.called).toBe(true)
-    expect(onPreviousPageClick.called).toBe(true)
+    expect(onPageClick.callCount).toBe(2)
   })
 
   it('dispaches reload click', () => {

+ 7 - 8
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -107,10 +107,9 @@ type Props = {
   onTargetEndpointChange: (endpoint: Endpoint) => void,
   onAddEndpoint: (provider: string, fromSource: boolean) => void,
   onInstancesSearchInputChange: (searchText: string) => void,
-  onInstancesNextPageClick: (searchText: string) => void,
-  onInstancesPreviousPageClick: () => void,
-  onInstancesReloadClick: (searchText: string) => void,
+  onInstancesReloadClick: () => void,
   onInstanceClick: (instance: Instance) => void,
+  onInstancePageClick: (page: number) => void,
   onOptionsChange: (field: Field, value: any) => void,
   onNetworkChange: (nic: Nic, network: Network) => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
@@ -311,18 +310,18 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
           <WizardInstances
             instances={this.props.instanceStore.instances}
+            chunkSize={this.props.instanceStore.chunkSize}
+            chunksLoading={this.props.instanceStore.chunksLoading}
+            currentPage={this.props.instanceStore.currentPage}
+            searchText={this.props.instanceStore.searchText}
             loading={this.props.instanceStore.instancesLoading}
             searching={this.props.instanceStore.searching}
             searchNotFound={this.props.instanceStore.searchNotFound}
             reloading={this.props.instanceStore.reloading}
             onSearchInputChange={this.props.onInstancesSearchInputChange}
-            onNextPageClick={this.props.onInstancesNextPageClick}
-            onPreviousPageClick={this.props.onInstancesPreviousPageClick}
-            hasNextPage={this.props.instanceStore.hasNextPage}
-            currentPage={this.props.instanceStore.currentPage}
-            loadingPage={this.props.instanceStore.loadingPage}
             onReloadClick={this.props.onInstancesReloadClick}
             onInstanceClick={this.props.onInstanceClick}
+            onPageClick={this.props.onInstancePageClick}
             selectedInstances={this.props.wizardData.selectedInstances}
           />
         )

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

@@ -189,7 +189,7 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
   handleSourceEndpointChange(sourceEndpoint: ?Endpoint) {
     this.setState({ selectedNetworks: [] })
     azureStore.updateSourceEndpoint(sourceEndpoint)
-    instanceStore.loadInstances(this.getSourceEndpointId(), true, true, true).then(() => {
+    instanceStore.loadInstances(this.getSourceEndpointId()).then(() => {
       this.initSelectedVms()
       this.loadInstancesDetails()
     })

+ 11 - 17
src/components/pages/WizardPage/WizardPage.jsx

@@ -88,6 +88,7 @@ class WizardPage extends React.Component<Props, State> {
 
   componentWillUnmount() {
     wizardStore.clearData()
+    instanceStore.cancelIntancesChunksLoading()
     KeyboardManager.removeKeyDown('wizard')
   }
 
@@ -181,7 +182,7 @@ class WizardPage extends React.Component<Props, State> {
       endpointStore.getConnectionInfo(source).then(() => {
         if (source) {
           // Preload instances for 'vms' page
-          instanceStore.loadInstances(source.id)
+          instanceStore.loadInstancesInChunks(source.id)
         }
       }).catch(() => {
         this.handleSourceEndpointChange(null)
@@ -225,19 +226,9 @@ class WizardPage extends React.Component<Props, State> {
     }
   }
 
-  handleInstancesNextPageClick(searchText: string) {
+  handleInstancesReloadClick() {
     if (wizardStore.data.source) {
-      instanceStore.loadNextPage(wizardStore.data.source.id, searchText)
-    }
-  }
-
-  handleInstancesPreviousPageClick() {
-    instanceStore.loadPreviousPage()
-  }
-
-  handleInstancesReloadClick(searchText: string) {
-    if (wizardStore.data.source) {
-      instanceStore.reloadInstances(wizardStore.data.source.id, searchText)
+      instanceStore.reloadInstances(wizardStore.data.source.id)
     }
   }
 
@@ -247,6 +238,10 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.setPermalink(wizardStore.data)
   }
 
+  handleInstancePageClick(page: number) {
+    instanceStore.setPage(page)
+  }
+
   handleOptionsChange(field: Field, value: any) {
     wizardStore.updateData({ networks: null })
     wizardStore.updateOptions({ field, value })
@@ -338,7 +333,7 @@ class WizardPage extends React.Component<Props, State> {
           // Check if user has permission for this endpoint
           endpointStore.getConnectionInfo(source).then(() => {
             // Preload instances for 'vms' page
-            instanceStore.loadInstances(source.id)
+            instanceStore.loadInstancesInChunks(source.id)
           }).catch(() => {
             this.handleSourceEndpointChange(null)
           })
@@ -479,10 +474,9 @@ class WizardPage extends React.Component<Props, State> {
             onTargetEndpointChange={endpoint => { this.handleTargetEndpointChange(endpoint) }}
             onAddEndpoint={(type, fromSource) => { this.handleAddEndpoint(type, fromSource) }}
             onInstancesSearchInputChange={searchText => { this.handleInstancesSearchInputChange(searchText) }}
-            onInstancesNextPageClick={searchText => { this.handleInstancesNextPageClick(searchText) }}
-            onInstancesPreviousPageClick={() => { this.handleInstancesPreviousPageClick() }}
-            onInstancesReloadClick={searchText => { this.handleInstancesReloadClick(searchText) }}
+            onInstancesReloadClick={() => { this.handleInstancesReloadClick() }}
             onInstanceClick={instance => { this.handleInstanceClick(instance) }}
+            onInstancePageClick={page => { this.handleInstancePageClick(page) }}
             onOptionsChange={(field, value) => { this.handleOptionsChange(field, value) }}
             onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}

+ 0 - 1
src/config.js

@@ -85,7 +85,6 @@ export const wizardConfig = {
     { id: 'schedule', title: 'Schedule', breadcrumb: 'Schedule', excludeFrom: 'migration' },
     { id: 'summary', title: 'Summary', breadcrumb: 'Summary' },
   ],
-  instancesItemsPerPage: 6,
 }
 
 // A list of providers for which `destination-options` API call(s) will be made in the Wizard

+ 19 - 13
src/sources/InstanceSource.js

@@ -17,29 +17,35 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import Api from '../utils/ApiCaller'
 import type { Instance } from '../types/Instance'
 
-import { servicesUrl, wizardConfig } from '../config'
+import { servicesUrl } from '../config'
 
 class InstanceSource {
-  static loadInstances(endpointId: string, searchText: ?string, lastInstanceId: ?string, skipLimit?: boolean): Promise<Instance[]> {
-    Api.cancelRequests(endpointId)
-
+  static loadInstancesChunk(
+    endpointId: string,
+    chunkSize: number,
+    lastInstanceId?: string,
+    cancelId?: string,
+    searchText?: string
+  ): Promise<Instance[]> {
     let url = `${servicesUrl.coriolis}/${Api.projectId}/endpoints/${endpointId}/instances`
-    let symbol = '?'
+    url = `${url}?limit=${chunkSize}`
 
-    if (!skipLimit) {
-      url = `${url + symbol}limit=${wizardConfig.instancesItemsPerPage + 1}`
-      symbol = '&'
+    if (lastInstanceId) {
+      url = `${url}&marker=${lastInstanceId}`
     }
 
     if (searchText) {
-      url = `${url + symbol}name=${searchText}`
-      symbol = '&'
+      url = `${url}&name=${searchText}`
     }
 
-    if (lastInstanceId) {
-      url = `${url + symbol}&marker=${lastInstanceId}`
-    }
+    return Api.send({ url, cancelId }).then(response => {
+      return response.data.instances
+    })
+  }
 
+  static loadInstances(endpointId: string): Promise<Instance[]> {
+    Api.cancelRequests(endpointId)
+    let url = `${servicesUrl.coriolis}/${Api.projectId}/endpoints/${endpointId}/instances`
     return Api.send({ url, cancelId: endpointId }).then(response => {
       return response.data.instances
     })

+ 120 - 116
src/stores/InstanceStore.js

@@ -14,11 +14,11 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-import { observable, action } from 'mobx'
+import { observable, action, computed } from 'mobx'
 
-import { wizardConfig } from '../config'
 import type { Instance } from '../types/Instance'
 import InstanceSource from '../sources/InstanceSource'
+import ApiCaller from '../utils/ApiCaller'
 
 class InstanceLocalStorage {
   static saveInstancesToLocalStorage(endpointId: string, instances: Instance[]) {
@@ -81,81 +81,93 @@ class InstanceLocalStorage {
   }
 }
 
-class InstanceStoreUtils {
-  static hasNextPage(instances) {
-    let result = false
-    if (instances.length - 1 === wizardConfig.instancesItemsPerPage) {
-      result = true
-      instances.pop()
-    }
-
-    return result
-  }
-
-  static loadFromCache(cache, page) {
-    let startIndex = wizardConfig.instancesItemsPerPage * (page - 1)
-    let endIndex = startIndex + wizardConfig.instancesItemsPerPage
-    return cache.filter((item, index) => {
-      if (index >= startIndex && index < endIndex) {
-        return true
-      }
-
-      return false
-    })
-  }
-}
-
 class InstanceStore {
-  @observable instances: Instance[] = []
   @observable instancesLoading = false
-  @observable searching = false
-  @observable searchNotFound: boolean = false
-  @observable loadingPage = false
+  @observable chunkSize = 6
   @observable currentPage = 1
-  @observable hasNextPage = false
-  @observable cachedHasNextPage = false
-  @observable cachedInstances: Instance[] = []
+  @observable searchChunksLoading = false
+  @observable searchedInstances: Instance[] = []
+  @observable backgroundInstances: Instance[] = []
+  @observable backgroundChunksLoading = false
+  @observable searching = false
+  @observable searchNotFound = false
   @observable reloading = false
   @observable instancesDetails: Instance[] = []
   @observable loadingInstancesDetails = true
-  @observable instancesDetailsCount: number = 0
-  @observable instancesDetailsRemaining: number = 0
+  @observable instancesDetailsCount = 0
+  @observable instancesDetailsRemaining = 0
+  @observable searchText = ''
+
+  @computed get instances(): Instance[] {
+    if (this.searchText && this.searchedInstances.length > 0) {
+      return this.searchedInstances
+    }
+    return this.backgroundInstances
+  }
+
+  @computed get chunksLoading(): boolean {
+    if (this.searchText) {
+      return this.searchChunksLoading
+    }
+    return this.backgroundChunksLoading
+  }
 
   lastEndpointId: string
   reqId: number
 
-  @action loadInstances(endpointId: string, skipLimit?: boolean, useCache?: boolean, useLocalStorage?: boolean): Promise<void> {
-    if (this.cachedInstances.length > 0 && this.lastEndpointId === endpointId && useCache) {
-      return Promise.resolve()
+  @action loadInstancesInChunks(endpointId: string, reload?: boolean) {
+    ApiCaller.cancelRequests(`${endpointId}-chunk`)
+
+    this.backgroundInstances = []
+    if (reload) {
+      this.reloading = true
+    } else {
+      this.instancesLoading = true
+    }
+    this.backgroundChunksLoading = true
+    this.lastEndpointId = endpointId
+
+    let loadNextChunk = (lastEndpointId?: string) => {
+      let currentEndpointId = endpointId
+      InstanceSource.loadInstancesChunk(currentEndpointId, this.chunkSize, lastEndpointId, `${endpointId}-chunk`)
+        .then(instances => {
+          if (currentEndpointId !== this.lastEndpointId) {
+            return
+          }
+
+          this.backgroundInstances = [...this.backgroundInstances, ...instances]
+          if (reload) {
+            this.reloading = false
+          }
+          this.instancesLoading = false
+
+          if (instances.length < this.chunkSize) {
+            this.backgroundChunksLoading = false
+            return
+          }
+          loadNextChunk(instances[instances.length - 1].id)
+        })
     }
+    loadNextChunk()
+  }
 
+  @action loadInstances(endpointId: string): Promise<void> {
     this.instancesLoading = true
-    this.searchNotFound = false
     this.lastEndpointId = endpointId
 
-    if (useLocalStorage) {
-      let endpointInstances = InstanceLocalStorage.loadInstancesFromLocalStorage(endpointId)
-      if (endpointInstances) {
-        this.currentPage = 1
-        this.hasNextPage = false
-        this.instances = endpointInstances
-        this.cachedInstances = endpointInstances
-        this.instancesLoading = false
-        return Promise.resolve()
-      }
+    let endpointInstances = InstanceLocalStorage.loadInstancesFromLocalStorage(endpointId)
+    if (endpointInstances) {
+      this.backgroundInstances = endpointInstances
+      this.instancesLoading = false
+      return Promise.resolve()
     }
 
-    return InstanceSource.loadInstances(endpointId, null, null, skipLimit).then(instances => {
+    return InstanceSource.loadInstances(endpointId).then(instances => {
       if (endpointId !== this.lastEndpointId) {
         return
       }
-
-      this.currentPage = 1
-      this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
-      this.instances = instances
-      this.cachedInstances = instances
+      this.backgroundInstances = instances
       this.instancesLoading = false
-
       InstanceLocalStorage.saveInstancesToLocalStorage(endpointId, instances)
     }).catch(() => {
       if (endpointId !== this.lastEndpointId) {
@@ -166,75 +178,67 @@ class InstanceStore {
   }
 
   @action searchInstances(endpointId: string, searchText: string) {
-    this.searching = true
-    return InstanceSource.loadInstances(endpointId, searchText).then(instances => {
+    ApiCaller.cancelRequests(`${endpointId}-chunk-search`)
+
+    this.searchText = searchText
+    this.searchNotFound = false
+
+    if (!searchText) {
       this.currentPage = 1
-      this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
-      this.instances = instances
-      this.cachedInstances = instances
-      this.searching = false
-      this.searchNotFound = Boolean(instances.length === 0 && searchText)
-    }).catch(r => {
-      if (r.canceled) {
-        return
-      }
-      this.searching = false
-      this.searchNotFound = true
-    })
-  }
+      this.searchedInstances = []
+      return
+    }
 
-  @action loadNextPage(endpointId: string, searchText: string): Promise<void> {
-    if (this.cachedInstances.length > wizardConfig.instancesItemsPerPage * this.currentPage) {
-      this.currentPage = this.currentPage + 1
-      let numCachedPages = Math.ceil(this.cachedInstances.length / wizardConfig.instancesItemsPerPage)
-      if (this.currentPage === numCachedPages) {
-        this.hasNextPage = this.cachedHasNextPage
-      } else {
-        this.hasNextPage = true
-      }
-      this.instances = InstanceStoreUtils.loadFromCache(this.cachedInstances, this.currentPage)
-      return Promise.resolve()
+    if (!this.backgroundChunksLoading) {
+      this.searchedInstances = this.backgroundInstances
+        .filter(i => i.instance_name.toLowerCase().indexOf(searchText.toLowerCase()) > -1)
+      this.searchNotFound = Boolean(this.searchedInstances.length === 0)
+      this.currentPage = 1
+      return
     }
 
-    this.loadingPage = true
-    return InstanceSource.loadInstances(
-      endpointId,
-      searchText,
-      this.instances[this.instances.length - 1].id
-    ).then(instances => {
-      this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
-      this.cachedHasNextPage = this.hasNextPage
-      this.cachedInstances = [...this.cachedInstances, ...instances]
-      this.instances = instances
-      this.loadingPage = false
-      this.currentPage = this.currentPage + 1
-    }).catch(() => {
-      this.loadingPage = false
-    })
+    this.searching = true
+    this.searchChunksLoading = true
+
+    let loadNextChunk = (lastEndpointId?: string) => {
+      InstanceSource.loadInstancesChunk(
+        endpointId,
+        this.chunkSize,
+        lastEndpointId,
+        `${endpointId}-chunk-search`,
+        searchText
+      ).then(instances => {
+        if (this.searching) {
+          this.currentPage = 1
+          this.searchedInstances = []
+        }
+
+        this.searchedInstances = [...this.searchedInstances, ...instances]
+        this.searching = false
+        this.searchNotFound = Boolean(this.searchedInstances.length === 0)
+        if (instances.length < this.chunkSize) {
+          this.searchChunksLoading = false
+        }
+        return loadNextChunk(instances[instances.length - 1].id)
+      })
+    }
+    loadNextChunk()
   }
 
-  @action loadPreviousPage() {
-    this.hasNextPage = true
-    this.currentPage = this.currentPage - 1
-    this.instances = InstanceStoreUtils.loadFromCache(this.cachedInstances, this.currentPage)
+  @action reloadInstances(endpointId: string) {
+    this.searchNotFound = false
+    this.searchText = ''
+    this.currentPage = 1
+    this.loadInstancesInChunks(endpointId, true)
   }
 
-  @action reloadInstances(endpointId: string, searchText: string) {
-    this.reloading = true
-    this.searchNotFound = false
+  @action cancelIntancesChunksLoading() {
+    ApiCaller.cancelRequests(`${this.lastEndpointId}-chunk`)
+    this.lastEndpointId = ''
+  }
 
-    InstanceSource.loadInstances(endpointId, searchText).then(instances => {
-      this.reloading = false
-      this.currentPage = 1
-      this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
-      this.instances = instances
-      this.cachedInstances = instances
-      this.searching = false
-      this.searchNotFound = Boolean(instances.length === 0 && searchText)
-    }).catch(() => {
-      this.reloading = false
-      this.searchNotFound = true
-    })
+  @action setPage(page: number) {
+    this.currentPage = page
   }
 
   @action loadInstancesDetails(endpointId: string, instancesInfo: Instance[], useLocalStorage?: boolean, quietError?: boolean): Promise<void> {