Dropdown Button (#744)

This commit is contained in:
Kyle Knight 2017-01-05 09:26:10 -06:00 committed by GitHub
parent f4ae2c1446
commit ab540cdfb5
10 changed files with 241 additions and 38 deletions

View file

@ -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;
}

View file

@ -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 (
<li className={`${baseClass}__option`} key={`dropdown-button-option-${idx}`}>
<Button variant="unstyled" onClick={evt => optionClick(evt, onClick)} disabled={disabled}>{label}</Button>
</li>
);
};
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 (
<div className={`${baseClass}__wrapper`} ref={setDOMNode}>
<Button
className={buttonClass}
disabled={disabled}
onClick={toggleDropdown}
size={size}
tabIndex={tabIndex}
type={type}
variant={variant}
>
{children} <Icon name="downcarat" className={`${baseClass}__carat`} />
</Button>
<ul className={optionsClass}>
{options.map((option, i) => {
return renderOptions(option, i);
})}
</ul>
</div>
);
}
}
export default ClickOutside(DropdownButton, {
getDOMNode: (component) => {
return component.DOMNode;
},
onOutsideClick: (component) => {
return () => {
component.setState({ isOpen: false });
return false;
};
},
});

View file

@ -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(
<DropdownButton options={dropdownOptions}>
New Button
</DropdownButton>
);
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();
});
});

View file

@ -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;
}
}
}
}

View file

@ -0,0 +1 @@
export default from './DropdownButton';

View file

@ -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 (
<div className={`${baseClass}__button-wrap`}>
<Button
className={`${baseClass}__save-changes-btn`}
disabled={!canSaveChanges(formData, query)}
onClick={onUpdate}
variant="inverse"
>
Save Changes
</Button>
<Button
className={`${baseClass}__save-as-new-btn`}
disabled={!canSaveAsNew(formData, query)}
onClick={onSave}
<DropdownButton
className={`${baseClass}__save`}
options={dropdownBtnOptions}
variant="success"
>
Save As New...
</Button>
Save
</DropdownButton>
{runQueryButton}
</div>
);

View file

@ -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(<QueryForm query={query} queryText={queryText} />);
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(<QueryForm query={query} queryText={queryText} />);
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(<QueryForm query={query} queryText={queryText} onSave={onSaveAsNewSpy} />);
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(<QueryForm query={query} queryText={queryText} onSave={onSaveAsNewSpy} />);
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({

View file

@ -9,7 +9,8 @@
&__button-wrap {
text-align: right;
.button {
.query-form__run-query-btn,
.query-form__stop-query-btn {
margin-left: $pad-xsmall;
}
}

View file

@ -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,

View file

@ -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, {