瀏覽代碼

Update validation process for adding a new cloud connection

The validation status and progress is now displayed inside the Add Cloud Endpoint popup.
There's a new dropdown button component created for handling whether to validate or not the cloud endpoint.
Sergiu Miclea 8 年之前
父節點
當前提交
bf8c0ef13e

+ 136 - 36
src/components/AddCloudConnection/AddCloudConnection.js

@@ -25,10 +25,15 @@ import ConnectionsActions from '../../actions/ConnectionsActions';
 import NotificationActions from '../../actions/NotificationActions';
 import Dropdown from '../NewDropdown';
 import Switch from '../Switch'
+import DropdownButton from '../DropdownButton'
 import LoadingIcon from "../LoadingIcon/LoadingIcon";
-import ValidateEndpoint from '../ValidateEndpoint';
 
 const title = 'Add Cloud Endpoint';
+const saveOptions = [
+  { label: 'Validate and Save', value: 'saveWithValidation' },
+  { label: 'Save', value: 'saveWithoutValidation' }
+]
+const endpointStatuses = { IDLE: 0, VALIDATING: 1, ERROR: 2, SUCCESS: 3 }
 
 class AddCloudConnection extends Reflux.Component {
 
@@ -49,13 +54,14 @@ class AddCloudConnection extends Reflux.Component {
     this.store = ConnectionsStore
 
     this.state = {
+      endpointStatus: endpointStatuses.IDLE,
+      saveOption: saveOptions[0].value,
       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
     }
@@ -193,33 +199,67 @@ class AddCloudConnection extends Reflux.Component {
           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)
         })
         this.props.addHandle(this.state.connectionName);
+        if (this.state.saveOption === saveOptions[0].value) {
+          this.setState({ endpointStatus: endpointStatuses.VALIDATING })
+        } else {
+          this.props.closeHandle()
+        }
       } 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.validateEndpoint(response.data.endpoint)
           this.props.updateHandle({
             name: this.state.connectionName,
             description: this.state.description
           })
         })
+        if (this.state.saveOption === saveOptions[0].value) {
+          this.setState({ endpointStatus: endpointStatuses.VALIDATING })
+        } else {
+          this.props.closeHandle()
+        }
       }
     }
   }
 
+  validateEndpoint(endpoint) {
+    if (this.state.saveOption === saveOptions[1].value) {
+      return;
+    }
+
+    ConnectionsActions.validateConnection(endpoint, response => {
+      let validation = response.data["validate-connection"]
+      this.setState({
+        endpointStatus: validation.valid ? endpointStatuses.SUCCESS : endpointStatuses.ERROR,
+      })
+
+      if (validation.valid) {
+        this.setState({
+          connection: endpoint,
+          endpointStatus: endpointStatuses.SUCCESS
+        })
+      } else {
+        this.setState({
+          connection: endpoint,
+          endpointStatus: endpointStatuses.ERROR,
+          type: 'edit'
+        })
+      }
+    }, () => {
+      this.setState({
+        connection: endpoint,
+        endpointStatus: endpointStatuses.ERROR,
+        type: 'edit'
+      })
+    })
+  }
+
   /**
    * Handles change `name` property
    * @param e
@@ -369,6 +409,11 @@ class AddCloudConnection extends Reflux.Component {
     this.setState({ requiredFields: requiredFields });
   }
 
+  areFieldsDisabled() {
+    return (this.state.endpointStatus === endpointStatuses.VALIDATING
+      || this.state.endpointStatus === endpointStatuses.SUCCESS)
+  }
+
   /**
    * Handles cancel edit/add endpoint
    */
@@ -398,6 +443,12 @@ class AddCloudConnection extends Reflux.Component {
     this.setState({ currentCloudData: currentCloudData })
   }
 
+  handleSaveOptionChange(e) {
+    this.setState({
+      saveOption: e.value
+    })
+  }
+
   /**
    * Renders the cloud list
    * @returns {XML}
@@ -448,6 +499,7 @@ 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]}
             />
@@ -463,6 +515,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 +549,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 +568,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 +597,63 @@ class AddCloudConnection extends Reflux.Component {
     return returnValue
   }
 
+  renderEndpointStatus() {
+    let label = ''
+    let icon = ''
+
+    switch (this.state.endpointStatus) {
+      case endpointStatuses.VALIDATING:
+        label = 'Validating Endpoint...'
+        icon = 'spinner'
+        break
+      case endpointStatuses.ERROR:
+        label = 'Validation Failed'
+        icon = 'errorIcon'
+        break
+      case endpointStatuses.SUCCESS:
+        label = 'Endpoint is Valid'
+        icon = 'successIcon'
+        break
+      default:
+    }
+
+    return (
+      <div className={s.endpointStatus}>
+        <div className={s.endpointStatusIcon + ' ' + icon}></div>
+        <div className={s.endpointStatusLabel}>{label}</div>
+      </div>
+    )
+  }
+
+  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 saveButton = this.state.endpointStatus === endpointStatuses.IDLE ||
+      this.state.endpointStatus === endpointStatuses.ERROR ?
+      <DropdownButton
+        disabled={this.areFieldsDisabled()}
+        className={s.rightBtn}
+        options={saveOptions}
+        onChange={this.handleSaveOptionChange.bind(this)}
+        onButtonClick={this.handleSave.bind(this)}
+        value={saveOptions.find(o => o.value === this.state.saveOption)}
+      /> :
+      <button
+        className={s.rightBtn + (this.state.endpointStatus === endpointStatuses.VALIDATING ? ' gray' : '')}
+        onClick={this.handleCancel.bind(this)}
+        disabled={this.state.endpointStatus === endpointStatuses.VALIDATING}
+      >{this.state.endpointStatus === endpointStatuses.VALIDATING ? 'Validating...' : 'Save'}</button>
+
+    return (
+      <div className={s.buttons}>
+        {cancelButton}
+        {saveButton}
+      </div>
+    )
+  }
+
   /**
    * Renders the new/edit endpoint form
    * @param cloud
@@ -555,6 +667,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 +678,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 +689,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 +698,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 {

+ 14 - 1
src/components/AddCloudConnection/AddCloudConnection.scss

@@ -42,6 +42,16 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
   margin: 0 auto;
   padding: 48px 32px 32px;
   position: relative;
+  .endpointStatus {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    margin-bottom: 32px;
+    height: 20px;
+    .endpointStatusIcon {
+      margin-right: 8px;
+    }
+  }
   :global(.form-group) {
     margin-bottom: 16px;
     margin-left: 64px;
@@ -83,6 +93,9 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
   }
   .buttons {
     padding-top: 16px;
+    > * {
+      width: 242px;
+    }
     .leftBtn {
       float: left;
     }
@@ -179,7 +192,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 {
+    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() {

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

@@ -0,0 +1,131 @@
+/*
+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
+    })
+  }
+
+  onLabelClick() {
+    if (this.props.onButtonClick && !this.isDisabled()) {
+      this.props.onButtonClick()
+    }
+  }
+
+  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.options.map(o => (
+          <div key={o.value}
+            className={s.menuItem + (o.value === this.state.value.value ? ' ' + s.selected : '')}
+            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);

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

@@ -0,0 +1,86 @@
+/*
+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;
+
+    .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"
+}

+ 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+');