Fleet UI: *New* policy details page + updates to the edit policy and edit report pages (#43102)

This commit is contained in:
RachelElysia 2026-04-09 09:16:54 -04:00 committed by GitHub
parent a06415174e
commit fe5e537b22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 914 additions and 824 deletions

View file

@ -237,7 +237,7 @@
}
.icon {
vertical-align: sub;
vertical-align: center;
}
}
.linkToFilteredHosts__header {

View file

@ -106,6 +106,7 @@ const LabelChooser = ({
customTargetOptions = [],
onSelectCustomTarget,
onSelectLabel,
disableOptions,
}: ILabelChooserProps) => {
const getHelpText = (value?: string) => {
if (dropdownHelpText) return dropdownHelpText;
@ -138,6 +139,7 @@ const LabelChooser = ({
options={customTargetOptions}
searchable={false}
onChange={onSelectCustomTarget}
disabled={disableOptions}
/>
)}
<div className={`${baseClass}__description`}>
@ -155,8 +157,10 @@ const LabelChooser = ({
value={!!selectedLabels[label.name]}
onChange={onSelectLabel}
parseTarget
/>
<div className={`${baseClass}__label-name`}>{label.name}</div>
disabled={disableOptions}
>
{label.name}
</Checkbox>
</div>
);
})}

View file

@ -10,7 +10,7 @@
&__checkboxes {
display: flex;
max-height: 187px;
max-height: 189px; // Fits 5 options before scrolling
flex-direction: column;
border-radius: $border-radius;
border: 1px solid $ui-fleet-black-10;
@ -38,6 +38,6 @@
}
&__label-name {
padding-left: $pad-large;
padding-left: $pad-small;
}
}

View file

@ -8,6 +8,7 @@ export type ButtonVariant =
| "default"
| "alert"
| "pill"
| "grey-pill"
| "text-link" // Underlines on hover
| "text-link-dark" // underline on hover, dark text
| "brand-inverse-icon" // Green icon with text, no underline on hover
@ -146,7 +147,8 @@ class Button extends React.Component<IButtonProps, IButtonState> {
variant === "inverse" ||
variant === "brand-inverse-icon" ||
variant === "text-icon" ||
variant === "pill";
variant === "pill" ||
variant === "grey-pill";
return (
<button

View file

@ -190,6 +190,33 @@ $base-class: "button";
}
}
&--grey-pill {
@include button-variant(
$core-fleet-white,
$ui-off-white,
null,
$inverse: true
);
color: $ui-fleet-black-75;
border: 1px solid $ui-fleet-black-25;
border-radius: 4px;
box-sizing: border-box;
font-size: $xx-small;
font-weight: $bold;
padding: 0 $pad-small;
height: 28px;
white-space: nowrap;
&:hover,
&:focus {
border: 1px solid $ui-fleet-black-50;
}
&:active {
box-shadow: inset 2px 2px 2px rgba(0, 0, 0, 0.1);
}
}
&--text-link {
@include button-variant(transparent);
border: 0;

View file

@ -206,7 +206,7 @@
&.is-disabled {
> .Select-control .Select-value .Select-value-label {
color: $ui-fleet-black-5;
color: $ui-fleet-black-50;
}
}
}

View file

@ -401,11 +401,15 @@ describe("Edit Auto Update Config Modal", () => {
await screen.findByLabelText("Custom");
// Now wait specifically for one label to appear
const freshLabel = await screen.findByLabelText(mockLabels[1].name);
const freshLabel = await screen.findByRole("checkbox", {
name: mockLabels[1].name,
});
expect(freshLabel).toBeInTheDocument();
expect(freshLabel).toBeChecked();
const funLabel = screen.getByLabelText(mockLabels[0].name);
const funLabel = screen.getByRole("checkbox", {
name: mockLabels[0].name,
});
expect(funLabel).toBeInTheDocument();
expect(funLabel).not.toBeChecked();
});
@ -426,10 +430,12 @@ describe("Edit Auto Update Config Modal", () => {
/>
);
// Wait for labels to load
await screen.findByLabelText(mockLabels[1].name);
await screen.findByRole("checkbox", { name: mockLabels[1].name });
const customOption = screen.getByLabelText("Custom");
expect(customOption).toBeChecked();
const labelOption = screen.getByLabelText(mockLabels[1].name);
const labelOption = screen.getByRole("checkbox", {
name: mockLabels[1].name,
});
expect(labelOption).toBeChecked();
await user.click(labelOption);
expect(labelOption).not.toBeChecked();

View file

@ -42,7 +42,7 @@ const generateInstallerPoliciesTableConfig = ({
value={cellProps.cell.value}
tooltipTruncate
path={getPathWithQueryParams(
PATHS.EDIT_POLICY(cellProps.row.original.id),
PATHS.POLICY_DETAILS(cellProps.row.original.id),
{
fleet_id: teamId,
}

View file

@ -131,8 +131,8 @@ const generateTableHeaders = (
)}
</>
}
path={getPathWithQueryParams(PATHS.EDIT_POLICY(id), {
fleet_id: team_id,
path={getPathWithQueryParams(PATHS.POLICY_DETAILS(id), {
team_id,
})}
/>
);

View file

@ -0,0 +1,411 @@
import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { InjectedRouter, Params } from "react-router/lib/Router";
import { useErrorHandler } from "react-error-boundary";
import { noop } from "lodash";
import PATHS from "router/paths";
import { AppContext } from "context/app";
import { PolicyContext } from "context/policy";
import { IPolicy, IStoredPolicyResponse } from "interfaces/policy";
import { ILabelPolicy } from "interfaces/label";
import { API_ALL_TEAMS_ID, APP_CONTEXT_ALL_TEAMS_ID } from "interfaces/team";
import { PLATFORM_DISPLAY_NAMES, Platform } from "interfaces/platform";
import globalPoliciesAPI from "services/entities/global_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { addGravatarUrlToResource } from "utilities/helpers";
import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
import { getPathWithQueryParams } from "utilities/url";
import useTeamIdParam from "hooks/useTeamIdParam";
import BackButton from "components/BackButton";
import Button from "components/buttons/Button";
import DataSet from "components/DataSet";
import Icon from "components/Icon";
import MainContent from "components/MainContent";
import PageDescription from "components/PageDescription";
import Spinner from "components/Spinner";
import TooltipWrapper from "components/TooltipWrapper";
import Avatar from "components/Avatar";
import ShowQueryModal from "components/modals/ShowQueryModal";
import PolicyAutomations from "pages/policies/PolicyPage/components/PolicyAutomations";
interface IPolicyDetailsPageProps {
router: InjectedRouter;
params: Params;
location: {
pathname: string;
search: string;
query: { team_id?: string };
};
}
const baseClass = "policy-details-page";
const PolicyDetailsPage = ({
router,
params: { id: paramsPolicyId },
location,
}: IPolicyDetailsPageProps): JSX.Element => {
const policyId = paramsPolicyId ? parseInt(paramsPolicyId, 10) : null;
const handlePageError = useErrorHandler();
const {
currentUser,
isGlobalAdmin,
isGlobalMaintainer,
isGlobalObserver,
isGlobalTechnician,
isOnGlobalTeam,
config,
currentTeam,
} = useContext(AppContext);
const {
lastEditedQueryName,
lastEditedQueryDescription,
lastEditedQueryResolution,
lastEditedQueryBody,
lastEditedQueryPlatform,
setLastEditedQueryId,
setLastEditedQueryName,
setLastEditedQueryDescription,
setLastEditedQueryBody,
setLastEditedQueryResolution,
setLastEditedQueryCritical,
setLastEditedQueryPlatform,
setLastEditedQueryLabelsIncludeAny,
setLastEditedQueryLabelsExcludeAny,
setPolicyTeamId,
} = useContext(PolicyContext);
const {
isRouteOk,
teamIdForApi,
isTeamMaintainerOrTeamAdmin,
isTeamTechnician,
isObserverPlus,
} = useTeamIdParam({
location,
router,
includeAllTeams: true,
includeNoTeam: true,
permittedAccessByTeamRole: {
admin: true,
maintainer: true,
observer: true,
observer_plus: true,
technician: true,
},
});
const [showQueryModal, setShowQueryModal] = useState(false);
if (policyId === null || isNaN(policyId)) {
router.push(PATHS.MANAGE_POLICIES);
}
const { isLoading, data: storedPolicy, error: apiError } = useQuery<
IStoredPolicyResponse,
Error,
IPolicy
>(["policy", policyId], () => globalPoliciesAPI.load(policyId as number), {
enabled: isRouteOk && !!policyId,
refetchOnWindowFocus: false,
retry: false,
select: (data: IStoredPolicyResponse) => data.policy,
onSuccess: (returnedPolicy) => {
setLastEditedQueryId(returnedPolicy.id);
setLastEditedQueryName(returnedPolicy.name);
setLastEditedQueryDescription(returnedPolicy.description);
setLastEditedQueryBody(returnedPolicy.query);
setLastEditedQueryResolution(returnedPolicy.resolution);
setLastEditedQueryCritical(returnedPolicy.critical);
setLastEditedQueryPlatform(returnedPolicy.platform);
setLastEditedQueryLabelsIncludeAny(
returnedPolicy.labels_include_any || []
);
setLastEditedQueryLabelsExcludeAny(
returnedPolicy.labels_exclude_any || []
);
const deNulledTeamId = returnedPolicy.team_id ?? undefined;
setPolicyTeamId(
deNulledTeamId === API_ALL_TEAMS_ID
? APP_CONTEXT_ALL_TEAMS_ID
: deNulledTeamId
);
},
onError: (error) => handlePageError(error),
});
const { data: teamData } = useQuery<ILoadTeamResponse>(
["team", teamIdForApi],
() => teamsAPI.load(teamIdForApi as number),
{
enabled: !!teamIdForApi && teamIdForApi > 0,
refetchOnWindowFocus: false,
}
);
let currentAutomatedPolicies: number[] = [];
if (teamData?.team) {
const {
webhook_settings: { failing_policies_webhook: webhook },
integrations,
} = teamData.team;
const isIntegrationEnabled =
(integrations?.jira?.some((j: any) => j.enable_failing_policies) ||
integrations?.zendesk?.some((z: any) => z.enable_failing_policies)) ??
false;
if (isIntegrationEnabled || webhook?.enable_failing_policies_webhook) {
currentAutomatedPolicies = webhook?.policy_ids || [];
}
}
useEffect(() => {
if (storedPolicy?.name) {
document.title = `${storedPolicy.name} | Policies | ${DOCUMENT_TITLE_SUFFIX}`;
} else {
document.title = `Policies | ${DOCUMENT_TITLE_SUFFIX}`;
}
}, [location.pathname, storedPolicy?.name]);
const isInheritedPolicy = storedPolicy?.team_id === null;
const canEditPolicy =
(isGlobalAdmin || isGlobalMaintainer || isTeamMaintainerOrTeamAdmin) &&
// Team users cannot edit inherited (global) policies
!(isInheritedPolicy && !isOnGlobalTeam);
const canRunPolicy =
isObserverPlus ||
isTeamMaintainerOrTeamAdmin ||
isGlobalAdmin ||
isGlobalMaintainer ||
isGlobalTechnician ||
isTeamTechnician;
const disabledLiveQuery = config?.server_settings.live_query_disabled;
const backToPoliciesPath = getPathWithQueryParams(PATHS.MANAGE_POLICIES, {
team_id: teamIdForApi,
});
const renderAuthor = (): JSX.Element | null => {
if (!storedPolicy) return null;
return (
<DataSet
className={`${baseClass}__author`}
title="Author"
value={
<div className={`${baseClass}__author-info`}>
<Avatar
user={addGravatarUrlToResource({
email: storedPolicy.author_email,
})}
size="xsmall"
/>
<span>
{storedPolicy.author_name === currentUser?.name
? "You"
: storedPolicy.author_name}
</span>
</div>
}
/>
);
};
const renderPlatforms = (): JSX.Element | null => {
if (!lastEditedQueryPlatform) return null;
const platforms = lastEditedQueryPlatform
.split(",")
.map((p) => p.trim())
.filter((p): p is Platform => p in PLATFORM_DISPLAY_NAMES);
if (platforms.length === 0) return null;
return (
<DataSet
className={`${baseClass}__platforms`}
title="Platforms"
value={
<div className={`${baseClass}__platform-list`}>
{platforms.map((platform) => (
<span key={platform} className={`${baseClass}__platform-item`}>
<Icon name={platform} color="ui-fleet-black-75" />
{PLATFORM_DISPLAY_NAMES[platform] || platform}
</span>
))}
</div>
}
/>
);
};
const onLabelClick = (label: ILabelPolicy) => {
router.push(PATHS.MANAGE_HOSTS_LABEL(label.id));
};
const renderLabels = (): JSX.Element | null => {
const includeAny = storedPolicy?.labels_include_any;
const excludeAny = storedPolicy?.labels_exclude_any;
if (!includeAny?.length && !excludeAny?.length) return null;
const isInclude = !!includeAny?.length;
const labels = isInclude ? includeAny : excludeAny;
return (
<DataSet
className={`${baseClass}__labels`}
title="Labels"
value={
<div className={`${baseClass}__labels-section`}>
<p>
Policy will target hosts that{" "}
<b>{isInclude ? "have any" : "exclude any"}</b> of these labels:
</p>
<ul className={`${baseClass}__labels-list`}>
{labels?.map((label: ILabelPolicy) => (
<li key={label.id}>
<Button
onClick={() => onLabelClick(label)}
variant="grey-pill"
className={`${baseClass}__label-pill`}
>
{label.name}
</Button>
</li>
))}
</ul>
</div>
}
/>
);
};
const renderHeader = () => {
return (
<>
<div className={`${baseClass}__header-links`}>
<BackButton text="Back to policies" path={backToPoliciesPath} />
</div>
{!isLoading && !apiError && (
<>
<div className={`${baseClass}__title-bar`}>
<div className={`${baseClass}__name-description`}>
<h1 className={`${baseClass}__policy-name`}>
{lastEditedQueryName}
{storedPolicy?.critical && (
<TooltipWrapper
tipContent="This policy has been marked as critical."
showArrow
underline={false}
>
<Icon
className="critical-policy-icon"
name="policy"
color="ui-fleet-black-50"
/>
</TooltipWrapper>
)}
</h1>
<PageDescription
className={`${baseClass}__policy-description`}
content={lastEditedQueryDescription}
/>
</div>
<div className={`${baseClass}__action-button-container`}>
<Button
className={`${baseClass}__show-query-btn`}
onClick={() => setShowQueryModal(true)}
variant="inverse"
>
Show query
</Button>
{canRunPolicy && (
<Button
className={`${baseClass}__run`}
variant="inverse"
onClick={() => {
policyId &&
router.push(
`${getPathWithQueryParams(
PATHS.EDIT_POLICY(policyId),
{
team_id: teamIdForApi,
}
)}#targets`
);
}}
disabled={!!disabledLiveQuery}
>
Run policy <Icon name="run" />
</Button>
)}
{canEditPolicy && (
<Button
onClick={() => {
policyId &&
router.push(
getPathWithQueryParams(PATHS.EDIT_POLICY(policyId), {
team_id: teamIdForApi,
})
);
}}
className={`${baseClass}__edit-policy-btn`}
>
Edit policy
</Button>
)}
</div>
</div>
{lastEditedQueryResolution && (
<DataSet
className={`${baseClass}__resolve`}
title="Resolve"
value={lastEditedQueryResolution}
/>
)}
{renderAuthor()}
{currentTeam && (
<DataSet
className={`${baseClass}__fleet`}
title="Fleet"
value={currentTeam.name}
/>
)}
{renderPlatforms()}
{renderLabels()}
{storedPolicy && (
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
onAddAutomation={noop}
isAddingAutomation={false}
gitOpsModeEnabled={false}
/>
)}
</>
)}
</>
);
};
if (!isRouteOk) {
return <Spinner />;
}
return (
<MainContent className={baseClass}>
{isLoading ? <Spinner /> : renderHeader()}
{showQueryModal && (
<ShowQueryModal
query={lastEditedQueryBody}
onCancel={() => setShowQueryModal(false)}
/>
)}
</MainContent>
);
};
export default PolicyDetailsPage;

View file

@ -0,0 +1,74 @@
.policy-details-page {
@include vertical-page-layout;
p {
margin: 0; // Until this is an app-wide style
}
&__title-bar {
display: flex;
justify-content: space-between;
gap: $pad-xlarge;
}
&__name-description,
&__platform-list,
&__labels-section {
display: flex;
flex-direction: column;
gap: $pad-small;
}
&__platform-list {
flex-direction: row;
}
&__action-button-container {
display: flex;
justify-content: flex-end;
min-width: max-content;
gap: $pad-medium;
}
&__policy-name {
font-size: $large;
display: flex;
align-items: center;
gap: $pad-small;
.critical-policy-icon {
flex-shrink: 0;
}
}
// Override DataSet 4px gap to 8px for all instances on this page
.data-set {
gap: $pad-small;
}
&__author-info {
display: flex;
align-items: center;
gap: $pad-small;
}
&__platform-item {
display: flex;
align-items: center;
gap: $pad-xsmall;
}
&__labels-help-text {
margin: 0;
font-weight: $regular;
}
&__labels-list {
display: flex;
flex-wrap: wrap;
gap: $pad-small;
list-style: none;
margin: 0;
padding: 0;
}
}

View file

@ -0,0 +1 @@
export { default } from "./PolicyDetailsPage";

View file

@ -144,7 +144,9 @@ const PolicyPage = ({
};
}, []);
const [step, setStep] = useState(LIVE_POLICY_STEPS[1]);
const [step, setStep] = useState(
location.hash === "#targets" ? LIVE_POLICY_STEPS[2] : LIVE_POLICY_STEPS[1]
);
const [selectedTargets, setSelectedTargets] = useState<ITarget[]>([]);
const [targetedHosts, setTargetedHosts] = useState<IHost[]>([]);
const [targetedLabels, setTargetedLabels] = useState<ILabel[]>([]);

View file

@ -1,5 +1,6 @@
.policy-automations {
max-width: 600px;
font-size: $x-small;
&__cta-card {
display: flex;

View file

@ -1,6 +1,6 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import { createCustomRenderer, createMockRouter } from "test/test-utils";
import { http, HttpResponse } from "msw";
import mockServer from "test/mock-server";
import userEvent from "@testing-library/user-event";
@ -42,6 +42,8 @@ const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => {
describe("PolicyForm - component", () => {
const defaultProps = {
router: createMockRouter(),
teamIdForApi: 3,
policyIdForEdit: mockPolicy.id,
showOpenSchemaActionText: false,
storedPolicy: createMockPolicy({ name: "Foo" }),
@ -127,6 +129,8 @@ describe("PolicyForm - component", () => {
render(
<PolicyForm
router={createMockRouter()}
teamIdForApi={3}
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ name: "" })}
@ -190,6 +194,8 @@ describe("PolicyForm - component", () => {
const { user } = render(
<PolicyForm
router={createMockRouter()}
teamIdForApi={3}
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy({ platform: undefined })}
@ -266,6 +272,8 @@ describe("PolicyForm - component", () => {
const { user } = render(
<PolicyForm
router={createMockRouter()}
teamIdForApi={3}
policyIdForEdit={mockPolicy.id}
showOpenSchemaActionText={false}
storedPolicy={createMockPolicy()}
@ -360,7 +368,9 @@ describe("PolicyForm - component", () => {
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
const funButton = screen.getByLabelText("Fun");
const funButton = await screen.findByRole("checkbox", {
name: "Fun",
});
expect(funButton).not.toBeChecked();
await userEvent.click(funButton);
await waitFor(() => {
@ -378,7 +388,11 @@ describe("PolicyForm - component", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", {
name: "Fun",
})
);
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
@ -398,7 +412,11 @@ describe("PolicyForm - component", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", {
name: "Fun",
})
);
// Click "Include any" to open the dropdown.
const includeAnyOption = screen.getByRole("option", {
@ -433,7 +451,11 @@ describe("PolicyForm - component", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", {
name: "Fun",
})
);
await userEvent.click(screen.getByLabelText("All hosts"));
@ -655,53 +677,6 @@ describe("PolicyForm - component", () => {
).toBeInTheDocument();
});
it("shows 'Viewing policy' when existing policy and user has no save permissions", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
currentTeam: createMockTeamSummary(),
isGlobalObserver: true, // no save perms
isGlobalAdmin: false,
isGlobalMaintainer: false,
isTeamMaintainerOrTeamAdmin: false,
isOnGlobalTeam: true,
isPremiumTier: true,
isSandboxMode: false,
isFreeTier: false,
config: createMockConfig(),
},
policy: {
policyTeamId: undefined,
lastEditedQueryId: mockPolicy.id,
lastEditedQueryName: mockPolicy.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(),
},
},
});
render(<PolicyForm {...defaultProps} />);
expect(screen.getByText(/Viewing policy for/i)).toBeInTheDocument();
expect(
screen.getByText(createMockTeamSummary().name)
).toBeInTheDocument();
});
it("shows 'Creating a new policy' when there is no existing policy", () => {
const render = createCustomRenderer({
withBackendMock: true,

View file

@ -1,20 +1,21 @@
/* 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 React, { useState, useContext, useEffect } from "react";
import { useQuery, useQueryClient } from "react-query";
import { Ace } from "ace-builds";
import { useDebouncedCallback } from "use-debounce";
import { size } from "lodash";
import classnames from "classnames";
import { InjectedRouter } from "react-router";
import { addGravatarUrlToResource } from "utilities/helpers";
import { AppContext } from "context/app";
import { NotificationContext } from "context/notification";
import { PolicyContext } from "context/policy";
import usePlatformCompatibility from "hooks/usePlatformCompatibility";
import usePlatformSelector from "hooks/usePlatformSelector";
import PATHS from "router/paths";
import CUSTOM_TARGET_OPTIONS from "pages/policies/helpers";
import { getPathWithQueryParams } from "utilities/url";
import { IPolicy, IPolicyFormData } from "interfaces/policy";
import { CommaSeparatedPlatformString } from "interfaces/platform";
@ -25,22 +26,19 @@ import {
LEARN_MORE_ABOUT_BASE_LINK,
} from "utilities/constants";
import Avatar from "components/Avatar";
import SQLEditor from "components/SQLEditor";
// @ts-ignore
import { validateQuery } from "components/forms/validators/validate_query";
import Button from "components/buttons/Button";
import RevealButton from "components/buttons/RevealButton";
import Checkbox from "components/forms/fields/Checkbox";
import TooltipWrapper from "components/TooltipWrapper";
import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
import PageDescription from "components/PageDescription";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import CustomLink from "components/CustomLink";
import TargetLabelSelector from "components/TargetLabelSelector";
import DataSet from "components/DataSet";
import labelsAPI, {
getCustomLabels,
@ -55,6 +53,8 @@ import PolicyAutomations from "../PolicyAutomations";
const baseClass = "policy-form";
interface IPolicyFormProps {
router: InjectedRouter;
teamIdForApi?: number;
policyIdForEdit: number | null;
showOpenSchemaActionText: boolean;
storedPolicy: IPolicy | undefined;
@ -74,6 +74,7 @@ interface IPolicyFormProps {
onClickAutofillResolution: () => Promise<void>;
resetAiAutofillData: () => void;
currentAutomatedPolicies: number[];
onCancel?: () => void;
}
const validateQuerySQL = (query: string) => {
@ -89,6 +90,8 @@ const validateQuerySQL = (query: string) => {
};
const PolicyForm = ({
router,
teamIdForApi,
policyIdForEdit,
showOpenSchemaActionText,
storedPolicy,
@ -108,15 +111,13 @@ const PolicyForm = ({
onClickAutofillResolution,
resetAiAutofillData,
currentAutomatedPolicies,
onCancel,
}: IPolicyFormProps): JSX.Element => {
const [errors, setErrors] = useState<{ [key: string]: any }>({}); // string | null | undefined or boolean | undefined
const [isSaveNewPolicyModalOpen, setIsSaveNewPolicyModalOpen] = useState(
false
);
const [showQueryEditor, setShowQueryEditor] = useState(false);
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [isEditingResolution, setIsEditingResolution] = useState(false);
const [selectedTargetType, setSelectedTargetType] = useState("All hosts");
const [selectedCustomTarget, setSelectedCustomTarget] = useState(
@ -239,12 +240,39 @@ const PolicyForm = ({
policyIdForEdit = policyIdForEdit || 0;
const isExistingPolicy = !!policyIdForEdit;
const isEditMode = !!policyIdForEdit && !isTeamObserver && !isGlobalObserver;
const isNewTemplatePolicy =
!isExistingPolicy &&
!policyIdForEdit &&
DEFAULT_POLICIES.find((p) => p.name === lastEditedQueryName);
/* - Observer/Observer+ and Technicians cannot edit existing policies
- Team users cannot edit inherited policies
Reroute edit existing policy page (/:policyId/edit) to policy details page (/:policyId) */
useEffect(() => {
const isInheritedPolicy = isEditMode && storedPolicy?.team_id === null;
const noEditPermissions =
isTeamObserver ||
isGlobalObserver ||
isTeamTechnician ||
isGlobalTechnician ||
(!isOnGlobalTeam && isInheritedPolicy); // Team user viewing inherited policy
if (
!isStoredPolicyLoading && // Confirms teamId for storedQuery before RBAC reroute
policyIdForEdit &&
policyIdForEdit > 0 &&
noEditPermissions
) {
router.push(
getPathWithQueryParams(PATHS.POLICY_DETAILS(policyIdForEdit), {
fleet_id: teamIdForApi,
})
);
}
}, [policyIdForEdit, isTeamMaintainerOrTeamAdmin, isStoredPolicyLoading]);
useEffect(() => {
setSelectedTargetType(
!lastEditedQueryLabelsIncludeAny.length &&
@ -286,9 +314,6 @@ const PolicyForm = ({
setCompatiblePlatforms(lastEditedQueryBody);
}, [lastEditedQueryBody, lastEditedQueryId]);
const hasSavePermissions =
isGlobalAdmin || isGlobalMaintainer || isTeamMaintainerOrTeamAdmin;
const onLoad = (editor: Ace.Editor) => {
editor.setOptions({
enableLinking: true,
@ -313,16 +338,6 @@ const PolicyForm = ({
resetAiAutofillData(); // Allows retry of AI autofill API if the SQL has changed
};
const onInputKeypress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key.toLowerCase() === "enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
setIsEditingName(false);
setIsEditingDescription(false);
setIsEditingResolution(false);
}
};
const onAddPatchAutomation = async () => {
if (
!storedPolicy?.patch_software?.software_title_id ||
@ -348,21 +363,21 @@ const PolicyForm = ({
const promptSavePolicy = () => (evt: React.MouseEvent<HTMLButtonElement>) => {
evt.preventDefault();
if (isExistingPolicy && !lastEditedQueryName) {
if (isEditMode && !lastEditedQueryName) {
return setErrors({
...errors,
name: "Policy name must be present",
});
}
if (isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) {
if (isEditMode && !isPatchPolicy && !isAnyPlatformSelected) {
return setErrors({
...errors,
name: "At least one platform must be selected",
});
}
if (isPatchPolicy && isExistingPolicy) {
if (isPatchPolicy && isEditMode) {
// Patch policies: only send editable fields, not query/platform
const payload: IPolicyFormData = {
name: lastEditedQueryName,
@ -373,14 +388,11 @@ const PolicyForm = ({
payload.critical = lastEditedQueryCritical;
}
onUpdate(payload);
setIsEditingName(false);
setIsEditingDescription(false);
setIsEditingResolution(false);
return;
}
let selectedPlatforms = getSelectedPlatforms();
if (selectedPlatforms.length === 0 && !isExistingPolicy && !defaultPolicy) {
if (selectedPlatforms.length === 0 && !isEditMode && !defaultPolicy) {
// If no platforms are selected, default to all compatible platforms
selectedPlatforms = getCompatiblePlatforms();
setSelectedPlatforms(selectedPlatforms);
@ -394,7 +406,7 @@ const PolicyForm = ({
setLastEditedQueryPlatform(newPlatformString);
}
if (!isExistingPolicy) {
if (!isEditMode) {
setIsSaveNewPolicyModalOpen(true);
} else {
const payload: IPolicyFormData = {
@ -423,34 +435,6 @@ const PolicyForm = ({
}
onUpdate(payload);
}
setIsEditingName(false);
setIsEditingDescription(false);
setIsEditingResolution(false);
};
const renderAuthor = (): JSX.Element | null => {
return storedPolicy ? (
<DataSet
className={`${baseClass}__author`}
title="Author"
value={
<>
<Avatar
user={addGravatarUrlToResource({
email: storedPolicy.author_email,
})}
size="xsmall"
/>
<span>
{storedPolicy.author_name === currentUser?.name
? "You"
: storedPolicy.author_name}
</span>
</>
}
/>
) : null;
};
const renderLabelComponent = (): JSX.Element | null => {
@ -476,88 +460,17 @@ const PolicyForm = ({
);
};
const editName = () => {
if (!isEditingName) {
setIsEditingName(true);
}
};
const editDescription = () => {
if (!isEditingDescription) {
setIsEditingDescription(true);
}
};
const editResolution = () => {
if (!isEditingResolution) {
setIsEditingResolution(true);
}
};
const policyNameWrapperBase = `${baseClass}__policy-name-wrapper`;
const policyNameWrapperClasses = classnames(policyNameWrapperBase, {
[`${baseClass}--editing`]: isEditingName,
});
const policyDescriptionWrapperBase = `${baseClass}__policy-description-wrapper`;
const policyDescriptionWrapperClasses = classnames(
policyDescriptionWrapperBase,
{
[`${baseClass}--editing`]: isEditingDescription,
}
);
const policyResolutionWrapperBase = `${baseClass}__policy-resolution-wrapper`;
const policyResolutionWrapperClasses = classnames(
policyResolutionWrapperBase,
{
[`${baseClass}--editing`]: isEditingResolution,
}
);
const renderName = () => {
if (isExistingPolicy) {
if (isEditMode) {
return (
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
renderChildren={(disableChildren) => {
const classes = classnames(policyNameWrapperClasses, {
[`${policyNameWrapperBase}--disabled-by-gitops-mode`]: disableChildren,
});
return (
<div
className={classes}
onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)}
onClick={editName}
>
<AutoSizeInputField
name="policy-name"
placeholder="Add name here"
value={lastEditedQueryName}
hasError={errors && errors.name}
inputClassName={`${baseClass}__policy-name ${
!lastEditedQueryName ? "no-value" : ""
}
`}
maxLength={160}
onChange={setLastEditedQueryName}
onKeyPress={onInputKeypress}
isFocused={isEditingName}
disableTabability={disableChildren}
/>
<Icon
name="pencil"
className={`${baseClass}__edit-icon ${
isEditingName ? `${baseClass}__edit-icon--hide` : ""
}`}
size="small-medium"
color="core-fleet-green"
/>
</div>
);
}}
<InputField
name="policy-name"
label="Name"
placeholder="Add name here"
value={lastEditedQueryName}
error={errors && errors.name}
onChange={(value: string) => setLastEditedQueryName(value)}
disabled={gitOpsModeEnabled}
/>
);
}
@ -572,46 +485,17 @@ const PolicyForm = ({
};
const renderDescription = () => {
if (isExistingPolicy) {
if (isEditMode) {
return (
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
renderChildren={(disableChildren) => {
const classes = classnames(policyDescriptionWrapperClasses, {
[`${policyDescriptionWrapperBase}--disabled-by-gitops-mode`]: disableChildren,
});
return (
<div
className={classes}
onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)}
onClick={editDescription}
>
<AutoSizeInputField
name="policy-description"
placeholder="Add description here."
value={lastEditedQueryDescription}
inputClassName={`${baseClass}__policy-description ${
!lastEditedQueryDescription ? "no-value" : ""
}`}
maxLength={250}
onChange={setLastEditedQueryDescription}
onKeyPress={onInputKeypress}
isFocused={isEditingDescription}
disableTabability={disableChildren}
/>
<Icon
name="pencil"
className={`${baseClass}__edit-icon ${
isEditingDescription ? `${baseClass}__edit-icon--hide` : ""
}`}
size="small-medium"
color="core-fleet-green"
/>
</div>
);
}}
<InputField
name="policy-description"
label="Description"
placeholder="Add description here."
value={lastEditedQueryDescription}
type="textarea"
helpText="How does this policy's failure put the organization at risk?"
onChange={(value: string) => setLastEditedQueryDescription(value)}
disabled={gitOpsModeEnabled}
/>
);
}
@ -620,50 +504,18 @@ const PolicyForm = ({
};
const renderResolution = () => {
if (isExistingPolicy) {
if (isEditMode) {
return (
<div className={`form-field ${baseClass}__policy-resolve`}>
<div className="form-field__label">Resolve</div>
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
renderChildren={(disableChildren) => {
const classes = classnames(policyResolutionWrapperClasses, {
[`${policyResolutionWrapperBase}--disabled-by-gitops-mode`]: disableChildren,
});
return (
<div
className={classes}
onFocus={() => setIsEditingResolution(true)}
onBlur={() => setIsEditingResolution(false)}
onClick={editResolution}
>
<AutoSizeInputField
name="policy-resolution"
placeholder="Add resolution here."
value={lastEditedQueryResolution}
inputClassName={`${baseClass}__policy-resolution ${
!lastEditedQueryResolution ? "no-value" : ""
}`}
maxLength={500}
onChange={setLastEditedQueryResolution}
onKeyPress={onInputKeypress}
isFocused={isEditingResolution}
disableTabability={disableChildren}
/>
<Icon
name="pencil"
className={`${baseClass}__edit-icon ${
isEditingResolution ? `${baseClass}__edit-icon--hide` : ""
}`}
size="small-medium"
color="core-fleet-green"
/>
</div>
);
}}
/>
</div>
<InputField
name="policy-resolution"
label="Resolution"
placeholder="Add resolution here."
value={lastEditedQueryResolution}
type="textarea"
helpText="If this policy fails, what should the end user expect?"
onChange={(value: string) => setLastEditedQueryResolution(value)}
disabled={gitOpsModeEnabled}
/>
);
}
@ -672,7 +524,7 @@ const PolicyForm = ({
const renderPlatformCompatibility = () => {
if (
isExistingPolicy &&
isEditMode &&
(isStoredPolicyLoading || policyIdForEdit !== lastEditedQueryId)
) {
return null;
@ -711,99 +563,22 @@ const PolicyForm = ({
const renderPolicyFleetName = () => {
if (isFreeTier || !currentTeam?.name) return null;
if (isExistingPolicy) {
return hasSavePermissions ? (
<p>
Editing policy for <strong>{currentTeam?.name}</strong>.
</p>
) : (
<p>
Viewing policy for <strong>{currentTeam?.name}</strong>.
</p>
);
}
return (
return isEditMode ? (
<p>
Editing policy for <strong>{currentTeam?.name}</strong>.
</p>
) : (
<p>
Creating a new policy for <strong>{currentTeam?.name}</strong>.
</p>
);
};
// Non-editable form used for:
// - Team observers and team observer+ viewing any of their team's policies and any inherited policies
// - Team admins and team maintainers viewing any inherited policy
// - Global observers and global observer+ viewing any team's policies and any inherited policies
// - Global technicians and team technicians viewing any team's policies and any inherited policies
const renderNonEditableForm = (
<form className={`${baseClass}__wrapper`}>
<div className={`${baseClass}__title-bar`}>
<h1
className={`${baseClass}__policy-name ${baseClass}__policy-name--no-hover`}
>
{lastEditedQueryName}
</h1>
{renderAuthor()}
</div>
{renderPolicyFleetName()}
{lastEditedQueryDescription && (
<PageDescription
className={`${baseClass}__policy-description no-hover`}
content={lastEditedQueryDescription}
/>
)}
{lastEditedQueryResolution && (
<>
<p className="resolve-title">
<strong>Resolve:</strong>
</p>
<p className={`${baseClass}__policy-resolution no-hover`}>
{lastEditedQueryResolution}
</p>
</>
)}
<RevealButton
isShowing={showQueryEditor}
className={baseClass}
hideText="Hide SQL"
showText="Show SQL"
onClick={() => setShowQueryEditor(!showQueryEditor)}
/>
{showQueryEditor && (
<SQLEditor
value={lastEditedQueryBody}
name="query editor"
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
wrapEnabled
readOnly
/>
)}
{renderLiveQueryWarning()}
{(isObserverPlus ||
isTeamMaintainerOrTeamAdmin ||
isGlobalTechnician ||
isTeamTechnician) && ( // Team admin, team maintainer, any Observer+ and any Technician can run a policy
<div className="button-wrap">
<Button
className={`${baseClass}__run`}
onClick={goToSelectTargets}
disabled={isExistingPolicy && !isAnyPlatformSelected}
>
Run
</Button>
</div>
)}
</form>
);
// Editable form is used for:
// Global admins and global maintainers
// Team admins and team maintainers viewing any of their team's policies
const renderEditablePolicyForm = () => {
const renderPolicyForm = () => {
// Save disabled for no platforms selected, query name blank on existing query, or sql errors
const disableSaveFormErrors =
isAddingAutomation ||
(isExistingPolicy && !isPatchPolicy && !isAnyPlatformSelected) ||
(isEditMode && !isPatchPolicy && !isAnyPlatformSelected) ||
(lastEditedQueryName === "" && !!lastEditedQueryId) ||
(selectedTargetType === "Custom" &&
!Object.entries(selectedLabels).some(([, value]) => {
@ -814,13 +589,57 @@ const PolicyForm = ({
return (
<>
<form className={`${baseClass}__wrapper`} autoComplete="off">
<div className={`${baseClass}__title-bar`}>
<div className={`${baseClass}__policy-name`}>{renderName()}</div>
{isExistingPolicy && renderAuthor()}
</div>
{renderPolicyFleetName()}
{isEditMode ? (
<div className={`${baseClass}__page-header`}>
<h1 className={`${baseClass}__page-title`}>Edit policy</h1>
{renderPolicyFleetName()}
</div>
) : (
<div className={`${baseClass}__title-bar`}>
<div className={`${baseClass}__policy-name-fleet-name`}>
{renderName()}
{renderPolicyFleetName()}
</div>
</div>
)}
{isEditMode && renderName()}
{renderDescription()}
{renderResolution()}
{isEditMode && !isPatchPolicy && platformSelector.render()}
{isEditMode && isPremiumTier && !isPatchPolicy && (
<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>
}
disableOptions={gitOpsModeEnabled}
suppressTitle
/>
)}
{isEditMode && storedPolicy && (
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
gitOpsModeEnabled={!!gitOpsModeEnabled}
/>
)}
{isEditMode &&
isPremiumTier &&
!isPatchPolicy &&
renderCriticalPolicy()}
<SQLEditor
value={lastEditedQueryBody}
error={errors.query}
@ -846,75 +665,47 @@ const PolicyForm = ({
onChange={onChangePolicySql}
handleSubmit={promptSavePolicy}
wrapEnabled
focus={!isExistingPolicy}
focus={!isEditMode}
readOnly={isPatchPolicy}
/>
{renderPlatformCompatibility()}
{isExistingPolicy && !isPatchPolicy && platformSelector.render()}
{isExistingPolicy && isPremiumTier && !isPatchPolicy && (
<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
/>
)}
{isExistingPolicy && storedPolicy && (
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
gitOpsModeEnabled={!!gitOpsModeEnabled}
/>
)}
{isExistingPolicy && isPremiumTier && renderCriticalPolicy()}
{renderLiveQueryWarning()}
<div className="button-wrap">
{hasSavePermissions && (
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<TooltipWrapper
tipContent={
<>
Select the platforms this
<br />
policy will be checked on
<br />
to save or run the policy.
</>
}
tooltipClass={`${baseClass}__button-wrap--tooltip`}
position="top"
disableTooltip={!isExistingPolicy || isAnyPlatformSelected}
underline={false}
>
<span className={`${baseClass}__button-wrap--tooltip`}>
<Button
onClick={promptSavePolicy()}
disabled={disableSaveFormErrors || disableChildren}
className="save-loading"
isLoading={isUpdatingPolicy}
>
Save
</Button>
</span>
</TooltipWrapper>
)}
/>
{isEditMode && onCancel && (
<Button variant="inverse" onClick={onCancel}>
Cancel
</Button>
)}
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<TooltipWrapper
tipContent={
<>
Select the platforms this
<br />
policy will be checked on
<br />
to save or run the policy.
</>
}
tooltipClass={`${baseClass}__button-wrap--tooltip`}
position="top"
disableTooltip={!isEditMode || isAnyPlatformSelected}
underline={false}
>
<span className={`${baseClass}__button-wrap--tooltip`}>
<Button
onClick={promptSavePolicy()}
disabled={disableSaveFormErrors || disableChildren}
className="save-loading"
isLoading={isUpdatingPolicy}
>
Save
</Button>
</span>
</TooltipWrapper>
)}
/>
<TooltipWrapper
tipContent={
disabledLiveQuery ? (
@ -931,8 +722,7 @@ const PolicyForm = ({
)
}
disableTooltip={
(!isExistingPolicy || isAnyPlatformSelected) &&
!disabledLiveQuery
(!isEditMode || isAnyPlatformSelected) && !disabledLiveQuery
}
underline={false}
showArrow
@ -943,7 +733,7 @@ const PolicyForm = ({
onClick={goToSelectTargets}
disabled={
isAddingAutomation ||
(isExistingPolicy && !isAnyPlatformSelected) ||
(isEditMode && !isAnyPlatformSelected) ||
disabledLiveQuery
}
variant="inverse"
@ -979,22 +769,7 @@ const PolicyForm = ({
return <Spinner />;
}
const isInheritedPolicy = isExistingPolicy && storedPolicy?.team_id === null;
const noEditPermissions =
isTeamObserver ||
isGlobalObserver ||
isTeamTechnician ||
isGlobalTechnician ||
(!isOnGlobalTeam && isInheritedPolicy); // Team user viewing inherited policy
// Render non-editable form only
if (noEditPermissions) {
return renderNonEditableForm;
}
// Render default editable form
return renderEditablePolicyForm();
return renderPolicyForm();
};
export default PolicyForm;

View file

@ -17,6 +17,24 @@
}
}
&__page-header {
display: flex;
flex-direction: column;
gap: $pad-small;
}
&__page-title {
font-size: $large;
font-weight: $bold;
margin: 0;
}
&__page-subtitle {
font-size: $x-small;
color: $ui-fleet-black-75;
margin: 0;
}
&__title-bar {
display: flex;
justify-content: space-between;
@ -34,10 +52,8 @@
}
}
.input-field,
.input-field__text-area {
min-height: auto;
white-space: normal;
.input-field__textarea {
min-width: 100%;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@ -56,7 +72,7 @@
height: 18px;
}
&__policy-name,
&__policy-name-fleet-name,
&__description,
&__resolution {
.button--text-icon {
@ -65,90 +81,31 @@
}
}
&__policy-name-wrapper,
&__policy-description-wrapper,
&__policy-resolution-wrapper {
&__policy-name-fleet-name {
display: flex;
align-items: flex-start;
flex-direction: column;
gap: $pad-small;
width: fit-content;
&:not(&--editing) {
&:hover {
cursor: pointer;
* {
color: $core-fleet-green;
cursor: pointer;
}
}
}
&--disabled-by-gitops-mode {
@include disabled;
}
}
&__policy-name-wrapper {
line-height: $line-height-large;
.no-value {
min-width: 112px;
}
}
&__edit-icon {
opacity: 1;
transition: opacity 0.2s;
margin-left: 0;
/** Designed to be aligned with first line of text, not center of multiple
lines of text, without this, the icon will vertically center on multiline */
align-self: initial;
height: 42px; // 24px font-size * 1.75 line height
align-items: center; // Centers the icon in the height area
&--hide {
opacity: 0;
}
}
&__policy-description-wrapper,
&__policy-resolution-wrapper {
.no-value {
min-width: 106px;
}
.policy-form__edit-icon {
height: 21px; // 14px font-size * 1.5 line height
}
}
&__policy-name,
&__policy-description,
&__policy-resolution {
width: 100%;
margin: 0;
padding: 0;
border: 0;
resize: none;
white-space: normal;
background-color: transparent;
overflow: hidden;
font-size: $x-small;
}
&__policy-name {
font-size: $large;
&.input-field--error {
border: 1px solid $core-vibrant-red;
}
}
&__autofill-label {
display: flex;
justify-content: space-between;
align-items: center;
.autofill-tooltip-wrapper {
display: flex; // Required for vertical centering
}
.autofill-button-tooltip {
font-weight: $regular;
}
}
&__button-wrap {
&--tooltip {
display: flex;
}
.policy-form__run {
min-width: 64px;
}

View file

@ -125,7 +125,9 @@ describe("SaveNewPolicyModal", () => {
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
const funButton = screen.getByLabelText("Fun");
const funButton = await screen.findByRole("checkbox", {
name: "Fun",
});
expect(funButton).not.toBeChecked();
await userEvent.click(funButton);
expect(saveButton).toBeEnabled();
@ -149,7 +151,9 @@ describe("SaveNewPolicyModal", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", { name: "Fun" })
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onCreatePolicy.mock.calls[0][0].labels_include_any).toEqual([
@ -175,7 +179,9 @@ describe("SaveNewPolicyModal", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", { name: "Fun" })
);
// Click "Include any" to open the dropdown.
const includeAnyOption = screen.getByRole("option", {

View file

@ -259,12 +259,22 @@ const QueryEditor = ({
);
};
const backPath = policyIdForEdit
? getPathWithQueryParams(PATHS.POLICY_DETAILS(policyIdForEdit), {
team_id: teamIdForApi,
})
: backToPoliciesPath();
const backText = policyIdForEdit ? "Back to policy" : "Back to policies";
return (
<div className={`${baseClass}__form`}>
<div className={`${baseClass}__header-links`}>
<BackButton text="Back to policies" path={backToPoliciesPath()} />
<BackButton text={backText} path={backPath} />
</div>
<PolicyForm
router={router}
teamIdForApi={teamIdForApi}
onCreatePolicy={onCreatePolicy}
goToSelectTargets={goToSelectTargets}
onOsqueryTableSelect={onOsqueryTableSelect}
@ -284,6 +294,17 @@ const QueryEditor = ({
onClickAutofillResolution={onClickAutofillResolution}
resetAiAutofillData={() => setPolicyAutofillData(null)}
currentAutomatedPolicies={currentAutomatedPolicies || []}
onCancel={
policyIdForEdit
? () =>
router.push(
getPathWithQueryParams(
PATHS.POLICY_DETAILS(policyIdForEdit),
{ fleet_id: teamIdForApi }
)
)
: undefined
}
/>
</div>
);

View file

@ -429,8 +429,10 @@ describe("EditQueryForm - component", () => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeChecked();
expect(screen.getByLabelText("Fun")).toBeChecked();
expect(screen.getByLabelText("Fresh")).not.toBeChecked();
expect(screen.getByRole("checkbox", { name: "Fun" })).toBeChecked();
expect(
screen.getByRole("checkbox", { name: "Fresh" })
).not.toBeChecked();
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
});
@ -449,9 +451,11 @@ describe("EditQueryForm - component", () => {
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeChecked();
funButton = screen.getByLabelText("Fun");
funButton = screen.getByRole("checkbox", { name: "Fun" });
expect(funButton).toBeChecked();
expect(screen.getByLabelText("Fresh")).not.toBeChecked();
expect(
screen.getByRole("checkbox", { name: "Fresh" })
).not.toBeChecked();
saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeEnabled();
});

View file

@ -2,7 +2,6 @@ import React, {
useState,
useContext,
useEffect,
KeyboardEvent,
useCallback,
useMemo,
} from "react";
@ -11,7 +10,6 @@ import { Location } from "history";
import { useQuery } from "react-query";
import { size } from "lodash";
import classnames from "classnames";
import { useDebouncedCallback } from "use-debounce";
import { Ace } from "ace-builds";
@ -65,7 +63,8 @@ import Slider from "components/forms/fields/Slider";
import TooltipWrapper from "components/TooltipWrapper";
import Spinner from "components/Spinner";
import Icon from "components/Icon/Icon";
import AutoSizeInputField from "components/forms/fields/AutoSizeInputField";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import LogDestinationIndicator from "components/LogDestinationIndicator";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import TargetLabelSelector from "components/TargetLabelSelector";
@ -192,6 +191,7 @@ const EditQueryForm = ({
config,
isPremiumTier,
isFreeTier,
currentTeam,
} = useContext(AppContext);
const isExistingQuery = !!queryIdForEdit;
@ -207,8 +207,6 @@ const EditQueryForm = ({
const [showQueryEditor, setShowQueryEditor] = useState(
isObserverPlus || isAnyTeamObserverPlus || false
);
const [isEditingName, setIsEditingName] = useState(false);
const [isEditingDescription, setIsEditingDescription] = useState(false);
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const [queryWasChanged, setQueryWasChanged] = useState(false);
const [selectedTargetType, setSelectedTargetType] = useState("");
@ -327,14 +325,6 @@ const EditQueryForm = ({
setLastEditedQueryBody(sqlString);
};
const onInputKeypress = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key.toLowerCase() === "enter" && !event.shiftKey) {
event.preventDefault();
event.currentTarget.blur();
setIsEditingName(false);
setIsEditingDescription(false);
}
};
const frequencyOptions = useMemo(
() =>
getCustomDropdownOptions(
@ -413,30 +403,6 @@ const EditQueryForm = ({
}
};
const renderAuthor = (): JSX.Element | null => {
return storedQuery ? (
<DataSet
className={`${baseClass}__author`}
title="Author"
value={
<>
<Avatar
user={addGravatarUrlToResource({
email: storedQuery.author_email,
})}
size="xsmall"
/>
<span>
{storedQuery.author_name === currentUser?.name
? "You"
: storedQuery.author_name}
</span>
</>
}
/>
) : null;
};
const renderLabelComponent = (): JSX.Element | null => {
if (!showOpenSchemaActionText) {
return null;
@ -460,70 +426,20 @@ const EditQueryForm = ({
return platformCompatibility.render();
};
const editName = () => {
if (!isEditingName) {
setIsEditingName(true);
}
};
const queryNameWrapperClass = `${baseClass}__query-name-wrapper`;
const queryNameWrapperClasses = classnames(queryNameWrapperClass, {
[`${baseClass}--editing`]: isEditingName,
});
const queryDescriptionWrapperClass = `${baseClass}__query-description-wrapper`;
const queryDescriptionWrapperClasses = classnames(
queryDescriptionWrapperClass,
{
[`${baseClass}--editing`]: isEditingDescription,
}
);
const renderName = () => {
if (isExistingQuery) {
return (
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
renderChildren={(disableChildren) => {
const classes = classnames(queryNameWrapperClasses, {
[`${queryNameWrapperClass}--disabled-by-gitops-mode`]: disableChildren,
});
return (
<div
className={classes}
onFocus={() => setIsEditingName(true)}
onBlur={() => setIsEditingName(false)}
onClick={editName}
>
<AutoSizeInputField
name="query-name"
placeholder="Add name"
value={lastEditedQueryName}
inputClassName={`${baseClass}__query-name ${
!lastEditedQueryName ? "no-value" : ""
}`}
maxLength={160}
hasError={errors && errors.name}
onChange={setLastEditedQueryName}
onBlur={() => {
setLastEditedQueryName(lastEditedQueryName.trim());
}}
onKeyPress={onInputKeypress}
isFocused={isEditingName}
disableTabability={disableChildren}
/>
<Icon
name="pencil"
className={`${baseClass}__edit-icon ${
isEditingName ? `${baseClass}__edit-icon--hide` : ""
}`}
size="small-medium"
color="core-fleet-green"
/>
</div>
);
<InputField
name="query-name"
label="Name"
placeholder="Add name"
value={lastEditedQueryName}
error={errors && errors.name}
onChange={(value: string) => setLastEditedQueryName(value)}
onBlur={() => {
setLastEditedQueryName(lastEditedQueryName.trim());
}}
disabled={gitOpsModeEnabled}
/>
);
}
@ -531,53 +447,18 @@ const EditQueryForm = ({
return <h1 className={`${baseClass}__query-name no-hover`}>New report</h1>;
};
const editDescription = () => {
if (!isEditingDescription) {
setIsEditingDescription(true);
}
};
const renderDescription = () => {
if (isExistingQuery) {
return (
<GitOpsModeTooltipWrapper
position="right"
tipOffset={16}
renderChildren={(disableChildren) => {
const classes = classnames(queryDescriptionWrapperClasses, {
[`${queryDescriptionWrapperClass}--disabled-by-gitops-mode`]: disableChildren,
});
return (
<div
className={classes}
onFocus={() => setIsEditingDescription(true)}
onBlur={() => setIsEditingDescription(false)}
onClick={editDescription}
>
<AutoSizeInputField
name="query-description"
placeholder="Add description"
value={lastEditedQueryDescription}
maxLength={250}
inputClassName={`${baseClass}__query-description ${
!lastEditedQueryDescription ? "no-value" : ""
}`}
onChange={setLastEditedQueryDescription}
onKeyPress={onInputKeypress}
isFocused={isEditingDescription}
disableTabability={disableChildren}
/>
<Icon
name="pencil"
className={`${baseClass}__edit-icon ${
isEditingDescription ? `${baseClass}__edit-icon--hide` : ""
}`}
size="small-medium"
color="core-fleet-green"
/>
</div>
);
}}
<InputField
name="query-description"
label="Description"
placeholder="Add description"
value={lastEditedQueryDescription}
type="textarea"
helpText="What information does your report reveal? (Optional)"
onChange={(value: string) => setLastEditedQueryDescription(value)}
disabled={gitOpsModeEnabled}
/>
);
}
@ -616,12 +497,9 @@ const EditQueryForm = ({
// Observers and observer+ of existing query
const renderNonEditableForm = (
<form className={`${baseClass}`}>
<div className={`${baseClass}__title-bar`}>
<h1 className={`${baseClass}__query-name no-hover`}>
{lastEditedQueryName}
</h1>
{renderAuthor()}
</div>
<h1 className={`${baseClass}__query-name no-hover`}>
{lastEditedQueryName}
</h1>
{renderQueryTeam()}
<PageDescription
className={`${baseClass}__query-description no-hover`}
@ -657,6 +535,7 @@ const EditQueryForm = ({
isAnyTeamObserverPlus) && (
<div className={`button-wrap ${baseClass}__button-wrap--new-query`}>
<TooltipWrapper
className="live-query-button-tooltip"
tipContent="Live reports are disabled in organization settings"
disableTooltip={!disabledLiveQuery}
position="top"
@ -740,28 +619,19 @@ const EditQueryForm = ({
return (
<>
<form className={baseClass} autoComplete="off">
<div className={`${baseClass}__title-bar`}>
{renderName()}
{isExistingQuery && renderAuthor()}
</div>
{renderQueryTeam()}
{isExistingQuery ? (
<div className={`${baseClass}__page-header`}>
<h1 className={`${baseClass}__page-title`}>Edit report</h1>
{renderQueryTeam()}
</div>
) : (
<div className={`${baseClass}__query-name-fleet-name`}>
{renderName()}
{renderQueryTeam()}
</div>
)}
{isExistingQuery && renderName()}
{renderDescription()}
<SQLEditor
value={lastEditedQueryBody}
error={errors.query}
label="Query"
labelActionComponent={renderLabelComponent()}
name="query editor"
onLoad={onLoad}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
onChange={onChangeQuery}
handleSubmit={
confirmChanges ? toggleConfirmSaveChangesModal : handleSaveQuery
}
wrapEnabled
focus={!isExistingQuery}
/>
{renderPlatformCompatibility()}
{isExistingQuery && (
<div
@ -858,6 +728,26 @@ const EditQueryForm = ({
suppressTitle
/>
)}
</div>
)}
<SQLEditor
value={lastEditedQueryBody}
error={errors.query}
label="Query"
labelActionComponent={renderLabelComponent()}
name="query editor"
onLoad={onLoad}
wrapperClassName={`${baseClass}__text-editor-wrapper form-field`}
onChange={onChangeQuery}
handleSubmit={
confirmChanges ? toggleConfirmSaveChangesModal : handleSaveQuery
}
wrapEnabled
focus={!isExistingQuery}
/>
{renderPlatformCompatibility()}
{isExistingQuery && (
<>
<RevealButton
isShowing={showAdvancedOptions}
className="advanced-options-toggle"
@ -894,7 +784,7 @@ const EditQueryForm = ({
)}
</>
)}
</div>
</>
)}
{renderLiveQueryWarning()}
<div className={`button-wrap ${baseClass}__button-wrap--new-query`}>
@ -935,6 +825,7 @@ const EditQueryForm = ({
</>
)}
<TooltipWrapper
className="live-query-button-tooltip"
tipContent="Live reports are disabled in organization settings"
disableTooltip={!disabledLiveQuery}
position="top"

View file

@ -6,20 +6,37 @@
margin: 0;
}
&__title-bar {
&__page-header {
display: flex;
justify-content: space-between;
gap: 1.5rem;
flex-direction: column;
gap: $pad-small;
}
&__page-title {
font-size: $large;
font-weight: $bold;
margin: 0;
}
&__page-subtitle {
font-size: $x-small;
color: $ui-fleet-black-75;
margin: 0;
}
&__query-name-fleet-name {
display: flex;
flex-direction: column;
gap: $pad-small;
}
.form-field {
margin-bottom: 0px;
}
.input-field,
.input-field__text-area {
min-height: auto;
line-height: normal;
white-space: normal;
.input-field__textarea {
min-width: 100%;
resize: vertical;
}
/* Hide scrollbar for Chrome, Safari and Opera */
@ -33,57 +50,6 @@
scrollbar-width: none; /* Firefox */
}
&__query-name-wrapper,
&__query-description-wrapper {
display: flex;
align-items: baseline;
gap: 0.5rem;
width: fit-content;
&:not(.edit-query-form--editing) {
&:hover {
cursor: pointer;
* {
color: $core-fleet-green;
cursor: pointer;
}
}
}
&--disabled-by-gitops-mode {
@include disabled;
}
}
&__edit-icon {
opacity: 1;
transition: opacity 0.2s;
margin-left: 0;
/** Designed to be aligned with first line of text, not center of multiple
lines of text, without this, the icon will vertically center on multiline */
align-self: initial;
height: 42px; // 24px font-size * 1.75 line height
align-items: center; // Centers the icon in the height area
&--hide {
opacity: 0;
}
}
&__query-name-wrapper {
.no-value {
min-width: 168px;
}
}
&__query-description-wrapper {
.no-value {
min-width: 144px;
}
.edit-query-form__edit-icon {
height: 21px; // 14px font-size * 1.5 line height
}
}
// Author section
&__author {
align-items: flex-end;
@ -96,49 +62,6 @@
}
}
&__query-name,
&__query-description {
width: 100%;
margin: 0;
padding: 0;
border: 0;
resize: none;
white-space: normal;
background-color: transparent;
overflow: hidden;
&.focus-visible {
outline: 0;
}
}
// Future iteration: move size="large" into AutoSizeInputField isntead of styling here
&__query-name-wrapper {
color: $core-fleet-black;
font-size: $large;
line-height: $line-height-large;
.no-value {
min-width: 168px;
}
.edit-query-form__query-name,
.input-sizer::after {
font-size: $large;
line-height: $line-height-large;
}
.component__auto-size-input-field {
font-size: $large;
line-height: $line-height-large;
}
&.input-field--error {
border: 1px solid $core-vibrant-red;
}
}
&__query-description {
color: $ui-fleet-black-75;
}
&__button-wrap {
&--new-query {
display: flex;

View file

@ -261,7 +261,9 @@ describe("SaveNewQueryModal", () => {
const saveButton = screen.getByRole("button", { name: "Save" });
expect(saveButton).toBeDisabled();
const funButton = screen.getByLabelText("Fun");
const funButton = await screen.findByRole("checkbox", {
name: "Fun",
});
expect(funButton).not.toBeChecked();
await userEvent.click(funButton);
expect(saveButton).toBeEnabled();
@ -280,7 +282,9 @@ describe("SaveNewQueryModal", () => {
// Set a label.
await userEvent.click(screen.getByLabelText("Custom"));
await userEvent.click(screen.getByLabelText("Fun"));
await userEvent.click(
await screen.findByRole("checkbox", { name: "Fun" })
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(saveQuery.mock.calls[0][0].labels_include_any).toEqual(["Fun"]);

View file

@ -42,6 +42,7 @@ import ManagePacksPage from "pages/packs/ManagePacksPage";
import ManagePoliciesPage from "pages/policies/ManagePoliciesPage";
import NoAccessPage from "pages/NoAccessPage";
import PackComposerPage from "pages/packs/PackComposerPage";
import PolicyDetailsPage from "pages/policies/PolicyDetailsPage";
import PolicyPage from "pages/policies/PolicyPage";
import QueryDetailsPage from "pages/queries/details/QueryDetailsPage";
import LiveQueryPage from "pages/queries/live/LiveQueryPage";
@ -411,7 +412,10 @@ const routes = (
<Route component={AuthAnyMaintainerAnyAdminRoutes}>
<Route path="new" component={PolicyPage} />
</Route>
<Route path=":id" component={PolicyPage} />
<Route path=":id">
<IndexRoute component={PolicyDetailsPage} />
<Route path="edit" component={PolicyPage} />
</Route>
</Route>
<Redirect from="profile" to="account" /> {/* deprecated URL */}
<Route path="account" component={AccountPage} />

View file

@ -124,8 +124,10 @@ export default {
`${URL_PREFIX}/reports/${queryId || "new"}/live`,
REPORT_DETAILS: (queryId: number): string =>
`${URL_PREFIX}/reports/${queryId}`,
EDIT_POLICY: (policyId: number): string =>
POLICY_DETAILS: (policyId: number): string =>
`${URL_PREFIX}/policies/${policyId}`,
EDIT_POLICY: (policyId: number): string =>
`${URL_PREFIX}/policies/${policyId}/edit`,
FORGOT_PASSWORD: `${URL_PREFIX}/login/forgot`,
MFA: `${URL_PREFIX}/login/mfa`,
NO_ACCESS: `${URL_PREFIX}/login/denied`,