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" && ( + ); + const headerAction = + showAddPackageAction && atPackageLimit ? ( + + This title already has 10 packages. +
+ Delete one you no longer use before adding. + + } + showArrow + position="left" + underline={false} + > + {addPackageButton} +
+ ) : ( + addPackageButton + ); + // 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. + // The "Add package" action sits on the description row (not the section + // header) so it visually aligns with the secondary copy rather than the + // h2 title — matches the Library row layout in Figma page 2:130. return (
- +
+ {/* The multi-package copy is an action prompt — only meaningful to + a user who can both edit software AND is on a multi-package- + eligible title. Read-only users and single-package types (FMA, + VPP, Google Play, iOS in-house .ipa) get the legacy + "available to be installed" wording. */} + + {headerAction} +
{appStore ? renderAppStoreRow() : packages.map(renderPackageRows)} @@ -414,22 +539,47 @@ const SoftwareTitleDetailsPage = ({ ); }; + // Resolves the targeted package on a multi-package title. Returns the + // package matching `selectedInstallerId`, or `null` if none matches — in + // which case the caller falls back to the legacy `software_package` flow. + const findSelectedPackage = ( + title: ISoftwareTitleDetails + ): ISoftwarePackage | null => { + if (selectedInstallerId === null) return null; + return ( + title.packages?.find((p) => p.installer_id === selectedInstallerId) ?? + null + ); + }; + + const closeDeleteModal = () => { + setShowDeleteModal(false); + setSelectedInstallerId(null); + }; + + const closeLibraryEditModal = () => { + setShowLibraryEditModal(false); + setSelectedInstallerId(null); + }; + // Delete modal for the active library row's installer. - const renderDeleteModal = () => { + const renderDeleteModal = (title: ISoftwareTitleDetails) => { if (!showDeleteModal || typeof teamIdForApi !== "number") return null; const meta = installerResult?.meta; const isAndroidApp = !!meta?.isAndroidPlayStoreApp; const isAppStoreApp = meta?.installerType === "app-store" && !isAndroidApp; + const selected = findSelectedPackage(title); return ( setShowDeleteModal(false)} + onExit={closeDeleteModal} onSuccess={() => { - setShowDeleteModal(false); + closeDeleteModal(); onDeleteInstaller(); }} /> @@ -439,13 +589,19 @@ const SoftwareTitleDetailsPage = ({ const renderLibraryEditModal = (title: ISoftwareTitleDetails) => { if (!showLibraryEditModal || !installerResult) return null; const { meta } = installerResult; + // On a multi-package title, the row callback set `selectedInstallerId`; + // resolve it to the actual package so the modal edits the right one. + // Otherwise (single-package back-compat or app-store), `meta.softwareInstaller` + // already points at the only installer. + const selected = findSelectedPackage(title); return ( setShowLibraryEditModal(false)} + onExit={closeLibraryEditModal} installerType={meta.installerType} openViewYamlModal={onToggleViewYaml} isFleetMaintainedApp={meta.isFleetMaintainedApp} @@ -454,6 +610,40 @@ const SoftwareTitleDetailsPage = ({ displayName={getDisplayedSoftwareName(title.name, title.display_name)} source={title.source} iconUrl={title.icon_url} + canActivateMultiplePackages={canActivateMultiplePackages} + /> + ); + }; + + const renderPackagePoliciesModal = () => { + if (!selectedPackagePolicies) return null; + return ( + setSelectedPackagePolicies(null)} + /> + ); + }; + + const renderAddPackageModal = (title: ISoftwareTitleDetails) => { + if (!showAddPackageModal || typeof teamIdForApi !== "number") return null; + // First-added is the canonical source for the file-type restriction. + // Multi-package titles always have `packages[0]`; back-compat titles fall + // back to `software_package`. The "+ Add package" button is gated on the + // section being visible, so we always have a name here. + const existingPackageName = + title.packages?.[0]?.name ?? title.software_package?.name ?? ""; + return ( + setShowAddPackageModal(false)} + onSuccess={() => { + setShowAddPackageModal(false); + refetchSoftwareTitle(); + }} /> ); }; @@ -503,7 +693,9 @@ const SoftwareTitleDetailsPage = ({ {renderLibrarySection(softwareTitle)} {renderInventorySection(softwareTitle)} {renderLibraryEditModal(softwareTitle)} - {renderDeleteModal()} + {renderDeleteModal(softwareTitle)} + {renderAddPackageModal(softwareTitle)} + {renderPackagePoliciesModal()} {renderViewYamlModal(softwareTitle)} {renderVersionsModal(softwareTitle)} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss index 1d7f84dea5d..01e9730b695 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/_styles.scss @@ -15,4 +15,11 @@ flex-direction: column; gap: $pad-medium; } + + &__library-description-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: $pad-medium; + } } diff --git a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx index 9cb19a012be..c530c3e50ed 100644 --- a/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx +++ b/frontend/pages/SoftwarePage/components/cards/SoftwareDetailsSummary/SoftwareDetailsSummary.tsx @@ -25,7 +25,10 @@ import useGitOpsMode from "hooks/useGitOpsMode"; import DataSet from "components/DataSet"; import LastUpdatedHostCount from "components/LastUpdatedHostCount"; +import Button from "components/buttons/Button"; import DropdownWrapper from "components/forms/fields/DropdownWrapper"; +import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper"; +import Icon from "components/Icon"; import TooltipWrapper from "components/TooltipWrapper"; import TooltipTruncatedText from "components/TooltipTruncatedText"; import CustomLink from "components/CustomLink"; @@ -201,6 +204,10 @@ interface ISoftwareDetailsSummaryProps { /** Apple VPP — gates Edit software behind the gitops tooltip. See * `BuildActionOptionsArgs.isAppleVpp` for the canonical computation. */ isAppleVpp?: boolean; + /** Custom non-FMA packages collapse the Actions dropdown into a single + * pencil-icon "Edit" button that opens the Edit Appearance modal directly. + * Per-installer Edit lives on the Library accordion row (#48400). */ + useSingleEditAppearanceButton?: boolean; } const SoftwareDetailsSummary = ({ @@ -226,6 +233,7 @@ const SoftwareDetailsSummary = ({ patchPolicyId, headerPills, isAppleVpp = false, + useSingleEditAppearanceButton = false, }: ISoftwareDetailsSummaryProps) => { const hostCountPath = getPathWithQueryParams(paths.MANAGE_HOSTS, queryParams); @@ -329,15 +337,35 @@ const SoftwareDetailsSummary = ({ {canManageSoftware && (
- + {useSingleEditAppearanceButton ? ( + // GitOps mode wraps the button so hover surfaces the + // "Managed by GitOps" tooltip + repo link, mirroring how the + // Actions dropdown's items are disabled with the same tip. + ( + + )} + /> + ) : ( + + )}
)}
diff --git a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx index 77ace51a52d..6819c4a0b5e 100644 --- a/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx +++ b/frontend/pages/SoftwarePage/components/forms/PackageForm/PackageForm.tsx @@ -123,6 +123,20 @@ interface IPackageFormProps { gitopsCompatible?: boolean; /** When provided, the categories list is fetched dynamically for this fleet. */ teamId?: number; + /** Set when this form is mounted inside the multi-package add modal + * (#48400). Renders a contextual banner just under the file chooser — + * GitOps copy when GitOps mode is on, the first-added-wins copy otherwise. + * Other call sites (single-package add page, edit modal) leave it false. */ + multiPackageContext?: boolean; + /** Restricts the file picker to a specific platform/file type when set — + * used by the multi-package add modal so a second .pkg upload can't slip + * onto a Linux title. Falls back to PackageForm's full all-platforms accept + * + message when omitted. */ + restrictedFileAccept?: string; + restrictedFileTypeLabel?: React.ReactNode; + /** Overrides the initial `targetType` for new (non-editing) forms. The + * multi-package add modal preselects `"Custom"` per Figma. */ + initialTargetType?: string; } // application/gzip is used for .tar.gz files because browsers can't handle double-extensions correctly const ACCEPTED_EXTENSIONS = @@ -147,6 +161,10 @@ const PackageForm = ({ className, gitopsCompatible = false, teamId, + multiPackageContext = false, + restrictedFileAccept, + restrictedFileTypeLabel, + initialTargetType, }: IPackageFormProps) => { const { gitOpsModeEnabled, repoURL } = useGitOpsMode("software"); @@ -157,7 +175,7 @@ const PackageForm = ({ postInstallScript: defaultPostInstallScript || "", uninstallScript: defaultUninstallScript || "", selfService: defaultSelfService || false, - targetType: getTargetType(defaultSoftware), + targetType: initialTargetType ?? getTargetType(defaultSoftware), customTarget: getCustomTarget(defaultSoftware), labelTargets: generateSelectedLabels(defaultSoftware), automaticInstall: false, @@ -349,6 +367,7 @@ const PackageForm = ({ !!formData.software && // show after selection !gitOpsModeEnabled && // hide in gitOps mode !isEditingSoftware && // show only on add, not edit + !multiPackageContext && // hide in the multi-package add modal — per Figma 2:130 the modal omits the deploy slider // automatic install is not supported for ipa packages, exe, tarball, or script packages !isIpaPackage && !isExePackage && @@ -367,9 +386,14 @@ const PackageForm = ({ ); - // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector - // 4.83 Removed option/targets from Add page - const showOptionsTargetsSelectors = !gitOpsModeEnabled && isEditingSoftware; + // GitOps mode hides SoftwareOptionsSelector and TargetLabelSelector. + // 4.83 removed option/targets from the (single-package) Add page; the + // multi-package Add modal (#48400) reintroduces the targets selector only, + // since each package on a multi-package title needs its own label scope. + // The options selector (self-service + categories) stays edit-only. + const showSoftwareOptionsSelector = !gitOpsModeEnabled && isEditingSoftware; + const showTargetLabelSelector = + !gitOpsModeEnabled && (isEditingSoftware || multiPackageContext); const renderSoftwareOptionsSelector = () => ( - {(showDeploySoftwareSlider || showOptionsTargetsSelectors) && ( // Only show container if one of the two components will be rendered to avoid extra gap spacing + {multiPackageContext && + (gitOpsModeEnabled ? ( + // Preserves the apostrophe typo "it's" verbatim from Figma page + // 2:130 so copy stays in sync with design. + + Add custom packages in GitOps mode so Fleet can host your + software. After adding, copy it's SHA-256 hash into your YAML + so the next GitOps workflow doesn't delete it.{" "} + + + ) : ( + + If multiple packages target the same host, Fleet will install the + one that was added first. + + ))} + {(showDeploySoftwareSlider || + showSoftwareOptionsSelector || + showTargetLabelSelector) && ( // Only show container if any one component will render — avoids stray gap spacing
{showDeploySoftwareSlider && renderSoftwareDeploySlider()} - {showOptionsTargetsSelectors && ( + {(showSoftwareOptionsSelector || showTargetLabelSelector) && (
- {renderSoftwareOptionsSelector()} - {renderTargetLabelSelector()} + {showSoftwareOptionsSelector && renderSoftwareOptionsSelector()} + {showTargetLabelSelector && renderTargetLabelSelector()}
)}
@@ -460,23 +514,32 @@ const PackageForm = ({ /> )}
- {submitTooltipContent ? ( - + {(() => { + // Single source of truth for the submit button — both the + // tooltipped and non-tooltipped branches need identical text, + // disabled state, and type. A previous duplication let the + // "Save" / "Add software" copy drift between branches (#48400). + const submitButton = ( - - ) : ( - - )} + ); + return submitTooltipContent ? ( + + {submitButton} + + ) : ( + submitButton + ); + })()}