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

Merge pull request #512 from smiclea/multiple-endpoints-import

Upload multi .endpoint and zipped .endpoint files
Nashwan Azhari 6 лет назад
Родитель
Сommit
8bdeeb1abc

+ 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