Преглед изворни кода

Upload multi .endpoint and zipped .endpoint files

Uploading and drag / dropping multiple .endpoint and zipped .endpoint
files is now supported.

Any combination of .endpoint and .zip files is supported.

All uploaded endpoints are validated after they are added.
Sergiu Miclea пре 6 година
родитељ
комит
190bb811e0

+ 128 - 18
src/components/organisms/ChooseProvider/ChooseProvider.jsx

@@ -26,11 +26,16 @@ import StatusImage from '../../atoms/StatusImage'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
-import ObjectUtils from '../../../utils/ObjectUtils'
+import FileUtils from '../../../utils/FileUtils'
+import configLoader from '../../../utils/Config'
 
-import type { Endpoint } from '../../../types/Endpoint'
+import type { FileContent } from '../../../utils/FileUtils'
+import type { Endpoint, MultiValidationItem } from '../../../types/Endpoint'
+
+import MultipleUploadedEndpoints from './MultipleUploadedEndpoints'
 
 const Wrapper = styled.div`
+  min-height: 0;
   padding: 22px 0 32px 0;
   text-align: center;
 `
@@ -74,21 +79,28 @@ const LoadingText = styled.div`
   font-size: 18px;
   margin-top: 32px;
 `
-
 type Props = {
   providers: string[],
   onCancelClick: () => void,
   onProviderClick: (provider: string) => void,
   onUploadEndpoint: (endpoint: Endpoint) => void,
   loading: boolean,
+  onValidateMultipleEndpoints: (endpoints: Endpoint[]) => void,
+  onResizeUpdate?: () => void,
+  multiValidating: boolean,
+  multiValidation: MultiValidationItem[],
+  onRemoveEndpoint: (endpoint: Endpoint) => void,
+  onResetValidation: () => void,
 }
 type State = {
   highlightDropzone: boolean,
+  multipleUploadedEndpoints: (Endpoint | string)[],
 }
 @observer
 class ChooseProvider extends React.Component<Props, State> {
   state = {
     highlightDropzone: false,
+    multipleUploadedEndpoints: [],
   }
 
   fileInput: HTMLElement
@@ -98,6 +110,13 @@ class ChooseProvider extends React.Component<Props, State> {
     setTimeout(() => { this.addDragAndDrop() }, 1000)
   }
 
+  componentDidUpdate(prevProps: Props, prevState: State) {
+    if (prevState.multipleUploadedEndpoints.length !== this.state.multipleUploadedEndpoints.length
+      && this.props.onResizeUpdate) {
+      this.props.onResizeUpdate()
+    }
+  }
+
   componentWillUnmount() {
     this.removeDragDrop()
   }
@@ -128,8 +147,12 @@ class ChooseProvider extends React.Component<Props, State> {
       listener: async e => {
         e.preventDefault()
         this.setState({ highlightDropzone: false })
-        let text = await ObjectUtils.readFromFileList(e.dataTransfer.files)
-        this.processFileContent(text)
+        let filesContents = await FileUtils.readContentFromFileList(e.dataTransfer.files)
+        if (filesContents.length === 1) {
+          this.processOneFileContent(filesContents[0].content)
+        } else {
+          this.processMultipleFilesContents(filesContents)
+        }
       },
     }]
 
@@ -145,29 +168,114 @@ class ChooseProvider extends React.Component<Props, State> {
     this.dragDropListeners = []
   }
 
-  processFileContent(content: ?string) {
-    if (!content) {
-      return
+  parseEndpoint(content: string): Endpoint {
+    let endpoint: Endpoint = JSON.parse(content)
+    if (!endpoint.name || !endpoint.type || !this.props.providers.find(p => p === endpoint.type)) {
+      throw new Error()
     }
+    delete endpoint.id
+    return endpoint
+  }
+
+  processOneFileContent(content: string) {
+    this.props.onResetValidation()
     try {
-      let endpoint: Endpoint = JSON.parse(content)
-      if (!endpoint.name || !endpoint.type || !this.props.providers.find(p => p === endpoint.type)) {
-        throw new Error()
-      }
-      delete endpoint.id
+      let endpoint = this.parseEndpoint(content)
       this.chooseEndpoint(endpoint)
     } catch (err) {
       notificationStore.alert('Invalid .endpoint file', 'error')
     }
   }
 
+  processMultipleFilesContents(filesContents: FileContent[]) {
+    this.props.onResetValidation()
+    let uniqueNames: { [string]: number } = {}
+    let endpoints = filesContents.map(fileContent => {
+      try {
+        let endpoint = this.parseEndpoint(fileContent.content)
+        let key = `${endpoint.type}${endpoint.name}`
+        if (uniqueNames[key] === undefined) {
+          uniqueNames[key] = 0
+        } else {
+          uniqueNames[key] += 1
+          endpoint.name = `${endpoint.name} (${uniqueNames[key]})`
+        }
+        return endpoint
+      } catch (err) {
+        return fileContent.name
+      }
+    })
+
+    let sortPriority = configLoader.config.providerSortPriority
+    endpoints.sort((a, b) => {
+      if (typeof a === 'string' && typeof b === 'string') {
+        return a.localeCompare(b)
+      }
+      if (typeof a === 'string') {
+        return 1
+      }
+      if (typeof b === 'string') {
+        return -1
+      }
+      if (sortPriority[a.type] && sortPriority[b.type]) {
+        return (sortPriority[a.type] - sortPriority[b.type]) || a.type.localeCompare(b.type)
+      }
+      if (sortPriority[a.type]) {
+        return -1
+      }
+      if (sortPriority[b.type]) {
+        return 1
+      }
+      return a.type.localeCompare(b.type)
+    })
+
+    this.setState({ multipleUploadedEndpoints: endpoints })
+  }
+
   chooseEndpoint(endpoint: Endpoint) {
     this.props.onUploadEndpoint(endpoint)
   }
 
   async handleFileUpload(files: FileList) {
-    let text = await ObjectUtils.readFromFileList(files)
-    this.processFileContent(text)
+    let filesContents = await FileUtils.readContentFromFileList(files)
+    if (filesContents.length === 1) {
+      this.processOneFileContent(filesContents[0].content)
+    } else {
+      this.processMultipleFilesContents(filesContents)
+    }
+  }
+
+  handleRemoveUploadedEndpoint(endpoint: Endpoint | string, isAdded: boolean) {
+    let multipleUploadedEndpoints = this.state.multipleUploadedEndpoints.filter(e => {
+      if (typeof e === 'string' && typeof endpoint === 'string') {
+        return e !== endpoint
+      }
+      if (typeof e !== 'string' && typeof endpoint !== 'string') {
+        return e.name !== endpoint.name || e.type !== endpoint.type
+      }
+      return true
+    })
+    if (isAdded && typeof endpoint !== 'string') {
+      this.props.onRemoveEndpoint(endpoint)
+    }
+    this.setState({ multipleUploadedEndpoints })
+  }
+
+  renderMultipleUploadedEndpoints() {
+    return (
+      <MultipleUploadedEndpoints
+        endpoints={this.state.multipleUploadedEndpoints}
+        onBackClick={() => { this.setState({ multipleUploadedEndpoints: [] }) }}
+        onRemove={(e, isAdded) => { this.handleRemoveUploadedEndpoint(e, isAdded) }}
+        validating={this.props.multiValidating}
+        multiValidation={this.props.multiValidation}
+        onValidateClick={() => {
+          // $FlowIgnore
+          this.props.onValidateMultipleEndpoints(this.state.multipleUploadedEndpoints.filter(e => typeof e !== 'string'))
+        }}
+        onDone={this.props.onCancelClick}
+      />
+    )
   }
 
   renderLoading() {
@@ -207,13 +315,14 @@ class ChooseProvider extends React.Component<Props, State> {
           <UploadMessage>
             You can
             &nbsp;<UploadMessageButton onClick={() => { this.fileInput.click() }}>upload</UploadMessageButton>&nbsp;
-            or drop a .endpoint file.
+            or drop multiple .endpoint and zipped .endpoint files.
           </UploadMessage>
         </Upload>
         <FakeFileInput
           type="file"
           innerRef={r => { this.fileInput = r }}
-          accept=".endpoint"
+          accept=".endpoint,.zip"
+          multiple
           onChange={e => { this.handleFileUpload(e.target.files) }}
         />
         <Button secondary onClick={this.props.onCancelClick} data-test-id="cProvider-cancelButton">Cancel</Button>
@@ -224,8 +333,9 @@ class ChooseProvider extends React.Component<Props, State> {
   render() {
     return (
       <Wrapper>
-        {this.renderProviders()}
+        {this.state.multipleUploadedEndpoints.length === 0 ? this.renderProviders() : null}
         {this.renderLoading()}
+        {this.state.multipleUploadedEndpoints.length > 0 ? this.renderMultipleUploadedEndpoints() : null}
       </Wrapper>
     )
   }

+ 250 - 0
src/components/organisms/ChooseProvider/MultipleUploadedEndpoints.jsx

@@ -0,0 +1,250 @@
+/*
+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/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import type { Endpoint, MultiValidationItem } from '../../../types/Endpoint'
+
+import StatusIcon from '../../atoms/StatusIcon'
+import Button from '../../atoms/Button'
+import EndpointLogos from '../../atoms/EndpointLogos'
+import LoadingButton from '../../molecules/LoadingButton'
+
+import deleteImage from './images/delete.svg'
+import deleteHoverImage from './images/delete-hover.svg'
+import DomUtils from '../../../utils/DomUtils'
+import notificationStore from '../../../stores/NotificationStore'
+
+const Wrapper = styled.div`
+  min-height: 0;
+`
+const Buttons = styled.div`
+  display: flex;
+  justify-content: space-between;
+  margin-top: 32px;
+  flex-shrink: 0;
+  padding: 0 32px;
+`
+const DeleteButton = styled.div`
+  width: 16px;
+  height: 16px;
+  background: url('${deleteImage}') center no-repeat;
+  cursor: pointer;
+
+  &:hover {
+    background: url('${deleteHoverImage}') center no-repeat;
+  }
+`
+const Content = styled.div`
+  overflow: auto;
+  display: flex;
+  flex-direction: column;
+  margin: 0 32px;
+  min-height: 200px;
+  max-height: 384px;
+  text-align: left;
+`
+const InvalidEndpoint = styled.div`
+  margin-bottom: 8px;
+`
+const EndpointItem = styled.div`
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+`
+const EndpointLogoWrapper = styled.div`
+  min-width: 110px;
+`
+const EndpointData = styled.div`
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-grow: 1;
+  overflow: hidden;
+`
+const EndpointName = styled.div`
+  overflow: hidden;
+  text-overflow: ellipsis;
+`
+const EndpointOptions = styled.div`
+  display: flex;
+  align-items: center;
+`
+const EndpointStatus = styled.div`
+  margin: 0 8px;
+`
+type Props = {
+  endpoints: (Endpoint | string)[],
+  multiValidation: MultiValidationItem[],
+  validating: boolean,
+  onBackClick: () => void,
+  onRemove: (endpoint: Endpoint, isAdded: boolean) => void,
+  onValidateClick: () => void,
+  onDone: () => void,
+}
+type State = {
+  validationDone: boolean,
+}
+@observer
+class MultipleUploadedEndpoints extends React.Component<Props, State> {
+  state = {
+    validationDone: false,
+  }
+
+  componentWillReceiveProps(prevProps: Props) {
+    if (prevProps.validating && !this.props.validating) {
+      this.setState({ validationDone: true })
+    }
+  }
+
+  handleRemove(uploadedEndpoint: Endpoint) {
+    let multiEndpoint = this.props.multiValidation.find(mv => mv.endpoint.name === uploadedEndpoint.name
+      && mv.endpoint.type === uploadedEndpoint.type)
+    if (multiEndpoint) {
+      this.props.onRemove(multiEndpoint.endpoint, true)
+    } else {
+      this.props.onRemove(uploadedEndpoint, false)
+    }
+  }
+
+  copyErrorMessae(e: Event, message: string) {
+    if (e && e.stopPropagation) e.stopPropagation()
+
+    let succesful = DomUtils.copyTextToClipboard(message)
+
+    if (succesful) {
+      notificationStore.alert('The message has been copied to clipboard.')
+    } else {
+      notificationStore.alert('The message couldn\'t be copied', 'error')
+    }
+  }
+
+  renderButtons() {
+    let actionButton = null
+
+    if (this.props.validating) {
+      actionButton = <LoadingButton large>Validate and save</LoadingButton>
+    } else if (this.state.validationDone) {
+      actionButton = (
+        <Button
+          large
+          primary
+          onClick={this.props.onDone}
+        >Done</Button>
+      )
+    } else {
+      actionButton = (
+        <Button
+          large
+          primary
+          onClick={this.props.onValidateClick}
+        >Validate and save</Button>
+      )
+    }
+
+    return (
+      <Buttons>
+        <Button
+          large
+          secondary
+          onClick={this.props.onBackClick}
+        >Back</Button>
+        {actionButton}
+      </Buttons>
+    )
+  }
+
+  renderStatus(endpoint: Endpoint) {
+    let validationItem = this.props.multiValidation.find(v => v.endpoint.name === endpoint.name
+      && v.endpoint.type === endpoint.type)
+
+    if (!validationItem) {
+      return null
+    }
+
+    if (validationItem.validating) {
+      return (
+        <StatusIcon status="RUNNING" />
+      )
+    }
+    let validation = validationItem.validation
+    if (validation) {
+      if (validation.valid) {
+        return (
+          <StatusIcon status="COMPLETED" />
+        )
+      }
+      return (
+        <StatusIcon
+          status="WARNING"
+          onClick={e => { this.copyErrorMessae(e, validation.message) }}
+          data-tip={validation.message}
+          style={{ cursor: 'pointer' }}
+        />
+      )
+    }
+
+    return null
+  }
+
+  renderContent() {
+    return (
+      <Content>
+        {this.props.endpoints.map((endpoint, i) => {
+          if (typeof endpoint === 'string') {
+            return (
+              <InvalidEndpoint key={i}>
+                File may contain an unsupported provider type: {endpoint}
+              </InvalidEndpoint>
+            )
+          }
+          return (
+            <EndpointItem key={`${endpoint.name}${String(endpoint.type)}`}>
+              <EndpointLogoWrapper>
+                <EndpointLogos
+                  endpoint={endpoint.type}
+                  height={32}
+                />
+              </EndpointLogoWrapper>
+              <EndpointData>
+                <EndpointName>{endpoint.name}</EndpointName>
+                <EndpointOptions>
+                  <EndpointStatus>
+                    {this.renderStatus(endpoint)}
+                  </EndpointStatus>
+                  <DeleteButton onClick={() => { this.handleRemove(endpoint) }} />
+                </EndpointOptions>
+              </EndpointData>
+            </EndpointItem>
+          )
+        })}
+      </Content>
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderContent()}
+        {this.renderButtons()}
+      </Wrapper>
+    )
+  }
+}
+
+export default MultipleUploadedEndpoints

+ 13 - 0
src/components/organisms/ChooseProvider/images/delete-hover.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" 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 -->
+
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Icon/Delete/Fill">
+            <circle id="Oval-2" fill="#F91661" cx="8" cy="8" r="8"></circle>
+            <path d="M4,8 L12,8" id="Line" stroke="#FFFFFF" stroke-width="1.5" stroke-linejoin="round"></path>
+        </g>
+    </g>
+</svg>

+ 15 - 0
src/components/organisms/ChooseProvider/images/delete.svg

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="16px" height="16px" viewBox="0 0 16 16" 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 -->
+
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Elements/Schedule-Line" transform="translate(-816.000000, -24.000000)" stroke="#F91661" stroke-width="1.5">
+            <g id="Icon/Delete/Outline" transform="translate(816.000000, 24.000000)">
+                <circle id="Oval-2" cx="8" cy="8" r="7.25"></circle>
+                <path d="M4,8 L12,8" id="Line"></path>
+            </g>
+        </g>
+    </g>
+</svg>

+ 3 - 3
src/components/organisms/Licence/Licence.jsx

@@ -27,7 +27,7 @@ import CopyValue from '../../atoms/CopyValue'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
-import ObjectUtils from '../../../utils/ObjectUtils'
+import FileUtils from '../../../utils/FileUtils'
 
 import type { Licence } from '../../../types/Licence'
 
@@ -173,7 +173,7 @@ class LicenceC extends React.Component<Props, State> {
       listener: async e => {
         e.preventDefault()
         this.setState({ highlightDropzone: false })
-        let text = await ObjectUtils.readFromFileList(e.dataTransfer.files)
+        let text = await FileUtils.readTextFromFirstFile(e.dataTransfer.files)
         if (text) {
           this.handleLicenceChange(text)
         }
@@ -220,7 +220,7 @@ class LicenceC extends React.Component<Props, State> {
   }
 
   async handleFileUpload(files: FileList) {
-    let text = await ObjectUtils.readFromFileList(files)
+    let text = await FileUtils.readTextFromFirstFile(files)
     if (text) {
       this.handleLicenceChange(text)
     }

+ 22 - 0
src/components/organisms/PageHeader/PageHeader.jsx

@@ -83,6 +83,7 @@ type State = {
   showAbout: boolean,
   providerType: ?string,
   uploadedEndpoint: ?EndpointType,
+  multiValidating: boolean,
 }
 @observer
 class PageHeader extends React.Component<Props, State> {
@@ -94,6 +95,7 @@ class PageHeader extends React.Component<Props, State> {
     providerType: null,
     uploadedEndpoint: null,
     showAbout: false,
+    multiValidating: false,
   }
 
   pollTimeout: TimeoutID
@@ -192,6 +194,21 @@ class PageHeader extends React.Component<Props, State> {
     })
   }
 
+  handleRemoveEndpoint(endpoint: EndpointType) {
+    endpointStore.delete(endpoint)
+  }
+
+  async handleValidateMultipleEndpoints(endpoints: EndpointType[]) {
+    this.setState({ multiValidating: true })
+    let addedEndpoints = await endpointStore.addMultiple(endpoints)
+    await endpointStore.validateMultiple(addedEndpoints)
+    this.setState({ multiValidating: false })
+  }
+
+  handleResetValidation() {
+    endpointStore.resetMultiValidation()
+  }
+
   handleCloseEndpointModal() {
     if (this.props.onModalClose) {
       this.props.onModalClose()
@@ -289,6 +306,11 @@ class PageHeader extends React.Component<Props, State> {
             loading={providerStore.providersLoading}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
             onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
+            multiValidating={this.state.multiValidating}
+            onValidateMultipleEndpoints={endpoints => { this.handleValidateMultipleEndpoints(endpoints) }}
+            multiValidation={endpointStore.multiValidation}
+            onRemoveEndpoint={e => { this.handleRemoveEndpoint(e) }}
+            onResetValidation={() => { this.handleResetValidation() }}
           />
         </Modal>
         <Modal

+ 2 - 2
src/components/organisms/WizardScripts/WizardScripts.jsx

@@ -25,7 +25,7 @@ import StatusIcon from '../../atoms/StatusIcon'
 
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
-import ObjectUtils from '../../../utils/ObjectUtils'
+import FileUtils from '../../../utils/FileUtils'
 
 import scriptItemImage from './images/script-item.svg'
 
@@ -151,7 +151,7 @@ class WizardScripts extends React.Component<Props> {
       return
     }
     let fileName = files[0].name
-    let scriptContent = await ObjectUtils.readFromFileList(files)
+    let scriptContent = await FileUtils.readTextFromFirstFile(files)
     this.props.onScriptUpload({
       instanceName,
       global,

+ 22 - 0
src/components/pages/EndpointsPage/EndpointsPage.jsx

@@ -56,6 +56,7 @@ type State = {
   showDuplicateModal: boolean,
   duplicating: boolean,
   uploadedEndpoint: ?EndpointType,
+  multiValidating: boolean,
 }
 @observer
 class EndpointsPage extends React.Component<{ history: any }, State> {
@@ -70,6 +71,7 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     showDeleteEndpointsModal: false,
     selectedEndpoints: [],
     uploadedEndpoint: null,
+    multiValidating: false,
   }
 
   pollTimeout: TimeoutID
@@ -156,6 +158,21 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
     this.setState({ showChooseProviderModal: true })
   }
 
+  handleRemoveEndpoint(endpoint: EndpointType) {
+    endpointStore.delete(endpoint)
+  }
+
+  async handleValidateMultipleEndpoints(endpoints: EndpointType[]) {
+    this.setState({ multiValidating: true })
+    let addedEndpoints = await endpointStore.addMultiple(endpoints)
+    await endpointStore.validateMultiple(addedEndpoints)
+    this.setState({ multiValidating: false })
+  }
+
+  handleResetValidation() {
+    endpointStore.resetMultiValidation()
+  }
+
   handleCloseChooseProviderModal() {
     this.setState({ showChooseProviderModal: false })
   }
@@ -318,6 +335,11 @@ class EndpointsPage extends React.Component<{ history: any }, State> {
             loading={providerStore.providersLoading}
             onUploadEndpoint={endpoint => { this.handleUploadEndpoint(endpoint) }}
             onProviderClick={providerName => { this.handleProviderClick(providerName) }}
+            multiValidating={this.state.multiValidating}
+            onValidateMultipleEndpoints={endpoints => { this.handleValidateMultipleEndpoints(endpoints) }}
+            multiValidation={endpointStore.multiValidation}
+            onRemoveEndpoint={e => { this.handleRemoveEndpoint(e) }}
+            onResetValidation={() => { this.handleResetValidation() }}
           />
         </Modal>
         <Modal

+ 53 - 1
src/stores/EndpointStore.js

@@ -18,7 +18,7 @@ import { observable, runInAction, action } from 'mobx'
 import JSZip from 'jszip'
 import { saveAs } from 'file-saver'
 
-import type { Endpoint, Validation, StorageBackend, Storage } from '../types/Endpoint'
+import type { Endpoint, Validation, StorageBackend, Storage, MultiValidationItem } from '../types/Endpoint'
 
 import notificationStore from './NotificationStore'
 import EndpointSource from '../sources/EndpointSource'
@@ -47,6 +47,7 @@ class EndpointStore {
   @observable storageBackends: StorageBackend[] = []
   @observable storageLoading: boolean = false
   @observable storageConfigDefault: string = ''
+  @observable multiValidation: MultiValidationItem[] = []
 
   @action async getEndpoints(options?: { showLoading?: boolean, skipLog?: boolean }) {
     if (options && options.showLoading) {
@@ -168,12 +169,49 @@ class EndpointStore {
     this.connectionInfoLoading = false
   }
 
+  @action resetMultiValidation() {
+    this.multiValidation = []
+  }
+
+  @action async validateMultiple(endpoints: Endpoint[]) {
+    this.multiValidation = endpoints.map(endpoint => ({
+      endpoint,
+      validating: true,
+    }))
+
+    await Promise.all(endpoints.map(async endpoint => {
+      try {
+        let validation = await EndpointSource.validate(endpoint)
+        runInAction(() => {
+          this.multiValidation = this.multiValidation.filter(mv => mv.endpoint.name !== endpoint.name
+            || mv.endpoint.type !== endpoint.type)
+          this.multiValidation = [...this.multiValidation, {
+            endpoint,
+            validation,
+            validating: false,
+          }]
+        })
+      } catch (ex) {
+        runInAction(() => {
+          this.multiValidation = this.multiValidation.filter(mv => mv.endpoint.name !== endpoint.name
+            || mv.endpoint.type !== endpoint.type)
+          this.multiValidation = [...this.multiValidation, {
+            endpoint,
+            validation: { valid: false, message: 'Validation request failed' },
+            validating: false,
+          }]
+        })
+      }
+    }))
+  }
+
   @action async validate(endpoint: Endpoint) {
     this.validating = true
 
     try {
       let validation = await EndpointSource.validate(endpoint)
       this.validateSuccess(validation)
+      return validation
     } catch (ex) {
       this.validateFailed()
       throw ex
@@ -225,12 +263,26 @@ class EndpointStore {
     try {
       let addedEndpoint = await EndpointSource.add(endpoint)
       this.addSuccess(addedEndpoint)
+      return addedEndpoint
     } catch (ex) {
       runInAction(() => { this.adding = false })
       throw ex
     }
   }
 
+  async addMultiple(endpoints: Endpoint[]) {
+    let addedEndpoints: Endpoint[] = []
+    await Promise.all(endpoints.map(async endpoint => {
+      try {
+        let addedEndpoint = await EndpointSource.add(endpoint, true)
+        this.addSuccess(addedEndpoint)
+        addedEndpoints.push(addedEndpoint)
+        // eslint-disable-next-line no-empty
+      } catch (err) { }
+    }))
+    return addedEndpoints
+  }
+
   @action addSuccess(addedEndpoint: Endpoint) {
     this.endpoints = [
       addedEndpoint,

+ 6 - 0
src/types/Endpoint.js

@@ -34,6 +34,12 @@ export type Endpoint = {
   },
 }
 
+export type MultiValidationItem = {
+  endpoint: Endpoint,
+  validation?: Validation,
+  validating: boolean,
+}
+
 export type OptionValues = {
   name: string,
   // $FlowIssue

+ 72 - 0
src/utils/FileUtils.js

@@ -0,0 +1,72 @@
+/*
+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/>.
+*/
+
+// @flow
+
+import JSZip from 'jszip'
+
+export type FileContent = {
+  name: string,
+  content: string,
+}
+
+class FileUtils {
+  static async readFile(file: File): Promise<FileContent> {
+    let reader = new FileReader()
+
+    return new Promise((resolve, reject) => {
+      reader.onload = e => { resolve({ name: file.name, content: e.target.result }) }
+      reader.onerror = e => { reject(e) }
+      reader.readAsText(file)
+    })
+  }
+
+  static async readContentFromFileList(fileList: FileList): Promise<FileContent[]> {
+    if (!fileList.length) {
+      return []
+    }
+    let result: FileContent[] = []
+    await Promise.all(Array.from(fileList).map(async file => {
+      if (file.name.substr(file.name.length - 4) === '.zip') {
+        let zip = await JSZip.loadAsync(file)
+        await Promise.all(Object.keys(zip.files).map(async zipFileName => {
+          if (zipFileName.indexOf('__MACOSX') === 0) {
+            return
+          }
+          let zipContent = await zip.files[zipFileName].async('string')
+          result.push({ name: zipFileName, content: zipContent })
+        }))
+      } else {
+        let fileContent = await this.readFile(file)
+        result.push(fileContent)
+      }
+    }))
+    return result
+  }
+
+  static readTextFromFirstFile(fileList: FileList): Promise<?string> {
+    if (!fileList.length) {
+      return Promise.resolve(null)
+    }
+    let file = fileList[0]
+    let reader = new FileReader()
+    return new Promise((resolve, reject) => {
+      reader.onload = e => { resolve(e.target.result) }
+      reader.onerror = e => { reject(e) }
+      reader.readAsText(file)
+    })
+  }
+}
+
+export default FileUtils

+ 5 - 13
src/utils/ObjectUtils.js

@@ -16,6 +16,11 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import configLoader from './Config'
 
+export type FileContent = {
+  name: string,
+  content: string,
+}
+
 class ObjectUtils {
   static flatten(object: any, appendParentPath?: boolean, parent?: string): any {
     let result = {}
@@ -88,19 +93,6 @@ class ObjectUtils {
       || fieldName.toLowerCase().indexOf('password') > -1
     return typeof value === 'string' && !isPassword ? value.trim() : value
   }
-
-  static readFromFileList(fileList: FileList): Promise<?string> {
-    if (!fileList.length) {
-      return Promise.resolve(null)
-    }
-    let file = fileList[0]
-    let reader = new FileReader()
-    return new Promise((resolve, reject) => {
-      reader.onload = e => { resolve(e.target.result) }
-      reader.onerror = e => { reject(e) }
-      reader.readAsText(file)
-    })
-  }
 }
 
 export default ObjectUtils