Query Manage Page: Role based views (#843)

* Removes create new query button from only observers
* Renders CTA ManageQueriesPage side panel
* Renders Observer can run column for non observers
* Fixes integration tests: ManageQueriesPage and SidePanel

UI co-authored by: @gillespi314
Integration tests co-authored by: @ghernandez345
This commit is contained in:
RachelElysia 2021-05-25 16:03:32 -04:00 committed by GitHub
parent 0395020b6f
commit a6e921c7b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 109 additions and 25 deletions

View File

@ -15,11 +15,11 @@ class KolideAce extends Component {
static propTypes = { static propTypes = {
error: PropTypes.string, error: PropTypes.string,
fontSize: PropTypes.number, fontSize: PropTypes.number,
handleSubmit: PropTypes.func.isRequired, handleSubmit: PropTypes.func,
label: PropTypes.string, label: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func,
onLoad: PropTypes.func.isRequired, onLoad: PropTypes.func,
value: PropTypes.string, value: PropTypes.string,
readOnly: PropTypes.bool, readOnly: PropTypes.bool,
showGutter: PropTypes.bool, showGutter: PropTypes.bool,

View File

@ -19,6 +19,7 @@ class QueriesList extends Component {
onDblClickQuery: PropTypes.func, onDblClickQuery: PropTypes.func,
queries: PropTypes.arrayOf(queryInterface).isRequired, queries: PropTypes.arrayOf(queryInterface).isRequired,
selectedQuery: queryInterface, selectedQuery: queryInterface,
isOnlyObserver: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -96,6 +97,7 @@ class QueriesList extends Component {
onDblClickQuery, onDblClickQuery,
queries, queries,
selectedQuery, selectedQuery,
isOnlyObserver,
} = this.props; } = this.props;
const { allQueriesChecked } = this.state; const { allQueriesChecked } = this.state;
const { renderHelpText, handleCheckAll, handleCheckQuery } = this; const { renderHelpText, handleCheckAll, handleCheckQuery } = this;
@ -103,7 +105,6 @@ class QueriesList extends Component {
const wrapperClassName = classnames(`${baseClass}__table`, { const wrapperClassName = classnames(`${baseClass}__table`, {
[`${baseClass}__table--query-selected`]: size(checkedQueryIDs), [`${baseClass}__table--query-selected`]: size(checkedQueryIDs),
}); });
return ( return (
<div className={baseClass}> <div className={baseClass}>
<table className={wrapperClassName}> <table className={wrapperClassName}>
@ -118,6 +119,11 @@ class QueriesList extends Component {
</th> </th>
<th>Query name</th> <th>Query name</th>
<th>Description</th> <th>Description</th>
{isOnlyObserver ? null : (
<th className={`${baseClass}__observers-can-run`}>
Observers can run
</th>
)}
<th className={`${baseClass}__author-name`}>Author</th> <th className={`${baseClass}__author-name`}>Author</th>
<th>Last modified</th> <th>Last modified</th>
</tr> </tr>
@ -134,6 +140,7 @@ class QueriesList extends Component {
onSelect={onSelectQuery} onSelect={onSelectQuery}
onDoubleClick={onDblClickQuery} onDoubleClick={onDblClickQuery}
query={query} query={query}
isOnlyObserver={isOnlyObserver}
selected={ selected={
allQueriesChecked || selectedQuery.id === query.id allQueriesChecked || selectedQuery.id === query.id
} }

View File

@ -18,6 +18,7 @@ class QueriesListRow extends Component {
onDoubleClick: PropTypes.func, onDoubleClick: PropTypes.func,
query: queryInterface.isRequired, query: queryInterface.isRequired,
selected: PropTypes.bool, selected: PropTypes.bool,
isOnlyObserver: PropTypes.bool,
}; };
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
@ -47,7 +48,7 @@ class QueriesListRow extends Component {
}; };
render() { render() {
const { checked, query, selected } = this.props; const { checked, query, selected, isOnlyObserver } = this.props;
const { onCheck, onSelect, onDblClick } = this; const { onCheck, onSelect, onDblClick } = this;
const { const {
author_name: authorName, author_name: authorName,
@ -55,6 +56,7 @@ class QueriesListRow extends Component {
name, name,
updated_at: updatedAt, updated_at: updatedAt,
description, description,
observer_can_run,
} = query; } = query;
const lastModifiedDate = moment(updatedAt).format("MM/DD/YY"); const lastModifiedDate = moment(updatedAt).format("MM/DD/YY");
const rowClassName = classnames(baseClass, { const rowClassName = classnames(baseClass, {
@ -76,6 +78,11 @@ class QueriesListRow extends Component {
</td> </td>
<td className={`${baseClass}__name`}>{name}</td> <td className={`${baseClass}__name`}>{name}</td>
<td className={`${baseClass}__description`}>{description}</td> <td className={`${baseClass}__description`}>{description}</td>
{isOnlyObserver ? null : (
<td className={`${baseClass}__observers-can-run`}>
{observer_can_run.toString()}
</td>
)}
<td className={`${baseClass}__author-name`}>{authorName}</td> <td className={`${baseClass}__author-name`}>{authorName}</td>
<td>{lastModifiedDate}</td> <td>{lastModifiedDate}</td>
</ClickableTableRow> </ClickableTableRow>

View File

@ -6,6 +6,10 @@
max-width: 280px; max-width: 280px;
} }
&__observers-can-run {
max-width: 180px;
}
&:last-child { &:last-child {
border-bottom: 0; border-bottom: 0;
} }

View File

@ -2,9 +2,11 @@ import React, { Component } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Link } from "react-router"; import { Link } from "react-router";
import permissionUtils from "utilities/permissions";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import KolideAce from "components/KolideAce"; import KolideAce from "components/KolideAce";
import queryInterface from "interfaces/query"; import queryInterface from "interfaces/query";
import userInterface from "interfaces/user";
import SecondarySidePanelContainer from "components/side_panels/SecondarySidePanelContainer"; import SecondarySidePanelContainer from "components/side_panels/SecondarySidePanelContainer";
const baseClass = "query-details-side-panel"; const baseClass = "query-details-side-panel";
@ -13,6 +15,7 @@ class QueryDetailsSidePanel extends Component {
static propTypes = { static propTypes = {
onEditQuery: PropTypes.func.isRequired, onEditQuery: PropTypes.func.isRequired,
query: queryInterface.isRequired, query: queryInterface.isRequired,
currentUser: userInterface,
}; };
handleEditQueryClick = (evt) => { handleEditQueryClick = (evt) => {
@ -57,9 +60,27 @@ class QueryDetailsSidePanel extends Component {
}; };
render() { render() {
const { query } = this.props; const { query, currentUser } = this.props;
const { handleEditQueryClick, renderPacks } = this; const { handleEditQueryClick, renderPacks } = this;
const { description, name, query: queryText } = query; const { description, name, query: queryText, observer_can_run } = query;
const renderCTA = () => {
if (
permissionUtils.isGlobalAdmin(currentUser) ||
permissionUtils.isGlobalMaintainer(currentUser)
) {
return "Edit or run query";
}
if (
permissionUtils.isAnyTeamMaintainer(currentUser) ||
(permissionUtils.isOnlyObserver(currentUser) && observer_can_run)
) {
return "Run query";
}
if (permissionUtils.isOnlyObserver(currentUser) && !observer_can_run) {
return "Show query";
}
};
return ( return (
<SecondarySidePanelContainer className={baseClass}> <SecondarySidePanelContainer className={baseClass}>
@ -81,8 +102,8 @@ class QueryDetailsSidePanel extends Component {
</p> </p>
<p className={`${baseClass}__label`}>Packs</p> <p className={`${baseClass}__label`}>Packs</p>
{renderPacks()} {renderPacks()}
<Button onClick={handleEditQueryClick} variant="inverse"> <Button onClick={handleEditQueryClick} variant="brand">
Edit or run query {renderCTA(currentUser)}
</Button> </Button>
</SecondarySidePanelContainer> </SecondarySidePanelContainer>
); );

View File

@ -3,12 +3,16 @@ import { mount } from "enzyme";
import { noop } from "lodash"; import { noop } from "lodash";
import QueryDetailsSidePanel from "components/side_panels/QueryDetailsSidePanel"; import QueryDetailsSidePanel from "components/side_panels/QueryDetailsSidePanel";
import { queryStub } from "test/stubs"; import { queryStub, userStub } from "test/stubs";
describe("QueryDetailsSidePanel - component", () => { describe("QueryDetailsSidePanel - component", () => {
it("renders", () => { it("renders", () => {
const component = mount( const component = mount(
<QueryDetailsSidePanel onEditQuery={noop} query={queryStub} /> <QueryDetailsSidePanel
onEditQuery={noop}
query={queryStub}
currentUser={userStub}
/>
); );
expect(component.length).toEqual(1); expect(component.length).toEqual(1);
@ -16,7 +20,11 @@ describe("QueryDetailsSidePanel - component", () => {
it("renders a read-only Kolide Ace component with the query text", () => { it("renders a read-only Kolide Ace component with the query text", () => {
const component = mount( const component = mount(
<QueryDetailsSidePanel onEditQuery={noop} query={queryStub} /> <QueryDetailsSidePanel
onEditQuery={noop}
query={queryStub}
currentUser={userStub}
/>
); );
const aceEditor = component.find("KolideAce"); const aceEditor = component.find("KolideAce");
@ -28,7 +36,11 @@ describe("QueryDetailsSidePanel - component", () => {
it("calls the onEditQuery prop when Edit/Run Query is clicked", () => { it("calls the onEditQuery prop when Edit/Run Query is clicked", () => {
const spy = jest.fn(); const spy = jest.fn();
const component = mount( const component = mount(
<QueryDetailsSidePanel onEditQuery={spy} query={queryStub} /> <QueryDetailsSidePanel
onEditQuery={spy}
query={queryStub}
currentUser={userStub}
/>
); );
const button = component.find("Button"); const button = component.find("Button");

View File

@ -7,6 +7,7 @@ export default PropTypes.shape({
id: PropTypes.number, id: PropTypes.number,
interval: PropTypes.number, interval: PropTypes.number,
last_excuted: PropTypes.string, last_excuted: PropTypes.string,
observer_can_run: PropTypes.bool,
}); });
export interface IQuery { export interface IQuery {
@ -16,4 +17,5 @@ export interface IQuery {
id: number; id: number;
interval: number; interval: number;
last_excuted: string; last_excuted: string;
observer_can_run: boolean;
} }

View File

@ -5,6 +5,7 @@ import { filter, get, includes, pull } from "lodash";
import { push } from "react-router-redux"; import { push } from "react-router-redux";
import Button from "components/buttons/Button"; import Button from "components/buttons/Button";
import permissionUtils from "utilities/permissions";
import entityGetter from "redux/utilities/entityGetter"; import entityGetter from "redux/utilities/entityGetter";
import InputField from "components/forms/fields/InputField"; import InputField from "components/forms/fields/InputField";
import Modal from "components/modals/Modal"; import Modal from "components/modals/Modal";
@ -15,6 +16,7 @@ import QueryDetailsSidePanel from "components/side_panels/QueryDetailsSidePanel"
import QueriesList from "components/queries/QueriesList"; import QueriesList from "components/queries/QueriesList";
import queryActions from "redux/nodes/entities/queries/actions"; import queryActions from "redux/nodes/entities/queries/actions";
import queryInterface from "interfaces/query"; import queryInterface from "interfaces/query";
import userInterface from "interfaces/user";
import { renderFlash } from "redux/nodes/notifications/actions"; import { renderFlash } from "redux/nodes/notifications/actions";
const baseClass = "manage-queries-page"; const baseClass = "manage-queries-page";
@ -25,6 +27,7 @@ export class ManageQueriesPage extends Component {
loadingQueries: PropTypes.bool.isRequired, loadingQueries: PropTypes.bool.isRequired,
queries: PropTypes.arrayOf(queryInterface), queries: PropTypes.arrayOf(queryInterface),
selectedQuery: queryInterface, selectedQuery: queryInterface,
currentUser: userInterface,
}; };
static defaultProps = { static defaultProps = {
@ -183,6 +186,8 @@ export class ManageQueriesPage extends Component {
renderCTAs = () => { renderCTAs = () => {
const { goToNewQueryPage, onToggleModal } = this; const { goToNewQueryPage, onToggleModal } = this;
const { currentUser } = this.props;
const btnClass = `${baseClass}__delete-queries-btn`; const btnClass = `${baseClass}__delete-queries-btn`;
const checkedQueryCount = this.state.checkedQueryIDs.length; const checkedQueryCount = this.state.checkedQueryIDs.length;
@ -201,11 +206,14 @@ export class ManageQueriesPage extends Component {
); );
} }
return ( // Render option to create new query only for maintainers and admin
<Button variant="brand" onClick={goToNewQueryPage}> if (!permissionUtils.isOnlyObserver(currentUser)) {
Create new query return (
</Button> <Button variant="brand" onClick={goToNewQueryPage}>
); Create new query
</Button>
);
}
}; };
renderModal = () => { renderModal = () => {
@ -233,7 +241,7 @@ export class ManageQueriesPage extends Component {
renderSidePanel = () => { renderSidePanel = () => {
const { goToEditQueryPage } = this; const { goToEditQueryPage } = this;
const { selectedQuery } = this.props; const { selectedQuery, currentUser } = this.props;
if (!selectedQuery) { if (!selectedQuery) {
// FIXME: Render QueryDetailsSidePanel when Fritz has completed the mock // FIXME: Render QueryDetailsSidePanel when Fritz has completed the mock
@ -251,6 +259,7 @@ export class ManageQueriesPage extends Component {
<QueryDetailsSidePanel <QueryDetailsSidePanel
onEditQuery={goToEditQueryPage} onEditQuery={goToEditQueryPage}
query={selectedQuery} query={selectedQuery}
currentUser={currentUser}
/> />
); );
}; };
@ -268,12 +277,18 @@ export class ManageQueriesPage extends Component {
renderModal, renderModal,
renderSidePanel, renderSidePanel,
} = this; } = this;
const { loadingQueries, queries: allQueries, selectedQuery } = this.props; const {
loadingQueries,
queries: allQueries,
selectedQuery,
currentUser,
} = this.props;
const queries = getQueries(); const queries = getQueries();
const queriesCount = queries.length; const queriesCount = queries.length;
const queriesTotalDisplay = const queriesTotalDisplay =
queriesCount === 1 ? "1 query" : `${queriesCount} queries`; queriesCount === 1 ? "1 query" : `${queriesCount} queries`;
const isQueriesAvailable = allQueries.length > 0; const isQueriesAvailable = allQueries.length > 0;
const isOnlyObserver = permissionUtils.isOnlyObserver(currentUser);
if (loadingQueries) { if (loadingQueries) {
return false; return false;
@ -307,6 +322,7 @@ export class ManageQueriesPage extends Component {
onDblClickQuery={onDblClickQuery} onDblClickQuery={onDblClickQuery}
queries={queries} queries={queries}
selectedQuery={selectedQuery} selectedQuery={selectedQuery}
isOnlyObserver={isOnlyObserver}
/> />
</div> </div>
{renderSidePanel()} {renderSidePanel()}
@ -323,8 +339,9 @@ const mapStateToProps = (state, { location }) => {
const selectedQuery = const selectedQuery =
selectedQueryID && queryEntities.findBy({ id: selectedQueryID }); selectedQueryID && queryEntities.findBy({ id: selectedQueryID });
const { loading: loadingQueries } = state.entities.queries; const { loading: loadingQueries } = state.entities.queries;
const currentUser = state.auth.user;
return { loadingQueries, queries, selectedQuery }; return { loadingQueries, queries, selectedQuery, currentUser };
}; };
export default connect(mapStateToProps)(ManageQueriesPage); export default connect(mapStateToProps)(ManageQueriesPage);

View File

@ -11,7 +11,7 @@ import {
reduxMockStore, reduxMockStore,
} from "test/helpers"; } from "test/helpers";
import queryActions from "redux/nodes/entities/queries/actions"; import queryActions from "redux/nodes/entities/queries/actions";
import { queryStub } from "test/stubs"; import { queryStub, userStub } from "test/stubs";
const store = { const store = {
entities: { entities: {
@ -27,6 +27,9 @@ const store = {
}, },
}, },
}, },
auth: {
user: userStub,
},
}; };
describe("ManageQueriesPage - component", () => { describe("ManageQueriesPage - component", () => {
@ -40,6 +43,7 @@ describe("ManageQueriesPage - component", () => {
it("does not render if queries are loading", () => { it("does not render if queries are loading", () => {
const loadingQueriesStore = { const loadingQueriesStore = {
entities: { queries: { loading: true, data: {} } }, entities: { queries: { loading: true, data: {} } },
auth: { user: userStub },
}; };
const Component = connectedComponent(ConnectedManageQueriesPage, { const Component = connectedComponent(ConnectedManageQueriesPage, {
mockStore: reduxMockStore(loadingQueriesStore), mockStore: reduxMockStore(loadingQueriesStore),
@ -50,6 +54,7 @@ describe("ManageQueriesPage - component", () => {
}); });
it("renders a QueriesList component", () => { it("renders a QueriesList component", () => {
console.log(store);
const Component = connectedComponent(ConnectedManageQueriesPage, { const Component = connectedComponent(ConnectedManageQueriesPage, {
mockStore: reduxMockStore(store), mockStore: reduxMockStore(store),
}); });
@ -76,6 +81,7 @@ describe("ManageQueriesPage - component", () => {
dispatch: () => Promise.resolve(), dispatch: () => Promise.resolve(),
loadingQueries: false, loadingQueries: false,
queries: [queryStub], queries: [queryStub],
currentUser: userStub,
}; };
const page = mount(<ManageQueriesPage {...props} />); const page = mount(<ManageQueriesPage {...props} />);
@ -97,6 +103,7 @@ describe("ManageQueriesPage - component", () => {
dispatch: () => Promise.reject(), dispatch: () => Promise.reject(),
loadingQueries: false, loadingQueries: false,
queries: [queryStub], queries: [queryStub],
currentUser: userStub,
}; };
const page = mount(<ManageQueriesPage {...props} />); const page = mount(<ManageQueriesPage {...props} />);
@ -128,7 +135,9 @@ describe("ManageQueriesPage - component", () => {
}); });
it("updates checkedQueryIDs in state when the check all queries Checkbox is toggled", () => { it("updates checkedQueryIDs in state when the check all queries Checkbox is toggled", () => {
const page = mount(<ManageQueriesPage queries={[queryStub]} />); const page = mount(
<ManageQueriesPage queries={[queryStub]} currentUser={userStub} />
);
const selectAllQueries = page.find({ name: "check-all-queries" }); const selectAllQueries = page.find({ name: "check-all-queries" });
expect(page.state("checkedQueryIDs")).toEqual([]); expect(page.state("checkedQueryIDs")).toEqual([]);
@ -143,7 +152,9 @@ describe("ManageQueriesPage - component", () => {
}); });
it("updates checkedQueryIDs in state when a query row Checkbox is toggled", () => { it("updates checkedQueryIDs in state when a query row Checkbox is toggled", () => {
const page = mount(<ManageQueriesPage queries={[queryStub]} />); const page = mount(
<ManageQueriesPage queries={[queryStub]} currentUser={userStub} />
);
const queryCheckbox = page.find({ name: `query-checkbox-${queryStub.id}` }); const queryCheckbox = page.find({ name: `query-checkbox-${queryStub.id}` });
expect(page.state("checkedQueryIDs")).toEqual([]); expect(page.state("checkedQueryIDs")).toEqual([]);
@ -207,7 +218,9 @@ describe("ManageQueriesPage - component", () => {
const queries = [queryStub, { ...queryStub, id: 101, name: "alpha query" }]; const queries = [queryStub, { ...queryStub, id: 101, name: "alpha query" }];
it("displays the delete action button when a query is checked", () => { it("displays the delete action button when a query is checked", () => {
const page = mount(<ManageQueriesPage queries={queries} />); const page = mount(
<ManageQueriesPage queries={queries} currentUser={userStub} />
);
const checkAllQueries = page.find({ name: "check-all-queries" }); const checkAllQueries = page.find({ name: "check-all-queries" });
checkAllQueries.hostNodes().simulate("change"); checkAllQueries.hostNodes().simulate("change");

View File

@ -137,6 +137,7 @@ export const queryStub = {
snapshot: false, snapshot: false,
updated_at: "2016-10-17T07:06:00Z", updated_at: "2016-10-17T07:06:00Z",
version: "", version: "",
observer_can_run: true,
}; };
export const scheduledQueryStub = { export const scheduledQueryStub = {