From d7c2c93182a8d26229c1cc350a78879b482b108e Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Tue, 12 May 2026 14:07:46 -0400
Subject: [PATCH] Fleet UI: Allow users with edit access to add automation from
policy details page (#45239)
---
.../PolicyDetailsPage/PolicyDetailsPage.tsx | 36 +++-
.../PolicyAutomations.tests.tsx | 180 ++++++++++++++++++
.../PolicyAutomations/PolicyAutomations.tsx | 10 +-
.../edit/components/PolicyForm/PolicyForm.tsx | 3 +-
4 files changed, 222 insertions(+), 7 deletions(-)
create mode 100644 frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tests.tsx
diff --git a/frontend/pages/policies/details/PolicyDetailsPage/PolicyDetailsPage.tsx b/frontend/pages/policies/details/PolicyDetailsPage/PolicyDetailsPage.tsx
index d336bbe2b3..c91e6b4c67 100644
--- a/frontend/pages/policies/details/PolicyDetailsPage/PolicyDetailsPage.tsx
+++ b/frontend/pages/policies/details/PolicyDetailsPage/PolicyDetailsPage.tsx
@@ -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 = ({
)}
>
diff --git a/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tests.tsx b/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tests.tsx
new file mode 100644
index 0000000000..5a0bf2291d
--- /dev/null
+++ b/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tests.tsx
@@ -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 () => ;
+});
+
+const createMockPatchPolicy = (overrides?: Partial): 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(
+
+ {ui}
+
+ );
+};
+
+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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
+ });
+
+ it("hides the CTA card when patch_software is not set", () => {
+ renderWithAppContext(
+
+ );
+
+ expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
+ });
+
+ it("shows the CTA card for a no-team policy (team_id === 0)", () => {
+ renderWithAppContext(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.queryByText(/Automatically patch/)).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tsx b/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tsx
index c70031ccf3..f46004adae 100644
--- a/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tsx
+++ b/frontend/pages/policies/edit/components/PolicyAutomations/PolicyAutomations.tsx
@@ -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[] = [];
diff --git a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx
index 88a8057477..ff9ecee357 100644
--- a/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx
+++ b/frontend/pages/policies/edit/components/PolicyForm/PolicyForm.tsx
@@ -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 = ({