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 = ({