Fleet UI: Allow users with edit access to add automation from policy details page (#45239)

This commit is contained in:
RachelElysia
2026-05-12 14:07:46 -04:00
committed by GitHub
parent fc907c4634
commit d7c2c93182
4 changed files with 222 additions and 7 deletions
@@ -1,10 +1,10 @@
import React, { useContext, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { useQuery, useQueryClient } 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 { NotificationContext } from "context/notification";
import { PolicyContext } from "context/policy";
import { IPolicy, IStoredPolicyResponse } from "interfaces/policy";
import { ILabelPolicy } from "interfaces/label";
@@ -16,6 +16,7 @@ import {
} from "interfaces/team";
import { PLATFORM_DISPLAY_NAMES, Platform } from "interfaces/platform";
import policiesAPI from "services/entities/policies";
import teamPoliciesAPI from "services/entities/team_policies";
import teamsAPI, { ILoadTeamResponse } from "services/entities/teams";
import { addGravatarUrlToResource } from "utilities/helpers";
import { DOCUMENT_TITLE_SUFFIX } from "utilities/constants";
@@ -63,6 +64,7 @@ const PolicyDetailsPage = ({
}: IPolicyDetailsPageProps): JSX.Element => {
const policyId = paramsPolicyId ? parseInt(paramsPolicyId, 10) : null;
const handlePageError = useErrorHandler();
const queryClient = useQueryClient();
const {
currentUser,
@@ -112,7 +114,10 @@ const PolicyDetailsPage = ({
},
});
const { renderFlash } = useContext(NotificationContext);
const [showQueryModal, setShowQueryModal] = useState(false);
const [isAddingAutomation, setIsAddingAutomation] = useState(false);
if (policyId === null || isNaN(policyId)) {
router.push(PATHS.MANAGE_POLICIES);
@@ -211,6 +216,28 @@ const PolicyDetailsPage = ({
const disabledLiveQuery = config?.server_settings.live_query_disabled;
const onAddPatchAutomation = async () => {
if (
!storedPolicy?.patch_software?.software_title_id ||
storedPolicy?.team_id == null
) {
return;
}
setIsAddingAutomation(true);
try {
await teamPoliciesAPI.update(policyId as number, {
team_id: storedPolicy.team_id,
software_title_id: storedPolicy.patch_software.software_title_id,
});
queryClient.invalidateQueries(["policy", policyId]);
renderFlash("success", "Automation added.");
} catch {
renderFlash("error", "Couldn't set automation. Please try again.");
} finally {
setIsAddingAutomation(false);
}
};
const backToPoliciesPath = getPathWithQueryParams(PATHS.MANAGE_POLICIES, {
fleet_id: teamIdForApi,
});
@@ -412,8 +439,9 @@ const PolicyDetailsPage = ({
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
onAddAutomation={noop}
isAddingAutomation={false}
canEditPolicy={canEditPolicy}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
/>
)}
</>
@@ -0,0 +1,180 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AppContext, initialState } from "context/app";
import { IPolicy } from "interfaces/policy";
import createMockConfig from "__mocks__/configMock";
import PolicyAutomations from "./PolicyAutomations";
// Stub SoftwareIcon to avoid asset resolution in tests
jest.mock("pages/SoftwarePage/components/icons/SoftwareIcon", () => {
return () => <span data-testid="software-icon" />;
});
const createMockPatchPolicy = (overrides?: Partial<IPolicy>): IPolicy => ({
id: 10,
name: "macOS - Zoom up to date",
query: "SELECT 1;",
description: "Checks Zoom is up to date",
author_id: 1,
author_name: "Admin",
author_email: "admin@example.com",
resolution: "Install the latest version from self-service.",
platform: "darwin",
team_id: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
critical: false,
calendar_events_enabled: false,
conditional_access_enabled: false,
type: "patch",
patch_software: {
name: "Zoom",
display_name: "Zoom",
software_title_id: 42,
},
...overrides,
});
// Wrap with AppContext so GitOpsModeTooltipWrapper's useGitOpsMode hook works
const renderWithAppContext = (ui: React.ReactElement) => {
return render(
<AppContext.Provider
value={{ ...initialState, config: createMockConfig() }}
>
{ui}
</AppContext.Provider>
);
};
const defaultProps = {
onAddAutomation: jest.fn(),
currentAutomatedPolicies: [] as number[],
};
describe("PolicyAutomations", () => {
describe("CTA card (patch policy with patch_software, no install_software)", () => {
it("shows the CTA card and Add automation button when canEditPolicy is true", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy()}
canEditPolicy
{...defaultProps}
/>
);
expect(screen.getByText(/Automatically patch Zoom/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Add automation/ })
).toBeInTheDocument();
});
it("calls onAddAutomation when the button is clicked", async () => {
const user = userEvent.setup();
const onAddAutomation = jest.fn();
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy()}
currentAutomatedPolicies={[]}
canEditPolicy
onAddAutomation={onAddAutomation}
/>
);
await user.click(screen.getByRole("button", { name: /Add automation/ }));
expect(onAddAutomation).toHaveBeenCalledTimes(1);
});
it("does NOT show the CTA card when canEditPolicy is false", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy()}
canEditPolicy={false}
{...defaultProps}
/>
);
expect(
screen.queryByText(/Automatically patch Zoom/)
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Add automation/ })
).not.toBeInTheDocument();
});
it("shows 'Adding...' text when isAddingAutomation is true", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy()}
canEditPolicy
{...defaultProps}
isAddingAutomation
/>
);
expect(screen.getByText("Adding...")).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /Add automation/ })
).not.toBeInTheDocument();
});
});
describe("CTA card is hidden when conditions are not met", () => {
it("hides the CTA card for a dynamic (non-patch) policy", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy({ type: "dynamic" })}
canEditPolicy
{...defaultProps}
/>
);
expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
});
it("hides the CTA card when patch_software is not set", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy({ patch_software: undefined })}
canEditPolicy
{...defaultProps}
/>
);
expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
});
it("shows the CTA card for a no-team policy (team_id === 0)", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy({ team_id: 0 })}
canEditPolicy
{...defaultProps}
/>
);
expect(screen.getByText(/Automatically patch Zoom/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Add automation/ })
).toBeInTheDocument();
});
it("hides the CTA card when install_software is already set", () => {
renderWithAppContext(
<PolicyAutomations
storedPolicy={createMockPatchPolicy({
install_software: {
name: "Zoom",
software_title_id: 42,
},
})}
canEditPolicy
{...defaultProps}
/>
);
expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
});
});
});
@@ -17,8 +17,10 @@ const baseClass = "policy-automations";
interface IPolicyAutomationsProps {
storedPolicy: IPolicy;
currentAutomatedPolicies: number[];
/** Some users only have access to read-only view */
canEditPolicy: boolean;
onAddAutomation: () => void;
isAddingAutomation: boolean;
isAddingAutomation?: boolean;
}
interface IAutomationRow {
@@ -34,6 +36,7 @@ interface IAutomationRow {
const PolicyAutomations = ({
storedPolicy,
currentAutomatedPolicies,
canEditPolicy,
onAddAutomation,
isAddingAutomation,
}: IPolicyAutomationsProps): JSX.Element => {
@@ -41,7 +44,10 @@ const PolicyAutomations = ({
const hasPatchSoftware = !!storedPolicy.patch_software;
const hasSoftwareAutomation = !!storedPolicy.install_software;
const showCtaCard =
isPatchPolicy && hasPatchSoftware && !hasSoftwareAutomation;
isPatchPolicy &&
hasPatchSoftware &&
!hasSoftwareAutomation &&
canEditPolicy;
const automationRows: IAutomationRow[] = [];
@@ -378,7 +378,7 @@ const PolicyForm = ({
const onAddPatchAutomation = async () => {
if (
!storedPolicy?.patch_software?.software_title_id ||
!storedPolicy?.team_id
storedPolicy?.team_id == null
) {
return;
}
@@ -693,6 +693,7 @@ const PolicyForm = ({
<PolicyAutomations
storedPolicy={storedPolicy}
currentAutomatedPolicies={currentAutomatedPolicies}
canEditPolicy={isEditMode}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
/>