mirror of
https://github.com/fleetdm/fleet
synced 2026-05-23 08:58:41 +00:00
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:
parent
0122f6cb0a
commit
4ba3ad51f0
29 changed files with 1120 additions and 75 deletions
15
frontend/components/ClickableTableRow/index.jsx
Normal file
15
frontend/components/ClickableTableRow/index.jsx
Normal 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;
|
||||
11
frontend/components/NumberPill/NumberPill.jsx
Normal file
11
frontend/components/NumberPill/NumberPill.jsx
Normal 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;
|
||||
13
frontend/components/NumberPill/_styles.scss
Normal file
13
frontend/components/NumberPill/_styles.scss
Normal 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;
|
||||
}
|
||||
1
frontend/components/NumberPill/index.js
Normal file
1
frontend/components/NumberPill/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default from './NumberPill';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
78
frontend/components/packs/PacksList/PacksList.jsx
Normal file
78
frontend/components/packs/PacksList/PacksList.jsx
Normal 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;
|
||||
33
frontend/components/packs/PacksList/PacksList.tests.jsx
Normal file
33
frontend/components/packs/PacksList/PacksList.tests.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
91
frontend/components/packs/PacksList/Row/Row.jsx
Normal file
91
frontend/components/packs/PacksList/Row/Row.jsx
Normal 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;
|
||||
|
||||
24
frontend/components/packs/PacksList/Row/Row.tests.jsx
Normal file
24
frontend/components/packs/PacksList/Row/Row.tests.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
57
frontend/components/packs/PacksList/Row/_styles.scss
Normal file
57
frontend/components/packs/PacksList/Row/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
frontend/components/packs/PacksList/Row/index.js
Normal file
1
frontend/components/packs/PacksList/Row/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default from './Row';
|
||||
37
frontend/components/packs/PacksList/_styles.scss
Normal file
37
frontend/components/packs/PacksList/_styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
1
frontend/components/packs/PacksList/index.js
Normal file
1
frontend/components/packs/PacksList/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export default from './PacksList';
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export default from './PackDetailsSidePanel';
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
178
frontend/pages/packs/AllPacksPage/AllPacksPage.tests.jsx
Normal file
178
frontend/pages/packs/AllPacksPage/AllPacksPage.tests.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue