Explorar o código

Merge pull request #73 from smiclea/CORWEB-14

Update and refactor validation process for adding a new cloud connection CORWEB-14
Dorin Paslaru %!s(int64=8) %!d(string=hai) anos
pai
achega
7d114cb598

+ 210 - 50
src/components/AddCloudConnection/AddCloudConnection.js

@@ -20,15 +20,17 @@ import React, { PropTypes } from 'react';
 import withStyles from 'isomorphic-style-loader/lib/withStyles';
 import s from './AddCloudConnection.scss';
 import Reflux from 'reflux';
+import Helper from "../Helper"
 import ConnectionsStore from '../../stores/ConnectionsStore';
 import ConnectionsActions from '../../actions/ConnectionsActions';
 import NotificationActions from '../../actions/NotificationActions';
 import Dropdown from '../NewDropdown';
 import Switch from '../Switch'
 import LoadingIcon from "../LoadingIcon/LoadingIcon";
-import ValidateEndpoint from '../ValidateEndpoint';
 
-const title = 'Add Cloud Endpoint';
+const title = 'Add Cloud Connection';
+const endpointStatuses = { IDLE: 0, VALIDATING: 1, ERROR: 2, SUCCESS: 3 }
+const submissionTypes = { ADD: 0, EDIT: 1 }
 
 class AddCloudConnection extends Reflux.Component {
 
@@ -49,13 +51,15 @@ class AddCloudConnection extends Reflux.Component {
     this.store = ConnectionsStore
 
     this.state = {
+      submissionType: submissionTypes.ADD,
+      endpointStatus: endpointStatuses.IDLE,
+      showErrorMessage: false,
       type: props.type, // type of operation: new/edit
       connection: props.connection, // connection object (on edit)
       connectionName: "", // connection name field
       description: "", // connection description field
       currentCloud: this.props.cloud, // chosen cloud - if adding a new endpoint
       currentCloudData: null, // endpoint field data
-      validateEndpoint: false, // holds the endpoint object when validation
       requiredFields: [], // array that holds all the endpoint required fields - used for field validation
       cloudFormsSubmitted: false // flag that indicates if the form has been submitted - used for field validation
     }
@@ -63,10 +67,19 @@ class AddCloudConnection extends Reflux.Component {
 
   componentWillMount() {
     super.componentWillMount.call(this)
+    this.componentWillUnmount = false
     this.context.onSetTitle(title);
     if (this.state.currentCloudData == null) {
       this.setState({ currentCloudData: {} })
     }
+
+    this.setState({ submissionType: this.props.type === 'new' ? submissionTypes.ADD : submissionTypes.EDIT })
+  }
+
+  componentWillUnmount() {
+    super.componentWillUnmount.call(this)
+    this.componentWillUnmount = true
+    clearTimeout(this.closeTimeout)
   }
 
   componentDidMount() {
@@ -186,40 +199,64 @@ class AddCloudConnection extends Reflux.Component {
       })
 
       // If endpoint is new
-      if (this.state.type == "new") {
+      if (this.state.type === 'new') {
+        this.setState({ type: 'edit' })
+
         ConnectionsActions.newEndpoint({
           name: this.state.connectionName,
           description: this.state.description,
           type: this.state.currentCloud.name,
           connection_info: credentials
         }, (response) => {
-          this.setState({
-            validateEndpoint: response.data.endpoint,
-            type: "edit",
-            connection: response.data.endpoint
-          })
+          this.validateEndpoint(response.data.endpoint)
+
+          if (this.props.onConnectionAdded) {
+            this.props.onConnectionAdded(response.data.endpoint)
+          }
         })
-        this.props.addHandle(this.state.connectionName);
+
+        this.setState({ endpointStatus: endpointStatuses.VALIDATING })
       } else { // If editing an endpoint
         ConnectionsActions.editEndpoint(this.state.connection, {
           name: this.state.connectionName,
           description: this.state.description,
           connection_info: credentials
         }, (response) => {
-          this.setState({
-            validateEndpoint: response.data.endpoint,
-            type: "edit",
-            connection: response.data.endpoint
-          })
-          this.props.updateHandle({
-            name: this.state.connectionName,
-            description: this.state.description
-          })
+          this.validateEndpoint(response.data.endpoint)
+
+          if (this.props.onConnectionAdded && this.submissionType === submissionTypes.ADD) {
+            this.props.onConnectionAdded(response.data.endpoint)
+          }
         })
+
+        this.setState({ endpointStatus: endpointStatuses.VALIDATING })
       }
     }
   }
 
+  validateEndpoint(endpoint) {
+    if (this.componentWillUnmount && this.state.submissionType === submissionTypes.ADD) {
+      ConnectionsActions.deleteConnection(endpoint)
+      return
+    }
+
+    this.setState({ connection: endpoint })
+
+    ConnectionsActions.validateConnection(endpoint, response => {
+      let validation = response.data["validate-connection"]
+      if (validation.valid) {
+        this.setState({ endpointStatus: endpointStatuses.SUCCESS })
+      } else {
+        this.setState({
+          endpointStatus: endpointStatuses.ERROR,
+          errorMessage: validation.message
+        })
+      }
+    }, () => {
+      this.setState({ endpointStatus: endpointStatuses.ERROR })
+    })
+  }
+
   /**
    * Handles change `name` property
    * @param e
@@ -290,6 +327,21 @@ class AddCloudConnection extends Reflux.Component {
     })
   }
 
+  /**
+   * Handles cancel edit/add endpoint
+   */
+  handleCancel() {
+    if (this.state.submissionType === submissionTypes.ADD && this.state.connection && this.state.connection.id) {
+      ConnectionsActions.deleteConnection(this.state.connection)
+    }
+
+    this.props.closeHandle();
+  }
+
+  handleClose() {
+    this.props.closeHandle();
+  }
+
   /**
    * Sets default values for cloud fields
    */
@@ -369,11 +421,9 @@ class AddCloudConnection extends Reflux.Component {
     this.setState({ requiredFields: requiredFields });
   }
 
-  /**
-   * Handles cancel edit/add endpoint
-   */
-  handleCancel() {
-    this.props.closeHandle();
+  areFieldsDisabled() {
+    return (this.state.endpointStatus === endpointStatuses.VALIDATING
+      || this.state.endpointStatus === endpointStatuses.SUCCESS)
   }
 
   /**
@@ -398,6 +448,22 @@ class AddCloudConnection extends Reflux.Component {
     this.setState({ currentCloudData: currentCloudData })
   }
 
+  handleCopyErrorClick() {
+    let succesful = Helper.copyTextToClipboard(this.state.errorMessage)
+
+    if (succesful) {
+      NotificationActions.notify('The error message has been copied to clipboard.')
+    } else {
+      NotificationActions.notify('The error message couldn\'t be copied', 'error')
+    }
+  }
+
+  handleShowErrorClick() {
+    this.setState({
+      showErrorMessage: !this.state.showErrorMessage
+    })
+  }
+
   /**
    * Renders the cloud list
    * @returns {XML}
@@ -448,8 +514,9 @@ class AddCloudConnection extends Reflux.Component {
             <input
               type="text"
               placeholder={field.label}
+              disabled={this.areFieldsDisabled()}
               onChange={(e) => this.handleCloudFieldChange(e, field)}
-              value={this.state.currentCloudData[field.name]}
+              value={this.state.currentCloudData[field.name] || ''}
             />
           </div>
         )
@@ -463,6 +530,7 @@ class AddCloudConnection extends Reflux.Component {
             <input
               type="password"
               placeholder={field.label}
+              disabled={this.areFieldsDisabled()}
               onChange={(e) => this.handleCloudFieldChange(e, field)}
               value={this.state.currentCloudData[field.name]}
             />
@@ -496,6 +564,7 @@ class AddCloudConnection extends Reflux.Component {
               {field.label + (field.required ? " *" : "")}
             </div>
             <Dropdown
+              disabled={this.areFieldsDisabled()}
               options={field.options}
               onChange={(e) => this.handleCloudFieldChange(e, field)}
               placeholder="Choose a value"
@@ -514,13 +583,14 @@ class AddCloudConnection extends Reflux.Component {
         })
         let radioOptions = field.options.map((option, key) => (
             <div key={"radio_option_" + key} className={s.radioOption}>
-              <input
-                type="radio"
-                value={option.value}
-                id={option.name}
-                checked={option.value == this.state.currentCloudData[field.name]}
-                onChange={(e) => this.handleCloudFieldChange(e, field)}
-              /> <label htmlFor={option.name}>{option.label}</label>
+            <input
+              disabled={this.areFieldsDisabled()}
+              type="radio"
+              value={option.value}
+              id={option.name}
+              checked={option.value == this.state.currentCloudData[field.name]}
+              onChange={(e) => this.handleCloudFieldChange(e, field)}
+            /> <label htmlFor={option.name}>{option.label}</label>
             </div>
           )
         )
@@ -542,6 +612,108 @@ class AddCloudConnection extends Reflux.Component {
     return returnValue
   }
 
+  renderEndpointErrorMessage() {
+    if (this.state.endpointStatus !== endpointStatuses.ERROR || !this.state.showErrorMessage) {
+      return null
+    }
+
+    return (
+      <div className={s.endpointErrorMessage}
+        onClick={() => this.handleCopyErrorClick()}
+        onMouseDown={e => e.stopPropagation()}
+        onMouseUp={e => e.stopPropagation()}
+      >
+        <span className={s.endpointErrorMessageContent}>
+          {this.state.errorMessage}<span className="copyButton" />
+        </span>
+      </div>
+    )
+  }
+
+  renderEndpointErrorTitle() {
+    let errorMessage = null
+    if (this.state.errorMessage) {
+      errorMessage = (
+        <span className={s.ednpointErrorMessageViewMore}
+          onClick={() => { this.handleShowErrorClick() }}
+        >{this.state.showErrorMessage ? 'Hide Error' : 'Show Error'}</span>
+      )
+    }
+
+    return (
+      <div className={s.endpointErrorMessageTitle}>
+        <span className={s.endpointErrorMessageTitleContent}>Validation Failed</span>
+        {errorMessage}
+      </div>
+    )
+  }
+
+  renderEndpointStatus() {
+    if (this.state.endpointStatus === endpointStatuses.SUCCESS) {
+      clearTimeout(this.closeTimeout)
+      this.closeTimeout = setTimeout(() => {
+        this.closeTimeout = null
+        this.handleClose()
+      }, 2000)
+    }
+
+    let endpointStatus = null
+    if (this.state.endpointStatus === endpointStatuses.ERROR ||
+      this.state.endpointStatus === endpointStatuses.SUCCESS) {
+      let icon = 'successIcon'
+      let content = 'Endpoint is Valid'
+      if (this.state.endpointStatus === endpointStatuses.ERROR) {
+        icon = 'errorIcon'
+        content = this.renderEndpointErrorTitle()
+      }
+
+      endpointStatus = (
+        <div className={s.endpointStatus}>
+          <div className={s.endpointStatusTitle}>
+            <div className={s.endpointStatusIcon + ' ' + icon}></div>
+            <div className={s.endpointStatusLabel}>
+              {content}
+            </div>
+          </div>
+          {this.renderEndpointErrorMessage()}
+        </div>
+      )
+    }
+
+    return endpointStatus
+  }
+
+  renderButtons() {
+    let cancelButton = (this.state.type == "new" && this.props.cloud == null) ?
+      <button className={s.leftBtn + " gray"} onClick={(e) => this.handleBack(e)}>Back</button> :
+      <button className={s.leftBtn + " gray"} onClick={(e) => this.handleCancel(e)}>Cancel</button>
+
+    let saveButtonContent = 'Validate and Save'
+
+    if (this.state.endpointStatus === endpointStatuses.VALIDATING ||
+      this.state.endpointStatus === endpointStatuses.SUCCESS) {
+      let text = this.state.endpointStatus === endpointStatuses.VALIDATING ? 'Validating' : 'Saving'
+      saveButtonContent = <span>{text} ... <div className="spinner"></div></span>
+    }
+
+    let saveButton = (
+      <button
+        className={s.rightBtn}
+        onClick={this.handleSave.bind(this)}
+        disabled={this.areFieldsDisabled()}
+      >
+        {saveButtonContent}
+      </button>
+    )
+
+    return (
+      <div className={s.buttons}>
+        {cancelButton}
+        {saveButton}
+      </div>
+    )
+  }
+
   /**
    * Renders the new/edit endpoint form
    * @param cloud
@@ -555,6 +727,7 @@ class AddCloudConnection extends Reflux.Component {
         <div className={s.cloudImage}>
           <div className={" icon large-cloud " + this.state.currentCloud.name}></div>
         </div>
+        {this.renderEndpointStatus()}
         <div className={s.cloudFields + (cloud.endpoint.fields.length > 6 ? " " + s.larger : "")}>
           <div className={"form-group " + (this.state.cloudFormsSubmitted &&
             this.state.connectionName.trim().length == 0 ? s.error : "") + ' required'}
@@ -565,6 +738,7 @@ class AddCloudConnection extends Reflux.Component {
             <input
               type="text"
               placeholder="Endpoint Name"
+              disabled={this.areFieldsDisabled()}
               onChange={(e) => this.handleChangeName(e)}
               value={this.state.connectionName}
             />
@@ -575,6 +749,7 @@ class AddCloudConnection extends Reflux.Component {
             </div>
             <input
               type="text"
+              disabled={this.areFieldsDisabled()}
               placeholder="Endpoint Description"
               onChange={(e) => this.handleChangeDescription(e)}
               value={this.state.description}
@@ -583,29 +758,14 @@ class AddCloudConnection extends Reflux.Component {
 
           {fields}
         </div>
-        <div className={s.buttons}>
-          {(this.state.type == "new" && this.props.cloud == null) ? (
-            <button className={s.leftBtn + " gray"} onClick={(e) => this.handleBack(e)}>Back</button>
-          ) : (
-            <button className={s.leftBtn + " gray"} onClick={(e) => this.handleCancel(e)}>Cancel</button>
-          )}
-          <button className={s.rightBtn} onClick={(e) => this.handleSave(e)}>Save</button>
-        </div>
+        {this.renderButtons()}
       </div>
     )
   }
 
   render() {
     let modalBody
-    if (this.state.validateEndpoint) {
-      modalBody = (
-        <ValidateEndpoint
-          closeHandle={this.props.closeHandle}
-          endpoint={this.state.validateEndpoint}
-          backHandle={(e) => this.backToEdit(e)}
-        />
-      )
-    } else if (this.state.currentCloud == null) {
+    if (this.state.currentCloud == null) {
       if (this.state.allClouds) {
         modalBody = this.renderCloudList()
       } else {
@@ -617,7 +777,7 @@ class AddCloudConnection extends Reflux.Component {
     return (
       <div tabIndex="0" className={s.root} ref={rootDiv => { this.rootDiv = rootDiv }}>
         <div className={s.header}>
-          <h3>{title}</h3>
+          <h3>{this.props.type === 'edit' ? 'Edit Cloud Connection' : title}</h3>
         </div>
         {modalBody}
       </div>

+ 51 - 4
src/components/AddCloudConnection/AddCloudConnection.scss

@@ -42,6 +42,42 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
   margin: 0 auto;
   padding: 48px 32px 32px;
   position: relative;
+  .endpointStatus {
+    display: flex;
+    margin: 0 auto 32px auto;
+    flex-direction: column;
+    align-items: center;
+
+    .endpointErrorMessageTitle {
+      display: flex;
+      align-items: center;
+      .ednpointErrorMessageViewMore {
+        font-size: 10px;
+        color: $blue;
+        margin-left: 8px;
+        margin-top: 1px;
+        cursor: pointer;
+      }
+    }
+    .endpointStatusIcon {
+      margin-right: 8px;
+      margin-top: 1px;
+    }
+    .endpointStatusTitle {
+      display: flex;
+      justify-content: center;
+      width: 336px;
+    }
+    .endpointErrorMessage {
+      margin-top: 16px;
+      cursor: pointer;
+      display: flex;
+
+      &:hover :global(.copyButton) {
+        opacity: 1;
+      }
+    }
+  }
   :global(.form-group) {
     margin-bottom: 16px;
     margin-left: 64px;
@@ -83,6 +119,10 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
   }
   .buttons {
     padding-top: 16px;
+    > * {
+      width: 224px;
+      position: relative;
+    }
     .leftBtn {
       float: left;
     }
@@ -91,6 +131,15 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
     }
     &:after {
 
+    }
+    :global(.spinner) {
+      position: absolute;
+      top: 8px;
+      right: 8px;
+      border-top-color: white;
+      border-right-color: white;
+      border-bottom-color: white;
+      border-left-color: #7190CD;
     }
     .centerBtn {
       margin: 0 auto;
@@ -102,6 +151,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
     display: flex;
     flex-wrap: wrap;
     margin-left: -64px;
+    margin-top: 48px;
     &.radioFields {
       margin-left: 0;
     }
@@ -124,9 +174,6 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
       }
 
     }
-    &.larger {
-      margin-top: 0px;
-    }
   }
   &:after {
     clear: both;
@@ -179,7 +226,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 }
 .cloudImage {
   text-align: center;
-  height: 164px;
+  height: 142px;
 }
 .connecting {
   margin: 160px auto;

+ 14 - 3
src/components/App/App.scss

@@ -326,7 +326,9 @@ button {
   text-align: center;
   cursor: pointer;
   &:global(.Dropdown-disabled) {
+    color: $gray-dark;
     background-color: $gray-lighter;
+    border-color: $gray-lighter;
   }
 }
 
@@ -521,6 +523,16 @@ button {
     transform: translateZ(0);
     animation: rotate 2s infinite linear;
   }
+  .errorIcon {
+    min-width: 16px;
+    height: 16px;
+    background-image: $error-icon;
+  }
+  .successIcon {
+    width: 16px;
+    height: 16px;
+    background-image: $success-icon;
+  }
   .taskIcon {
     width: 16px;
     height: 16px;
@@ -528,7 +540,7 @@ button {
     float: left;
     margin: 1px 8px 0 8px;
     &.COMPLETED {
-      background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPg0KICAgICAgICA8ZyBpZD0iSWNvbi1PayI+DQogICAgICAgICAgICA8Y2lyY2xlIGlkPSJPdmFsLTIiIGZpbGw9IiM0Q0Q5NjQiIGN4PSI4IiBjeT0iOCIgcj0iOCI+PC9jaXJjbGU+DQogICAgICAgICAgICA8cG9seWxpbmUgaWQ9IlN0cm9rZS0zIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgcG9pbnRzPSIxMiA2IDcuMDgzMDc0MTkgMTEgNCA4LjAwMzk3NTQyIj48L3BvbHlsaW5lPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+');
+      background-image: $success-icon
     }
     &.RUNNING {
       background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGcgaWQ9Ikdyb3VwLTciIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxjaXJjbGUgaWQ9Ik92YWwtMi1Db3B5IiBmaWxsPSIjQTRBQUI1IiBjeD0iOCIgY3k9IjgiIHI9IjgiPjwvY2lyY2xlPjxwYXRoIGQ9Ik0xNiw4IEMxNiwzLjU4MTcyMiAxMi40MTgyNzgsMCA4LDAgTDgsOCBMMTYsOCBaIiBpZD0iQ29tYmluZWQtU2hhcGUiIGZpbGw9IiMwMDU2QjgiPjwvcGF0aD48Y2lyY2xlIGlkPSJPdmFsLTItQ29weSIgZmlsbD0iI0ZGRkZGRiIgY3g9IjgiIGN5PSI4IiByPSI2Ij48L2NpcmNsZT48L2c+PC9zdmc+');
@@ -544,7 +556,7 @@ button {
       background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGNpcmNsZSBpZD0iT3ZhbC0yLUNvcHkiIHN0cm9rZT0ibm9uZSIgZmlsbD0iI0M4Q0NENyIgZmlsbC1ydWxlPSJldmVub2RkIiBjeD0iOCIgY3k9IjgiIHI9IjgiPjwvY2lyY2xlPjxjaXJjbGUgaWQ9Ik92YWwtMi1Db3B5IiBzdHJva2U9Im5vbmUiIGZpbGw9Im5vbmUiIGN4PSI4IiBjeT0iOCIgcj0iNiI+PC9jaXJjbGU+PHBhdGggZD0iTTgsOCBMOCw0IiBpZD0iTGluZS1Db3B5LTIiIHN0cm9rZT0iI0ZGRkZGRiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIGZpbGw9Im5vbmUiPjwvcGF0aD48cGF0aCBkPSJNOCw4IEwxMCwxMCIgaWQ9IkxpbmUtQ29weS0zIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBmaWxsPSJub25lIj48L3BhdGg+PC9zdmc+');
     }
     &.ERROR {
-      background-image: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGlkPSJJY29uLUVycm9yIj48Y2lyY2xlIGlkPSJPdmFsLTIiIGZpbGw9IiNFNjI1NjUiIGN4PSI4IiBjeT0iOCIgcj0iOCI+PC9jaXJjbGU+PHBhdGggZD0iTTExLjQyODU3MTQsNC41NzE0Mjg1NyBMNC41NzE0Mjg1NywxMS40Mjg1NzE0IiBpZD0iTGluZSIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPjxwYXRoIGQ9Ik0xMS40Mjg1NzE0LDExLjQyODU3MTQgTDQuNTcxNDI4NTcsNC41NzE0Mjg1NyIgaWQ9IkxpbmUtQ29weSIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPjwvZz48L2c+PC9zdmc+');
+      background-image: $error-icon;
     }
   }
 
@@ -845,7 +857,6 @@ input[type="text"], input[type="password"] {
     outline: none;
   }
   &:disabled {
-    color: $gray;
     border-color: $gray-lighter;
     background-color: $gray-lighter;
   }

+ 1 - 3
src/components/CloudConnectionsView/CloudConnectionsView.js

@@ -142,10 +142,8 @@ class CloudConnectionsView extends Component {
       endpoint[i] = itemAttrs[i]
     }
     this.setState({
-      connection: endpoint,
-      showModal: false
+      connection: endpoint
     })
-    this.validateConnection()
   }
 
   goBack() {

+ 3 - 3
src/components/CloudItem/CloudItem.js

@@ -86,10 +86,10 @@ class CloudItem extends Component {
     return credential;
   }
 
-  addConnection(connection) {
+  handleConnectionAdded(connection) {
     let newCredentials = { cloudName: this.props.cloud.name, connection: connection }
     this.props.addCredentialsCallback(newCredentials)
-    this.onCredentialsChange({ label: connection, value: connection })
+    this.onCredentialsChange({ label: connection.name, value: connection.id })
   }
 
   closeModal() {
@@ -146,8 +146,8 @@ class CloudItem extends Component {
         >
           <AddCloudConnection
             closeHandle={(e) => this.closeModal(e)}
-            addHandle={(e) => this.addConnection(e)}
             cloud={this.props.cloud}
+            onConnectionAdded={this.handleConnectionAdded.bind(this)}
           />
         </Modal>
       </div>

+ 146 - 0
src/components/DropdownButton/DropdownButton.js

@@ -0,0 +1,146 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+import React, { PropTypes } from 'react';
+import withStyles from 'isomorphic-style-loader/lib/withStyles';
+import s from './DropdownButton.scss';
+
+class DropdownButton extends React.Component {
+  static propTypes = {
+    className: PropTypes.string,
+    options: PropTypes.array.isRequired,
+    onChange: PropTypes.func,
+    value: PropTypes.object.isRequired,
+    onButtonClick: PropTypes.func,
+    disabled: PropTypes.bool
+  }
+
+  constructor(props) {
+    super(props)
+
+    this.onPageClick = this.onPageClick.bind(this)
+
+    this.state = {
+      value: props.value,
+      options: props.options,
+      showMenu: false
+    }
+  }
+
+  componentDidMount() {
+    window.addEventListener('mousedown', this.onPageClick, false)
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('mousedown', this.onPageClick, false)
+  }
+
+  onPageClick() {
+    if (!this.itemMouseDown) {
+      this.closeMenu()
+    }
+  }
+
+  onMenuItemClick(option) {
+    if (this.props.onChange) {
+      this.props.onChange(option)
+    }
+
+    this.closeMenu()
+    this.setState({
+      value: option,
+      firstItemHover: false
+    })
+  }
+
+  onLabelClick() {
+    if (this.props.onButtonClick && !this.isDisabled()) {
+      this.props.onButtonClick()
+    }
+  }
+
+  onMenuItemMouseEnter(index) {
+    if (index === 0) {
+      this.setState({ firstItemHover: true })
+    }
+  }
+
+  onMenuItemMouseLeave(index) {
+    if (index === 0) {
+      this.setState({ firstItemHover: false })
+    }
+  }
+
+  isDisabled() {
+    return typeof this.props.disabled !== 'undefined' ? this.props.disabled : false
+  }
+
+  toggleMenu() {
+    if (!this.isDisabled()) {
+      this.setState({ showMenu: !this.state.showMenu })
+    }
+  }
+
+  closeMenu() {
+    this.setState({ showMenu: false })
+  }
+
+  renderMenu() {
+    if (!this.state.showMenu || this.state.options.length === 0) {
+      return null
+    }
+
+    return (
+      <div className={s.menu + (this.state.firstItemHover ? ' ' + s.firstItemHover : '')}>
+        {this.state.options.map((o, i) => (
+          <div key={o.value}
+            className={s.menuItem + (o.value === this.state.value.value ? ' ' + s.selected : '')}
+            onMouseEnter={() => { this.onMenuItemMouseEnter(i) }}
+            onMouseLeave={() => { this.onMenuItemMouseLeave(i) }}
+            onMouseDown={() => { this.itemMouseDown = true }}
+            onMouseUp={() => { this.itemMouseDown = false }}
+            onClick={() => { this.onMenuItemClick(o) }}
+          >{o.label}</div>
+        ))}
+      </div>
+    )
+  }
+
+  render() {
+    let className = this.props.className || ' '
+
+    if (this.isDisabled()) {
+      className += ' ' + s.disabled
+    }
+
+    return (
+      <div className={s.root + ' ' + className}>
+        <div className={s.label}
+          onClick={this.onLabelClick.bind(this)}
+        >{this.state && this.state.value.label}</div>
+        <div className={s.arrow}
+          onClick={this.toggleMenu.bind(this)}
+          onMouseDown={() => { this.itemMouseDown = true }}
+          onMouseUp={() => { this.itemMouseDown = false }}
+        />
+        {this.renderMenu()}
+      </div>
+    )
+  }
+}
+
+export default withStyles(DropdownButton, s);

+ 115 - 0
src/components/DropdownButton/DropdownButton.scss

@@ -0,0 +1,115 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+@import '../variables.scss';
+
+.root {
+  display: flex;
+  position: relative;
+  align-items: center;
+  height: 32px;
+  border-radius: 4px;
+  background-color: $blue;
+  border: none;
+  font-size: 14px;
+  color: #FFF;
+  padding: 0;
+  cursor: pointer;
+  &.disabled {
+    opacity: 0.7;
+    cursor: not-allowed;
+  }
+
+  .label {
+    flex-grow: 1;
+    text-align: center;
+    padding-left: 16px;
+  }
+
+  .arrow {
+    width: 32px;
+    height: 28px;
+    background-repeat: no-repeat;
+    background-position: center;
+    background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c3ZnIHdpZHRoPSIxMnB4IiBoZWlnaHQ9IjZweCIgdmlld0JveD0iMCAwIDEyIDYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+ICAgICAgICA8dGl0bGU+Q2hldnJvbi1HcmV5PC90aXRsZT4gICAgPGRlc2M+Q3JlYXRlZCB3aXRoIFNrZXRjaC48L2Rlc2M+ICAgIDxkZWZzPjwvZGVmcz4gICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCI+ICAgICAgICA8ZyBpZD0iRHJvcGRvd24tRmlsbCIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTE3MC4wMDAwMDAsIC0xMy4wMDAwMDApIiBzdHJva2U9IiNGRkZGRkYiPiAgICAgICAgICAgIDxnIGlkPSJDaGV2cm9uLVdoaXRlIiB0cmFuc2Zvcm09InRyYW5zbGF0ZSgxNjguMDAwMDAwLCA4LjAwMDAwMCkiPiAgICAgICAgICAgICAgICA8cG9seWxpbmUgaWQ9IlJlY3RhbmdsZS1Db3B5IiB0cmFuc2Zvcm09InRyYW5zbGF0ZSg4LjAwMDAwMCwgNS41MDAwMDApIHJvdGF0ZSgtMzE1LjAwMDAwMCkgdHJhbnNsYXRlKC04LjAwMDAwMCwgLTUuNTAwMDAwKSAiIHBvaW50cz0iMTEuODg5MDg3MyAxLjYxMDkxMjcgMTEuODg5MDg3MyA5LjM4OTA4NzMgNC4xMTA5MTI3IDkuMzg5MDg3MyI+PC9wb2x5bGluZT4gICAgICAgICAgICA8L2c+ICAgICAgICA8L2c+ICAgIDwvZz48L3N2Zz4=);
+  }
+
+  .menu {
+    position: absolute;
+    top: 32px;
+    display: flex;
+    flex-direction: column;
+    width: 100%;
+    color: #333;
+    outline: none;
+    overflow: hidden;
+    background-color: white;
+    border: 1px solid $gray;
+    box-shadow: 0 1px 0 rgba(0,0,0,0.06);
+    box-sizing: border-box;
+    margin-top: 8px;
+    border-radius: $border-radius;
+    overflow: visible;
+
+    &:after {
+      content: " ";
+      display: block;
+      position: absolute;
+      border: 1px solid $gray;
+      width: 10px;
+      height: 10px;
+      right: 9px;
+      top: -6px;
+      transform: rotate(135deg);
+      border-color: transparent transparent $gray $gray;
+      background-color: white;
+    }
+
+    &.firstItemHover:after {
+      background-color: $blue;
+    }
+
+    :first-child {
+      border-top-left-radius: $border-radius;
+      border-top-right-radius: $border-radius;
+    }
+
+    :last-child {
+      border-bottom-left-radius: $border-radius;
+      border-bottom-right-radius: $border-radius;
+    }
+
+    .menuItem {
+      box-sizing: border-box;
+      color: rgba(51,51,51,0.8);
+      cursor: pointer;
+      display: block;
+      padding: 8px 16px;
+      background-color: #FFF;
+      text-align: left;
+
+      &:hover {
+        background-color: $blue;
+        color: #FFF;
+      }
+
+      &.selected {
+        font-weight: $weight-semibold;
+      }
+    }
+  }
+}

+ 6 - 0
src/components/DropdownButton/package.json

@@ -0,0 +1,6 @@
+{
+  "name": "DropdownButton",
+  "version": "0.0.0",
+  "private": true,
+  "main": "./DropdownButton.js"
+}

+ 2 - 11
src/components/EndpointList/EndpointList.js

@@ -110,9 +110,6 @@ class EndpointList extends Reflux.Component {
     this.setState({ showValidationModal: false })
   }
 
-  addHandle() {
-  }
-
   renderItem(item) {
     let createdAt = Helper.getTimeObject(item.created_at)
     return (
@@ -173,10 +170,7 @@ class EndpointList extends Reflux.Component {
             contentLabel="Add new cloud connection"
             onRequestClose={this.closeModal.bind(this)}
           >
-            <AddCloudConnection
-              closeHandle={(e) => this.closeModal(e)}
-              addHandle={(e) => this.addHandle(e)}
-            />
+            <AddCloudConnection closeHandle={(e) => this.closeModal(e)} />
           </Modal>
         </div>
       );
@@ -210,10 +204,7 @@ class EndpointList extends Reflux.Component {
             contentLabel="Add new cloud connection"
             onRequestClose={this.closeModal.bind(this)}
           >
-            <AddCloudConnection
-              closeHandle={(e) => this.closeModal(e)}
-              addHandle={(e) => this.addHandle(e)}
-            />
+            <AddCloudConnection closeHandle={(e) => this.closeModal(e)} />
           </Modal>
         </div>
       )

+ 1 - 2
src/components/Header/Header.scss

@@ -26,8 +26,7 @@ $brand-color: #FFF;
   position: absolute;
   width: 100%;
   top: 0;
-  left: 0;
-  z-index: 9999;
+  z-index: 999;
   .bannerTitle {
     margin: 0;
     font-weight: $weight-light;

+ 1 - 1
src/components/NewModal/NewModal.js

@@ -79,7 +79,7 @@ class NewModal extends React.Component {
     let modalStyle = {
       overlay: {
         position: 'fixed',
-        zIndex: 10000,
+        zIndex: 1000,
         top: 0,
         left: 0,
         right: 0,

+ 6 - 0
src/components/variables.scss

@@ -60,3 +60,9 @@ $screen-lg-min:         1200px; /* Large screen / wide desktop */
  * ========================================================================== */
 
 $animation-swift-out:   .45s cubic-bezier(0.3, 1, 0.4, 1) 0s;
+
+/*
+ * Icons
+ * ========================================================================== */
+$error-icon: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxnIGlkPSJJY29uLUVycm9yIj48Y2lyY2xlIGlkPSJPdmFsLTIiIGZpbGw9IiNFNjI1NjUiIGN4PSI4IiBjeT0iOCIgcj0iOCI+PC9jaXJjbGU+PHBhdGggZD0iTTExLjQyODU3MTQsNC41NzE0Mjg1NyBMNC41NzE0Mjg1NywxMS40Mjg1NzE0IiBpZD0iTGluZSIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPjxwYXRoIGQ9Ik0xMS40Mjg1NzE0LDExLjQyODU3MTQgTDQuNTcxNDI4NTcsNC41NzE0Mjg1NyIgaWQ9IkxpbmUtQ29weSIgc3Ryb2tlPSIjRkZGRkZGIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PC9wYXRoPjwvZz48L2c+PC9zdmc+');
+$success-icon: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTZweCIgaGVpZ2h0PSIxNnB4IiB2aWV3Qm94PSIwIDAgMTYgMTYiIHZlcnNpb249IjEuMSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+DQogICAgPGcgaWQ9IlN5bWJvbHMiIHN0cm9rZT0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIxIiBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPg0KICAgICAgICA8ZyBpZD0iSWNvbi1PayI+DQogICAgICAgICAgICA8Y2lyY2xlIGlkPSJPdmFsLTIiIGZpbGw9IiM0Q0Q5NjQiIGN4PSI4IiBjeT0iOCIgcj0iOCI+PC9jaXJjbGU+DQogICAgICAgICAgICA8cG9seWxpbmUgaWQ9IlN0cm9rZS0zIiBzdHJva2U9IiNGRkZGRkYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgcG9pbnRzPSIxMiA2IDcuMDgzMDc0MTkgMTEgNCA4LjAwMzk3NTQyIj48L3BvbHlsaW5lPg0KICAgICAgICA8L2c+DQogICAgPC9nPg0KPC9zdmc+');

+ 2 - 1
src/stores/ConnectionsStore/ConnectionsStore.js

@@ -169,10 +169,11 @@ class ConnectionsStore extends Reflux.Store
 
   onSaveEditEndpointSuccess(response) {
     let connections = this.state.connections
-    connections.forEach(connection => {
+    connections = connections.map(connection => {
       if (connection.id == response.data.endpoint.id) {
         connection = response.data.endpoint
       }
+      return connection
     })
     this.setState({connections: connections})
   }