Schedules page: Action cell (Update and delete a global scheduled query) (#1525)

* Create and edit modal component
* Update e2e test for update and delete global scheduled query
This commit is contained in:
RachelElysia 2021-08-03 14:06:09 -04:00 committed by GitHub
parent dea00479d7
commit c934f3e172
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 307 additions and 121 deletions

View File

@ -0,0 +1 @@
* Users can update and remove a global scheduled query with an Action dropdown

View File

@ -4,7 +4,7 @@ describe("Query flow", () => {
cy.login();
});
it("Create, check, edit, and delete a query successfully", () => {
it("Create, check, edit, and delete a query successfully and create, edit, and delete a global scheduled query successfully", () => {
cy.visit("/queries/manage");
cy.findByRole("button", { name: /create new query/i }).click();
@ -83,16 +83,38 @@ describe("Query flow", () => {
cy.findByText(/query all window crashes/i).should("exist");
// Checkbox won't check so can't test remove schedule
// cy.get("tbody").get(".table-checkbox__input").click();
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/actions/i).click();
cy.findByText(/edit/i).click();
// cy.findByRole("button", { name: /remove query/i }).click();
cy.get(
".schedule-editor-modal__form-field--frequency > .dropdown__select"
).click();
// cy.get(".remove-scheduled-query-modal__btn-wrap")
// .contains("button", /remove/i)
// .click();
cy.findByText(/every 6 hours/i).click();
// cy.findByText(/query all window crashes/i).should("not.exist");
cy.findByText(/show advanced options/i).click();
cy.findByText(/ignore removals/i).click();
cy.findByText(/snapshot/i).click();
cy.get(".schedule-editor-modal__form-field--shard > .input-field")
.click()
.type("{selectall}{backspace}10");
cy.get(".schedule-editor-modal__btn-wrap")
.contains("button", /schedule/i)
.click();
cy.wait(1000); // eslint-disable-line cypress/no-unnecessary-waiting
cy.findByText(/actions/i).click();
cy.findByText(/remove/i).click();
cy.get(".remove-scheduled-query-modal__btn-wrap")
.contains("button", /remove/i)
.click();
cy.findByText(/query all window crashes/i).should("not.exist");
// End Test Schedules
cy.visit("/queries/manage");

View File

@ -48,11 +48,37 @@
&:last-child {
border-right: none;
width: 180px;
max-width: 140px;
}
}
}
.Select.is-focused:not(.is-open) > .Select-control {
border: none;
box-shadow: none;
}
.Select.is-open {
.Select-arrow {
margin-top: 6px;
margin-bottom: -2px;
}
}
.Select-arrow {
margin-top: 4px;
}
.Select-control:hover {
box-shadow: none;
}
.Select-placeholder {
font-size: 14px;
margin-top: 2px;
padding-left: 0;
}
.active-selection {
position: absolute;
top: 0px;

View File

@ -19,6 +19,7 @@
&__ex {
text-decoration: none;
padding-left: $pad-xsmall;
.button::after {
content: url("../assets/images/icon-close-fleet-blue-16x16@2x.png");
@ -37,10 +38,16 @@
font-size: $large;
font-weight: $regular;
text-align: left;
padding-bottom: 15px;
padding-bottom: $pad-xsmall;
border-bottom: 1px solid $ui-fleet-blue-15;
display: flex;
justify-content: space-between;
span {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&__modal_container {

View File

@ -7,8 +7,10 @@ export default PropTypes.shape({
shard: PropTypes.number,
query: PropTypes.string.isRequired,
query_id: PropTypes.number.isRequired,
removed: PropTypes.bool,
snapshot: PropTypes.bool,
removed: PropTypes.bool.isRequired,
snapshot: PropTypes.bool.isRequired,
version: PropTypes.string,
platform: PropTypes.string,
});
export interface IGlobalScheduledQuery {
@ -20,4 +22,6 @@ export interface IGlobalScheduledQuery {
query_id: number;
removed: boolean;
snapshot: boolean;
version?: string;
platform?: string;
}

View File

@ -5,30 +5,4 @@
@include sticky-settings-description;
padding-bottom: $pad-medium;
}
.Select.is-focused:not(.is-open) > .Select-control {
border: none;
box-shadow: none;
}
.Select.is-open {
.Select-arrow {
margin-top: 6px;
margin-bottom: -2px;
}
}
.Select-arrow {
margin-top: $pad-xsmall;
}
.Select-control:hover {
box-shadow: none;
}
.Select-placeholder {
font-size: $x-small;
margin-top: $pad-xxsmall;
padding-left: 0;
}
}

View File

@ -45,7 +45,7 @@
border-radius: 6px 0;
&:last-child {
min-width: 100px;
min-width: 80px;
}
}
}
@ -124,30 +124,4 @@
}
}
}
.Select.is-focused:not(.is-open) > .Select-control {
border: none;
box-shadow: none;
}
.Select.is-open {
.Select-arrow {
margin-top: 6px;
margin-bottom: -2px;
}
}
.Select-arrow {
margin-top: 4px;
}
.Select-control:hover {
box-shadow: none;
}
.Select-placeholder {
font-size: 14px;
margin-top: 2px;
padding-left: 0;
}
}

View File

@ -2,6 +2,8 @@ import React, { useState, useCallback, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { push } from "react-router-redux";
// @ts-ignore
import deepDifference from "utilities/deep_difference";
import { IQuery } from "interfaces/query";
import { IGlobalScheduledQuery } from "interfaces/global_scheduled_query";
// @ts-ignore
@ -22,6 +24,7 @@ const baseClass = "manage-schedule-page";
const renderTable = (
onRemoveScheduledQueryClick: React.MouseEventHandler<HTMLButtonElement>,
onEditScheduledQueryClick: React.MouseEventHandler<HTMLButtonElement>,
allGlobalScheduledQueriesList: IGlobalScheduledQuery[],
allGlobalScheduledQueriesError: any,
toggleScheduleEditorModal: () => void
@ -33,6 +36,7 @@ const renderTable = (
return (
<ScheduleListWrapper
onRemoveScheduledQueryClick={onRemoveScheduledQueryClick}
onEditScheduledQueryClick={onEditScheduledQueryClick}
allGlobalScheduledQueriesList={allGlobalScheduledQueriesList}
toggleScheduleEditorModal={toggleScheduleEditorModal}
/>
@ -89,8 +93,13 @@ const ManageSchedulePage = (): JSX.Element => {
setShowRemoveScheduledQueryModal,
] = useState(false);
const [selectedQueryIds, setSelectedQueryIds] = useState([]);
const [
selectedScheduledQuery,
setSelectedScheduledQuery,
] = useState<IGlobalScheduledQuery>();
const toggleScheduleEditorModal = useCallback(() => {
setSelectedScheduledQuery(undefined); // create modal renders
setShowScheduleEditorModal(!showScheduleEditorModal);
}, [showScheduleEditorModal, setShowScheduleEditorModal]);
@ -98,11 +107,16 @@ const ManageSchedulePage = (): JSX.Element => {
setShowRemoveScheduledQueryModal(!showRemoveScheduledQueryModal);
}, [showRemoveScheduledQueryModal, setShowRemoveScheduledQueryModal]);
const onRemoveScheduledQueryClick = (selectedTableQueryIds: any) => {
const onRemoveScheduledQueryClick = (selectedTableQueryIds: any): any => {
toggleRemoveScheduledQueryModal();
setSelectedQueryIds(selectedTableQueryIds);
};
const onEditScheduledQueryClick = (selectedQuery: any): void => {
toggleScheduleEditorModal();
setSelectedScheduledQuery(selectedQuery); // edit modal renders
};
const onRemoveScheduledQuerySubmit = useCallback(() => {
const promises = selectedQueryIds.map((id: number) => {
return dispatch(globalScheduledQueryActions.destroy({ id }));
@ -131,22 +145,50 @@ const ManageSchedulePage = (): JSX.Element => {
}, [dispatch, selectedQueryIds, toggleRemoveScheduledQueryModal]);
const onAddScheduledQuerySubmit = useCallback(
(formData: IFormData) => {
dispatch(globalScheduledQueryActions.create({ ...formData }))
.then(() => {
dispatch(
renderFlash(
"success",
`Successfully added ${formData.name} to the schedule.`
)
);
dispatch(globalScheduledQueryActions.loadAll());
})
.catch(() => {
dispatch(
renderFlash("error", "Could not schedule query. Please try again.")
);
});
(formData: IFormData, editQuery: IGlobalScheduledQuery | undefined) => {
if (editQuery) {
const updatedAttributes = deepDifference(formData, editQuery);
dispatch(
globalScheduledQueryActions.update(editQuery, updatedAttributes)
)
.then(() => {
dispatch(
renderFlash(
"success",
`Successfully updated ${formData.name} in the schedule.`
)
);
dispatch(globalScheduledQueryActions.loadAll());
})
.catch(() => {
dispatch(
renderFlash(
"error",
"Could not update scheduled query. Please try again."
)
);
});
} else {
dispatch(globalScheduledQueryActions.create({ ...formData }))
.then(() => {
dispatch(
renderFlash(
"success",
`Successfully added ${formData.name} to the schedule.`
)
);
dispatch(globalScheduledQueryActions.loadAll());
})
.catch(() => {
dispatch(
renderFlash(
"error",
"Could not schedule query. Please try again."
)
);
});
}
toggleScheduleEditorModal();
},
[dispatch, toggleScheduleEditorModal]
@ -194,6 +236,7 @@ const ManageSchedulePage = (): JSX.Element => {
<div>
{renderTable(
onRemoveScheduledQueryClick,
onEditScheduledQueryClick,
allGlobalScheduledQueriesList,
allGlobalScheduledQueriesError,
toggleScheduleEditorModal
@ -204,6 +247,7 @@ const ManageSchedulePage = (): JSX.Element => {
onCancel={toggleScheduleEditorModal}
onScheduleSubmit={onAddScheduledQuerySubmit}
allQueries={allQueriesList}
editQuery={selectedScheduledQuery}
/>
)}
{showRemoveScheduledQueryModal && (

View File

@ -1,4 +1,6 @@
import React, { useState, useCallback } from "react";
/* This component is used for both creating and editing global scheduled queries */
import React, { useState, useCallback, useEffect } from "react";
import { pull } from "lodash";
// @ts-ignore
@ -35,17 +37,32 @@ interface IFormData {
interface IScheduleEditorModalProps {
allQueries: IQuery[];
onCancel: () => void;
onScheduleSubmit: (formData: IFormData) => void;
onScheduleSubmit: (
formData: IFormData,
editQuery: IGlobalScheduledQuery | undefined
) => void;
editQuery?: IGlobalScheduledQuery;
}
interface INoQueryOption {
id: number;
name: string;
}
const generateLoggingType = (query: IGlobalScheduledQuery) => {
if (query.snapshot) {
return "snapshot";
}
if (query.removed) {
return "differential";
}
return "differential_ignore_removals";
};
const ScheduleEditorModal = ({
onCancel,
onScheduleSubmit,
allQueries,
editQuery,
}: IScheduleEditorModalProps): JSX.Element => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState<boolean>(
false
@ -53,19 +70,23 @@ const ScheduleEditorModal = ({
const [selectedQuery, setSelectedQuery] = useState<
IGlobalScheduledQuery | INoQueryOption
>();
const [selectedFrequency, setSelectedFrequency] = useState<number>(86400);
const [selectedFrequency, setSelectedFrequency] = useState<number>(
editQuery ? editQuery.interval : 86400
);
const [
selectedPlatformOptions,
setSelectedPlatformOptions,
] = useState<string>("");
] = useState<string>(editQuery?.platform || "");
const [selectedLoggingType, setSelectedLoggingType] = useState<string>(
"snapshot"
editQuery ? generateLoggingType(editQuery) : "snapshot"
);
const [
selectedMinOsqueryVersionOptions,
setSelectedMinOsqueryVersionOptions,
] = useState<string>("");
const [selectedShard, setSelectedShard] = useState<string>("");
] = useState<string>(editQuery?.version || "");
const [selectedShard, setSelectedShard] = useState<string>(
editQuery?.shard ? editQuery?.shard.toString() : ""
);
const createQueryDropdownOptions = () => {
const queryOptions = allQueries.map((q) => {
@ -137,28 +158,51 @@ const ScheduleEditorModal = ({
);
const onFormSubmit = () => {
onScheduleSubmit({
shard: parseInt(selectedShard, 10),
interval: selectedFrequency,
query_id: selectedQuery?.id,
name: selectedQuery?.name,
logging_type: selectedLoggingType,
platform: selectedPlatformOptions,
version: selectedMinOsqueryVersionOptions,
});
const query_id = () => {
if (editQuery) {
return editQuery.id;
}
return selectedQuery?.id;
};
const name = () => {
if (editQuery) {
return editQuery.name;
}
return selectedQuery?.name;
};
onScheduleSubmit(
{
shard: parseInt(selectedShard, 10),
interval: selectedFrequency,
query_id: query_id(),
name: name(),
logging_type: selectedLoggingType,
platform: selectedPlatformOptions,
version: selectedMinOsqueryVersionOptions,
},
editQuery
);
};
return (
<Modal title={"Schedule editor"} onExit={onCancel} className={baseClass}>
<Modal
title={editQuery?.name || "Schedule editor"}
onExit={onCancel}
className={baseClass}
>
<form className={`${baseClass}__form`}>
<Dropdown
searchable
options={createQueryDropdownOptions()}
onChange={onChangeSelectQuery}
placeholder={"Select query"}
value={selectedQuery?.id}
wrapperClassName={`${baseClass}__select-query-dropdown-wrapper`}
/>
{!editQuery && (
<Dropdown
searchable
options={createQueryDropdownOptions()}
onChange={onChangeSelectQuery}
placeholder={"Select query"}
value={selectedQuery?.id}
wrapperClassName={`${baseClass}__select-query-dropdown-wrapper`}
/>
)}
<Dropdown
searchable={false}
options={FREQUENCY_DROPDOWN_OPTIONS}
@ -244,7 +288,7 @@ const ScheduleEditorModal = ({
type="button"
variant="brand"
onClick={onFormSubmit}
disabled={!selectedQuery}
disabled={!selectedQuery && !editQuery}
>
Schedule
</Button>

View File

@ -12,7 +12,7 @@ import { IGlobalScheduledQuery } from "interfaces/global_scheduled_query";
import globalScheduledQueryActions from "redux/nodes/entities/global_scheduled_queries/actions";
import TableContainer from "components/TableContainer";
import generateTableHeaders from "./ScheduleTableConfig";
import { generateTableHeaders, generateDataSet } from "./ScheduleTableConfig";
// @ts-ignore
import scheduleSvg from "../../../../../../assets/images/schedule.svg";
@ -21,6 +21,7 @@ const noScheduleClass = "no-schedule";
interface IScheduleListWrapperProps {
onRemoveScheduledQueryClick: any;
onEditScheduledQueryClick: any;
allGlobalScheduledQueriesList: IGlobalScheduledQuery[];
toggleScheduleEditorModal: any;
}
@ -38,6 +39,7 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
onRemoveScheduledQueryClick,
allGlobalScheduledQueriesList,
toggleScheduleEditorModal,
onEditScheduledQueryClick,
} = props;
const dispatch = useDispatch();
const { MANAGE_PACKS } = paths;
@ -77,7 +79,21 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
);
};
const tableHeaders = generateTableHeaders();
const onActionSelection = (
action: string,
global_scheduled_query: IGlobalScheduledQuery
): void => {
switch (action) {
case "edit":
onEditScheduledQueryClick(global_scheduled_query);
break;
default:
onRemoveScheduledQueryClick([global_scheduled_query.id]);
break;
}
};
const tableHeaders = generateTableHeaders(onActionSelection);
const loadingTableData = useSelector(
(state: IRootState) => state.entities.global_scheduled_queries.isLoading
);
@ -102,7 +118,7 @@ const ScheduleListWrapper = (props: IScheduleListWrapperProps): JSX.Element => {
<TableContainer
resultsTitle={"queries"}
columns={tableHeaders}
data={allGlobalScheduledQueriesList}
data={generateDataSet(allGlobalScheduledQueriesList)}
isLoading={loadingTableData}
defaultSortHeader={"query"}
defaultSortDirection={"desc"}

View File

@ -7,6 +7,8 @@ import { secondsToDhms } from "fleet/helpers";
// @ts-ignore
import Checkbox from "components/forms/fields/Checkbox";
import TextCell from "components/TableContainer/DataTable/TextCell";
import DropdownCell from "components/TableContainer/DataTable/DropdownCell";
import { IDropdownOption } from "interfaces/dropdownOption";
import { IGlobalScheduledQuery } from "interfaces/global_scheduled_query";
interface IHeaderProps {
@ -38,10 +40,22 @@ interface IDataColumn {
disableHidden?: boolean;
disableSortBy?: boolean;
}
interface IGlobalScheduledQueryTableData {
name: string;
interval: number;
actions: IDropdownOption[];
id: number;
type: string;
}
// NOTE: cellProps come from react-table
// more info here https://react-table.tanstack.com/docs/api/useTable#cell-properties
const generateTableHeaders = (): IDataColumn[] => {
const generateTableHeaders = (
actionSelectHandler: (
value: string,
global_scheduled_query: IGlobalScheduledQuery
) => void
): IDataColumn[] => {
return [
{
id: "selection",
@ -65,10 +79,10 @@ const generateTableHeaders = (): IDataColumn[] => {
disableHidden: true,
},
{
title: "Query name",
Header: "Query name",
title: "Query",
Header: "Query",
disableSortBy: true,
accessor: "query_name",
accessor: "name",
Cell: (cellProps: ICellProps): JSX.Element => (
<TextCell value={cellProps.cell.value} />
),
@ -82,7 +96,64 @@ const generateTableHeaders = (): IDataColumn[] => {
<TextCell value={secondsToDhms(cellProps.cell.value)} />
),
},
{
title: "Actions",
Header: "",
disableSortBy: true,
accessor: "actions",
Cell: (cellProps) => (
<DropdownCell
options={cellProps.cell.value}
onChange={(value: string) =>
actionSelectHandler(value, cellProps.row.original)
}
placeholder={"Actions"}
/>
),
},
];
};
export default generateTableHeaders;
const generateActionDropdownOptions = (): IDropdownOption[] => {
const dropdownOptions = [
{
label: "Edit",
disabled: false,
value: "edit",
},
{
label: "Remove",
disabled: false,
value: "remove",
},
];
return dropdownOptions;
};
const enhanceGlobalScheduledQueryData = (
global_scheduled_queries: IGlobalScheduledQuery[]
): IGlobalScheduledQueryTableData[] => {
return global_scheduled_queries.map((global_scheduled_query) => {
return {
name: global_scheduled_query.name,
interval: global_scheduled_query.interval,
actions: generateActionDropdownOptions(),
id: global_scheduled_query.id,
query_id: global_scheduled_query.query_id,
snapshot: global_scheduled_query.snapshot,
removed: global_scheduled_query.removed,
platform: global_scheduled_query.platform,
version: global_scheduled_query.version,
shard: global_scheduled_query.shard,
type: "global_scheduled_query",
};
});
};
const generateDataSet = (
global_scheduled_queries: IGlobalScheduledQuery[]
): IGlobalScheduledQueryTableData[] => {
return [...enhanceGlobalScheduledQueryData(global_scheduled_queries)];
};
export { generateTableHeaders, generateDataSet };

View File

@ -12,10 +12,6 @@
background-color: $ui-off-white;
border-bottom: 1px solid $ui-fleet-blue-15;
th + th {
border-left: 1px solid $ui-fleet-blue-15;
}
th {
font-size: $x-small;
font-weight: $bold;
@ -32,11 +28,18 @@
}
&:nth-child(3) {
width: 37%;
width: 20%;
max-width: 150px;
}
&:nth-last-child(2) {
border-right: 0;
}
&:last-child {
width: 150px;
border-top-right-radius: 6px;
border-left: 0;
}
}
}