mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 17:08:53 +00:00
Dropdown Button (#744)
This commit is contained in:
parent
f4ae2c1446
commit
ab540cdfb5
10 changed files with 241 additions and 38 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
118
frontend/components/buttons/DropdownButton/DropdownButton.jsx
Normal file
118
frontend/components/buttons/DropdownButton/DropdownButton.jsx
Normal 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;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
54
frontend/components/buttons/DropdownButton/_styles.scss
Normal file
54
frontend/components/buttons/DropdownButton/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/components/buttons/DropdownButton/index.js
Normal file
1
frontend/components/buttons/DropdownButton/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default from './DropdownButton';
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@
|
|||
&__button-wrap {
|
||||
text-align: right;
|
||||
|
||||
.button {
|
||||
.query-form__run-query-btn,
|
||||
.query-form__stop-query-btn {
|
||||
margin-left: $pad-xsmall;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Reference in a new issue