Add ability to target labels on policies (#27599)

For #27276 

# Details

This PR adds the ability to select labels when saving or editing a query
in the UI, so that the query will only target hosts with those labels.
It follows the API design from
https://github.com/fleetdm/fleet/pull/27196, utilizing the
labels_include_any and labels_exclude_any fields. The expectation is
that when creating or updating a query, labels_include_any and
labels_exclude_any are arrays of label names, and when fetching a single
query, they are arrays of objects with a name and an id key.

Other updates in this PR:

* Removed colons from various headings on the Save Policy Modal and Edit
Policy form
* Updated the "Delete label" text
* Removed "Policy runs on all hosts with these platforms." subheading
underneath the platform selector
* TargetLabelSelector component now has `suppressTitle` flag to turn off
the "Target" title.
This commit is contained in:
Scott Gress
2025-04-02 16:31:03 -05:00
committed by GitHub
parent dd06b2a414
commit d6eaa0913a
16 changed files with 1014 additions and 198 deletions
@@ -0,0 +1 @@
- Allow adding labels when saving or editing polices in the UI
@@ -76,7 +76,7 @@ export const PlatformSelector = ({
return (
<div className={`${parentClass}__${baseClass} ${baseClass} form-field`}>
<span className={labelClasses}>Target:</span>
<span className={labelClasses}>Target</span>
<span className={`${baseClass}__checkboxes`}>
<Checkbox
value={checkDarwin}
@@ -112,7 +112,6 @@ export const PlatformSelector = ({
</Checkbox>
</span>
<div className="form-field__help-text">
Policy runs on all hosts with these platforms.
{renderInstallSoftwareHelpText()}
</div>
</div>
@@ -112,4 +112,75 @@ describe("TargetLabelSelector component", () => {
expect(screen.getByRole("checkbox", { name: "label 1" })).toBeChecked();
expect(screen.getByRole("checkbox", { name: "label 2" })).not.toBeChecked();
});
it("sets the title to Target by default", () => {
const TITLE = "Target";
render(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
expect(screen.getByText(TITLE)).toBeVisible();
});
it("allows a custom title to be passed in", () => {
const TITLE = "Choose a target";
render(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
title={TITLE}
/>
);
expect(screen.getByText(TITLE)).toBeVisible();
});
it("suppresses the title when suppressTitle is true", () => {
render(
<TargetLabelSelector
selectedTargetType="Custom"
selectedCustomTarget="labelIncludeAny"
customTargetOptions={[
{ value: "labelIncludeAny", label: "Include any" },
]}
selectedLabels={{}}
labels={[
{ id: 1, name: "label 1", label_type: "regular" },
{ id: 2, name: "label 2", label_type: "regular" },
]}
onSelectCustomTarget={noop}
onSelectLabel={noop}
onSelectTargetType={noop}
suppressTitle
/>
);
expect(screen.queryByText("Target")).not.toBeInTheDocument();
});
});
@@ -42,16 +42,18 @@ interface ITargetChooserProps {
selectedTarget: string;
onSelect: (val: string) => void;
disableOptions?: boolean;
title: string | null;
}
const TargetChooser = ({
selectedTarget,
onSelect,
disableOptions = false,
title,
}: ITargetChooserProps) => {
return (
<div className="form-field">
<div className="form-field__label">Target</div>
{title && <div className="form-field__label">{title}</div>}
<Radio
className={`${baseClass}__radio-input`}
label="All hosts"
@@ -178,6 +180,8 @@ interface ITargetLabelSelectorProps {
onSelectCustomTarget?: (val: string) => void;
onSelectLabel: ({ name, value }: { name: string; value: boolean }) => void;
disableOptions?: boolean;
title?: string;
suppressTitle?: boolean;
}
const TargetLabelSelector = ({
@@ -195,6 +199,8 @@ const TargetLabelSelector = ({
onSelectCustomTarget,
onSelectLabel,
disableOptions = false,
title = "Target",
suppressTitle = false,
}: ITargetLabelSelectorProps) => {
const classNames = classnames(baseClass, className, "form");
@@ -204,6 +210,7 @@ const TargetLabelSelector = ({
selectedTarget={selectedTargetType}
onSelect={onSelectTargetType}
disableOptions={disableOptions}
title={suppressTitle ? null : title}
/>
{selectedTargetType === "Custom" && (
<LabelChooser
+45
View File
@@ -10,6 +10,7 @@ import { find } from "lodash";
import { osqueryTables } from "utilities/osquery_tables";
import { IOsQueryTable, DEFAULT_OSQUERY_TABLE } from "interfaces/osquery_table";
import { CommaSeparatedPlatformString } from "interfaces/platform";
import { ILabelPolicy } from "interfaces/label";
enum ACTIONS {
SET_LAST_EDITED_QUERY_INFO = "SET_LAST_EDITED_QUERY_INFO",
@@ -26,6 +27,8 @@ interface ISetLastEditedQueryInfo {
lastEditedQueryResolution?: string;
lastEditedQueryCritical?: boolean;
lastEditedQueryPlatform?: CommaSeparatedPlatformString | null;
lastEditedQueryLabelsIncludeAny?: ILabelPolicy[];
lastEditedQueryLabelsExcludeAny?: ILabelPolicy[];
defaultPolicy?: boolean;
}
@@ -56,6 +59,8 @@ type InitialStateType = {
lastEditedQueryResolution: string;
lastEditedQueryCritical: boolean;
lastEditedQueryPlatform: CommaSeparatedPlatformString | null;
lastEditedQueryLabelsIncludeAny: ILabelPolicy[];
lastEditedQueryLabelsExcludeAny: ILabelPolicy[];
defaultPolicy: boolean;
setLastEditedQueryId: (value: number | null) => void;
setLastEditedQueryName: (value: string) => void;
@@ -66,6 +71,8 @@ type InitialStateType = {
setLastEditedQueryPlatform: (
value: CommaSeparatedPlatformString | null
) => void;
setLastEditedQueryLabelsIncludeAny: (value: ILabelPolicy[]) => void;
setLastEditedQueryLabelsExcludeAny: (value: ILabelPolicy[]) => void;
setDefaultPolicy: (value: boolean) => void;
policyTeamId: number;
setPolicyTeamId: (id: number) => void;
@@ -87,6 +94,8 @@ const initialState = {
lastEditedQueryResolution: "",
lastEditedQueryCritical: false,
lastEditedQueryPlatform: null,
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
defaultPolicy: false,
setLastEditedQueryId: () => null,
setLastEditedQueryName: () => null,
@@ -95,6 +104,8 @@ const initialState = {
setLastEditedQueryResolution: () => null,
setLastEditedQueryCritical: () => null,
setLastEditedQueryPlatform: () => null,
setLastEditedQueryLabelsIncludeAny: () => null,
setLastEditedQueryLabelsExcludeAny: () => null,
setDefaultPolicy: () => null,
policyTeamId: 0,
setPolicyTeamId: () => null,
@@ -147,6 +158,14 @@ const reducer = (state: InitialStateType, action: IAction) => {
typeof action.lastEditedQueryPlatform === "undefined"
? state.lastEditedQueryPlatform
: action.lastEditedQueryPlatform,
lastEditedQueryLabelsIncludeAny:
typeof action.lastEditedQueryLabelsIncludeAny === "undefined"
? state.lastEditedQueryLabelsIncludeAny
: action.lastEditedQueryLabelsIncludeAny,
lastEditedQueryLabelsExcludeAny:
typeof action.lastEditedQueryLabelsExcludeAny === "undefined"
? state.lastEditedQueryLabelsExcludeAny
: action.lastEditedQueryLabelsExcludeAny,
defaultPolicy:
typeof action.defaultPolicy === "undefined"
? state.defaultPolicy
@@ -228,6 +247,24 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
},
[]
);
const setLastEditedQueryLabelsIncludeAny = useCallback(
(lastEditedQueryLabelsIncludeAny: ILabelPolicy[]) => {
dispatch({
type: ACTIONS.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryLabelsIncludeAny,
});
},
[]
);
const setLastEditedQueryLabelsExcludeAny = useCallback(
(lastEditedQueryLabelsExcludeAny: ILabelPolicy[]) => {
dispatch({
type: ACTIONS.SET_LAST_EDITED_QUERY_INFO,
lastEditedQueryLabelsExcludeAny,
});
},
[]
);
const setDefaultPolicy = useCallback((defaultPolicy: boolean) => {
dispatch({
type: ACTIONS.SET_LAST_EDITED_QUERY_INFO,
@@ -248,6 +285,8 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
lastEditedQueryResolution: state.lastEditedQueryResolution,
lastEditedQueryCritical: state.lastEditedQueryCritical,
lastEditedQueryPlatform: state.lastEditedQueryPlatform,
lastEditedQueryLabelsIncludeAny: state.lastEditedQueryLabelsIncludeAny,
lastEditedQueryLabelsExcludeAny: state.lastEditedQueryLabelsExcludeAny,
defaultPolicy: state.defaultPolicy,
setLastEditedQueryId,
setLastEditedQueryName,
@@ -256,6 +295,8 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
setLastEditedQueryResolution,
setLastEditedQueryCritical,
setLastEditedQueryPlatform,
setLastEditedQueryLabelsIncludeAny,
setLastEditedQueryLabelsExcludeAny,
setDefaultPolicy,
policyTeamId: state.policyTeamId,
setPolicyTeamId,
@@ -270,6 +311,8 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryPlatform,
setLastEditedQueryLabelsIncludeAny,
setLastEditedQueryLabelsExcludeAny,
setLastEditedQueryResolution,
setPolicyTeamId,
setSelectedOsqueryTable,
@@ -280,6 +323,8 @@ const PolicyProvider = ({ children }: Props): JSX.Element => {
state.lastEditedQueryId,
state.lastEditedQueryName,
state.lastEditedQueryPlatform,
state.lastEditedQueryLabelsIncludeAny,
state.lastEditedQueryLabelsExcludeAny,
state.lastEditedQueryResolution,
state.policyTeamId,
state.selectedOsqueryTable,
+5
View File
@@ -34,6 +34,11 @@ export interface ILabelQuery {
name: string;
}
export interface ILabelPolicy {
id: number;
name: string;
}
export interface ILabel extends ILabelSummary {
created_at: string;
updated_at: string;
+5
View File
@@ -1,6 +1,7 @@
import PropTypes from "prop-types";
import { CommaSeparatedPlatformString } from "interfaces/platform";
import { IScript } from "./script";
import { ILabelPolicy } from "./label";
// Legacy PropTypes used on host interface
export default PropTypes.shape({
@@ -44,6 +45,8 @@ export interface IPolicy {
calendar_events_enabled: boolean;
install_software?: IPolicySoftwareToInstall;
run_script?: Pick<IScript, "id" | "name">;
labels_include_any?: ILabelPolicy[];
labels_exclude_any?: ILabelPolicy[];
}
export interface IPolicySoftwareToInstall {
name: string;
@@ -108,6 +111,8 @@ export interface IPolicyFormData {
software_title_id?: number | null;
// null for PATCH to unset - note asymmetry with GET/LIST - see IPolicy.run_script
script_id?: number | null;
labels_include_any?: string[];
labels_exclude_any?: string[];
}
export interface IPolicyNew {
@@ -1,4 +1,6 @@
import React from "react";
import React, { useContext } from "react";
import { AppContext } from "context/app";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
@@ -16,6 +18,7 @@ const DeleteLabelModal = ({
onCancel,
isUpdatingLabel,
}: IDeleteLabelModalProps): JSX.Element => {
const { isPremiumTier } = useContext(AppContext);
return (
<Modal
title="Delete label"
@@ -24,16 +27,19 @@ const DeleteLabelModal = ({
className={baseClass}
>
<>
<p>
If a configuration profile uses this label as a custom target, the
profile will break. After deleting the label, remove broken profiles
and upload new profiles in their place.
</p>
<p>
If software uses this label as a custom target, the label will not be
able to be deleted. Please remove the label from the software target
first before deleting.
</p>
<p>Are you sure you wish to delete this label?</p>
{isPremiumTier && (
<ul>
<li>
Configuration profiles that target this label will not be applied
to new hosts.
</li>
<li>
Queries and policies that target this label will continue to run,
but may target different hosts.
</li>
</ul>
)}
<div className="modal-cta-wrap">
<Button
onClick={onSubmit}
@@ -75,6 +75,8 @@ const PolicyPage = ({
setLastEditedQueryResolution,
setLastEditedQueryCritical,
setLastEditedQueryPlatform,
setLastEditedQueryLabelsIncludeAny,
setLastEditedQueryLabelsExcludeAny,
setPolicyTeamId,
} = useContext(PolicyContext);
@@ -173,6 +175,12 @@ const PolicyPage = ({
setLastEditedQueryResolution(returnedQuery.resolution);
setLastEditedQueryCritical(returnedQuery.critical);
setLastEditedQueryPlatform(returnedQuery.platform);
setLastEditedQueryLabelsIncludeAny(
returnedQuery.labels_include_any || []
);
setLastEditedQueryLabelsExcludeAny(
returnedQuery.labels_exclude_any || []
);
// TODO(sarah): What happens if the team id in the policy response doesn't match the
// url param? In theory, the backend should ensure this doesn't happen.
setPolicyTeamId(
@@ -1,219 +1,449 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import { http, HttpResponse } from "msw";
import mockServer from "test/mock-server";
import userEvent from "@testing-library/user-event";
import createMockPolicy from "__mocks__/policyMock";
import createMockUser from "__mocks__/userMock";
import createMockConfig from "__mocks__/configMock";
import { ILabelSummary } from "interfaces/label";
import PolicyProvider from "context/policy";
import PolicyForm from "./PolicyForm";
const baseUrl = (path: string) => {
return `/api/latest/fleet${path}`;
};
const mockPolicy = createMockPolicy();
const mockLabels: ILabelSummary[] = [
{
id: 1,
name: "Fun",
description: "Computers that like to have a good time",
label_type: "regular",
},
{
id: 2,
name: "Fresh",
description: "Laptops with dirty mouths",
label_type: "regular",
},
];
const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => {
return HttpResponse.json({
labels: mockLabels,
});
});
describe("PolicyForm - component", () => {
it("disables save button for missing policy name", async () => {
const defaultProps = {
policyIdForEdit: mockPolicy.id,
showOpenSchemaActionText: false,
storedPolicy: createMockPolicy({ name: "Foo" }),
isStoredPolicyLoading: false,
isTeamObserver: false,
isUpdatingPolicy: false,
onCreatePolicy: jest.fn(),
onOsqueryTableSelect: jest.fn(),
goToSelectTargets: jest.fn(),
onUpdate: jest.fn(),
onOpenSchemaSidebar: jest.fn(),
renderLiveQueryWarning: jest.fn(),
backendValidators: {},
onClickAutofillDescription: jest.fn(),
onClickAutofillResolution: jest.fn(),
isFetchingAutofillDescription: false,
isFetchingAutofillResolution: false,
resetAiAutofillData: jest.fn(),
};
it("should not show the target selector in the free tier", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: "", // missing policy name
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: mockPolicy.platform,
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ name: "" })}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
render(<PolicyForm {...defaultProps} />);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
// Wait for any queries (that should not be happening) to finish.
await new Promise((resolve) => setTimeout(resolve, 500));
// Check that the target selector is not present.
expect(screen.queryByText("All hosts")).not.toBeInTheDocument();
});
it("disables save and run button with tooltip for missing policy platforms", async () => {
const render = createCustomRenderer({
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: mockPolicy.name,
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: undefined, // missing policy platforms
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
},
describe("in premium tier", () => {
beforeEach(() => {
mockServer.use(labelSummariesHandler);
});
const { container, user } = render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ platform: undefined })}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Run" })).toBeDisabled();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByRole("button", { name: "Save" }));
it("disables save button for missing policy name", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: "", // missing policy name
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: mockPolicy.platform,
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
},
});
expect(container.querySelector("#save-policy-button")).toHaveTextContent(
/to save or run the policy/i
render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ name: "" })}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
});
});
it("disables run button with tooltip when live queries are globally disabled", async () => {
const render = createCustomRenderer({
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: mockPolicy.name,
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: undefined, // missing policy platforms
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig({
server_settings: {
...createMockConfig().server_settings,
live_query_disabled: true, // Live query disabled
},
}),
},
},
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
const { user } = render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy()}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
expect(screen.getByRole("button", { name: "Run" })).toBeDisabled();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByRole("button", { name: "Run" }));
it("disables save and run button with tooltip for missing policy platforms", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: mockPolicy.name,
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: undefined, // missing policy platforms
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
},
});
expect(
screen.getByText(/live queries are disabled/i)
).toBeInTheDocument();
const { container, user } = render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ platform: undefined })}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Run" })).toBeDisabled();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByRole("button", { name: "Save" }));
});
expect(
container.querySelector("#save-policy-button")
).toHaveTextContent(/to save or run the policy/i);
});
});
it("disables run button with tooltip when live queries are globally disabled", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: mockPolicy.name,
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: undefined, // missing policy platforms
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
defaultPolicy: false,
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig({
server_settings: {
...createMockConfig().server_settings,
live_query_disabled: true, // Live query disabled
},
}),
},
},
});
const { user } = render(
<PolicyForm
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy()}
isStoredPolicyLoading={false}
isTeamObserver={false}
isUpdatingPolicy={false}
onCreatePolicy={jest.fn()}
onOsqueryTableSelect={jest.fn()}
goToSelectTargets={jest.fn()}
onUpdate={jest.fn()}
onOpenSchemaSidebar={jest.fn()}
renderLiveQueryWarning={jest.fn()}
backendValidators={{}}
onClickAutofillDescription={jest.fn()}
onClickAutofillResolution={jest.fn()}
isFetchingAutofillDescription={false}
isFetchingAutofillResolution={false}
resetAiAutofillData={jest.fn()}
/>
);
expect(screen.getByRole("button", { name: "Run" })).toBeDisabled();
await waitFor(() => {
waitFor(() => {
user.hover(screen.getByRole("button", { name: "Run" }));
});
expect(
screen.getByText(/live queries are disabled/i)
).toBeInTheDocument();
});
});
describe("target selector", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: "sumthin sumthin",
lastEditedQueryDescription: mockPolicy.description,
lastEditedQueryBody: mockPolicy.query,
lastEditedQueryResolution: mockPolicy.resolution,
lastEditedQueryCritical: mockPolicy.critical,
lastEditedQueryPlatform: "linux",
lastEditedQueryLabelsIncludeAny: [],
lastEditedQueryLabelsExcludeAny: [],
setLastEditedQueryName: jest.fn(),
setLastEditedQueryDescription: jest.fn(),
setLastEditedQueryBody: jest.fn(),
setLastEditedQueryResolution: jest.fn(),
setLastEditedQueryCritical: jest.fn(),
setLastEditedQueryPlatform: jest.fn(),
},
},
});
it("should show the target selector in All hosts target mode when the query has no labels", async () => {
render(<PolicyForm {...defaultProps} />);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeInTheDocument();
expect(screen.getByLabelText("All hosts")).toBeChecked();
});
});
it("should disable the save button in Custom target mode when no labels are selected, and enable it once labels are selected", async () => {
render(<PolicyForm {...defaultProps} />);
let allHosts;
let custom;
await waitFor(() => {
allHosts = screen.getByLabelText("All hosts");
custom = screen.getByLabelText("Custom");
expect(allHosts).toBeInTheDocument();
expect(custom).toBeInTheDocument();
});
custom && (await userEvent.click(custom));
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
const funButton = screen.getByLabelText("Fun");
expect(funButton).not.toBeChecked();
await userEvent.click(funButton);
await waitFor(() => {
expect(saveButton).toBeEnabled();
});
});
it("should send labels when saving a new query in Custom target mode (include any)", async () => {
const onUpdate = jest.fn();
const props = { ...defaultProps, onUpdate };
render(<PolicyForm {...props} />);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(onUpdate.mock.calls[0][0].labels_include_any).toEqual(["Fun"]);
expect(onUpdate.mock.calls[0][0].labels_exclude_any).toEqual([]);
});
it("should send labels when saving a new query in Custom target mode (exclude any)", async () => {
const onUpdate = jest.fn();
const props = { ...defaultProps, onUpdate };
render(<PolicyForm {...props} />);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
// Click "Include any" to open the dropdown.
const includeAnyOption = screen.getByRole("option", {
name: "Include any",
});
await userEvent.click(includeAnyOption);
// Click "Exclude any" to select it.
let excludeAnyOption: unknown;
await waitFor(() => {
excludeAnyOption = screen.getByRole("option", {
name: "Exclude any",
});
});
await userEvent.click(excludeAnyOption as Element);
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(onUpdate.mock.calls[0][0].labels_exclude_any).toEqual(["Fun"]);
expect(onUpdate.mock.calls[0][0].labels_include_any).toEqual([]);
});
it("should clear labels when saving a new query in All hosts target mode", async () => {
const onUpdate = jest.fn();
const props = { ...defaultProps, onUpdate };
render(<PolicyForm {...props} />);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(screen.getByLabelText("All hosts"));
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
await userEvent.click(saveButton);
expect(onUpdate.mock.calls[0][0].labels_include_any).toEqual([]);
expect(onUpdate.mock.calls[0][0].labels_exclude_any).toEqual([]);
});
});
});
// TODO: Consider testing save button is disabled for a sql error
// Trickiness is in modifying react-ace using react-testing library
});
@@ -1,6 +1,8 @@
/* eslint-disable jsx-a11y/no-noninteractive-element-to-interactive-role */
/* eslint-disable jsx-a11y/interactive-supports-focus */
import React, { useState, useContext, useEffect, KeyboardEvent } from "react";
import { useQuery } from "react-query";
import { IAceEditor } from "react-ace/lib/types";
import ReactTooltip from "react-tooltip";
import { useDebouncedCallback } from "use-debounce";
@@ -13,12 +15,16 @@ import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import usePlatformCompatibility from "hooks/usePlatformCompatibility";
import usePlatformSelector from "hooks/usePlatformSelector";
import CUSTOM_TARGET_OPTIONS from "pages/policies/helpers";
import { IPolicy, IPolicyFormData } from "interfaces/policy";
import { CommaSeparatedPlatformString } from "interfaces/platform";
import { DEFAULT_POLICIES } from "pages/policies/constants";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
import {
DEFAULT_USE_QUERY_OPTIONS,
LEARN_MORE_ABOUT_BASE_LINK,
} from "utilities/constants";
import Avatar from "components/Avatar";
import SQLEditor from "components/SQLEditor";
@@ -33,6 +39,12 @@ import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import CustomLink from "components/CustomLink";
import TargetLabelSelector from "components/TargetLabelSelector";
import labelsAPI, {
getCustomLabels,
ILabelsSummaryResponse,
} from "services/entities/labels";
import SaveNewPolicyModal from "../SaveNewPolicyModal";
@@ -100,6 +112,12 @@ const PolicyForm = ({
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isEditingResolution, setIsEditingResolution] = useState(false);
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
"labelsIncludeAny"
);
const [selectedLabels, setSelectedLabels] = useState({});
// Note: The PolicyContext values should always be used for any mutable policy data such as query name
// The storedPolicy prop should only be used to access immutable metadata such as author id
const {
@@ -110,6 +128,8 @@ const PolicyForm = ({
lastEditedQueryResolution,
lastEditedQueryCritical,
lastEditedQueryPlatform,
lastEditedQueryLabelsIncludeAny,
lastEditedQueryLabelsExcludeAny,
defaultPolicy,
setLastEditedQueryName,
setLastEditedQueryDescription,
@@ -119,6 +139,19 @@ const PolicyForm = ({
setLastEditedQueryPlatform,
} = useContext(PolicyContext);
const onSelectLabel = ({
name: labelName,
value,
}: {
name: string;
value: boolean;
}) => {
setSelectedLabels({
...selectedLabels,
[labelName]: value,
});
};
const {
currentUser,
currentTeam,
@@ -132,6 +165,20 @@ const PolicyForm = ({
config,
} = useContext(AppContext);
const {
data: { labels } = { labels: [] },
isFetching: isFetchingLabels,
} = useQuery<ILabelsSummaryResponse, Error>(
["custom_labels"],
() => labelsAPI.summary(),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: isPremiumTier,
staleTime: 10000,
select: (res) => ({ labels: getCustomLabels(res.labels) }),
}
);
const disabledLiveQuery = config?.server_settings.live_query_disabled;
const aiFeaturesDisabled =
config?.server_settings.ai_features_disabled || false;
@@ -178,6 +225,30 @@ const PolicyForm = ({
!policyIdForEdit &&
DEFAULT_POLICIES.find((p) => p.name === lastEditedQueryName);
useEffect(() => {
setSelectedTargetType(
!lastEditedQueryLabelsIncludeAny.length &&
!lastEditedQueryLabelsExcludeAny.length
? "All hosts"
: "Custom"
);
setSelectedCustomTarget(
lastEditedQueryLabelsExcludeAny.length
? "labelsExcludeAny"
: "labelsIncludeAny"
);
setSelectedLabels(
lastEditedQueryLabelsIncludeAny
.concat(lastEditedQueryLabelsExcludeAny)
.reduce((acc, label) => {
return {
...acc,
[label.name]: true,
};
}, {}) || {}
);
}, [lastEditedQueryLabelsIncludeAny, lastEditedQueryLabelsExcludeAny]);
useEffect(() => {
if (isNewTemplatePolicy) {
setCompatiblePlatforms(lastEditedQueryBody);
@@ -276,6 +347,20 @@ const PolicyForm = ({
query: lastEditedQueryBody,
resolution: lastEditedQueryResolution,
platform: newPlatformString,
labels_include_any:
selectedTargetType === "Custom" &&
selectedCustomTarget === "labelsIncludeAny"
? Object.entries(selectedLabels)
.filter(([, selected]) => selected)
.map(([labelName]) => labelName)
: [],
labels_exclude_any:
selectedTargetType === "Custom" &&
selectedCustomTarget === "labelsExcludeAny"
? Object.entries(selectedLabels)
.filter(([, selected]) => selected)
.map(([labelName]) => labelName)
: [],
};
if (isPremiumTier) {
payload.critical = lastEditedQueryCritical;
@@ -473,7 +558,7 @@ const PolicyForm = ({
if (isEditMode) {
return (
<div className={`form-field ${baseClass}__policy-resolve`}>
<div className="form-field__label">Resolve:</div>
<div className="form-field__label">Resolve</div>
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
@@ -547,7 +632,7 @@ const PolicyForm = ({
</p>
}
>
Critical:
Critical
</TooltipWrapper>
</Checkbox>
</div>
@@ -617,6 +702,10 @@ const PolicyForm = ({
const disableSaveFormErrors =
(isEditMode && !isAnyPlatformSelected) ||
(lastEditedQueryName === "" && !!lastEditedQueryId) ||
(selectedTargetType === "Custom" &&
!Object.entries(selectedLabels).some(([, value]) => {
return value;
})) ||
!!size(errors);
return (
@@ -646,6 +735,26 @@ const PolicyForm = ({
/>
{renderPlatformCompatibility()}
{isEditMode && platformSelector.render()}
{isEditMode && isPremiumTier && (
<TargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
onSelectCustomTarget={setSelectedCustomTarget}
selectedLabels={selectedLabels}
className={`${baseClass}__target`}
onSelectTargetType={setSelectedTargetType}
onSelectLabel={onSelectLabel}
labels={labels || []}
customHelpText={
<span className="form-field__help-text">
Policy will target hosts on selected platforms that{" "}
<b>have any</b> of these labels:
</span>
}
suppressTitle
/>
)}
{isEditMode && isPremiumTier && renderCriticalPolicy()}
{renderLiveQueryWarning()}
<div className="button-wrap">
@@ -741,6 +850,7 @@ const PolicyForm = ({
isFetchingAutofillResolution={isFetchingAutofillResolution}
onClickAutofillDescription={onClickAutofillDescription}
onClickAutofillResolution={onClickAutofillResolution}
labels={labels}
/>
)}
</>
@@ -0,0 +1,224 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import createMockQuery from "__mocks__/queryMock";
import createMockUser from "__mocks__/userMock";
import createMockConfig from "__mocks__/configMock";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import mockServer from "test/mock-server";
import { ILabelSummary } from "interfaces/label";
import PolicyProvider from "context/policy";
import SaveNewPolicyModal from "./SaveNewPolicyModal";
const baseUrl = (path: string) => {
return `/api/latest/fleet${path}`;
};
const mockLabels: ILabelSummary[] = [
{
id: 1,
name: "Fun",
description: "Computers that like to have a good time",
label_type: "regular",
},
{
id: 2,
name: "Fresh",
description: "Laptops with dirty mouths",
label_type: "regular",
},
];
const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => {
return HttpResponse.json({
labels: mockLabels,
});
});
describe("SaveNewPolicyModal", () => {
const defaultProps = {
baseClass: "",
queryValue: "",
onCreatePolicy: jest.fn(),
setIsSaveNewPolicyModalOpen: jest.fn(),
backendValidators: {},
platformSelector: {
setSelectedPlatforms: jest.fn(),
getSelectedPlatforms: () => {
return [];
},
isAnyPlatformSelected: true,
render: () => <div />,
disabled: false,
},
isUpdatingPolicy: false,
isFetchingAutofillDescription: false,
isFetchingAutofillResolution: false,
onClickAutofillDescription: jest.fn(),
onClickAutofillResolution: jest.fn(),
labels: mockLabels,
};
it("should not show the target selector in the free tier", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(<SaveNewPolicyModal {...defaultProps} />);
// Wait for any queries (that should not be happening) to finish.
await new Promise((resolve) => setTimeout(resolve, 500));
// Check that the target selector is not present.
expect(screen.queryByText("All hosts")).not.toBeInTheDocument();
});
describe("in premium tier", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
isGlobalObserver: false,
isGlobalAdmin: true,
isGlobalMaintainer: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
config: createMockConfig(),
},
},
});
beforeEach(() => {
mockServer.use(labelSummariesHandler);
});
it("should show the target selector in All hosts target mode when the policy has no labels", async () => {
render(<SaveNewPolicyModal {...defaultProps} />);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeInTheDocument();
expect(screen.getByLabelText("All hosts")).toBeChecked();
});
});
it("should disable the save button in Custom target mode when no labels are selected, and enable it once labels are selected", async () => {
render(<SaveNewPolicyModal {...defaultProps} />);
let allHosts;
let custom;
await waitFor(() => {
allHosts = screen.getByLabelText("All hosts");
custom = screen.getByLabelText("Custom");
expect(allHosts).toBeInTheDocument();
expect(custom).toBeInTheDocument();
});
custom && (await userEvent.click(custom));
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
const funButton = screen.getByLabelText("Fun");
expect(funButton).not.toBeChecked();
await userEvent.click(funButton);
expect(saveButton).toBeEnabled();
});
it("should send labels when saving a new policy in Custom target mode (include any)", async () => {
const onCreatePolicy = jest.fn();
const props = { ...defaultProps, onCreatePolicy };
render(
<PolicyProvider>
<SaveNewPolicyModal {...props} />
</PolicyProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a name.
const nameInput = screen.getByLabelText("Name");
await userEvent.type(nameInput, "A Brand New Policy!");
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onCreatePolicy.mock.calls[0][0].labels_include_any).toEqual([
"Fun",
]);
});
it("should send labels when saving a new policy in Custom target mode (exclude any)", async () => {
const onCreatePolicy = jest.fn();
const props = { ...defaultProps, onCreatePolicy };
render(
<PolicyProvider>
<SaveNewPolicyModal {...props} />
</PolicyProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a name.
const nameInput = screen.getByLabelText("Name");
await userEvent.type(nameInput, "A Brand New Policy!");
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
// Click "Include any" to open the dropdown.
const includeAnyOption = screen.getByRole("option", {
name: "Include any",
});
await userEvent.click(includeAnyOption);
// Click "Exclude any" to select it.
let excludeAnyOption: unknown;
await waitFor(() => {
excludeAnyOption = screen.getByRole("option", { name: "Exclude any" });
});
await userEvent.click(excludeAnyOption as Element);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onCreatePolicy.mock.calls[0][0].labels_exclude_any).toEqual([
"Fun",
]);
});
it("should clear labels when saving a new policy in All hosts target mode", async () => {
const onCreatePolicy = jest.fn();
const props = { ...defaultProps, onCreatePolicy };
render(
<PolicyProvider>
<SaveNewPolicyModal {...props} />
</PolicyProvider>
);
await waitFor(() => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
});
// Set a name.
await userEvent.type(
screen.getByLabelText("Name"),
"A Brand New Policy!"
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onCreatePolicy.mock.calls[0][0].labels_include_any).toEqual([]);
});
});
});
@@ -2,9 +2,12 @@ import React, { useState, useContext, useEffect, useCallback } from "react";
import { size } from "lodash";
import classNames from "classnames";
import CUSTOM_TARGET_OPTIONS from "pages/policies/helpers";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { IPlatformSelector } from "hooks/usePlatformSelector";
import { ILabelSummary } from "interfaces/label";
import { IPolicyFormData } from "interfaces/policy";
import { CommaSeparatedPlatformString } from "interfaces/platform";
import useDeepEffect from "hooks/useDeepEffect";
@@ -15,6 +18,7 @@ import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import Button from "components/buttons/Button";
import Modal from "components/Modal";
import TargetLabelSelector from "components/TargetLabelSelector";
import Icon from "components/Icon";
import ReactTooltip from "react-tooltip";
import { COLORS } from "styles/var/colors";
@@ -32,6 +36,7 @@ export interface ISaveNewPolicyModalProps {
isFetchingAutofillResolution: boolean;
onClickAutofillDescription: () => Promise<void>;
onClickAutofillResolution: () => Promise<void>;
labels: ILabelSummary[];
}
const validatePolicyName = (name: string) => {
@@ -58,6 +63,7 @@ const SaveNewPolicyModal = ({
isFetchingAutofillResolution,
onClickAutofillDescription,
onClickAutofillResolution,
labels,
}: ISaveNewPolicyModalProps): JSX.Element => {
const { isPremiumTier } = useContext(AppContext);
const {
@@ -77,9 +83,34 @@ const SaveNewPolicyModal = ({
backendValidators
);
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
"labelsIncludeAny"
);
const [selectedLabels, setSelectedLabels] = useState({});
const onSelectLabel = ({
name: labelName,
value,
}: {
name: string;
value: boolean;
}) => {
setSelectedLabels({
...selectedLabels,
[labelName]: value,
});
};
const disableForm =
isFetchingAutofillDescription || isFetchingAutofillResolution;
const disableSave = !platformSelector.isAnyPlatformSelected || disableForm;
const disableSave =
!platformSelector.isAnyPlatformSelected ||
disableForm ||
(selectedTargetType === "Custom" &&
!Object.entries(selectedLabels).some(([, value]) => {
return value;
}));
useDeepEffect(() => {
if (lastEditedQueryName) {
@@ -115,6 +146,20 @@ const SaveNewPolicyModal = ({
resolution: lastEditedQueryResolution,
platform: newPlatformString,
critical: lastEditedQueryCritical,
labels_include_any:
selectedTargetType === "Custom" &&
selectedCustomTarget === "labelsIncludeAny"
? Object.entries(selectedLabels)
.filter(([, selected]) => selected)
.map(([labelName]) => labelName)
: [],
labels_exclude_any:
selectedTargetType === "Custom" &&
selectedCustomTarget === "labelsExcludeAny"
? Object.entries(selectedLabels)
.filter(([, selected]) => selected)
.map(([labelName]) => labelName)
: [],
});
}
};
@@ -235,6 +280,26 @@ const SaveNewPolicyModal = ({
disabled={disableForm}
/>
{platformSelector.render()}
{isPremiumTier && (
<TargetLabelSelector
selectedTargetType={selectedTargetType}
selectedCustomTarget={selectedCustomTarget}
customTargetOptions={CUSTOM_TARGET_OPTIONS}
onSelectCustomTarget={setSelectedCustomTarget}
selectedLabels={selectedLabels}
className={`${baseClass}__target`}
onSelectTargetType={setSelectedTargetType}
onSelectLabel={onSelectLabel}
labels={labels || []}
customHelpText={
<span className="form-field__help-text">
Policy will target hosts on selected platforms that{" "}
<b>have any</b> of these labels:
</span>
}
suppressTitle
/>
)}
{isPremiumTier && (
<div className="critical-checkbox-wrapper">
<Checkbox
@@ -272,7 +337,7 @@ const SaveNewPolicyModal = ({
className="save-policy-loading"
isLoading={isUpdatingPolicy}
>
Save policy
Save
</Button>
<ReactTooltip
className={`${baseClass}__button--modal-save-tooltip`}
@@ -153,6 +153,8 @@ const QueryEditor = ({
query: formData.query,
resolution: formData.resolution,
platform: formData.platform,
labels_include_any: formData.labels_include_any,
labels_exclude_any: formData.labels_exclude_any,
};
if (isPremiumTier) {
payload.critical = formData.critical;
+30
View File
@@ -0,0 +1,30 @@
import React from "react";
import { IDropdownOption } from "interfaces/dropdownOption";
const CUSTOM_TARGET_OPTIONS: IDropdownOption[] = [
{
value: "labelsIncludeAny",
label: "Include any",
helpText: (
<>
Policy will target hosts on selected platforms that <b>have any</b> of
these labels:
</>
),
disabled: false,
},
{
value: "labelsExcludeAny",
label: "Exclude any",
helpText: (
<>
Policy will target hosts on selected platforms that{" "}
<b>don&rsquo;t have any</b> of these labels:
</>
),
disabled: false,
},
];
export default CUSTOM_TARGET_OPTIONS;
@@ -65,6 +65,8 @@ export default {
platform,
critical,
software_title_id,
labels_include_any,
labels_exclude_any,
// note absence of automations-related fields, which are only set by the UI via update
} = data;
const { TEAMS } = endpoints;
@@ -78,6 +80,8 @@ export default {
platform,
critical,
software_title_id,
labels_include_any,
labels_exclude_any,
});
},
update: (id: number, data: IPolicyFormData) => {
@@ -93,6 +97,8 @@ export default {
calendar_events_enabled,
software_title_id,
script_id,
labels_include_any,
labels_exclude_any,
} = data;
const { TEAMS } = endpoints;
const path = `${TEAMS}/${team_id}/policies/${id}`;
@@ -107,6 +113,8 @@ export default {
calendar_events_enabled,
software_title_id,
script_id,
labels_include_any,
labels_exclude_any,
});
},
destroy: (teamId: number | undefined, ids: number[]) => {