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 = {
error: PropTypes.string,
fontSize: PropTypes.number,
handleSubmit: PropTypes.func.isRequired,
handleSubmit: PropTypes.func,
label: PropTypes.string,
name: PropTypes.string,
onChange: PropTypes.func.isRequired,
onLoad: PropTypes.func.isRequired,
onChange: PropTypes.func,
onLoad: PropTypes.func,
value: PropTypes.string,
readOnly: PropTypes.bool,
showGutter: PropTypes.bool,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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