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 (
+
+
+
+
+ {options.map((option, i) => {
+ return renderOptions(option, i);
+ })}
+
+
+ );
+ }
+}
+
+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, {