From b07ac351b8b4355d5d8d06d465b770e461cc9dfa Mon Sep 17 00:00:00 2001 From: nulmete Date: Mon, 13 Jul 2026 14:20:11 -0300 Subject: [PATCH 1/2] Fix Policies automations filter disappearing for the Unassigned fleet The automations filter's option list incorrectly restricted the "Unassigned" fleet (team ID 0) to the same webhook/ticket-only subset used for "All fleets" (team ID undefined), since 0 is falsy. Unassigned supports every automation type except calendar events, so the filter now offers the full set (minus calendar) for it. --- ...624-policies-automations-filter-unassigned | 1 + .../ManagePoliciesPage.tests.tsx | 158 ++++++++++++++++++ .../ManagePoliciesPage/ManagePoliciesPage.tsx | 23 ++- 3 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 changes/44624-policies-automations-filter-unassigned create mode 100644 frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx diff --git a/changes/44624-policies-automations-filter-unassigned b/changes/44624-policies-automations-filter-unassigned new file mode 100644 index 00000000000..cee2ea740f3 --- /dev/null +++ b/changes/44624-policies-automations-filter-unassigned @@ -0,0 +1 @@ +* Fixed the Policies page automations filter disappearing from the UI when switching to the "Unassigned" fleet and selecting a different automation type. diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx new file mode 100644 index 00000000000..fc2f761a0f0 --- /dev/null +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx @@ -0,0 +1,158 @@ +import React from "react"; +import { screen, waitFor } from "@testing-library/react"; +import { http, HttpResponse } from "msw"; + +import { + createCustomRenderer, + createMockRouter, + baseUrl, +} from "test/test-utils"; +import mockServer from "test/mock-server"; +import createMockConfig from "__mocks__/configMock"; +import createMockUser from "__mocks__/userMock"; +import createMockPolicy from "__mocks__/policyMock"; + +import ManagePoliciesPage from "./ManagePoliciesPage"; + +const FLEET_WITH_POLICIES_ID = 1; +const FLEET_WITHOUT_POLICIES_ID = 2; + +const getConfigHandler = () => + http.get(baseUrl("/config"), () => HttpResponse.json(createMockConfig())); + +const getFleetHandler = () => + http.get(baseUrl("/fleets/:id"), ({ params }) => { + const fleet = { ...createMockConfig(), id: Number(params.id) }; + return HttpResponse.json({ team: fleet, fleet }); + }); + +const getFleetPoliciesHandler = () => + http.get(baseUrl("/fleets/:fleetId/policies"), ({ params }) => { + const fleetId = Number(params.fleetId); + const policies = + fleetId === FLEET_WITH_POLICIES_ID + ? [ + createMockPolicy({ + id: 1, + name: "Software policy", + team_id: fleetId, + install_software: { name: "Zoom", software_title_id: 1 }, + }), + ] + : []; + return HttpResponse.json({ policies }); + }); + +const getFleetPoliciesCountHandler = () => + http.get(baseUrl("/fleets/:fleetId/policies/count"), ({ params }) => { + const fleetId = Number(params.fleetId); + return HttpResponse.json({ + count: fleetId === FLEET_WITH_POLICIES_ID ? 1 : 0, + }); + }); + +const getGlobalPoliciesHandler = () => + http.get(baseUrl("/policies"), () => HttpResponse.json({ policies: [] })); + +const getGlobalPoliciesCountHandler = () => + http.get(baseUrl("/policies/count"), () => HttpResponse.json({ count: 0 })); + +const setupHandlers = () => { + mockServer.use( + getConfigHandler(), + getFleetHandler(), + getFleetPoliciesHandler(), + getFleetPoliciesCountHandler(), + getGlobalPoliciesHandler(), + getGlobalPoliciesCountHandler() + ); +}; + +describe("ManagePoliciesPage - automations filter", () => { + const renderPage = (fleetId: string, automationType?: string) => { + setupHandlers(); + + const render = createCustomRenderer({ + withBackendMock: true, + context: { + app: { + currentUser: createMockUser({ global_role: "admin" }), + isGlobalAdmin: true, + isOnGlobalTeam: true, + isPremiumTier: true, + availableTeams: [ + { id: -1, name: "All fleets" }, + { id: 0, name: "Unassigned" }, + { id: FLEET_WITH_POLICIES_ID, name: "Fleet with policies" }, + { id: FLEET_WITHOUT_POLICIES_ID, name: "Fleet without policies" }, + ], + config: createMockConfig(), + setCurrentTeam: jest.fn(), + setFilteredPoliciesPath: jest.fn(), + setConfig: jest.fn(), + }, + }, + }); + + const query: Record = { fleet_id: fleetId }; + if (automationType) { + query.automation_type = automationType; + } + + return render( + + ); + }; + + it("keeps the automations filter dropdown visible and enabled for a fleet with no policies when a filter is present in the URL", async () => { + renderPage(FLEET_WITHOUT_POLICIES_ID.toString(), "software"); + + await waitFor(() => { + expect( + screen.getByText("No policies match the current filters.") + ).toBeInTheDocument(); + }); + + // Per the fix for #44624: when a filter is present in the query params, + // the automations filter dropdown must remain visible (and reflect the + // selected filter) even though this fleet has zero policies. + expect(screen.getByText("Software")).toBeInTheDocument(); + }); + + it("offers software/scripts/conditional access automation types for the 'Unassigned' fleet, but not calendar", async () => { + const { user } = renderPage("0", "software"); + + await waitFor(() => { + expect( + screen.getByText("No policies match the current filters.") + ).toBeInTheDocument(); + }); + + // The selected filter persists (sticky), matching what's in the URL. + expect(screen.getByText("Software")).toBeInTheDocument(); + + // "Unassigned" is a real, policy-bearing fleet (unlike "All fleets", which + // is restricted to webhook/ticket-only automations) -- it should offer + // every automation type EXCEPT calendar events, which + // PolicyAutomationsFields hardcodes as fleet-only (never available for + // "All fleets" or "Unassigned"). + const control = document.querySelector( + ".manage-policies-page__filter-automation-dropdown .react-select__control" + ) as HTMLElement; + await user.click(control); + + expect(screen.getByText("Scripts")).toBeInTheDocument(); + expect(screen.getByText("Conditional access")).toBeInTheDocument(); + expect(screen.queryByText("Calendar")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 86250fd1f5d..01457b2792a 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -28,8 +28,8 @@ import { } from "interfaces/policy"; import { API_ALL_TEAMS_ID, + API_NO_TEAM_ID, APP_CONTEXT_ALL_TEAMS_ID, - ITeamConfig, } from "interfaces/team"; import { isQueryablePlatform } from "interfaces/platform"; @@ -782,12 +782,21 @@ const ManagePolicyPage = ({ !automationFilter && !targetedPlatformParam; - // No team ID = All fleets → only show "all" and "other" options - const optionsForTeam = teamIdForApi - ? automationFilterOptions - : automationFilterOptions.filter((opt) => - ["all", "other"].includes(opt.value as string) - ); + // All fleets (teamIdForApi undefined) → global policies only support + // webhook/ticket automations, so only show "all" and "other". + // Unassigned (teamIdForApi === API_NO_TEAM_ID) supports every automation + // type EXCEPT calendar events, which PolicyAutomationsFields hardcodes + // as fleet-only (never available for "All fleets" or "Unassigned"). + let optionsForTeam = automationFilterOptions; + if (teamIdForApi === undefined) { + optionsForTeam = automationFilterOptions.filter((opt) => + ["all", "other"].includes(opt.value as string) + ); + } else if (teamIdForApi === API_NO_TEAM_ID) { + optionsForTeam = automationFilterOptions.filter( + (opt) => opt.value !== "calendar" + ); + } return ( Date: Mon, 13 Jul 2026 14:49:41 -0300 Subject: [PATCH 2/2] Address PR review comments on automations filter fix - Mocked fleet response now includes a name field to match ILoadTeamResponse - Test asserts the filter control isn't left disabled, not just that its label renders - Replace an unsafe querySelector cast with a helper that throws on a missing element - Extract getValidAutomationTypesForTeam() as the single source of truth for which automation types are valid per fleet, shared by both the initial query param validation and the dropdown's option list, so they can't drift out of sync (calendar was excluded from the dropdown but still accepted by validation) --- .../ManagePoliciesPage.tests.tsx | 44 ++++++++++++++++--- .../ManagePoliciesPage/ManagePoliciesPage.tsx | 43 ++++++++++-------- 2 files changed, 62 insertions(+), 25 deletions(-) diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx index fc2f761a0f0..297ea23ffdc 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tests.tsx @@ -22,7 +22,12 @@ const getConfigHandler = () => const getFleetHandler = () => http.get(baseUrl("/fleets/:id"), ({ params }) => { - const fleet = { ...createMockConfig(), id: Number(params.id) }; + const fleetId = Number(params.id); + const fleet = { + ...createMockConfig(), + id: fleetId, + name: `Fleet ${fleetId}`, + }; return HttpResponse.json({ team: fleet, fleet }); }); @@ -57,6 +62,16 @@ const getGlobalPoliciesHandler = () => const getGlobalPoliciesCountHandler = () => http.get(baseUrl("/policies/count"), () => HttpResponse.json({ count: 0 })); +const getAutomationFilterControl = (): HTMLElement => { + const control = document.querySelector( + ".manage-policies-page__filter-automation-dropdown .react-select__control" + ); + if (!control) { + throw new Error("Automations filter control not found"); + } + return control as HTMLElement; +}; + const setupHandlers = () => { mockServer.use( getConfigHandler(), @@ -124,9 +139,12 @@ describe("ManagePoliciesPage - automations filter", () => { }); // Per the fix for #44624: when a filter is present in the query params, - // the automations filter dropdown must remain visible (and reflect the - // selected filter) even though this fleet has zero policies. + // the automations filter dropdown must remain visible and enabled (and + // reflect the selected filter) even though this fleet has zero policies. expect(screen.getByText("Software")).toBeInTheDocument(); + expect(getAutomationFilterControl()).not.toHaveClass( + "react-select__control--is-disabled" + ); }); it("offers software/scripts/conditional access automation types for the 'Unassigned' fleet, but not calendar", async () => { @@ -146,13 +164,25 @@ describe("ManagePoliciesPage - automations filter", () => { // every automation type EXCEPT calendar events, which // PolicyAutomationsFields hardcodes as fleet-only (never available for // "All fleets" or "Unassigned"). - const control = document.querySelector( - ".manage-policies-page__filter-automation-dropdown .react-select__control" - ) as HTMLElement; - await user.click(control); + await user.click(getAutomationFilterControl()); expect(screen.getByText("Scripts")).toBeInTheDocument(); expect(screen.getByText("Conditional access")).toBeInTheDocument(); expect(screen.queryByText("Calendar")).not.toBeInTheDocument(); }); + + it("rejects an automation_type=calendar query param for the 'Unassigned' fleet, since calendar isn't a valid option there", async () => { + renderPage("0", "calendar"); + + await waitFor(() => { + expect( + screen.getByText("No policies for this fleet") + ).toBeInTheDocument(); + }); + + // "calendar" isn't a valid filter for "Unassigned", so it must not stick + // as the selected value -- the filter should fall back to its default. + expect(screen.getByText("All automations")).toBeInTheDocument(); + expect(screen.queryByText("Calendar")).not.toBeInTheDocument(); + }); }); diff --git a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx index 01457b2792a..9d97220a68f 100644 --- a/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx +++ b/frontend/pages/policies/ManagePoliciesPage/ManagePoliciesPage.tsx @@ -105,6 +105,22 @@ const AUTOMATION_TYPES: AutomationType[] = [ const GLOBAL_AUTOMATION_TYPES: GlobalPoliciesAutomationType[] = ["other"]; +const getValidAutomationTypesForTeam = ( + teamIdForApi: number | undefined +): (AutomationType | GlobalPoliciesAutomationType)[] => { + if (teamIdForApi === undefined) { + // All fleets → global policies only support webhook/ticket automations. + return GLOBAL_AUTOMATION_TYPES; + } + if (teamIdForApi === API_NO_TEAM_ID) { + // Unassigned supports every automation type EXCEPT calendar events, + // which PolicyAutomationsFields hardcodes as fleet-only (never available + // for "All fleets" or "Unassigned"). + return AUTOMATION_TYPES.filter((type) => type !== "calendar"); + } + return AUTOMATION_TYPES; +}; + const baseClass = "manage-policies-page"; const ManagePolicyPage = ({ @@ -191,9 +207,7 @@ const ManagePolicyPage = ({ return null; } - const validValues = isAllTeamsSelected - ? GLOBAL_AUTOMATION_TYPES - : AUTOMATION_TYPES; + const validValues = getValidAutomationTypesForTeam(teamIdForApi); return (validValues as string[]).includes(automationQueryParam) ? automationQueryParam @@ -782,21 +796,14 @@ const ManagePolicyPage = ({ !automationFilter && !targetedPlatformParam; - // All fleets (teamIdForApi undefined) → global policies only support - // webhook/ticket automations, so only show "all" and "other". - // Unassigned (teamIdForApi === API_NO_TEAM_ID) supports every automation - // type EXCEPT calendar events, which PolicyAutomationsFields hardcodes - // as fleet-only (never available for "All fleets" or "Unassigned"). - let optionsForTeam = automationFilterOptions; - if (teamIdForApi === undefined) { - optionsForTeam = automationFilterOptions.filter((opt) => - ["all", "other"].includes(opt.value as string) - ); - } else if (teamIdForApi === API_NO_TEAM_ID) { - optionsForTeam = automationFilterOptions.filter( - (opt) => opt.value !== "calendar" - ); - } + const validAutomationTypesForTeam = getValidAutomationTypesForTeam( + teamIdForApi + ); + const optionsForTeam = automationFilterOptions.filter( + (opt) => + opt.value === "all" || + (validAutomationTypesForTeam as string[]).includes(opt.value) + ); return (