Fleet UI: Add policies table to the sw title details page (#28886)
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import React from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import ReactTooltip from "react-tooltip";
|
||||
import { uniqueId } from "lodash";
|
||||
|
||||
import { SELF_SERVICE_TOOLTIP } from "pages/SoftwarePage/helpers";
|
||||
|
||||
import Icon from "components/Icon";
|
||||
@@ -49,7 +49,8 @@ const installIconMap: Record<InstallType, installIconConfig> = {
|
||||
{count === 1
|
||||
? "A policy triggers install."
|
||||
: `${count} policies trigger install.`}{" "}
|
||||
End users can reinstall from <b>Fleet Desktop {">"} Self-service</b>.
|
||||
<br /> End users can reinstall from
|
||||
<br /> <b>Fleet Desktop {">"} Self-service</b>.
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -74,6 +75,7 @@ const InstallIconWithTooltip = ({
|
||||
}
|
||||
|
||||
const tooltipId = uniqueId();
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__install-icon-with-tooltip`}>
|
||||
<div
|
||||
|
||||
@@ -68,6 +68,20 @@ export type SoftwareCategory =
|
||||
| "Developer tools"
|
||||
| "Productivity";
|
||||
|
||||
export interface ISoftwarePackageStatus {
|
||||
installed: number;
|
||||
pending_install: number;
|
||||
failed_install: number;
|
||||
pending_uninstall: number;
|
||||
failed_uninstall: number;
|
||||
}
|
||||
|
||||
export interface ISoftwareAppStoreAppStatus {
|
||||
installed: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface ISoftwarePackage {
|
||||
name: string;
|
||||
last_install: string | null;
|
||||
@@ -82,18 +96,13 @@ export interface ISoftwarePackage {
|
||||
automatic_install?: boolean; // POST only
|
||||
self_service: boolean;
|
||||
icon_url: string | null;
|
||||
status: {
|
||||
installed: number;
|
||||
pending_install: number;
|
||||
failed_install: number;
|
||||
pending_uninstall: number;
|
||||
failed_uninstall: number;
|
||||
};
|
||||
status: ISoftwarePackageStatus;
|
||||
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
|
||||
install_during_setup?: boolean;
|
||||
labels_include_any: ILabelSoftwareTitle[] | null;
|
||||
labels_exclude_any: ILabelSoftwareTitle[] | null;
|
||||
categories?: SoftwareCategory[];
|
||||
fleet_maintained_app_id?: number | null;
|
||||
}
|
||||
|
||||
export const isSoftwarePackage = (
|
||||
@@ -109,11 +118,7 @@ export interface IAppStoreApp {
|
||||
icon_url: string;
|
||||
self_service: boolean;
|
||||
platform: typeof HOST_APPLE_PLATFORMS[number];
|
||||
status: {
|
||||
installed: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
status: ISoftwareAppStoreAppStatus;
|
||||
install_during_setup?: boolean;
|
||||
automatic_install_policies?: ISoftwareInstallPolicy[] | null;
|
||||
automatic_install?: boolean;
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
import { Link } from "react-router";
|
||||
import paths from "router/paths";
|
||||
import { ISoftwareInstallPolicy } from "interfaces/software";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
|
||||
import Modal from "components/Modal";
|
||||
import Button from "components/buttons/Button";
|
||||
import CustomLink from "components/CustomLink";
|
||||
|
||||
const baseClass = "automatic-install-modal";
|
||||
|
||||
interface IPoliciesListItemProps {
|
||||
teamId: number;
|
||||
policy: ISoftwareInstallPolicy;
|
||||
}
|
||||
|
||||
const PoliciesListItem = ({ teamId, policy }: IPoliciesListItemProps) => {
|
||||
return (
|
||||
<li key={policy.id} className={`${baseClass}__list-item`}>
|
||||
<Link
|
||||
to={getPathWithQueryParams(paths.EDIT_POLICY(policy.id), {
|
||||
team_id: teamId,
|
||||
})}
|
||||
>
|
||||
{policy.name}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPoliciesListProps {
|
||||
teamId: number;
|
||||
policies: ISoftwareInstallPolicy[];
|
||||
}
|
||||
|
||||
const PoliciesList = ({ teamId, policies }: IPoliciesListProps) => {
|
||||
return (
|
||||
<ul className={`${baseClass}__list`}>
|
||||
{policies.map((policy) => (
|
||||
<PoliciesListItem key={policy.id} teamId={teamId} policy={policy} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
interface IAutomaticInstallModalProps {
|
||||
teamId: number;
|
||||
policies: ISoftwareInstallPolicy[];
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
const AutomaticInstallModal = ({
|
||||
teamId,
|
||||
policies,
|
||||
onExit,
|
||||
}: IAutomaticInstallModalProps) => {
|
||||
const description =
|
||||
policies.length > 1 ? (
|
||||
<>
|
||||
Software will be installed when hosts fail any of these policies.{" "}
|
||||
<CustomLink
|
||||
newTab
|
||||
text="Learn more"
|
||||
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Software will be installed when hosts fail this policy.{" "}
|
||||
<CustomLink
|
||||
newTab
|
||||
text="Learn more"
|
||||
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={baseClass}
|
||||
title="Automatic install"
|
||||
onExit={onExit}
|
||||
width="large"
|
||||
>
|
||||
<>
|
||||
<p className={`${baseClass}__description`}>{description}</p>
|
||||
<PoliciesList teamId={teamId} policies={policies} />
|
||||
<div className="modal-cta-wrap">
|
||||
<Button onClick={onExit}>Done</Button>
|
||||
</div>
|
||||
</>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AutomaticInstallModal;
|
||||
-23
@@ -1,23 +0,0 @@
|
||||
.automatic-install-modal {
|
||||
|
||||
&__description {
|
||||
margin: 0 0 $pad-large
|
||||
}
|
||||
|
||||
&__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: $border-radius-medium;
|
||||
}
|
||||
|
||||
&__list-item {
|
||||
border-bottom: 1px solid $ui-fleet-black-10;
|
||||
padding: $pad-small $pad-large;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from "./AutomaticInstallModal";
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import InstallerDetailsWidget from "./InstallerDetailsWidget";
|
||||
|
||||
// Mock current time for time stamp test
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date("2024-05-08T10:00:00Z"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
describe("InstallerDetailsWidget", () => {
|
||||
const defaultProps = {
|
||||
softwareName: "Test Software",
|
||||
installerType: "package" as const,
|
||||
addedTimestamp: "2024-05-06T10:00:00Z",
|
||||
versionInfo: <span>v1.2.3</span>,
|
||||
isFma: false,
|
||||
};
|
||||
|
||||
it("renders the package icon when installerType is 'package'", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} />);
|
||||
expect(screen.queryByTestId("file-pkg-graphic")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("software-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the software name", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} />);
|
||||
expect(screen.getByText("Test Software")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders version info and relative time when addedTimestamp is present", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} />);
|
||||
expect(screen.getByText("v1.2.3")).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 days ago/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only version info when addedTimestamp is not present", () => {
|
||||
render(
|
||||
<InstallerDetailsWidget {...defaultProps} addedTimestamp={undefined} />
|
||||
);
|
||||
expect(screen.queryByText(/2 days ago/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies additional className if provided", () => {
|
||||
render(
|
||||
<InstallerDetailsWidget {...defaultProps} className="extra-class" />
|
||||
);
|
||||
const rootDiv = document.querySelector(
|
||||
".installer-details-widget.extra-class"
|
||||
);
|
||||
expect(rootDiv).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom package label", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/custom package/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders FMA label", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} isFma />);
|
||||
|
||||
expect(screen.getByText(/Fleet-maintained/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders VPP label", () => {
|
||||
render(<InstallerDetailsWidget {...defaultProps} installerType="vpp" />);
|
||||
|
||||
expect(screen.getByText(/App Store \(VPP\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("InstallerName disables tooltip if not truncated", () => {
|
||||
// useCheckTruncatedElement is mocked to return false
|
||||
render(<InstallerDetailsWidget {...defaultProps} />);
|
||||
// TooltipWrapper is mocked, so we just check that the child is rendered
|
||||
expect(screen.getByText("Test Software")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+36
-18
@@ -12,13 +12,13 @@ import Graphic from "components/Graphic";
|
||||
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
|
||||
const baseClass = "software-details-widget";
|
||||
const baseClass = "installer-details-widget";
|
||||
|
||||
interface ISoftwareNameProps {
|
||||
interface IInstallerNameProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const SoftwareName = ({ name }: ISoftwareNameProps) => {
|
||||
const InstallerName = ({ name }: IInstallerNameProps) => {
|
||||
const titleRef = React.useRef<HTMLDivElement>(null);
|
||||
const isTruncated = useCheckTruncatedElement(titleRef);
|
||||
|
||||
@@ -37,21 +37,30 @@ const SoftwareName = ({ name }: ISoftwareNameProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwareDetailsWidget {
|
||||
const renderInstallerDisplayText = (installerType: string, isFma: boolean) => {
|
||||
if (installerType === "package") {
|
||||
return isFma ? "Fleet-maintained" : "Custom package";
|
||||
}
|
||||
return "App Store (VPP)";
|
||||
};
|
||||
|
||||
interface IInstallerDetailsWidgetProps {
|
||||
className?: string;
|
||||
softwareName: string;
|
||||
installerType: "package" | "vpp";
|
||||
addedTimestamp?: string;
|
||||
versionInfo?: JSX.Element;
|
||||
isFma: boolean;
|
||||
}
|
||||
|
||||
const SoftwareDetailsWidget = ({
|
||||
const InstallerDetailsWidget = ({
|
||||
className,
|
||||
softwareName,
|
||||
installerType,
|
||||
addedTimestamp,
|
||||
versionInfo,
|
||||
}: ISoftwareDetailsWidget) => {
|
||||
isFma,
|
||||
}: IInstallerDetailsWidgetProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
const renderIcon = () => {
|
||||
@@ -63,17 +72,26 @@ const SoftwareDetailsWidget = ({
|
||||
};
|
||||
|
||||
const renderDetails = () => {
|
||||
return !addedTimestamp ? (
|
||||
versionInfo
|
||||
) : (
|
||||
const renderTimeStamp = () =>
|
||||
addedTimestamp ? (
|
||||
<>
|
||||
{" "}
|
||||
•{" "}
|
||||
<TooltipWrapper
|
||||
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
|
||||
underline={false}
|
||||
>
|
||||
{addedFromNow(addedTimestamp)}
|
||||
</TooltipWrapper>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{versionInfo} •{" "}
|
||||
<TooltipWrapper
|
||||
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
|
||||
underline={false}
|
||||
>
|
||||
{addedFromNow(addedTimestamp)}
|
||||
</TooltipWrapper>
|
||||
{renderInstallerDisplayText(installerType, isFma)} • {versionInfo}
|
||||
{renderTimeStamp()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -82,11 +100,11 @@ const SoftwareDetailsWidget = ({
|
||||
<div className={classNames}>
|
||||
{renderIcon()}
|
||||
<div className={`${baseClass}__info`}>
|
||||
<SoftwareName name={softwareName} />
|
||||
<InstallerName name={softwareName} />
|
||||
<span className={`${baseClass}__details`}>{renderDetails()}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwareDetailsWidget;
|
||||
export default InstallerDetailsWidget;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
.software-details-widget {
|
||||
.installer-details-widget {
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./InstallerDetailsWidget";
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { screen, render } from "@testing-library/react";
|
||||
import InstallerPoliciesTable from "./InstallerPoliciesTable";
|
||||
|
||||
describe("InstallerPoliciesTable", () => {
|
||||
it("renders policy names as links and footer info", () => {
|
||||
const policies = [{ id: 1, name: "No Gatekeeper" }];
|
||||
|
||||
render(<InstallerPoliciesTable teamId={42} policies={policies} />);
|
||||
|
||||
// There should be two cells, each with a link
|
||||
const cells = screen.getAllByRole("cell");
|
||||
expect(cells).toHaveLength(1);
|
||||
|
||||
// Each cell should contain a link with the policy name
|
||||
expect(cells[0].querySelector("a.link-cell")).toHaveTextContent(
|
||||
/No Gatekeeper/i
|
||||
);
|
||||
const POLICY_COUNT = /1 policy/i;
|
||||
expect(screen.getByText(POLICY_COUNT)).toBeInTheDocument();
|
||||
|
||||
const FOOTER_TEXT = /Software will be installed when hosts fail/i;
|
||||
expect(screen.getByText(FOOTER_TEXT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import React, { useCallback } from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
import { ISoftwareInstallPolicy } from "interfaces/software";
|
||||
|
||||
import TableContainer from "components/TableContainer";
|
||||
import TableCount from "components/TableContainer/TableCount";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import generateInstallerPoliciesTableConfig from "./InstallerPoliciesTableConfig";
|
||||
|
||||
const baseClass = "installer-policies-table";
|
||||
|
||||
interface IInstallerPoliciesTable {
|
||||
className?: string;
|
||||
teamId?: number;
|
||||
isLoading?: boolean;
|
||||
policies?: ISoftwareInstallPolicy[] | null;
|
||||
}
|
||||
const InstallerPoliciesTable = ({
|
||||
className,
|
||||
teamId,
|
||||
isLoading = false,
|
||||
policies,
|
||||
}: IInstallerPoliciesTable) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
const softwareStatusHeaders = generateInstallerPoliciesTableConfig({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const renderInstallerPoliciesCount = useCallback(() => {
|
||||
return <TableCount name="policies" count={policies?.length} />;
|
||||
}, [policies?.length]);
|
||||
|
||||
const renderTableHelpText = () => (
|
||||
<div>
|
||||
Software will be installed when hosts fail{" "}
|
||||
{policies?.length === 1 ? "this policy" : "any of these policies"}.{" "}
|
||||
<CustomLink
|
||||
url="https://fleetdm.com/learn-more-about/policy-automation-install-software"
|
||||
text="Learn more"
|
||||
newTab
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
className={baseClass}
|
||||
isLoading={isLoading}
|
||||
columnConfigs={softwareStatusHeaders}
|
||||
data={policies || []}
|
||||
renderCount={renderInstallerPoliciesCount}
|
||||
disablePagination
|
||||
disableMultiRowSelect
|
||||
emptyComponent={() => <></>}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
renderTableHelpText={renderTableHelpText}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstallerPoliciesTable;
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import React from "react";
|
||||
|
||||
import { ISoftwareInstallPolicy } from "interfaces/software";
|
||||
import PATHS from "router/paths";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
|
||||
import LinkCell from "components/TableContainer/DataTable/LinkCell";
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
|
||||
|
||||
interface IInstallerPoliciesTableConfig {
|
||||
teamId?: number;
|
||||
}
|
||||
interface ICellProps {
|
||||
cell: {
|
||||
value: string;
|
||||
};
|
||||
row: {
|
||||
original: ISoftwareInstallPolicy;
|
||||
};
|
||||
column: {
|
||||
isSortedDesc: boolean;
|
||||
title: string;
|
||||
};
|
||||
}
|
||||
|
||||
const generateInstallerPoliciesTableConfig = ({
|
||||
teamId,
|
||||
}: IInstallerPoliciesTableConfig) => {
|
||||
const tableHeaders = [
|
||||
{
|
||||
accessor: "name",
|
||||
title: "Name",
|
||||
Header: (cellProps: ICellProps) => (
|
||||
<HeaderCell
|
||||
value={cellProps.column.title}
|
||||
isSortedDesc={cellProps.column.isSortedDesc}
|
||||
/>
|
||||
),
|
||||
Cell: (cellProps: ICellProps) => (
|
||||
<LinkCell
|
||||
value={cellProps.cell.value}
|
||||
path={getPathWithQueryParams(
|
||||
PATHS.EDIT_POLICY(cellProps.row.original.id),
|
||||
{
|
||||
team_id: teamId,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return tableHeaders;
|
||||
};
|
||||
|
||||
export default generateInstallerPoliciesTableConfig;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.installer-status-table {
|
||||
&__status-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./InstallerPoliciesTable";
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import React from "react";
|
||||
import { screen } from "@testing-library/react";
|
||||
import { createCustomRenderer } from "test/test-utils";
|
||||
import InstallerStatusTable from "./InstallerStatusTable";
|
||||
|
||||
describe("InstallerStatusTable", () => {
|
||||
const render = createCustomRenderer();
|
||||
|
||||
it("renders columns and links for statuses", () => {
|
||||
render(
|
||||
<InstallerStatusTable
|
||||
softwareId={123}
|
||||
teamId={5}
|
||||
status={{ installed: 0, pending: 1, failed: 3 }}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check cell values (always "hosts", even for 1)
|
||||
const cells = screen.getAllByRole("cell");
|
||||
expect(cells[0]).toHaveTextContent("0 hosts");
|
||||
expect(cells[1]).toHaveTextContent("1 host");
|
||||
expect(cells[2]).toHaveTextContent("3 hosts");
|
||||
|
||||
// Check the anchor and its text in each cell
|
||||
expect(cells[0].querySelector("a.link-cell")).toHaveTextContent("0 hosts");
|
||||
expect(cells[1].querySelector("a.link-cell")).toHaveTextContent("1 host");
|
||||
expect(cells[2].querySelector("a.link-cell")).toHaveTextContent("3 hosts");
|
||||
});
|
||||
});
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import React from "react";
|
||||
import classnames from "classnames";
|
||||
|
||||
import TableContainer from "components/TableContainer";
|
||||
import {
|
||||
ISoftwarePackageStatus,
|
||||
ISoftwareAppStoreAppStatus,
|
||||
} from "interfaces/software";
|
||||
import generateSoftwareTitleDetailsTableConfig from "./InstallerStatusTableConfig";
|
||||
|
||||
const baseClass = "installer-status-table";
|
||||
|
||||
interface IInstallerStatusTableProps {
|
||||
className?: string;
|
||||
softwareId: number;
|
||||
teamId?: number;
|
||||
status: ISoftwarePackageStatus | ISoftwareAppStoreAppStatus;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
const InstallerStatusTable = ({
|
||||
className,
|
||||
softwareId,
|
||||
teamId,
|
||||
status,
|
||||
isLoading = false,
|
||||
}: IInstallerStatusTableProps) => {
|
||||
const classNames = classnames(baseClass, className);
|
||||
|
||||
const softwareStatusHeaders = generateSoftwareTitleDetailsTableConfig({
|
||||
baseClass: classNames,
|
||||
softwareId,
|
||||
teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
className={baseClass}
|
||||
isLoading={isLoading}
|
||||
columnConfigs={softwareStatusHeaders}
|
||||
data={[status]}
|
||||
disablePagination
|
||||
disableMultiRowSelect
|
||||
emptyComponent={() => <></>}
|
||||
showMarkAllPages={false}
|
||||
isAllPagesSelected={false}
|
||||
disableHighlightOnHover
|
||||
hideFooter
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default InstallerStatusTable;
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
|
||||
import { ISoftwareTitleVersion } from "interfaces/software";
|
||||
import PATHS from "router/paths";
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
import { generateResultsCountText } from "components/TableContainer/utilities/TableContainerUtils";
|
||||
|
||||
import LinkCell from "components/TableContainer/DataTable/LinkCell";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import Icon from "components/Icon";
|
||||
import HeaderCell from "components/TableContainer/DataTable/HeaderCell";
|
||||
|
||||
interface ISoftwareTitleDetailsTableConfigProps {
|
||||
softwareId?: number;
|
||||
teamId?: number;
|
||||
baseClass?: string;
|
||||
}
|
||||
interface ICellProps {
|
||||
cell: {
|
||||
value: number;
|
||||
};
|
||||
row: {
|
||||
original: ISoftwareTitleVersion;
|
||||
};
|
||||
}
|
||||
|
||||
interface IStatusDisplayOption {
|
||||
displayName: string;
|
||||
iconName: "success" | "pending-outline" | "error";
|
||||
tooltip: React.ReactNode;
|
||||
}
|
||||
|
||||
// "pending" and "failed" each encompass both "_install" and "_uninstall" sub-statuses
|
||||
type SoftwareInstallDisplayStatus = "installed" | "pending" | "failed";
|
||||
|
||||
const STATUS_DISPLAY_OPTIONS: Record<
|
||||
SoftwareInstallDisplayStatus,
|
||||
IStatusDisplayOption
|
||||
> = {
|
||||
installed: {
|
||||
displayName: "Installed",
|
||||
iconName: "success",
|
||||
tooltip: (
|
||||
<>
|
||||
Software is installed on these hosts (install script finished
|
||||
<br />
|
||||
with exit code 0). Currently, if the software is uninstalled, the
|
||||
<br />
|
||||
"Installed" status won't be updated.
|
||||
</>
|
||||
),
|
||||
},
|
||||
pending: {
|
||||
displayName: "Pending",
|
||||
iconName: "pending-outline",
|
||||
tooltip: (
|
||||
<>
|
||||
Fleet is installing/uninstalling or will
|
||||
<br />
|
||||
do so when the host comes online.
|
||||
</>
|
||||
),
|
||||
},
|
||||
failed: {
|
||||
displayName: "Failed",
|
||||
iconName: "error",
|
||||
tooltip: (
|
||||
<>
|
||||
These hosts failed to install/uninstall software.
|
||||
<br />
|
||||
Click on a host to view error(s).
|
||||
</>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const generateSoftwareTitleDetailsTableConfig = ({
|
||||
softwareId,
|
||||
teamId,
|
||||
baseClass,
|
||||
}: ISoftwareTitleDetailsTableConfigProps) => {
|
||||
const tableHeaders = [
|
||||
{
|
||||
accessor: "installed",
|
||||
disableSortBy: true,
|
||||
title: "Installed",
|
||||
Header: () => {
|
||||
const displayData = STATUS_DISPLAY_OPTIONS.installed;
|
||||
const titleWithTooltip = (
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={displayData.tooltip}
|
||||
underline={false}
|
||||
showArrow
|
||||
tipOffset={10}
|
||||
>
|
||||
<div className={`${baseClass}__status-title`}>
|
||||
<Icon name={displayData.iconName} />
|
||||
<div>{displayData.displayName}</div>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
return <HeaderCell value={titleWithTooltip} disableSortBy />;
|
||||
},
|
||||
Cell: (cellProps: ICellProps) => {
|
||||
return (
|
||||
<LinkCell
|
||||
value={generateResultsCountText("hosts", cellProps.cell.value)}
|
||||
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
|
||||
software_title_id: softwareId,
|
||||
software_status: "installed",
|
||||
team_id: teamId,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "pending",
|
||||
disableSortBy: true,
|
||||
title: "Pending",
|
||||
Header: () => {
|
||||
const displayData = STATUS_DISPLAY_OPTIONS.pending;
|
||||
return (
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={displayData.tooltip}
|
||||
underline={false}
|
||||
showArrow
|
||||
tipOffset={10}
|
||||
>
|
||||
<div className={`${baseClass}__status-title`}>
|
||||
<Icon name={displayData.iconName} />
|
||||
<div>{displayData.displayName}</div>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
},
|
||||
Cell: (cellProps: ICellProps) => {
|
||||
return (
|
||||
<LinkCell
|
||||
value={generateResultsCountText("hosts", cellProps.cell.value)}
|
||||
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
|
||||
software_title_id: softwareId,
|
||||
software_status: "pending",
|
||||
team_id: teamId,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessor: "failed",
|
||||
disableSortBy: true,
|
||||
title: "Failed",
|
||||
Header: () => {
|
||||
const displayData = STATUS_DISPLAY_OPTIONS.failed;
|
||||
return (
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={displayData.tooltip}
|
||||
underline={false}
|
||||
showArrow
|
||||
tipOffset={10}
|
||||
>
|
||||
<div className={`${baseClass}__status-title`}>
|
||||
<Icon name={displayData.iconName} />
|
||||
<div>{displayData.displayName}</div>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
},
|
||||
Cell: (cellProps: ICellProps) => {
|
||||
return (
|
||||
<LinkCell
|
||||
value={generateResultsCountText("hosts", cellProps.cell.value)}
|
||||
path={getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
|
||||
software_title_id: softwareId,
|
||||
software_status: "failed",
|
||||
team_id: teamId,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return tableHeaders;
|
||||
};
|
||||
|
||||
export default generateSoftwareTitleDetailsTableConfig;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.installer-status-table {
|
||||
&__status-title {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: $pad-small;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./InstallerStatusTable";
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { default } from "./SoftwareDetailsWidget";
|
||||
+74
-118
@@ -2,7 +2,6 @@
|
||||
|
||||
import React, { useCallback, useContext, useState } from "react";
|
||||
|
||||
import PATHS from "router/paths";
|
||||
import { AppContext } from "context/app";
|
||||
import { NotificationContext } from "context/notification";
|
||||
import {
|
||||
@@ -12,33 +11,32 @@ import {
|
||||
} from "interfaces/software";
|
||||
import softwareAPI from "services/entities/software";
|
||||
|
||||
import { getPathWithQueryParams } from "utilities/url";
|
||||
import { SELF_SERVICE_TOOLTIP } from "pages/SoftwarePage/helpers";
|
||||
|
||||
import Card from "components/Card";
|
||||
|
||||
import ActionsDropdown from "components/ActionsDropdown";
|
||||
import TooltipWrapper from "components/TooltipWrapper";
|
||||
import DataSet from "components/DataSet";
|
||||
import Icon from "components/Icon";
|
||||
import Tag from "components/Tag";
|
||||
import Button from "components/buttons/Button";
|
||||
|
||||
import endpoints from "utilities/endpoints";
|
||||
import URL_PREFIX from "router/url_prefix";
|
||||
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import SoftwareDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareDetailsWidget";
|
||||
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
|
||||
import CategoriesEndUserExperienceModal from "pages/SoftwarePage/components/modals/CategoriesEndUserExperienceModal";
|
||||
|
||||
import DeleteSoftwareModal from "../DeleteSoftwareModal";
|
||||
import EditSoftwareModal from "../EditSoftwareModal";
|
||||
|
||||
import {
|
||||
APP_STORE_APP_DROPDOWN_OPTIONS,
|
||||
SOFTWARE_PACKAGE_DROPDOWN_OPTIONS,
|
||||
APP_STORE_APP_ACTION_OPTIONS,
|
||||
SOFTWARE_PACKAGE_ACTION_OPTIONS,
|
||||
downloadFile,
|
||||
} from "./helpers";
|
||||
import AutomaticInstallModal from "../AutomaticInstallModal";
|
||||
import InstallerStatusTable from "./InstallerStatusTable";
|
||||
import InstallerPoliciesTable from "./InstallerPoliciesTable";
|
||||
|
||||
const baseClass = "software-installer-card";
|
||||
|
||||
@@ -92,52 +90,6 @@ const STATUS_DISPLAY_OPTIONS: Record<
|
||||
},
|
||||
};
|
||||
|
||||
interface IInstallerStatusCountProps {
|
||||
softwareId: number;
|
||||
status: SoftwareInstallDisplayStatus;
|
||||
count: number;
|
||||
teamId?: number;
|
||||
}
|
||||
|
||||
const InstallerStatusCount = ({
|
||||
softwareId,
|
||||
status,
|
||||
count,
|
||||
teamId,
|
||||
}: IInstallerStatusCountProps) => {
|
||||
const displayData = STATUS_DISPLAY_OPTIONS[status];
|
||||
const linkUrl = getPathWithQueryParams(PATHS.MANAGE_HOSTS, {
|
||||
software_title_id: softwareId,
|
||||
software_status: status,
|
||||
team_id: teamId,
|
||||
});
|
||||
|
||||
return (
|
||||
<DataSet
|
||||
className={`${baseClass}__status`}
|
||||
title={
|
||||
<TooltipWrapper
|
||||
position="top"
|
||||
tipContent={displayData.tooltip}
|
||||
underline={false}
|
||||
showArrow
|
||||
tipOffset={10}
|
||||
>
|
||||
<div className={`${baseClass}__status-title`}>
|
||||
<Icon name={displayData.iconName} />
|
||||
<div>{displayData.displayName}</div>
|
||||
</div>
|
||||
</TooltipWrapper>
|
||||
}
|
||||
value={
|
||||
<a className={`${baseClass}__status-count`} href={linkUrl}>
|
||||
{count} hosts
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
interface IActionsDropdownProps {
|
||||
installerType: "package" | "vpp";
|
||||
onDownloadClick: () => void;
|
||||
@@ -145,7 +97,7 @@ interface IActionsDropdownProps {
|
||||
onEditSoftwareClick: () => void;
|
||||
}
|
||||
|
||||
const SoftwareActionsDropdown = ({
|
||||
const SoftwareActionButtons = ({
|
||||
installerType,
|
||||
onDownloadClick,
|
||||
onDeleteClick,
|
||||
@@ -155,26 +107,10 @@ const SoftwareActionsDropdown = ({
|
||||
const { gitops_mode_enabled: gitOpsModeEnabled, repository_url: repoURL } =
|
||||
config?.gitops || {};
|
||||
|
||||
const onSelect = (action: string) => {
|
||||
switch (action) {
|
||||
case "download":
|
||||
onDownloadClick();
|
||||
break;
|
||||
case "delete":
|
||||
onDeleteClick();
|
||||
break;
|
||||
case "edit":
|
||||
onEditSoftwareClick();
|
||||
break;
|
||||
default:
|
||||
// noop
|
||||
}
|
||||
};
|
||||
|
||||
let options =
|
||||
installerType === "package"
|
||||
? [...SOFTWARE_PACKAGE_DROPDOWN_OPTIONS]
|
||||
: [...APP_STORE_APP_DROPDOWN_OPTIONS];
|
||||
? [...SOFTWARE_PACKAGE_ACTION_OPTIONS]
|
||||
: [...APP_STORE_APP_ACTION_OPTIONS];
|
||||
|
||||
if (gitOpsModeEnabled) {
|
||||
const tooltipContent = (
|
||||
@@ -206,15 +142,39 @@ const SoftwareActionsDropdown = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Map action values to handlers
|
||||
const actionHandlers = {
|
||||
download: onDownloadClick,
|
||||
delete: onDeleteClick,
|
||||
edit: onEditSoftwareClick,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${baseClass}__actions`}>
|
||||
<ActionsDropdown
|
||||
className={`${baseClass}__software-actions-dropdown`}
|
||||
onChange={onSelect}
|
||||
placeholder="Actions"
|
||||
menuAlign="right"
|
||||
options={options}
|
||||
/>
|
||||
{options.map((option) => {
|
||||
const ButtonContent = (
|
||||
<Button
|
||||
key={option.value}
|
||||
className={`btn btn-link ${baseClass}__action-btn`}
|
||||
disabled={option.disabled}
|
||||
onClick={() =>
|
||||
actionHandlers[option.value as keyof typeof actionHandlers]?.()
|
||||
}
|
||||
variant="icon"
|
||||
>
|
||||
<Icon name={option.iconName} color="core-fleet-blue" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
// If there's a tooltip, wrap the button
|
||||
return option.tooltipContent ? (
|
||||
<TooltipWrapper key={option.value} tipContent={option.tooltipContent}>
|
||||
{ButtonContent}
|
||||
</TooltipWrapper>
|
||||
) : (
|
||||
ButtonContent
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -234,6 +194,7 @@ interface ISoftwareInstallerCardProps {
|
||||
softwareInstaller: ISoftwarePackage | IAppStoreApp;
|
||||
onDelete: () => void;
|
||||
refetchSoftwareTitle: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
// NOTE: This component is dependent on having either a software package
|
||||
@@ -250,10 +211,19 @@ const SoftwareInstallerCard = ({
|
||||
teamId,
|
||||
onDelete,
|
||||
refetchSoftwareTitle,
|
||||
isLoading,
|
||||
}: ISoftwareInstallerCardProps) => {
|
||||
const installerType = isSoftwarePackage(softwareInstaller)
|
||||
? "package"
|
||||
: "vpp";
|
||||
const isFleetMaintainedApp =
|
||||
"fleet_maintained_app_id" in softwareInstaller &&
|
||||
!!softwareInstaller.fleet_maintained_app_id;
|
||||
|
||||
const {
|
||||
automatic_install_policies: automaticInstallPolicies,
|
||||
} = softwareInstaller;
|
||||
|
||||
const {
|
||||
isGlobalAdmin,
|
||||
isGlobalMaintainer,
|
||||
@@ -265,9 +235,6 @@ const SoftwareInstallerCard = ({
|
||||
|
||||
const [showEditSoftwareModal, setShowEditSoftwareModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showAutomaticInstallModal, setShowAutomaticInstallModal] = useState(
|
||||
false
|
||||
);
|
||||
|
||||
const onEditSoftwareClick = () => {
|
||||
setShowEditSoftwareModal(true);
|
||||
@@ -340,26 +307,27 @@ const SoftwareInstallerCard = ({
|
||||
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
|
||||
<div className={`${baseClass}__row-1`}>
|
||||
<div className={`${baseClass}__row-1--responsive`}>
|
||||
<SoftwareDetailsWidget
|
||||
<InstallerDetailsWidget
|
||||
softwareName={softwareInstaller?.name || name}
|
||||
installerType={installerType}
|
||||
versionInfo={versionInfo}
|
||||
addedTimestamp={addedTimestamp}
|
||||
isFma={isFleetMaintainedApp}
|
||||
/>
|
||||
<div className={`${baseClass}__tags-wrapper`}>
|
||||
{Array.isArray(softwareInstaller.automatic_install_policies) &&
|
||||
softwareInstaller.automatic_install_policies.length > 0 && (
|
||||
{Array.isArray(automaticInstallPolicies) &&
|
||||
automaticInstallPolicies.length > 0 && (
|
||||
<TooltipWrapper
|
||||
showArrow
|
||||
position="top"
|
||||
tipContent="Click to see policy that triggers automatic install."
|
||||
tipContent={
|
||||
automaticInstallPolicies.length === 1
|
||||
? "A policy triggers install."
|
||||
: `${automaticInstallPolicies.length} policies trigger install.`
|
||||
}
|
||||
underline={false}
|
||||
>
|
||||
<Tag
|
||||
icon="refresh"
|
||||
text="Automatic install"
|
||||
onClick={() => setShowAutomaticInstallModal(true)}
|
||||
/>
|
||||
<Tag icon="refresh" text="Automatic install" />
|
||||
</TooltipWrapper>
|
||||
)}
|
||||
{isSelfService && (
|
||||
@@ -376,7 +344,7 @@ const SoftwareInstallerCard = ({
|
||||
</div>
|
||||
<div className={`${baseClass}__actions-wrapper`}>
|
||||
{showActions && (
|
||||
<SoftwareActionsDropdown
|
||||
<SoftwareActionButtons
|
||||
installerType={installerType}
|
||||
onDownloadClick={onDownloadClick}
|
||||
onDeleteClick={onDeleteClick}
|
||||
@@ -385,26 +353,23 @@ const SoftwareInstallerCard = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${baseClass}__installer-statuses`}>
|
||||
<InstallerStatusCount
|
||||
<div className={`${baseClass}__installer-status-table`}>
|
||||
<InstallerStatusTable
|
||||
softwareId={softwareId}
|
||||
status="installed"
|
||||
count={status.installed}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<InstallerStatusCount
|
||||
softwareId={softwareId}
|
||||
status="pending"
|
||||
count={status.pending}
|
||||
teamId={teamId}
|
||||
/>
|
||||
<InstallerStatusCount
|
||||
softwareId={softwareId}
|
||||
status="failed"
|
||||
count={status.failed}
|
||||
teamId={teamId}
|
||||
status={status}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{automaticInstallPolicies && (
|
||||
<div className={`${baseClass}__installer-policies-table`}>
|
||||
<InstallerPoliciesTable
|
||||
teamId={teamId}
|
||||
isLoading={isLoading}
|
||||
policies={automaticInstallPolicies}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showEditSoftwareModal && (
|
||||
<EditSoftwareModal
|
||||
softwareId={softwareId}
|
||||
@@ -424,15 +389,6 @@ const SoftwareInstallerCard = ({
|
||||
onSuccess={onDeleteSuccess}
|
||||
/>
|
||||
)}
|
||||
{showAutomaticInstallModal &&
|
||||
softwareInstaller?.automatic_install_policies &&
|
||||
softwareInstaller?.automatic_install_policies.length > 0 && (
|
||||
<AutomaticInstallModal
|
||||
teamId={teamId}
|
||||
policies={softwareInstaller.automatic_install_policies}
|
||||
onExit={() => setShowAutomaticInstallModal(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
+9
@@ -5,6 +5,15 @@
|
||||
align-items: center;
|
||||
gap: $pad-medium;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&__installer-status-table,
|
||||
&__installer-policies-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__row-1 {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
|
||||
+21
-8
@@ -1,24 +1,37 @@
|
||||
const DOWNLOAD_OPTION = {
|
||||
label: "Download",
|
||||
import { IconNames } from "components/icons";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type ISoftwareOption = {
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
iconName: IconNames;
|
||||
tooltipContent?: ReactNode;
|
||||
};
|
||||
|
||||
const DOWNLOAD_OPTION: ISoftwareOption = {
|
||||
value: "download",
|
||||
disabled: false,
|
||||
iconName: "download",
|
||||
};
|
||||
|
||||
const EDIT_OPTION = {
|
||||
label: "Edit",
|
||||
const EDIT_OPTION: ISoftwareOption = {
|
||||
value: "edit",
|
||||
disabled: false,
|
||||
iconName: "pencil",
|
||||
};
|
||||
const DELETE_OPTION = {
|
||||
label: "Delete",
|
||||
const DELETE_OPTION: ISoftwareOption = {
|
||||
value: "delete",
|
||||
disabled: false,
|
||||
iconName: "trash",
|
||||
};
|
||||
|
||||
export const SOFTWARE_PACKAGE_DROPDOWN_OPTIONS = [
|
||||
export const SOFTWARE_PACKAGE_ACTION_OPTIONS = [
|
||||
DOWNLOAD_OPTION,
|
||||
EDIT_OPTION,
|
||||
DELETE_OPTION,
|
||||
] as const;
|
||||
|
||||
export const APP_STORE_APP_DROPDOWN_OPTIONS = [
|
||||
export const APP_STORE_APP_ACTION_OPTIONS = [
|
||||
EDIT_OPTION,
|
||||
DELETE_OPTION,
|
||||
] as const;
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/** software/titles/:id > First section */
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import {
|
||||
formatSoftwareType,
|
||||
isIpadOrIphoneSoftwareSource,
|
||||
ISoftwareTitleDetails,
|
||||
} from "interfaces/software";
|
||||
|
||||
import Card from "components/Card";
|
||||
import SoftwareDetailsSummary from "pages/SoftwarePage/components/cards/SoftwareDetailsSummary";
|
||||
import TitleVersionsTable from "./TitleVersionsTable";
|
||||
|
||||
interface ISoftwareSummaryCard {
|
||||
title: ISoftwareTitleDetails;
|
||||
softwareId: number;
|
||||
teamId?: number;
|
||||
isAvailableForInstall?: boolean;
|
||||
isLoading?: boolean;
|
||||
router: InjectedRouter;
|
||||
}
|
||||
|
||||
const baseClass = "software-summary-card";
|
||||
|
||||
const SoftwareSummaryCard = ({
|
||||
teamId,
|
||||
softwareId,
|
||||
isAvailableForInstall,
|
||||
title,
|
||||
isLoading = false,
|
||||
router,
|
||||
}: ISoftwareSummaryCard) => {
|
||||
// Hide versions card for tgz_packages only
|
||||
if (title.source === "tgz_packages") return null;
|
||||
|
||||
return (
|
||||
<Card borderRadiusSize="xxlarge" includeShadow className={baseClass}>
|
||||
<SoftwareDetailsSummary
|
||||
title={title.name}
|
||||
type={formatSoftwareType(title)}
|
||||
versions={title.versions?.length ?? 0}
|
||||
hosts={title.hosts_count}
|
||||
countsUpdatedAt={title.counts_updated_at}
|
||||
queryParams={{
|
||||
software_title_id: softwareId,
|
||||
team_id: teamId,
|
||||
}}
|
||||
name={title.name}
|
||||
source={title.source}
|
||||
iconUrl={title.app_store_app ? title.app_store_app.icon_url : undefined}
|
||||
/>
|
||||
<TitleVersionsTable
|
||||
router={router}
|
||||
data={title.versions ?? []}
|
||||
isLoading={isLoading}
|
||||
teamIdForApi={teamId}
|
||||
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(title.source)}
|
||||
isAvailableForInstall={isAvailableForInstall}
|
||||
countsUpdatedAt={title.counts_updated_at}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwareSummaryCard;
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
import React from "react";
|
||||
import { screen, render } from "@testing-library/react";
|
||||
import { ISoftwareTitleVersion } from "interfaces/software";
|
||||
import TitleVersionsTable from "./TitleVersionsTable";
|
||||
|
||||
// TODO: figure out how to mock the router properly.
|
||||
const mockRouter = {
|
||||
push: jest.fn(),
|
||||
replace: jest.fn(),
|
||||
goBack: jest.fn(),
|
||||
goForward: jest.fn(),
|
||||
go: jest.fn(),
|
||||
setRouteLeaveHook: jest.fn(),
|
||||
isActive: jest.fn(),
|
||||
createHref: jest.fn(),
|
||||
createPath: jest.fn(),
|
||||
};
|
||||
|
||||
describe("TitleVersionsTable", () => {
|
||||
it("renders version names as links and footer info", () => {
|
||||
const versions = [
|
||||
{ id: 10, version: "1.2.3", vulnerabilities: [] },
|
||||
{ id: 11, version: "1.2.4", vulnerabilities: [] },
|
||||
];
|
||||
|
||||
render(
|
||||
<TitleVersionsTable
|
||||
router={mockRouter}
|
||||
data={versions}
|
||||
isLoading={false}
|
||||
teamIdForApi={42}
|
||||
isIPadOSOrIOSApp={false}
|
||||
countsUpdatedAt="2024-05-08T12:00:00Z"
|
||||
/>
|
||||
);
|
||||
|
||||
// There should be one cell with a link for the version
|
||||
const cells = screen.getAllByRole("cell");
|
||||
expect(cells).toHaveLength(8);
|
||||
expect(screen.getByText(/1.2.3/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1.2.4/i)).toBeInTheDocument();
|
||||
|
||||
// Version count should be shown
|
||||
expect(screen.getByText(/2 versions/i)).toBeInTheDocument();
|
||||
|
||||
// Last updated info should be shown
|
||||
expect(screen.getByText(/updated/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders empty state if no versions detected", () => {
|
||||
const versions: ISoftwareTitleVersion[] = [];
|
||||
|
||||
render(
|
||||
<TitleVersionsTable
|
||||
router={mockRouter}
|
||||
data={versions}
|
||||
isLoading={false}
|
||||
teamIdForApi={42}
|
||||
isIPadOSOrIOSApp={false}
|
||||
countsUpdatedAt="2024-05-08T12:00:00Z"
|
||||
/>
|
||||
);
|
||||
|
||||
const cells = screen.queryAllByRole("cell");
|
||||
expect(cells).toHaveLength(0);
|
||||
|
||||
// Version count should not be shown
|
||||
expect(screen.queryByText(/0 versions/i)).not.toBeInTheDocument();
|
||||
|
||||
// Last updated info should be shown
|
||||
expect(screen.getByText(/updated/i)).toBeInTheDocument();
|
||||
|
||||
// Empty state should be shown
|
||||
expect(screen.getByText(/no versions detected/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+39
-34
@@ -14,15 +14,17 @@ import TableCount from "components/TableContainer/TableCount";
|
||||
import EmptyTable from "components/EmptyTable";
|
||||
import CustomLink from "components/CustomLink";
|
||||
import LastUpdatedText from "components/LastUpdatedText";
|
||||
import Card from "components/Card";
|
||||
|
||||
import generateSoftwareTitleDetailsTableConfig from "./SoftwareTitleDetailsTableConfig";
|
||||
import generateSoftwareTitleVersionsTableConfig from "./TitleVersionsTableConfig";
|
||||
|
||||
const DEFAULT_SORT_HEADER = "hosts_count";
|
||||
const DEFAULT_SORT_DIRECTION = "desc";
|
||||
const DEFAULT_PAGE_SIZE = 5;
|
||||
|
||||
const baseClass = "software-title-details-table";
|
||||
const baseClass = "software-title-versions-table";
|
||||
|
||||
const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => {
|
||||
const TitleVersionsLastUpdatedInfo = (lastUpdatedAt: string) => {
|
||||
return (
|
||||
<LastUpdatedText
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
@@ -39,31 +41,33 @@ const SoftwareLastUpdatedInfo = (lastUpdatedAt: string) => {
|
||||
|
||||
const NoVersionsDetected = (isAvailableForInstall = false): JSX.Element => {
|
||||
return (
|
||||
<EmptyTable
|
||||
header={
|
||||
isAvailableForInstall
|
||||
? "No versions detected."
|
||||
: "No versions detected for this software item."
|
||||
}
|
||||
info={
|
||||
isAvailableForInstall ? (
|
||||
"Install this software on a host to see versions."
|
||||
) : (
|
||||
<>
|
||||
Expecting to see versions?{" "}
|
||||
<CustomLink
|
||||
url={GITHUB_NEW_ISSUE_LINK}
|
||||
text="File an issue on GitHub"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Card borderRadiusSize="medium">
|
||||
<EmptyTable
|
||||
header={
|
||||
isAvailableForInstall
|
||||
? "No versions detected."
|
||||
: "No versions detected for this software item."
|
||||
}
|
||||
info={
|
||||
isAvailableForInstall ? (
|
||||
"Install this software on a host to see versions."
|
||||
) : (
|
||||
<>
|
||||
Expecting to see versions?{" "}
|
||||
<CustomLink
|
||||
url={GITHUB_NEW_ISSUE_LINK}
|
||||
text="File an issue on GitHub"
|
||||
newTab
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISoftwareTitleDetailsTableProps {
|
||||
interface ITitleVersionsTableProps {
|
||||
router: InjectedRouter;
|
||||
data: ISoftwareTitleVersion[];
|
||||
isLoading: boolean;
|
||||
@@ -79,7 +83,7 @@ interface IRowProps extends Row {
|
||||
};
|
||||
}
|
||||
|
||||
const SoftwareTitleDetailsTable = ({
|
||||
const TitleVersionsTable = ({
|
||||
router,
|
||||
data,
|
||||
isLoading,
|
||||
@@ -87,7 +91,7 @@ const SoftwareTitleDetailsTable = ({
|
||||
isIPadOSOrIOSApp,
|
||||
isAvailableForInstall,
|
||||
countsUpdatedAt,
|
||||
}: ISoftwareTitleDetailsTableProps) => {
|
||||
}: ITitleVersionsTableProps) => {
|
||||
const handleRowSelect = (row: IRowProps) => {
|
||||
if (row.original.id) {
|
||||
const softwareVersionId = row.original.id;
|
||||
@@ -103,18 +107,17 @@ const SoftwareTitleDetailsTable = ({
|
||||
|
||||
const softwareTableHeaders = useMemo(
|
||||
() =>
|
||||
generateSoftwareTitleDetailsTableConfig({
|
||||
router,
|
||||
generateSoftwareTitleVersionsTableConfig({
|
||||
teamId: teamIdForApi,
|
||||
isIPadOSOrIOSApp,
|
||||
}),
|
||||
[router, teamIdForApi, isIPadOSOrIOSApp]
|
||||
[teamIdForApi, isIPadOSOrIOSApp]
|
||||
);
|
||||
|
||||
const renderVersionsCount = () => (
|
||||
<>
|
||||
<TableCount name="versions" count={data?.length} />
|
||||
{countsUpdatedAt && SoftwareLastUpdatedInfo(countsUpdatedAt)}
|
||||
{data?.length > 0 && <TableCount name="versions" count={data?.length} />}
|
||||
{countsUpdatedAt && TitleVersionsLastUpdatedInfo(countsUpdatedAt)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -129,12 +132,14 @@ const SoftwareTitleDetailsTable = ({
|
||||
isAllPagesSelected={false}
|
||||
defaultSortHeader={DEFAULT_SORT_HEADER}
|
||||
defaultSortDirection={DEFAULT_SORT_DIRECTION}
|
||||
disablePagination
|
||||
pageSize={DEFAULT_PAGE_SIZE}
|
||||
isClientSidePagination
|
||||
disableMultiRowSelect
|
||||
onSelectSingleRow={handleRowSelect}
|
||||
renderCount={renderVersionsCount}
|
||||
hideFooter={data?.length <= DEFAULT_PAGE_SIZE} // Removes footer space
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SoftwareTitleDetailsTable;
|
||||
export default TitleVersionsTable;
|
||||
+5
-8
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { InjectedRouter } from "react-router";
|
||||
|
||||
import {
|
||||
ISoftwareTitleVersion,
|
||||
@@ -12,10 +11,9 @@ import TextCell from "components/TableContainer/DataTable/TextCell";
|
||||
import ViewAllHostsLink from "components/ViewAllHostsLink";
|
||||
import LinkCell from "components/TableContainer/DataTable/LinkCell";
|
||||
|
||||
import VulnerabilitiesCell from "../../components/tables/VulnerabilitiesCell";
|
||||
import VulnerabilitiesCell from "../../../components/tables/VulnerabilitiesCell";
|
||||
|
||||
interface ISoftwareTitleDetailsTableConfigProps {
|
||||
router: InjectedRouter;
|
||||
interface ISoftwareTitleVersionsTableConfigProps {
|
||||
teamId?: number;
|
||||
isIPadOSOrIOSApp: boolean;
|
||||
}
|
||||
@@ -46,11 +44,10 @@ interface IVulnCellProps extends ICellProps {
|
||||
};
|
||||
}
|
||||
|
||||
const generateSoftwareTitleDetailsTableConfig = ({
|
||||
router,
|
||||
const generateSoftwareTitleVersionsTableConfig = ({
|
||||
teamId,
|
||||
isIPadOSOrIOSApp,
|
||||
}: ISoftwareTitleDetailsTableConfigProps) => {
|
||||
}: ISoftwareTitleVersionsTableConfigProps) => {
|
||||
const tableHeaders = [
|
||||
{
|
||||
title: "Version",
|
||||
@@ -132,4 +129,4 @@ const generateSoftwareTitleDetailsTableConfig = ({
|
||||
return tableHeaders;
|
||||
};
|
||||
|
||||
export default generateSoftwareTitleDetailsTableConfig;
|
||||
export default generateSoftwareTitleVersionsTableConfig;
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
.software-title-versions-table {
|
||||
.empty-table__container {
|
||||
margin: $pad-small auto; // Creates 32px total
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { default } from "./TitleVersionsTable";
|
||||
@@ -0,0 +1,2 @@
|
||||
.software-summary-card {
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./SoftwareSummaryCard";
|
||||
@@ -10,11 +10,7 @@ import paths from "router/paths";
|
||||
import useTeamIdParam from "hooks/useTeamIdParam";
|
||||
import { AppContext } from "context/app";
|
||||
import { ignoreAxiosError } from "interfaces/errors";
|
||||
import {
|
||||
ISoftwareTitleDetails,
|
||||
formatSoftwareType,
|
||||
isIpadOrIphoneSoftwareSource,
|
||||
} from "interfaces/software";
|
||||
import { ISoftwareTitleDetails } from "interfaces/software";
|
||||
import {
|
||||
APP_CONTEXT_ALL_TEAMS_ID,
|
||||
APP_CONTEXT_NO_TEAM_ID,
|
||||
@@ -30,11 +26,8 @@ import { DEFAULT_USE_QUERY_OPTIONS } from "utilities/constants";
|
||||
import Spinner from "components/Spinner";
|
||||
import MainContent from "components/MainContent";
|
||||
import TeamsHeader from "components/TeamsHeader";
|
||||
import Card from "components/Card";
|
||||
|
||||
import SoftwareDetailsSummary from "../components/cards/SoftwareDetailsSummary";
|
||||
import SoftwareTitleDetailsTable from "./SoftwareTitleDetailsTable";
|
||||
import DetailsNoHosts from "../components/cards/DetailsNoHosts";
|
||||
import SoftwareSummaryCard from "./SoftwareSummaryCard";
|
||||
import SoftwareInstallerCard from "./SoftwareInstallerCard";
|
||||
import { getInstallerCardInfo } from "./helpers";
|
||||
|
||||
@@ -163,31 +156,21 @@ const SoftwareTitleDetailsPage = ({
|
||||
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
|
||||
onDelete={onDeleteInstaller}
|
||||
refetchSoftwareTitle={refetchSoftwareTitle}
|
||||
isLoading={isSoftwareTitleLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSoftwareVersionsCard = (title: ISoftwareTitleDetails) => {
|
||||
// Hide versions card for tgz_packages only
|
||||
if (title.source === "tgz_packages") return null;
|
||||
|
||||
const renderSoftwareSummaryCard = (title: ISoftwareTitleDetails) => {
|
||||
return (
|
||||
<Card
|
||||
borderRadiusSize="xxlarge"
|
||||
includeShadow
|
||||
className={`${baseClass}__versions-section`}
|
||||
>
|
||||
<h2>Versions</h2>
|
||||
<SoftwareTitleDetailsTable
|
||||
router={router}
|
||||
data={title.versions ?? []}
|
||||
isLoading={isSoftwareTitleLoading}
|
||||
teamIdForApi={teamIdForApi}
|
||||
isIPadOSOrIOSApp={isIpadOrIphoneSoftwareSource(title.source)}
|
||||
isAvailableForInstall={isAvailableForInstall}
|
||||
countsUpdatedAt={title.counts_updated_at}
|
||||
/>
|
||||
</Card>
|
||||
<SoftwareSummaryCard
|
||||
title={title}
|
||||
softwareId={softwareId}
|
||||
teamId={teamIdForApi}
|
||||
isAvailableForInstall={isAvailableForInstall}
|
||||
isLoading={isSoftwareTitleLoading}
|
||||
router={router}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -208,26 +191,8 @@ const SoftwareTitleDetailsPage = ({
|
||||
if (softwareTitle) {
|
||||
return (
|
||||
<>
|
||||
<SoftwareDetailsSummary
|
||||
title={softwareTitle.name}
|
||||
type={formatSoftwareType(softwareTitle)}
|
||||
versions={softwareTitle.versions?.length ?? 0}
|
||||
hosts={softwareTitle.hosts_count}
|
||||
countsUpdatedAt={softwareTitle.counts_updated_at}
|
||||
queryParams={{
|
||||
software_title_id: softwareId,
|
||||
team_id: teamIdForApi,
|
||||
}}
|
||||
name={softwareTitle.name}
|
||||
source={softwareTitle.source}
|
||||
iconUrl={
|
||||
softwareTitle.app_store_app
|
||||
? softwareTitle.app_store_app.icon_url
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{renderSoftwareSummaryCard(softwareTitle)}
|
||||
{renderSoftwareInstallerCard(softwareTitle)}
|
||||
{renderSoftwareVersionsCard(softwareTitle)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
-1
@@ -1 +0,0 @@
|
||||
export { default } from "./SoftwareTitleDetailsTable";
|
||||
@@ -12,3 +12,9 @@
|
||||
font-size: $small;
|
||||
}
|
||||
}
|
||||
|
||||
.software-summary-and-versions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $pad-medium;
|
||||
}
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ interface ISoftwareDetailsSummaryProps {
|
||||
type?: string;
|
||||
hosts: number;
|
||||
countsUpdatedAt?: string;
|
||||
/** The query param that will be added when user clicks on "View all hosts" link */
|
||||
/** The query param that will be added when user clicks on the host count */
|
||||
queryParams: QueryParams;
|
||||
name?: string;
|
||||
source?: string;
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
.software-details-summary {
|
||||
background-color: $core-white;
|
||||
padding: $pad-xxlarge;
|
||||
border: 1px solid $ui-fleet-black-10;
|
||||
border-radius: $border-radius-xxlarge;
|
||||
box-shadow: $box-shadow;
|
||||
display: flex;
|
||||
gap: $pad-medium;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user