diff --git a/frontend/components/buttons/Button/_styles.scss b/frontend/components/buttons/Button/_styles.scss index 5feba67a0b..4edbf31c54 100644 --- a/frontend/components/buttons/Button/_styles.scss +++ b/frontend/components/buttons/Button/_styles.scss @@ -28,7 +28,7 @@ $base-class: 'button'; .#{$base-class} { @include user-select(none); - @include transition(background 150ms ease-in-out, top 50ms ease-in-out, box-shadow 50ms ease-in-out, border 50ms ease-in-out); + @include transition(color 150ms ease-in-out, background 150ms ease-in-out, top 50ms ease-in-out, box-shadow 50ms ease-in-out, border 50ms ease-in-out); @include button-variant($link); position: relative; height: 38px; @@ -49,11 +49,6 @@ $base-class: 'button'; cursor: pointer; box-sizing: border-box; - &:active { - box-shadow: 0 0 0 $black, 0 1px 2px rgba($black, .15), 0 -1px 0 rgba($white, .1) inset; - top: 2px; - } - &:focus { outline: none; } diff --git a/frontend/components/buttons/DropdownButton/DropdownButton.jsx b/frontend/components/buttons/DropdownButton/DropdownButton.jsx new file mode 100644 index 0000000000..cdedc52192 --- /dev/null +++ b/frontend/components/buttons/DropdownButton/DropdownButton.jsx @@ -0,0 +1,118 @@ +import React, { Component, PropTypes } from 'react'; +import { noop } from 'lodash'; +import classnames from 'classnames'; + +import ClickOutside from 'components/ClickOutside'; +import Icon from 'components/icons/Icon'; +import Button from 'components/buttons/Button'; + +const baseClass = 'dropdown-button'; + +export class DropdownButton extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + disabled: PropTypes.bool, + options: PropTypes.arrayOf( + PropTypes.shape({ + disabled: PropTypes.bool, + label: PropTypes.string, + onClick: PropTypes.func, + }) + ).isRequired, + size: PropTypes.string, + tabIndex: PropTypes.number, + type: PropTypes.string, + variant: PropTypes.string, + }; + + static defaultProps = { + onChange: noop, + }; + + constructor (props) { + super(props); + + this.state = { isOpen: false }; + } + + setDOMNode = (DOMNode) => { + this.DOMNode = DOMNode; + } + + toggleDropdown = () => { + const { isOpen } = this.state; + this.setState({ isOpen: !isOpen }); + }; + + optionClick = (evt, onClick) => { + this.setState({ isOpen: false }); + onClick(evt); + }; + + renderOptions = (opt, idx) => { + const { optionClick } = this; + const { disabled, label, onClick } = opt; + + return ( +
  • + +
  • + ); + }; + + render () { + const { + children, + className, + disabled, + options, + size, + tabIndex, + type, + variant, + } = this.props; + const { isOpen } = this.state; + const { toggleDropdown, renderOptions, setDOMNode } = this; + + const buttonClass = classnames(baseClass, className); + const optionsClass = classnames(`${baseClass}__options`, { + [`${baseClass}__options--opened`]: isOpen, + }); + + return ( +
    + + + +
    + ); + } +} + +export default ClickOutside(DropdownButton, { + getDOMNode: (component) => { + return component.DOMNode; + }, + onOutsideClick: (component) => { + return () => { + component.setState({ isOpen: false }); + + return false; + }; + }, +}); diff --git a/frontend/components/buttons/DropdownButton/DropdownButton.tests.jsx b/frontend/components/buttons/DropdownButton/DropdownButton.tests.jsx new file mode 100644 index 0000000000..81f2055dfc --- /dev/null +++ b/frontend/components/buttons/DropdownButton/DropdownButton.tests.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import expect, { createSpy, restoreSpies } from 'expect'; +import { mount } from 'enzyme'; +import { noop } from 'lodash'; + +import { DropdownButton } from './DropdownButton'; + +describe('DropdownButton - component', () => { + afterEach(restoreSpies); + + it("calls the clicked item's onClick attribute", () => { + const optionSpy = createSpy(); + const dropdownOptions = [{ label: 'btn1', onClick: noop }, { label: 'btn2', onClick: optionSpy }]; + const component = mount( + + New Button + + ); + + component.find('.dropdown-button').simulate('click'); + expect(component.state().isOpen).toEqual(true); + + component.find('li.dropdown-button__option').last().find('Button').simulate('click'); + expect(optionSpy).toHaveBeenCalled(); + }); +}); diff --git a/frontend/components/buttons/DropdownButton/_styles.scss b/frontend/components/buttons/DropdownButton/_styles.scss new file mode 100644 index 0000000000..36179944f8 --- /dev/null +++ b/frontend/components/buttons/DropdownButton/_styles.scss @@ -0,0 +1,54 @@ +.dropdown-button { + &__wrapper { + display: inline-block; + position: relative; + } + + &__carat { + font-size: 6px; + vertical-align: middle; + margin-left: 10px; + } + + &__options { + position: absolute; + right: 0; + top: 45px; + list-style: none; + padding: 0; + margin: 0; + display: none; + z-index: 2; + border-radius: 2px; + background-color: $white; + border: solid 1px $accent-medium; + + &--opened { + display: inline-block; + } + } + + &__option { + display: block; + + .button { + color: $text-dark; + text-transform: none; + text-align: left; + font-weight: $normal; + margin: 0; + border-radius: 0; + padding: 0 10px; + white-space: nowrap; + width: 100%; + font-size: 15px; + height: 38px; + letter-spacing: -0.5px; + + &:hover { + background-color: $brand-light; + color: $white; + } + } + } +} diff --git a/frontend/components/buttons/DropdownButton/index.js b/frontend/components/buttons/DropdownButton/index.js new file mode 100644 index 0000000000..af3a223bc5 --- /dev/null +++ b/frontend/components/buttons/DropdownButton/index.js @@ -0,0 +1 @@ +export default from './DropdownButton'; diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.jsx index bb527ae9df..973ded5075 100644 --- a/frontend/components/forms/queries/QueryForm/QueryForm.jsx +++ b/frontend/components/forms/queries/QueryForm/QueryForm.jsx @@ -2,6 +2,7 @@ import React, { Component, PropTypes } from 'react'; import { isEqual } from 'lodash'; import Button from 'components/buttons/Button'; +import DropdownButton from 'components/buttons/DropdownButton'; import Dropdown from 'components/forms/fields/Dropdown'; import helpers from 'components/forms/queries/QueryForm/helpers'; import InputField from 'components/forms/fields/InputField'; @@ -173,6 +174,17 @@ class QueryForm extends Component { queryType, } = this.props; const { onCancel, onSave, onUpdate } = this; + + const dropdownBtnOptions = [{ + disabled: !canSaveChanges(formData, query), + label: 'Save Changes', + onClick: onUpdate, + }, { + disabled: !canSaveAsNew(formData, query), + label: 'Save As New...', + onClick: onSave, + }]; + let runQueryButton; if (queryIsRunning) { @@ -221,22 +233,14 @@ class QueryForm extends Component { return (
    - - + Save + + {runQueryButton}
    ); diff --git a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx index 40f2c10861..469bf51274 100644 --- a/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx +++ b/frontend/components/forms/queries/QueryForm/QueryForm.tests.jsx @@ -80,9 +80,10 @@ describe('QueryForm - component', () => { fillInFormInput(nameInput, ''); - const saveChangesBtn = form.find('.query-form__save-changes-btn'); + const saveDropButton = form.find('.query-form__save'); - saveChangesBtn.simulate('click'); + saveDropButton.simulate('click'); + form.find('li').first().find('Button').simulate('click'); expect(onSaveChangesSpy).toNotHaveBeenCalled(); expect(form.state()).toInclude({ @@ -101,9 +102,10 @@ describe('QueryForm - component', () => { fillInFormInput(nameInput, 'New query name'); - const saveChangesBtn = form.find('.query-form__save-changes-btn'); + const saveDropButton = form.find('.query-form__save'); - saveChangesBtn.simulate('click'); + saveDropButton.simulate('click'); + form.find('li').first().find('Button').simulate('click'); expect(onSaveChangesSpy).toHaveBeenCalledWith({ description: query.description, @@ -116,15 +118,15 @@ describe('QueryForm - component', () => { const form = mount(); const inputFields = form.find('InputField'); const nameInput = inputFields.find({ name: 'name' }); - const saveChangesBtn = form.find('.query-form__save-changes-btn'); + const saveChangesOption = form.find('li.dropdown-button__option').first().find('Button'); - expect(saveChangesBtn.props()).toInclude({ + expect(saveChangesOption.props()).toInclude({ disabled: true, }); fillInFormInput(nameInput, 'New query name'); - expect(saveChangesBtn.props()).toNotInclude({ + expect(saveChangesOption.props()).toNotInclude({ disabled: true, }); }); @@ -133,15 +135,15 @@ describe('QueryForm - component', () => { const form = mount(); const inputFields = form.find('InputField'); const descriptionInput = inputFields.find({ name: 'description' }); - const saveChangesBtn = form.find('.query-form__save-changes-btn'); + const saveChangesOption = form.find('li.dropdown-button__option').first().find('Button'); - expect(saveChangesBtn.props()).toInclude({ + expect(saveChangesOption.props()).toInclude({ disabled: true, }); fillInFormInput(descriptionInput, 'New query description'); - expect(saveChangesBtn.props()).toNotInclude({ + expect(saveChangesOption.props()).toNotInclude({ disabled: true, }); }); @@ -151,11 +153,11 @@ describe('QueryForm - component', () => { const form = mount(); const inputFields = form.find('InputField'); const nameInput = inputFields.find({ name: 'name' }); - const saveAsNewBtn = form.find('.query-form__save-as-new-btn'); + const saveAsNewOption = form.find('li.dropdown-button__option').last().find('Button'); fillInFormInput(nameInput, 'New query name'); - saveAsNewBtn.simulate('click'); + saveAsNewOption.simulate('click'); expect(onSaveAsNewSpy).toHaveBeenCalledWith({ description: query.description, @@ -169,11 +171,11 @@ describe('QueryForm - component', () => { const form = mount(); const inputFields = form.find('InputField'); const nameInput = inputFields.find({ name: 'name' }); - const saveAsNewBtn = form.find('.query-form__save-as-new-btn'); + const saveAsNewOption = form.find('li.dropdown-button__option').last().find('Button'); fillInFormInput(nameInput, ''); - saveAsNewBtn.simulate('click'); + saveAsNewOption.simulate('click'); expect(onSaveAsNewSpy).toNotHaveBeenCalled(); expect(form.state()).toInclude({ diff --git a/frontend/components/forms/queries/QueryForm/_styles.scss b/frontend/components/forms/queries/QueryForm/_styles.scss index 5f9fd90008..4476f924ba 100644 --- a/frontend/components/forms/queries/QueryForm/_styles.scss +++ b/frontend/components/forms/queries/QueryForm/_styles.scss @@ -9,7 +9,8 @@ &__button-wrap { text-align: right; - .button { + .query-form__run-query-btn, + .query-form__stop-query-btn { margin-left: $pad-xsmall; } } diff --git a/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx b/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx index db4220c7f4..17453c3e8f 100644 --- a/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx +++ b/frontend/components/queries/QueryComposer/QueryComposer.tests.jsx @@ -68,7 +68,8 @@ describe('QueryComposer - component', () => { fillInFormInput(form.find({ name: 'name' }), 'My query name'); fillInFormInput(form.find({ name: 'description' }), 'My query description'); - form.find('.query-form__save-as-new-btn').simulate('click'); + form.find('.query-form__save').simulate('click'); + form.find('li').last().find('Button').simulate('click'); expect(onSaveQueryFormSubmitSpy).toHaveBeenCalledWith({ description: 'My query description', @@ -112,7 +113,8 @@ describe('QueryComposer - component', () => { const form = component.find('QueryForm'); fillInFormInput(form.find({ name: 'name' }), 'My new query name'); - form.find('.query-form__save-changes-btn').simulate('click'); + form.find('.query-form__save').simulate('click'); + form.find('li').first().find('Button').simulate('click'); expect(onSaveChangesSpy).toHaveBeenCalledWith({ description: query.description, diff --git a/frontend/pages/queries/QueryPage/QueryPage.tests.js b/frontend/pages/queries/QueryPage/QueryPage.tests.js index 35c9df2b4c..29bd7cd0c9 100644 --- a/frontend/pages/queries/QueryPage/QueryPage.tests.js +++ b/frontend/pages/queries/QueryPage/QueryPage.tests.js @@ -65,7 +65,7 @@ describe('QueryPage - component', () => { })); const form = page.find('QueryForm'); const nameInput = form.find({ name: 'name' }).find('input'); - const saveChangesBtn = form.find('Button').first(); + const saveChangesBtn = form.find('li.dropdown-button__option').first().find('Button'); kolide.setBearerToken(bearerToken); validUpdateQueryRequest(bearerToken, query, {