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

Add Coriolis Regions support to all providers

When creating or editing an endpoint, a Coriolis Region mandatory
multiple selection dropdown field is shown. The dropdown is populated
using the new Coriolis Regions API.

The new 'mapped_regions' field is added to the endpoint payload body.

Also includes some new scheduling status labels used by the Status Pill
and Status Icon components.
Sergiu Miclea 5 лет назад
Родитель
Сommit
eda0875725

+ 2 - 1
.eslintrc

@@ -56,6 +56,7 @@
     "react/jsx-props-no-spreading": "off",
     "max-classes-per-file": "off",
     "prefer-promise-reject-errors": "off",
-    "import/prefer-default-export": "off"
+    "import/prefer-default-export": "off",
+    "no-param-reassign": "off"
   }
 }

+ 1 - 1
README.md

@@ -25,7 +25,7 @@ Your server will be running at `http://localhost:3000/` (the port is configurabl
 
 ## Development mode
 
-- set env. variable `ENV='development'`
+- set env. variable `NODE_MODE='development'`
 - run `yarn ui-dev` to start local development server (starts on port 3001)
 - run `yarn server-dev` to start the express server in development mode
 

+ 1 - 0
src/@types/Endpoint.ts

@@ -26,6 +26,7 @@ export type Endpoint = {
   description: string,
   type: ProviderTypes,
   created_at: Date,
+  mapped_regions: string[],
   connection_info: {
     secret_ref?: string,
     host?: string,

+ 21 - 0
src/@types/Region.ts

@@ -0,0 +1,21 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+export type Region = {
+  id: string,
+  name: string,
+  description: string,
+  enabled: boolean,
+  mapped_endpoints: string[],
+}

+ 2 - 0
src/components/atoms/StatusIcon/StatusIcon.tsx

@@ -54,6 +54,7 @@ const statuses = (status: any, props: any) => {
       return css`
       background-image: url('${props.hollow ? successHollowImage : successImage}');
     `
+    case 'STARTING':
     case 'RUNNING':
     case 'PENDING':
       return css`
@@ -70,6 +71,7 @@ const statuses = (status: any, props: any) => {
       return css`
         background-image: url('${pendingImage}');
       `
+    case 'FAILED_TO_SCHEDULE':
     case 'ERROR':
       return css`
         background-image: url('${props.hollow ? errorHollowImage : errorImage}');

+ 2 - 0
src/components/atoms/StatusIcon/story.tsx

@@ -26,6 +26,7 @@ const STATUSES = [
   'SCHEDULED',
   'UNSCHEDULED',
   'COMPLETED',
+  'STARTING',
   'RUNNING',
   'PENDING',
   'CANCELLING',
@@ -35,6 +36,7 @@ const STATUSES = [
   'CANCELED_FOR_DEBUGGING',
   'FORCE_CANCELED',
   'WARNING',
+  'FAILED_TO_SCHEDULE',
   'ERROR',
   'DEADLOCKED',
   'STRANDED_AFTER_DEADLOCK',

+ 3 - 0
src/components/atoms/StatusPill/StatusPill.tsx

@@ -27,6 +27,7 @@ const LABEL_MAP: { [status: string]: string } = {
   STRANDED_AFTER_DEADLOCK: 'DEADLOCKED',
   CANCELED_AFTER_COMPLETION: 'CANCELED',
   CANCELLING_AFTER_COMPLETION: 'CANCELLING',
+  FAILED_TO_SCHEDULE: 'UNSCHEDULABLE',
 }
 
 const statuses = (status: any) => {
@@ -37,6 +38,7 @@ const statuses = (status: any) => {
         color: white;
         border-color: transparent;
       `
+    case 'FAILED_TO_SCHEDULE':
     case 'ERROR':
       return css`
         background: ${Palette.alert};
@@ -58,6 +60,7 @@ const statuses = (status: any) => {
         color: ${Palette.primary};
         border-color: ${Palette.primary};
       `
+    case 'STARTING':
     case 'RUNNING':
     case 'PENDING':
       return css`

+ 2 - 0
src/components/atoms/StatusPill/story.tsx

@@ -25,6 +25,7 @@ const STATUSES = [
   'SCHEDULED',
   'UNSCHEDULED',
   'COMPLETED',
+  'STARTING',
   'RUNNING',
   'PENDING',
   'CANCELLING',
@@ -34,6 +35,7 @@ const STATUSES = [
   'CANCELED_FOR_DEBUGGING',
   'FORCE_CANCELED',
   'ERROR',
+  'FAILED_TO_SCHEDULE',
   'DEADLOCKED',
   'STRANDED_AFTER_DEADLOCK',
 ]

+ 1 - 1
src/components/molecules/FieldInput/FieldInput.tsx

@@ -275,7 +275,7 @@ class FieldInput extends React.Component<Props> {
         width={this.props.width}
         disabled={this.props.disabled}
         disabledLoading={this.props.disabledLoading}
-        noSelectionMessage="Choose values"
+        noSelectionMessage={this.props.noSelectionMessage || 'Choose values'}
         noItemsMessage={this.props.noItemsMessage}
         items={items}
         selectedItems={selectedItems}

+ 8 - 1
src/components/organisms/Endpoint/Endpoint.tsx

@@ -258,7 +258,14 @@ class Endpoint extends React.Component<Props, State> {
       const endpoint: any = { ...prevState.endpoint }
 
       items.forEach(item => {
-        endpoint[item.field.name] = item.value
+        let value = item.value
+        if (item.field.type === 'array') {
+          const arrayItems = endpoint[item.field.name] || []
+          value = arrayItems.find((v: any) => v === item.value)
+            ? arrayItems.filter((v: any) => v !== item.value) : [...arrayItems, item.value]
+        }
+
+        endpoint[item.field.name] = value
       })
 
       return { endpoint }

+ 15 - 0
src/components/organisms/EndpointDetailsContent/EndpointDetailsContent.tsx

@@ -31,6 +31,7 @@ import Palette from '../../styleUtils/Palette'
 import DateUtils from '../../../utils/DateUtils'
 import LabelDictionary from '../../../utils/LabelDictionary'
 import configLoader from '../../../utils/Config'
+import { Region } from '../../../@types/Region'
 
 const Wrapper = styled.div<any>`
   ${StyleProps.exactWidth(StyleProps.contentWidth)}
@@ -77,6 +78,7 @@ const LinkStyled = styled(Link)`
 
 type Props = {
   item: Endpoint | null,
+  regions: Region[],
   connectionInfo: Endpoint['connection_info'] | null,
   loading: boolean,
   usage: { migrations: MainItem[], replicas: MainItem[] },
@@ -163,6 +165,15 @@ class EndpointDetailsContent extends React.Component<Props> {
     return <CopyValue data-test-id={dataTestId ? `edContent-${dataTestId}` : undefined} value={value} maxWidth="90%" />
   }
 
+  renderRegions() {
+    return (
+      <span>
+        {this.props.item?.mapped_regions
+          .map(regionId => this.props.regions.find(r => r.id === regionId)?.name).join(', ') || '-'}
+      </span>
+    )
+  }
+
   renderUsage(items: MainItem[]) {
     return items.map(item => (
       <span>
@@ -200,6 +211,10 @@ class EndpointDetailsContent extends React.Component<Props> {
             <Label>Type</Label>
             {this.renderValue(type || '', 'type')}
           </Field>
+          <Field>
+            <Label>Coriolis Regions</Label>
+            {this.renderRegions()}
+          </Field>
           <Field>
             <Label>Description</Label>
             {description ? <CopyMultilineValue data-test-id="edContent-description" value={description} /> : <Value>-</Value>}

+ 7 - 1
src/components/pages/EndpointDetailsPage/EndpointDetailsPage.tsx

@@ -38,6 +38,7 @@ import type { MainItem } from '../../../@types/MainItem'
 import Palette from '../../styleUtils/Palette'
 
 import endpointImage from './images/endpoint.svg'
+import regionStore from '../../../stores/RegionStore'
 
 const Wrapper = styled.div<any>``
 
@@ -192,7 +193,11 @@ class EndpointDetailsPage extends React.Component<Props, State> {
 
     this.loadEndpoints()
 
-    await Promise.all([replicaStore.getReplicas(), migrationStore.getMigrations()])
+    await Promise.all([
+      replicaStore.getReplicas(),
+      migrationStore.getMigrations(),
+      regionStore.getRegions(),
+    ])
     this.setState({ endpointUsage: this.getEndpointUsage() })
   }
 
@@ -250,6 +255,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
           contentComponent={(
             <EndpointDetailsContent
               item={endpoint}
+              regions={regionStore.regions}
               usage={this.state.endpointUsage}
               loading={endpointStore.connectionInfoLoading || endpointStore.loading}
               connectionInfo={endpointStore.connectionInfo}

+ 4 - 12
src/plugins/endpoint/azure/ConnectionSchemaPlugin.ts

@@ -15,8 +15,8 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import {
   connectionSchemaToFields,
   defaultSchemaToFields,
-  generateField,
   fieldsToPayload,
+  generateBaseFields,
 } from '../default/ConnectionSchemaPlugin'
 import { Endpoint } from '../../../@types/Endpoint'
 
@@ -95,20 +95,14 @@ export default class ConnectionSchemaParser {
     let fields = azureConnectionParse(schema)
 
     fields = [
-      generateField('name', 'Endpoint Name', true),
-      generateField('description', 'Endpoint Description'),
+      ...generateBaseFields(),
       ...fields,
     ]
 
     return fields
   }
 
-  static parseFieldsToPayload(data: any, schema: any) {
-    const payload: any = {}
-
-    payload.name = data.name
-    payload.description = data.description
-
+  static parseConnectionInfoToPayload(data: any, schema: any) {
     const connectionInfo: any = fieldsToPayload(data, schema)
     const loginType = data.login_type || 'user_credentials'
     connectionInfo[loginType] = fieldsToPayload(data, schema.properties[loginType])
@@ -134,9 +128,7 @@ export default class ConnectionSchemaParser {
       connectionInfo.secret_ref = data.secret_ref
     }
 
-    payload.connection_info = connectionInfo
-
-    return payload
+    return connectionInfo
   }
 
   static parseConnectionResponse(endpoint: Endpoint) {

+ 2 - 1
src/plugins/endpoint/azure/ContentPlugin.tsx

@@ -158,7 +158,8 @@ class ContentPlugin extends React.Component<Props, State> {
       }
       fields.forEach((field: Field | null) => {
         if ((field?.name === 'tenant' && (isCustomCloud || this.isServicePrincipalLogin())) || field?.required) {
-          if (!this.props.getFieldValue(field)) {
+          const value = this.props.getFieldValue(field)
+          if (!value || value.length === 0) {
             invalidFields.push(field?.name)
           }
         }

+ 26 - 20
src/plugins/endpoint/default/ConnectionSchemaPlugin.ts

@@ -81,22 +81,34 @@ export const connectionSchemaToFields = (schema: SchemaProperties) => {
   return fields
 }
 
-export const generateField = (name: string, label: string, required: boolean = false, type: string = 'string', defaultValue: any = null) => {
-  const field = {
-    name,
-    label,
-    type,
-    required,
-    default: undefined,
+export const generateField = (fieldBuilderProps: any) => {
+  const field = { ...fieldBuilderProps }
+
+  if (!field.type) {
+    field.type = 'string'
   }
 
-  if (defaultValue) {
-    field.default = defaultValue
+  if (!field.required) {
+    field.required = false
   }
+  field.default = undefined
 
   return field
 }
 
+export const generateBaseFields = () => [
+  generateField({ name: 'name', title: 'Name', required: true }),
+  generateField({
+    name: 'mapped_regions',
+    title: 'Coriolis Regions',
+    required: true,
+    type: 'array',
+    noItemsMessage: 'No regions available',
+    noSelectionMessage: 'Choose regions',
+  }),
+  generateField({ name: 'description', title: 'Description' }),
+]
+
 export const fieldsToPayload = (data: { [prop: string]: any }, schema: SchemaProperties) => {
   const info: any = {}
   const usableSchema: any = schema
@@ -134,28 +146,22 @@ export default class ConnectionSchemaParser {
     let fields = connectionSchemaToFields(schema.oneOf[0])
 
     fields = [
-      generateField('name', 'Endpoint Name', true),
-      generateField('description', 'Endpoint Description'),
+      ...generateBaseFields(),
       ...fields,
     ]
 
     return fields
   }
 
-  static parseFieldsToPayload(data: { [prop: string]: any }, schema: any) {
-    const payload: any = {}
-
-    payload.name = data.name
-    payload.description = data.description
-
+  static parseConnectionInfoToPayload(data: { [prop: string]: any }, schema: any) {
     const schemaRoot = schema.oneOf ? schema.oneOf[0] : schema
-    payload.connection_info = fieldsToPayload(data, schemaRoot)
+    const connection_info = fieldsToPayload(data, schemaRoot)
 
     if (data.secret_ref) {
-      payload.connection_info.secret_ref = data.secret_ref
+      connection_info.secret_ref = data.secret_ref
     }
 
-    return payload
+    return connection_info
   }
 
   static parseConnectionResponse(endpoint: Endpoint) {

+ 1 - 1
src/plugins/endpoint/default/ContentPlugin.tsx

@@ -78,7 +78,7 @@ class ContentPlugin extends React.Component<Props> {
     const invalidFields = this.props.connectionInfoSchema.filter(field => {
       if (field.required) {
         const value = this.props.getFieldValue(field)
-        return !value
+        return !value || value.length === 0
       }
       return false
     }).map(f => f.name)

+ 2 - 2
src/plugins/endpoint/oci/ConnectionSchemaPlugin.ts

@@ -29,8 +29,8 @@ export default class ConnectionSchemaParser {
     return fields
   }
 
-  static parseFieldsToPayload(data: { [prop: string]: any }, schema: Schema) {
-    const payload = DefaultConnectionSchemaParser.parseFieldsToPayload(data, schema)
+  static parseConnectionInfoToPayload(data: { [prop: string]: any }, schema: Schema) {
+    const payload = DefaultConnectionSchemaParser.parseConnectionInfoToPayload(data, schema)
     return payload
   }
 

+ 4 - 7
src/plugins/endpoint/openstack/ConnectionSchemaPlugin.ts

@@ -21,6 +21,7 @@ import DefaultConnectionSchemaParser from '../default/ConnectionSchemaPlugin'
 const customSort = (fields: Field[]) => {
   const sortPriority: any = {
     name: 1,
+    mapped_regions: 1.5,
     description: 2,
     username: 3,
     password: 4,
@@ -80,19 +81,15 @@ export default class ConnectionSchemaParser {
     return fields
   }
 
-  static parseFieldsToPayload(data: { [prop: string]: any }, schema: Schema) {
+  static parseConnectionInfoToPayload(data: { [prop: string]: any }, schema: Schema) {
     if (data.openstack_use_current_user) {
-      return {
-        name: data.name,
-        description: data.description,
-        connection_info: {},
-      }
+      return {}
     }
     // eslint-disable-next-line no-param-reassign
     delete data.project_domain
     // eslint-disable-next-line no-param-reassign
     delete data.user_domain
-    const payload = DefaultConnectionSchemaParser.parseFieldsToPayload(data, schema)
+    const payload = DefaultConnectionSchemaParser.parseConnectionInfoToPayload(data, schema)
     return payload
   }
 

+ 1 - 1
src/plugins/endpoint/openstack/ContentPlugin.tsx

@@ -158,7 +158,7 @@ class ContentPlugin extends React.Component<Props, State> {
     let invalidFields = this.props.connectionInfoSchema.filter(field => {
       if (this.isFieldRequired(field)) {
         const value = this.getFieldValue(field)
-        return !value
+        return !value || value.length === 0
       }
       const inputChoice = inputChoices.find(c => c === field.name)
       if (inputChoice && this.getApiVersion() > 2) {

+ 31 - 17
src/sources/EndpointSource.ts

@@ -136,14 +136,14 @@ class EndpointSource {
   }
 
   async update(endpoint: Endpoint): Promise<Endpoint> {
-    const parsedEndpoint = SchemaParser.fieldsToPayload(endpoint)
+    const parsedConnectionInfo = SchemaParser.connectionInfoToPayload(endpoint)
 
-    if (parsedEndpoint.connection_info
-      && Object.keys(parsedEndpoint.connection_info).length > 0
-      && parsedEndpoint.connection_info.secret_ref) {
-      let uuidIndex = parsedEndpoint.connection_info.secret_ref.lastIndexOf('/')
+    if (parsedConnectionInfo
+      && Object.keys(parsedConnectionInfo).length > 0
+      && parsedConnectionInfo.secret_ref) {
+      let uuidIndex = parsedConnectionInfo.secret_ref.lastIndexOf('/')
 
-      let uuid = parsedEndpoint.connection_info.secret_ref.substr(uuidIndex + 1)
+      let uuid = parsedConnectionInfo.secret_ref.substr(uuidIndex + 1)
       let newEndpoint: any = {}
       let connectionInfo: any = {}
 
@@ -152,20 +152,23 @@ class EndpointSource {
         method: 'DELETE',
       })
 
+      const barbicanPayload = getBarbicanPayload(ObjectUtils.skipFields(parsedConnectionInfo, ['secret_ref']))
       const response = await Api.send({
         url: `${configLoader.config.servicesUrls.barbican}/v1/secrets`,
         method: 'POST',
-        data: getBarbicanPayload(ObjectUtils.skipField(parsedEndpoint.connection_info, 'secret_ref')),
+        data: barbicanPayload,
       })
 
       connectionInfo = { secret_ref: response.data.secret_ref }
       const newPayload = {
         endpoint: {
-          name: parsedEndpoint.name,
-          description: parsedEndpoint.description,
+          name: endpoint.name,
+          mapped_regions: endpoint.mapped_regions,
+          description: endpoint.description,
           connection_info: connectionInfo,
         },
       }
+
       const putResponse = await Api.send({
         url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/endpoints/${endpoint.id}`,
         method: 'PUT',
@@ -193,29 +196,37 @@ class EndpointSource {
     const response = await Api.send({
       url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/endpoints/${endpoint.id}`,
       method: 'PUT',
-      data: { endpoint: parsedEndpoint },
+      data: {
+        endpoint: {
+          name: endpoint.name,
+          mapped_regions: endpoint.mapped_regions,
+          description: endpoint.description,
+          connection_info: parsedConnectionInfo,
+        },
+      },
     })
     return SchemaParser.parseConnectionResponse(response.data.endpoint)
   }
 
   async add(endpoint: Endpoint, skipSchemaParser: boolean = false): Promise<Endpoint> {
-    const parsedEndpoint: any = skipSchemaParser
-      ? { ...endpoint } : SchemaParser.fieldsToPayload(endpoint)
+    const parsedConnectionInfo: any = skipSchemaParser
+      ? { ...endpoint } : SchemaParser.connectionInfoToPayload(endpoint)
     let newEndpoint: any = {}
     let connectionInfo: any = {}
     if (configLoader.config.useBarbicanSecrets
-      && parsedEndpoint.connection_info && Object.keys(parsedEndpoint.connection_info).length > 0) {
+      && parsedConnectionInfo && Object.keys(parsedConnectionInfo).length > 0) {
       const response = await Api.send({
         url: `${configLoader.config.servicesUrls.barbican}/v1/secrets`,
         method: 'POST',
-        data: getBarbicanPayload(ObjectUtils.skipField(parsedEndpoint.connection_info, 'secret_ref')),
+        data: getBarbicanPayload(ObjectUtils.skipFields(parsedConnectionInfo, ['secret_ref'])),
       })
 
       connectionInfo = { secret_ref: response.data.secret_ref }
       const newPayload = {
         endpoint: {
-          name: parsedEndpoint.name,
-          description: parsedEndpoint.description,
+          name: endpoint.name,
+          mapped_regions: endpoint.mapped_regions,
+          description: endpoint.description,
           type: endpoint.type,
           connection_info: connectionInfo,
         },
@@ -250,8 +261,11 @@ class EndpointSource {
       method: 'POST',
       data: {
         endpoint: {
-          ...parsedEndpoint,
+          name: endpoint.name,
+          mapped_regions: endpoint.mapped_regions,
+          description: endpoint.description,
           type: endpoint.type,
+          connection_info: parsedConnectionInfo,
         },
       },
     })

+ 29 - 0
src/sources/RegionSource.ts

@@ -0,0 +1,29 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import Api from '../utils/ApiCaller'
+
+import configLoader from '../utils/Config'
+import { Region } from '../@types/Region'
+
+class RegionSource {
+  async getRegions(): Promise<Region[]> {
+    const response = await Api.send({
+      url: `${configLoader.config.servicesUrls.coriolis}/${Api.projectId}/regions`,
+    })
+    return response.data.regions
+  }
+}
+
+export default new RegionSource()

+ 2 - 2
src/sources/Schemas.ts

@@ -49,11 +49,11 @@ class SchemaParser {
     return fields
   }
 
-  static fieldsToPayload(data: { [prop: string]: any }) {
+  static connectionInfoToPayload(data: { [prop: string]: any }) {
     const storedSchema = this.storedConnectionsSchemas[data.type]
       || this.storedConnectionsSchemas.general
     const parsers = ConnectionSchemaPlugin.for(data.type)
-    const payload = parsers.parseFieldsToPayload(data, storedSchema)
+    const payload = parsers.parseConnectionInfoToPayload(data, storedSchema)
 
     return payload
   }

+ 10 - 0
src/stores/ProviderStore.ts

@@ -25,6 +25,7 @@ import { OptionsSchemaPlugin } from '../plugins/endpoint'
 import type { OptionValues } from '../@types/Endpoint'
 import type { Field } from '../@types/Field'
 import type { Providers, ProviderTypes } from '../@types/Providers'
+import regionStore from './RegionStore'
 
 export const getFieldChangeOptions = (config: {
   providerName: string | null,
@@ -147,11 +148,20 @@ class ProviderStore {
     return array
   }
 
+  private async setRegions(regionsField: Field | undefined) {
+    if (!regionsField) {
+      return
+    }
+    await regionStore.getRegions()
+    regionsField.enum = [...regionStore.regions]
+  }
+
   @action async getConnectionInfoSchema(providerName: ProviderTypes): Promise<void> {
     this.connectionSchemaLoading = true
 
     try {
       const fields: Field[] = await ProviderSource.getConnectionInfoSchema(providerName)
+      await this.setRegions(fields.find(f => f.name === 'mapped_regions'))
       runInAction(() => { this.connectionInfoSchema = fields })
     } finally {
       runInAction(() => { this.connectionSchemaLoading = false })

+ 39 - 0
src/stores/RegionStore.ts

@@ -0,0 +1,39 @@
+/*
+Copyright (C) 2020  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/>.
+*/
+
+import { observable, action, runInAction } from 'mobx'
+import { Region } from '../@types/Region'
+import regionSource from '../sources/RegionSource'
+
+class RegionStore {
+  @observable regions: Region[] = []
+
+  @observable loading = false
+
+  @action async getRegions() {
+    this.loading = true
+    try {
+      const regions = await regionSource.getRegions()
+      runInAction(() => {
+        this.regions = regions
+      })
+    } finally {
+      runInAction(() => {
+        this.loading = false
+      })
+    }
+  }
+}
+
+export default new RegionStore()

+ 3 - 3
src/utils/ObjectUtils.ts

@@ -28,7 +28,7 @@ class ObjectUtils {
     let result: any = {}
 
     Object.keys(object).forEach(k => {
-      if (typeof object[k] === 'object') {
+      if (typeof object[k] === 'object' && !Array.isArray(object[k])) {
         if (object[k]) {
           result = {
             ...result,
@@ -51,7 +51,7 @@ class ObjectUtils {
     return result
   }
 
-  static skipField(object: any, fieldName: string) {
+  static skipFields(object: any, fieldNames: string[]) {
     const result: any = {}
 
     if (Object.keys(object).length === 0) {
@@ -59,7 +59,7 @@ class ObjectUtils {
     }
 
     Object.keys(object).forEach(k => {
-      if (k !== fieldName) {
+      if (!fieldNames.find(fn => fn === k)) {
         result[k] = object[k]
       }
     })