Explorar o código

Improve options loading UX

The first time the options are loading, show the loading animation for
the entire page.

For subsequent options loading, show disabled loading for all fields
except for the fields which are required in `env` parameter of the
options calls.

This means that while the options are being loaded, users can still
change those fields, in which case, the previous options call is
canceled and a one is started.
Sergiu Miclea %!s(int64=6) %!d(string=hai) anos
pai
achega
c62519803a

+ 15 - 5
src/components/organisms/EditReplica/EditReplica.jsx

@@ -182,9 +182,9 @@ class EditReplica extends React.Component<Props, State> {
 
   isUpdateDisabled() {
     let isLoadingDestOptions = this.state.selectedPanel === 'dest_options'
-      && (providerStore.destinationSchemaLoading || providerStore.destinationOptionsLoading)
+      && (providerStore.destinationSchemaLoading || providerStore.destinationOptionsPrimaryLoading)
     let isLoadingSourceOptions = this.state.selectedPanel === 'source_options'
-      && (providerStore.sourceSchemaLoading || providerStore.sourceOptionsLoading)
+      && (providerStore.sourceSchemaLoading || providerStore.sourceOptionsPrimaryLoading)
     let isLoadingNetwork = this.state.selectedPanel === 'network_mapping' && this.props.instancesDetailsLoading
     let isLoadingStorage = this.state.selectedPanel === 'storage_mapping'
       && (this.props.instancesDetailsLoading || endpointStore.storageLoading)
@@ -395,14 +395,23 @@ class EditReplica extends React.Component<Props, State> {
   }
 
   renderOptions(type: 'source' | 'destination') {
-    let loading = type === 'source' ? providerStore.sourceSchemaLoading : providerStore.destinationSchemaLoading
+    let loading = type === 'source' ? (providerStore.sourceSchemaLoading || providerStore.sourceOptionsPrimaryLoading)
+      : (providerStore.destinationSchemaLoading || providerStore.destinationOptionsPrimaryLoading)
     if (loading) {
       return this.renderLoading(`Loading ${type === 'source' ? 'source' : 'target'} options ...`)
     }
-    let optionsLoading = type === 'source' ? providerStore.sourceOptionsLoading : providerStore.destinationOptionsLoading
+    let optionsLoading = type === 'source' ? providerStore.sourceOptionsSecondaryLoading
+      : providerStore.destinationOptionsSecondaryLoading
     let schema = type === 'source' ? providerStore.sourceSchema : providerStore.destinationSchema
     let fields = this.props.type === 'replica' ? schema.filter(f => !f.readOnly) : schema
-
+    let extraOptionsConfig = configLoader.config.extraOptionsApiCalls.find(o => {
+      let provider = type === 'source' ? this.props.sourceEndpoint.type : this.props.destinationEndpoint.type
+      return o.name === provider && o.types.find(t => t === type)
+    })
+    let optionsLoadingSkipFields = []
+    if (extraOptionsConfig) {
+      optionsLoadingSkipFields = extraOptionsConfig.requiredFields
+    }
     return (
       <WizardOptions
         wizardType={`replica-${type}-options-edit`}
@@ -420,6 +429,7 @@ class EditReplica extends React.Component<Props, State> {
         useAdvancedOptions
         layout="modal"
         optionsLoading={optionsLoading}
+        optionsLoadingSkipFields={[...optionsLoadingSkipFields, 'description', 'execute_now', 'execute_now_options', 'default_storage']}
       />
     )
   }

+ 4 - 2
src/components/organisms/WizardOptions/WizardOptions.jsx

@@ -86,14 +86,15 @@ type Props = {
   storageConfigDefault?: string,
   onAdvancedOptionsToggle?: (showAdvanced: boolean) => void,
   wizardType: string,
-  loading?: boolean,
   columnStyle?: { [string]: mixed },
   oneColumnStyle?: { [string]: mixed },
   fieldWidth?: number,
   onScrollableRef?: (ref: HTMLElement) => void,
   availableHeight?: number,
   layout?: 'page' | 'modal',
+  loading?: boolean,
   optionsLoading?: boolean,
+  optionsLoadingSkipFields?: string[],
 }
 @observer
 class WizardOptions extends React.Component<Props> {
@@ -184,6 +185,7 @@ class WizardOptions extends React.Component<Props> {
         onChange: value => { this.props.onChange(field, value) },
       }
     }
+    let optionsLoadingReqFields = this.props.optionsLoadingSkipFields || []
     return (
       <FieldInputStyled
         layout={this.props.layout || 'page'}
@@ -198,7 +200,7 @@ class WizardOptions extends React.Component<Props> {
         width={this.props.fieldWidth || StyleProps.inputSizes.wizard.width}
         label={field.label}
         nullableBoolean={field.nullableBoolean}
-        disabledLoading={this.props.optionsLoading}
+        disabledLoading={this.props.optionsLoading && !optionsLoadingReqFields.find(fn => fn === field.name)}
         {...additionalProps}
       />
     )

+ 21 - 4
src/components/organisms/WizardPageContent/WizardPageContent.jsx

@@ -33,6 +33,8 @@ import WizardSummary from '../WizardSummary'
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 import { providerTypes, wizardPages } from '../../../constants'
+import configLoader from '../../../utils/Config'
+
 import type { WizardData, WizardPage } from '../../../types/WizardData'
 import type { Endpoint, StorageBackend, StorageMap } from '../../../types/Endpoint'
 import type { Instance, Nic, Disk } from '../../../types/Instance'
@@ -279,6 +281,19 @@ class WizardPageContent extends React.Component<Props, State> {
   renderBody() {
     let body = null
 
+    let getOptionsLoadingSkipFields = (type: 'source' | 'destination') => {
+      let extraOptionsConfig = configLoader.config.extraOptionsApiCalls.find(o => {
+        let provider = type === 'source' ? this.props.wizardData.source && this.props.wizardData.source.type
+          : this.props.wizardData.target && this.props.wizardData.target.type
+        return o.name === provider && o.types.find(t => t === type)
+      })
+      let optionsLoadingRequiredFields = []
+      if (extraOptionsConfig) {
+        optionsLoadingRequiredFields = extraOptionsConfig.requiredFields
+      }
+      return optionsLoadingRequiredFields
+    }
+
     switch (this.props.page.id) {
       case 'type':
         body = (
@@ -338,8 +353,9 @@ class WizardPageContent extends React.Component<Props, State> {
       case 'source-options':
         body = (
           <WizardOptions
-            loading={this.props.providerStore.sourceSchemaLoading}
-            optionsLoading={this.props.providerStore.sourceOptionsLoading}
+            loading={this.props.providerStore.sourceSchemaLoading || this.props.providerStore.sourceOptionsPrimaryLoading}
+            optionsLoading={this.props.providerStore.sourceOptionsSecondaryLoading}
+            optionsLoadingSkipFields={getOptionsLoadingSkipFields('source')}
             fields={this.props.providerStore.sourceSchema}
             onChange={this.props.onSourceOptionsChange}
             data={this.props.wizardData.sourceOptions}
@@ -352,8 +368,9 @@ class WizardPageContent extends React.Component<Props, State> {
       case 'dest-options':
         body = (
           <WizardOptions
-            loading={this.props.providerStore.destinationSchemaLoading}
-            optionsLoading={this.props.providerStore.destinationOptionsLoading}
+            loading={this.props.providerStore.destinationSchemaLoading || this.props.providerStore.destinationOptionsPrimaryLoading}
+            optionsLoading={this.props.providerStore.destinationOptionsSecondaryLoading}
+            optionsLoadingSkipFields={[...getOptionsLoadingSkipFields('destination'), 'description', 'execute_now', 'execute_now_options', 'default_storage']}
             selectedInstances={this.props.wizardData.selectedInstances}
             fields={this.props.providerStore.destinationSchema}
             onChange={this.props.onDestOptionsChange}

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

@@ -491,7 +491,7 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
               targetEndpointsLoading={endpointStore.loading}
               loadingVmSizes={this.state.loadingTargetVmSizes}
               sourceEndpointsLoading={endpointsLoading}
-              targetOptionsLoading={providerStore.destinationOptionsLoading}
+              targetOptionsLoading={providerStore.destinationOptionsPrimaryLoading || providerStore.destinationOptionsSecondaryLoading}
               targetEndpoints={this.getTargetEndpoints()}
               targetEndpoint={localData.endpoint}
               onTargetEndpointChange={endpoint => { this.handleTargetEndpointChange(endpoint) }}

+ 1 - 0
src/sources/ProviderSource.js

@@ -59,6 +59,7 @@ class ProviderSource {
     let response = await Api.send({
       url: `${servicesUrl.coriolis}/${Api.projectId}/endpoints/${endpointId}/${callName}${envString}`,
       cache,
+      cancelId: endpointId,
     })
     return response.data[fieldName]
   }

+ 40 - 12
src/stores/ProviderStore.js

@@ -17,6 +17,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import { observable, action, computed, runInAction } from 'mobx'
 
 import ProviderSource from '../sources/ProviderSource'
+import apiCaller from '../utils/ApiCaller'
+
 import configLoader from '../utils/Config'
 import { providerTypes } from '../constants'
 import { OptionsSchemaPlugin } from '../plugins/endpoint'
@@ -83,9 +85,15 @@ class ProviderStore {
   @observable destinationSchema: Field[] = []
   @observable destinationSchemaLoading: boolean = false
   @observable destinationOptions: OptionValues[] = []
-  @observable destinationOptionsLoading: boolean = false
+  // Set to true while loading the options call for the first set of options
+  @observable destinationOptionsPrimaryLoading: boolean = false
+  // Set to true while loading the options call with a set of values in the 'env' parameter
+  @observable destinationOptionsSecondaryLoading: boolean = false
   @observable sourceOptions: OptionValues[] = []
-  @observable sourceOptionsLoading: boolean = false
+  // Set to true while loading the options call for the first set of options
+  @observable sourceOptionsPrimaryLoading: boolean = false
+  // Set to true while loading the options call with a set of values in the 'env' parameter
+  @observable sourceOptionsSecondaryLoading: boolean = false
   @observable sourceSchema: Field[] = []
   @observable sourceSchemaLoading: boolean = false
 
@@ -203,7 +211,9 @@ class ProviderStore {
       return []
     }
 
-    this.getOptionsValuesStart(optionsType)
+    let canceled = false
+    apiCaller.cancelRequests(endpointId)
+    this.getOptionsValuesStart(optionsType, !envData)
 
     try {
       let options = await ProviderSource.getOptionsValues(optionsType, endpointId, envData, useCache)
@@ -211,6 +221,10 @@ class ProviderStore {
       return options
     } catch (err) {
       console.error(err)
+      canceled = err ? err.canceled : false
+      if (canceled) {
+        return optionsType === 'source' ? [...this.sourceOptions] : [...this.destinationOptions]
+      }
       let schemaType = optionsType === 'source' ? this.lastSourceSchemaType : this.lastDestinationSchemaType
       if (!envData) {
         return []
@@ -218,25 +232,39 @@ class ProviderStore {
       let newOptions = await this.loadOptionsSchema({ providerName, schemaType, optionsType })
       return newOptions
     } finally {
-      this.getOptionsValuesDone(optionsType)
+      if (!canceled) {
+        this.getOptionsValuesDone(optionsType, !envData)
+      }
     }
   }
 
-  @action getOptionsValuesStart(optionsType: 'source' | 'destination') {
+  @action getOptionsValuesStart(optionsType: 'source' | 'destination', isPrimary: boolean) {
     if (optionsType === 'source') {
-      this.sourceOptionsLoading = true
-      this.sourceOptions = []
-    } else {
-      this.destinationOptionsLoading = true
+      if (isPrimary) {
+        this.sourceOptions = []
+        this.sourceOptionsPrimaryLoading = true
+      } else {
+        this.sourceOptionsSecondaryLoading = true
+      }
+    } else if (isPrimary) {
       this.destinationOptions = []
+      this.destinationOptionsPrimaryLoading = true
+    } else {
+      this.destinationOptionsSecondaryLoading = true
     }
   }
 
-  @action getOptionsValuesDone(optionsType: 'source' | 'destination') {
+  @action getOptionsValuesDone(optionsType: 'source' | 'destination', isPrimary: boolean) {
     if (optionsType === 'source') {
-      this.sourceOptionsLoading = false
+      if (isPrimary) {
+        this.sourceOptionsPrimaryLoading = false
+      } else {
+        this.sourceOptionsSecondaryLoading = false
+      }
+    } else if (isPrimary) {
+      this.destinationOptionsPrimaryLoading = false
     } else {
-      this.destinationOptionsLoading = false
+      this.destinationOptionsSecondaryLoading = false
     }
   }