All packs page (#709)

* Display packs page at /packs/manage

* Adds NumberPill component

* Filter packs list

* Render the pack info side panel when no packs are selected

* Adds packs list

* Moves state management to page component

* Display selected pack count

* Render bulk action buttons

* API client - update pack

* API client - destroy pack

* Adds update/destroy functions to packs redux config

* Bulk actions (enable, disable, delete)

* Selecting a pack updates state

* PackDetailsSidePanel updates pack status

* Link to edit pack on side panel

* sets selected pack in URL

* Sets color for unsettled buttons

* Loads scheduled queries for selected pack in All Packs Page

* PackDetailsSidePanel component

* PackDetailsSidePanel styles

* styles PacksList component

* Stop rendering flash when pack status is updated

* Makes full row clickable

* highlight selected pack
This commit is contained in:
Mike Stone 2017-01-03 15:56:50 -05:00 committed by GitHub
parent 0122f6cb0a
commit 4ba3ad51f0
29 changed files with 1120 additions and 75 deletions

View file

@ -0,0 +1,15 @@
import React, { PropTypes } from 'react';
const ClickableTableRow = ({ children, className, onClick }) => {
/* eslint-disable jsx-a11y/no-static-element-interactions */
return <tr className={className} onClick={onClick} tabIndex={-1}>{children}</tr>;
/* eslint-enable jsx-a11y/no-static-element-interactions */
};
ClickableTableRow.propTypes = {
children: PropTypes.node,
className: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
export default ClickableTableRow;

View file

@ -0,0 +1,11 @@
import React, { PropTypes } from 'react';
const NumberPill = ({ number }) => {
return <span className="number-pill">{number}</span>;
};
NumberPill.propTypes = {
number: PropTypes.number,
};
export default NumberPill;

View file

@ -0,0 +1,13 @@
.number-pill {
background-color: $brand;
border-radius: 14px;
color: $white;
display: inline-block;
line-height: 26px;
height: 26px;
text-align: center;
width: 44px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.5px;
}

View file

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

View file

@ -35,6 +35,7 @@ $base-class: 'button';
text-align: center;
color: $white;
line-height: 36px;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 2px;
@ -121,6 +122,7 @@ $base-class: 'button';
background-color: transparent;
border: 0;
box-shadow: none;
color: $text-dark;
cursor: pointer;
margin: 0;
padding: 0;

View file

@ -14,6 +14,7 @@ class Checkbox extends Component {
name: PropTypes.string,
onChange: PropTypes.func,
value: PropTypes.bool,
wrapperClassName: PropTypes.string,
};
static defaultProps = {
@ -29,7 +30,7 @@ class Checkbox extends Component {
render () {
const { handleChange } = this;
const { children, className, disabled, name, value } = this.props;
const { children, className, disabled, name, value, wrapperClassName } = this.props;
const checkBoxClass = classnames(baseClass, className);
const formFieldProps = pick(this.props, ['hint', 'label', 'error', 'name']);
@ -39,7 +40,7 @@ class Checkbox extends Component {
});
return (
<FormField {...formFieldProps} type="checkbox">
<FormField {...formFieldProps} className={wrapperClassName} type="checkbox">
<label htmlFor={name} className={checkBoxClass}>
<input
checked={value}

View file

@ -0,0 +1,78 @@
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
import { includes } from 'lodash';
import Checkbox from 'components/forms/fields/Checkbox';
import packInterface from 'interfaces/pack';
import Row from 'components/packs/PacksList/Row';
const baseClass = 'packs-list';
class PacksList extends Component {
static propTypes = {
allPacksChecked: PropTypes.bool,
checkedPackIDs: PropTypes.arrayOf(PropTypes.number),
className: PropTypes.string,
onCheckAllPacks: PropTypes.func.isRequired,
onCheckPack: PropTypes.func.isRequired,
onSelectPack: PropTypes.func.isRequired,
packs: PropTypes.arrayOf(packInterface),
selectedPack: packInterface,
};
static defaultProps = {
checkedPackIDs: [],
packs: [],
selectedPack: {},
};
renderPack = (pack) => {
const { checkedPackIDs, onCheckPack, onSelectPack, selectedPack } = this.props;
const checked = includes(checkedPackIDs, pack.id);
const selected = pack.id === selectedPack.id;
return (
<Row
checked={checked}
key={`pack-row-${pack.id}`}
onCheck={onCheckPack}
onSelect={onSelectPack}
pack={pack}
selected={selected}
/>
);
}
render () {
const { allPacksChecked, className, onCheckAllPacks, packs } = this.props;
const { renderPack } = this;
const tableClassName = classnames(baseClass, className);
return (
<table className={tableClassName}>
<thead>
<tr>
<th className={`${baseClass}__th`}>
<Checkbox
name="select-all-packs"
onChange={onCheckAllPacks}
value={allPacksChecked}
wrapperClassName={`${baseClass}__select-all`}
/>
</th>
<th className={`${baseClass}__th ${baseClass}__th-pack-name`}>Pack Name</th>
<th className={`${baseClass}__th`}>Queries</th>
<th className={`${baseClass}__th`}>Status</th>
<th className={`${baseClass}__th`}>Hosts</th>
<th className={`${baseClass}__th`}>Last Modified</th>
</tr>
</thead>
<tbody>
{packs.map(pack => renderPack(pack))}
</tbody>
</table>
);
}
}
export default PacksList;

View file

@ -0,0 +1,33 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import PacksList from 'components/packs/PacksList';
import { packStub } from 'test/stubs';
describe('PacksList - component', () => {
afterEach(restoreSpies);
it('renders', () => {
expect(mount(<PacksList packs={[packStub]} />).length).toEqual(1);
});
it('calls the onCheckAllPacks prop when select all packs checkbox is checked', () => {
const spy = createSpy();
const component = mount(<PacksList onCheckAllPacks={spy} packs={[packStub]} />);
component.find({ name: 'select-all-packs' }).simulate('change');
expect(spy).toHaveBeenCalledWith(true);
});
it('calls the onCheckPack prop when a pack checkbox is checked', () => {
const spy = createSpy();
const component = mount(<PacksList onCheckPack={spy} packs={[packStub]} />);
const packCheckbox = component.find({ name: `select-pack-${packStub.id}` });
packCheckbox.simulate('change');
expect(spy).toHaveBeenCalledWith(true, packStub.id);
});
});

View file

@ -0,0 +1,91 @@
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { isEqual } from 'lodash';
import moment from 'moment';
import Checkbox from 'components/forms/fields/Checkbox';
import ClickableTableRow from 'components/ClickableTableRow';
import Icon from 'components/icons/Icon';
import packInterface from 'interfaces/pack';
const baseClass = 'packs-list-row';
class Row extends Component {
static propTypes = {
checked: PropTypes.bool,
onCheck: PropTypes.func,
onSelect: PropTypes.func,
pack: packInterface.isRequired,
selected: PropTypes.bool,
};
shouldComponentUpdate (nextProps) {
return !isEqual(this.props, nextProps);
}
handleChange = (shouldCheck) => {
const { onCheck, pack } = this.props;
return onCheck(shouldCheck, pack.id);
}
handleSelect = () => {
const { onSelect, pack } = this.props;
return onSelect(pack);
}
renderStatusData = () => {
const { disabled } = this.props.pack;
const iconClassName = classNames(`${baseClass}__status-icon`, {
[`${baseClass}__status-icon--enabled`]: !disabled,
[`${baseClass}__status-icon--disabled`]: disabled,
});
if (disabled) {
return (
<td className={`${baseClass}__td`}>
<Icon className={iconClassName} name="offline" />
<span className={`${baseClass}__status-text`}>Disabled</span>
</td>
);
}
return (
<td className={`${baseClass}__td`}>
<Icon className={iconClassName} name="success-check" />
<span className={`${baseClass}__status-text`}>Enabled</span>
</td>
);
}
render () {
const { checked, pack, selected } = this.props;
const { handleChange, handleSelect, renderStatusData } = this;
const updatedTime = moment(pack.updated_at).format('MM/DD/YY');
const rowClass = classNames(baseClass, {
[`${baseClass}--selected`]: selected,
});
return (
<ClickableTableRow className={rowClass} onClick={handleSelect}>
<td className={`${baseClass}__td`}>
<Checkbox
name={`select-pack-${pack.id}`}
onChange={handleChange}
value={checked}
wrapperClassName={`${baseClass}__checkbox`}
/>
</td>
<td className={`${baseClass}__td ${baseClass}__td-pack-name`}>{pack.name}</td>
<td className={`${baseClass}__td ${baseClass}__td-query-count`}>{pack.query_count}</td>
{renderStatusData()}
<td />
<td className={`${baseClass}__td`}>{updatedTime}</td>
</ClickableTableRow>
);
}
}
export default Row;

View file

@ -0,0 +1,24 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import Row from 'components/packs/PacksList/Row';
import { packStub } from 'test/stubs';
describe('PacksList - Row - component', () => {
afterEach(restoreSpies);
it('renders', () => {
expect(mount(<Row pack={packStub} />).length).toEqual(1);
});
it('calls the onCheck prop with the value and pack id when checked', () => {
const spy = createSpy();
const component = mount(<Row checked onCheck={spy} pack={packStub} />);
component.find({ name: `select-pack-${packStub.id}` }).simulate('change');
expect(spy).toHaveBeenCalledWith(false, packStub.id);
});
});

View file

@ -0,0 +1,57 @@
.packs-list-row {
border-bottom: 1px solid $accent-medium;
cursor: pointer;
&--selected {
background-color: $accent-light;
}
&:focus {
outline: none;
}
&__td {
color: $text-ultradark;
font-weight: $bold;
padding: 7px 0;
text-align: center;
&:first-child {
padding-left: 14px;
text-align: left;
}
&:last-child {
padding-right: 25px;
text-align: right;
}
}
&__td-pack-name {
text-align: left;
}
&__checkbox {
margin-bottom: 0;
}
&__status-icon {
&--enabled {
color: $success;
}
&--disabled {
color: $alert;
}
}
&__status-text {
color: $text-ultradark;
font-weight: $normal;
margin-left: 10px;
@include breakpoint(smalldesk) {
display: none;
}
}
}

View file

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

View file

@ -0,0 +1,37 @@
.packs-list {
border: 1px solid #b9c2e4;
border-radius: 3px;
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
margin-top: 20px;
opacity: 0.8;
thead {
background-color: $bg-medium;
border-bottom: 1px solid $accent-medium;
}
&__th {
color: $link;
font-size: 14px;
padding: 14px;
text-align: center;
&:first-child {
text-align: left;
}
&:last-child {
padding-right: 25px;
text-align: right;
}
}
&__th-pack-name {
padding-left: 0;
text-align: left;
}
&__select-all {
margin-bottom: 0;
}
}

View file

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

View file

@ -4,6 +4,7 @@ import { pull } from 'lodash';
import Button from 'components/buttons/Button';
import helpers from 'components/queries/QueriesListWrapper/helpers';
import InputField from 'components/forms/fields/InputField';
import NumberPill from 'components/NumberPill';
import QueriesList from 'components/queries/QueriesList';
import queryInterface from 'interfaces/query';
@ -102,7 +103,7 @@ class QueriesListWrapper extends Component {
const queryCount = scheduledQueries.length;
const queryText = queryCount === 1 ? 'Query' : 'Queries';
return <h1 className={`${baseClass}__query-count`}><span>{queryCount}</span> {queryText}</h1>;
return <h1 className={`${baseClass}__query-count`}><NumberPill number={queryCount} /> {queryText}</h1>;
}
renderQueriesList = () => {

View file

@ -7,20 +7,6 @@
letter-spacing: -0.5px;
text-align: left;
color: $text-dark;
span {
background-color: $brand;
border-radius: 14px;
color: $white;
display: inline-block;
line-height: 26px;
height: 26px;
text-align: center;
width: 44px;
font-size: 15px;
font-weight: $bold;
letter-spacing: -0.5px;
}
}
&__queries-list-wrapper {

View file

@ -0,0 +1,62 @@
import React, { PropTypes } from 'react';
import Icon from 'components/icons/Icon';
import { Link } from 'react-router';
import packInterface from 'interfaces/pack';
import ScheduledQueriesSection from 'components/side_panels/PackDetailsSidePanel/ScheduledQueriesSection';
import scheduledQueryInterface from 'interfaces/scheduled_query';
import SecondarySidePanelContainer from 'components/side_panels/SecondarySidePanelContainer';
import Slider from 'components/forms/fields/Slider';
const baseClass = 'pack-details-side-panel';
const Description = ({ pack }) => {
if (!pack.description) {
return false;
}
return (
<div>
<p className={`${baseClass}__section-label`}>Description</p>
<p className={`${baseClass}__description`}>{pack.description}</p>
</div>
);
};
const PackDetailsSidePanel = ({ onUpdateSelectedPack, pack, scheduledQueries = [] }) => {
const { disabled } = pack;
const updatePackStatus = (value) => {
return onUpdateSelectedPack(pack, { disabled: !value });
};
return (
<SecondarySidePanelContainer className={baseClass}>
<Icon className={`${baseClass}__pack-icon`} name="packs" />
<span className={`${baseClass}__pack-name`}>{pack.name}</span>
<Slider
activeText="ENABLED"
inactiveText="DISABLED"
onChange={updatePackStatus}
value={!disabled}
/>
<Link className={`${baseClass}__edit-pack-link button button--inverse`} to={`/packs/${pack.id}`}>
Edit Pack
</Link>
<Description pack={pack} />
<ScheduledQueriesSection scheduledQueries={scheduledQueries} />
</SecondarySidePanelContainer>
);
};
Description.propTypes = {
pack: packInterface.isRequired,
};
PackDetailsSidePanel.propTypes = {
onUpdateSelectedPack: PropTypes.func,
pack: packInterface.isRequired,
scheduledQueries: PropTypes.arrayOf(scheduledQueryInterface),
};
export default PackDetailsSidePanel;

View file

@ -0,0 +1,72 @@
import React, { Component, PropTypes } from 'react';
import { take } from 'lodash';
import Button from 'components/buttons/Button';
import Icon from 'components/icons/Icon';
import scheduledQueryInterface from 'interfaces/scheduled_query';
const baseClass = 'pack-details-side-panel';
const DEFAULT_NUM_QUERIES = 6;
class ScheduledQueriesSection extends Component {
static propTypes = {
scheduledQueries: PropTypes.arrayOf(scheduledQueryInterface),
};
constructor (props) {
super(props);
this.state = { showAllQueries: false };
}
onShowAll = () => {
this.setState({ showAllQueries: true });
return false;
}
renderShowMoreQueries = () => {
const { showAllQueries } = this.state;
const scheduledQueryCount = this.props.scheduledQueries.length;
const shouldRenderShowMore = !showAllQueries && scheduledQueryCount > DEFAULT_NUM_QUERIES;
if (shouldRenderShowMore) {
const { onShowAll } = this;
const numMoreQueries = scheduledQueryCount - DEFAULT_NUM_QUERIES;
const queryText = numMoreQueries === 1 ? 'Query' : 'Queries';
return (
<div className={`${baseClass}__more-queries-section`}>
<span>{numMoreQueries} More {queryText}</span>
<Button onClick={onShowAll} variant="unstyled">SHOW</Button>
</div>
);
}
return false;
}
render () {
const { renderShowMoreQueries } = this;
const { scheduledQueries } = this.props;
const { showAllQueries } = this.state;
const queriesToRender = showAllQueries ? scheduledQueries : take(scheduledQueries, DEFAULT_NUM_QUERIES);
return (
<div>
<p className={`${baseClass}__section-label`}>Queries</p>
{queriesToRender.map((scheduledQuery) => {
return (
<div key={`scheduled-query-${scheduledQuery.id}`}>
<Icon className={`${baseClass}__query-icon`} name="query" />
<span className={`${baseClass}__query-name`}>{scheduledQuery.name}</span>
</div>
);
})}
{renderShowMoreQueries()}
</div>
);
}
}
export default ScheduledQueriesSection;

View file

@ -0,0 +1,61 @@
.pack-details-side-panel {
padding: 20px;
&__description {
color: $text-medium;
font-size: 14px;
}
&__section-label {
border-bottom: 1px solid $accent-light;
color: $text-dark;
font-size: 16px;
font-weight: $bold;
letter-spacing: -0.5px;
margin-bottom: 0;
padding-bottom: 10px;
}
&__edit-pack-link {
display: block;
}
&__more-queries-section {
@include display(flex);
@include justify-content(space-between);
color: $link;
font-size: 16px;
text-transform: uppercase;
button {
color: $link;
font-size: 16px;
font-weight: $normal;
text-transform: uppercase;
}
}
&__pack-icon {
color: $text-light;
margin-right: 11px;
}
&__pack-name {
color: $text-dark;
font-size: 18px;
font-weight: $bold;
letter-spacing: -0.5px;
}
&__query-icon {
color: $text-light;
font-size: 16px;
margin-right: 15px;
}
&__query-name {
color: $text-medium;
font-size: 16px;
}
}

View file

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

View file

@ -86,15 +86,15 @@ export default (admin) => {
name: 'Packs',
location: {
regex: /^\/packs/,
pathname: '/packs/all',
pathname: '/packs/manage',
},
subItems: [
{
icon: 'packs',
name: 'Manage Packs',
location: {
regex: /\/packs\/all/,
pathname: '/packs/all',
regex: /\/packs\/manage/,
pathname: '/packs/manage',
},
},
{

View file

@ -64,6 +64,13 @@ class Kolide extends Base {
.then(response => response.scheduled);
}
destroyPack = ({ id }) => {
const { PACKS } = endpoints;
const endpoint = `${this.endpoint(PACKS)}/${id}`;
return this.authenticatedDelete(endpoint);
}
destroyScheduledQuery = ({ id }) => {
const endpoint = `${this.endpoint('/v1/kolide/schedule')}/${id}`;
@ -343,6 +350,14 @@ class Kolide extends Base {
return this.authenticatedPatch(this.endpoint(CONFIG), JSON.stringify(configData));
}
updatePack = ({ id: packID }, updateParams) => {
const { PACKS } = endpoints;
const updatePackEndpoint = `${this.baseURL}${PACKS}/${packID}`;
return this.authenticatedPatch(updatePackEndpoint, JSON.stringify(updateParams))
.then((response) => { return response.pack; });
}
updateQuery = ({ id: queryID }, updateParams) => {
const { QUERIES } = endpoints;
const updateQueryEndpoint = `${this.baseURL}${QUERIES}/${queryID}`;

View file

@ -12,6 +12,7 @@ const {
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
validDestroyPackRequest,
validDestroyScheduledQueryRequest,
validForgotPasswordRequest,
validGetConfigRequest,
@ -31,6 +32,7 @@ const {
validRunQueryRequest,
validSetupRequest,
validUpdateConfigRequest,
validUpdatePackRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
validUser,
@ -70,16 +72,42 @@ describe('Kolide - API client', () => {
});
});
describe('#createPack', () => {
it('calls the appropriate endpoint with the correct parameters', (done) => {
const bearerToken = 'valid-bearer-token';
const description = 'pack description';
const name = 'pack name';
const queryParams = { description, name };
const request = validCreatePackRequest(bearerToken, queryParams);
describe('packs', () => {
const bearerToken = 'valid-bearer-token';
const pack = { id: 1, name: 'Pack Name', description: 'Pack Description' };
it('#createPack', (done) => {
const { description, name } = pack;
const params = { description, name };
const request = validCreatePackRequest(bearerToken, params);
Kolide.setBearerToken(bearerToken);
Kolide.createPack(queryParams)
Kolide.createPack(params)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
it('#destroyPack', (done) => {
const request = validDestroyPackRequest(bearerToken, pack);
Kolide.setBearerToken(bearerToken);
Kolide.destroyPack(pack)
.then(() => {
expect(request.isDone()).toEqual(true);
done();
})
.catch(done);
});
it('#updatePack', (done) => {
const updatePackParams = { name: 'New Pack Name' };
const request = validUpdatePackRequest(bearerToken, pack, updatePackParams);
Kolide.setBearerToken(bearerToken);
Kolide.updatePack(pack, updatePackParams)
.then(() => {
expect(request.isDone()).toEqual(true);
done();

View file

@ -1,30 +1,179 @@
import React, { Component, PropTypes } from 'react';
import { connect } from 'react-redux';
import moment from 'moment';
import { filter, get, includes, isEqual, noop, pull } from 'lodash';
import { push } from 'react-router-redux';
import Button from 'components/buttons/Button';
import entityGetter from 'redux/utilities/entityGetter';
import Icon from 'components/icons/Icon';
import InputField from 'components/forms/fields/InputField';
import NumberPill from 'components/NumberPill';
import packActions from 'redux/nodes/entities/packs/actions';
import PackDetailsSidePanel from 'components/side_panels/PackDetailsSidePanel';
import PackInfoSidePanel from 'components/side_panels/PackInfoSidePanel';
import packInterface from 'interfaces/pack';
import PacksList from 'components/packs/PacksList';
import paths from 'router/paths';
import { renderFlash } from 'redux/nodes/notifications/actions';
import scheduledQueryActions from 'redux/nodes/entities/scheduled_queries/actions';
import scheduledQueryInterface from 'interfaces/scheduled_query';
const baseClass = 'all-packs-page';
class AllPacksPage extends Component {
export class AllPacksPage extends Component {
static propTypes = {
dispatch: PropTypes.func,
packs: PropTypes.arrayOf(packInterface),
selectedPack: packInterface,
selectedScheduledQueries: PropTypes.arrayOf(scheduledQueryInterface),
}
static defaultProps = {
dispatch: noop,
};
constructor (props) {
super(props);
this.state = {
allPacksChecked: false,
checkedPackIDs: [],
packFilter: '',
};
}
componentWillMount() {
const { dispatch, packs } = this.props;
const { dispatch, packs, selectedPack } = this.props;
if (!packs.length) {
dispatch(packActions.loadAll());
}
if (selectedPack) {
this.getScheduledQueriesForPack(selectedPack);
}
return false;
}
componentWillReceiveProps ({ selectedPack }) {
if (!isEqual(this.props.selectedPack, selectedPack)) {
this.getScheduledQueriesForPack(selectedPack);
}
return false;
}
onBulkAction = (actionType) => {
return (evt) => {
evt.preventDefault();
const { checkedPackIDs } = this.state;
const { dispatch } = this.props;
const { destroy, update } = packActions;
const promises = checkedPackIDs.map((packID) => {
const disabled = actionType === 'disable';
if (actionType === 'delete') {
return dispatch(destroy({ id: packID }));
}
return dispatch(update({ id: packID }, { disabled }));
});
return Promise.all(promises)
.then(() => {
if (actionType === 'delete') {
dispatch(renderFlash('success', 'Packs successfully deleted.'));
}
return false;
})
.catch(() => dispatch(renderFlash('error', 'Something went wrong.')));
};
}
onCheckAllPacks = (shouldCheck) => {
if (shouldCheck) {
const packs = this.getPacks();
const checkedPackIDs = packs.map(pack => pack.id);
this.setState({ allPacksChecked: true, checkedPackIDs });
return false;
}
this.setState({ allPacksChecked: false, checkedPackIDs: [] });
return false;
}
onCheckPack = (checked, id) => {
const { checkedPackIDs } = this.state;
const newCheckedPackIDs = checked ? checkedPackIDs.concat(id) : pull(checkedPackIDs, id);
this.setState({ allPacksChecked: false, checkedPackIDs: newCheckedPackIDs });
return false;
}
onFilterPacks = (packFilter) => {
this.setState({ packFilter });
return false;
}
onSelectPack = (selectedPack) => {
const { dispatch } = this.props;
const locationObject = {
pathname: '/packs/manage',
query: { selectedPack: selectedPack.id },
};
dispatch(push(locationObject));
return false;
}
onUpdateSelectedPack = (pack, updatedAttrs) => {
const { dispatch } = this.props;
const { update } = packActions;
return dispatch(update(pack, updatedAttrs));
}
getPacks = () => {
const { packFilter } = this.state;
const { packs } = this.props;
if (!packFilter) {
return packs;
}
const lowerPackFilter = packFilter.toLowerCase();
return filter(packs, (pack) => {
if (!pack.name) {
return false;
}
const lowerPackName = pack.name.toLowerCase();
return includes(lowerPackName, lowerPackFilter);
});
}
getScheduledQueriesForPack = (pack) => {
const { dispatch } = this.props;
const { loadAll } = scheduledQueryActions;
if (!pack) {
return false;
}
dispatch(loadAll(pack));
return false;
}
@ -37,63 +186,120 @@ class AllPacksPage extends Component {
return false;
}
renderPack = (pack) => {
const updatedTime = moment(pack.updated_at);
renderCTAs = () => {
const { goToNewPackPage, onBulkAction } = this;
const btnClass = `${baseClass}__bulk-action-btn`;
const checkedPackCount = this.state.checkedPackIDs.length;
if (checkedPackCount) {
const packText = checkedPackCount === 1 ? 'Pack' : 'Packs';
return (
<div>
<p className={`${baseClass}__pack-count`}>{checkedPackCount} {packText} Selected</p>
<Button
className={`${btnClass} ${btnClass}--disable`}
onClick={onBulkAction('disable')}
variant="unstyled"
>
<Icon name="offline" /> Disable
</Button>
<Button
className={`${btnClass} ${btnClass}--enable`}
onClick={onBulkAction('enable')}
variant="unstyled"
>
<Icon name="success-check" /> Enable
</Button>
<Button
className={`${btnClass} ${btnClass}--delete`}
onClick={onBulkAction('delete')}
variant="unstyled"
>
<Icon name="delete-cloud" /> Delete
</Button>
</div>
);
}
return (
<tr key={`pack-${pack.id}-table`}>
<td>{pack.name}</td>
<td>{pack.query_count}</td>
<td>Enabled?</td>
<td>Jason Meller?</td>
<td>{pack.hosts_count}</td>
<td>{updatedTime.fromNow()}</td>
</tr>
<Button variant="brand" onClick={goToNewPackPage}>CREATE NEW PACK</Button>
);
}
renderSidePanel = () => {
const { onUpdateSelectedPack } = this;
const { selectedPack, selectedScheduledQueries } = this.props;
if (!selectedPack) {
return <PackInfoSidePanel />;
}
return (
<PackDetailsSidePanel
onUpdateSelectedPack={onUpdateSelectedPack}
pack={selectedPack}
scheduledQueries={selectedScheduledQueries}
/>
);
}
render () {
const { goToNewPackPage, renderPack } = this;
const { packs } = this.props;
const { allPacksChecked, checkedPackIDs, packFilter } = this.state;
const {
getPacks,
onCheckAllPacks,
onCheckPack,
onSelectPack,
onFilterPacks,
renderCTAs,
renderSidePanel,
} = this;
const { selectedPack } = this.props;
const packs = getPacks();
const packsCount = packs.length;
return (
<div className={`${baseClass} body-wrap`}>
<div className={`${baseClass}__wrapper`}>
<div className={`${baseClass} has-sidebar`}>
<div className={`${baseClass}__wrapper body-wrap`}>
<p className={`${baseClass}__title`}>
Query Packs
<NumberPill number={packsCount} /> Query Packs
</p>
<div className={`${baseClass}__new_pack`}>
<Button variant="brand" onClick={goToNewPackPage}>
CREATE NEW PACK
</Button>
<div className={`${baseClass}__search-create-section`}>
<InputField
name="pack-filter"
onChange={onFilterPacks}
placeholder="Search Packs"
value={packFilter}
/>
{renderCTAs()}
</div>
<table className={`${baseClass}__table`}>
<thead>
<tr>
<th>Name</th>
<th>Queries</th>
<th>Status</th>
<th>Author</th>
<th>Number of Hosts</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{packs.map((pack) => {
return renderPack(pack);
})}
</tbody>
</table>
<PacksList
allPacksChecked={allPacksChecked}
checkedPackIDs={checkedPackIDs}
className={`${baseClass}__table`}
onCheckAllPacks={onCheckAllPacks}
onCheckPack={onCheckPack}
onSelectPack={onSelectPack}
packs={packs}
selectedPack={selectedPack}
/>
</div>
{renderSidePanel()}
</div>
);
}
}
const mapStateToProps = (state) => {
const { entities: packs } = entityGetter(state).get('packs');
const mapStateToProps = (state, { location }) => {
const packEntities = entityGetter(state).get('packs');
const scheduledQueryEntities = entityGetter(state).get('scheduled_queries');
const { entities: packs } = packEntities;
const selectedPackID = get(location, 'query.selectedPack');
const selectedPack = selectedPackID && packEntities.findBy({ id: selectedPackID });
const selectedScheduledQueries = selectedPack && scheduledQueryEntities.where({ pack_id: selectedPack.id });
return { packs };
return { packs, selectedPack, selectedScheduledQueries };
};
export default connect(mapStateToProps)(AllPacksPage);

View file

@ -0,0 +1,178 @@
import React from 'react';
import expect from 'expect';
import { find } from 'lodash';
import { mount } from 'enzyme';
import ConnectedAllPacksPage, { AllPacksPage } from 'pages/packs/AllPacksPage/AllPacksPage';
import { connectedComponent, fillInFormInput, reduxMockStore } from 'test/helpers';
import { packStub } from 'test/stubs';
const store = {
entities: {
packs: {
data: {
[packStub.id]: packStub,
101: {
...packStub,
id: 101,
name: 'My unique pack name',
},
},
},
},
};
describe('AllPacksPage - component', () => {
it('filters the packs list', () => {
const Component = connectedComponent(ConnectedAllPacksPage, {
mockStore: reduxMockStore(store),
});
const page = mount(Component).find('AllPacksPage');
const packsFilterInput = page.find({ name: 'pack-filter' }).find('input');
expect(page.node.getPacks().length).toEqual(2);
fillInFormInput(packsFilterInput, 'My unique pack name');
expect(page.node.getPacks().length).toEqual(1);
});
it('renders a PacksList component', () => {
const page = mount(connectedComponent(ConnectedAllPacksPage));
expect(page.find('PacksList').length).toEqual(1);
});
it('renders the PackInfoSidePanel by default', () => {
const page = mount(connectedComponent(ConnectedAllPacksPage));
expect(page.find('PackInfoSidePanel').length).toEqual(1);
});
it('updates checkedPackIDs in state when the select all packs Checkbox is toggled', () => {
const page = mount(<AllPacksPage packs={[packStub]} />);
const selectAllPacks = page.find({ name: 'select-all-packs' });
expect(page.state('checkedPackIDs')).toEqual([]);
selectAllPacks.simulate('change');
expect(page.state('checkedPackIDs')).toEqual([packStub.id]);
selectAllPacks.simulate('change');
expect(page.state('checkedPackIDs')).toEqual([]);
});
it('updates checkedPackIDs in state when a pack row Checkbox is toggled', () => {
const page = mount(<AllPacksPage packs={[packStub]} />);
const selectPack = page.find({ name: `select-pack-${packStub.id}` });
expect(page.state('checkedPackIDs')).toEqual([]);
selectPack.simulate('change');
expect(page.state('checkedPackIDs')).toEqual([packStub.id]);
selectPack.simulate('change');
expect(page.state('checkedPackIDs')).toEqual([]);
});
describe('bulk actions', () => {
const packs = [packStub, { ...packStub, id: 101, name: 'My unique pack name' }];
it('displays the bulk action buttons when a pack is checked', () => {
const page = mount(<AllPacksPage packs={packs} />);
const selectAllPacks = page.find({ name: 'select-all-packs' });
selectAllPacks.simulate('change');
expect(page.state('checkedPackIDs')).toEqual([packStub.id, 101]);
expect(page.find('.all-packs-page__bulk-action-btn--disable').length).toEqual(1);
expect(page.find('.all-packs-page__bulk-action-btn--enable').length).toEqual(1);
expect(page.find('.all-packs-page__bulk-action-btn--delete').length).toEqual(1);
});
it('dispatches the pack update function when disable is clicked', () => {
const mockStore = reduxMockStore(store);
const Component = connectedComponent(ConnectedAllPacksPage, { mockStore });
const page = mount(Component).find('AllPacksPage');
const selectAllPacks = page.find({ name: 'select-all-packs' });
selectAllPacks.simulate('change');
const disableBtn = page.find('.all-packs-page__bulk-action-btn--disable');
disableBtn.simulate('click');
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toInclude({ type: 'packs_UPDATE_REQUEST' });
});
it('dispatches the pack update function when enable is clicked', () => {
const mockStore = reduxMockStore(store);
const Component = connectedComponent(ConnectedAllPacksPage, { mockStore });
const page = mount(Component).find('AllPacksPage');
const selectAllPacks = page.find({ name: 'select-all-packs' });
selectAllPacks.simulate('change');
const enableBtn = page.find('.all-packs-page__bulk-action-btn--enable');
enableBtn.simulate('click');
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toInclude({ type: 'packs_UPDATE_REQUEST' });
});
it('dispatches the pack destroy function when delete is clicked', () => {
const mockStore = reduxMockStore(store);
const Component = connectedComponent(ConnectedAllPacksPage, { mockStore });
const page = mount(Component).find('AllPacksPage');
const selectAllPacks = page.find({ name: 'select-all-packs' });
selectAllPacks.simulate('change');
const deleteBtn = page.find('.all-packs-page__bulk-action-btn--delete');
deleteBtn.simulate('click');
const dispatchedActions = mockStore.getActions();
expect(dispatchedActions).toInclude({ type: 'packs_DESTROY_REQUEST' });
});
});
describe('selecting a pack', () => {
it('updates the URL when a pack is selected', () => {
const mockStore = reduxMockStore(store);
const Component = connectedComponent(ConnectedAllPacksPage, { mockStore });
const page = mount(Component).find('AllPacksPage');
const firstRow = page.find('Row').first();
expect(page.prop('selectedPack')).toNotExist();
firstRow.find('ClickableTableRow').first().simulate('click');
const dispatchedActions = mockStore.getActions();
const locationChangeAction = find(dispatchedActions, { type: '@@router/CALL_HISTORY_METHOD' });
expect(locationChangeAction.payload.args).toEqual([{
pathname: '/packs/manage',
query: { selectedPack: packStub.id },
}]);
});
it('sets the selectedPack prop', () => {
const mockStore = reduxMockStore(store);
const props = { location: { query: { selectedPack: packStub.id } } };
const Component = connectedComponent(ConnectedAllPacksPage, { mockStore, props });
const page = mount(Component).find('AllPacksPage');
expect(page.prop('selectedPack')).toEqual(packStub);
});
});
});

View file

@ -1,4 +1,50 @@
.all-packs-page {
&__bulk-action-btn {
color: $link;
font-size: 15px;
font-weight: $normal;
margin-left: 26px;
&--disable {
margin-left: 0;
i {
color: $alert;
}
}
&--enable {
i {
color: $success;
}
}
&--delete {
i {
color: $silver;
}
}
}
&__pack-count {
color: #858495;
font-size: 14px;
font-weight: $bold;
letter-spacing: -0.5px;
margin: 0;
}
&__search-create-section {
@include display(flex);
@include justify-content(space-between);
input {
&[name='pack-filter'] {
width: 240px;
}
}
}
&__title {
color: $text-medium;
display: inline-block;

View file

@ -1,13 +1,15 @@
import Kolide from '../../../../kolide';
import reduxConfig from '../base/reduxConfig';
import schemas from '../base/schemas';
import Kolide from 'kolide';
import reduxConfig from 'redux/nodes/entities/base/reduxConfig';
import schemas from 'redux/nodes/entities/base/schemas';
const { PACKS: schema } = schemas;
export default reduxConfig({
createFunc: Kolide.createPack,
destroyFunc: Kolide.destroyPack,
entityName: 'packs',
loadAllFunc: Kolide.getPacks,
loadFunc: Kolide.getPack,
schema,
updateFunc: Kolide.updatePack,
});

View file

@ -49,7 +49,7 @@ const routes = (
<Route path="manage" component={ManageHostsPage} />
</Route>
<Route path="packs" component={PackPageWrapper}>
<Route path="all" component={AllPacksPage} />
<Route path="manage" component={AllPacksPage} />
<Route path="new" component={PackComposerPage} />
<Route path=":id">
<IndexRoute component={EditPackPage} />

View file

@ -64,6 +64,16 @@ export const validCreateScheduledQueryRequest = (bearerToken, formData) => {
.reply(201, { scheduled_query: scheduledQueryStub });
};
export const validDestroyPackRequest = (bearerToken, pack) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.delete(`/api/v1/kolide/packs/${pack.id}`)
.reply(200, {});
};
export const validDestroyScheduledQueryRequest = (bearerToken, scheduledQuery) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -305,6 +315,16 @@ export const validUpdateConfigRequest = (bearerToken, configData) => {
.reply(200, {});
};
export const validUpdatePackRequest = (bearerToken, pack, formData) => {
return nock('http://localhost:8080', {
reqHeaders: {
Authorization: `Bearer ${bearerToken}`,
},
})
.patch(`/api/v1/kolide/packs/${pack.id}`, JSON.stringify(formData))
.reply(200, { pack: { ...pack, ...formData } });
};
export const validUpdateQueryRequest = (bearerToken, query, formData) => {
return nock('http://localhost:8080', {
reqHeaders: {
@ -330,6 +350,7 @@ export default {
validCreatePackRequest,
validCreateQueryRequest,
validCreateScheduledQueryRequest,
validDestroyPackRequest,
validDestroyScheduledQueryRequest,
validForgotPasswordRequest,
validGetConfigRequest,
@ -349,6 +370,7 @@ export default {
validRunQueryRequest,
validSetupRequest,
validUpdateConfigRequest,
validUpdatePackRequest,
validUpdateQueryRequest,
validUpdateUserRequest,
validUser,