Przeglądaj źródła

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 lat temu
rodzic
commit
cc3a3a6d74

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

@@ -22,6 +22,7 @@ import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 import StyleProps from '../../styleUtils/StyleProps'
 
 
 import arrowImage from './images/arrow.js'
 import arrowImage from './images/arrow.js'
+import arrowThickImage from './images/arrow-thick.js'
 
 
 const getOrientation = props => `
 const getOrientation = props => `
   ${props.orientation === 'left' ? 'transform: rotate(180deg);' : ''}
   ${props.orientation === 'left' ? 'transform: rotate(180deg);' : ''}
@@ -47,6 +48,8 @@ type Props = {
   orientation: 'left' | 'down' | 'up' | 'right',
   orientation: 'left' | 'down' | 'up' | 'right',
   opacity: number,
   opacity: number,
   disabled?: boolean,
   disabled?: boolean,
+  color?: string,
+  thick?: boolean,
 }
 }
 
 
 @observer
 @observer
@@ -58,12 +61,13 @@ class Arrow extends React.Component<Props> {
 
 
   render() {
   render() {
     let color = this.props.primary ? Palette.primary : Palette.grayscale[4]
     let color = this.props.primary ? Palette.primary : Palette.grayscale[4]
+    color = this.props.color || color
     color = this.props.disabled ? Palette.grayscale[0] : color
     color = this.props.disabled ? Palette.grayscale[0] : color
     return (
     return (
       <Wrapper
       <Wrapper
         {...this.props}
         {...this.props}
         dangerouslySetInnerHTML={
         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"?>
 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">
 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 -->
     <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
     <title>Chevron-Blue Copy</title>
     <title>Chevron-Blue Copy</title>
     <desc>Created with Sketch.</desc>
     <desc>Created with Sketch.</desc>
     <defs></defs>
     <defs></defs>
     <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"
     <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="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)">
             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>
                 points="11.8890873 1.6109127 11.8890873 9.3890873 4.1109127 9.3890873"></polyline>
             </g>
             </g>
         </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 Checkbox from '../../atoms/Checkbox'
 import ReloadButton from '../../atoms/ReloadButton'
 import ReloadButton from '../../atoms/ReloadButton'
 import Arrow from '../../atoms/Arrow'
 import Arrow from '../../atoms/Arrow'
-import StatusIcon from '../../atoms/StatusIcon'
+import HorizontalLoading from '../../atoms/HorizontalLoading'
 import StatusImage from '../../atoms/StatusImage'
 import StatusImage from '../../atoms/StatusImage'
 import Button from '../../atoms/Button'
 import Button from '../../atoms/Button'
 import SearchInput from '../../molecules/SearchInput'
 import SearchInput from '../../molecules/SearchInput'
@@ -125,31 +125,38 @@ const Pagination = styled.div`
   margin: 32px 0 16px 0;
   margin: 32px 0 16px 0;
   flex-shrink: 0;
   flex-shrink: 0;
 `
 `
-const Page = styled.div`
-  width: 30px;
-  height: 30px;
+const pageStyle = css`
   display: flex;
   display: flex;
   justify-content: center;
   justify-content: center;
   align-items: 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'};
   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`
 const Reloading = styled.div`
   margin: 32px auto 0 auto;
   margin: 32px auto 0 auto;
@@ -183,33 +190,33 @@ type Props = {
   instances: InstanceType[],
   instances: InstanceType[],
   selectedInstances: ?InstanceType[],
   selectedInstances: ?InstanceType[],
   currentPage: number,
   currentPage: number,
+  chunkSize: number,
   loading: boolean,
   loading: boolean,
+  chunksLoading: boolean,
   searching: boolean,
   searching: boolean,
   searchNotFound: boolean,
   searchNotFound: boolean,
-  loadingPage: boolean,
-  hasNextPage: boolean,
   reloading: boolean,
   reloading: boolean,
   onSearchInputChange: (value: string) => void,
   onSearchInputChange: (value: string) => void,
-  onNextPageClick: (searchText: string) => void,
-  onPreviousPageClick: () => void,
-  onReloadClick: (searchText: string) => void,
+  onReloadClick: () => void,
   onInstanceClick: (instance: InstanceType) => void,
   onInstanceClick: (instance: InstanceType) => void,
+  onPageClick: (page: number) => void,
 }
 }
-
 type State = {
 type State = {
   searchText: string,
   searchText: string,
 }
 }
+
 @observer
 @observer
 class WizardInstances extends React.Component<Props, State> {
 class WizardInstances extends React.Component<Props, State> {
   state = {
   state = {
     searchText: '',
     searchText: '',
   }
   }
-
   timeout: TimeoutID
   timeout: TimeoutID
 
 
-  handleSeachInputChange(searchText: string) {
-    this.setState({ searchText })
+  componentWillUnmount() {
+    this.props.onSearchInputChange('')
+  }
 
 
+  handleSeachInputChange(searchText: string) {
     clearTimeout(this.timeout)
     clearTimeout(this.timeout)
     this.setState({ searchText })
     this.setState({ searchText })
     this.timeout = setTimeout(() => {
     this.timeout = setTimeout(() => {
@@ -217,8 +224,17 @@ class WizardInstances extends React.Component<Props, State> {
     }, 500)
     }, 500)
   }
   }
 
 
+  handlePreviousPageClick() {
+    this.props.onPageClick(this.props.currentPage - 1)
+  }
+
+  handleNextPageClick() {
+    this.props.onPageClick(this.props.currentPage + 1)
+  }
+
   areNoInstances() {
   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() {
   renderNoInstances() {
@@ -231,7 +247,7 @@ class WizardInstances extends React.Component<Props, State> {
         <BigInstanceImage />
         <BigInstanceImage />
         <SearchNotFoundText>It seems like you don’t have any Instances in this Endpoint</SearchNotFoundText>
         <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>
         <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>
       </SearchNotFound>
     )
     )
   }
   }
@@ -245,7 +261,7 @@ class WizardInstances extends React.Component<Props, State> {
       <SearchNotFound>
       <SearchNotFound>
         <StatusImage status="ERROR" />
         <StatusImage status="ERROR" />
         <SearchNotFoundText data-test-id="wInstances-notFoundText">Your search returned no results</SearchNotFoundText>
         <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>
       </SearchNotFound>
     )
     )
   }
   }
@@ -279,10 +295,13 @@ class WizardInstances extends React.Component<Props, State> {
     if (this.props.loading || this.props.searchNotFound || this.props.reloading || this.areNoInstances()) {
     if (this.props.loading || this.props.searchNotFound || this.props.reloading || this.areNoInstances()) {
       return null
       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 (
     return (
       <InstancesWrapper>
       <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 selected = Boolean(this.props.selectedInstances && this.props.selectedInstances.find(i => i.id === instance.id))
           let flavorName = instance.flavor_name ? ` | ${instance.flavor_name}` : ''
           let flavorName = instance.flavor_name ? ` | ${instance.flavor_name}` : ''
           return (
           return (
@@ -327,7 +346,7 @@ class WizardInstances extends React.Component<Props, State> {
           <SelectionInfo data-test-id="wInstances-selInfo">{count} instance{plural} selected</SelectionInfo>
           <SelectionInfo data-test-id="wInstances-selInfo">{count} instance{plural} selected</SelectionInfo>
           <FilterSeparator>|</FilterSeparator>
           <FilterSeparator>|</FilterSeparator>
           <ReloadButton
           <ReloadButton
-            onClick={() => { this.props.onReloadClick(this.state.searchText) }}
+            onClick={() => { this.props.onReloadClick() }}
             data-test-id="wInstances-reloadButton"
             data-test-id="wInstances-reloadButton"
           />
           />
         </FilterInfo>
         </FilterInfo>
@@ -340,37 +359,33 @@ class WizardInstances extends React.Component<Props, State> {
       return null
       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 isPreviousDisabled = this.props.currentPage === 1 || areAllDisabled
-    let isNextDisabled = !this.props.hasNextPage || areAllDisabled
+    let isNextDisabled = !hasNextPage || areAllDisabled
 
 
     return (
     return (
-      <Pagination>
-        <Page
-          previous
+      <Pagination onMouseDown={e => { e.preventDefault() }}>
+        <PagePrevious
           disabled={isPreviousDisabled}
           disabled={isPreviousDisabled}
-          onClick={() => { if (!isPreviousDisabled) { this.props.onPreviousPageClick() } }}
+          onClick={() => { if (!isPreviousDisabled) { this.handlePreviousPageClick() } }}
           data-test-id="wInstances-prevPageButton"
           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}
           disabled={isNextDisabled}
           data-test-id="wInstances-nextPageButton"
           data-test-id="wInstances-nextPageButton"
         >
         >
-          <Arrow disabled={isNextDisabled} />
-        </Page>
+          <Arrow disabled={isNextDisabled} color={Palette.black} thick />
+        </PageNext>
       </Pagination>
       </Pagination>
     )
     )
   }
   }

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

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

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

@@ -107,10 +107,9 @@ type Props = {
   onTargetEndpointChange: (endpoint: Endpoint) => void,
   onTargetEndpointChange: (endpoint: Endpoint) => void,
   onAddEndpoint: (provider: string, fromSource: boolean) => void,
   onAddEndpoint: (provider: string, fromSource: boolean) => void,
   onInstancesSearchInputChange: (searchText: string) => void,
   onInstancesSearchInputChange: (searchText: string) => void,
-  onInstancesNextPageClick: (searchText: string) => void,
-  onInstancesPreviousPageClick: () => void,
-  onInstancesReloadClick: (searchText: string) => void,
+  onInstancesReloadClick: () => void,
   onInstanceClick: (instance: Instance) => void,
   onInstanceClick: (instance: Instance) => void,
+  onInstancePageClick: (page: number) => void,
   onOptionsChange: (field: Field, value: any) => void,
   onOptionsChange: (field: Field, value: any) => void,
   onNetworkChange: (nic: Nic, network: Network) => void,
   onNetworkChange: (nic: Nic, network: Network) => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
   onAddScheduleClick: (schedule: ScheduleType) => void,
@@ -311,18 +310,18 @@ class WizardPageContent extends React.Component<Props, State> {
         body = (
         body = (
           <WizardInstances
           <WizardInstances
             instances={this.props.instanceStore.instances}
             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}
             loading={this.props.instanceStore.instancesLoading}
             searching={this.props.instanceStore.searching}
             searching={this.props.instanceStore.searching}
             searchNotFound={this.props.instanceStore.searchNotFound}
             searchNotFound={this.props.instanceStore.searchNotFound}
             reloading={this.props.instanceStore.reloading}
             reloading={this.props.instanceStore.reloading}
             onSearchInputChange={this.props.onInstancesSearchInputChange}
             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}
             onReloadClick={this.props.onInstancesReloadClick}
             onInstanceClick={this.props.onInstanceClick}
             onInstanceClick={this.props.onInstanceClick}
+            onPageClick={this.props.onInstancePageClick}
             selectedInstances={this.props.wizardData.selectedInstances}
             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) {
   handleSourceEndpointChange(sourceEndpoint: ?Endpoint) {
     this.setState({ selectedNetworks: [] })
     this.setState({ selectedNetworks: [] })
     azureStore.updateSourceEndpoint(sourceEndpoint)
     azureStore.updateSourceEndpoint(sourceEndpoint)
-    instanceStore.loadInstances(this.getSourceEndpointId(), true, true, true).then(() => {
+    instanceStore.loadInstances(this.getSourceEndpointId()).then(() => {
       this.initSelectedVms()
       this.initSelectedVms()
       this.loadInstancesDetails()
       this.loadInstancesDetails()
     })
     })

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

@@ -88,6 +88,7 @@ class WizardPage extends React.Component<Props, State> {
 
 
   componentWillUnmount() {
   componentWillUnmount() {
     wizardStore.clearData()
     wizardStore.clearData()
+    instanceStore.cancelIntancesChunksLoading()
     KeyboardManager.removeKeyDown('wizard')
     KeyboardManager.removeKeyDown('wizard')
   }
   }
 
 
@@ -181,7 +182,7 @@ class WizardPage extends React.Component<Props, State> {
       endpointStore.getConnectionInfo(source).then(() => {
       endpointStore.getConnectionInfo(source).then(() => {
         if (source) {
         if (source) {
           // Preload instances for 'vms' page
           // Preload instances for 'vms' page
-          instanceStore.loadInstances(source.id)
+          instanceStore.loadInstancesInChunks(source.id)
         }
         }
       }).catch(() => {
       }).catch(() => {
         this.handleSourceEndpointChange(null)
         this.handleSourceEndpointChange(null)
@@ -225,19 +226,9 @@ class WizardPage extends React.Component<Props, State> {
     }
     }
   }
   }
 
 
-  handleInstancesNextPageClick(searchText: string) {
+  handleInstancesReloadClick() {
     if (wizardStore.data.source) {
     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)
     wizardStore.setPermalink(wizardStore.data)
   }
   }
 
 
+  handleInstancePageClick(page: number) {
+    instanceStore.setPage(page)
+  }
+
   handleOptionsChange(field: Field, value: any) {
   handleOptionsChange(field: Field, value: any) {
     wizardStore.updateData({ networks: null })
     wizardStore.updateData({ networks: null })
     wizardStore.updateOptions({ field, value })
     wizardStore.updateOptions({ field, value })
@@ -338,7 +333,7 @@ class WizardPage extends React.Component<Props, State> {
           // Check if user has permission for this endpoint
           // Check if user has permission for this endpoint
           endpointStore.getConnectionInfo(source).then(() => {
           endpointStore.getConnectionInfo(source).then(() => {
             // Preload instances for 'vms' page
             // Preload instances for 'vms' page
-            instanceStore.loadInstances(source.id)
+            instanceStore.loadInstancesInChunks(source.id)
           }).catch(() => {
           }).catch(() => {
             this.handleSourceEndpointChange(null)
             this.handleSourceEndpointChange(null)
           })
           })
@@ -479,10 +474,9 @@ class WizardPage extends React.Component<Props, State> {
             onTargetEndpointChange={endpoint => { this.handleTargetEndpointChange(endpoint) }}
             onTargetEndpointChange={endpoint => { this.handleTargetEndpointChange(endpoint) }}
             onAddEndpoint={(type, fromSource) => { this.handleAddEndpoint(type, fromSource) }}
             onAddEndpoint={(type, fromSource) => { this.handleAddEndpoint(type, fromSource) }}
             onInstancesSearchInputChange={searchText => { this.handleInstancesSearchInputChange(searchText) }}
             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) }}
             onInstanceClick={instance => { this.handleInstanceClick(instance) }}
+            onInstancePageClick={page => { this.handleInstancePageClick(page) }}
             onOptionsChange={(field, value) => { this.handleOptionsChange(field, value) }}
             onOptionsChange={(field, value) => { this.handleOptionsChange(field, value) }}
             onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
             onNetworkChange={(sourceNic, targetNetwork) => { this.handleNetworkChange(sourceNic, targetNetwork) }}
             onAddScheduleClick={schedule => { this.handleAddScheduleClick(schedule) }}
             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: 'schedule', title: 'Schedule', breadcrumb: 'Schedule', excludeFrom: 'migration' },
     { id: 'summary', title: 'Summary', breadcrumb: 'Summary' },
     { 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
 // 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 Api from '../utils/ApiCaller'
 import type { Instance } from '../types/Instance'
 import type { Instance } from '../types/Instance'
 
 
-import { servicesUrl, wizardConfig } from '../config'
+import { servicesUrl } from '../config'
 
 
 class InstanceSource {
 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 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) {
     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 Api.send({ url, cancelId: endpointId }).then(response => {
       return response.data.instances
       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
 // @flow
 
 
-import { observable, action } from 'mobx'
+import { observable, action, computed } from 'mobx'
 
 
-import { wizardConfig } from '../config'
 import type { Instance } from '../types/Instance'
 import type { Instance } from '../types/Instance'
 import InstanceSource from '../sources/InstanceSource'
 import InstanceSource from '../sources/InstanceSource'
+import ApiCaller from '../utils/ApiCaller'
 
 
 class InstanceLocalStorage {
 class InstanceLocalStorage {
   static saveInstancesToLocalStorage(endpointId: string, instances: Instance[]) {
   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 {
 class InstanceStore {
-  @observable instances: Instance[] = []
   @observable instancesLoading = false
   @observable instancesLoading = false
-  @observable searching = false
-  @observable searchNotFound: boolean = false
-  @observable loadingPage = false
+  @observable chunkSize = 6
   @observable currentPage = 1
   @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 reloading = false
   @observable instancesDetails: Instance[] = []
   @observable instancesDetails: Instance[] = []
   @observable loadingInstancesDetails = true
   @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
   lastEndpointId: string
   reqId: number
   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.instancesLoading = true
-    this.searchNotFound = false
     this.lastEndpointId = endpointId
     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) {
       if (endpointId !== this.lastEndpointId) {
         return
         return
       }
       }
-
-      this.currentPage = 1
-      this.hasNextPage = InstanceStoreUtils.hasNextPage(instances)
-      this.instances = instances
-      this.cachedInstances = instances
+      this.backgroundInstances = instances
       this.instancesLoading = false
       this.instancesLoading = false
-
       InstanceLocalStorage.saveInstancesToLocalStorage(endpointId, instances)
       InstanceLocalStorage.saveInstancesToLocalStorage(endpointId, instances)
     }).catch(() => {
     }).catch(() => {
       if (endpointId !== this.lastEndpointId) {
       if (endpointId !== this.lastEndpointId) {
@@ -166,75 +178,67 @@ class InstanceStore {
   }
   }
 
 
   @action searchInstances(endpointId: string, searchText: string) {
   @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.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> {
   @action loadInstancesDetails(endpointId: string, instancesInfo: Instance[], useLocalStorage?: boolean, quietError?: boolean): Promise<void> {