* Stop rendering results when query hasn’t been run

* Adds QueryPageSelectTargets component

* Re-arranges target select input on Query Page

* Adds label to KolideAce component

* Re-arrange inputs on the Query Form component
This commit is contained in:
Mike Stone 2017-02-16 15:31:21 -05:00 коммит произвёл GitHub
Родитель 146ee18c62
Коммит 803bc41366
17 изменённых файлов: 464 добавлений и 205 удалений

Просмотреть файл

@ -14,6 +14,7 @@ class KolideAce extends Component {
static propTypes = {
error: PropTypes.string,
fontSize: PropTypes.number,
label: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func,
onLoad: PropTypes.func,
@ -31,6 +32,18 @@ class KolideAce extends Component {
wrapEnabled: false,
};
renderLabel = () => {
const { error, label } = this.props;
const labelClassName = classnames(`${baseClass}__label`, {
[`${baseClass}__label--error`]: error,
});
return (
<p className={labelClassName}>{error || label}</p>
);
}
render () {
const {
error,
@ -44,6 +57,7 @@ class KolideAce extends Component {
wrapEnabled,
wrapperClassName,
} = this.props;
const { renderLabel } = this;
const wrapperClass = classnames(wrapperClassName, {
[`${baseClass}__wrapper--error`]: error,
@ -51,7 +65,7 @@ class KolideAce extends Component {
return (
<div className={wrapperClass}>
<div className={`${baseClass}__error-field`}>{error}</div>
{renderLabel()}
<AceEditor
enableBasicAutocompletion
enableLiveAutocompletion

Просмотреть файл

@ -1,14 +1,18 @@
.kolide-ace {
&__error-field {
color: $alert;
display: block;
&__label {
font-size: 16px;
font-stretch: normal;
font-style: normal;
font-weight: $bold;
font-style: normal;
font-stretch: normal;
letter-spacing: -0.5px;
color: $text-dark;
display: block;
margin-bottom: 4px;
min-height: 25px;
&--error {
color: $alert;
}
}
&__wrapper {

Просмотреть файл

@ -75,6 +75,10 @@ $base-class: 'button';
@include button-variant($warning);
}
&--link {
@include button-variant($link);
}
&--inverse {
@include button-variant($white, $inverse-hover, $brand);
color: $brand;

Просмотреть файл

@ -1,7 +1,6 @@
import React, { Component, PropTypes } from 'react';
import { size } from 'lodash';
import Button from 'components/buttons/Button';
import DropdownButton from 'components/buttons/DropdownButton';
import Form from 'components/forms/Form';
import formFieldInterface from 'interfaces/form_field';
@ -9,10 +8,7 @@ import helpers from 'components/forms/queries/QueryForm/helpers';
import InputField from 'components/forms/fields/InputField';
import KolideAce from 'components/KolideAce';
import queryInterface from 'interfaces/query';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
import targetInterface from 'interfaces/target';
import validateQuery from 'components/forms/validators/validate_query';
import Timer from 'components/loaders/Timer';
const baseClass = 'query-form';
@ -46,16 +42,9 @@ class QueryForm extends Component {
}).isRequired,
handleSubmit: PropTypes.func,
formData: queryInterface,
onFetchTargets: PropTypes.func,
onOsqueryTableSelect: PropTypes.func,
onRunQuery: PropTypes.func,
onStopQuery: PropTypes.func,
onTargetSelect: PropTypes.func,
onUpdate: PropTypes.func,
queryIsRunning: PropTypes.bool,
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
targetsError: PropTypes.string,
};
static defaultProps = {
@ -85,16 +74,6 @@ class QueryForm extends Component {
});
}
onRunQuery = (queryText) => {
return (evt) => {
evt.preventDefault();
const { onRunQuery: handleRunQuery } = this.props;
return handleRunQuery(queryText);
};
}
onUpdate = (evt) => {
evt.preventDefault();
@ -126,113 +105,58 @@ class QueryForm extends Component {
renderButtons = () => {
const { canSaveAsNew, canSaveChanges } = helpers;
const {
fields,
formData,
handleSubmit,
onStopQuery,
queryIsRunning,
} = this.props;
const { onRunQuery, onUpdate } = this;
const { fields, formData, handleSubmit } = this.props;
const { onUpdate } = this;
const dropdownBtnOptions = [{
disabled: !canSaveChanges(fields, formData),
label: 'Save Changes',
onClick: onUpdate,
}, {
disabled: !canSaveAsNew(fields, formData),
label: 'Save As New...',
onClick: handleSubmit,
}];
let runQueryButton;
if (queryIsRunning) {
runQueryButton = (
<Button
className={`${baseClass}__stop-query-btn`}
onClick={onStopQuery}
variant="alert"
>
Stop Query
</Button>
);
} else {
runQueryButton = (
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery(fields.query.value)}
variant="brand"
>
Run Query
</Button>
);
}
const dropdownBtnOptions = [
{
disabled: !canSaveChanges(fields, formData),
label: 'Save Changes',
onClick: onUpdate,
},
{
disabled: !canSaveAsNew(fields, formData),
label: 'Save As New...',
onClick: handleSubmit,
},
];
return (
<div className={`${baseClass}__button-wrap`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<DropdownButton
className={`${baseClass}__save`}
options={dropdownBtnOptions}
variant="success"
variant="brand"
>
Save
</DropdownButton>
{runQueryButton}
</div>
);
}
renderTargetsInput = () => {
const {
onFetchTargets,
onTargetSelect,
selectedTargets,
targetsCount,
targetsError,
} = this.props;
return (
<div>
<SelectTargetsDropdown
error={targetsError}
onFetchTargets={onFetchTargets}
onSelect={onTargetSelect}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
label="Select Targets"
/>
</div>
);
}
render () {
const { errors } = this.state;
const { baseError, fields, handleSubmit, queryIsRunning } = this.props;
const { onLoad, renderButtons, renderTargetsInput } = this;
const { errors } = this.state;
const { onLoad, renderButtons } = this;
return (
<form className={`${baseClass}__wrapper`} onSubmit={handleSubmit}>
<h1>New Query</h1>
<KolideAce
{...fields.query}
error={fields.query.error || errors.query}
onLoad={onLoad}
readOnly={queryIsRunning}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
/>
{baseError && <div className="form__base-error">{baseError}</div>}
{renderTargetsInput()}
<InputField
{...fields.name}
error={fields.name.error || errors.name}
inputClassName={`${baseClass}__query-title`}
label="Query Title"
/>
<KolideAce
{...fields.query}
error={fields.query.error || errors.query}
label="SQL"
onLoad={onLoad}
readOnly={queryIsRunning}
wrapperClassName={`${baseClass}__text-editor-wrapper`}
/>
<InputField
{...fields.description}
inputClassName={`${baseClass}__query-description`}

Просмотреть файл

@ -37,36 +37,6 @@ describe('QueryForm - component', () => {
expect(inputFields.find({ name: 'description' }).length).toEqual(1);
});
it('renders a "stop query" button when a query is running', () => {
const form = mount(<QueryForm onTargetSelect={noop} query={query} queryIsRunning queryText={queryText} />);
const runQueryBtn = form.find('.query-form__run-query-btn');
const stopQueryBtn = form.find('.query-form__stop-query-btn');
expect(runQueryBtn.length).toEqual(0);
expect(stopQueryBtn.length).toEqual(1);
});
it('renders a "run query" button when a query is not running', () => {
const form = mount(<QueryForm formData={{ ...query, query: queryText }} onTargetSelect={noop} queryIsRunning={false} />);
const runQueryBtn = form.find('.query-form__run-query-btn');
const stopQueryBtn = form.find('.query-form__stop-query-btn');
expect(runQueryBtn.length).toEqual(1);
expect(stopQueryBtn.length).toEqual(0);
});
it('calls the onStopQuery prop when the stop query button is clicked', () => {
const onStopQuerySpy = createSpy();
const form = mount(
<QueryForm onStopQuery={onStopQuerySpy} onTargetSelect={noop} formData={query} queryIsRunning queryText={queryText} />
);
const stopQueryBtn = form.find('.query-form__stop-query-btn');
stopQueryBtn.simulate('click');
expect(onStopQuerySpy).toHaveBeenCalled();
});
it('validates the query name before saving changes', () => {
const updateSpy = createSpy();
const form = mount(<QueryForm formData={{ ...query, query: queryText }} onTargetSelect={noop} onUpdate={updateSpy} />);
@ -175,14 +145,4 @@ describe('QueryForm - component', () => {
},
});
});
it('calls the onRunQuery prop with the query text when "Run Query" is clicked and the form is valid', () => {
const onRunQuerySpy = createSpy();
const form = mount(<QueryForm formData={{ ...query, query: queryText }} onRunQuery={onRunQuerySpy} onTargetSelect={noop} />);
const runQueryBtn = form.find('.query-form__run-query-btn');
runQueryBtn.simulate('click');
expect(onRunQuerySpy).toHaveBeenCalledWith(query.query);
});
});

Просмотреть файл

@ -1,6 +1,10 @@
.query-form {
&__wrapper {
padding: $base;
h1 {
margin-bottom: 19px;
}
}
&__query-title,

Просмотреть файл

@ -0,0 +1,124 @@
import React, { Component, PropTypes } from 'react';
import { get } from 'lodash';
import Button from 'components/buttons/Button';
import campaignInterface from 'interfaces/campaign';
import ProgressBar from 'components/loaders/ProgressBar';
import SelectTargetsDropdown from 'components/forms/fields/SelectTargetsDropdown';
import targetInterface from 'interfaces/target';
import Timer from 'components/loaders/Timer';
const baseClass = 'query-page-select-targets';
class QueryPageSelectTargets extends Component {
static propTypes = {
campaign: campaignInterface,
error: PropTypes.string,
onFetchTargets: PropTypes.func.isRequired,
onRunQuery: PropTypes.func.isRequired,
onStopQuery: PropTypes.func.isRequired,
onTargetSelect: PropTypes.func.isRequired,
query: PropTypes.string,
queryIsRunning: PropTypes.bool,
selectedTargets: PropTypes.arrayOf(targetInterface),
targetsCount: PropTypes.number,
};
onRunQuery = () => {
const { onRunQuery, query } = this.props;
return onRunQuery(query);
}
renderProgressDetails = () => {
const {
campaign,
onStopQuery,
queryIsRunning,
} = this.props;
const { onRunQuery } = this;
const { hosts_count: hostsCount } = campaign;
const totalHostsCount = get(campaign, ['totals', 'count'], 0);
const totalRowsCount = get(campaign, ['query_results', 'length'], 0);
const runQueryBtn = (
<div className={`${baseClass}__query-btn-wrapper`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<Button
className={`${baseClass}__run-query-btn`}
onClick={onRunQuery}
variant="success"
>
Run
</Button>
</div>
);
const stopQueryBtn = (
<div className={`${baseClass}__query-btn-wrapper`}>
{queryIsRunning && <Timer running={queryIsRunning} />}
<Button
className={`${baseClass}__stop-query-btn`}
onClick={onStopQuery}
variant="alert"
>
Stop
</Button>
</div>
);
if (!hostsCount.total) {
return (
<div className={`${baseClass}__progress-wrapper`}>
<div className={`${baseClass}__progress-details`} />
{queryIsRunning ? stopQueryBtn : runQueryBtn}
</div>
);
}
return (
<div className={`${baseClass}__progress-wrapper`}>
<div className={`${baseClass}__progress-details`}>
<span>
<b>{hostsCount.total}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
({hostsCount.failed} failed)
</span>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}
success={hostsCount.successful}
/>
</div>
{queryIsRunning ? stopQueryBtn : runQueryBtn}
</div>
);
}
render () {
const {
error,
onFetchTargets,
onTargetSelect,
selectedTargets,
targetsCount,
} = this.props;
const { renderProgressDetails } = this;
return (
<div className={`${baseClass}__wrapper body-wrap`}>
{renderProgressDetails()}
<SelectTargetsDropdown
error={error}
onFetchTargets={onFetchTargets}
onSelect={onTargetSelect}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
label="Select Targets"
/>
</div>
);
}
}
export default QueryPageSelectTargets;

Просмотреть файл

@ -0,0 +1,173 @@
import React from 'react';
import expect, { createSpy, restoreSpies } from 'expect';
import { mount } from 'enzyme';
import { noop } from 'lodash';
import { campaignStub } from 'test/stubs';
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
describe('QueryPageSelectTargets - component', () => {
const DEFAULT_CAMPAIGN = {
hosts_count: {
total: 0,
},
};
const defaultProps = {
campaign: DEFAULT_CAMPAIGN,
onFetchTargets: noop,
onRunQuery: noop,
onStopQuery: noop,
onTargetSelect: noop,
query: 'select * from users',
queryIsRunning: false,
selectedTargets: [],
targetsCount: 0,
};
afterEach(restoreSpies);
describe('rendering', () => {
const DefaultComponent = mount(<QueryPageSelectTargets {...defaultProps} />);
it('renders', () => {
expect(DefaultComponent.length).toEqual(1, 'QueryPageSelectTargets did not render');
});
it('renders a SelectTargetsDropdown component', () => {
const SelectTargetsDropdown = DefaultComponent.find('SelectTargetsDropdown');
expect(SelectTargetsDropdown.length).toEqual(1, 'SelectTargetsDropdown did not render');
});
it('renders a Run Query Button', () => {
const RunQueryButton = DefaultComponent.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(1, 'RunQueryButton did not render');
});
it('does not render a Stop Query Button', () => {
const StopQueryButton = DefaultComponent.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(0, 'StopQueryButton is not expected to render');
});
it('does not render a Timer component', () => {
const Timer = DefaultComponent.find('Timer');
expect(Timer.length).toEqual(0, 'Timer is not expected to render');
});
it('does not render a ProgressBar component', () => {
const ProgressBar = DefaultComponent.find('ProgressBar');
expect(ProgressBar.length).toEqual(0, 'ProgressBar is not expected to render');
});
describe('when the campaign has results', () => {
describe('and the query is running', () => {
const props = {
...defaultProps,
campaign: campaignStub,
queryIsRunning: true,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
it('renders a Timer component', () => {
const Timer = Component.find('Timer');
expect(Timer.length).toEqual(1, 'Timer is expected to render');
});
it('renders a Stop Query Button', () => {
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(1, 'StopQueryButton is expected to render');
});
it('does not render a Run Query Button', () => {
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(0, 'RunQueryButton is not expected render');
});
it('renders a ProgressBar component', () => {
const ProgressBar = Component.find('ProgressBar');
expect(ProgressBar.length).toEqual(1, 'ProgressBar is expected to render');
});
});
describe('and the query is not running', () => {
const props = {
...defaultProps,
campaign: campaignStub,
queryIsRunning: false,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
it('does not render a Timer component', () => {
const Timer = Component.find('Timer');
expect(Timer.length).toEqual(0, 'Timer is not expected to render');
});
it('does not render a Stop Query Button', () => {
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
expect(StopQueryButton.length).toEqual(0, 'StopQueryButton is not expected to render');
});
it('renders a Run Query Button', () => {
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
expect(RunQueryButton.length).toEqual(1, 'RunQueryButton did not render');
});
it('renders a ProgressBar component', () => {
const ProgressBar = Component.find('ProgressBar');
expect(ProgressBar.length).toEqual(1, 'ProgressBar is expected to render');
});
});
});
});
describe('running a query', () => {
it('calls the onRunQuery prop with the query text', () => {
const spy = createSpy();
const query = 'select * from groups';
const props = {
...defaultProps,
campaign: campaignStub,
onRunQuery: spy,
query,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
const RunQueryButton = Component.find('.query-page-select-targets__run-query-btn');
RunQueryButton.simulate('click');
expect(spy).toHaveBeenCalledWith(query);
});
});
describe('stopping a query', () => {
it('calls the onStopQuery prop', () => {
const spy = createSpy();
const props = {
...defaultProps,
campaign: campaignStub,
onStopQuery: spy,
queryIsRunning: true,
};
const Component = mount(<QueryPageSelectTargets {...props} />);
const StopQueryButton = Component.find('.query-page-select-targets__stop-query-btn');
StopQueryButton.simulate('click');
expect(spy).toHaveBeenCalled();
});
});
});

Просмотреть файл

@ -0,0 +1,18 @@
.query-page-select-targets {
&__wrapper {
padding: $base;
}
&__progress-details {
display: inline-block;
width: 378px;
}
&__query-btn-wrapper {
float: right;
button {
margin-left: 10px;
}
}
}

Просмотреть файл

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

Просмотреть файл

@ -1,13 +1,12 @@
import React, { Component, PropTypes } from 'react';
import classnames from 'classnames';
import { get, keys, omit } from 'lodash';
import { keys, omit } from 'lodash';
import Button from 'components/buttons/Button';
import campaignInterface from 'interfaces/campaign';
import filterArrayByHash from 'utilities/filter_array_by_hash';
import Icon from 'components/icons/Icon';
import InputField from 'components/forms/fields/InputField';
import ProgressBar from 'components/loaders/ProgressBar';
import QueryResultsRow from 'components/queries/QueryResultsTable/QueryResultsRow';
const baseClass = 'query-results-table';
@ -45,29 +44,6 @@ class QueryResultsTable extends Component {
};
}
renderProgressDetails = () => {
const { campaign } = this.props;
const { hosts_count: hostsCount } = campaign;
const totalHostsCount = get(campaign, 'totals.count', 0);
const totalRowsCount = get(campaign, 'query_results.length', 0);
return (
<div className={`${baseClass}__progress-details`}>
<span>
<b>{hostsCount.total}</b>&nbsp;of&nbsp;
<b>{totalHostsCount} Hosts</b>&nbsp;Returning&nbsp;
<b>{totalRowsCount} Records&nbsp;</b>
({hostsCount.failed} failed)
</span>
<ProgressBar
error={hostsCount.failed}
max={totalHostsCount}
success={hostsCount.successful}
/>
</div>
);
}
renderTableHeaderRowData = (column, index) => {
const filterable = column === 'hostname' ? 'host_hostname' : column;
const { activeColumn, resultsFilter } = this.state;
@ -127,7 +103,6 @@ class QueryResultsTable extends Component {
render () {
const { campaign, onExportQueryResults } = this.props;
const {
renderProgressDetails,
renderTableHeaderRow,
renderTableRows,
} = this;
@ -139,8 +114,8 @@ class QueryResultsTable extends Component {
if (!hostsCount.successful) {
return (
<div className={baseClass}>
{renderProgressDetails()}
<div className={`${baseClass} ${baseClass}__no-results`}>
<em>No results found</em>
</div>
);
}
@ -150,11 +125,10 @@ class QueryResultsTable extends Component {
<Button
className={`${baseClass}__export-btn`}
onClick={onExportQueryResults}
variant="brand"
variant="link"
>
Export
</Button>
{renderProgressDetails()}
<div className={`${baseClass}__table-wrapper`}>
<table className={`${baseClass}__table`}>
<thead>

Просмотреть файл

@ -78,12 +78,6 @@ describe('QueryResultsTable - component', () => {
expect(componentWithoutQueryResults.html()).toNotExist();
});
it('renders a ProgressBar component', () => {
expect(
componentWithQueryResults.find('ProgressBar').length
).toEqual(1);
});
it('sets the column headers to the keys of the query results', () => {
const queryResultKeys = keys(queryResult.rows[0]);
const tableHeaderText = componentWithQueryResults.find('thead').text();

Просмотреть файл

@ -1,7 +1,7 @@
.query-results-table {
background-color: $white;
padding: $pad-base;
max-width: 100%;
width: 100%;
box-sizing: border-box;
&__export-btn {
@ -14,6 +14,12 @@
}
}
&__no-results {
@include display(flex);
@include align-items(center);
@include justify-content(center);
}
&__progress-details {
display: inline-block;
width: 378px;
@ -24,8 +30,9 @@
border-radius: 3px;
box-shadow: inset 0 0 8px 0 rgba(0, 0, 0, 0.12);
overflow: scroll;
margin-top: 20px;
margin-top: 58px;
max-height: 550px;
width: 100%;
}
&__table {

Просмотреть файл

@ -18,6 +18,7 @@ import QueryForm from 'components/forms/queries/QueryForm';
import osqueryTableInterface from 'interfaces/osquery_table';
import queryActions from 'redux/nodes/entities/queries/actions';
import queryInterface from 'interfaces/query';
import QueryPageSelectTargets from 'components/queries/QueryPageSelectTargets';
import QueryResultsTable from 'components/queries/QueryResultsTable';
import QuerySidePanel from 'components/side_panels/QuerySidePanel';
import { renderFlash } from 'redux/nodes/notifications/actions';
@ -27,6 +28,11 @@ import validateQuery from 'components/forms/validators/validate_query';
import Spinner from 'components/loaders/Spinner';
const baseClass = 'query-page';
const DEFAULT_CAMPAIGN = {
hosts_count: {
total: 0,
},
};
export class QueryPage extends Component {
static propTypes = {
@ -52,10 +58,9 @@ export class QueryPage extends Component {
super(props);
this.state = {
campaign: {
hosts_count: { total: 0 },
},
campaign: DEFAULT_CAMPAIGN,
queryIsRunning: false,
queryText: props.query.query,
targetsCount: 0,
targetsError: null,
};
@ -95,6 +100,10 @@ export class QueryPage extends Component {
this.csvQueryName = value;
}
if (fieldName === 'query') {
this.setState({ queryText: value });
}
return false;
}
@ -271,7 +280,7 @@ export class QueryPage extends Component {
if (this.campaign || campaign) {
this.campaign = null;
this.setState({ campaign: {} });
this.setState({ campaign: DEFAULT_CAMPAIGN });
}
return false;
@ -307,6 +316,10 @@ export class QueryPage extends Component {
});
let resultBody = '';
if (!loading && isEqual(campaign, DEFAULT_CAMPAIGN)) {
return false;
}
if (loading) {
resultBody = <Spinner />;
} else {
@ -320,26 +333,45 @@ export class QueryPage extends Component {
);
}
renderTargetsInput = () => {
const { onFetchTargets, onRunQuery, onStopQuery, onTargetSelect } = this;
const { campaign, queryIsRunning, queryText, targetsCount, targetsError } = this.state;
const { selectedTargets } = this.props;
return (
<QueryPageSelectTargets
campaign={campaign}
error={targetsError}
onFetchTargets={onFetchTargets}
onRunQuery={onRunQuery}
onStopQuery={onStopQuery}
onTargetSelect={onTargetSelect}
query={queryText}
queryIsRunning={queryIsRunning}
selectedTargets={selectedTargets}
targetsCount={targetsCount}
/>
);
}
render () {
const {
onChangeQueryFormField,
onFetchTargets,
onOsqueryTableSelect,
onRunQuery,
onSaveQueryFormSubmit,
onStopQuery,
onTargetSelect,
onTextEditorInputChange,
onUpdateQuery,
renderResultsTable,
renderTargetsInput,
} = this;
const { queryIsRunning, targetsCount, targetsError } = this.state;
const { queryIsRunning } = this.state;
const {
errors,
loadingQueries,
query,
selectedOsqueryTable,
selectedTargets,
} = this.props;
if (loadingQueries) {
@ -354,20 +386,16 @@ export class QueryPage extends Component {
formData={query}
handleSubmit={onSaveQueryFormSubmit}
onChangeFunc={onChangeQueryFormField}
onFetchTargets={onFetchTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
onRunQuery={onRunQuery}
onStopQuery={onStopQuery}
onTargetSelect={onTargetSelect}
onUpdate={onUpdateQuery}
queryIsRunning={queryIsRunning}
selectedTargets={selectedTargets}
serverErrors={errors}
targetsCount={targetsCount}
targetsError={targetsError}
selectedOsqueryTable={selectedOsqueryTable}
/>
</div>
{renderTargetsInput()}
{renderResultsTable()}
</div>
<QuerySidePanel

Просмотреть файл

@ -11,7 +11,7 @@ import helpers from 'test/helpers';
import hostActions from 'redux/nodes/entities/hosts/actions';
import queryActions from 'redux/nodes/entities/queries/actions';
import ConnectedQueryPage, { QueryPage } from 'pages/queries/QueryPage/QueryPage';
import { hostStub } from 'test/stubs';
import { hostStub, queryStub } from 'test/stubs';
const { connectedComponent, createAceSpy, fillInFormInput, reduxMockStore } = helpers;
const { defaultSelectedOsqueryTable } = queryPageActions;
@ -113,14 +113,14 @@ describe('QueryPage - component', () => {
it('sets targetError in state when the query is run and there are no selected targets', () => {
const page = mount(connectedComponent(ConnectedQueryPage, { mockStore, props: locationProp }));
const form = page.find('QueryForm');
const runQueryBtn = form.find('.query-form__run-query-btn');
const QueryPageSelectTargets = page.find('QueryPageSelectTargets');
const runQueryBtn = page.find('.query-page-select-targets__run-query-btn');
expect(form.prop('targetsError')).toNotExist();
expect(QueryPageSelectTargets.prop('error')).toNotExist();
runQueryBtn.simulate('click');
expect(form.prop('targetsError')).toEqual('You must select at least one target to run a query');
expect(QueryPageSelectTargets.prop('error')).toEqual('You must select at least one target to run a query');
});
it('calls the onUpdateQuery prop when the query is updated', () => {
@ -165,11 +165,12 @@ describe('QueryPage - component', () => {
describe('#componentWillReceiveProps', () => {
it('resets selected targets and removed the campaign when the hostname changes', () => {
const queryResult = { org_name: 'Kolide', org_url: 'https://kolide.co' };
const campaign = { id: 1, query_results: [queryResult] };
const campaign = { id: 1, query_results: [queryResult], hosts_count: { total: 1 } };
const props = {
dispatch: noop,
loadingQueries: false,
location: { pathname: '/queries/11' },
query: { query: 'select * from users' },
selectedOsqueryTable: defaultSelectedOsqueryTable,
selectedTargets: [hostStub],
};
@ -203,7 +204,7 @@ describe('QueryPage - component', () => {
};
const queryResultsCSV = convertToCSV([queryResult]);
const fileSaveSpy = spyOn(FileSave, 'saveAs');
const Page = mount(<QueryPage dispatch={noop} selectedOsqueryTable={defaultSelectedOsqueryTable} />);
const Page = mount(<QueryPage dispatch={noop} query={queryStub} selectedOsqueryTable={defaultSelectedOsqueryTable} />);
const filename = 'query_results.csv';
const fileStub = new global.window.File([queryResultsCSV], filename, { type: 'text/csv' });

Просмотреть файл

@ -9,6 +9,7 @@
&__results {
@include display(flex);
@include flex-grow(1);
min-height: 400px;
&--loading {
@include align-items(center);

Просмотреть файл

@ -204,8 +204,36 @@ export const userStub = {
username: 'gnardog',
};
const queryResultStub = {
description: 'root',
directory: '/root',
gid: '0',
gid_signed: '0',
groupname: 'root',
host_hostname: hostStub.hostname,
};
export const campaignStub = {
hosts: [hostStub, { ...hostStub, id: 100 }],
hosts_count: {
failed: 0,
successful: 2,
total: 2,
},
id: 1,
query_id: queryStub.id,
query_results: [queryResultStub],
totals: {
count: 2,
missing_in_action: 0,
offline: 0,
online: 2,
},
};
export default {
adminUserStub,
campaignStub,
configStub,
flatConfigStub,
hostStub,