patch when closed UI
This commit is contained in:
@@ -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"],
|
||||
|
||||
+46
@@ -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 });
|
||||
|
||||
+31
-3
@@ -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 = (
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+41
@@ -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,
|
||||
|
||||
+10
@@ -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.
|
||||
|
||||
+68
@@ -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();
|
||||
});
|
||||
});
|
||||
+38
-8
@@ -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
|
||||
|
||||
+100
@@ -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/)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+49
-9
@@ -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}
|
||||
|
||||
-34
@@ -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) &&
|
||||
|
||||
-45
@@ -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);
|
||||
});
|
||||
});
|
||||
-91
@@ -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'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";
|
||||
+268
@@ -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";
|
||||
+4
@@ -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}>
|
||||
|
||||
+20
-7
@@ -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}
|
||||
|
||||
+13
-12
@@ -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 && (
|
||||
|
||||
+17
-50
@@ -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(
|
||||
|
||||
+13
-25
@@ -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 (
|
||||
|
||||
+16
-2
@@ -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't run when install is triggered via
|
||||
self-service, manually on the host, or during the setup
|
||||
experience.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
readOnly={disableFields || patchWhenClosed}
|
||||
/>
|
||||
<Editor
|
||||
wrapEnabled
|
||||
|
||||
+3
@@ -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`}>
|
||||
|
||||
+78
@@ -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");
|
||||
});
|
||||
});
|
||||
+135
@@ -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";
|
||||
|
||||
+29
@@ -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}
|
||||
|
||||
+54
@@ -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();
|
||||
});
|
||||
});
|
||||
+16
@@ -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
|
||||
|
||||
+132
-1
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
+107
-51
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user