From 8d7066f05b57c72ba76cc03fb863f7137ca75824 Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Mon, 29 Jun 2026 17:11:23 -0400
Subject: [PATCH 1/2] Fleet UI: Multi-package Library foundation (#48400)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Foundation slice for the multi-package title-details page. Renders
title.packages[] when present and falls back to [software_package] so
back-compat single-package titles look identical to before. No new
modals yet — the AddPackageModal and per-installer Edit/Delete wiring
land in a follow-up slice.
- Types: optional installer_id on ISoftwarePackage; packages[] on
ISoftwareTitle and ISoftwareTitleDetails. installer_id stays optional
until #48397 ships and the server populates it everywhere.
- API client: addSoftwarePackage takes software_title_id (multi-package
add); editSoftwarePackage and deleteSoftwareInstaller take an optional
installer_id so they target one specific package.
- MSW: new frontend/test/handlers/multi-package-handlers.ts with a
buildTitleWithPackages helper and handlers for GET (multi/empty), POST
(happy + duplicate-hash + 10-limit + FMA-conflict + VPP-conflict),
PATCH per-installer, and DELETE per-installer.
- Library section iterates packages instead of operating on a single
software_package; description copy updated to Figma. App-store /
custom-package branches stay mutually exclusive at the data layer.
TODOs flagged in code:
- Strip issue refs (#48396/#48397/#48400) from comments before merging
to main.
- Make installer_id required once #48397 ships.
- Drop the software_package fallback in renderLibrarySection once
packages[] is always present.
---
frontend/__mocks__/softwareMock.ts | 4 +
frontend/interfaces/software.ts | 19 ++
.../SoftwareTitleDetailsPage.tsx | 184 +++++++++--------
.../SoftwareTitleDetailsPage/helpers.tests.ts | 3 +
frontend/services/entities/software.ts | 29 ++-
.../test/handlers/multi-package-handlers.ts | 189 ++++++++++++++++++
6 files changed, 341 insertions(+), 87 deletions(-)
create mode 100644 frontend/test/handlers/multi-package-handlers.ts
diff --git a/frontend/__mocks__/softwareMock.ts b/frontend/__mocks__/softwareMock.ts
index 0b89d5321c0..c02b38d639e 100644
--- a/frontend/__mocks__/softwareMock.ts
+++ b/frontend/__mocks__/softwareMock.ts
@@ -224,6 +224,7 @@ const DEFAULT_SOFTWARE_TITLE_DETAILS_MOCK: ISoftwareTitleDetails = {
name: "test.app",
icon_url: null,
software_package: null,
+ packages: null,
app_store_app: null,
source: "apps",
hosts_count: 1,
@@ -260,6 +261,7 @@ export const createMockSoftwareVersionResponse = (
};
const DEFAULT_SOFTWARE_PACKAGE_MOCK: ISoftwarePackage = {
+ installer_id: 1,
name: "TestPackage-1.2.3.pkg",
title_id: 2,
version: "1.2.3",
@@ -295,6 +297,7 @@ export const createMockSoftwarePackage = (
};
const DEFAULT_SOFTWARE_PACKAGE_IOS_MOCK: ISoftwarePackage = {
+ installer_id: 2,
name: "MyApp-2.0.0.ipa",
title_id: 10,
version: "2.0.0",
@@ -336,6 +339,7 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
extension_for: "",
versions: [createMockSoftwareTitleVersion()],
software_package: createMockSoftwarePackage(),
+ packages: null,
app_store_app: null,
};
diff --git a/frontend/interfaces/software.ts b/frontend/interfaces/software.ts
index 023789e85a7..65bd2d7d3c9 100644
--- a/frontend/interfaces/software.ts
+++ b/frontend/interfaces/software.ts
@@ -123,7 +123,17 @@ export interface IFleetMaintainedVersion {
uploaded_at: string;
}
+// TODO(48400, before merge to main): strip the (#48396/#48397/#48400) issue
+// refs sprinkled through this PR's comments — git blame is the audit trail.
+
export interface ISoftwarePackage {
+ /** Per-installer id — distinct from `title_id`. Used by per-package edit/delete
+ * endpoints so the request targets one specific package on a title that may
+ * have several. Optional during the transition; #48397 sets the canonical
+ * server-side name (could land as `id`, `package_id`, etc. — search-replace
+ * when locked).
+ * TODO(48400): make required once #48397 ships. */
+ installer_id?: number;
name: string;
/** Not included in SoftwareTitle software.software_package response, hoisted up one level
* Custom name set per team by admin
@@ -212,7 +222,11 @@ export interface ISoftwareTitle {
extension_for?: SoftwareExtensionFor;
hosts_count: number;
versions: ISoftwareTitleVersion[] | null;
+ /** First-added; mirrors packages[0]. Retained for back-compat. */
software_package: ISoftwarePackage | null;
+ /** All custom packages on this title (trimmed shape on list responses). `null`
+ * until the server is on the multi-package contract (#48397). */
+ packages: ISoftwarePackage[] | null;
app_store_app: IAppStoreApp | null;
/** @deprecated Use extension_for instead */
browser?: string;
@@ -225,7 +239,12 @@ export interface ISoftwareTitleDetails {
/** Custom name set per team by admin */
display_name?: string;
icon_url: string | null;
+ /** First-added; mirrors packages[0]. Retained for back-compat. */
software_package: ISoftwarePackage | null;
+ /** All custom packages on this title, in first-added order. `null` until the
+ * server is on the multi-package contract (#48397). When present, treat it
+ * as the source of truth; `software_package` is a convenience alias. */
+ packages: ISoftwarePackage[] | null;
app_store_app: IAppStoreApp | null;
source: SoftwareSource;
extension_for?: SoftwareExtensionFor;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
index c8e8233b52d..d85f5b8b12d 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx
@@ -229,7 +229,29 @@ const SoftwareTitleDetailsPage = ({
return null;
}
+ // Prefer the multi-package array (#48397). Fall back to the single-package
+ // alias when the server hasn't moved to the multi-package contract yet, so
+ // back-compat titles render identically to before.
+ // TODO(48400): drop the `software_package` fallback once #48397 ships and
+ // `packages` is always present.
+ const packages =
+ title.packages ??
+ (title.software_package ? [title.software_package] : []);
+ const appStore = title.app_store_app;
+
+ // No installable to render at all.
+ if (packages.length === 0 && !appStore) {
+ return null;
+ }
+
+ // Per-row edit/delete callbacks are intentionally page-level for this
+ // slice — multi-package titles still open the legacy single-package modals
+ // (which target `software_package`). The modal slice introduces
+ // `selectedInstallerId` state plus per-row threading so add/edit/delete
+ // target one specific package on titles with several. Avoid wiring dead
+ // state here so this slice stays honest about what works.
const openEditModal = () => setShowLibraryEditModal(true);
+ const openDeleteModal = () => setShowDeleteModal(true);
const statusPath = (software_status: "installed" | "pending" | "failed") =>
getPathWithQueryParams(paths.MANAGE_HOSTS, {
@@ -238,45 +260,44 @@ const SoftwareTitleDetailsPage = ({
fleet_id: currentTeamId ?? APP_CONTEXT_NO_TEAM_ID,
});
- const libraryAccordionList = () => {
- const pkg = title.software_package;
- const appStore = title.app_store_app;
-
- if (appStore) {
- const { labels, kind } = pickLabels(appStore);
- const isAndroidPlayStoreApp = appStore.platform === "android";
- const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source);
- return (
-
- setShowDeleteModal(true)}
- />
-
- );
- }
+ const renderAppStoreRow = () => {
+ if (!appStore) return null;
+ const { labels, kind } = pickLabels(appStore);
+ const isAndroidPlayStoreApp = appStore.platform === "android";
+ const isIosOrIpadosApp = isIpadOrIphoneSoftwareSource(title.source);
+ return (
+
+ );
+ };
- if (!pkg) return null;
+ // FMAs expand a single package into one badged "active" row plus dimmed
+ // rollback rows for every cached version. Custom packages render exactly
+ // one row each. With multi-package titles we run this per `pkg` so each
+ // top-level entry stays addressable by its own `installer_id`.
+ const renderPackageRows = (pkg: ISoftwarePackage) => {
const { labels, kind } = pickLabels(pkg);
const isFma = installerResult?.meta.isFleetMaintainedApp ?? false;
const isLatestFmaVersion =
@@ -286,61 +307,60 @@ const SoftwareTitleDetailsPage = ({
const { installed, pending, failed } = aggregateInstallStatusCounts(
pkg.status
);
- // FMAs list every cached version (active row badged from the pin, the
- // rest dimmed rollback candidates); other packages render a single row.
const rows = buildLibraryVersionRows({
fleetMaintainedVersions: pkg.fleet_maintained_versions,
activeVersion: pkg.version,
pinnedVersion: pkg.pinned_version,
addedTimestamp: pkg.uploaded_at,
});
- return (
-
- {rows.map((row) => (
- setShowVersionsModal(true)
- : undefined
- }
- onLabelCountClick={openEditModal}
- onLabelsClick={openEditModal}
- onDownloadClick={onDownloadInstaller}
- onTrashClick={() => setShowDeleteModal(true)}
- />
- ))}
-
- );
+ return rows.map((row) => (
+ setShowVersionsModal(true)
+ : undefined
+ }
+ onLabelCountClick={openEditModal}
+ onLabelsClick={openEditModal}
+ onDownloadClick={onDownloadInstaller}
+ onTrashClick={openDeleteModal}
+ />
+ ));
};
+ // App-store and custom-package paths are mutually exclusive at the data
+ // layer (the backend rejects custom uploads against an FMA/VPP title), so
+ // only one branch ever renders rows. The wrapper stays the same shape.
return (
-
- {libraryAccordionList()}
+
+
+ {appStore ? renderAppStoreRow() : packages.map(renderPackageRows)}
+
);
};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
index bc167b179f3..18e81bf92be 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/helpers.tests.ts
@@ -92,6 +92,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
icon_url: "https://example.com/icon.png",
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
software_package: {
+ installer_id: 1,
labels_include_any: null,
labels_exclude_any: null,
labels_include_all: null,
@@ -113,6 +114,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
automatic_install_policies: [],
url: "",
},
+ packages: null,
app_store_app: null,
source: "apps",
hosts_count: 10,
@@ -145,6 +147,7 @@ describe("SoftwareTitleDetailsPage helpers", () => {
icon_url: "https://example.com/icon.png",
versions: [{ id: 1, version: "1.0.0", vulnerabilities: [] }],
software_package: null,
+ packages: null,
app_store_app: {
app_store_id: "1",
name: "Test App",
diff --git a/frontend/services/entities/software.ts b/frontend/services/entities/software.ts
index 7c37f469f65..49352532dda 100644
--- a/frontend/services/entities/software.ts
+++ b/frontend/services/entities/software.ts
@@ -518,12 +518,16 @@ export default {
addSoftwarePackage: ({
data,
teamId,
+ softwareTitleId,
timeout,
onUploadProgress,
signal,
}: {
data: IPackageFormData;
teamId?: number;
+ /** When set, add this package to an existing software title (multi-package flow, #48397).
+ * When omitted, the server creates a new title for the uploaded file (original flow). */
+ softwareTitleId?: number;
timeout?: number;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
signal?: AbortSignal;
@@ -536,6 +540,8 @@ export default {
const formData = new FormData();
formData.append("software", data.software);
+ softwareTitleId !== undefined &&
+ formData.append("software_title_id", softwareTitleId.toString());
formData.append("self_service", data.selfService.toString());
// Base64 encode script fields to bypass WAF rules that block script patterns
data.installScript &&
@@ -598,6 +604,7 @@ export default {
data,
orignalPackage,
softwareId,
+ installerId,
teamId,
timeout,
onUploadProgress,
@@ -610,6 +617,9 @@ export default {
| IVersionPinFormData;
orignalPackage?: ISoftwarePackage;
softwareId: number;
+ /** Targets one specific package on a multi-package title (#48397). Omit on
+ * single-package titles to keep the legacy single-package edit behavior. */
+ installerId?: number;
teamId: number;
timeout?: number;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
@@ -618,6 +628,8 @@ export default {
const { EDIT_SOFTWARE_PACKAGE } = endpoints;
const formData = new FormData();
formData.append("fleet_id", teamId.toString());
+ installerId !== undefined &&
+ formData.append("installer_id", installerId.toString());
if ("configuration" in data) {
// Handles Edit configuration form (iOS/iPadOS in-house apps)
@@ -759,12 +771,19 @@ export default {
return sendRequest("PUT", path, formData);
},
- // Endpoint for deleting packages or VPP
- deleteSoftwareInstaller: (softwareId: number, teamId: number) => {
+ // Endpoint for deleting packages or VPP. Pass `installerId` to delete one
+ // specific package on a multi-package title (#48397); omit to keep the legacy
+ // single-package / VPP behavior (deletes the whole installer slot).
+ deleteSoftwareInstaller: (
+ softwareId: number,
+ teamId: number,
+ installerId?: number
+ ) => {
const { SOFTWARE_AVAILABLE_FOR_INSTALL } = endpoints;
- const path = `${SOFTWARE_AVAILABLE_FOR_INSTALL(
- softwareId
- )}?fleet_id=${teamId}`;
+ const path = getPathWithQueryParams(
+ SOFTWARE_AVAILABLE_FOR_INSTALL(softwareId),
+ { fleet_id: teamId, installer_id: installerId }
+ );
return sendRequest("DELETE", path);
},
diff --git a/frontend/test/handlers/multi-package-handlers.ts b/frontend/test/handlers/multi-package-handlers.ts
new file mode 100644
index 00000000000..12f59b5c035
--- /dev/null
+++ b/frontend/test/handlers/multi-package-handlers.ts
@@ -0,0 +1,189 @@
+import { http, HttpResponse } from "msw";
+
+import { baseUrl } from "test/test-utils";
+import {
+ createMockSoftwarePackage,
+ createMockSoftwareTitleDetails,
+} from "__mocks__/softwareMock";
+import { ISoftwarePackage } from "interfaces/software";
+
+// Per-title package cap exposed by #48396's backend guard. Mirrored here so
+// tests don't have to hard-code the literal in multiple places.
+export const MAX_PACKAGES_PER_TITLE = 10;
+
+const titleUrl = baseUrl("/software/titles/:id");
+const addPackageUrl = baseUrl("/software/package");
+const editPackageUrl = baseUrl("/software/titles/:id/package");
+const deletePackageUrl = baseUrl("/software/titles/:id/available_for_install");
+
+/** Build an `n`-package fixture list. Each package gets a distinct
+ * `installer_id`, name, version, and hash so assertions on per-installer
+ * targeting are unambiguous. */
+export const createMockPackages = (
+ n: number,
+ overrides?: (i: number) => Partial
+): ISoftwarePackage[] =>
+ Array.from({ length: n }, (_, i) =>
+ createMockSoftwarePackage({
+ installer_id: i + 1,
+ title_id: 1,
+ name: `TestPackage-1.${i}.0.pkg`,
+ version: `1.${i}.0`,
+ hash_sha256: `hash${i + 1}`,
+ ...overrides?.(i),
+ })
+ );
+
+/** Build a title-details fixture whose `software_package` is always derived
+ * from `packages[0]`. Use this anywhere a test needs both fields to stay in
+ * lockstep so mutating `packages` can't silently drift the alias. */
+export const buildTitleWithPackages = (
+ packages: ISoftwarePackage[],
+ titleOverrides?: Parameters[0]
+) =>
+ createMockSoftwareTitleDetails({
+ software_package: packages[0] ?? null,
+ packages,
+ ...titleOverrides,
+ });
+
+// GET /software/titles/:id — multi-package response.
+export const getMultiPackageTitleHandler = (
+ packageCount = 2,
+ titleOverrides?: Parameters[0]
+) =>
+ http.get(titleUrl, ({ params }) => {
+ const packages = createMockPackages(packageCount, () => ({
+ title_id: Number(params.id),
+ }));
+ return HttpResponse.json({
+ software_title: buildTitleWithPackages(packages, {
+ id: Number(params.id),
+ ...titleOverrides,
+ }),
+ });
+ });
+
+// GET /software/titles/:id — title with no packages.
+export const getEmptyPackagesTitleHandler = http.get(titleUrl, ({ params }) =>
+ HttpResponse.json({
+ software_title: createMockSoftwareTitleDetails({
+ id: Number(params.id),
+ software_package: null,
+ packages: [],
+ }),
+ })
+);
+
+// POST /software/package — happy path adds to an existing title.
+export const addPackageHandler = http.post(addPackageUrl, async ({ request }) =>
+ HttpResponse.json({
+ software_title_id: Number(
+ (await request.formData()).get("software_title_id") ?? 0
+ ),
+ })
+);
+
+// POST /software/package — duplicate-hash rejection. Copy is verbatim from
+// Figma page 2:130 / issue #48400.
+export const addPackageDuplicateHashHandler = http.post(
+ addPackageUrl,
+ async ({ request }) => {
+ const filename =
+ ((await request.formData()).get("software") as File | null)?.name ?? "";
+ return HttpResponse.json(
+ {
+ errors: [
+ {
+ name: "base",
+ reason: `Couldn't add. ${filename} package is already added (same SHA-256 hash).`,
+ },
+ ],
+ },
+ { status: 409 }
+ );
+ }
+);
+
+// POST /software/package — 10-package limit rejection. Copy is verbatim.
+export const addPackageLimitHandler = (titleName = "Fleet osquery") =>
+ http.post(addPackageUrl, () =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ name: "base",
+ reason: `Couldn't add. ${titleName} already has ${MAX_PACKAGES_PER_TITLE} packages. Before adding, delete one you no longer use.`,
+ },
+ ],
+ },
+ { status: 409 }
+ )
+ );
+
+// POST /software/package — FMA-conflict rejection (preserved from pre-multi-package
+// behavior). Surfaces when the title already has a Fleet-maintained app.
+export const addPackageFmaConflictHandler = (
+ titleName = "Zoom",
+ fleetName = "Testing & QA"
+) =>
+ http.post(addPackageUrl, () =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ name: "base",
+ reason: `Couldn't add. ${titleName} already has a Fleet-maintained app on the ${fleetName} fleet.`,
+ },
+ ],
+ },
+ { status: 409 }
+ )
+ );
+
+// POST /software/package — VPP-conflict rejection (preserved from pre-multi-package
+// behavior). Surfaces when the title already has an Apple App Store (VPP) app.
+export const addPackageVppConflictHandler = (
+ titleName = "Zoom",
+ fleetName = "Testing & QA"
+) =>
+ http.post(addPackageUrl, () =>
+ HttpResponse.json(
+ {
+ errors: [
+ {
+ name: "base",
+ reason: `Couldn't add. ${titleName} already has an Apple App Store (VPP) on the ${fleetName} fleet.`,
+ },
+ ],
+ },
+ { status: 409 }
+ )
+ );
+
+// PATCH /software/titles/:id/package — per-installer edit. Echoes the
+// targeted installer_id so tests can assert the request hit the right row.
+export const editPackageHandler = http.patch(
+ editPackageUrl,
+ async ({ request, params }) => {
+ const form = await request.formData();
+ return HttpResponse.json({
+ software_title_id: Number(params.id),
+ installer_id: Number(form.get("installer_id") ?? 0),
+ });
+ }
+);
+
+// DELETE /software/titles/:id/available_for_install — per-installer delete.
+// 204 when other packages remain; same shape as today when last.
+export const deletePackageHandler = http.delete(
+ deletePackageUrl,
+ () => new HttpResponse(null, { status: 204 })
+);
+
+export const deletePackageErrorHandler = http.delete(deletePackageUrl, () =>
+ HttpResponse.json(
+ { errors: [{ name: "base", reason: "Internal Server Error" }] },
+ { status: 500 }
+ )
+);
From 58e70e0dc2dcff5c98377dd907e841eb01b288b0 Mon Sep 17 00:00:00 2001
From: RachelElysia <71795832+RachelElysia@users.noreply.github.com>
Date: Tue, 30 Jun 2026 16:19:12 -0400
Subject: [PATCH 2/2] Fleet UI: Multi-package add/edit/delete and Library row
indicators (#48400)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Builds on the foundation slice with the per-package modal flows and the
coordinated UI states for multi-package custom titles. Hides title-level
status indicators (chips, page-level Edit) in favor of per-package
indicators on each Library accordion row so the surfaces stay accurate
when one package on a title differs from another.
Single source of truth at the page level: `canActivateMultiplePackages`
flips three behaviors together — the "+ Add package" action, the per-row
self-service / auto-install icons, and the summary card's chip-hide
and pencil-Edit treatment. True for custom non-FMA non-iOS titles
(Mac/Linux/Windows binary + script packages); false for FMA, VPP, Google
Play, and iOS in-house .ipa, which stay on the single-package path.
New components
- AddPackageModal: real in-place modal wrapping PackageForm. File-type
chooser is restricted to the existing title's platform (helper at
AddPackageModal/helpers.ts maps filename -> {accept, label}, with the
.tar.gz workaround mirrored). Standard mode renders the first-added-wins
banner; GitOps mode renders the SHA-256/YAML banner with the "YAML docs"
link. Target defaults to Custom so labels are required.
- PackageForm: new multiPackageContext prop embeds the right banner under
the file uploader, swaps the Save button copy, hides the deploy slider,
and shows the target-label selector above Advanced options. Caught and
fixed a pre-existing duplication of the submit button text across the
tooltipped/non-tooltipped branches that let the copy drift.
- useBlockNavigation hook: extracts the `beforeunload` pattern that was
duplicated across SoftwareCustomPackage, EditSoftwareModal, and the new
AddPackageModal.
Per-package wiring
- selectedInstallerId state on the page resolves which package the Edit /
Delete modal targets; row callbacks pass `pkg.installer_id`.
- EditSoftwareModal title swaps to "Edit package" via the new
canActivateMultiplePackages prop; PATCH carries the installer id.
- DeleteSoftwareModal DELETE carries the installer id when set.
- Per-package PoliciesModal: clicking the auto-install icon navigates to
the single linked policy or opens a modal scoped to that package's
policies, distinct from the title-aggregate modal owned by the
SoftwareSummaryCard.
Library accordion row
- Custom non-iOS rows hide the Latest badge and render clickable
self-service / auto-install indicator icons. A renderRowActionIcon
helper deduplicates the tooltip + optional button + static-fallback
pattern.
- Latest badge tightened to FMA-only (`isFma && badgeState === "latest"`)
so VPP/Play Store/iOS rows don't surface it either.
- ariaLabels: "Edit package" (matches the modal title that opens) for
self-service; "View auto-install policies" / "View patch policy" for
the auto-install icon.
Summary card
- Hides Self-service / Auto install / Patch chips when
canActivateMultiplePackages — per-row icons replace them.
- Collapses the Actions dropdown to a single pencil-icon Edit button
that opens the Edit Appearance modal directly; per-installer editing
is on the accordion. The pencil button is wrapped in
GitOpsModeTooltipWrapper so GitOps mode disables it with the standard
"Managed by GitOps" tooltip.
- "Custom package(s)" chip pluralizes via `pluralize()` from stringUtils.
InfoBanner
- Wired up the existing `icon` prop (previously unused per its TODO) so
banners can render a leading Icon. CSS gates the layout shift behind
the existing `__icon` modifier so callers without the prop are
unaffected.
Tests
- AddPackageModal component tests (13): title, banner copy verbatim,
GitOps mode variant + YAML link, file-type restriction across .pkg
/.deb/.msi/.tar.gz/.sh, Save vs Add software label, Custom radio
preselected.
- EditSoftwareModal title swap (3 tests).
- LibraryItemAccordion custom-package row (4 tests): hidden by default,
self-service icon fires onSelfServiceClick, auto-install icon fires
onAutoInstallClick, patch-policy variant uses the patch aria-label.
- Updated SoftwareSummaryCard chip tests to use FMA mocks so they keep
exercising the chip-render path; added a new test verifying chips are
hidden for multi-package titles.
- New helper tests: getFileTypeRestriction (10), MSW factories (8).
- 411 SoftwarePage tests passing total (up from ~211 pre-PR).
Verbatim copy from Figma page 2:130 throughout (including the "it's"
apostrophe typo in the GitOps banner — preserved deliberately so design
and code stay in sync).
TODOs before merge to main:
- Strip the #48396/#48397/#48400 issue refs sprinkled in comments.
- Lock the `installer_id` field name once #48397 settles.
---
frontend/components/InfoBanner/InfoBanner.tsx | 6 +-
frontend/components/InfoBanner/_styles.scss | 12 +
frontend/hooks/useBlockNavigation.ts | 26 ++
.../SoftwareCustomPackage.tsx | 25 +-
.../AddPackageModal/AddPackageModal.tests.tsx | 148 +++++++++++
.../AddPackageModal/AddPackageModal.tsx | 160 ++++++++++++
.../AddPackageModal/_styles.scss | 5 +
.../AddPackageModal/helpers.tests.ts | 68 +++++
.../AddPackageModal/helpers.ts | 39 +++
.../AddPackageModal/index.ts | 1 +
.../DeleteSoftwareModal.tsx | 13 +-
.../EditSoftwareModal.tests.tsx | 53 ++++
.../EditSoftwareModal/EditSoftwareModal.tsx | 38 +--
.../LibraryItemAccordion.tests.tsx | 92 +++++++
.../LibraryItemAccordion.tsx | 123 +++++++++-
.../SoftwareSummaryCard.tests.tsx | 94 +++++--
.../SoftwareSummaryCard.tsx | 48 +++-
.../SoftwareTitleDetailsPage.tsx | 232 ++++++++++++++++--
.../SoftwareTitleDetailsPage/_styles.scss | 7 +
.../SoftwareDetailsSummary.tsx | 46 +++-
.../forms/PackageForm/PackageForm.tsx | 113 +++++++--
.../handlers/multi-package-handlers.tests.ts | 62 +++++
22 files changed, 1288 insertions(+), 123 deletions(-)
create mode 100644 frontend/hooks/useBlockNavigation.ts
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
create mode 100644 frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tests.tsx
create mode 100644 frontend/test/handlers/multi-package-handlers.tests.ts
diff --git a/frontend/components/InfoBanner/InfoBanner.tsx b/frontend/components/InfoBanner/InfoBanner.tsx
index eea024a374b..dcc2fe106a6 100644
--- a/frontend/components/InfoBanner/InfoBanner.tsx
+++ b/frontend/components/InfoBanner/InfoBanner.tsx
@@ -20,7 +20,10 @@ export interface IInfoBannerProps {
cta?: JSX.Element;
/** closable and link are mutually exclusive */
closable?: boolean;
- icon?: IconNames; // TODO: This is unused but several banners have icons within children that can be refactored to use this for consistent styling
+ /** Renders an icon to the left of the banner copy. When set, the banner
+ * switches from `space-between` to a left-aligned flex layout so the icon
+ * groups with the text rather than getting pushed to the opposite edge. */
+ icon?: IconNames;
}
const InfoBanner = ({
@@ -46,6 +49,7 @@ const InfoBanner = ({
const content = (
<>
+ {icon && }
{children}
{(cta || closable) && (
diff --git a/frontend/components/InfoBanner/_styles.scss b/frontend/components/InfoBanner/_styles.scss
index 4d75d8e9230..9e118ee042d 100644
--- a/frontend/components/InfoBanner/_styles.scss
+++ b/frontend/components/InfoBanner/_styles.scss
@@ -14,6 +14,18 @@
gap: $pad-small;
}
+ // When a leading icon is rendered, group it with the copy on the left
+ // instead of pushing them to opposite edges with `space-between`.
+ &__icon {
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: $pad-small;
+ }
+
+ &__leading-icon {
+ flex-shrink: 0;
+ }
+
&__info {
// Do not use display flex as it will have adverse effects on spacing around HTML tags
align-content: center;
diff --git a/frontend/hooks/useBlockNavigation.ts b/frontend/hooks/useBlockNavigation.ts
new file mode 100644
index 00000000000..fd6b1e7bc1f
--- /dev/null
+++ b/frontend/hooks/useBlockNavigation.ts
@@ -0,0 +1,26 @@
+import { useEffect } from "react";
+
+/** Browser navigation guard — shows the leave-confirm prompt while `block`
+ * is true. Tied to the `beforeunload` event, which fires on tab close, hard
+ * navigation, and reload. Used during multi-step uploads where losing the
+ * in-flight request would discard the user's work.
+ *
+ * Note: this does NOT block in-app react-router navigation; only browser-level
+ * navigation. Soft navigation between Fleet routes still proceeds. */
+const useBlockNavigation = (block: boolean): void => {
+ useEffect(() => {
+ if (!block) return undefined;
+
+ const handler = (e: BeforeUnloadEvent) => {
+ e.preventDefault();
+ // Legacy support for Chrome/Edge < 119, which only respect
+ // `returnValue` rather than the modern `preventDefault()`.
+ e.returnValue = true;
+ };
+
+ addEventListener("beforeunload", handler);
+ return () => removeEventListener("beforeunload", handler);
+ }, [block]);
+};
+
+export default useBlockNavigation;
diff --git a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
index 6ad47ddb1ac..6292becaa26 100644
--- a/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/SoftwareCustomPackage.tsx
@@ -1,4 +1,4 @@
-import React, { useContext, useEffect, useState } from "react";
+import React, { useContext, useState } from "react";
import { InjectedRouter } from "react-router";
import { useQuery, useQueryClient } from "react-query";
@@ -10,6 +10,7 @@ import softwareAPI from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
import { AppContext } from "context/app";
+import useBlockNavigation from "hooks/useBlockNavigation";
import useGitOpsMode from "hooks/useGitOpsMode";
import { ILabelSummary } from "interfaces/label";
@@ -72,26 +73,8 @@ const SoftwareCustomPackage = ({
}
);
- useEffect(() => {
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- // Next line with e.returnValue is included for legacy support
- // e.g.Chrome / Edge < 119
- e.returnValue = true;
- };
-
- // set up event listener to prevent user from leaving page while uploading
- if (uploadDetails) {
- addEventListener("beforeunload", beforeUnloadHandler);
- } else {
- removeEventListener("beforeunload", beforeUnloadHandler);
- }
-
- // clean up event listener and timeout on component unmount
- return () => {
- removeEventListener("beforeunload", beforeUnloadHandler);
- };
- }, [uploadDetails]);
+ // Block tab close / hard navigation while an upload is in flight.
+ useBlockNavigation(!!uploadDetails);
const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => {
setShowPreviewEndUserExperience(!showPreviewEndUserExperience);
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
new file mode 100644
index 00000000000..c566de40a02
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tests.tsx
@@ -0,0 +1,148 @@
+import React from "react";
+import { screen } from "@testing-library/react";
+
+import { createCustomRenderer } from "test/test-utils";
+
+import AddPackageModal from "./AddPackageModal";
+
+const BASE_PROPS = {
+ softwareTitleId: 42,
+ teamId: 1,
+ existingPackageName: "GlobalProtect-v6.3.2.pkg",
+ onExit: jest.fn(),
+ onSuccess: jest.fn(),
+};
+
+const renderModal = (
+ overrides: Partial> = {},
+ gitOpsModeEnabled = false
+) => {
+ const render = createCustomRenderer({
+ withBackendMock: true,
+ context: {
+ app: {
+ isPremiumTier: true,
+ isGlobalAdmin: true,
+ config: {
+ gitops: {
+ gitops_mode_enabled: gitOpsModeEnabled,
+ repository_url: gitOpsModeEnabled ? "https://example.com/repo" : "",
+ },
+ },
+ },
+ },
+ });
+ return render();
+};
+
+describe("AddPackageModal", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe("standard mode", () => {
+ it("renders with the 'Add package' title", () => {
+ renderModal();
+ // Modal renders its title as a , not a heading; use getByText.
+ expect(screen.getByText("Add package")).toBeInTheDocument();
+ });
+
+ it("renders the multi-package first-added banner copy verbatim", () => {
+ renderModal();
+ // Figma page 2:130 — single source of truth for this string.
+ expect(
+ screen.getByText(
+ "If multiple packages target the same host, Fleet will install the one that was added first."
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("hides the GitOps banner copy", () => {
+ renderModal();
+ expect(
+ screen.queryByText(/Add custom packages in GitOps mode/i)
+ ).not.toBeInTheDocument();
+ expect(screen.queryByText("YAML docs")).not.toBeInTheDocument();
+ });
+
+ it("derives the platform label from the existing package's filename", () => {
+ renderModal({ existingPackageName: "GlobalProtect-v6.3.2.pkg" });
+ // `getFileTypeRestriction` returns label "macOS (.pkg)" — the modal
+ // forwards it to PackageForm's FileUploader message slot.
+ expect(screen.getByText("macOS (.pkg)")).toBeInTheDocument();
+ });
+
+ it("falls back to the all-platforms file-type message when the existing name has no recognized extension", () => {
+ renderModal({ existingPackageName: "no-extension" });
+ // PackageForm's default message lists every supported platform.
+ expect(screen.getByText(/macOS \(.pkg,/)).toBeInTheDocument();
+ });
+
+ it("renders the form's Save button as 'Save' (not 'Add software')", async () => {
+ renderModal();
+ // The button text comes from PackageForm — `multiPackageContext` flips
+ // it from "Add software" to "Save". The form mounts after labels load
+ // (an empty array via the optional-chained fallback), so await it.
+ const saveButton = await screen.findByRole("button", { name: "Save" });
+ expect(saveButton).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Add software" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("preselects the Custom target radio (multi-package default per Figma)", () => {
+ renderModal();
+ const customRadio = screen.getByLabelText("Custom");
+ expect(customRadio).toBeChecked();
+ });
+ });
+
+ describe("GitOps mode", () => {
+ it("renders the GitOps banner copy verbatim (apostrophe typo preserved per Figma)", () => {
+ renderModal({}, true);
+ // The "it's" misspelling is intentional — sourced verbatim from
+ // Figma page 2:130 so copy stays in sync with design.
+ expect(
+ screen.getByText(/Add custom packages in GitOps mode/i)
+ ).toBeInTheDocument();
+ expect(
+ screen.getByText(
+ /copy it.s SHA-256 hash into your YAML so the next GitOps workflow doesn.t delete it/i
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("renders the YAML docs CustomLink", () => {
+ renderModal({}, true);
+ const link = screen.getByRole("link", { name: /YAML docs/i });
+ expect(link).toHaveAttribute(
+ "href",
+ expect.stringMatching(/learn-more-about\/software-yaml$/)
+ );
+ });
+
+ it("hides the standard multi-package banner copy in GitOps mode", () => {
+ renderModal({}, true);
+ expect(
+ screen.queryByText(/will install the one that was added first/)
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ describe("file-type restriction (per-row)", () => {
+ it("constrains a Linux .deb title to .deb uploads", () => {
+ renderModal({ existingPackageName: "cinc_18.2.11-1_amd64.deb" });
+ expect(screen.getByText("Linux (.deb)")).toBeInTheDocument();
+ });
+
+ it("constrains a Windows .msi title to .msi uploads", () => {
+ renderModal({ existingPackageName: "ZoomInstaller.msi" });
+ expect(screen.getByText("Windows (.msi)")).toBeInTheDocument();
+ });
+
+ it("constrains a .sh script-only title to .sh uploads", () => {
+ renderModal({ existingPackageName: "setup.sh" });
+ expect(screen.getByText("macOS & Linux (.sh)")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
new file mode 100644
index 00000000000..86de9da4d46
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/AddPackageModal.tsx
@@ -0,0 +1,160 @@
+import React, { useState } from "react";
+import { useQuery, useQueryClient } from "react-query";
+
+import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
+import { getFileDetails, IFileDetails } from "utilities/file/fileUtils";
+import softwareAPI from "services/entities/software";
+import labelsAPI, { getCustomLabels } from "services/entities/labels";
+
+import useBlockNavigation from "hooks/useBlockNavigation";
+import useGitOpsMode from "hooks/useGitOpsMode";
+import { ILabelSummary } from "interfaces/label";
+
+import { notify } from "components/ToastNotification";
+import Modal from "components/Modal";
+import FileProgressModal from "components/FileProgressModal";
+import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
+
+import PackageForm from "pages/SoftwarePage/components/forms/PackageForm";
+import { IPackageFormData } from "pages/SoftwarePage/components/forms/PackageForm/PackageForm";
+
+import { getErrorMessage } from "pages/SoftwarePage/SoftwareAddPage/SoftwareCustomPackage/helpers";
+
+import { getFileTypeRestriction } from "./helpers";
+
+const baseClass = "add-package-modal";
+
+interface IAddPackageModalProps {
+ /** The id of the software title we're adding a package to (multi-package
+ * flow, #48397). The POST carries this as `software_title_id` so the new
+ * package attaches to an existing title instead of creating a new one. */
+ softwareTitleId: number;
+ teamId: number;
+ /** File name of the title's first-added package — used to derive the
+ * platform/file-type restriction so the new upload matches the existing
+ * package's platform (e.g. ".pkg" only when the title is a macOS title). */
+ existingPackageName: string;
+ onExit: () => void;
+ /** Fires after a successful upload so the caller can refetch the title's
+ * `packages[]` and surface the new row. */
+ onSuccess: () => void;
+}
+
+const AddPackageModal = ({
+ softwareTitleId,
+ teamId,
+ existingPackageName,
+ onExit,
+ onSuccess,
+}: IAddPackageModalProps) => {
+ const queryClient = useQueryClient();
+ const { gitOpsModeEnabled } = useGitOpsMode("software");
+ const restriction = getFileTypeRestriction(existingPackageName);
+
+ const [uploadProgress, setUploadProgress] = useState(0);
+ const [uploadDetails, setUploadDetails] = useState(null);
+ const [
+ showPreviewEndUserExperience,
+ setShowPreviewEndUserExperience,
+ ] = useState(false);
+ const [
+ isIpadOrIphoneSoftwareSource,
+ setIsIpadOrIphoneSoftwareSource,
+ ] = useState(false);
+
+ const { data: labels } = useQuery(
+ ["custom_labels"],
+ () => labelsAPI.summary(teamId).then((res) => getCustomLabels(res.labels)),
+ { ...DEFAULT_USE_QUERY_OPTIONS }
+ );
+
+ // Block tab close / hard navigation while an upload is in flight so the
+ // user doesn't lose their work mid-request.
+ useBlockNavigation(!!uploadDetails);
+
+ const onClickPreviewEndUserExperience = (isIosOrIpadosApp = false) => {
+ setShowPreviewEndUserExperience(!showPreviewEndUserExperience);
+ setIsIpadOrIphoneSoftwareSource(isIosOrIpadosApp);
+ };
+
+ const onSubmit = async (formData: IPackageFormData) => {
+ if (!formData.software) {
+ notify.error("Couldn't add. Please refresh the page and try again.");
+ return;
+ }
+
+ setUploadDetails(getFileDetails(formData.software));
+
+ try {
+ await softwareAPI.addSoftwarePackage({
+ data: formData,
+ teamId,
+ softwareTitleId,
+ onUploadProgress: (progressEvent) => {
+ const progress = progressEvent.progress || 0;
+ // Keep the progress bar at 97% until the server finalizes its
+ // response — large uploads stall on the last few percent otherwise.
+ setUploadProgress(Math.max(progress - 0.03, 0.01));
+ },
+ });
+
+ if (!gitOpsModeEnabled) {
+ notify.success(
+ <>
+ Successfully added new {formData.software.name} package.
+ >
+ );
+ }
+
+ queryClient.invalidateQueries({
+ queryKey: [{ scope: "software-titles" }],
+ });
+ queryClient.invalidateQueries({
+ queryKey: [{ scope: "software-library" }],
+ });
+
+ onSuccess();
+ } catch (e) {
+ notify.error(getErrorMessage(e), { response: e });
+ }
+ setUploadDetails(null);
+ };
+
+ return (
+ <>
+
+
+
+ {uploadDetails && (
+
+ )}
+ {showPreviewEndUserExperience && (
+
+ )}
+ >
+ );
+};
+
+export default AddPackageModal;
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss
new file mode 100644
index 00000000000..e9b293002f3
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/_styles.scss
@@ -0,0 +1,5 @@
+.add-package-modal {
+ // The multi-package and GitOps banners live inside PackageForm
+ // (`package-form__multi-package-banner`) so they sit under the file chooser
+ // alongside the rest of the form's controls. No modal-level styles needed yet.
+}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
new file mode 100644
index 00000000000..f4949660012
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.tests.ts
@@ -0,0 +1,68 @@
+import { getFileTypeRestriction } from "./helpers";
+
+describe("AddPackageModal helpers — getFileTypeRestriction", () => {
+ it("returns a macOS .pkg restriction for a .pkg filename", () => {
+ expect(getFileTypeRestriction("GlobalProtect-v6.3.2.pkg")).toEqual({
+ accept: ".pkg",
+ label: "macOS (.pkg)",
+ });
+ });
+
+ it("returns a Linux .deb restriction for a .deb filename", () => {
+ expect(getFileTypeRestriction("cinc_18.2.11-1_amd64.deb")).toEqual({
+ accept: ".deb",
+ label: "Linux (.deb)",
+ });
+ });
+
+ it("returns a Windows .msi restriction for a .msi filename", () => {
+ expect(getFileTypeRestriction("ZoomInstaller.msi")).toEqual({
+ accept: ".msi",
+ label: "Windows (.msi)",
+ });
+ });
+
+ // .tar.gz needs the dual MIME/extension workaround because browsers can't
+ // match the compound extension via `accept` alone.
+ it("uses the gzip MIME workaround for .tar.gz", () => {
+ expect(getFileTypeRestriction("bundle-1.0.0.tar.gz")).toEqual({
+ accept: "application/gzip,.tgz",
+ label: "Linux (.tar.gz)",
+ });
+ });
+
+ it("normalizes .tgz aliases through to .tar.gz", () => {
+ // `getExtensionFromFileName` rewrites .tgz → .tar.gz; the restriction
+ // should match.
+ expect(getFileTypeRestriction("bundle.tgz")).toEqual({
+ accept: "application/gzip,.tgz",
+ label: "Linux (.tar.gz)",
+ });
+ });
+
+ it("returns null for an unrecognized extension", () => {
+ expect(getFileTypeRestriction("README.txt")).toBeNull();
+ });
+
+ it("returns null for a filename without an extension", () => {
+ expect(getFileTypeRestriction("installer")).toBeNull();
+ });
+
+ it("returns null for an empty string", () => {
+ expect(getFileTypeRestriction("")).toBeNull();
+ });
+
+ it("returns a macOS & Linux restriction for a .sh script package", () => {
+ expect(getFileTypeRestriction("setup.sh")).toEqual({
+ accept: ".sh",
+ label: "macOS & Linux (.sh)",
+ });
+ });
+
+ it("returns a Windows .ps1 restriction", () => {
+ expect(getFileTypeRestriction("setup.ps1")).toEqual({
+ accept: ".ps1",
+ label: "Windows (.ps1)",
+ });
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
new file mode 100644
index 00000000000..0b8b72916c7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/helpers.ts
@@ -0,0 +1,39 @@
+import {
+ FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME,
+ getExtensionFromFileName,
+} from "utilities/file/fileUtils";
+
+/** What the file-uploader should accept and how the file-type hint reads when
+ * adding a new package to a multi-package title. The new upload must match the
+ * existing title's platform/file type, so we derive both from the first-added
+ * package's filename. Returns `null` when we can't determine the restriction
+ * (unknown extension or missing name) so callers fall back to PackageForm's
+ * full all-platforms accept + message. */
+export interface IFileTypeRestriction {
+ /** Value for `` — narrowed to a single extension
+ * (or, for tar.gz, the same MIME/extension pair PackageForm uses globally). */
+ accept: string;
+ /** Display label, e.g. `"macOS (.pkg)"` or `"Linux (.tar.gz)"`. */
+ label: string;
+}
+
+export const getFileTypeRestriction = (
+ existingPackageName: string
+): IFileTypeRestriction | null => {
+ const extension = getExtensionFromFileName(existingPackageName);
+ if (!extension) return null;
+
+ const platform = FILE_EXTENSIONS_TO_PLATFORM_DISPLAY_NAME[extension];
+ if (!platform) return null;
+
+ // Browsers can't reliably match `.tar.gz` via extension alone (double-
+ // extension). Mirror PackageForm's global accept value for tar.gz so the
+ // file dialog filters correctly without us reimplementing the workaround.
+ const accept =
+ extension === "tar.gz" ? "application/gzip,.tgz" : `.${extension}`;
+
+ return {
+ accept,
+ label: `${platform} (.${extension})`,
+ };
+};
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
new file mode 100644
index 00000000000..0deb5e114c7
--- /dev/null
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/AddPackageModal/index.ts
@@ -0,0 +1 @@
+export { default } from "./AddPackageModal";
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
index ee62855ff3f..05bba7ea472 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/DeleteSoftwareModal/DeleteSoftwareModal.tsx
@@ -65,6 +65,10 @@ const getPlatformMessage = (isAppStoreApp: boolean, isAndroidApp: boolean) => {
interface IDeleteSoftwareModalProps {
softwareId: number;
teamId: number;
+ /** Per-installer id on a multi-package title (#48397). When set, only this
+ * specific package is deleted; otherwise the request deletes the legacy
+ * single-package row (or VPP/FMA installer slot). */
+ installerId?: number;
onExit: () => void;
onSuccess: () => void;
gitOpsModeEnabled?: boolean;
@@ -75,6 +79,7 @@ interface IDeleteSoftwareModalProps {
const DeleteSoftwareModal = ({
softwareId,
teamId,
+ installerId,
onExit,
onSuccess,
gitOpsModeEnabled,
@@ -86,7 +91,11 @@ const DeleteSoftwareModal = ({
const onDeleteSoftware = useCallback(async () => {
setIsDeleting(true);
try {
- await softwareAPI.deleteSoftwareInstaller(softwareId, teamId);
+ await softwareAPI.deleteSoftwareInstaller(
+ softwareId,
+ teamId,
+ installerId
+ );
notify.success("Successfully deleted software.");
onSuccess();
} catch (error) {
@@ -109,7 +118,7 @@ const DeleteSoftwareModal = ({
}
setIsDeleting(false);
onExit();
- }, [softwareId, teamId, onSuccess, onExit]);
+ }, [softwareId, teamId, installerId, onSuccess, onExit]);
return (
= {
+ softwareId: 1,
+ teamId: 1,
+ softwareInstaller: createMockSoftwarePackage(),
+ refetchSoftwareTitle: jest.fn(),
+ onExit: jest.fn(),
+ installerType: "package",
+ openViewYamlModal: jest.fn(),
+ name: "GlobalProtect",
+ displayName: "GlobalProtect",
+ source: "apps",
+ iconUrl: null,
+};
+
+const renderModal = (
+ overrides: Partial> = {}
+) => {
+ const render = createCustomRenderer({ withBackendMock: true });
+ return render();
+};
+
+describe("EditSoftwareModal — multi-package title (#48400)", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("renders the 'Edit software' title by default (single-package legacy path)", () => {
+ renderModal();
+ expect(screen.getByText("Edit software")).toBeInTheDocument();
+ expect(screen.queryByText("Edit package")).not.toBeInTheDocument();
+ });
+
+ it("renders the 'Edit package' title when canActivateMultiplePackages is true", () => {
+ renderModal({ canActivateMultiplePackages: true });
+ expect(screen.getByText("Edit package")).toBeInTheDocument();
+ expect(screen.queryByText("Edit software")).not.toBeInTheDocument();
+ });
+
+ it("accepts an installerId prop (threaded into the API call on save)", () => {
+ // Smoke test — the prop is optional and exists on the interface. We don't
+ // submit here since that would require asserting against the mocked API
+ // client; the page tests cover the submit path end-to-end.
+ expect(() => renderModal({ installerId: 7 })).not.toThrow();
+ });
+});
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
index 82caaebdd59..521c9179ab5 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/EditSoftwareModal/EditSoftwareModal.tsx
@@ -9,6 +9,7 @@ import {
isSoftwarePackage,
InstallerType,
} from "interfaces/software";
+import useBlockNavigation from "hooks/useBlockNavigation";
import useGitOpsMode from "hooks/useGitOpsMode";
import softwareAPI from "services/entities/software";
import labelsAPI, { getCustomLabels } from "services/entities/labels";
@@ -44,6 +45,10 @@ export type IEditPackageFormData = Omit;
interface IEditSoftwareModalProps {
softwareId: number;
teamId: number;
+ /** Per-installer id on a multi-package title (#48397). When set, the PATCH
+ * targets this specific package; otherwise the request edits the legacy
+ * single-package row. */
+ installerId?: number;
softwareInstaller: ISoftwarePackage | IAppStoreApp;
refetchSoftwareTitle: () => void;
onExit: () => void;
@@ -55,11 +60,17 @@ interface IEditSoftwareModalProps {
displayName: string;
source?: string;
iconUrl?: string | null;
+ /** Passed through from `SoftwareTitleDetailsPage`. When true, the modal
+ * title reads "Edit package" instead of "Edit software" — we're editing one
+ * specific installer on a title that has several, not the title's only
+ * package (#48400). */
+ canActivateMultiplePackages?: boolean;
}
const EditSoftwareModal = ({
softwareId,
teamId,
+ installerId,
softwareInstaller,
onExit,
refetchSoftwareTitle,
@@ -71,6 +82,7 @@ const EditSoftwareModal = ({
displayName,
source,
iconUrl = undefined,
+ canActivateMultiplePackages = false,
}: IEditSoftwareModalProps) => {
const queryClient = useQueryClient();
const { gitOpsModeEnabled } = useGitOpsMode("software");
@@ -147,29 +159,21 @@ const EditSoftwareModal = ({
isUpdatingSoftware,
]);
- /* 1. Delays showing the file progress modal until isUpdatingSoftware
- * has been true for 3 seconds to prevent flashing modal on quick uploads
- * 2. Prevents page unload during the upload
- * 3. Cleans both up when uploading stops or the component unmounts */
+ // Block tab close / hard navigation while the PATCH is in flight.
+ useBlockNavigation(isUpdatingSoftware);
+
+ /* Delays showing the file progress modal until isUpdatingSoftware has been
+ * true for 3 seconds to prevent flashing modal on quick uploads, and
+ * hides it when uploading stops. */
useEffect(() => {
// Timer for delayed modal
let timeoutId: ReturnType | undefined;
- const beforeUnloadHandler = (e: BeforeUnloadEvent) => {
- e.preventDefault();
- // Next line with e.returnValue is included for legacy support
- // e.g.Chrome / Edge < 119
- e.returnValue = true;
- };
-
if (isUpdatingSoftware) {
// only show modal if still uploading after 3 seconds
timeoutId = setTimeout(() => {
setShowFileProgressModal(true);
}, 3000);
-
- // Prevents user from leaving page while uploading
- addEventListener("beforeunload", beforeUnloadHandler);
} else {
// upload finished: hide modal and reset
setShowFileProgressModal(false);
@@ -180,7 +184,6 @@ const EditSoftwareModal = ({
if (timeoutId) {
clearTimeout(timeoutId);
}
- removeEventListener("beforeunload", beforeUnloadHandler);
};
}, [isUpdatingSoftware]);
@@ -208,6 +211,7 @@ const EditSoftwareModal = ({
data: formData,
orignalPackage: softwareInstaller as ISoftwarePackage,
softwareId,
+ installerId,
teamId,
onUploadProgress: (progressEvent) => {
const progress = progressEvent.progress || 0;
@@ -252,7 +256,7 @@ const EditSoftwareModal = ({
setIsUpdatingSoftware(false);
};
- const isOnlySelfServiceUpdated = (updates: Record) => {
+ const isOnlySelfServiceUpdated = (updates: Record) => {
return Object.keys(updates).length === 1 && "selfService" in updates;
};
@@ -393,7 +397,7 @@ const EditSoftwareModal = ({
<>
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
index 5b251355b51..16b7c7636e7 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx
@@ -32,6 +32,11 @@ const baseProps: ILibraryItemAccordionProps = {
addedAt: new Date("2026-06-15T00:00:00Z").toISOString(),
isActive: true,
canEditSoftware: true,
+ // The base accordion mock represents a Fleet-maintained app — that's the
+ // shape where Latest/Pinned/Major-version badges apply (#48400 scopes the
+ // "Latest" badge to FMA only). Custom-package tests opt out with
+ // `isFma: false, isCustomPackage: true` overrides.
+ isFma: true,
installed: 32,
pending: 5,
failed: 3,
@@ -242,6 +247,93 @@ describe("LibraryItemAccordion", () => {
});
});
+ // #48400 — custom non-FMA non-iOS rows swap the Latest badge for per-
+ // package self-service / auto-install indicators. The page passes
+ // `canActivateMultiplePackages=true` for these rows.
+ describe("custom-package row (multi-package title)", () => {
+ const customRowProps: Partial = {
+ isFma: false,
+ canActivateMultiplePackages: true,
+ };
+
+ it("renders neither Latest nor the per-row icons by default", () => {
+ renderAccordion({ ...customRowProps, badgeState: "latest" });
+
+ expect(
+ screen.queryByRole("button", { name: "Latest" })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "Edit package" })
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /auto-install polic/i })
+ ).not.toBeInTheDocument();
+ });
+
+ it("renders the self-service icon button and fires onSelfServiceClick", async () => {
+ const onSelfServiceClick = jest.fn();
+ const { user } = renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ isSelfService: true,
+ onSelfServiceClick,
+ });
+
+ // Self-service icon opens the per-package Edit modal; aria-label
+ // matches the modal title.
+ const button = screen.getByRole("button", { name: "Edit package" });
+ await user.click(button);
+ expect(onSelfServiceClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("renders the auto-install icon button and fires onAutoInstallClick", async () => {
+ const onAutoInstallClick = jest.fn();
+ const { user } = renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ hasAutoInstallPolicy: true,
+ onAutoInstallClick,
+ });
+
+ const button = screen.getByRole("button", {
+ name: "View auto-install policies",
+ });
+ await user.click(button);
+ expect(onAutoInstallClick).toHaveBeenCalledTimes(1);
+ });
+
+ it("uses the patch-policy aria-label when isPatchPolicyOnly is true", () => {
+ renderAccordion({
+ ...customRowProps,
+ badgeState: "latest",
+ hasAutoInstallPolicy: true,
+ isPatchPolicyOnly: true,
+ onAutoInstallClick: jest.fn(),
+ });
+
+ expect(
+ screen.getByRole("button", { name: "View patch policy" })
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: "View auto-install policies" })
+ ).not.toBeInTheDocument();
+ });
+
+ it("does not render the Latest badge for a non-FMA row even when badgeState is 'latest'", () => {
+ // The Latest badge is gated on `isFma` (#48400); custom rows never
+ // surface it regardless of `canActivateMultiplePackages` / badgeState.
+ renderAccordion({
+ isFma: false,
+ canActivateMultiplePackages: false,
+ badgeState: "latest",
+ });
+
+ expect(
+ screen.queryByRole("button", { name: "Latest" })
+ ).not.toBeInTheDocument();
+ });
+ });
+
describe("inactive row", () => {
it("hides all badges and the chevron interaction", async () => {
const { user } = renderAccordion({
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
index f61a4700818..044e4ce7581 100644
--- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
+++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx
@@ -6,11 +6,13 @@ import CopyButton from "components/buttons/CopyButton";
import CustomLink from "components/CustomLink";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Icon from "components/Icon";
+import { IconNames } from "components/icons";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { ILabelSoftwareTitle } from "interfaces/label";
import { InstallerType } from "interfaces/software";
+import { getSelfServiceTooltip } from "pages/SoftwarePage/helpers";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
const baseClass = "library-item-accordion";
@@ -99,6 +101,32 @@ export interface ILibraryItemAccordionProps {
onLabelsClick?: () => void;
onDownloadClick?: () => void;
onTrashClick?: () => void;
+
+ /** Single page-level flag mirroring `SoftwareTitleDetailsPage`'s
+ * `canActivateMultiplePackages` — true for titles that can hold multiple
+ * custom packages (#48400). Drives the row's self-service / auto-install
+ * icons (and the absence of the Latest badge, which is FMA-only by gate). */
+ canActivateMultiplePackages?: boolean;
+ /** Drives the self-service icon's tooltip variant and visibility on the
+ * custom-package row. Mirrors `software_package.self_service`. */
+ isSelfService?: boolean;
+ /** Drives the auto-install icon's visibility on the custom-package row.
+ * Truthy when the package has ≥1 linked policy (auto-install OR patch). */
+ hasAutoInstallPolicy?: boolean;
+ /** When set, tooltips mention "Patch policy" instead of "Auto install" —
+ * matches the SoftwareSummaryCard chip's tooltip split. */
+ isPatchPolicyOnly?: boolean;
+ /** Self-service tooltip copy varies for Android Play Store apps. Wired
+ * through for completeness even though the current call sites only enable
+ * the custom-package path for desktop titles. */
+ isAndroidPlayStoreApp?: boolean;
+ /** Click handler for the self-service icon — opens the per-package Edit
+ * software modal (same target as the labels-count badge). */
+ onSelfServiceClick?: () => void;
+ /** Click handler for the auto-install icon — the page resolves whether to
+ * navigate straight to the single linked policy or open the PoliciesModal,
+ * scoped to THIS package's policies (#48400). */
+ onAutoInstallClick?: () => void;
}
const ALL_HOSTS_LABEL = "All hosts";
@@ -132,6 +160,13 @@ const LibraryItemAccordion = ({
onLabelsClick,
onDownloadClick,
onTrashClick,
+ canActivateMultiplePackages = false,
+ isSelfService = false,
+ hasAutoInstallPolicy = false,
+ isPatchPolicyOnly = false,
+ isAndroidPlayStoreApp = false,
+ onSelfServiceClick,
+ onAutoInstallClick,
}: ILibraryItemAccordionProps) => {
const [expanded, setExpanded] = useState(false);
@@ -179,12 +214,92 @@ const LibraryItemAccordion = ({
handler?.();
};
+ // Per-row indicator icon — tooltipped, optionally wrapped in a Button when
+ // a click handler is provided AND the caller's gate (e.g. permission) is
+ // open. Falls back to a static Icon otherwise. Used for the self-service
+ // and auto-install indicators on multi-package custom rows (#48400).
+ const renderRowActionIcon = ({
+ iconName,
+ tooltipContent,
+ ariaLabel,
+ onClick,
+ canClick = true,
+ }: {
+ iconName: IconNames;
+ tooltipContent: React.ReactNode;
+ ariaLabel: string;
+ onClick?: () => void;
+ /** When false, the icon stays static even if `onClick` is provided —
+ * used to gate clickability on permission (e.g. self-service). */
+ canClick?: boolean;
+ }) => (
+
+ {onClick && canClick ? (
+
+ ) : (
+
+ )}
+
+ );
+
+ // Self-service tooltip mirrors the SoftwareSummaryCard chip's copy so the
+ // per-row indicator says the same thing the title-level chip would.
+ const renderSelfServiceIcon = () =>
+ renderRowActionIcon({
+ iconName: "user",
+ tooltipContent: getSelfServiceTooltip(
+ !!isIosOrIpadosApp,
+ !!isAndroidPlayStoreApp
+ ),
+ // Same modal opens regardless of which icon is clicked; the icon glyph
+ // carries the contextual signal ("self-service is on for this package").
+ ariaLabel: "Edit package",
+ onClick: onSelfServiceClick,
+ canClick: canEditSoftware,
+ });
+
+ // Auto-install icon navigates rather than edits — its label is verb-forward
+ // ("View") so it doesn't read as a state toggle. Patch-only and dynamic
+ // variants get their own labels for screen-reader clarity.
+ const renderAutoInstallIcon = () =>
+ renderRowActionIcon({
+ iconName: isPatchPolicyOnly ? "policy" : "refresh",
+ tooltipContent: isPatchPolicyOnly ? (
+ <>Patch policy is linked to this package.>
+ ) : (
+ <>Policy triggers install.>
+ ),
+ ariaLabel: isPatchPolicyOnly
+ ? "View patch policy"
+ : "View auto-install policies",
+ onClick: onAutoInstallClick,
+ });
+
const renderHeaderBadges = () => {
if (!isActive) return null;
return (
- {badgeState === "latest" && (
+ {/* The "Latest" badge is FMA-specific — only Fleet-maintained apps
+ have a meaningful "latest available cached version" concept that
+ drives the badge state. VPP / App Store / Play Store / iOS
+ in-house and custom packages do not render this badge.
+ Pinned / Major version variants below also stay FMA-only by
+ construction (only FMA exposes version pinning). */}
+ {isFma && badgeState === "latest" && (
)}
+ {canActivateMultiplePackages &&
+ isSelfService &&
+ renderSelfServiceIcon()}
+ {canActivateMultiplePackages &&
+ hasAutoInstallPolicy &&
+ renderAutoInstallIcon()}
{badgeState === "pinned" && (