FE: Add tests to install/uninstall modals (#31992)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
IAppStoreApp,
|
||||
IFleetMaintainedApp,
|
||||
IFleetMaintainedAppDetails,
|
||||
ISoftwareInstallResult,
|
||||
} from "interfaces/software";
|
||||
import {
|
||||
ISoftwareTitlesResponse,
|
||||
@@ -305,3 +306,29 @@ export const createMockFleetMaintainedAppDetails = (
|
||||
) => {
|
||||
return { ...DEFAULT_FLEET_MAINTAINED_APP_DETAILS_MOCK, ...overrides };
|
||||
};
|
||||
|
||||
const DEFAULT_SOFTWARE_INSTALL_RESULT: ISoftwareInstallResult = {
|
||||
host_display_name: "Test Host",
|
||||
install_uuid: "uuid-123",
|
||||
software_title: "CoolApp",
|
||||
software_title_id: 1,
|
||||
software_package: "com.cool.app",
|
||||
host_id: 42,
|
||||
status: "installed",
|
||||
detail: "",
|
||||
output: "",
|
||||
pre_install_query_output: "",
|
||||
post_install_script_output: "",
|
||||
created_at: "2025-08-10T12:00:00Z",
|
||||
updated_at: "2025-08-10T12:05:00Z",
|
||||
self_service: false,
|
||||
};
|
||||
|
||||
export const createMockSoftwareInstallResult = (
|
||||
overrides?: Partial<ISoftwareInstallResult>
|
||||
) => {
|
||||
return {
|
||||
...DEFAULT_SOFTWARE_INSTALL_RESULT,
|
||||
...overrides,
|
||||
};
|
||||
};
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithSetup } from "test/test-utils";
|
||||
import { createMockSoftwareInstallResult } from "__mocks__/softwareMock";
|
||||
import { StatusMessage, ModalButtons } from "./SoftwareInstallDetailsModal";
|
||||
|
||||
describe("SoftwareInstallDetailsModal - StatusMessage component", () => {
|
||||
it("renders basic 'is installed' message when not installed by fleet (no installResult provided)", () => {
|
||||
render(<StatusMessage softwareName="CoolApp" isDUP={false} />);
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is installed/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on software library page/pending activity, renders pending install message with host and package name", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
softwareName="CoolApp"
|
||||
installResult={createMockSoftwareInstallResult({
|
||||
status: "pending_install",
|
||||
})}
|
||||
isDUP={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-outline-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/is installing or will install/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(com\.cool\.app\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/when it comes online/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on device user page, renders failed install with retry option with contact link", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
softwareName="CoolApp"
|
||||
installResult={createMockSoftwareInstallResult({
|
||||
status: "failed_install",
|
||||
})}
|
||||
isDUP
|
||||
contactUrl="http://support"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
// Host name should not be rendered for device user page
|
||||
expect(screen.queryByText(/Test Host/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/You can retry/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /contact your IT admin/ })
|
||||
).toHaveAttribute("href", "http://support");
|
||||
});
|
||||
|
||||
it("on device user page, renders failed install with retry option without contact link", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
softwareName="CoolApp"
|
||||
installResult={createMockSoftwareInstallResult({
|
||||
status: "failed_install",
|
||||
})}
|
||||
isDUP
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
// Host name should not be rendered for device user page
|
||||
expect(screen.queryByText(/Test Host/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/You can retry/)).toBeInTheDocument();
|
||||
// Don't show link of not provided
|
||||
expect(
|
||||
screen.queryByRole("link", { name: /contact your IT admin/ })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on host details page, renders failed install without retry", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
softwareName="CoolApp"
|
||||
installResult={createMockSoftwareInstallResult({
|
||||
status: "failed_install",
|
||||
})}
|
||||
isDUP={false}
|
||||
contactUrl="http://support"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to install/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/You can retry/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on host details page/install activity, renders installed message with timestamp", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
softwareName="CoolApp"
|
||||
installResult={createMockSoftwareInstallResult({
|
||||
status: "installed",
|
||||
})}
|
||||
isDUP={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("success-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fleet installed/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(com\.cool\.app\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+.*ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SoftwareInstallDetailsModal - ModalButtons component", () => {
|
||||
it("on device user page, shows Retry/Cancel for failed install and triggers handlers", async () => {
|
||||
const onCancel = jest.fn();
|
||||
const onRetry = jest.fn();
|
||||
|
||||
const { user } = renderWithSetup(
|
||||
<ModalButtons
|
||||
deviceAuthToken="token123"
|
||||
status="failed_install"
|
||||
hostSoftwareId={99}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRetry).toHaveBeenCalledWith(99);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows Done button for pending install", () => {
|
||||
const onCancel = jest.fn();
|
||||
render(<ModalButtons status="pending_install" onCancel={onCancel} />);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Retry" })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on device user page, shows Done button for installed software", () => {
|
||||
const onCancel = jest.fn();
|
||||
render(
|
||||
<ModalButtons
|
||||
deviceAuthToken="token123"
|
||||
status="installed"
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+64
-38
@@ -16,6 +16,7 @@ import deviceUserAPI from "services/entities/device_user";
|
||||
import InventoryVersions from "pages/hosts/details/components/InventoryVersions";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import ModalFooter from "components/ModalFooter";
|
||||
import Button from "components/buttons/Button";
|
||||
import Icon from "components/Icon";
|
||||
import Textarea from "components/Textarea";
|
||||
@@ -49,19 +50,21 @@ export const renderContactOption = (url?: string) => (
|
||||
</>
|
||||
);
|
||||
|
||||
// TODO - match VppInstallDetailsModal status to this, still accounting for MDM-specific cases
|
||||
// present there
|
||||
const StatusMessage = ({
|
||||
softwareName,
|
||||
installResult,
|
||||
isDUP,
|
||||
contactUrl,
|
||||
}: {
|
||||
interface IInstallStatusMessage {
|
||||
softwareName: string;
|
||||
installResult?: ISoftwareInstallResult;
|
||||
isDUP: boolean;
|
||||
contactUrl?: string;
|
||||
}) => {
|
||||
}
|
||||
|
||||
// TODO - match VppInstallDetailsModal status to this, still accounting for MDM-specific cases
|
||||
// present there
|
||||
export const StatusMessage = ({
|
||||
softwareName,
|
||||
installResult,
|
||||
isDUP,
|
||||
contactUrl,
|
||||
}: IInstallStatusMessage) => {
|
||||
// the case when software is installed by the user and not by Fleet
|
||||
if (!installResult) {
|
||||
return (
|
||||
@@ -142,6 +145,51 @@ const StatusMessage = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface IModalButtonsProps {
|
||||
deviceAuthToken?: string;
|
||||
status?: string;
|
||||
hostSoftwareId?: number;
|
||||
onRetry?: (id: number) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ModalButtons = ({
|
||||
deviceAuthToken,
|
||||
status,
|
||||
hostSoftwareId,
|
||||
onRetry,
|
||||
onCancel,
|
||||
}: IModalButtonsProps) => {
|
||||
if (deviceAuthToken && status === "failed_install") {
|
||||
const onClickRetry = () => {
|
||||
// on DUP, where this is relevant, both will be defined
|
||||
if (onRetry && hostSoftwareId) {
|
||||
onRetry(hostSoftwareId);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalFooter
|
||||
primaryButtons={
|
||||
<>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFooter primaryButtons={<Button onClick={onCancel}>Done</Button>} />
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwareInstallDetailsProps {
|
||||
/** note that details.install_uuid is present in hostSoftware, but since it is always needed for
|
||||
this modal while hostSoftware is not, as in the case of the activity feeds, it is specifically
|
||||
@@ -170,14 +218,6 @@ export const SoftwareInstallDetailsModal = ({
|
||||
setShowInstallDetails((prev) => !prev);
|
||||
};
|
||||
|
||||
const onClickRetry = () => {
|
||||
// on DUP, where this is relevant, both will be defined
|
||||
if (onRetry && hostSoftware?.id) {
|
||||
onRetry(hostSoftware.id);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const isInstalledByFleet = hostSoftware
|
||||
? !!hostSoftware.software_package?.last_install
|
||||
: true; // if no hostSoftware passed in, can assume this is the activity feed, meaning this can only refer to a Fleet-handled install
|
||||
@@ -304,26 +344,6 @@ export const SoftwareInstallDetailsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderCta = () => {
|
||||
if (deviceAuthToken && swInstallResult?.status === "failed_install") {
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Install details"
|
||||
@@ -333,7 +353,13 @@ export const SoftwareInstallDetailsModal = ({
|
||||
>
|
||||
<>
|
||||
{renderContent()}
|
||||
{renderCta()}
|
||||
<ModalButtons
|
||||
deviceAuthToken={deviceAuthToken}
|
||||
status={swInstallResult?.status}
|
||||
hostSoftwareId={hostSoftware?.id}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
import React from "react";
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { renderWithSetup } from "test/test-utils";
|
||||
import { createMockHostSoftware } from "__mocks__/hostMock";
|
||||
import { IHostSoftwareUiStatus } from "interfaces/software";
|
||||
|
||||
import { StatusMessage, ModalButtons } from "./SoftwareUninstallDetailsModal";
|
||||
|
||||
describe("SoftwareUninstallDetailsModal - StatusMessage component", () => {
|
||||
it("from activity or host details page of offline host, renders pending uninstall message with package name and host", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
hostDisplayName="Offline Host"
|
||||
status="pending_uninstall"
|
||||
softwareName="CoolApp"
|
||||
softwarePackageName="com.cool.app"
|
||||
isDUP={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-outline-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/is uninstalling or will uninstall/)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(com\.cool\.app\)/)).toBeInTheDocument(); // Show package name
|
||||
expect(screen.getByText(/Offline Host/)).toBeInTheDocument(); // Show host name
|
||||
expect(screen.getByText(/when it comes online/)).toBeInTheDocument(); // Only reach modal if host offline
|
||||
});
|
||||
|
||||
// from device user page, cannot reach pending uninstall modal
|
||||
|
||||
it("from device user page, renders failed uninstall message with retry text", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
hostDisplayName="Test Host"
|
||||
status="failed_uninstall"
|
||||
softwareName="CoolApp"
|
||||
isDUP
|
||||
contactUrl="http://support"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to uninstall/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Test Host/)).not.toBeInTheDocument(); // Do not render host name
|
||||
expect(screen.getByText(/You can retry/)).toBeInTheDocument(); // Render retry message
|
||||
});
|
||||
|
||||
it("from host details page/failed uninstall activity, renders failed uninstall message for with no retry text", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
hostDisplayName="Test Host"
|
||||
status="failed_uninstall"
|
||||
softwareName="CoolApp"
|
||||
isDUP={false}
|
||||
contactUrl="http://support"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/failed to uninstall/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test Host/)).toBeInTheDocument(); // Render host name
|
||||
expect(screen.queryByText(/You can retry/)).not.toBeInTheDocument(); // Do not render retry message
|
||||
});
|
||||
|
||||
it("from successful uninstall activity, renders uninstalled message with timestamp", () => {
|
||||
render(
|
||||
<StatusMessage
|
||||
hostDisplayName="Test Host"
|
||||
status="uninstalled"
|
||||
softwareName="CoolApp"
|
||||
softwarePackageName="com.cool.app"
|
||||
timestamp="2025-08-10T12:00:00Z"
|
||||
isDUP={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("success-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fleet uninstalled/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/CoolApp/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Test Host/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\(com\.cool\.app\)/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\d+.*ago/)).toBeInTheDocument(); // timestamp relative
|
||||
});
|
||||
|
||||
// from device user page, cannot reach successful uninstall modal
|
||||
});
|
||||
|
||||
describe("SoftwareUninstallDetailsModal - ModalButtons component", () => {
|
||||
it("from failed uninstall on a device user page, shows Retry/Cancel and calls handlers", async () => {
|
||||
const onCancel = jest.fn();
|
||||
const onRetry = jest.fn();
|
||||
const hostSoftware = {
|
||||
...createMockHostSoftware({ status: "failed_uninstall" }),
|
||||
ui_status: "failed_uninstall" as IHostSoftwareUiStatus,
|
||||
};
|
||||
|
||||
const { user } = renderWithSetup(
|
||||
<ModalButtons
|
||||
uninstallStatus="failed_uninstall"
|
||||
deviceAuthToken="token123"
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
hostSoftware={hostSoftware}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Retry" }));
|
||||
expect(onRetry).toHaveBeenCalledWith(hostSoftware);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(2); // first from retry, second from cancel
|
||||
});
|
||||
|
||||
it("from pending uninstall activity or software library of an offline host, shows only Done button", () => {
|
||||
const onCancel = jest.fn();
|
||||
|
||||
render(
|
||||
<ModalButtons uninstallStatus="pending_uninstall" onCancel={onCancel} />
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Retry" })
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Cancel" })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// from device user page, cannot reach pending uninstall modal
|
||||
|
||||
it("from successful uninstall activity, shows Done", () => {
|
||||
const onCancel = jest.fn();
|
||||
|
||||
render(
|
||||
<ModalButtons
|
||||
uninstallStatus="uninstalled"
|
||||
deviceAuthToken="token123"
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// from device user page, cannot reach successful uninstall modal
|
||||
});
|
||||
+58
-38
@@ -18,6 +18,7 @@ import Button from "components/buttons/Button";
|
||||
import DataError from "components/DataError";
|
||||
import Icon from "components/Icon";
|
||||
import Modal from "components/Modal";
|
||||
import ModalFooter from "components/ModalFooter";
|
||||
import Spinner from "components/Spinner";
|
||||
import Textarea from "components/Textarea";
|
||||
import RevealButton from "components/buttons/RevealButton";
|
||||
@@ -30,7 +31,7 @@ import { renderContactOption } from "../SoftwareInstallDetailsModal/SoftwareInst
|
||||
const baseClass = "software-uninstall-details-modal";
|
||||
|
||||
interface IUninstallStatusMessage {
|
||||
host_display_name: string;
|
||||
hostDisplayName: string;
|
||||
status: SoftwareUninstallStatus;
|
||||
softwareName: string;
|
||||
softwarePackageName?: string;
|
||||
@@ -39,8 +40,8 @@ interface IUninstallStatusMessage {
|
||||
contactUrl?: string;
|
||||
}
|
||||
|
||||
const StatusMessage = ({
|
||||
host_display_name,
|
||||
export const StatusMessage = ({
|
||||
hostDisplayName,
|
||||
status,
|
||||
softwareName,
|
||||
softwarePackageName,
|
||||
@@ -48,11 +49,7 @@ const StatusMessage = ({
|
||||
isDUP,
|
||||
contactUrl,
|
||||
}: IUninstallStatusMessage) => {
|
||||
const formattedHost = host_display_name ? (
|
||||
<b>{host_display_name}</b>
|
||||
) : (
|
||||
"the host"
|
||||
);
|
||||
const formattedHost = hostDisplayName ? <b>{hostDisplayName}</b> : "the host";
|
||||
|
||||
const isPending = isPendingStatus(status);
|
||||
const displayTimeStamp =
|
||||
@@ -100,6 +97,51 @@ const StatusMessage = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface IModalButtonsProps {
|
||||
uninstallStatus: SoftwareUninstallStatus;
|
||||
deviceAuthToken?: string;
|
||||
onCancel: () => void;
|
||||
onRetry?: (s: IHostSoftwareWithUiStatus) => void;
|
||||
hostSoftware?: IHostSoftwareWithUiStatus;
|
||||
}
|
||||
|
||||
export const ModalButtons = ({
|
||||
uninstallStatus,
|
||||
deviceAuthToken,
|
||||
onCancel,
|
||||
onRetry,
|
||||
hostSoftware,
|
||||
}: IModalButtonsProps) => {
|
||||
const onClickRetry = () => {
|
||||
if (onRetry && hostSoftware) {
|
||||
onRetry(hostSoftware);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (deviceAuthToken && uninstallStatus === "failed_uninstall") {
|
||||
return (
|
||||
<ModalFooter
|
||||
primaryButtons={
|
||||
<>
|
||||
{" "}
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalFooter primaryButtons={<Button onClick={onCancel}>Done</Button>} />
|
||||
);
|
||||
};
|
||||
|
||||
export interface ISWUninstallDetailsParentState {
|
||||
softwareName: string;
|
||||
uninstallStatus: SoftwareUninstallStatus;
|
||||
@@ -142,14 +184,6 @@ const SoftwareUninstallDetailsModal = ({
|
||||
|
||||
const toggleDetails = () => setShowDetails((prev) => !prev);
|
||||
|
||||
const onClickRetry = () => {
|
||||
// on DUP, where this is relevant, both will be defined
|
||||
if (onRetry && hostSoftware) {
|
||||
onRetry(hostSoftware);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const { data: uninstallResult, isLoading, isError, error } = useQuery<
|
||||
IScriptResultResponse,
|
||||
AxiosError
|
||||
@@ -192,7 +226,7 @@ const SoftwareUninstallDetailsModal = ({
|
||||
return (
|
||||
<div className={`${baseClass}__modal-content`}>
|
||||
<StatusMessage
|
||||
host_display_name={hostDisplayName || ""}
|
||||
hostDisplayName={hostDisplayName || ""}
|
||||
status={
|
||||
(uninstallStatus || "pending_uninstall") as SoftwareUninstallStatus
|
||||
}
|
||||
@@ -225,26 +259,6 @@ const SoftwareUninstallDetailsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderCta = () => {
|
||||
if (deviceAuthToken && uninstallStatus === "failed_uninstall") {
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Uninstall details"
|
||||
@@ -254,7 +268,13 @@ const SoftwareUninstallDetailsModal = ({
|
||||
>
|
||||
<>
|
||||
{renderContent()}
|
||||
{renderCta()}
|
||||
<ModalButtons
|
||||
uninstallStatus={uninstallStatus}
|
||||
deviceAuthToken={deviceAuthToken}
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
hostSoftware={hostSoftware}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+105
-1
@@ -1,5 +1,8 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { getStatusMessage } from "./VppInstallDetailsModal";
|
||||
import { renderWithSetup } from "test/test-utils";
|
||||
|
||||
import { getStatusMessage, ModalButtons } from "./VppInstallDetailsModal";
|
||||
|
||||
describe("getStatusMessage helper function", () => {
|
||||
it("shows NotNow message when isStatusNotNow is true", () => {
|
||||
@@ -77,6 +80,20 @@ describe("getStatusMessage helper function", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows pending install on host when it comes online", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
displayStatus: "pending_install",
|
||||
isMDMStatusNotNow: false,
|
||||
isMDMStatusAcknowledged: false,
|
||||
appName: "Logic Pro",
|
||||
hostDisplayName: "Marko's MacBook Pro",
|
||||
commandUpdatedAt: "2025-07-29T22:49:52Z",
|
||||
})
|
||||
);
|
||||
expect(screen.getByText(/when it comes online/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows default message for installed status", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
@@ -93,6 +110,22 @@ describe("getStatusMessage helper function", () => {
|
||||
expect(screen.getByText(/Marko's MacBook Pro/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows manual install message when installed not through Fleet", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
displayStatus: "installed",
|
||||
isMDMStatusNotNow: false,
|
||||
isMDMStatusAcknowledged: false,
|
||||
appName: "Logic Pro",
|
||||
hostDisplayName: "Marko's MacBook Pro",
|
||||
commandUpdatedAt: "", // <-- empty
|
||||
})
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Logic Pro/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is installed\./i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows default message with 'the host' if host_display_name is empty", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
@@ -107,4 +140,75 @@ describe("getStatusMessage helper function", () => {
|
||||
expect(screen.getByText(/Fleet installed/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/the host/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows relative timestamp when available", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
displayStatus: "failed_install",
|
||||
isMDMStatusNotNow: false,
|
||||
isMDMStatusAcknowledged: false,
|
||||
appName: "Logic Pro",
|
||||
hostDisplayName: "Marko's MacBook Pro",
|
||||
commandUpdatedAt: new Date().toISOString(),
|
||||
})
|
||||
);
|
||||
expect(screen.getByText(/\(.*ago\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("on the device user page, does not show host info", () => {
|
||||
render(
|
||||
getStatusMessage({
|
||||
isDUP: true,
|
||||
displayStatus: "installed",
|
||||
isMDMStatusNotNow: false,
|
||||
isMDMStatusAcknowledged: false,
|
||||
appName: "Logic Pro",
|
||||
hostDisplayName: "Marko's MacBook Pro",
|
||||
commandUpdatedAt: "2025-07-29T22:49:52Z",
|
||||
})
|
||||
);
|
||||
expect(screen.queryByText(/Marko's MacBook Pro/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VPP Install Details Modal - ModalButtons component", () => {
|
||||
it("renders Done button by default", async () => {
|
||||
const onCancel = jest.fn();
|
||||
|
||||
const { user } = renderWithSetup(
|
||||
<ModalButtons displayStatus="installed" onCancel={onCancel} />
|
||||
);
|
||||
|
||||
const doneButton = screen.getByRole("button", { name: /done/i });
|
||||
expect(doneButton).toBeInTheDocument();
|
||||
|
||||
await user.click(doneButton);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders Cancel + Retry when failed_install with deviceAuthToken", async () => {
|
||||
const onCancel = jest.fn();
|
||||
const onRetry = jest.fn();
|
||||
|
||||
const { user } = renderWithSetup(
|
||||
<ModalButtons
|
||||
displayStatus="failed_install"
|
||||
deviceAuthToken="fake_token"
|
||||
onCancel={onCancel}
|
||||
onRetry={onRetry}
|
||||
hostSoftwareId={123}
|
||||
/>
|
||||
);
|
||||
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
const retryButton = screen.getByRole("button", { name: /retry/i });
|
||||
|
||||
expect(cancelButton).toBeInTheDocument();
|
||||
expect(retryButton).toBeInTheDocument();
|
||||
|
||||
// Retry should trigger onRetry + onCancel
|
||||
await user.click(retryButton);
|
||||
expect(onRetry).toHaveBeenCalledWith(123);
|
||||
expect(onCancel).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+52
-28
@@ -14,6 +14,7 @@ import { IMdmCommandResult } from "interfaces/mdm";
|
||||
import InventoryVersions from "pages/hosts/details/components/InventoryVersions";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import ModalFooter from "components/ModalFooter";
|
||||
import Button from "components/buttons/Button";
|
||||
import Icon from "components/Icon";
|
||||
import Textarea from "components/Textarea";
|
||||
@@ -148,6 +149,50 @@ export const getStatusMessage = ({
|
||||
);
|
||||
};
|
||||
|
||||
interface IModalButtonsProps {
|
||||
displayStatus: SoftwareInstallStatus | "pending";
|
||||
deviceAuthToken?: string;
|
||||
onCancel: () => void;
|
||||
onRetry?: (id: number) => void;
|
||||
hostSoftwareId?: number;
|
||||
}
|
||||
|
||||
export const ModalButtons = ({
|
||||
displayStatus,
|
||||
deviceAuthToken,
|
||||
onCancel,
|
||||
onRetry,
|
||||
hostSoftwareId,
|
||||
}: IModalButtonsProps) => {
|
||||
const onClickRetry = () => {
|
||||
// on DUP, where this is relevant, both will be defined
|
||||
if (onRetry && hostSoftwareId) {
|
||||
onRetry(hostSoftwareId);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
|
||||
if (deviceAuthToken && displayStatus === "failed_install") {
|
||||
return (
|
||||
<ModalFooter
|
||||
primaryButtons={
|
||||
<>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ModalFooter primaryButtons={<Button onClick={onCancel}>Done</Button>} />
|
||||
);
|
||||
};
|
||||
|
||||
const baseClass = "vpp-install-details-modal";
|
||||
|
||||
export type IVppInstallDetails = {
|
||||
@@ -206,13 +251,6 @@ export const VppInstallDetailsModal = ({
|
||||
};
|
||||
};
|
||||
|
||||
const onClickRetry = () => {
|
||||
// on DUP, where this is relevant, both will be defined
|
||||
if (onRetry && hostSoftware?.id) {
|
||||
onRetry(hostSoftware.id);
|
||||
}
|
||||
onCancel();
|
||||
};
|
||||
const {
|
||||
data: vppCommandResult,
|
||||
isLoading: isLoadingVPPCommandResult,
|
||||
@@ -349,26 +387,6 @@ export const VppInstallDetailsModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
const renderCta = () => {
|
||||
if (deviceAuthToken && displayStatus === "failed_install") {
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button type="submit" onClick={onClickRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
<Button variant="inverse" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onCancel}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Install details"
|
||||
@@ -378,7 +396,13 @@ export const VppInstallDetailsModal = ({
|
||||
>
|
||||
<>
|
||||
{renderContent()}
|
||||
{renderCta()}
|
||||
<ModalButtons
|
||||
deviceAuthToken={deviceAuthToken}
|
||||
hostSoftwareId={hostSoftware?.id}
|
||||
onRetry={onRetry}
|
||||
onCancel={onCancel}
|
||||
displayStatus={displayStatus}
|
||||
/>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+5
@@ -69,6 +69,8 @@ describe("SoftwareUpdateModal", () => {
|
||||
onUpdate={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-outline-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Fleet is updating or will update/i)
|
||||
).toBeInTheDocument();
|
||||
@@ -89,6 +91,8 @@ describe("SoftwareUpdateModal", () => {
|
||||
onUpdate={noop}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("pending-outline-icon")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Fleet is updating or will update/i)
|
||||
).toBeInTheDocument();
|
||||
@@ -108,6 +112,7 @@ describe("SoftwareUpdateModal", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("error-outline-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText(/New version of/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/mock software.app/i)).toBeInTheDocument();
|
||||
expect(
|
||||
|
||||
Reference in New Issue
Block a user