Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/__mocks__/softwareMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -336,6 +339,7 @@ const DEFAULT_SOFTWARE_TITLE_MOCK: ISoftwareTitle = {
extension_for: "",
versions: [createMockSoftwareTitleVersion()],
software_package: createMockSoftwarePackage(),
packages: null,
app_store_app: null,
};

Expand Down
6 changes: 5 additions & 1 deletion frontend/components/InfoBanner/InfoBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand All @@ -46,6 +49,7 @@ const InfoBanner = ({

const content = (
<>
{icon && <Icon name={icon} className={`${baseClass}__leading-icon`} />}
<div className={`${baseClass}__info`}>{children}</div>

{(cta || closable) && (
Expand Down
12 changes: 12 additions & 0 deletions frontend/components/InfoBanner/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions frontend/hooks/useBlockNavigation.ts
Original file line number Diff line number Diff line change
@@ -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;
19 changes: 19 additions & 0 deletions frontend/interfaces/software.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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";

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<React.ComponentProps<typeof AddPackageModal>> = {},
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(<AddPackageModal {...BASE_PROPS} {...overrides} />);
};

describe("AddPackageModal", () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe("standard mode", () => {
it("renders with the 'Add package' title", () => {
renderModal();
// Modal renders its title as a <span>, 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();
});
});
});
Loading
Loading