patch when closed UI

This commit is contained in:
Carlo DiCelico
2026-08-03 10:27:54 -04:00
parent 345eb0538b
commit 01bf4b88be
53 changed files with 1904 additions and 381 deletions
+2
View File
@@ -395,6 +395,8 @@ const DEFAULT_FLEET_MAINTAINED_APP_DETAILS_MOCK: IFleetMaintainedAppDetails = {
post_install_script: 'echo "Installed"',
uninstall_script:
"#!/bin/sh\n\n# Fleet extracts and saves package IDs\npkg_ids=$PACKAGE_ID",
automatic_install_query:
"SELECT 1 FROM apps WHERE bundle_identifier = 'com.example.test-app';",
slug: "applications/test-app",
url: "http://www.testurl1234abcd.com/testapp",
categories: ["Browsers"],
@@ -10,6 +10,7 @@ import {
getSoftwareInstallHandlerWithHash,
getSoftwareInstallHandlerWithPreInstall,
getSoftwareInstallHandlerOnlyPreInstallOutput,
getSoftwareInstallHandlerAppOpen,
getSoftwareInstallResultHandlerPremiumRequired,
} from "test/handlers/software-handlers";
import mockServer from "test/mock-server";
@@ -135,6 +136,28 @@ describe("SoftwareInstallDetailsModal", () => {
expect(screen.getByText(/\d+.*ago/)).toBeInTheDocument();
});
it("renders app-open skipped copy instead of generic failed-install copy", () => {
render(
<StatusMessage
softwareName="CoolApp"
installResult={createMockSoftwareInstallResult({
status: "failed_install",
})}
isMyDevicePage={false}
installSkippedWhenAppOpen
/>
);
expect(screen.getByText(/Fleet skipped install of/)).toBeInTheDocument();
expect(screen.getByText(/The app was open/)).toBeInTheDocument();
expect(
screen.getByText(
/It will update once the user closes it and policy runs again, or update via self service\./
)
).toBeInTheDocument();
expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument();
});
it("on host details page/install activity, renders installed message with timestamp", () => {
render(
<StatusMessage
@@ -310,6 +333,29 @@ describe("SoftwareInstallDetailsModal", () => {
).not.toBeInTheDocument();
});
it("renders the app-open pre-install output for a skipped install", async () => {
mockServer.use(getSoftwareInstallHandlerAppOpen);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
const { user } = renderWithServer(
<SoftwareInstallDetailsModal
details={{
...baseDetails,
install_skipped_when_app_open: true,
}}
onCancel={noop}
/>
);
await screen.findByText(/Fleet skipped install of/);
await user.click(screen.getByRole("button", { name: /Details/i }));
expect(
screen.getByText("Query didn't return result or failed:")
).toBeInTheDocument();
expect(screen.getByText("The app was open")).toBeInTheDocument();
expect(screen.queryByText("Install stopped")).not.toBeInTheDocument();
});
it("shows install and post-install outputs after clicking Details (no pre-install)", async () => {
mockServer.use(getDefaultSoftwareInstallHandler);
const renderWithServer = createCustomRenderer({ withBackendMock: true });
@@ -49,6 +49,7 @@ const baseClass = "software-install-details-modal";
export type IPackageInstallDetails = {
host_display_name?: string;
install_uuid?: string; // not actually optional
install_skipped_when_app_open?: boolean;
};
export const renderContactOption = (url?: string) => (
@@ -73,6 +74,7 @@ interface IInstallStatusMessage {
- From Activity feed: never override (always show the failure).
Parity with VPPInstallDetailsModal/SoftwareIpaInstallDetailsModal */
canOverrideFailureWithInstalled?: boolean;
installSkippedWhenAppOpen?: boolean;
}
// TODO - match VppInstallDetailsModal status to this, still accounting for MDM-specific cases
@@ -83,6 +85,7 @@ export const StatusMessage = ({
isMyDevicePage,
contactUrl,
canOverrideFailureWithInstalled = false,
installSkippedWhenAppOpen = false,
}: IInstallStatusMessage) => {
// the case when software is installed by the user and not by Fleet
if (!installResult) {
@@ -143,6 +146,23 @@ export const StatusMessage = ({
})})`
: "";
if (installSkippedWhenAppOpen && status === "failed_install") {
return (
<IconStatusMessage
className={`${baseClass}__status-message`}
iconName={INSTALL_DETAILS_STATUS_ICONS.skipped_install}
message={
<span>
Fleet skipped install of <b>{software_title}</b> ({software_package}
) on {formattedHost}
{displayTimeStamp}. The app was open. It will update once the user
closes it and policy runs again, or update via self service.
</span>
}
/>
);
}
const renderStatusCopy = () => {
const prefix = (
<>
@@ -295,8 +315,12 @@ export const SoftwareInstallDetailsModal = ({
const renderInstallDetailsSection = () => {
const outputs = [
{
label: "Pre-install query output:",
value: swInstallResult?.pre_install_query_output,
label: detailsFromProps.install_skipped_when_app_open
? "Query didn't return result or failed:"
: "Pre-install query output:",
value: detailsFromProps.install_skipped_when_app_open
? "The app was open"
: swInstallResult?.pre_install_query_output,
},
{
label: "Install script output:",
@@ -312,7 +336,8 @@ export const SoftwareInstallDetailsModal = ({
const showDetailsButton =
(!!swInstallResult?.post_install_script_output ||
!!swInstallResult?.output ||
!!swInstallResult?.pre_install_query_output) &&
!!swInstallResult?.pre_install_query_output ||
!!detailsFromProps.install_skipped_when_app_open) &&
swInstallResult?.status !== "pending_install";
return (
@@ -453,6 +478,9 @@ export const SoftwareInstallDetailsModal = ({
isMyDevicePage={!!deviceAuthToken}
contactUrl={contactUrl}
canOverrideFailureWithInstalled={canOverrideFailureWithInstalled}
installSkippedWhenAppOpen={
detailsFromProps.install_skipped_when_app_open
}
/>
{/* Package SHA-256 hash — backend hydrates `hash_sha256` on the
@@ -1,6 +1,6 @@
import { IconNames } from "components/icons";
import {
SoftwareInstallUninstallStatus,
SoftwareInstallDetailsStatus,
EnhancedSoftwareInstallUninstallStatus,
SoftwareInstallStatus,
} from "interfaces/software";
@@ -8,7 +8,7 @@ import {
// Install/Uninstall helpers
export const INSTALL_DETAILS_STATUS_ICONS: Record<
SoftwareInstallUninstallStatus, // former is superset of latter, latter included in union for type system
SoftwareInstallDetailsStatus,
IconNames
> = {
pending_install: "pending-outline",
@@ -17,10 +17,11 @@ export const INSTALL_DETAILS_STATUS_ICONS: Record<
failed_install: "error",
pending_uninstall: "pending-outline",
failed_uninstall: "error",
skipped_install: "info",
} as const;
const INSTALL_DETAILS_STATUS_PREDICATES: Record<
EnhancedSoftwareInstallUninstallStatus,
EnhancedSoftwareInstallUninstallStatus | "skipped_install",
string
> = {
pending_install: "is installing or will install",
@@ -32,6 +33,7 @@ const INSTALL_DETAILS_STATUS_PREDICATES: Record<
pending_script: "is running or will run",
failed_script: "failed to run",
ran_script: "ran",
skipped_install: "skipped install of",
} as const;
export const getInstallDetailsStatusPredicate = (
+1
View File
@@ -305,6 +305,7 @@ export interface IActivityDetails {
host_platform?: string;
host_serial?: string;
install_uuid?: string;
install_skipped_when_app_open?: boolean;
installed_from_dep?: boolean;
labels_exclude_any?: ILabelSoftwareTitle[];
labels_include_any?: ILabelSoftwareTitle[];
+2
View File
@@ -72,6 +72,7 @@ export interface IPolicy {
run_script?: Pick<IScript, "id" | "name">;
patch_software?: IPolicySoftwareToInstall;
continuous_automations_enabled?: boolean;
patch_when_closed?: boolean;
labels_include_any?: ILabelPolicy[];
labels_include_all?: ILabelPolicy[];
labels_exclude_any?: ILabelPolicy[];
@@ -146,6 +147,7 @@ export interface IPolicyFormData {
calendar_events_enabled?: boolean;
conditional_access_enabled?: boolean;
continuous_automations_enabled?: boolean;
patch_when_closed?: boolean;
software_title_id?: number | null;
/** Pins the policy to a specific package on a multi-package title. `null`
* on PATCH lets the backend fall back to the title's first-added package
+9
View File
@@ -69,6 +69,8 @@ export interface ISoftwareTitleVersion {
export interface ISoftwarePatchPolicy {
id: number;
name: string;
patch_when_closed: boolean;
continuous_automations_enabled?: boolean;
}
export type SoftwareInstallPolicyType = "dynamic" | "patch";
@@ -472,6 +474,12 @@ export const SOFTWARE_INSTALL_UNINSTALL_STATUSES = [
*/
export type SoftwareInstallUninstallStatus = typeof SOFTWARE_INSTALL_UNINSTALL_STATUSES[number];
/** Activity-backed install details can display a skipped state while the
* persisted install result remains failed_install. */
export type SoftwareInstallDetailsStatus =
| SoftwareInstallUninstallStatus
| "skipped_install";
/** Include script-only software statuses */
export const ENAHNCED_SOFTWARE_INSTALL_UNINSTALL_STATUSES = [
...SOFTWARE_INSTALL_STATUSES,
@@ -945,6 +953,7 @@ export interface IFleetMaintainedAppDetails {
install_script: string;
post_install_script: string;
uninstall_script: string;
automatic_install_query: string;
url: string;
slug: string;
software_title_id?: number; // null unless the team already has the software added (as a Fleet-maintained app, App Store (app), or custom package)
@@ -1917,6 +1917,47 @@ describe("Activity Feed", () => {
expect(screen.getByText("Script-only Software")).toBeInTheDocument();
});
it("renders skipped copy when the app was open", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Fleet",
fleet_initiated: true,
details: {
software_title: "Firefox",
software_package: "Firefox.pkg",
host_display_name: "Work Mac",
source: "apps",
status: "failed_install",
install_skipped_when_app_open: true,
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/skipped install of/)).toBeInTheDocument();
expect(screen.getByText("Firefox")).toBeInTheDocument();
expect(screen.getByText("Work Mac")).toBeInTheDocument();
expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument();
});
it("keeps generic failed-install copy when the app-open flag is absent", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Fleet",
fleet_initiated: true,
details: {
software_title: "Firefox",
software_package: "Firefox.pkg",
host_display_name: "Work Mac",
source: "apps",
status: "failed_install",
},
});
render(<GlobalActivityItem activity={activity} isPremiumTier />);
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
expect(screen.queryByText(/skipped install/)).not.toBeInTheDocument();
});
it("renders py script package ran status in InstalledSoftware activity", () => {
const activity = createMockActivity({
type: ActivityType.InstalledSoftware,
@@ -1478,6 +1478,7 @@ const TAGGED_TEMPLATES = {
source,
self_service,
from_setup_experience,
install_skipped_when_app_open,
} = details;
const showSoftwarePackage =
@@ -1485,6 +1486,15 @@ const TAGGED_TEMPLATES = {
activity.type === ActivityType.InstalledSoftware;
const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || "");
if (install_skipped_when_app_open) {
return (
<>
{" "}
skipped install of <b>{title}</b> on <b>{hostName}</b>.
</>
);
}
// Self-service actions: drop the actor and switch to passive voice so the
// sentence reads "<title> was installed on <host> (self-service)." without
// misattributing the action.
@@ -0,0 +1,68 @@
import React from "react";
import { screen } from "@testing-library/react";
import { createCustomRenderer } from "test/test-utils";
import FleetAppDetailsForm from "./FleetAppDetailsForm";
const defaultProps: React.ComponentProps<typeof FleetAppDetailsForm> = {
categories: [],
defaultInstallScript: "install",
defaultPostInstallScript: "post-install",
defaultUninstallScript: "uninstall",
teamId: "1",
onCancel: jest.fn(),
onSubmit: jest.fn(),
};
const renderForm = (gitOpsModeEnabled = false) => {
const render = createCustomRenderer({
context: {
app: {
config: { gitops: { gitops_mode_enabled: gitOpsModeEnabled } },
},
},
});
return render(<FleetAppDetailsForm {...defaultProps} />);
};
describe("FleetAppDetailsForm", () => {
beforeEach(() => jest.clearAllMocks());
it("submits Self-service and Deploy selections", async () => {
const { user } = renderForm();
const selfServiceSwitch = screen
.getByText("Self-service")
.closest(".fleet-slider__wrapper")
?.querySelector('[role="switch"]');
expect(selfServiceSwitch).not.toBeNull();
await user.click(selfServiceSwitch as Element);
await user.click(screen.getByRole("checkbox", { name: "force-install" }));
await user.click(screen.getByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("radio", { name: "Force patch" }));
await user.click(screen.getByRole("button", { name: "Add software" }));
expect(defaultProps.onSubmit).toHaveBeenCalledWith(
expect.objectContaining({
selfService: true,
forceInstall: true,
patch: true,
patchOption: "force",
})
);
});
it("disables Deploy and Add software in GitOps mode", () => {
renderForm(true);
expect(
screen.getByRole("checkbox", { name: "force-install" })
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("checkbox", { name: "patch" })).toHaveAttribute(
"aria-disabled",
"true"
);
expect(screen.getByRole("button", { name: "Add software" })).toBeDisabled();
});
});
@@ -10,7 +10,11 @@ import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
import CustomLink from "components/CustomLink";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import SoftwareDeploySlider from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
import SoftwareOptionsSelector from "pages/SoftwarePage/components/forms/SoftwareOptionsSelector";
import {
PatchOption,
SoftwareDeploySelector,
} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
const baseClass = "fleet-app-details-form";
@@ -40,7 +44,9 @@ export const softwareAlreadyAddedTipContent = (
};
export interface IFleetMaintainedAppFormData {
selfService: boolean;
automaticInstall: boolean;
forceInstall: boolean;
patch: boolean;
patchOption: PatchOption;
installScript: string;
preInstallQuery?: string;
postInstallScript?: string;
@@ -80,7 +86,9 @@ const FleetAppDetailsForm = ({
}: IFleetAppDetailsFormProps) => {
const [formData, setFormData] = useState<IFleetMaintainedAppFormData>({
selfService: false,
automaticInstall: false,
forceInstall: false,
patch: false,
patchOption: "closed",
preInstallQuery: "",
installScript: defaultInstallScript,
postInstallScript: defaultPostInstallScript,
@@ -91,10 +99,10 @@ const FleetAppDetailsForm = ({
categories: categories || [],
});
const onToggleDeploySoftware = () => {
const onToggleSelfService = () => {
setFormData((prevData: IFleetMaintainedAppFormData) => ({
...prevData,
automaticInstall: !prevData.automaticInstall,
selfService: !prevData.selfService,
}));
};
@@ -108,9 +116,31 @@ const FleetAppDetailsForm = ({
return (
<form className={baseClass} onSubmit={onSubmitForm}>
<SoftwareDeploySlider
deploySoftware={formData.automaticInstall}
onToggleDeploySoftware={onToggleDeploySoftware}
<SoftwareOptionsSelector
formData={formData}
onToggleSelfService={onToggleSelfService}
onClickPreviewEndUserExperience={() => undefined}
onSelectCategory={() => undefined}
/>
<GitOpsModeTooltipWrapper
entityType="software"
renderChildren={(disableChildren) => (
<SoftwareDeploySelector
forceInstall={formData.forceInstall}
patch={formData.patch}
patchOption={formData.patchOption}
onToggleForceInstall={(forceInstall) =>
setFormData((prevData) => ({ ...prevData, forceInstall }))
}
onTogglePatch={(patch) =>
setFormData((prevData) => ({ ...prevData, patch }))
}
onSelectPatchOption={(patchOption) =>
setFormData((prevData) => ({ ...prevData, patchOption }))
}
disabled={disableChildren}
/>
)}
/>
<div className={`${baseClass}__action-buttons`}>
<GitOpsModeTooltipWrapper
@@ -0,0 +1,100 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { createMockFleetMaintainedAppDetails } from "__mocks__/softwareMock";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import { createCustomRenderer, createMockRouter } from "test/test-utils";
import FleetMaintainedAppDetailsPage from "./FleetMaintainedAppDetailsPage";
describe("FleetMaintainedAppDetailsPage", () => {
beforeEach(() => {
jest.spyOn(softwareAPI, "getFleetMaintainedApp").mockResolvedValue({
fleet_maintained_app: createMockFleetMaintainedAppDetails(),
});
jest
.spyOn(softwareAPI, "addFleetMaintainedApp")
.mockResolvedValue({ software_title_id: 99 });
jest.spyOn(teamPoliciesAPI, "create").mockResolvedValue({} as never);
});
afterEach(() => jest.restoreAllMocks());
it("adds the FMA before creating its patch policy", async () => {
const render = createCustomRenderer({
withBackendMock: true,
context: { app: { isPremiumTier: true } },
});
const router = createMockRouter();
const { user } = render(
<FleetMaintainedAppDetailsPage
location={
({ query: { fleet_id: "3" } } as unknown) as React.ComponentProps<
typeof FleetMaintainedAppDetailsPage
>["location"]
}
router={router}
routeParams={{ id: "1" }}
/>
);
await user.click(await screen.findByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("button", { name: "Add software" }));
await waitFor(() => {
expect(softwareAPI.addFleetMaintainedApp).toHaveBeenCalledWith(
3,
expect.objectContaining({ patch: true, patchOption: "closed" })
);
expect(teamPoliciesAPI.create).toHaveBeenCalledWith({
team_id: 3,
type: "patch",
patch_software_title_id: 99,
software_title_id: 99,
patch_when_closed: true,
continuous_automations_enabled: true,
});
});
expect(
(softwareAPI.addFleetMaintainedApp as jest.Mock).mock
.invocationCallOrder[0]
).toBeLessThan(
(teamPoliciesAPI.create as jest.Mock).mock.invocationCallOrder[0]
);
});
it("navigates to the added software when patch policy creation fails", async () => {
(teamPoliciesAPI.create as jest.Mock).mockRejectedValueOnce(
new Error("Patch failed")
);
const render = createCustomRenderer({
withBackendMock: true,
context: { app: { isPremiumTier: true } },
});
const router = createMockRouter();
const { user } = render(
<FleetMaintainedAppDetailsPage
location={
({ query: { fleet_id: "3" } } as unknown) as React.ComponentProps<
typeof FleetMaintainedAppDetailsPage
>["location"]
}
router={router}
routeParams={{ id: "1" }}
/>
);
await user.click(await screen.findByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("button", { name: "Add software" }));
await waitFor(() => {
expect(softwareAPI.addFleetMaintainedApp).toHaveBeenCalledTimes(1);
expect(teamPoliciesAPI.create).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith(
expect.stringMatching(/\/software\/titles\/99.*fleet_id=3/)
);
});
});
});
@@ -9,6 +9,7 @@ import PATHS from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import { AppContext } from "context/app";
import { Platform, PLATFORM_DISPLAY_NAMES } from "interfaces/platform";
@@ -24,6 +25,7 @@ import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import PageDescription from "components/PageDescription";
import { getPatchPolicyFlags } from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
import FleetAppDetailsForm from "./FleetAppDetailsForm";
import { IFleetMaintainedAppFormData } from "./FleetAppDetailsForm/FleetAppDetailsForm";
@@ -177,13 +179,29 @@ const FleetMaintainedAppDetailsPage = ({
setShowAddFleetAppSoftwareModal(true);
let softwareFmaTitleId: number | undefined;
try {
const {
software_title_id: softwareFmaTitleId,
} = await softwareAPI.addFleetMaintainedApp(parseInt(teamId, 10), {
...formData,
appId,
});
const response = await softwareAPI.addFleetMaintainedApp(
parseInt(teamId, 10),
{
...formData,
appId,
}
);
const addedSoftwareTitleId = response.software_title_id;
softwareFmaTitleId = addedSoftwareTitleId;
if (formData.patch) {
await teamPoliciesAPI.create({
team_id: parseInt(teamId, 10),
type: "patch",
patch_software_title_id: addedSoftwareTitleId,
...(formData.patchOption !== "manual" && {
software_title_id: addedSoftwareTitleId,
}),
...getPatchPolicyFlags(formData.patchOption),
});
}
queryClient.invalidateQueries({
queryKey: [{ scope: "software-titles" }],
@@ -197,7 +215,7 @@ const FleetMaintainedAppDetailsPage = ({
router.push(
getPathWithQueryParams(
PATHS.SOFTWARE_TITLE_DETAILS(softwareFmaTitleId.toString()),
PATHS.SOFTWARE_TITLE_DETAILS(addedSoftwareTitleId.toString()),
{
fleet_id: teamId,
}
@@ -212,7 +230,29 @@ const FleetMaintainedAppDetailsPage = ({
} catch (error) {
const ae = (typeof error === "object" ? error : {}) as AxiosResponse;
notify.error(getErrorMessage(ae), { response: error });
if (softwareFmaTitleId) {
queryClient.invalidateQueries({
queryKey: [{ scope: "software-titles" }],
});
queryClient.invalidateQueries({
queryKey: [{ scope: "software-library" }],
});
queryClient.invalidateQueries({
queryKey: [{ scope: "fleet-maintained-apps" }],
});
router.push(
getPathWithQueryParams(
PATHS.SOFTWARE_TITLE_DETAILS(softwareFmaTitleId.toString()),
{ fleet_id: teamId }
)
);
notify.error(
"Software was added, but the deployment settings couldn't be saved. Try again from Actions > Deploy.",
{ response: error }
);
} else {
notify.error(getErrorMessage(ae), { response: error });
}
}
setShowAddFleetAppSoftwareModal(false);
@@ -240,7 +280,7 @@ const FleetMaintainedAppDetailsPage = ({
className={`${baseClass}__back-to-add-software`}
/>
<h1>{fleetApp.name}</h1>
<PageDescription content="Add software to your library. You can add it to self-service later." />
<PageDescription content="Add software to your library." />
<div className={`${baseClass}__page-content`}>
<FleetAppSummary
name={fleetApp.name}
@@ -10,36 +10,6 @@ import {
ensurePeriod,
formatAlreadyAvailableInstallMessage,
} from "../../helpers";
import fleetAppData from "../../../../../../server/mdm/maintainedapps/apps.json";
const NameToIdentifierMap: Record<string, string> = {
"1Password": "1password",
"Adobe Acrobat Reader": "adobe-acrobat-reader",
"Box Drive": "box-drive",
Brave: "brave-browser",
"Cloudflare One": "cloudflare-warp",
"Docker Desktop": "docker",
Figma: "figma",
"Mozilla Firefox": "firefox",
"Google Chrome": "google-chrome",
"Microsoft Edge": "microsoft-edge",
"Microsoft Excel": "microsoft-excel",
"Microsoft Teams": "microsoft-teams",
"Microsoft Word": "microsoft-word",
Notion: "notion",
Postman: "postman",
Slack: "slack",
TeamViewer: "teamviewer",
"Microsoft Visual Studio Code": "visual-studio-code",
WhatsApp: "whatsapp",
Zoom: "zoom",
"Zoom for IT Admins": "zoom-for-it-admins",
};
const getFleetAppData = (name: string) => {
const appId = NameToIdentifierMap[name]; // TODO: need a better matching mechanism here
return fleetAppData.find((app) => app.identifier === appId);
};
export const getFleetAppPolicyName = (appName: string) => {
return `[Install software] ${appName}`;
@@ -49,10 +19,6 @@ export const getFleetAppPolicyDescription = (appName: string) => {
return `Policy triggers automatic install of ${appName} on each host that's missing this software.`;
};
export const getFleetAppPolicyQuery = (name: string) => {
return getFleetAppData(name)?.automatic_policy_query;
};
export const getErrorMessage = (err: unknown) => {
const isTimeout =
isAxiosError(err) &&
@@ -1,45 +0,0 @@
import React from "react";
import { screen } from "@testing-library/react";
import { noop } from "lodash";
import { createCustomRenderer } from "test/test-utils";
import AddPatchPolicyModal from "./AddPatchPolicyModal";
const renderModal = (props: { gitOpsModeEnabled?: boolean } = {}) => {
const customRender = createCustomRenderer({
context: {
app: {
config: {
gitops: {
gitops_mode_enabled: props.gitOpsModeEnabled ?? false,
},
},
},
},
});
return customRender(
<AddPatchPolicyModal
softwareId={1}
teamId={1}
onExit={noop}
onSuccess={noop}
{...props}
/>
);
};
describe("AddPatchPolicyModal", () => {
beforeEach(() => {
jest.resetAllMocks();
});
it("renders add button as disabled when gitOpsModeEnabled is true", async () => {
const { user } = renderModal({ gitOpsModeEnabled: true });
const addButton = screen.getByRole("button", { name: "Add" });
expect(addButton).toBeDisabled();
await user.hover(addButton);
});
});
@@ -1,91 +0,0 @@
import React, { useCallback, useState } from "react";
import teamPoliciesAPI from "services/entities/team_policies";
import { getErrorReason } from "interfaces/errors";
import { notify } from "components/ToastNotification";
import Modal from "components/Modal";
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
const baseClass = "add-patch-policy-modal";
const EXISTING_PATCH_POLICY_ERROR_MSG = `Couldn't add patch policy. Specified "patch_software_title_id" already has a policy with "type" set to "patch".`;
interface IAddPatchPolicyModal {
softwareId: number;
teamId: number;
onExit: () => void;
onSuccess: () => void;
}
const AddPatchPolicyModal = ({
softwareId,
teamId,
onExit,
onSuccess,
}: IAddPatchPolicyModal) => {
const [isAddingPatchPolicy, setIsAddingPatchPolicy] = useState(false);
const onAddPatchPolicy = useCallback(async () => {
setIsAddingPatchPolicy(true);
try {
await teamPoliciesAPI.create({
type: "patch",
patch_software_title_id: softwareId,
team_id: teamId,
});
notify.success("Successfully added patch policy.");
onSuccess();
} catch (error) {
const reason = getErrorReason(error);
if (reason.includes("already has a policy")) {
notify.error(EXISTING_PATCH_POLICY_ERROR_MSG, { response: error });
} else {
notify.error("Couldn't add patch policy. Please try again.", {
response: error,
});
}
}
setIsAddingPatchPolicy(false);
onExit();
}, [softwareId, teamId, onSuccess, onExit]);
return (
<Modal
className={baseClass}
title="Add patch policy"
onExit={onExit}
isContentDisabled={isAddingPatchPolicy}
>
<>
<p>
This creates a read-only policy. Later, to enforce remediation, head
to this policy&apos;s page.
</p>
<div className="modal-cta-wrap">
<GitOpsModeTooltipWrapper
entityType="software"
position="top"
tipOffset={8}
renderChildren={(disableChildren) => (
<Button
onClick={onAddPatchPolicy}
isLoading={isAddingPatchPolicy}
disabled={disableChildren}
>
Add
</Button>
)}
/>
<Button variant="inverse" onClick={onExit}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default AddPatchPolicyModal;
@@ -1,3 +0,0 @@
.add-patch-policy-modal {
overflow-wrap: anywhere; // Prevent long software name overflow
}
@@ -1 +0,0 @@
export { default } from "./AddPatchPolicyModal";
@@ -0,0 +1,268 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { noop } from "lodash";
import {
createMockFleetMaintainedAppDetails,
createMockSoftwarePackage,
createMockSoftwareTitle,
} from "__mocks__/softwareMock";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import { createCustomRenderer } from "test/test-utils";
import DeployModal from "./DeployModal";
const renderModal = ({
softwarePackage = createMockSoftwarePackage({ fleet_maintained_app_id: 1 }),
gitOpsModeEnabled = false,
onExit = noop,
onSuccess = noop,
}: {
softwarePackage?: ReturnType<typeof createMockSoftwarePackage>;
gitOpsModeEnabled?: boolean;
onExit?: () => void;
onSuccess?: () => void;
} = {}) => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
config: {
gitops: { gitops_mode_enabled: gitOpsModeEnabled },
},
},
},
});
return render(
<DeployModal
softwareTitle={createMockSoftwareTitle({
id: 10,
name: "Firefox",
software_package: softwarePackage,
})}
teamId={1}
onExit={onExit}
onSuccess={onSuccess}
/>
);
};
describe("DeployModal", () => {
beforeEach(() => {
jest.spyOn(softwareAPI, "getFleetMaintainedApp").mockResolvedValue({
fleet_maintained_app: createMockFleetMaintainedAppDetails(),
});
jest.spyOn(teamPoliciesAPI, "create").mockResolvedValue({} as never);
jest.spyOn(teamPoliciesAPI, "update").mockResolvedValue({} as never);
jest.spyOn(teamPoliciesAPI, "destroy").mockResolvedValue({} as never);
});
afterEach(() => jest.restoreAllMocks());
it("reflects an externally-created manual patch policy", () => {
renderModal({
softwarePackage: createMockSoftwarePackage({
fleet_maintained_app_id: 1,
patch_policy: {
id: 22,
name: "Firefox up to date",
patch_when_closed: false,
},
automatic_install_policies: [],
}),
});
expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "End user initiated (manual)" })
).toBeChecked();
});
it("creates a Force patch policy through the policy endpoint", async () => {
const { user } = renderModal();
await user.click(screen.getByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("radio", { name: "Force patch" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(teamPoliciesAPI.create).toHaveBeenCalledWith({
team_id: 1,
type: "patch",
patch_software_title_id: 10,
software_title_id: 10,
patch_when_closed: false,
continuous_automations_enabled: true,
})
);
});
it("creates Force install with the current FMA query and platform", async () => {
const { user } = renderModal();
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled()
);
await user.click(screen.getByRole("checkbox", { name: "force-install" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(teamPoliciesAPI.create).toHaveBeenCalledWith({
team_id: 1,
name: "[Install software] Firefox",
description:
"Policy triggers automatic install of Firefox on each host that's missing this software.",
query:
"SELECT 1 FROM apps WHERE bundle_identifier = 'com.example.test-app';",
platform: "darwin",
software_title_id: 10,
})
);
});
it("attaches install automation when a manual patch policy changes to Force patch", async () => {
const { user } = renderModal({
softwarePackage: createMockSoftwarePackage({
fleet_maintained_app_id: 1,
patch_policy: {
id: 22,
name: "Firefox up to date",
patch_when_closed: false,
},
automatic_install_policies: [],
}),
});
await user.click(screen.getByRole("radio", { name: "Force patch" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, {
team_id: 1,
software_title_id: 10,
patch_when_closed: false,
continuous_automations_enabled: true,
})
);
});
it("removes install automation when Force patch changes to manual", async () => {
const { user } = renderModal({
softwarePackage: createMockSoftwarePackage({
fleet_maintained_app_id: 1,
patch_policy: {
id: 22,
name: "Firefox up to date",
patch_when_closed: false,
},
automatic_install_policies: [
{ id: 22, name: "Firefox up to date", type: "patch" },
],
}),
});
await user.click(
screen.getByRole("radio", { name: "End user initiated (manual)" })
);
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, {
team_id: 1,
software_title_id: null,
patch_when_closed: false,
continuous_automations_enabled: false,
})
);
});
it("enables continuous automation when saving a migrated Force patch policy", async () => {
const { user } = renderModal({
softwarePackage: createMockSoftwarePackage({
fleet_maintained_app_id: 1,
patch_policy: {
id: 22,
name: "Firefox up to date",
patch_when_closed: false,
continuous_automations_enabled: false,
},
automatic_install_policies: [
{ id: 22, name: "Firefox up to date", type: "patch" },
],
}),
});
expect(screen.getByRole("radio", { name: "Force patch" })).toBeChecked();
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(teamPoliciesAPI.update).toHaveBeenCalledWith(22, {
team_id: 1,
software_title_id: 10,
patch_when_closed: false,
continuous_automations_enabled: true,
})
);
});
it("deletes Force install and Patch policies independently", async () => {
const { user } = renderModal({
softwarePackage: createMockSoftwarePackage({
fleet_maintained_app_id: 1,
automatic_install_policies: [
{ id: 11, name: "[Install software] Firefox", type: "dynamic" },
],
patch_policy: {
id: 22,
name: "Firefox up to date",
patch_when_closed: true,
},
}),
});
await user.click(screen.getByRole("checkbox", { name: "force-install" }));
await user.click(screen.getByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(teamPoliciesAPI.destroy).toHaveBeenNthCalledWith(1, 1, [11]);
expect(teamPoliciesAPI.destroy).toHaveBeenNthCalledWith(2, 1, [22]);
});
});
it("closes and refreshes after a partial save so retry uses fresh policy state", async () => {
const onExit = jest.fn();
const onSuccess = jest.fn();
(teamPoliciesAPI.create as jest.Mock)
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("Patch failed"));
const { user } = renderModal({ onExit, onSuccess });
await waitFor(() =>
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled()
);
await user.click(screen.getByRole("checkbox", { name: "force-install" }));
await user.click(screen.getByRole("checkbox", { name: "patch" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => {
expect(teamPoliciesAPI.create).toHaveBeenCalledTimes(2);
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(onExit).toHaveBeenCalledTimes(1);
});
});
it("disables the Deploy control and Save in GitOps mode", () => {
renderModal({ gitOpsModeEnabled: true });
expect(
screen.getByRole("checkbox", { name: "force-install" })
).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("checkbox", { name: "patch" })).toHaveAttribute(
"aria-disabled",
"true"
);
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
});
});
@@ -0,0 +1,201 @@
import React, { useState } from "react";
import { useQuery } from "react-query";
import { ISoftwareTitleDetails } from "interfaces/software";
import { getErrorReason } from "interfaces/errors";
import softwareAPI from "services/entities/software";
import teamPoliciesAPI from "services/entities/team_policies";
import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
import Button from "components/buttons/Button";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Modal from "components/Modal";
import { notify } from "components/ToastNotification";
import {
getPatchPolicyFlags,
PatchOption,
SoftwareDeploySelector,
} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
import {
getFleetAppPolicyDescription,
getFleetAppPolicyName,
} from "pages/SoftwarePage/SoftwareAddPage/SoftwareFleetMaintained/FleetMaintainedAppDetailsPage/helpers";
const baseClass = "deploy-modal";
interface IDeployModalProps {
softwareTitle: ISoftwareTitleDetails;
teamId: number;
onExit: () => void;
onSuccess: () => void;
}
const DeployModal = ({
softwareTitle,
teamId,
onExit,
onSuccess,
}: IDeployModalProps) => {
const softwarePackage = softwareTitle.software_package;
const automaticInstallPolicies =
softwarePackage?.automatic_install_policies ?? [];
const patchPolicy = softwarePackage?.patch_policy;
const forceInstallPolicy = automaticInstallPolicies.find(
(policy) =>
policy.type === "dynamic" &&
policy.name === getFleetAppPolicyName(softwareTitle.name)
);
const patchHasAutomation =
!!patchPolicy &&
automaticInstallPolicies.some((policy) => policy.id === patchPolicy.id);
let initialPatchOption: PatchOption = "manual";
if (patchPolicy?.patch_when_closed) {
initialPatchOption = "closed";
} else if (patchHasAutomation) {
initialPatchOption = "force";
}
const [forceInstall, setForceInstall] = useState(!!forceInstallPolicy);
const [patch, setPatch] = useState(!!patchPolicy);
const [patchOption, setPatchOption] = useState<PatchOption>(
initialPatchOption
);
const [isSaving, setIsSaving] = useState(false);
const fleetMaintainedAppId = softwarePackage?.fleet_maintained_app_id;
const {
data: fleetMaintainedApp,
isLoading: isLoadingFleetMaintainedApp,
} = useQuery(
["fleet-maintained-app", fleetMaintainedAppId, teamId],
() =>
softwareAPI.getFleetMaintainedApp(
fleetMaintainedAppId as number,
String(teamId)
),
{
...DEFAULT_USE_QUERY_OPTIONS,
enabled: !!fleetMaintainedAppId && !forceInstallPolicy,
select: (res) => res.fleet_maintained_app,
}
);
const onSave = async () => {
setIsSaving(true);
let savedAnyChange = false;
try {
if (forceInstall !== !!forceInstallPolicy) {
if (forceInstall) {
if (!fleetMaintainedApp?.automatic_install_query) {
throw new Error(
"Couldn't create the Force install policy. Try again."
);
}
await teamPoliciesAPI.create({
team_id: teamId,
name: getFleetAppPolicyName(softwareTitle.name),
description: getFleetAppPolicyDescription(softwareTitle.name),
query: fleetMaintainedApp.automatic_install_query,
platform: fleetMaintainedApp.platform,
software_title_id: softwareTitle.id,
});
} else if (forceInstallPolicy) {
await teamPoliciesAPI.destroy(teamId, [forceInstallPolicy.id]);
}
savedAnyChange = true;
}
if (patch !== !!patchPolicy) {
if (patch) {
await teamPoliciesAPI.create({
team_id: teamId,
type: "patch",
patch_software_title_id: softwareTitle.id,
...(patchOption !== "manual" && {
software_title_id: softwareTitle.id,
}),
...getPatchPolicyFlags(patchOption),
});
} else if (patchPolicy) {
await teamPoliciesAPI.destroy(teamId, [patchPolicy.id]);
}
savedAnyChange = true;
} else if (
patch &&
patchPolicy &&
(patchOption !== initialPatchOption ||
patchHasAutomation !== (patchOption !== "manual") ||
patchPolicy.patch_when_closed !==
getPatchPolicyFlags(patchOption).patch_when_closed ||
patchPolicy.continuous_automations_enabled !==
getPatchPolicyFlags(patchOption).continuous_automations_enabled)
) {
await teamPoliciesAPI.update(patchPolicy.id, {
team_id: teamId,
software_title_id: patchOption === "manual" ? null : softwareTitle.id,
...getPatchPolicyFlags(patchOption),
});
savedAnyChange = true;
}
onSuccess();
onExit();
} catch (error) {
notify.error(getErrorReason(error), { response: error });
if (savedAnyChange) {
onSuccess();
onExit();
}
} finally {
setIsSaving(false);
}
};
return (
<Modal
className={baseClass}
title="Deploy"
onExit={onExit}
isContentDisabled={isSaving}
>
<>
<GitOpsModeTooltipWrapper
entityType="software"
renderChildren={(disableChildren) => (
<SoftwareDeploySelector
forceInstall={forceInstall}
patch={patch}
patchOption={patchOption}
onToggleForceInstall={setForceInstall}
onTogglePatch={setPatch}
onSelectPatchOption={setPatchOption}
disabled={disableChildren || isLoadingFleetMaintainedApp}
showPatchWhenClosedNotice
/>
)}
/>
<div className="modal-cta-wrap">
<GitOpsModeTooltipWrapper
entityType="software"
position="top"
tipOffset={8}
renderChildren={(disableChildren) => (
<Button
onClick={onSave}
isLoading={isSaving}
disabled={disableChildren || isLoadingFleetMaintainedApp}
>
Save
</Button>
)}
/>
<Button variant="inverse" onClick={onExit}>
Cancel
</Button>
</div>
</>
</Modal>
);
};
export default DeployModal;
@@ -0,0 +1,3 @@
.deploy-modal {
overflow-wrap: anywhere;
}
@@ -0,0 +1 @@
export { default } from "./DeployModal";
@@ -62,6 +62,7 @@ interface IEditSoftwareModalProps {
* software" we're editing one specific installer on a title that has
* several, not the title's only package. */
canActivateMultiplePackages?: boolean;
patchWhenClosed?: boolean;
}
const EditSoftwareModal = ({
@@ -79,6 +80,7 @@ const EditSoftwareModal = ({
source,
iconUrl = undefined,
canActivateMultiplePackages = false,
patchWhenClosed = false,
}: IEditSoftwareModalProps) => {
const queryClient = useQueryClient();
const { gitOpsModeEnabled } = useGitOpsMode("software");
@@ -219,6 +221,7 @@ const EditSoftwareModal = ({
// progress bar at 97% until the server response is received
setUploadProgress(Math.max(progress - 0.03, 0.01));
},
omitPreInstallQuery: patchWhenClosed,
});
notify.success(
@@ -367,6 +370,7 @@ const EditSoftwareModal = ({
defaultCategories={softwarePackage.categories}
gitopsCompatible={isGitOpsCompatible}
teamId={teamId}
patchWhenClosed={patchWhenClosed}
/>
);
}
@@ -14,8 +14,6 @@ interface IPoliciesModalProps {
onExit: () => void;
}
// TODO: Marko to update the design of this modal — layout, plus a
// description that accounts for patch policies that may not auto-update.
const PoliciesModal = ({ policies, teamId, onExit }: IPoliciesModalProps) => {
return (
<Modal className={baseClass} title="Policies" onExit={onExit}>
@@ -257,7 +257,7 @@ describe("Software Summary Card", () => {
expect(options).not.toContain("Edit configuration");
});
it("adds Versions option after Patch for a Premium Fleet-maintained app", async () => {
it("adds Versions after Deploy for a Premium Fleet-maintained app", async () => {
const { user } = render(
<SoftwareSummaryCard
softwareTitle={createMockSoftwareTitle({
@@ -274,12 +274,12 @@ describe("Software Summary Card", () => {
);
const options = await getDropdownOptions(user);
const patchIdx = options.indexOf("Patch");
const deployIdx = options.indexOf("Deploy");
const versionsIdx = options.indexOf("Versions");
expect(patchIdx).toBeGreaterThan(-1);
expect(deployIdx).toBeGreaterThan(-1);
expect(versionsIdx).toBeGreaterThan(-1);
expect(versionsIdx).toBe(patchIdx + 1);
expect(versionsIdx).toBe(deployIdx + 1);
});
it("hides Versions option on Fleet Free even for a Fleet-maintained app", async () => {
@@ -315,6 +315,7 @@ describe("Software Summary Card", () => {
const options = await getDropdownOptions(user);
expect(options).not.toContain("Versions");
expect(options).not.toContain("Deploy");
});
it("hides the Actions dropdown (and therefore Versions) for non-FMA custom installers", () => {
@@ -540,7 +541,11 @@ describe("Software Summary Card", () => {
automatic_install_policies: [
{ id: 1, name: "Policy A", type: "dynamic" },
],
patch_policy: { id: 42, name: "Outdated Postman" },
patch_policy: {
id: 42,
name: "Outdated Postman",
patch_when_closed: false,
},
}),
})}
softwareId={1}
@@ -607,7 +612,11 @@ describe("Software Summary Card", () => {
softwareTitle={createMockSoftwareTitle({
software_package: createMockSoftwarePackage({
fleet_maintained_app_id: 7,
patch_policy: { id: 42, name: "Outdated Postman" },
patch_policy: {
id: 42,
name: "Outdated Postman",
patch_when_closed: false,
},
}),
})}
softwareId={1}
@@ -631,7 +640,11 @@ describe("Software Summary Card", () => {
automatic_install_policies: [
{ id: 1, name: "Policy A", type: "dynamic" },
],
patch_policy: { id: 42, name: "Outdated Postman" },
patch_policy: {
id: 42,
name: "Outdated Postman",
patch_when_closed: false,
},
}),
})}
softwareId={1}
@@ -28,7 +28,7 @@ import EditIconModal from "../EditIconModal";
import EditSoftwareModal from "../EditSoftwareModal";
import EditConfigurationModal from "../EditConfigurationModal";
import EditAutoUpdateConfigModal from "../EditAutoUpdateConfigModal";
import AddPatchPolicyModal from "../AddPatchPolicyModal";
import DeployModal from "../DeployModal";
import PoliciesModal from "../PoliciesModal";
interface ISoftwareSummaryCard {
@@ -78,7 +78,7 @@ const SoftwareSummaryCard = ({
const [iconUploadedAt, setIconUploadedAt] = useState("");
const [showEditIconModal, setShowEditIconModal] = useState(false);
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
const [showAddPatchPolicyModal, setShowAddPatchPolicyModal] = useState(false);
const [showDeployModal, setShowDeployModal] = useState(false);
const [showEditConfigurationModal, setShowEditConfigurationModal] = useState(
false
);
@@ -265,7 +265,8 @@ const SoftwareSummaryCard = ({
const canEditConfiguration =
canManageSoftware &&
((isAndroidPlayStoreApp && !isAndroidPlayStoreWebApp) || isIosOrIpadosApp);
const canPatchSoftware = canManageSoftware && isFleetMaintainedApp;
const canDeploySoftware =
canManageSoftware && isFleetMaintainedApp && !!isPremiumTier;
/** Versions / pin is a Premium-only Fleet-maintained app feature */
const canManageVersions =
canManageSoftware && isFleetMaintainedApp && !!isPremiumTier;
@@ -278,7 +279,7 @@ const SoftwareSummaryCard = ({
const onClickEditAppearance = () => setShowEditIconModal(true);
const onClickEditSoftware = () => setShowEditSoftwareModal(true);
const onClickAddPatchPolicy = () => setShowAddPatchPolicyModal(true);
const onClickDeploy = () => setShowDeployModal(true);
const onClickEditConfiguration = () => setShowEditConfigurationModal(true);
const onClickEditAutoUpdateConfig = () =>
setShowEditAutoUpdateConfigModal(true);
@@ -315,9 +316,7 @@ const SoftwareSummaryCard = ({
: undefined
}
useSingleEditAppearanceButton={canActivateMultiplePackages}
onClickAddPatchPolicy={
canPatchSoftware ? onClickAddPatchPolicy : undefined
}
onClickDeploy={canDeploySoftware ? onClickDeploy : undefined}
onClickVersions={canManageVersions ? onClickVersions : undefined}
onClickEditConfiguration={
canEditConfiguration ? onClickEditConfiguration : undefined
@@ -325,7 +324,6 @@ const SoftwareSummaryCard = ({
onClickEditAutoUpdateConfig={
canEditAutoUpdateConfig ? onClickEditAutoUpdateConfig : undefined
}
patchPolicyId={softwareTitle.software_package?.patch_policy?.id}
headerPills={headerPills}
isAppleVpp={isAppleVpp}
/>
@@ -366,14 +364,17 @@ const SoftwareSummaryCard = ({
displayName={softwareDisplayName}
source={softwareTitle.source}
iconUrl={softwareTitle.icon_url}
patchWhenClosed={
softwareTitle.software_package?.patch_policy?.patch_when_closed
}
/>
)}
{showAddPatchPolicyModal && softwareInstallerOnTeam && (
<AddPatchPolicyModal
softwareId={softwareTitle.id}
{showDeployModal && softwareInstallerOnTeam && (
<DeployModal
softwareTitle={softwareTitle}
teamId={teamId}
onSuccess={refetchSoftwareTitle}
onExit={() => setShowAddPatchPolicyModal(false)}
onExit={() => setShowDeployModal(false)}
/>
)}
{showEditConfigurationModal && softwareInstallerOnTeam && (
@@ -6,7 +6,7 @@ import SoftwareDetailsSummary, {
ACTION_EDIT_APPEARANCE,
ACTION_EDIT_SOFTWARE,
ACTION_EDIT_CONFIGURATION,
ACTION_PATCH,
ACTION_DEPLOY,
ACTION_VERSIONS,
ACTION_EDIT_AUTO_UPDATE_CONFIGURATION,
} from "./SoftwareDetailsSummary";
@@ -24,10 +24,9 @@ describe("buildActionOptions", () => {
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
expect(result).toEqual([
@@ -46,10 +45,9 @@ describe("buildActionOptions", () => {
repoURL: undefined,
canEditSoftware: true,
canEditConfiguration: false,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const values = result.map((o) => o.value);
@@ -72,10 +70,9 @@ describe("buildActionOptions", () => {
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: true,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const values = result.map((o) => o.value);
@@ -99,10 +96,9 @@ describe("buildActionOptions", () => {
isAppleVpp: true,
canEditSoftware: true,
canEditConfiguration: true,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const editAppearance = result.find(
@@ -132,67 +128,41 @@ describe("buildActionOptions", () => {
});
});
it("adds Patch option enabled when canAddPatchPolicy and no existing patch policy", () => {
it("adds Deploy when software can be deployed", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: true,
canDeploySoftware: true,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const patch = result.find((opt) => opt.value === ACTION_PATCH);
const deploy = result.find((opt) => opt.value === ACTION_DEPLOY);
expect(patch).toEqual({
label: "Patch",
value: ACTION_PATCH,
isDisabled: false,
tooltipContent: undefined,
expect(deploy).toEqual({
label: "Deploy",
value: ACTION_DEPLOY,
});
});
it("adds Patch option disabled with tooltip when hasExistingPatchPolicy", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: true,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: true,
});
const patch = result.find((opt) => opt.value === ACTION_PATCH);
expect(patch).toEqual({
label: "Patch",
value: ACTION_PATCH,
isDisabled: true,
tooltipContent: "Patch policy is already added.",
});
});
it("adds Versions option after Patch when canManageVersions", () => {
it("adds Versions option after Deploy when canManageVersions", () => {
const result = buildActionOptions({
gitOpsModeEnabled: false,
repoURL: undefined,
canEditSoftware: true,
canEditConfiguration: false,
canAddPatchPolicy: true,
canDeploySoftware: true,
canManageVersions: true,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const values = result.map((o) => o.value);
expect(values).toEqual([
ACTION_EDIT_APPEARANCE,
ACTION_EDIT_SOFTWARE,
ACTION_PATCH,
ACTION_DEPLOY,
ACTION_VERSIONS,
]);
@@ -209,10 +179,9 @@ describe("buildActionOptions", () => {
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
expect(result.find((o) => o.value === ACTION_VERSIONS)).toBeUndefined();
@@ -224,10 +193,9 @@ describe("buildActionOptions", () => {
repoURL: "https://repo.git",
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: true,
canConfigureAutoUpdate: false,
hasExistingPatchPolicy: false,
});
const versions = result.find((opt) => opt.value === ACTION_VERSIONS);
@@ -243,10 +211,9 @@ describe("buildActionOptions", () => {
repoURL: undefined,
canEditSoftware: false,
canEditConfiguration: false,
canAddPatchPolicy: false,
canDeploySoftware: false,
canManageVersions: false,
canConfigureAutoUpdate: true,
hasExistingPatchPolicy: false,
});
const autoUpdate = result.find(
@@ -40,7 +40,7 @@ import OSIcon from "../../icons/OSIcon";
export const ACTION_EDIT_APPEARANCE = "edit_appearance";
export const ACTION_EDIT_SOFTWARE = "edit_software";
export const ACTION_EDIT_CONFIGURATION = "edit_configuration";
export const ACTION_PATCH = "patch";
export const ACTION_DEPLOY = "deploy";
export const ACTION_VERSIONS = "versions";
export const ACTION_EDIT_AUTO_UPDATE_CONFIGURATION =
"edit_auto_update_configuration";
@@ -57,10 +57,9 @@ export interface BuildActionOptionsArgs {
isAppleVpp?: boolean;
canEditSoftware: boolean;
canEditConfiguration: boolean;
canAddPatchPolicy: boolean;
canDeploySoftware: boolean;
canManageVersions: boolean;
canConfigureAutoUpdate: boolean;
hasExistingPatchPolicy?: boolean;
}
export const buildActionOptions = ({
@@ -69,14 +68,12 @@ export const buildActionOptions = ({
isAppleVpp = false,
canEditSoftware,
canEditConfiguration,
canAddPatchPolicy,
canDeploySoftware,
canManageVersions,
canConfigureAutoUpdate,
hasExistingPatchPolicy = false,
}: BuildActionOptionsArgs): CustomOptionType[] => {
let disableEditAppearanceTooltipContent: TooltipContent | undefined;
let disableEditSoftwareTooltipContent: TooltipContent | undefined;
let disabledPatchPolicyTooltipContent: TooltipContent | undefined;
let disabledEditConfigurationTooltipContent: TooltipContent | undefined;
// Disable state is keyed off `gitOpsModeEnabled` directly (see each option
@@ -95,10 +92,6 @@ export const buildActionOptions = ({
}
}
if (hasExistingPatchPolicy) {
disabledPatchPolicyTooltipContent = "Patch policy is already added.";
}
const options: CustomOptionType[] = [
{
label: "Edit appearance",
@@ -128,13 +121,11 @@ export const buildActionOptions = ({
});
}
// Show patch option only for fleet maintained apps
if (canAddPatchPolicy) {
// Show Deploy only for Fleet-maintained apps.
if (canDeploySoftware) {
options.push({
label: "Patch",
value: ACTION_PATCH,
isDisabled: !!disabledPatchPolicyTooltipContent,
tooltipContent: disabledPatchPolicyTooltipContent,
label: "Deploy",
value: ACTION_DEPLOY,
});
}
@@ -185,8 +176,8 @@ interface ISoftwareDetailsSummaryProps {
/** Displays an edit CTA to edit the software installer
* Should only be defined for team view of an installable software */
onClickEditSoftware?: () => void;
/** Displays Patch CTA to add a patch policy */
onClickAddPatchPolicy?: () => void;
/** Displays Deploy CTA for Fleet-maintained apps. */
onClickDeploy?: () => void;
/** Displays Versions CTA to open the versions / pin modal (Premium FMA only) */
onClickVersions?: () => void;
/** undefined unless previewing icon, in which case is string or null */
@@ -197,7 +188,6 @@ interface ISoftwareDetailsSummaryProps {
iconPreviewUrl?: string | null;
/** timestamp of when icon was last uploaded, used to force refresh of cached icon */
iconUploadedAt?: string;
patchPolicyId?: number;
/** Optional pill row rendered between the title and the Actions dropdown
* (e.g. Fleet-maintained, Self-service, Auto install). */
headerPills?: React.ReactNode;
@@ -224,13 +214,12 @@ const SoftwareDetailsSummary = ({
canManageSoftware = false,
onClickEditAppearance,
onClickEditSoftware,
onClickAddPatchPolicy,
onClickDeploy,
onClickVersions,
onClickEditConfiguration,
onClickEditAutoUpdateConfig,
iconPreviewUrl,
iconUploadedAt,
patchPolicyId,
headerPills,
isAppleVpp = false,
useSingleEditAppearanceButton = false,
@@ -248,8 +237,8 @@ const SoftwareDetailsSummary = ({
case ACTION_EDIT_SOFTWARE:
onClickEditSoftware && onClickEditSoftware();
break;
case ACTION_PATCH:
onClickAddPatchPolicy && onClickAddPatchPolicy();
case ACTION_DEPLOY:
onClickDeploy && onClickDeploy();
break;
case ACTION_VERSIONS:
onClickVersions && onClickVersions();
@@ -300,10 +289,9 @@ const SoftwareDetailsSummary = ({
isAppleVpp,
canEditSoftware: !!onClickEditSoftware,
canEditConfiguration: !!onClickEditConfiguration,
canAddPatchPolicy: !!onClickAddPatchPolicy,
canDeploySoftware: !!onClickDeploy,
canManageVersions: !!onClickVersions,
canConfigureAutoUpdate: !!onClickEditAutoUpdateConfig,
hasExistingPatchPolicy: !!patchPolicyId,
});
return (
@@ -30,6 +30,7 @@ interface IAdvancedOptionsFieldsProps {
onChangeUninstallScript: (value?: string) => void;
gitopsCompatible?: boolean;
gitOpsModeEnabled?: boolean;
patchWhenClosed?: boolean;
}
const AdvancedOptionsFields = ({
@@ -53,6 +54,7 @@ const AdvancedOptionsFields = ({
onChangeUninstallScript,
gitopsCompatible = false,
gitOpsModeEnabled = false,
patchWhenClosed = false,
}: IAdvancedOptionsFieldsProps) => {
const classNames = classnames(baseClass, className);
@@ -84,8 +86,20 @@ const AdvancedOptionsFields = ({
maxLines={10}
onChange={onChangePreInstallQuery}
labelActionComponent={renderLabelComponent()}
helpText="Software will be installed only if the query returns results."
readOnly={disableFields}
helpText={
<>
Software will be installed only if the query returns results.
{patchWhenClosed && (
<>
{" "}
Pre-install query won&apos;t run when install is triggered via
self-service, manually on the host, or during the setup
experience.
</>
)}
</>
}
readOnly={disableFields || patchWhenClosed}
/>
<Editor
wrapEnabled
@@ -219,6 +219,7 @@ interface IPackageAdvancedOptionsProps {
/** Currently for editing FMA only, users cannot edit */
gitopsCompatible?: boolean;
gitOpsModeEnabled?: boolean;
patchWhenClosed?: boolean;
}
const PackageAdvancedOptions = ({
@@ -236,6 +237,7 @@ const PackageAdvancedOptions = ({
onChangeUninstallScript,
gitopsCompatible = false,
gitOpsModeEnabled = false,
patchWhenClosed = false,
}: IPackageAdvancedOptionsProps) => {
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false);
const name = selectedPackage?.name || "";
@@ -269,6 +271,7 @@ const PackageAdvancedOptions = ({
onChangeUninstallScript={onChangeUninstallScript}
gitopsCompatible={gitopsCompatible}
gitOpsModeEnabled={gitOpsModeEnabled}
patchWhenClosed={patchWhenClosed}
/>
);
};
@@ -38,6 +38,7 @@ describe("PackageForm", () => {
expect(screen.queryByText(TARGET_BANNER_COPY)).not.toBeInTheDocument();
expect(screen.queryByLabelText("All hosts")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument();
expect(screen.queryByText("Self-service")).not.toBeInTheDocument();
});
it("renders the Target section with the first-added banner once a file is selected", () => {
@@ -49,6 +50,7 @@ describe("PackageForm", () => {
expect(screen.getByText(TARGET_BANNER_COPY)).toBeInTheDocument();
expect(screen.getByLabelText("All hosts")).toBeInTheDocument();
expect(screen.getByLabelText("Custom")).toBeInTheDocument();
expect(screen.getByText("Self-service")).toBeInTheDocument();
});
it("omits the first-added banner on the Edit flow", () => {
@@ -151,6 +151,7 @@ interface IPackageFormProps {
/** Overrides the initial `targetType` for new (non-editing) forms. The
* multi-package add modal preselects `"Custom"` per Figma. */
initialTargetType?: string;
patchWhenClosed?: boolean;
}
// application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly
const ACCEPTED_EXTENSIONS =
@@ -179,6 +180,7 @@ const PackageForm = ({
restrictedFileAccept,
restrictedFileTypeLabel,
initialTargetType,
patchWhenClosed = false,
}: IPackageFormProps) => {
const { gitOpsModeEnabled, repoURL } = useGitOpsMode("software");
@@ -408,11 +410,13 @@ const PackageForm = ({
);
// GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector.
// Options selector (self-service + categories) stays edit-only. The target
// selector shows whenever a package is being staged — on Edit, in the
// The options selector exposes Self-service on Add and Edit; categories
// remain edit-only inside SoftwareOptionsSelector. The target selector
// shows whenever a package is being staged — on Edit, in the
// multi-package Add modal, and on the single-package Add page once a file
// is chosen — because every package on a title needs its own label scope.
const showSoftwareOptionsSelector = !gitOpsModeEnabled && isEditingSoftware;
const showSoftwareOptionsSelector =
!gitOpsModeEnabled && (isEditingSoftware || !!formData.software);
const showTargetLabelSelector =
!gitOpsModeEnabled &&
(isEditingSoftware || multiPackageContext || !!formData.software);
@@ -525,6 +529,7 @@ const PackageForm = ({
onChangeUninstallScript={onChangeUninstallScript}
gitopsCompatible={gitopsCompatible}
gitOpsModeEnabled={gitOpsModeEnabled}
patchWhenClosed={patchWhenClosed}
/>
)}
<div className={`${baseClass}__action-buttons`}>
@@ -0,0 +1,78 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import SoftwareDeploySelector, { PatchOption } from "./SoftwareDeploySelector";
const renderSelector = (
overrides: Partial<React.ComponentProps<typeof SoftwareDeploySelector>> = {}
) => {
const props: React.ComponentProps<typeof SoftwareDeploySelector> = {
forceInstall: false,
patch: false,
patchOption: "closed",
onToggleForceInstall: jest.fn(),
onTogglePatch: jest.fn(),
onSelectPatchOption: jest.fn(),
...overrides,
};
return { ...render(<SoftwareDeploySelector {...props} />), props };
};
describe("SoftwareDeploySelector", () => {
it("shows Force install without patch options", () => {
renderSelector({ forceInstall: true });
expect(
screen.getByRole("checkbox", { name: "force-install" })
).toBeChecked();
expect(screen.queryByRole("radio")).not.toBeInTheDocument();
});
it("shows patch options with Patch when app is closed selected by default", () => {
renderSelector({ patch: true });
expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked();
expect(
screen.getByRole("radio", { name: "Patch when app is closed" })
).toBeChecked();
});
it("supports Force install and Patch together", () => {
renderSelector({ forceInstall: true, patch: true });
expect(
screen.getByRole("checkbox", { name: "force-install" })
).toBeChecked();
expect(screen.getByRole("checkbox", { name: "patch" })).toBeChecked();
expect(screen.getAllByRole("radio")).toHaveLength(3);
});
it("shows the Force patch information banner", () => {
renderSelector({ patch: true, patchOption: "force" });
expect(
screen.getByText(
"End user is not notified. Patch is forced as soon as policy fails. Notifications are coming soon."
)
).toBeInTheDocument();
});
it("shows the pre-install query override notice in the Deploy modal", () => {
renderSelector({ patch: true, showPatchWhenClosedNotice: true });
expect(
screen.getByText(/overrides the pre-install query \(advanced option\)/)
).toBeInTheDocument();
});
it("reports the selected patch option", async () => {
const onSelectPatchOption = jest.fn<void, [PatchOption]>();
const { getByRole } = renderSelector({
patch: true,
onSelectPatchOption,
});
getByRole("radio", { name: "Force patch" }).click();
expect(onSelectPatchOption).toHaveBeenCalledWith("force");
});
});
@@ -0,0 +1,135 @@
import React from "react";
import Checkbox from "components/forms/fields/Checkbox";
import Radio from "components/forms/fields/Radio";
import InfoBanner from "components/InfoBanner";
const baseClass = "software-deploy-selector";
export type PatchOption = "closed" | "force" | "manual";
export const getPatchPolicyFlags = (patchOption: PatchOption) => ({
patch_when_closed: patchOption === "closed",
continuous_automations_enabled: patchOption !== "manual",
});
interface ISoftwareDeploySelectorProps {
forceInstall: boolean;
patch: boolean;
patchOption: PatchOption;
onToggleForceInstall: (value: boolean) => void;
onTogglePatch: (value: boolean) => void;
onSelectPatchOption: (value: PatchOption) => void;
disabled?: boolean;
showPatchWhenClosedNotice?: boolean;
}
interface IPatchOptionSelectorProps {
patchOption: PatchOption;
onSelectPatchOption: (value: PatchOption) => void;
disabled?: boolean;
showPatchWhenClosedNotice?: boolean;
}
export const PatchOptionSelector = ({
patchOption,
onSelectPatchOption,
disabled = false,
showPatchWhenClosedNotice = false,
}: IPatchOptionSelectorProps) => {
const onChangePatchOption = (value: string) =>
onSelectPatchOption(value as PatchOption);
return (
<div
className={`${baseClass}__patch-options`}
role="radiogroup"
aria-label="Patch options"
>
<Radio
id="patch-when-closed"
name="patch-option"
value="closed"
label="Patch when app is closed"
checked={patchOption === "closed"}
onChange={onChangePatchOption}
disabled={disabled}
/>
<Radio
id="force-patch"
name="patch-option"
value="force"
label="Force patch"
checked={patchOption === "force"}
onChange={onChangePatchOption}
disabled={disabled}
/>
<Radio
id="manual-patch"
name="patch-option"
value="manual"
label="End user initiated (manual)"
checked={patchOption === "manual"}
onChange={onChangePatchOption}
disabled={disabled}
/>
{patchOption === "force" && (
<InfoBanner color="yellow">
End user is not notified. Patch is forced as soon as policy fails.
Notifications are coming soon.
</InfoBanner>
)}
{patchOption === "closed" && showPatchWhenClosedNotice && (
<p className={`${baseClass}__patch-when-closed-notice`}>
<b>Patch when app is closed</b> overrides the pre-install query
(advanced option) to check if the app is closed.
</p>
)}
</div>
);
};
const SoftwareDeploySelector = ({
forceInstall,
patch,
patchOption,
onToggleForceInstall,
onTogglePatch,
onSelectPatchOption,
disabled = false,
showPatchWhenClosedNotice = false,
}: ISoftwareDeploySelectorProps) => {
return (
<div className={`form-field ${baseClass}`}>
<div className="form-field__label">Deploy</div>
<div className={`${baseClass}__checkboxes`}>
<Checkbox
name="force-install"
value={forceInstall}
onChange={onToggleForceInstall}
disabled={disabled}
>
Force install
</Checkbox>
<Checkbox
name="patch"
value={patch}
onChange={onTogglePatch}
disabled={disabled}
>
Patch
</Checkbox>
</div>
{patch && (
<PatchOptionSelector
patchOption={patchOption}
onSelectPatchOption={onSelectPatchOption}
disabled={disabled}
showPatchWhenClosedNotice={showPatchWhenClosedNotice}
/>
)}
</div>
);
};
export default SoftwareDeploySelector;
@@ -1,2 +1,26 @@
.software-deploy-slider {
}
.software-deploy-selector {
&__checkboxes {
display: flex;
gap: $pad-xlarge;
.form-field--checkbox {
margin-bottom: 0;
width: auto;
}
}
&__patch-options {
display: flex;
flex-direction: column;
gap: $pad-medium;
margin-top: $pad-medium;
margin-left: $pad-xlarge;
}
&__patch-when-closed-notice {
margin: 0;
}
}
@@ -1 +1,7 @@
export { default } from "./SoftwareDeploySlider";
export {
default as SoftwareDeploySelector,
PatchOptionSelector,
} from "./SoftwareDeploySelector";
export { getPatchPolicyFlags } from "./SoftwareDeploySelector";
export type { PatchOption } from "./SoftwareDeploySelector";
@@ -0,0 +1,29 @@
import React from "react";
import { screen } from "@testing-library/react";
import { createMockVppApp } from "__mocks__/appleMdm";
import { createCustomRenderer } from "test/test-utils";
import SoftwareVppForm from "./SoftwareVppForm";
describe("SoftwareVppForm", () => {
it("shows Self-service after selecting an app to add", async () => {
const render = createCustomRenderer({ withBackendMock: true });
const { user } = render(
<SoftwareVppForm
labels={[]}
vppApps={[createMockVppApp()]}
onSubmit={jest.fn()}
onCancel={jest.fn()}
onClickPreviewEndUserExperience={jest.fn()}
teamId={1}
/>
);
expect(screen.queryByRole("switch")).not.toBeInTheDocument();
await user.click(screen.getByRole("radio", { name: /Test App/ }));
expect(screen.getByText("Self-service")).toBeInTheDocument();
});
});
@@ -319,6 +319,20 @@ const SoftwareVppForm = ({
These apps were added in Apple Business (AB). To add more apps, head
to <CustomLink url="https://business.apple.com" text="AB" newTab />
</div>
{formData.selectedApp && (
<SoftwareOptionsSelector
platform={formData.selectedApp.platform}
formData={formData}
onToggleSelfService={onToggleSelfService}
onSelectCategory={onSelectCategory}
onClickPreviewEndUserExperience={() =>
onClickPreviewEndUserExperience(
isIpadOrIphoneSoftware(formData.selectedApp?.platform || "")
)
}
teamId={teamId}
/>
)}
{showDeploySoftwareSlider && (
<SoftwareDeploySlider
deploySoftware={formData.automaticInstall}
@@ -0,0 +1,54 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import { noop } from "lodash";
import { createMockHostPastActivity } from "__mocks__/activityMock";
import { ActivityType } from "interfaces/activity";
import InstalledSoftwareActivityItem from "./InstalledSoftwareActivityItem";
const createInstallActivity = (installSkippedWhenAppOpen?: boolean) =>
createMockHostPastActivity({
type: ActivityType.InstalledSoftware,
actor_full_name: "Fleet",
fleet_initiated: true,
details: {
software_title: "Firefox",
software_package: "Firefox.pkg",
host_display_name: "Test Host",
source: "apps",
status: "failed_install",
install_uuid: "uuid-123",
install_skipped_when_app_open: installSkippedWhenAppOpen,
},
});
describe("InstalledSoftwareActivityItem", () => {
it("renders skipped copy when the app was open", () => {
render(
<InstalledSoftwareActivityItem
activity={createInstallActivity(true)}
tab="past"
onShowDetails={noop}
/>
);
expect(screen.getByText(/skipped install of/)).toBeInTheDocument();
expect(screen.getByText("Firefox")).toBeInTheDocument();
expect(screen.getByText("Test Host")).toBeInTheDocument();
expect(screen.queryByText(/failed to install/)).not.toBeInTheDocument();
});
it("keeps generic failed-install copy when the flag is absent", () => {
render(
<InstalledSoftwareActivityItem
activity={createInstallActivity()}
tab="past"
onShowDetails={noop}
/>
);
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
expect(screen.queryByText(/skipped install/)).not.toBeInTheDocument();
});
});
@@ -31,6 +31,22 @@ const InstalledSoftwareActivityItem = ({
details.status === "failed" ? "failed_uninstall" : details.status;
const isScriptPackageSource = SCRIPT_PACKAGE_SOURCES.includes(source || "");
if (details.install_skipped_when_app_open) {
return (
<ActivityItem
className={baseClass}
activity={activity}
hideCancel={hideCancel}
onShowDetails={onShowDetails}
onCancel={onCancel}
isSoloActivity={isSoloActivity}
>
<b>Fleet</b> skipped install of <b>{title}</b> on{" "}
<b>{details.host_display_name || "this host"}</b>.
</ActivityItem>
);
}
// Self-service installs/uninstalls can be triggered by anyone who opens the
// host's My device page, including admins. Drop the actor and switch to
// passive voice so the activity reads "<software> was installed on this
@@ -150,7 +150,10 @@ const render = createCustomRenderer({
* external `handleRef` always sees the latest closure. */
const renderWithHandle = (
policyOverrides?: Partial<IPolicy>,
handleRef?: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null>
handleRef?: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null>,
componentProps?: Partial<
React.ComponentPropsWithoutRef<typeof PolicyAutomationsFields>
>
) => {
return render(
<PolicyAutomationsFields
@@ -161,6 +164,7 @@ const renderWithHandle = (
automationsConfig={undefined}
globalConfig={undefined}
fleetName="Test Fleet"
{...componentProps}
/>
);
};
@@ -337,4 +341,131 @@ describe("PolicyAutomationsFields — payload", () => {
// installer_id as null on the wire.
expect(payload?.policyUpdate?.software_installer_id ?? null).toBeNull();
});
it("maps Patch when app is closed to both policy flags", () => {
const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = {
current: null,
};
renderWithHandle(
{
type: "patch",
patch_software: { name: "Firefox", software_title_id: 42 },
patch_when_closed: false,
continuous_automations_enabled: false,
},
handleRef,
{ patchOption: "closed" }
);
expect(
handleRef.current?.getAutomationsPayload().policyUpdate
).toMatchObject({
software_title_id: 42,
patch_when_closed: true,
continuous_automations_enabled: true,
});
});
it("maps Force patch to patch_when_closed false and continuous automation true", () => {
const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = {
current: null,
};
renderWithHandle(
{
type: "patch",
patch_software: { name: "Firefox", software_title_id: 42 },
patch_when_closed: true,
continuous_automations_enabled: true,
},
handleRef,
{ patchOption: "force" }
);
expect(
handleRef.current?.getAutomationsPayload().policyUpdate
).toMatchObject({
software_title_id: 42,
patch_when_closed: false,
continuous_automations_enabled: true,
});
});
it("maps End user initiated to no continuous automation", () => {
const handleRef: React.MutableRefObject<IPolicyAutomationsFieldsHandle | null> = {
current: null,
};
renderWithHandle(
{
type: "patch",
patch_software: { name: "Firefox", software_title_id: 42 },
install_software: { name: "Firefox", software_title_id: 42 },
patch_when_closed: false,
continuous_automations_enabled: true,
},
handleRef,
{ patchOption: "manual" }
);
expect(
handleRef.current?.getAutomationsPayload().policyUpdate
).toMatchObject({
software_title_id: null,
continuous_automations_enabled: false,
});
expect(
screen.queryByRole("checkbox", {
name: "continuous-automations-enabled",
})
).not.toBeInTheDocument();
});
it("checks and disables continuous automation for Patch when app is closed", async () => {
const { user, container } = renderWithHandle(
{
type: "patch",
patch_when_closed: true,
continuous_automations_enabled: true,
},
undefined,
{ patchOption: "closed" }
);
const continuous = screen.getByRole("checkbox", {
name: "continuous-automations-enabled",
});
expect(continuous).toHaveAttribute("aria-checked", "true");
expect(continuous).toHaveAttribute("aria-disabled", "true");
const icon = container.querySelector(
".policy-automations-fields__section:last-child .fleet-checkbox__icon"
);
expect(icon).not.toBeNull();
await user.hover(icon as Element);
expect(
await screen.findByText(
"Continuous automation can't be disabled when Patch when app is closed is selected."
)
).toBeInTheDocument();
});
it("keeps continuous automation editable for Force patch", async () => {
const onPatchOptionChange = jest.fn();
const { user } = renderWithHandle(
{
type: "patch",
patch_when_closed: false,
continuous_automations_enabled: true,
},
undefined,
{ patchOption: "force", onPatchOptionChange }
);
const continuous = screen.getByRole("checkbox", {
name: "continuous-automations-enabled",
});
expect(continuous).toHaveAttribute("aria-disabled", "false");
await user.click(continuous);
expect(onPatchOptionChange).toHaveBeenCalledWith("manual");
});
});
@@ -34,6 +34,7 @@ import {
getTicketOrWebhookLabel,
} from "pages/policies/helpers";
import { getDisplayedSoftwareName } from "pages/SoftwarePage/helpers";
import { PatchOption } from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
import { IPolicyAutomationUpdate } from "pages/policies/hooks";
@@ -85,6 +86,9 @@ interface IPolicyAutomationsFieldsProps {
globalConfig: IConfig | undefined;
/** Fleet display name, used in the "Not enabled for <fleet>" hints. */
fleetName: string;
/** Present only for patch policies on Premium. */
patchOption?: PatchOption;
onPatchOptionChange?: (patchOption: PatchOption) => void;
}
const PolicyAutomationsFields = forwardRef<
@@ -99,6 +103,8 @@ const PolicyAutomationsFields = forwardRef<
automationsConfig,
globalConfig,
fleetName,
patchOption,
onPatchOptionChange,
},
ref
) => {
@@ -147,6 +153,7 @@ const PolicyAutomationsFields = forwardRef<
const initialCalendar = policy.calendar_events_enabled;
const initialConditionalAccess = policy.conditional_access_enabled;
const initialContinuous = policy.continuous_automations_enabled ?? false;
const initialPatchWhenClosed = policy.patch_when_closed ?? false;
const [webhookOrTicketEnabled, setWebhookOrTicketEnabled] = useState(
initialWebhookOrTicket
@@ -162,6 +169,15 @@ const PolicyAutomationsFields = forwardRef<
const [continuousEnabled, setContinuousEnabled] = useState(
initialContinuous
);
const patchWhenClosed = patchOption
? patchOption === "closed"
: initialPatchWhenClosed;
let effectiveContinuousEnabled = continuousEnabled;
if (patchWhenClosed) {
effectiveContinuousEnabled = true;
} else if (patchOption) {
effectiveContinuousEnabled = patchOption === "force";
}
const [softwareTitleId, setSoftwareTitleId] = useState<number | null>(
policy.install_software?.software_title_id ?? null
@@ -173,6 +189,22 @@ const PolicyAutomationsFields = forwardRef<
const [softwareInstallerId, setSoftwareInstallerId] = useState<
number | null
>(policy.install_software?.software_installer_id ?? null);
const patchSoftwareTitleId =
policy.patch_software?.software_title_id ?? null;
const effectiveInstallSoftware =
patchOption === undefined ? installSoftware : patchOption !== "manual";
let effectiveSoftwareTitleId = softwareTitleId;
let effectiveSoftwareInstallerId = softwareInstallerId;
if (patchOption !== undefined) {
effectiveSoftwareTitleId = effectiveInstallSoftware
? patchSoftwareTitleId
: null;
effectiveSoftwareInstallerId =
effectiveInstallSoftware &&
policy.install_software?.software_title_id === patchSoftwareTitleId
? softwareInstallerId
: null;
}
const [scriptId, setScriptId] = useState<number | null>(
policy.run_script?.id ?? null
);
@@ -189,13 +221,14 @@ const PolicyAutomationsFields = forwardRef<
const validate = (): IAutomationsErrors => {
const newErrors: IAutomationsErrors = {};
if (installSoftware && softwareTitleId === null) {
if (effectiveInstallSoftware && effectiveSoftwareTitleId === null) {
newErrors.install_software = "Please select software to install.";
} else if (
installSoftware &&
softwareTitleId !== null &&
patchOption === undefined &&
effectiveInstallSoftware &&
effectiveSoftwareTitleId !== null &&
(selectedTitlePackages?.length ?? 0) > 0 &&
softwareInstallerId === null
effectiveSoftwareInstallerId === null
) {
// Only reachable when a custom title (with packages[]) is selected
// but its packages haven't hydrated yet — the auto-select effect
@@ -233,12 +266,21 @@ const PolicyAutomationsFields = forwardRef<
setScriptId(id);
if (id !== null) clearError("run_script");
};
const handleToggleContinuous = (next: boolean) => {
setContinuousEnabled(next);
if (patchOption) {
onPatchOptionChange?.(next ? "force" : "manual");
}
};
const canFetchTeamScopedLists =
!isGlobalPolicy && teamIdForApi !== undefined;
const { data: softwareTitlesData } = useSoftwareTitles({
fleetId: teamIdForApi ?? 0,
enabled: canFetchTeamScopedLists && installSoftware,
enabled:
canFetchTeamScopedLists &&
effectiveInstallSoftware &&
patchOption === undefined,
});
const { data: scriptsData } = useScripts({
fleetId: teamIdForApi ?? 0,
@@ -317,16 +359,18 @@ const PolicyAutomationsFields = forwardRef<
const perPolicyDirty =
!isGlobalPolicy &&
(installSoftware !== initialInstallSoftware ||
softwareTitleId !==
(effectiveInstallSoftware !== initialInstallSoftware ||
effectiveSoftwareTitleId !==
(policy.install_software?.software_title_id ?? null) ||
softwareInstallerId !==
effectiveSoftwareInstallerId !==
(policy.install_software?.software_installer_id ?? null) ||
runScript !== initialRunScript ||
scriptId !== (policy.run_script?.id ?? null) ||
calendarEvent !== initialCalendar ||
conditionalAccess !== initialConditionalAccess ||
continuousEnabled !== initialContinuous);
effectiveContinuousEnabled !== initialContinuous ||
(patchOption !== undefined &&
patchWhenClosed !== initialPatchWhenClosed));
const webhookDirty = webhookOrTicketEnabled !== initialWebhookOrTicket;
return {
@@ -334,13 +378,14 @@ const PolicyAutomationsFields = forwardRef<
isDirty: perPolicyDirty || webhookDirty,
policyUpdate: perPolicyDirty
? {
software_title_id: installSoftware ? softwareTitleId : null,
// Send the pinned installer id when install-software is on;
// omit when unchecked so the backend can clear it. Null
// (title selected, no packages hydrated yet) is a validation
// error and shouldn't reach here.
software_installer_id: installSoftware
? softwareInstallerId
software_title_id: effectiveInstallSoftware
? effectiveSoftwareTitleId
: null,
// Send the pinned installer id when install-software is on.
// Null clears the automation or lets the backend select the
// Fleet-maintained app's installer when a Patch radio owns it.
software_installer_id: effectiveInstallSoftware
? effectiveSoftwareInstallerId
: null,
script_id: runScript ? scriptId : null,
// When the team has the feature disabled, the row is locked
@@ -354,7 +399,11 @@ const PolicyAutomationsFields = forwardRef<
...(isConditionalAccessEnabledForTeam && {
conditional_access_enabled: conditionalAccess,
}),
continuous_automations_enabled: continuousEnabled,
continuous_automations_enabled: effectiveContinuousEnabled,
...(patchOption !== undefined &&
patchWhenClosed !== initialPatchWhenClosed && {
patch_when_closed: patchWhenClosed,
}),
}
: undefined,
webhookOrTicketUpdate: webhookDirty
@@ -387,47 +436,49 @@ const PolicyAutomationsFields = forwardRef<
learnMoreUrl="https://fleetdm.com/learn-more-about/policy-automation-install-software"
/>
),
checked: installSoftware,
checked: effectiveInstallSoftware,
onToggle: handleToggleInstallSoftware,
isDisabled: false,
picker: installSoftware ? (
<div className={`${baseClass}__software-pickers`}>
<DropdownWrapper
name="software-title"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
softwareOptions.find(
(o) => o.value === String(softwareTitleId ?? "")
) ?? null
}
options={softwareOptions}
placeholder="Select software"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectSoftware(opt ? Number(opt.value) : null)
}
/>
{/* Only surfaces for multi-package titles; first-added is
auto-selected above, so this is pin-adjustment. */}
{packageOptions.length > 1 && (
isLocked: patchOption !== undefined,
picker:
effectiveInstallSoftware && patchOption === undefined ? (
<div className={`${baseClass}__software-pickers`}>
<DropdownWrapper
name="software-package"
name="software-title"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
packageOptions.find(
(o) => o.value === String(softwareInstallerId ?? "")
softwareOptions.find(
(o) => o.value === String(softwareTitleId ?? "")
) ?? null
}
options={packageOptions}
placeholder="Select package"
options={softwareOptions}
placeholder="Select software"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectPackage(opt ? Number(opt.value) : null)
handleSelectSoftware(opt ? Number(opt.value) : null)
}
/>
)}
</div>
) : undefined,
{/* Only surfaces for multi-package titles; first-added is
auto-selected above, so this is pin-adjustment. */}
{packageOptions.length > 1 && (
<DropdownWrapper
name="software-package"
className={`${baseClass}__row-picker`}
isDisabled={gitOpsModeEnabled}
value={
packageOptions.find(
(o) => o.value === String(softwareInstallerId ?? "")
) ?? null
}
options={packageOptions}
placeholder="Select package"
onChange={(opt: SingleValue<CustomOptionType>) =>
handleSelectPackage(opt ? Number(opt.value) : null)
}
/>
)}
</div>
) : undefined,
},
{
key: "run_script",
@@ -566,15 +617,20 @@ const PolicyAutomationsFields = forwardRef<
</div>
</div>
{!isGlobalPolicy && (
{!isGlobalPolicy && patchOption !== "manual" && (
<div className={`${baseClass}__section`}>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<Checkbox
name="continuous-automations-enabled"
value={continuousEnabled}
disabled={disableChildren}
onChange={setContinuousEnabled}
value={effectiveContinuousEnabled}
disabled={disableChildren || patchWhenClosed}
onChange={handleToggleContinuous}
iconTooltipContent={
patchWhenClosed
? "Continuous automation can't be disabled when Patch when app is closed is selected."
: undefined
}
helpText="If the automations do not resolve the policy, this could cause a retry loop."
>
<TooltipWrapper
@@ -11,6 +11,8 @@ import createMockConfig from "__mocks__/configMock";
import { createMockTeamSummary } from "__mocks__/teamMock";
import { ILabelSummary } from "interfaces/label";
import teamPoliciesAPI from "services/entities/team_policies";
import teamsAPI from "services/entities/teams";
import PolicyForm from "./PolicyForm";
const baseUrl = (path: string) => {
@@ -41,6 +43,8 @@ const labelSummariesHandler = http.get(baseUrl("/labels/summary"), () => {
});
describe("PolicyForm - component", () => {
afterEach(() => jest.restoreAllMocks());
const defaultProps = {
router: createMockRouter(),
teamIdForApi: 3,
@@ -102,6 +106,33 @@ describe("PolicyForm - component", () => {
expect(screen.getByLabelText("Name")).toHaveAttribute("maxlength", "255");
});
it("hides patch options in the free tier", () => {
const render = createCustomRenderer({
withBackendMock: true,
context: {
app: {
currentUser: createMockUser(),
config: createMockConfig(),
isPremiumTier: false,
},
},
});
render(
<PolicyForm
{...defaultProps}
storedPolicy={createMockPolicy({
type: "patch",
patch_software: { name: "Firefox", software_title_id: 42 },
})}
/>
);
expect(
screen.queryByRole("radiogroup", { name: "Patch options" })
).not.toBeInTheDocument();
});
describe("in premium tier", () => {
beforeEach(() => {
mockServer.use(labelSummariesHandler);
@@ -800,6 +831,138 @@ describe("PolicyForm - component", () => {
expect(screen.queryByLabelText("Custom")).not.toBeInTheDocument();
});
it("selects Patch when app is closed from the stored policy flags", () => {
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={{
...patchPolicy,
patch_when_closed: true,
continuous_automations_enabled: true,
}}
/>
);
expect(
screen.getByRole("radio", { name: "Patch when app is closed" })
).toBeChecked();
});
it("selects Force patch from the stored policy flags", () => {
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={{
...patchPolicy,
install_software: {
name: "Firefox",
software_title_id: 42,
},
patch_when_closed: false,
continuous_automations_enabled: true,
}}
/>
);
expect(
screen.getByRole("radio", { name: "Force patch" })
).toBeChecked();
});
it("selects Force patch for a migrated attached policy without continuous automation", () => {
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={{
...patchPolicy,
install_software: {
name: "Firefox",
software_title_id: 42,
},
patch_when_closed: false,
continuous_automations_enabled: false,
}}
/>
);
expect(
screen.getByRole("radio", { name: "Force patch" })
).toBeChecked();
});
it("selects manual when continuous automation is on without install software", () => {
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={{
...patchPolicy,
install_software: undefined,
patch_when_closed: false,
continuous_automations_enabled: true,
}}
/>
);
expect(
screen.getByRole("radio", { name: "End user initiated (manual)" })
).toBeChecked();
});
it("saves the selected patch option before automation configuration loads", async () => {
jest
.spyOn(teamsAPI, "load")
.mockReturnValue(new Promise(() => undefined));
const updatePolicySpy = jest
.spyOn(teamPoliciesAPI, "update")
.mockResolvedValue({} as never);
const teamPatchPolicy = {
...patchPolicy,
team_id: 1,
patch_when_closed: false,
continuous_automations_enabled: false,
};
const onUpdate = jest.fn().mockResolvedValue({});
const { user } = renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={teamPatchPolicy}
onUpdate={onUpdate}
/>
);
await user.click(screen.getByRole("radio", { name: "Force patch" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(updatePolicySpy).toHaveBeenCalledWith(teamPatchPolicy.id, {
team_id: 1,
software_title_id: 42,
patch_when_closed: false,
continuous_automations_enabled: true,
})
);
expect(onUpdate).toHaveBeenCalledTimes(1);
});
it("selects End user initiated when both stored policy flags are false", () => {
renderPatchPolicy(
<PolicyForm
{...patchPolicyProps}
storedPolicy={{
...patchPolicy,
patch_when_closed: false,
continuous_automations_enabled: false,
}}
/>
);
expect(
screen.getByRole("radio", {
name: "End user initiated (manual)",
})
).toBeChecked();
});
it("submits only editable fields on save", async () => {
const onUpdate = jest.fn();
renderPatchPolicy(
@@ -820,14 +983,17 @@ describe("PolicyForm - component", () => {
expect(payload).not.toHaveProperty("labels_include_any");
});
it("shows 'Add automation' CTA when patch policy has no install_software", async () => {
it("hides the legacy Add automation CTA because the Patch radios own install automation", async () => {
renderPatchPolicy(<PolicyForm {...patchPolicyProps} />);
await waitFor(() => {
expect(
screen.getByText(/Automatically patch Firefox/)
screen.getByRole("radio", { name: "End user initiated (manual)" })
).toBeInTheDocument();
expect(screen.getByText(/Add automation/)).toBeInTheDocument();
});
expect(
screen.queryByText(/Automatically patch Firefox/)
).not.toBeInTheDocument();
expect(screen.queryByText(/Add automation/)).not.toBeInTheDocument();
});
it("hides 'Add automation' CTA when automation already exists", async () => {
@@ -55,6 +55,11 @@ import PolicyAutomationsFields, {
IPolicyAutomationsPayload,
} from "pages/policies/components/PolicyAutomationsFields";
import { PatchAutomationCta } from "pages/policies/components";
import {
getPatchPolicyFlags,
PatchOption,
PatchOptionSelector,
} from "pages/SoftwarePage/components/forms/SoftwareDeploySelector";
import {
useUpdatePolicyAutomations,
usePolicyLabelTargets,
@@ -135,6 +140,27 @@ const PolicyForm = ({
const isPatchPolicy = storedPolicy?.type === "patch";
const [isAddingAutomation, setIsAddingAutomation] = useState(false);
const [patchOption, setPatchOption] = useState<PatchOption>("manual");
const storedPatchPolicyId = storedPolicy?.id;
const storedPatchWhenClosed = storedPolicy?.patch_when_closed;
const storedInstallSoftwareId =
storedPolicy?.install_software?.software_title_id;
useEffect(() => {
if (!isPatchPolicy || !storedPatchPolicyId) return;
let nextPatchOption: PatchOption = "manual";
if (storedPatchWhenClosed) {
nextPatchOption = "closed";
} else if (storedInstallSoftwareId) {
nextPatchOption = "force";
}
setPatchOption(nextPatchOption);
}, [
isPatchPolicy,
storedPatchPolicyId,
storedPatchWhenClosed,
storedInstallSoftwareId,
]);
// Note: The PolicyContext values should always be used for any mutable policy data such as query name
// The storedPolicy prop should only be used to access immutable metadata such as author id
@@ -409,6 +435,19 @@ const PolicyForm = ({
let automations: IPolicyAutomationsPayload | undefined;
if (isEditMode) {
automations = automationsRef.current?.getAutomationsPayload();
if (!automations && isPremiumTier && isPatchPolicy) {
automations = {
isValid: true,
isDirty: true,
policyUpdate: {
software_title_id:
patchOption === "manual"
? null
: storedPolicy?.patch_software?.software_title_id ?? null,
...getPatchPolicyFlags(patchOption),
},
};
}
if (automations && !automations.isValid) {
return;
}
@@ -682,15 +721,31 @@ const PolicyForm = ({
disableOptions={gitOpsModeEnabled}
/>
)}
{isEditMode && isPremiumTier && isPatchPolicy && (
<div className="form-field">
<div className="form-field__label">Patch</div>
<GitOpsModeTooltipWrapper
renderChildren={(disableChildren) => (
<PatchOptionSelector
patchOption={patchOption}
onSelectPatchOption={setPatchOption}
disabled={disableChildren}
/>
)}
/>
</div>
)}
{isEditMode && !!storedPolicy && !!automationsConfig && (
<div className="form-field">
<div className="form-field__label">Automations</div>
<PatchAutomationCta
storedPolicy={storedPolicy}
canEditPolicy={isEditMode}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
/>
{!(isPremiumTier && isPatchPolicy) && (
<PatchAutomationCta
storedPolicy={storedPolicy}
canEditPolicy={isEditMode}
onAddAutomation={onAddPatchAutomation}
isAddingAutomation={isAddingAutomation}
/>
)}
<PolicyAutomationsFields
key={storedPolicy.updated_at}
ref={automationsRef}
@@ -700,6 +755,10 @@ const PolicyForm = ({
automationsConfig={automationsConfig}
globalConfig={config ?? undefined}
fleetName={automationsFleetName}
patchOption={
isPremiumTier && isPatchPolicy ? patchOption : undefined
}
onPatchOptionChange={setPatchOption}
/>
</div>
)}
@@ -18,6 +18,7 @@ export type IPolicyAutomationUpdate = Pick<
| "calendar_events_enabled"
| "conditional_access_enabled"
| "continuous_automations_enabled"
| "patch_when_closed"
>;
export interface IUpdatePolicyAutomationsVars {
+13 -7
View File
@@ -277,7 +277,8 @@ const handleDisplayNameForm = (
const handleEditPackageForm = (
data: IEditPackageFormData,
formData: FormData,
orignalPackage: ISoftwarePackage
orignalPackage: ISoftwarePackage,
omitPreInstallQuery = false
) => {
data.software && formData.append("software", data.software);
formData.append("self_service", data.selfService.toString());
@@ -286,10 +287,12 @@ const handleEditPackageForm = (
"install_script",
encodeScriptBase64(data.installScript) || ""
);
formData.append(
"pre_install_query",
encodeScriptBase64(data.preInstallQuery || "") || ""
);
if (!omitPreInstallQuery) {
formData.append(
"pre_install_query",
encodeScriptBase64(data.preInstallQuery || "") || ""
);
}
formData.append(
"post_install_script",
encodeScriptBase64(data.postInstallScript || "") || ""
@@ -609,6 +612,7 @@ export default {
timeout,
onUploadProgress,
signal,
omitPreInstallQuery,
}: {
data:
| IEditPackageFormData
@@ -624,6 +628,7 @@ export default {
timeout?: number;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
signal?: AbortSignal;
omitPreInstallQuery?: boolean;
}) => {
const { EDIT_SOFTWARE_PACKAGE } = endpoints;
const formData = new FormData();
@@ -650,7 +655,8 @@ export default {
handleEditPackageForm(
data as IEditPackageFormData,
formData,
orignalPackage
orignalPackage,
omitPreInstallQuery
);
}
@@ -847,7 +853,7 @@ export default {
post_install_script: encodeScriptBase64(formData.postInstallScript),
uninstall_script: encodeScriptBase64(formData.uninstallScript),
self_service: formData.selfService,
automatic_install: formData.automaticInstall,
automatic_install: formData.forceInstall,
categories: formData.categories,
};
@@ -0,0 +1,53 @@
import sendRequest from "services";
import teamPoliciesAPI from "./team_policies";
jest.mock("services", () => ({
__esModule: true,
default: jest.fn(),
}));
const mockSendRequest = sendRequest as jest.MockedFunction<typeof sendRequest>;
describe("teamPoliciesAPI patch policy flags", () => {
beforeEach(() => {
mockSendRequest.mockReset();
mockSendRequest.mockResolvedValue({});
});
it("forwards both flags when creating a patch policy", async () => {
await teamPoliciesAPI.create({
team_id: 1,
type: "patch",
patch_software_title_id: 10,
patch_when_closed: true,
continuous_automations_enabled: true,
});
expect(mockSendRequest).toHaveBeenCalledWith(
"POST",
expect.stringContaining("/1/policies"),
expect.objectContaining({
patch_when_closed: true,
continuous_automations_enabled: true,
})
);
});
it("retains false flag values when updating a patch policy", async () => {
await teamPoliciesAPI.update(22, {
team_id: 1,
patch_when_closed: false,
continuous_automations_enabled: false,
});
expect(mockSendRequest).toHaveBeenCalledWith(
"PATCH",
expect.stringContaining("/1/policies/22"),
expect.objectContaining({
patch_when_closed: false,
continuous_automations_enabled: false,
})
);
});
});
+6 -1
View File
@@ -88,7 +88,8 @@ export default {
labels_exclude_all,
type,
patch_software_title_id,
// note absence of automations-related fields, which are only set by the UI via update
continuous_automations_enabled,
patch_when_closed,
} = data;
const { TEAMS } = endpoints;
const path = `${TEAMS}/${team_id}/policies`;
@@ -107,6 +108,8 @@ export default {
labels_exclude_all,
type,
patch_software_title_id,
continuous_automations_enabled,
patch_when_closed,
});
},
// TODO - response type Promise<IPolicy>
@@ -123,6 +126,7 @@ export default {
calendar_events_enabled,
conditional_access_enabled,
continuous_automations_enabled,
patch_when_closed,
software_title_id,
software_installer_id,
script_id,
@@ -144,6 +148,7 @@ export default {
calendar_events_enabled,
conditional_access_enabled,
continuous_automations_enabled,
patch_when_closed,
software_title_id,
software_installer_id,
script_id,
@@ -113,6 +113,21 @@ export const getSoftwareInstallHandlerOnlyPreInstallOutput = http.get(
}
);
export const getSoftwareInstallHandlerAppOpen = http.get(
baseUrl("/software/install/:install_uuid/results"),
({ params }) => {
return HttpResponse.json({
results: createMockSoftwareInstallResult({
install_uuid: params.install_uuid as string,
status: "failed_install",
output: "",
post_install_script_output: "",
pre_install_query_output: "The app was open\nInstall stopped",
}),
});
}
);
// Installed, with SHA-256 hash
export const getSoftwareInstallHandlerWithHash = http.get(
baseUrl("/software/install/:install_uuid/results"),
+1 -1
View File
@@ -2994,7 +2994,7 @@ func (ds *Datastore) getPatchPolicyInstaller(ctx context.Context, teamID uint, t
}
func (ds *Datastore) GetPatchPolicy(ctx context.Context, teamID *uint, titleID uint) (*fleet.PatchPolicyData, error) {
query := `SELECT id, name, patch_when_closed FROM policies WHERE team_id = ? AND patch_software_title_id = ?`
query := `SELECT id, name, patch_when_closed, continuous_automations_enabled FROM policies WHERE team_id = ? AND patch_software_title_id = ?`
var policy fleet.PatchPolicyData
err := sqlx.GetContext(ctx, ds.reader(ctx), &policy, query, ptr.ValOrZero(teamID), titleID)
+1 -1
View File
@@ -15,7 +15,7 @@ type MaintainedApp struct {
UniqueIdentifier string `json:"-" db:"unique_identifier"`
InstallScript string `json:"install_script,omitempty" db:"install_script"`
UninstallScript string `json:"uninstall_script,omitempty" db:"uninstall_script"`
AutomaticInstallQuery string `json:"-" db:"pre_install_query"`
AutomaticInstallQuery string `json:"automatic_install_query,omitempty" db:"pre_install_query"`
Categories []string `json:"categories"`
UpgradeCode string `json:"upgrade_code,omitempty" db:"upgrade_code"`
PatchQuery string `json:"-" db:"patch_query"`
+4 -3
View File
@@ -889,9 +889,10 @@ type AutomaticInstallPolicy struct {
}
type PatchPolicyData struct {
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
PatchWhenClosed bool `json:"patch_when_closed" db:"patch_when_closed"`
ID uint `json:"id" db:"id"`
Name string `json:"name" db:"name"`
PatchWhenClosed bool `json:"patch_when_closed" db:"patch_when_closed"`
ContinuousAutomationsEnabled bool `json:"continuous_automations_enabled" db:"continuous_automations_enabled"`
}
// SoftwarePackageOrApp provides information about a software installer
+10 -9
View File
@@ -21809,15 +21809,16 @@ func (s *integrationEnterpriseTestSuite) TestMaintainedApps() {
_, err = maintained_apps.Hydrate(ctx, dbAppRecord, "", nil, nil)
require.NoError(t, err)
dbAppResponse := fleet.MaintainedApp{
ID: dbAppRecord.ID,
Name: dbAppRecord.Name,
Slug: dbAppRecord.Slug,
Version: dbAppRecord.Version,
Platform: dbAppRecord.Platform,
InstallerURL: dbAppRecord.InstallerURL,
InstallScript: dbAppRecord.InstallScript,
UninstallScript: dbAppRecord.UninstallScript,
Categories: []string{"Productivity"},
ID: dbAppRecord.ID,
Name: dbAppRecord.Name,
Slug: dbAppRecord.Slug,
Version: dbAppRecord.Version,
Platform: dbAppRecord.Platform,
InstallerURL: dbAppRecord.InstallerURL,
InstallScript: dbAppRecord.InstallScript,
UninstallScript: dbAppRecord.UninstallScript,
AutomaticInstallQuery: dbAppRecord.AutomaticInstallQuery,
Categories: []string{"Productivity"},
}
require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallerURL)
require.NotEmpty(t, getMAResp.FleetMaintainedApp.InstallScript)