diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx
new file mode 100644
index 0000000000..ae325551b8
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tests.tsx
@@ -0,0 +1,78 @@
+import React from "react";
+import { render, screen } from "@testing-library/react";
+
+import SoftwareDeploySelector, { PatchOption } from "./SoftwareDeploySelector";
+
+const renderSelector = (
+ overrides: Partial
> = {}
+) => {
+ const props: React.ComponentProps = {
+ forceInstall: false,
+ patch: false,
+ patchOption: "closed",
+ onToggleForceInstall: jest.fn(),
+ onTogglePatch: jest.fn(),
+ onSelectPatchOption: jest.fn(),
+ ...overrides,
+ };
+ return { ...render(), 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();
+ const { getByRole } = renderSelector({
+ patch: true,
+ onSelectPatchOption,
+ });
+
+ getByRole("radio", { name: "Force patch" }).click();
+ expect(onSelectPatchOption).toHaveBeenCalledWith("force");
+ });
+});
diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx
new file mode 100644
index 0000000000..142d1086c9
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/SoftwareDeploySelector.tsx
@@ -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 (
+
+
+
+
+ {patchOption === "force" && (
+
+ End user is not notified. Patch is forced as soon as policy fails.
+ Notifications are coming soon.
+
+ )}
+ {patchOption === "closed" && showPatchWhenClosedNotice && (
+
+ Patch when app is closed overrides the pre-install query
+ (advanced option) to check if the app is closed.
+
+ )}
+
+ );
+};
+
+const SoftwareDeploySelector = ({
+ forceInstall,
+ patch,
+ patchOption,
+ onToggleForceInstall,
+ onTogglePatch,
+ onSelectPatchOption,
+ disabled = false,
+ showPatchWhenClosedNotice = false,
+}: ISoftwareDeploySelectorProps) => {
+ return (
+
+
Deploy
+
+
+ Force install
+
+
+ Patch
+
+
+ {patch && (
+
+ )}
+
+ );
+};
+
+export default SoftwareDeploySelector;
diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss
index 9b85bdff22..2c75223215 100644
--- a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/_styles.scss
@@ -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;
+ }
+}
diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts
index fd84411aad..59ae55fd90 100644
--- a/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareDeploySelector/index.ts
@@ -1 +1,7 @@
export { default } from "./SoftwareDeploySlider";
+export {
+ default as SoftwareDeploySelector,
+ PatchOptionSelector,
+} from "./SoftwareDeploySelector";
+export { getPatchPolicyFlags } from "./SoftwareDeploySelector";
+export type { PatchOption } from "./SoftwareDeploySelector";
diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx
new file mode 100644
index 0000000000..60b4640139
--- /dev/null
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tests.tsx
@@ -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(
+
+ );
+
+ expect(screen.queryByRole("switch")).not.toBeInTheDocument();
+
+ await user.click(screen.getByRole("radio", { name: /Test App/ }));
+
+ expect(screen.getByText("Self-service")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx
index 3c2b418e69..3ec1bf47db 100644
--- a/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx
+++ b/frontend/pages/SoftwarePage/components/forms/SoftwareVppForm/SoftwareVppForm.tsx
@@ -319,6 +319,20 @@ const SoftwareVppForm = ({
These apps were added in Apple Business (AB). To add more apps, head
to
+ {formData.selectedApp && (
+