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

Load instances in chunks in the background

Instead of loading a new page of instances on next page click, load all
pages in the background one after one.

Searching is done on the server only if the instances are not completely
loaded in the background.

The UI shows the number of currently loaded pages and indicates whether
all pages have been completely loaded.
Sergiu Miclea 7 лет назад
Родитель
Сommit
cc3a3a6d74

+ 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> {