Fleet UI: APRF library item accordion component (#47944)

This commit is contained in:
RachelElysia
2026-06-23 08:46:13 -04:00
committed by GitHub
parent a174dde92e
commit 7939f6a711
30 changed files with 2923 additions and 151 deletions
+1
View File
@@ -59,6 +59,7 @@ const config: StorybookConfig = {
"../frontend/components/**/*.stories.mdx",
"../frontend/components/**/*.stories.@(js|jsx|ts|tsx)",
"../frontend/pages/SoftwarePage/components/**/*.stories.@(js|jsx|ts|tsx)",
"../frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/**/*.stories.@(js|jsx|ts|tsx)",
"../frontend/pages/admin/IntegrationsPage/**/*.stories.@(js|jsx|ts|tsx)",
],
addons: [
+1
View File
@@ -1,5 +1,6 @@
import React, { useEffect } from "react";
import "../frontend/index.scss";
import "./preview.scss";
export const globalTypes = {
theme: {
+8
View File
@@ -0,0 +1,8 @@
// Storybook canvas padding override. The default `sb-main-padded` class adds
// ~1rem of padding around stories, which makes our list/accordion previews
// sit flush against the canvas chrome and misrepresents how they look inside
// the real page. 90px gives them and other components breathing room similar
// to the production SoftwareTitleDetailsPage layout.
.sb-main-padded.sb-show-main {
padding: 90px;
}
@@ -1,11 +1,19 @@
import { Meta, StoryObj } from "@storybook/react";
import { ICON_MAP } from "components/icons";
import Icon from ".";
const meta: Meta<typeof Icon> = {
title: "Components/Icon",
component: Icon,
args: { name: "plus" },
argTypes: {
name: {
control: { type: "select" },
options: Object.keys(ICON_MAP).sort(),
},
},
};
export default meta;
@@ -16,6 +16,9 @@ interface ITooltipTruncatedTextCellProps {
* lives inside an `overflow: hidden` ancestor — the default `absolute`
* positioning can misplace the tooltip in that case. */
fixedPositionStrategy?: boolean;
/** When `true`, suppress the tooltip even if the text is truncated. Useful
* when a parent surface owns the hover tooltip. */
disableTooltip?: boolean;
}
const baseClass = "tooltip-truncated-text";
@@ -27,6 +30,7 @@ const TooltipTruncatedText = ({
tooltipPosition = "top",
isMobileView = false,
fixedPositionStrategy = false,
disableTooltip = false,
}: ITooltipTruncatedTextCellProps): JSX.Element => {
const classNames = classnames(baseClass, className);
@@ -38,7 +42,7 @@ const TooltipTruncatedText = ({
return (
<TooltipWrapper
className={classNames}
disableTooltip={!isTruncated}
disableTooltip={disableTooltip || !isTruncated}
underline={false}
position={tooltipPosition}
showArrow
@@ -0,0 +1,39 @@
import { Meta, StoryObj } from "@storybook/react";
import withFrame from "test/storybook-utils";
import TruncatedTextList from "./TruncatedTextList";
const meta: Meta<typeof TruncatedTextList> = {
title: "Components/TruncatedTextList",
component: TruncatedTextList,
args: {
items: [
"Engineering",
"Product",
"Quality Assurance",
"Marketing",
"Sales",
"Support",
"Operations",
],
},
};
export default meta;
type Story = StoryObj<typeof TruncatedTextList>;
export const Basic: Story = {
decorators: [withFrame(360)],
};
export const NarrowContainer: Story = {
args: { truncatedFirstMaxChars: 6 },
decorators: [withFrame(180)],
};
export const AllFit: Story = {
args: { items: ["Mac", "Linux"] },
decorators: [withFrame(360)],
};
@@ -0,0 +1,249 @@
import React, { useLayoutEffect, useRef, useState } from "react";
import classnames from "classnames";
import Button from "components/buttons/Button";
import TooltipWrapper from "components/TooltipWrapper";
const baseClass = "truncated-text-list";
interface ITruncatedTextListProps {
items: string[];
/** Inserted between items in both the visible row and the tooltip. */
separator?: string;
/** Tooltip placement. */
tooltipPosition?: "top" | "bottom" | "left" | "right";
/** Approximate character budget for the first label when even it doesn't
* fit alongside the "+N more" pill. The first label is truncated to this
* many chars and gets a trailing ellipsis. Default 30. */
truncatedFirstMaxChars?: number;
/** When provided, the whole visible row renders as `Button variant="link"`
* (CustomLink-style animated underline) and calls this handler on click. */
onClick?: () => void;
className?: string;
}
const truncateString = (s: string, max: number) =>
s.length > max ? `${s.slice(0, max).trimEnd()}...` : s;
const renderItemsList = (list: string[]) => (
<>
{list.map((name, i) => (
<React.Fragment key={name}>
{name}
{i < list.length - 1 && <br />}
</React.Fragment>
))}
</>
);
interface IRenderVisibleRowParams {
visibleCount: number;
visible: string[];
hidden: string[];
items: string[];
separator: string;
tooltipPosition: "top" | "bottom" | "left" | "right";
truncatedFirstMaxChars: number;
onClick?: () => void;
}
const renderVisibleRow = ({
visibleCount,
visible,
hidden,
items,
separator,
tooltipPosition,
truncatedFirstMaxChars,
onClick,
}: IRenderVisibleRowParams) => {
const truncatedFirst = truncateString(items[0] ?? "", truncatedFirstMaxChars);
const truncatedFirstContent = (
<>
<TooltipWrapper
tipContent={items[0]}
showArrow
underline={false}
position={tooltipPosition}
tipOffset={8}
fixedPositionStrategy
>
<span>{truncatedFirst}</span>
</TooltipWrapper>
{separator}
<TooltipWrapper
tipContent={renderItemsList(items.slice(1))}
showArrow
underline={false}
position={tooltipPosition}
tipOffset={8}
fixedPositionStrategy
>
<span className={`${baseClass}__more`}>+{items.length - 1} more</span>
</TooltipWrapper>
</>
);
const standardContent = (
<>
{visible.join(separator)}
{hidden.length > 0 && (
<>
{visible.length > 0 ? separator : ""}
<TooltipWrapper
tipContent={renderItemsList(hidden)}
showArrow
underline={false}
position={tooltipPosition}
tipOffset={8}
fixedPositionStrategy
>
<span className={`${baseClass}__more`}>+{hidden.length} more</span>
</TooltipWrapper>
</>
)}
</>
);
const isTruncatedFirst = visibleCount === 0;
const content = isTruncatedFirst ? truncatedFirstContent : standardContent;
const rowStyle: React.CSSProperties = {
whiteSpace: "nowrap",
overflow: "hidden",
};
const rowClass = classnames(`${baseClass}__visible`, {
[`${baseClass}__visible--truncated`]: isTruncatedFirst,
});
if (onClick) {
return (
<Button variant="link" className={rowClass} onClick={onClick}>
<span style={rowStyle}>{content}</span>
</Button>
);
}
return (
<span className={rowClass} style={rowStyle}>
{content}
</span>
);
};
const TruncatedTextList = ({
items,
separator = ", ",
tooltipPosition = "top",
truncatedFirstMaxChars = 30,
onClick,
className,
}: ITruncatedTextListProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<(HTMLSpanElement | null)[]>([]);
const moreRef = useRef<HTMLSpanElement>(null);
const [visibleCount, setVisibleCount] = useState(items.length);
useLayoutEffect(() => {
const measure = () => {
if (!containerRef.current) return;
// `getBoundingClientRect().width` gives subpixel precision —
// `clientWidth`/`offsetWidth` round to integers, which lets a row that
// sums to (say) container_width + 0.7px read as "fits." Plus a small
// buffer for the layout discrepancy between the measure layer (each
// item in its own `<span>`) and the visible row (items joined into a
// single string), which can differ by a few pixels from inline
// boundary kerning. Same spirit as the `max-width: 101%` subpixel
// trick in `TooltipTruncatedTextCell`.
const BOUNDARY_BUFFER_PX = 16;
const containerWidth =
containerRef.current.getBoundingClientRect().width - BOUNDARY_BUFFER_PX;
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 0;
const widths = itemRefs.current.map(
(el) => el?.getBoundingClientRect().width ?? 0
);
const totalWidth = widths.reduce((sum, w) => sum + w, 0);
// Everything fits — no "+N more" needed.
if (totalWidth <= containerWidth) {
setVisibleCount(items.length);
return;
}
// Some items must be hidden — reserve room for the "+N more" pill.
let used = 0;
let count = 0;
for (let i = 0; i < widths.length; i += 1) {
if (used + widths[i] + moreWidth > containerWidth) break;
used += widths[i];
count += 1;
}
setVisibleCount(count);
};
measure();
if (!containerRef.current) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(containerRef.current);
return () => observer.disconnect();
}, [items]);
if (items.length === 0) return null;
const visible = items.slice(0, visibleCount);
const hidden = items.slice(visibleCount);
return (
<div
ref={containerRef}
className={classnames(baseClass, className)}
style={{ position: "relative", minWidth: 0 }}
>
{/* Hidden measurement layer — same font/size as the visible row */}
<div
className={`${baseClass}__measure`}
aria-hidden
style={{
position: "absolute",
top: 0,
left: 0,
visibility: "hidden",
pointerEvents: "none",
whiteSpace: "nowrap",
}}
>
{items.map((item, i) => (
<span
// eslint-disable-next-line react/no-array-index-key
key={`measure-${item}-${i}`}
ref={(el) => {
itemRefs.current[i] = el;
}}
>
{i > 0 ? separator : ""}
{item}
</span>
))}
<span ref={moreRef}>
{separator}+{items.length} more
</span>
</div>
{/* Visible row */}
{renderVisibleRow({
visibleCount,
visible,
hidden,
items,
separator,
tooltipPosition,
truncatedFirstMaxChars,
onClick,
})}
</div>
);
};
export default TruncatedTextList;
@@ -0,0 +1,24 @@
.truncated-text-list {
position: relative;
min-width: 0;
&__measure {
position: absolute;
top: 0;
left: 0;
visibility: hidden;
pointer-events: none;
white-space: nowrap;
}
&__visible {
display: block;
white-space: nowrap;
overflow: hidden;
text-overflow: clip;
}
&__more {
cursor: default;
}
}
@@ -0,0 +1 @@
export { default } from "./TruncatedTextList";
@@ -11,9 +11,9 @@ export type ButtonVariant =
| "grey-pill"
| "link" // Looks like CustomLink with animated underline on hover
| "brand-inverse-icon" // Green icon with text, no underline on hover
| "text-icon"
| "text-icon" // DEPRECATED — use "inverse" instead. Swept in the 2025-09 UI reskin (#33558); kept for legacy callers only. New code: always reach for "inverse".
| "icon" // Buttons without text
| "inverse"
| "inverse" // Preferred secondary button. Use this anywhere you'd reflexively reach for "text-icon".
| "inverse-alert"
| "unstyled" // Avoid as much as possible (used in registration breadcrumbs, 404/500, an old button dropdown)
| "unstyled-modal-query"
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface IPinProps {
color?: Colors;
size?: IconSizes;
}
const Pin = ({ color = "ui-fleet-black-75", size = "medium" }: IPinProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
viewBox="0 0 16 16"
>
<path
fill={COLORS[color]}
fillRule="evenodd"
d="M12.724.346a1.18 1.18 0 0 0-1.667 0L7.722 3.68a.83.83 0 0 1-.864.194l-1.695-.618a1.1 1.1 0 0 0-1.15.254L2.834 4.689c-.46.46-.46 1.206 0 1.667l2.573 2.57-5.061 5.061a1.178 1.178 0 1 0 1.667 1.667l5.06-5.06 2.573 2.572c.46.46 1.206.46 1.667 0l1.178-1.18c.301-.299.4-.748.254-1.149l-.618-1.695a.83.83 0 0 1 .195-.864l3.332-3.335c.461-.46.461-1.206 0-1.667z"
clipRule="evenodd"
/>
</svg>
);
};
export default Pin;
+29
View File
@@ -0,0 +1,29 @@
import React from "react";
import { COLORS, Colors } from "styles/var/colors";
import { ICON_SIZES, IconSizes } from "styles/var/icon_sizes";
interface ITagProps {
color?: Colors;
size?: IconSizes;
}
const Tag = ({ color = "ui-fleet-black-75", size = "medium" }: ITagProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={ICON_SIZES[size]}
height={ICON_SIZES[size]}
fill="none"
viewBox="0 0 16 16"
>
<path
fill={COLORS[color]}
fillRule="evenodd"
d="M.609 8.04a2.08 2.08 0 0 0 0 2.94l4.41 4.411a2.08 2.08 0 0 0 2.94 0l7.432-7.431c.417-.417.637-.991.606-1.58l-.27-5.124a1.04 1.04 0 0 0-.983-.983L9.62.003a2.08 2.08 0 0 0-1.58.606zM10.9 5.1a1.56 1.56 0 1 0 2.205-2.205A1.56 1.56 0 0 0 10.9 5.1"
clipRule="evenodd"
/>
</svg>
);
};
export default Tag;
+4
View File
@@ -73,6 +73,8 @@ import User from "./User";
import InfoOutline from "./InfoOutline";
import GitOpsMode from "./GitOpsMode";
import Android from "./Android";
import Pin from "./Pin";
import Tag from "./Tag";
// a mapping of the usable names of icons to the icon source.
export const ICON_MAP = {
@@ -152,6 +154,8 @@ export const ICON_MAP = {
"automatic-self-service": AutomaticSelfService,
user: User,
"gitops-mode": GitOpsMode,
pin: Pin,
tag: Tag,
};
export type IconNames = keyof typeof ICON_MAP;
@@ -0,0 +1,151 @@
import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import {
QueryClient,
QueryClientProvider,
QueryClientProviderProps,
} from "react-query";
import { ILabelSoftwareTitle } from "interfaces/label";
import paths from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import LibraryItemAccordion from "./LibraryItemAccordion";
// Needed because the embedded `SoftwareIcon` (rendered for installerType
// "app-store") uses `useQuery` internally. Without a QueryClientProvider in
// scope, switching the `installerType` control to "app-store" throws.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
type CustomQueryClientProviderProps = React.PropsWithChildren<QueryClientProviderProps>;
const CustomQueryClientProvider: React.FC<CustomQueryClientProviderProps> = QueryClientProvider;
const labels7: ILabelSoftwareTitle[] = Array.from({ length: 7 }, (_, i) => ({
id: i + 1,
name: `Label ${i + 1}`,
})) as ILabelSoftwareTitle[];
const statusPath = (software_status: "installed" | "pending" | "failed") =>
getPathWithQueryParams(paths.MANAGE_HOSTS, {
software_title_id: 123,
software_status,
fleet_id: 0,
});
const meta: Meta<typeof LibraryItemAccordion> = {
title: "Pages/SoftwareTitleDetailsPage/LibraryItemAccordion",
component: LibraryItemAccordion,
args: {
filename: "GoogleChrome.pkg",
version: "149.0.7827.54",
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
isActive: true,
badgeState: "latest",
labels: labels7,
canEditSoftware: true,
installed: 32,
pending: 5,
failed: 3,
installedPath: statusPath("installed"),
pendingPath: statusPath("pending"),
failedPath: statusPath("failed"),
hashSha256:
"af001543fcc5fbf484203b207d8af4fce44fc6975ca3db0eac49a49581af29b7",
downloadUrl: "https://example.com/installer.pkg",
},
decorators: [
(Story) => (
<CustomQueryClientProvider client={queryClient}>
<Story />
</CustomQueryClientProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof LibraryItemAccordion>;
export const Collapsed: Story = {};
export const Expanded: Story = {
parameters: {
docs: {
description: {
story:
"Manually click the chevron in the Collapsed story to see the expanded panel. This entry is documentation-only since expansion is internal state.",
},
},
},
};
export const LatestActive: Story = {
args: {
badgeState: "latest",
},
};
export const PinnedActive: Story = {
args: {
badgeState: "pinned",
},
};
export const MajorVersionPinnedActive: Story = {
args: {
badgeState: "majorVersion",
},
};
export const AllHostsNoLabels: Story = {
args: {
badgeState: "latest",
labels: [],
},
};
export const Inactive: Story = {
args: {
isActive: false,
badgeState: undefined,
labels: [],
version: "148.0.7778.179",
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
},
};
/** Active row, user lacks edit permission. The label-count badge demotes to a
* static span (no button + no click handler), the expanded-panel labels list
* renders as plain text rather than a CustomLink, and the trash button is
* hidden entirely. The download button stays (gated only by `downloadUrl`). */
export const ActiveCannotEditSoftware: Story = {
args: {
canEditSoftware: false,
badgeState: "latest",
labels: labels7,
},
};
/** Inactive row, user lacks edit permission. The "Select Actions > Versions
* and pin this version to rollback" hover tooltip is suppressed because the
* user can't reach that menu anyway. */
export const InactiveCannotEditSoftware: Story = {
args: {
canEditSoftware: false,
isActive: false,
badgeState: undefined,
labels: [],
version: "148.0.7778.179",
addedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 20).toISOString(),
},
};
export const ZeroInstallState: Story = {
args: {
installed: 0,
pending: 0,
failed: 0,
labels: [],
},
};
@@ -0,0 +1,398 @@
import React from "react";
import { screen, waitFor } from "@testing-library/react";
import { renderWithSetup } from "test/test-utils";
import { ILabelSoftwareTitle } from "interfaces/label";
import paths from "router/paths";
import { stringToClipboard } from "utilities/copy_text";
import { getPathWithQueryParams } from "utilities/url";
import LibraryItemAccordion, {
ILibraryItemAccordionProps,
} from "./LibraryItemAccordion";
jest.mock("utilities/copy_text", () => ({
stringToClipboard: jest.fn(),
}));
const mockedStringToClipboard = stringToClipboard as jest.MockedFunction<
typeof stringToClipboard
>;
const statusPath = (software_status: "installed" | "pending" | "failed") =>
getPathWithQueryParams(paths.MANAGE_HOSTS, {
software_title_id: 123,
software_status,
fleet_id: 0,
});
const baseProps: ILibraryItemAccordionProps = {
filename: "GoogleChrome.pkg",
version: "149.0.7827.54",
addedAt: new Date("2026-06-15T00:00:00Z").toISOString(),
isActive: true,
canEditSoftware: true,
installed: 32,
pending: 5,
failed: 3,
installedPath: statusPath("installed"),
pendingPath: statusPath("pending"),
failedPath: statusPath("failed"),
hashSha256:
"af001543fcc5fbf484203b207d8af4fce44fc6975ca3db0eac49a49581af29b7",
downloadUrl: "https://example.com/installer.pkg",
};
const makeLabels = (count: number): ILabelSoftwareTitle[] =>
Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: `Label ${i + 1}`,
})) as ILabelSoftwareTitle[];
const renderAccordion = (overrides: Partial<ILibraryItemAccordionProps> = {}) =>
renderWithSetup(<LibraryItemAccordion {...baseProps} {...overrides} />);
describe("LibraryItemAccordion", () => {
describe("collapsed header", () => {
it("renders the filename and version", () => {
renderAccordion();
expect(screen.getByText("GoogleChrome.pkg")).toBeVisible();
expect(screen.getByText(/149\.0\.7827\.54/)).toBeVisible();
});
it("does not render the expanded panel by default", () => {
renderAccordion();
expect(screen.queryByText("32 installed")).not.toBeInTheDocument();
expect(screen.queryByText("Hash")).not.toBeInTheDocument();
});
});
describe("expand / collapse", () => {
it("expands when the header is clicked and collapses on a second click", async () => {
const { user } = renderAccordion();
const header = screen.getByRole("button", { expanded: false });
await user.click(header);
expect(screen.getByText("32 installed")).toBeVisible();
expect(screen.getByText("5 pending")).toBeVisible();
expect(screen.getByText("3 failed")).toBeVisible();
expect(screen.getByText("Hash")).toBeVisible();
await user.click(screen.getByRole("button", { expanded: true }));
expect(screen.queryByText("32 installed")).not.toBeInTheDocument();
});
});
describe("badges", () => {
it("renders the Latest badge when badgeState is 'latest'", () => {
renderAccordion({ badgeState: "latest" });
expect(screen.getByRole("button", { name: "Latest" })).toBeVisible();
});
it("renders the Pinned badge when badgeState is 'pinned'", () => {
renderAccordion({ badgeState: "pinned" });
expect(screen.getByRole("button", { name: "Pinned" })).toBeVisible();
});
it("renders the Major version badge when badgeState is 'majorVersion'", () => {
renderAccordion({ badgeState: "majorVersion" });
expect(
screen.getByRole("button", { name: "Major version" })
).toBeVisible();
});
it("renders no badge when badgeState is undefined", () => {
renderAccordion({ badgeState: undefined });
expect(
screen.queryByRole("button", { name: "Latest" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Pinned" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Major version" })
).not.toBeInTheDocument();
});
it("renders the label-count badge when labels are scoped", () => {
renderAccordion({ badgeState: "latest", labels: makeLabels(7) });
expect(screen.getByRole("button", { name: "7" })).toBeVisible();
});
it("renders 'All hosts' instead of the label-count when no labels are scoped", () => {
renderAccordion({ badgeState: "latest", labels: [] });
expect(screen.getByText("All hosts")).toBeVisible();
expect(
screen.queryByRole("button", { name: /^\d+$/ })
).not.toBeInTheDocument();
});
it("renders 'All hosts' when badgeState is 'majorVersion' with no scoped labels", () => {
renderAccordion({ badgeState: "majorVersion", labels: [] });
expect(screen.getByText("All hosts")).toBeVisible();
});
it("does not render 'All hosts' when badgeState is undefined (no badge means no fallback)", () => {
renderAccordion({ badgeState: undefined, labels: [] });
expect(screen.queryByText("All hosts")).not.toBeInTheDocument();
});
it("renders a tooltip with the label list when hovering the count badge", async () => {
const labels = [
{ id: 1, name: "Design" },
{ id: 2, name: "Engineering" },
{ id: 3, name: "IT" },
] as never;
const { user } = renderAccordion({
badgeState: "latest",
labels,
labelKind: "includeAll",
});
await user.hover(screen.getByRole("button", { name: "3" }));
// Tooltip renders the heading inside a `<strong>` and the names as
// sibling text nodes separated by `<br/>`. RTL can't match the
// individual text nodes (they aren't elements), so assert against the
// parent container's combined textContent — which preserves order but
// strips the `<br/>` whitespace.
await waitFor(() => {
expect(screen.getByText("Include all:")).toBeInTheDocument();
});
const tooltipDiv =
screen.getByText("Include all:").parentElement ?? document.body;
expect(tooltipDiv).toHaveTextContent(/Design.*Engineering.*IT/);
});
it.each([
["latest", "Latest"],
["pinned", "Pinned"],
["majorVersion", "Major version"],
] as const)(
"fires onBadgeClick when the %s badge is clicked",
async (state, label) => {
const onBadgeClick = jest.fn();
const { user } = renderAccordion({ badgeState: state, onBadgeClick });
await user.click(screen.getByRole("button", { name: label }));
expect(onBadgeClick).toHaveBeenCalledTimes(1);
}
);
it("does not propagate badge clicks to the header expand toggle", async () => {
const onBadgeClick = jest.fn();
const { user } = renderAccordion({ badgeState: "latest", onBadgeClick });
// The header would expand if the click bubbled — verify it stays collapsed.
await user.click(screen.getByRole("button", { name: "Latest" }));
expect(onBadgeClick).toHaveBeenCalledTimes(1);
expect(screen.queryByText("32 installed")).not.toBeInTheDocument();
});
it("fires onLabelCountClick when the label-count badge is clicked", async () => {
const onLabelCountClick = jest.fn();
const { user } = renderAccordion({
badgeState: "latest",
labels: makeLabels(4),
onLabelCountClick,
});
await user.click(screen.getByRole("button", { name: "4" }));
expect(onLabelCountClick).toHaveBeenCalledTimes(1);
});
it("renders the label-count as static (non-button) when canEditSoftware is false", () => {
renderAccordion({
badgeState: "latest",
labels: makeLabels(4),
canEditSoftware: false,
});
expect(
screen.queryByRole("button", { name: "4" })
).not.toBeInTheDocument();
// The static span still displays the count.
expect(screen.getByText("4")).toBeVisible();
});
});
describe("inactive row", () => {
it("hides all badges and the chevron interaction", async () => {
const { user } = renderAccordion({
isActive: false,
badgeState: "latest",
labels: makeLabels(3),
});
expect(
screen.queryByRole("button", { name: "Latest" })
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "3" })
).not.toBeInTheDocument();
await user.click(screen.getByRole("button"));
expect(screen.queryByText("32 installed")).not.toBeInTheDocument();
});
});
describe("expanded panel — status counts", () => {
it("renders zero-install state without crashing", async () => {
const { user } = renderAccordion({
installed: 0,
pending: 0,
failed: 0,
});
await user.click(screen.getByRole("button", { expanded: false }));
expect(screen.getByText("0 installed")).toBeVisible();
expect(screen.getByText("0 pending")).toBeVisible();
expect(screen.getByText("0 failed")).toBeVisible();
});
it("renders status counts as links", async () => {
const { user } = renderAccordion();
await user.click(screen.getByRole("button", { expanded: false }));
const installedLink = screen.getByRole("link", { name: /32 installed/ });
expect(installedLink).toHaveAttribute("href", statusPath("installed"));
expect(screen.getByRole("link", { name: /5 pending/ })).toHaveAttribute(
"href",
statusPath("pending")
);
expect(screen.getByRole("link", { name: /3 failed/ })).toHaveAttribute(
"href",
statusPath("failed")
);
});
});
describe("expanded panel — labels heading", () => {
it("renders the 'Include any' heading by default", async () => {
const { user } = renderAccordion({ labels: makeLabels(2) });
await user.click(screen.getByRole("button", { expanded: false }));
expect(screen.getByText("Include any")).toBeVisible();
});
it("renders the 'Include all' heading when labelKind is includeAll", async () => {
const { user } = renderAccordion({
labels: makeLabels(2),
labelKind: "includeAll",
});
await user.click(screen.getByRole("button", { expanded: false }));
expect(screen.getByText("Include all")).toBeVisible();
});
it("renders the 'Exclude any' heading when labelKind is excludeAny", async () => {
const { user } = renderAccordion({
labels: makeLabels(2),
labelKind: "excludeAny",
});
await user.click(screen.getByRole("button", { expanded: false }));
expect(screen.getByText("Exclude any")).toBeVisible();
});
});
describe("expanded panel — hash copy", () => {
it("copies the hash to the clipboard and shows a transient 'Copied!' message", async () => {
mockedStringToClipboard.mockResolvedValueOnce(undefined);
const { user } = renderAccordion();
await user.click(screen.getByRole("button", { expanded: false }));
await user.click(
screen.getByRole("button", { name: "Copy hash to clipboard" })
);
expect(mockedStringToClipboard).toHaveBeenCalledWith(
baseProps.hashSha256
);
expect(await screen.findByText("Copied!")).toBeVisible();
});
it("shows 'Copy failed' when the clipboard write rejects", async () => {
mockedStringToClipboard.mockRejectedValueOnce(new Error("denied"));
const { user } = renderAccordion();
await user.click(screen.getByRole("button", { expanded: false }));
await user.click(
screen.getByRole("button", { name: "Copy hash to clipboard" })
);
expect(await screen.findByText("Copy failed")).toBeVisible();
});
});
describe("download button", () => {
it("fires onDownloadClick when clicked", async () => {
const onDownloadClick = jest.fn();
const { user } = renderAccordion({ onDownloadClick });
await user.click(screen.getByRole("button", { expanded: false }));
await user.click(
screen.getByRole("button", { name: "Download installer" })
);
expect(onDownloadClick).toHaveBeenCalledTimes(1);
});
it("is omitted when downloadUrl is not provided", async () => {
const { user } = renderAccordion({ downloadUrl: undefined });
await user.click(screen.getByRole("button", { expanded: false }));
expect(
screen.queryByRole("button", { name: "Download installer" })
).not.toBeInTheDocument();
});
});
describe("trash button", () => {
it("is hidden entirely when canEditSoftware is false", async () => {
const { user } = renderAccordion({ canEditSoftware: false });
await user.click(screen.getByRole("button", { expanded: false }));
expect(
screen.queryByRole("button", { name: "Delete this version" })
).not.toBeInTheDocument();
// Download stays — gated only by `downloadUrl`, not edit permission.
expect(
screen.getByRole("button", { name: "Download installer" })
).toBeVisible();
});
it("invokes onTrashClick when enabled", async () => {
const onTrashClick = jest.fn();
const { user } = renderAccordion({ onTrashClick });
await user.click(screen.getByRole("button", { expanded: false }));
await user.click(
screen.getByRole("button", { name: "Delete this version" })
);
expect(onTrashClick).toHaveBeenCalledTimes(1);
});
});
// Cross-cutting: every callback prop is optional, so a row with none wired
// up must still click through every interactive element without throwing.
describe("no-handler safety", () => {
it("does not throw when interactive elements are clicked without handlers", async () => {
const { user } = renderAccordion({
badgeState: "latest",
labels: makeLabels(2),
// intentionally no onBadgeClick / onLabelCountClick / onDownloadClick /
// onTrashClick — exercising the optional-callback no-op paths
});
await user.click(screen.getByRole("button", { name: "Latest" }));
await user.click(screen.getByRole("button", { name: "2" }));
await user.click(screen.getByRole("button", { expanded: false }));
await user.click(
screen.getByRole("button", { name: "Download installer" })
);
await user.click(
screen.getByRole("button", { name: "Delete this version" })
);
// The test passes if none of the above throw.
});
});
});
@@ -0,0 +1,544 @@
import React, { useState } from "react";
import classnames from "classnames";
import Button from "components/buttons/Button";
import CustomLink from "components/CustomLink";
import GitOpsModeTooltipWrapper from "components/GitOpsModeTooltipWrapper";
import Icon from "components/Icon";
import TooltipWrapper from "components/TooltipWrapper";
import TooltipTruncatedText from "components/TooltipTruncatedText";
import TruncatedTextList from "components/TruncatedTextList";
import { ILabelSoftwareTitle } from "interfaces/label";
import { InstallerType } from "interfaces/software";
import InstallerDetailsWidget from "pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget";
import { stringToClipboard } from "utilities/copy_text";
const baseClass = "library-item-accordion";
export type LibraryItemLabelKind = "includeAny" | "includeAll" | "excludeAny";
/** Which status badge the active row renders, if any. `undefined` (the
* default) renders no badge. The three states are mutually exclusive by
* construction the type system, not prop comments, enforces this. */
export type LibraryItemBadgeState = "latest" | "pinned" | "majorVersion";
const LABEL_KIND_HEADING: Record<LibraryItemLabelKind, string> = {
includeAny: "Include any",
includeAll: "Include all",
excludeAny: "Exclude any",
};
export interface ILibraryItemAccordionProps {
/** Software title display name (or package filename for custom packages). */
filename: string;
version?: string | null;
/** ISO timestamp. Rendered as "Added X ago". */
addedAt: string;
/** Drives the file/store icon and the version-row treatment.
* - "package" (default): file-pkg graphic, plain version text
* - "app-store" without `androidPlayStoreId`: Apple App Store icon, version + "Updated every hour." tooltip
* - "app-store" with `androidPlayStoreId`: Play Store icon, "Latest" + Play Store link tooltip (web apps hide the version entirely) */
installerType?: InstallerType;
/** Play Store package id (e.g. `com.android.chrome`). Presence implies an Android app. */
androidPlayStoreId?: string;
/** Fleet-maintained app — switches the version tooltip to the "Actions > Edit" hint. */
isFma?: boolean;
isLatestFmaVersion?: boolean;
/** Hide the version entirely (script-only packages). */
isScriptPackage?: boolean;
/** When false, the row is dimmed and the expand affordance is hidden. */
isActive: boolean;
/** Mirrors backend WRITE on the `SoftwareInstaller` entity admin or
* maintainer. Compute with `permissions.canWriteSoftware(user, teamId)`.
* Gates every edit/delete affordance on the row: the label-count badge
* (button static span), the expanded-panel labels-click handler, the
* inactive-row "Select Actions > Versions and pin this version to rollback"
* hover tooltip, and the trash button (hidden entirely when false). */
canEditSoftware: boolean;
/** Which status badge the active row renders. `"latest"` "Latest" with a
* refresh icon. `"pinned"` "Pinned" with a pin icon. `"majorVersion"`
* "Major version" with the same pin icon, distinct label. `undefined`
* no badge. Inactive rows never render a badge regardless. */
badgeState?: LibraryItemBadgeState;
/** Labels assigned to this version (drives the label-count badge and the expanded Labels row). */
labels?: ILabelSoftwareTitle[] | null;
/** How `labels` are scoped — matches backend label fields. Defaults to "includeAny". */
labelKind?: LibraryItemLabelKind;
installed: number;
pending: number;
failed: number;
/** Link targets for the install-status counts. Every count renders as a
* link to the corresponding hosts filter there is no plain-text fallback,
* since the production page always builds these from the title id. */
installedPath: string;
pendingPath: string;
failedPath: string;
hashSha256?: string | null;
downloadUrl?: string;
/** Click handler for whichever badge is rendered per `badgeState`. The
* consumer can branch on `badgeState` inside the callback if it needs to
* differentiate (e.g. exact vs major-version pin); the row itself fires the
* same callback for all three. */
onBadgeClick?: () => void;
onLabelCountClick?: () => void;
/** Click on the labels list in the expanded panel opens the edit software
* modal. Wired as a CustomLink-style underline button via TruncatedTextList. */
onLabelsClick?: () => void;
onDownloadClick?: () => void;
onTrashClick?: () => void;
}
const ALL_HOSTS_LABEL = "All hosts";
const LibraryItemAccordion = ({
filename,
version,
addedAt,
installerType = "package",
androidPlayStoreId,
isFma = false,
isLatestFmaVersion,
isScriptPackage = false,
isActive,
canEditSoftware,
badgeState,
labels,
labelKind = "includeAny",
installed,
pending,
failed,
installedPath,
pendingPath,
failedPath,
hashSha256,
downloadUrl,
onBadgeClick,
onLabelCountClick,
onLabelsClick,
onDownloadClick,
onTrashClick,
}: ILibraryItemAccordionProps) => {
const [expanded, setExpanded] = useState(false);
const [copyMessage, setCopyMessage] = useState("");
const labelCount = labels?.length ?? 0;
const hasLabelScope = labelCount > 0;
const showAllHostsBadge =
isActive && !hasLabelScope && badgeState !== undefined;
const canExpand = isActive;
const isExpanded = canExpand && expanded;
const toggleExpanded = () => {
if (!canExpand) return;
setExpanded((prev) => !prev);
};
const handleCopyHash = () => {
if (!hashSha256) return;
stringToClipboard(hashSha256)
.then(() => setCopyMessage("Copied!"))
.catch(() => setCopyMessage("Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(""), 1000);
};
const inactiveTooltip = (
<>
Select <strong>Actions &gt; Versions</strong> and pin this version to
rollback.
</>
);
const sortedLabelNames = (labels ?? [])
.map((l) => l.name)
.sort((a, b) => a.localeCompare(b));
const renderLabelCountTooltip = () => (
<div style={{ textAlign: "center" }}>
<strong>{LABEL_KIND_HEADING[labelKind]}:</strong>
<br />
{sortedLabelNames.map((name, i) => (
<React.Fragment key={name}>
{name}
{i < sortedLabelNames.length - 1 && <br />}
</React.Fragment>
))}
</div>
);
const handleBadgeClick = (handler?: () => void) => (
e: React.MouseEvent | React.KeyboardEvent
) => {
e.stopPropagation();
handler?.();
};
const renderHeaderBadges = () => {
if (!isActive) return null;
return (
<div className={`${baseClass}__badges`}>
{badgeState === "latest" && (
<Button
variant="inverse"
size="small"
onClick={handleBadgeClick(onBadgeClick)}
className={`${baseClass}__badge-button`}
>
<Icon name="refresh" color="ui-fleet-black-75" />
<span>Latest</span>
</Button>
)}
{badgeState === "pinned" && (
<Button
variant="inverse"
size="small"
onClick={handleBadgeClick(onBadgeClick)}
className={`${baseClass}__badge-button`}
>
<Icon name="pin" color="ui-fleet-black-75" />
<span>Pinned</span>
</Button>
)}
{badgeState === "majorVersion" && (
<Button
variant="inverse"
size="small"
onClick={handleBadgeClick(onBadgeClick)}
className={`${baseClass}__badge-button`}
>
<Icon name="pin" color="ui-fleet-black-75" />
<span>Major version</span>
</Button>
)}
{hasLabelScope && (
<TooltipWrapper
tipContent={renderLabelCountTooltip()}
showArrow
underline={false}
position="top"
tipOffset={8}
>
{canEditSoftware ? (
<Button
variant="inverse"
size="small"
onClick={handleBadgeClick(onLabelCountClick)}
className={`${baseClass}__badge-button`}
>
<Icon name="tag" color="ui-fleet-black-75" />
<span>{labelCount}</span>
</Button>
) : (
<span
className={`${baseClass}__badge-button ${baseClass}__badge-button--static`}
>
<Icon name="tag" color="ui-fleet-black-75" />
<span>{labelCount}</span>
</span>
)}
</TooltipWrapper>
)}
{showAllHostsBadge && (
<span
className={`${baseClass}__badge-button ${baseClass}__badge-button--static`}
>
<Icon name="tag" color="ui-fleet-black-75" />
<span>{ALL_HOSTS_LABEL}</span>
</span>
)}
</div>
);
};
const renderStatusCount = (
iconName: "success" | "pending-outline" | "error",
count: number,
label: string,
iconTooltip: React.ReactNode,
path: string,
trailing?: React.ReactNode
) => (
<div className={`${baseClass}__status-count`}>
<TooltipWrapper
tipContent={iconTooltip}
showArrow
underline={false}
position="top"
tipOffset={8}
clickable={false}
>
<Icon name={iconName} />
</TooltipWrapper>
<CustomLink
url={path}
text={`${count} ${label}`}
className={`${baseClass}__status-count-link`}
/>
{trailing}
</div>
);
const statusCountsTooltip = (
<>
Latest status from policy automation,
<br />
setup experience, or manual install.
</>
);
const installedIconTooltip = (
<>
Software is installed on these hosts
<br />
(install script finished with exit code 0).
<br />
Currently, if the software is uninstalled,
<br />
the &quot;Installed&quot; status won&apos;t be updated.
</>
);
const pendingIconTooltip = (
<>
Fleet is installing/uninstalling or will
<br />
do so when the host comes online.
</>
);
const failedIconTooltip = (
<>
These hosts failed to install/uninstall
<br />
software. Click on a host to view error(s).
</>
);
const renderLabelsBlock = () => {
if (!hasLabelScope) return null;
return (
<div className={`${baseClass}__data-row`}>
<p className={`${baseClass}__data-heading`}>
{LABEL_KIND_HEADING[labelKind]}
</p>
<TruncatedTextList
className={`${baseClass}__data-value`}
items={sortedLabelNames}
onClick={canEditSoftware ? onLabelsClick : undefined}
/>
</div>
);
};
const renderHashBlock = () => {
if (!hashSha256) return null;
return (
<div className={`${baseClass}__data-row`}>
<p className={`${baseClass}__data-heading`}>Hash</p>
<div className={`${baseClass}__hash-row`}>
<TooltipTruncatedText
className={`${baseClass}__hash`}
value={hashSha256}
/>
<div className={`${baseClass}__copy-wrapper`}>
{copyMessage && (
<span className={`${baseClass}__copy-message`}>
{copyMessage}
</span>
)}
<Button
variant="icon"
iconStroke
onClick={handleCopyHash}
ariaLabel="Copy hash to clipboard"
className={`${baseClass}__copy-button`}
>
<Icon name="copy" />
</Button>
</div>
</div>
</div>
);
};
const renderTrashButtonBody = (disabled: boolean) => (
<Button
variant="icon"
disabled={disabled}
onClick={onTrashClick}
ariaLabel="Delete this version"
className={`${baseClass}__trash-button`}
>
<Icon name="trash" />
</Button>
);
// Only FMA and App Store / Play Store rows are GitOps-locked (those
// installer types can't be managed via YAML); custom packages stay
// deletable. The `software` entity exception is honored via the wrapper.
const isAppStore = installerType === "app-store";
const lockedByGitOpsMode = isFma || isAppStore;
const renderTrashButton = () =>
lockedByGitOpsMode ? (
<GitOpsModeTooltipWrapper
position="top"
tipOffset={8}
entityType="software"
renderChildren={(gitOpsDisabled) =>
renderTrashButtonBody(!!gitOpsDisabled)
}
/>
) : (
renderTrashButtonBody(false)
);
// `<div role="button">` rather than `<button>` because the badges nested
// inside are native `<button>`s — nesting them violates the HTML spec
// (React fires `validateDOMNesting`). Keyboard handling mirrors
// `DataTable.tsx`'s clickable-row pattern.
const handleHeaderKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!canExpand) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
toggleExpanded();
}
};
const headerButton = (
<div
role="button"
className={`${baseClass}__header`}
onClick={toggleExpanded}
onKeyDown={handleHeaderKeyDown}
aria-expanded={isExpanded}
aria-disabled={!canExpand}
tabIndex={canExpand ? 0 : -1}
>
<span
className={classnames(`${baseClass}__chevron`, {
[`${baseClass}__chevron--open`]: isExpanded,
})}
>
<Icon name="chevron-right" color="ui-fleet-black-75" />
</span>
<InstallerDetailsWidget
className={`${baseClass}__installer-details`}
softwareName={filename}
installerType={installerType}
version={version}
addedTimestamp={addedAt}
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
androidPlayStoreId={androidPlayStoreId}
hideInstallerType
// Inactive rows surface a single hover tooltip (the rollback hint);
// suppress the widget's tooltips to avoid stacking two on the same
// target. See `InstallerDetailsWidget` for the full set silenced.
disableTooltips={!isActive}
/>
<div className={`${baseClass}__header-right`}>{renderHeaderBadges()}</div>
</div>
);
return (
<div
className={classnames(baseClass, {
[`${baseClass}--inactive`]: !isActive,
[`${baseClass}--expanded`]: isExpanded,
})}
>
{isActive ? (
headerButton
) : (
<TooltipWrapper
className={`${baseClass}__inactive-tooltip`}
tipContent={inactiveTooltip}
showArrow
underline={false}
position="top"
tipOffset={8}
disableTooltip={!canEditSoftware}
>
{headerButton}
</TooltipWrapper>
)}
{isExpanded && (
<div className={`${baseClass}__panel`}>
<div className={`${baseClass}__status-column`}>
<div className={`${baseClass}__status-counts`}>
{renderStatusCount(
"success",
installed,
"installed",
installedIconTooltip,
installedPath,
<TooltipWrapper
className={`${baseClass}__status-counts-info`}
tipContent={statusCountsTooltip}
showArrow
underline={false}
position="top"
tipOffset={8}
>
<Icon name="info-outline" color="ui-fleet-black-50" />
</TooltipWrapper>
)}
{renderStatusCount(
"pending-outline",
pending,
"pending",
pendingIconTooltip,
pendingPath
)}
{renderStatusCount(
"error",
failed,
"failed",
failedIconTooltip,
failedPath
)}
</div>
</div>
<div className={`${baseClass}__details-column`}>
{renderLabelsBlock()}
{renderHashBlock()}
</div>
<div className={`${baseClass}__actions-column`}>
{downloadUrl && (
<Button
variant="icon"
onClick={onDownloadClick}
ariaLabel="Download installer"
className={`${baseClass}__download-button`}
>
<Icon name="download" />
</Button>
)}
{canEditSoftware && renderTrashButton()}
</div>
</div>
)}
</div>
);
};
export default LibraryItemAccordion;
@@ -0,0 +1,623 @@
/**
* Multi-row list stories. Single-row prop variants live in
* `LibraryItemAccordion.stories.tsx`.
*
* Two pieces of indirection:
* - `<StoryRow>` injects path props + a `canEditSoftware: true` default so
* rows stay terse.
* - `LibraryItemAccordionListDemo` clones each child to inject `labels` /
* `labelKind` / `badgeState` from the controls panel.
*
* New `<LibraryItemAccordion>` props may need wiring through one of these
* before they surface in any story here.
*/
import React from "react";
import { Meta, StoryObj } from "@storybook/react";
import {
QueryClient,
QueryClientProvider,
QueryClientProviderProps,
} from "react-query";
import { ILabelSoftwareTitle } from "interfaces/label";
import paths from "router/paths";
import { getPathWithQueryParams } from "utilities/url";
import LibraryItemAccordion, {
ILibraryItemAccordionProps,
LibraryItemLabelKind,
} from "./LibraryItemAccordion";
import LibraryItemAccordionList from "./LibraryItemAccordionList";
const daysAgo = (n: number) =>
new Date(Date.now() - 1000 * 60 * 60 * 24 * n).toISOString();
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
// React Query v3 with React 18 needs `children` explicitly typed on the
// provider. Mirrors the pattern in frontend/router/index.tsx and other stories.
type CustomQueryClientProviderProps = React.PropsWithChildren<QueryClientProviderProps>;
const CustomQueryClientProvider: React.FC<CustomQueryClientProviderProps> = QueryClientProvider;
const FAKE_LABEL_NAMES = [
"Engineering",
"Design",
"Marketing",
"Sales",
"Customer success",
"Finance",
"Legal",
"IT",
"Workstations",
"Servers",
"macOS workstations",
"Windows workstations",
"Linux servers",
"Production",
"Staging",
];
const generateLabels = (count: number): ILabelSoftwareTitle[] =>
Array.from({ length: count }, (_, i) => ({
id: i + 1,
name: FAKE_LABEL_NAMES[i] ?? `Label ${i + 1}`,
})) as ILabelSoftwareTitle[];
// Exposes labelKind/labelCount as Storybook args; builds the label list,
// wraps rows in LibraryItemAccordionList, and clones each row to inject the
// labels so story authors don't repeat them on every accordion.
type BadgeState = "latest" | "pinned" | "majorVersion";
interface ILibraryItemAccordionListDemoProps {
/** Label scope applied to every row drives the header label-count badge
* tooltip and the expanded "Include any / Include all / Exclude any"
* heading. */
labelKind: LibraryItemLabelKind;
/** Number of fake labels assigned to every row. 0 = no scoped labels
* (falls back to the "All hosts" badge on active rows). */
labelCount: number;
/** Which badge the active row(s) display. Injected into every active
* accordion child via `cloneElement` each story doesn't need to set
* `badgeState` itself. `pinned` "Pinned" badge (pin icon). `majorVersion`
* "Major version" badge (same pin icon, distinct label). */
badgeState: BadgeState;
children?: React.ReactNode;
}
// Recursively unwraps React.Fragment so stories can use `<>...</>`. Neither
// `Children.map` nor `Children.toArray` traverses fragments — they treat
// them as single leaf elements — which would route cloneElement's injected
// props to the fragment wrapper instead of the accordion rows.
const flattenFragments = (nodes: React.ReactNode): React.ReactElement[] => {
const out: React.ReactElement[] = [];
React.Children.forEach(nodes, (child) => {
if (!React.isValidElement(child)) return;
if (child.type === React.Fragment) {
out.push(
...flattenFragments(
(child.props as { children?: React.ReactNode }).children
)
);
} else {
out.push(child);
}
});
return out;
};
// Each control-panel option maps directly to one `badgeState` value — no
// boolean toggling, the prop is its own discriminated union now.
const badgeOverridesFor = (state: BadgeState) => ({ badgeState: state });
// Stub URLs from the production utility so install-status counts render as
// CustomLinks with realistic-looking hrefs. Only the shape matters here.
const statusPath = (software_status: "installed" | "pending" | "failed") =>
getPathWithQueryParams(paths.MANAGE_HOSTS, {
software_title_id: 123,
software_status,
fleet_id: 0,
});
const STORYBOOK_PATHS = {
installedPath: statusPath("installed"),
pendingPath: statusPath("pending"),
failedPath: statusPath("failed"),
};
// Shim around `<LibraryItemAccordion>`: injects path props + a
// `canEditSoftware: true` default so non-permission stories stay terse.
// Permission stories override `canEditSoftware` explicitly. The Demo
// wrapper still injects labels/labelKind/badgeState via cloneElement.
type IStoryRowProps = Omit<
ILibraryItemAccordionProps,
"installedPath" | "pendingPath" | "failedPath" | "canEditSoftware"
> & { canEditSoftware?: boolean };
const StoryRow = ({ canEditSoftware = true, ...props }: IStoryRowProps) => (
<LibraryItemAccordion
{...props}
{...STORYBOOK_PATHS}
canEditSoftware={canEditSoftware}
/>
);
const LibraryItemAccordionListDemo = ({
labelKind,
labelCount,
badgeState,
children,
}: ILibraryItemAccordionListDemoProps) => {
const labels = generateLabels(labelCount);
const rows = flattenFragments(children);
return (
<LibraryItemAccordionList>
{rows.map((child, i) => {
const childProps = child.props as ILibraryItemAccordionProps;
// Only push a badge override onto active rows — inactive rows hide all
// badges, so the override would be a no-op but it keeps the cloned
// props sane.
const badgeProps = childProps.isActive
? badgeOverridesFor(badgeState)
: {};
return React.cloneElement(
child as React.ReactElement<ILibraryItemAccordionProps>,
{
labels,
labelKind,
...badgeProps,
key: child.key ?? i,
}
);
})}
</LibraryItemAccordionList>
);
};
const meta: Meta<typeof LibraryItemAccordionListDemo> = {
title: "Pages/SoftwareTitleDetailsPage/LibraryItemAccordionList",
component: LibraryItemAccordionListDemo,
args: {
labelKind: "includeAny",
labelCount: 0,
badgeState: "latest",
},
argTypes: {
labelKind: {
control: "select",
options: ["includeAny", "includeAll", "excludeAny"],
description:
"Label scope applied to every row in the story (drives the badge tooltip + expanded heading).",
},
labelCount: {
control: { type: "number", min: 0, max: FAKE_LABEL_NAMES.length },
description:
"Number of fake labels assigned to every row. 0 = no scoped labels (falls back to 'All hosts').",
},
badgeState: {
control: "select",
options: ["latest", "pinned", "majorVersion"],
description:
"Badge shown on active rows. 'latest' → 'Latest' (refresh icon). 'pinned' → 'Pinned' (pin icon). 'majorVersion' → 'Major version' (pin icon).",
},
},
decorators: [
(Story) => (
<CustomQueryClientProvider client={queryClient}>
<Story />
</CustomQueryClientProvider>
),
],
};
export default meta;
type Story = StoryObj<typeof LibraryItemAccordionListDemo>;
// Thin shim so each story can stay terse: hands the current args to the
// wrapper component, which builds labels and clones them onto every row.
const renderList = (
args: ILibraryItemAccordionListDemoProps,
rows: React.ReactNode
) => (
<LibraryItemAccordionListDemo {...args}>{rows}</LibraryItemAccordionListDemo>
);
/** Scenario: user pinned the FMA to **major version 149** via Actions > Versions.
* The `Pinned` badge sits on the latest cached 149.x release (the row that
* satisfies the major); older 149.x patches and the previous major (148.x) are
* rendered inactive. The badge label itself is the same as for an exact-version
* pin the distinction is data-driven (and surfaced in the Versions modal /
* activity feed `pinned_version: "^149"`), not visual at the row level. */
export const PinnedToMajorVersion: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="Google Chrome"
version="149.0.7827.54"
addedAt={daysAgo(1)}
installerType="package"
isFma
isLatestFmaVersion
isActive
installed={32}
pending={5}
failed={3}
hashSha256="af001543fcc5fbf484203b207d8af4fce44fc6975ca3db0eac49a49581af29b7"
downloadUrl="https://example.com/chrome-149.0.7827.54.pkg"
/>
<StoryRow
filename="Google Chrome"
version="149.0.7800.10"
addedAt={daysAgo(10)}
installerType="package"
isFma
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="Google Chrome"
version="148.0.7778.179"
addedAt={daysAgo(28)}
installerType="package"
isFma
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
// Note: there is intentionally no "MixedInstallerTypes" story. A software
// title binds to one installer path (FMA, custom package, VPP App Store, or
// Google Play — mutually exclusive in the schema), so the production page
// will never render different installer types in the same list. The per-type
// row treatments are still visible across the remaining stories
// (`AndroidFmaSingleVersion`, `AppStoreVppSingleVersion`, the custom-package
// variants, and the doc-only Windows/macOS mixed custom+FMA stories below).
/** Single cached version of a Google Play FMA (Chrome for Android). Android
* Play Store apps don't cache multiple versions the version chip always
* reads "Latest" via `AndroidLatestVersionWithTooltip`, since the version is
* pulled live from the Play Store rather than tracked per-row. The list will
* therefore only ever contain one row for an Android FMA. */
export const AndroidFmaSingleVersion: Story = {
render: (args) =>
renderList(
args,
<StoryRow
filename="Google Chrome"
addedAt={daysAgo(1)}
installerType="app-store"
androidPlayStoreId="com.android.chrome"
isActive
installed={18}
pending={2}
failed={1}
/>
),
};
/** Three cached versions of one **custom macOS package** (`.pkg`). Same title,
* different versions the realistic "user uploaded patches over time" view.
* The newest row is active+latest with install counts; the older two are
* inactive (greyed) and show the rollback hover tooltip. */
export const MacCustomPackageMultipleVersions: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="AcmeHelper.pkg"
version="2.4.0"
addedAt={daysAgo(2)}
installerType="package"
isActive
installed={47}
pending={3}
failed={1}
hashSha256="b9d3a9d6c1e9442f9c0bb56af4f37b87f0bcb6df7f8db5a30e1bdce20c40a8d3"
downloadUrl="https://example.com/acme-helper-2.4.0.pkg"
/>
<StoryRow
filename="AcmeHelper.pkg"
version="2.3.5"
addedAt={daysAgo(18)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="AcmeHelper.pkg"
version="2.2.0"
addedAt={daysAgo(60)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
/** Three cached versions of one **custom Windows package** (`.msi`). At the
* component level Windows custom packages render the same as macOS (file-pkg
* icon, "Custom package" label is hidden by `hideInstallerType`) only the
* filename extension hints at the OS. */
export const WindowsCustomPackageMultipleVersions: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="NotepadPlusPlus.msi"
version="8.6.9"
addedAt={daysAgo(3)}
installerType="package"
isActive
installed={28}
pending={4}
failed={2}
hashSha256="2e8a4f3b9c1d5e7a8b6c2f0d1e3a5b7c9d2e4f6a8b0c1d3e5f7a9b1c3d5e7f9a"
downloadUrl="https://example.com/npp-8.6.9.msi"
/>
<StoryRow
filename="NotepadPlusPlus.msi"
version="8.6.4"
addedAt={daysAgo(22)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="NotepadPlusPlus.msi"
version="8.5.8"
addedAt={daysAgo(70)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
/** **Documentation only cannot occur in production.** A single title has
* one installer path (FMA or custom), so an FMA and a custom package never
* appear in the same list. This story stacks an FMA Windows row against a
* custom Windows `.msi` so designers can verify the FMA row's "(latest)"
* suffix and "Actions > Edit" tooltip on the version chip the only visual
* cue separating it from a custom Windows row (both share the `file-pkg`
* icon). */
export const WindowsMixedCustomAndFma: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="Mozilla Firefox"
version="131.0.3"
addedAt={daysAgo(1)}
installerType="package"
isFma
isLatestFmaVersion
isActive
installed={54}
pending={6}
failed={2}
hashSha256="9f2c4e6a8b0d1f3e5a7c9b1d3f5e7a9c1b3d5f7e9a1c3b5d7f9e1a3c5b7d9f1e"
downloadUrl="https://example.com/firefox-131.0.3.msi"
/>
<StoryRow
filename="Mozilla Firefox"
version="130.0.1"
addedAt={daysAgo(20)}
installerType="package"
isFma
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="CompanyVPN.msi"
version="4.1.0"
addedAt={daysAgo(7)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
hashSha256="3a7c9e1b5d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c"
/>
<StoryRow
filename="CompanyVPN.msi"
version="4.0.2"
addedAt={daysAgo(40)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
/** **Documentation only cannot occur in production.** Mac mirror of
* `WindowsMixedCustomAndFma`. An FMA macOS `.pkg` stacked against a custom
* macOS `.pkg` so the FMA row's "(latest)" suffix and "Actions > Edit"
* tooltip can be compared against a plain custom-package row. */
export const MacOSMixedCustomAndFma: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="Slack"
version="4.39.95"
addedAt={daysAgo(1)}
installerType="package"
isFma
isLatestFmaVersion
isActive
installed={87}
pending={4}
failed={2}
hashSha256="d4e7a1c3b5f9e1a3c5b7d9f1e3a5c7b9d1f3e5a7c9b1d3f5e7a9c1b3d5f7e9a1"
downloadUrl="https://example.com/slack-4.39.95.pkg"
/>
<StoryRow
filename="Slack"
version="4.38.121"
addedAt={daysAgo(25)}
installerType="package"
isFma
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="DesignTool.pkg"
version="3.2.1"
addedAt={daysAgo(8)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
hashSha256="7e3a9c1b5d2f4a6c8e0b2d4f6a8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4e"
/>
<StoryRow
filename="DesignTool.pkg"
version="3.1.0"
addedAt={daysAgo(50)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
/** Single cached version of an Apple App Store (VPP) app. Like Google Play
* apps, VPP apps don't cache multiple versions the `version` value updates
* hourly from the App Store (note the "Updated every hour" tooltip on the
* version chip), so the list will only ever contain one row. */
export const AppStoreVppSingleVersion: Story = {
render: (args) =>
renderList(
args,
<StoryRow
filename="1Password 7 - Password Manager"
version="7.9.11"
addedAt={daysAgo(2)}
installerType="app-store"
isActive
installed={42}
pending={3}
failed={0}
/>
),
};
/** Three cached versions of an **iOS/iPadOS in-house `.ipa`** uploaded as a
* custom software installer (enterprise app distributed outside the App
* Store). At the component level this renders the same way as any other
* custom package `file-pkg` icon, "Custom package" label hidden by
* `hideInstallerType`, only the `.ipa` filename extension hints at iOS. Note:
* the iOS-specific managed-app configuration plist (the `configuration` field
* on `ISoftwarePackage`) is rendered elsewhere on the page, not in the
* accordion. */
export const IOSInHouseIpaMultipleVersions: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="AcmeWarehouse.ipa"
version="5.2.1"
addedAt={daysAgo(4)}
installerType="package"
isActive
installed={36}
pending={2}
failed={1}
hashSha256="6b1d3a5c7e9f0b2d4a6c8e1f3b5d7a9c0e2f4b6d8a0c1e3f5b7d9a1c3e5f7b9d"
downloadUrl="https://example.com/acme-warehouse-5.2.1.ipa"
/>
<StoryRow
filename="AcmeWarehouse.ipa"
version="5.1.0"
addedAt={daysAgo(28)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
<StoryRow
filename="AcmeWarehouse.ipa"
version="5.0.3"
addedAt={daysAgo(75)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
/>
</>
),
};
/** **Documentation only cannot occur in production.** A single software
* title binds to one installer path (VPP App Store **or** in-house `.ipa`,
* not both). This story stacks an iOS VPP app row against an in-house `.ipa`
* row so designers can compare the two side-by-side: the Apple App Store
* icon + "Updated every hour" version tooltip vs the `file-pkg` icon + plain
* version chip. */
export const IOSMixedVppAndInHouseIpa: Story = {
render: (args) =>
renderList(
args,
<>
<StoryRow
filename="Microsoft Authenticator"
version="6.8.14"
addedAt={daysAgo(2)}
installerType="app-store"
isActive
installed={64}
pending={5}
failed={1}
/>
<StoryRow
filename="AcmeWarehouse.ipa"
version="5.2.1"
addedAt={daysAgo(6)}
installerType="package"
isActive={false}
installed={0}
pending={0}
failed={0}
hashSha256="6b1d3a5c7e9f0b2d4a6c8e1f3b5d7a9c0e2f4b6d8a0c1e3f5b7d9a1c3e5f7b9d"
/>
</>
),
};
@@ -0,0 +1,18 @@
import React from "react";
const baseClass = "library-item-accordion-list";
interface ILibraryItemAccordionListProps {
children: React.ReactNode;
className?: string;
}
const LibraryItemAccordionList = ({
children,
className,
}: ILibraryItemAccordionListProps) => {
const classes = className ? `${baseClass} ${className}` : baseClass;
return <div className={classes}>{children}</div>;
};
export default LibraryItemAccordionList;
@@ -0,0 +1,289 @@
.library-item-accordion {
display: flex;
flex-direction: column;
&__header {
// Explicit border-box: this is a `<div role="button">` (default
// `content-box`), and `width: 100%` plus horizontal padding would push
// the header past the parent without it.
box-sizing: border-box;
display: flex;
align-items: center;
gap: $pad-medium;
padding: $pad-medium $pad-large $pad-medium $pad-medium;
background: $core-fleet-white;
border: 0;
border-bottom: 1px solid $ui-fleet-black-10;
width: 100%;
cursor: pointer;
text-align: left;
&:hover {
background: $ui-off-white;
}
&:focus-visible {
outline: 1px solid $core-focused-outline;
outline-offset: -1px;
}
}
&__chevron {
display: inline-flex;
width: 16px;
height: 16px;
flex-shrink: 0;
svg {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: rotate(0deg);
}
}
&__chevron--open svg {
transform: rotate(90deg);
}
// Embedded InstallerDetailsWidget takes the remaining horizontal space
// between the chevron and the badges, and shrinks gracefully on narrow rows.
&__installer-details {
flex: 1 1 auto;
min-width: 0;
}
&__header-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
&__badges {
display: flex;
align-items: center;
gap: $pad-medium;
}
&__badge-button {
display: inline-flex;
align-items: center;
gap: $pad-xsmall;
}
// Non-clickable variant of the label badge (rendered when the viewer can't
// edit software). Matches the clickable badge's metrics so the header
// doesn't shift when the click affordance is removed.
&__badge-button--static {
padding: $pad-xsmall $pad-small;
font-size: $x-small;
font-weight: $bold;
color: $ui-fleet-black-75;
white-space: nowrap;
}
&__panel {
// Explicit border-box: the panel's width is set by `align-items: stretch`
// from the accordion container, and without border-box the padding would
// push the panel (and every child column) past the right edge.
box-sizing: border-box;
display: flex;
align-items: stretch;
// Uniform 1rem column gap; the status block carries an extra 2rem
// `margin-right` to reach the 3rem visual separation around its
// border-right divider. (Asymmetric per-pair spacing isn't expressible
// as a single `gap` value, hence the supplemental margin.)
gap: $pad-medium;
padding: $pad-large;
background: $ui-off-white;
border-bottom: 1px solid $ui-fleet-black-10;
> :nth-child(1) {
margin-right: $pad-xlarge;
}
}
&__status-column {
display: flex;
align-items: center;
gap: $pad-xlarge;
padding-right: $pad-medium;
border-right: 1px solid $ui-fleet-black-10;
}
&__status-counts {
display: flex;
flex-direction: column;
gap: $pad-medium;
}
&__status-count {
display: inline-flex;
align-items: center;
gap: $pad-small;
font-size: $x-small;
color: $ui-fleet-black-75;
// TooltipWrapper's inner element is block by default; force inline-flex
// so its bounds match the icon's instead of growing to a line-height box.
.component__tooltip-wrapper__element {
display: inline-flex;
align-items: center;
}
}
// Info-outline tooltip at the end of the first status row. Same
// inline-flex fix so the icon shares a vertical center with the count.
&__status-counts-info {
margin-left: $pad-medium;
align-items: center;
.component__tooltip-wrapper__element {
display: inline-flex;
align-items: center;
}
}
&__details-column {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: $pad-medium;
}
&__data-row {
display: flex;
flex-direction: column;
gap: $pad-xsmall;
}
&__data-heading {
font-size: $x-small;
font-weight: $bold;
color: $ui-fleet-black-75;
margin: 0;
}
&__data-value {
font-size: $x-small;
color: $ui-fleet-black-75;
margin: 0;
}
&__hash-row {
display: flex;
align-items: center;
gap: $pad-small;
min-width: 0;
}
&__hash {
font-size: $x-small;
color: $ui-fleet-black-75;
min-width: 0;
flex: 0 1 auto;
}
// Wraps the copy button so the absolute "Copied!" message can anchor to
// its left edge and float over the truncated hash. Same pattern as
// `InputField.__action-button-wrapper`.
&__copy-wrapper {
position: relative;
flex-shrink: 0;
}
&__copy-button {
flex-shrink: 0;
}
&__copy-message {
@include copy-message;
// Mixin's `margin: -4px 0` is for inline alignment; zero it out so the
// absolute chip centers cleanly against the copy button.
margin: 0;
position: absolute;
top: 50%;
right: calc(100% + #{$pad-xsmall});
transform: translateY(-50%);
pointer-events: none;
white-space: nowrap;
}
&__actions-column {
display: flex;
align-items: flex-start;
gap: $pad-medium;
flex-shrink: 0;
}
// The inactive-row tooltip wraps the full-width header; override the
// default inline-flex so the header keeps its row layout.
&__inactive-tooltip {
display: flex;
.component__tooltip-wrapper__element {
flex: 1 1 auto;
min-width: 0;
white-space: normal;
}
}
&--inactive {
.library-item-accordion__header {
cursor: default;
}
// `opacity` creates a stacking context that drags tooltip popups down
// with it. Apply opacity only to elements that don't host a tooltip;
// grey the rest via `color` so any popups inside still render at full
// opacity.
.library-item-accordion__chevron,
.installer-details-widget .graphic,
.installer-details-widget .software-icon {
opacity: 0.5;
}
.installer-details-widget__title,
.installer-details-widget__details {
color: $ui-fleet-black-50;
}
.library-item-accordion__header:hover {
background: $core-fleet-white;
}
}
}
.library-item-accordion-list {
display: flex;
flex-direction: column;
border: 1px solid $ui-fleet-black-10;
border-radius: 8px;
overflow: hidden;
// Match each header's corner radius to its position in the list so the
// focus-visible outline (drawn just inside the header) stays flush with
// the list's rounded clip.
.library-item-accordion:first-child .library-item-accordion__header {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
// Only round the last header's bottom corners when it sits flush against
// the list's bottom edge — i.e. when its panel isn't expanded below it.
.library-item-accordion:last-child:not(.library-item-accordion--expanded)
.library-item-accordion__header {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.library-item-accordion:last-child.library-item-accordion--expanded
.library-item-accordion__panel {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.library-item-accordion:last-child .library-item-accordion__header,
.library-item-accordion:last-child .library-item-accordion__panel {
border-bottom: 0;
}
}
@@ -0,0 +1,84 @@
import { deriveAccordionRowState } from "./helpers";
describe("deriveAccordionRowState", () => {
it("returns inactive when the row version doesn't match the active version", () => {
expect(
deriveAccordionRowState({
rowVersion: "148.0.7778.179",
activeVersion: "149.0.7827.54",
pinnedVersion: null,
})
).toEqual({ isActive: false });
});
it("returns inactive when activeVersion is null", () => {
expect(
deriveAccordionRowState({
rowVersion: "149.0.7827.54",
activeVersion: null,
pinnedVersion: null,
})
).toEqual({ isActive: false });
});
it("returns inactive when activeVersion is undefined", () => {
expect(
deriveAccordionRowState({
rowVersion: "149.0.7827.54",
activeVersion: undefined,
pinnedVersion: null,
})
).toEqual({ isActive: false });
});
it("returns latest badge for the active row when no pin is set (null)", () => {
expect(
deriveAccordionRowState({
rowVersion: "149.0.7827.54",
activeVersion: "149.0.7827.54",
pinnedVersion: null,
})
).toEqual({ isActive: true, badgeState: "latest" });
});
it("returns latest badge for the active row when no pin is set (undefined)", () => {
expect(
deriveAccordionRowState({
rowVersion: "149.0.7827.54",
activeVersion: "149.0.7827.54",
pinnedVersion: undefined,
})
).toEqual({ isActive: true, badgeState: "latest" });
});
it("returns pinned badge for the active row when pin is an exact version", () => {
expect(
deriveAccordionRowState({
rowVersion: "148.0.7778.179",
activeVersion: "148.0.7778.179",
pinnedVersion: "148.0.7778.179",
})
).toEqual({ isActive: true, badgeState: "pinned" });
});
it("returns majorVersion badge when pin is caret-prefixed", () => {
expect(
deriveAccordionRowState({
rowVersion: "149.0.7827.54",
activeVersion: "149.0.7827.54",
pinnedVersion: "^149",
})
).toEqual({ isActive: true, badgeState: "majorVersion" });
});
it("does not surface a pin badge on inactive rows even when the pin matches the row version", () => {
// The pin always applies to the active row, never to an older cached row.
expect(
deriveAccordionRowState({
rowVersion: "148.0.7778.179",
activeVersion: "149.0.7827.54",
pinnedVersion: "148.0.7778.179",
})
).toEqual({ isActive: false });
});
});
@@ -0,0 +1,41 @@
import { LibraryItemBadgeState } from "./LibraryItemAccordion";
export interface IDeriveAccordionRowStateInput {
/** Version string of this row (one entry from `fleet_maintained_versions[]`,
* or a cached `software_installers.version` row). */
rowVersion: string;
/** Version string of the currently active installer on the title. Rows that
* match are considered active. `null`/`undefined` collapses every row into
* the inactive state. */
activeVersion: string | null | undefined;
/** The title's pin value:
* - `null`/`undefined` no pin, active row gets `badgeState: "latest"`
* - exact string ("149.0.7827.54") active row gets `badgeState: "pinned"`
* - caret-prefixed string ("^149") active row gets `badgeState: "majorVersion"`
*
* Only the active row carries a badge; inactive rows always return
* `badgeState: undefined`. */
pinnedVersion: string | null | undefined;
}
/** Pure derivation of the accordion's per-row state from the title-level data
* that #47623 will read out of the API. Centralized here (and unit tested) so
* the page integration doesn't open-code the pin-vs-latest-vs-major branching
* keeps the parent story's "row badge matches the pin kind" rule in one
* place that's easy to grep. */
export const deriveAccordionRowState = ({
rowVersion,
activeVersion,
pinnedVersion,
}: IDeriveAccordionRowStateInput): {
isActive: boolean;
badgeState?: LibraryItemBadgeState;
} => {
const isActive = !!activeVersion && rowVersion === activeVersion;
if (!isActive) return { isActive: false };
if (!pinnedVersion) return { isActive: true, badgeState: "latest" };
if (pinnedVersion.startsWith("^")) {
return { isActive: true, badgeState: "majorVersion" };
}
return { isActive: true, badgeState: "pinned" };
};
@@ -0,0 +1 @@
export { default } from "./LibraryItemAccordion";
@@ -138,15 +138,4 @@ describe("InstallerDetailsWidget", () => {
// TooltipWrapper is mocked, so we just check that the child is rendered
expect(screen.getByText("Test Software")).toBeInTheDocument();
});
it("renders the sha256 hash when provided and a copy button", () => {
const sha256 =
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
render(<InstallerDetailsWidget {...defaultProps} sha256={sha256} />);
// The component shows the first 6 chars + ellipsis
expect(screen.getByText(/^abcdef1…$/)).toBeInTheDocument();
const copyIcon = screen.getByTestId("copy-icon");
const copyButton = copyIcon.closest("button");
expect(copyButton).toBeInTheDocument();
});
});
@@ -1,10 +1,9 @@
/** TODO: This component is similar to other UI elements that can
* be abstracted to use a shared base component (e.g. DetailsWidget) */
import React, { useState } from "react";
import React from "react";
import classnames from "classnames";
import { stringToClipboard } from "utilities/copy_text";
import { internationalTimeFormat } from "utilities/helpers";
import { addedFromNow } from "utilities/date_format";
import { LEARN_MORE_ABOUT_BASE_LINK } from "utilities/constants";
@@ -16,8 +15,6 @@ import { isAndroidWebApp } from "pages/SoftwarePage/helpers";
import Graphic from "components/Graphic";
import SoftwareIcon from "pages/SoftwarePage/components/icons/SoftwareIcon";
import TooltipWrapper from "components/TooltipWrapper";
import Button from "components/buttons/Button";
import Icon from "components/Icon";
import CustomLink from "components/CustomLink";
import AndroidLatestVersionWithTooltip from "components/MDM/AndroidLatestVersionWithTooltip";
@@ -25,9 +22,13 @@ const baseClass = "installer-details-widget";
interface IInstallerNameProps {
name: string;
/** When true, suppress the truncation tooltip used in contexts (e.g.
* inactive LibraryItemAccordion rows) where every tooltip on the row is
* suppressed. */
disableTooltip?: boolean;
}
const InstallerName = ({ name }: IInstallerNameProps) => {
const InstallerName = ({ name, disableTooltip }: IInstallerNameProps) => {
const titleRef = React.useRef<HTMLDivElement>(null);
const isTruncated = useCheckTruncatedElement(titleRef);
@@ -36,7 +37,7 @@ const InstallerName = ({ name }: IInstallerNameProps) => {
tipContent={name}
position="top"
underline={false}
disableTooltip={!isTruncated}
disableTooltip={disableTooltip || !isTruncated}
showArrow
>
<div ref={titleRef} className={`${baseClass}__title`}>
@@ -70,12 +71,23 @@ interface IInstallerDetailsWidgetProps {
installerType: InstallerType;
addedTimestamp?: string;
version?: string | null;
sha256?: string | null;
isFma: boolean;
isLatestFmaVersion?: boolean;
isScriptPackage: boolean;
androidPlayStoreId?: string;
customDetails?: string;
/** Suppress the leading installer-type label ("Custom package", "App Store (VPP)",
* etc.). Used when the widget is embedded somewhere that already conveys the type
* (e.g. LibraryItemAccordion, where the icon + container do the same work). */
hideInstallerType?: boolean;
/** Suppress every hover tooltip the widget would normally render (title
* truncation, FMA "change in Actions > Edit" hint, App Store "Updated every
* hour", Android Play Store link, "Fleet couldn't read the version", and the
* `addedAt` formatted-time tooltip). Used by inactive LibraryItemAccordion
* rows, whose outer wrapper already shows the rollback hover tooltip Fleet
* UI avoids stacking two tooltips on the same hover target across the app,
* so the widget's tooltips have to defer to the row-level one. */
disableTooltips?: boolean;
}
const InstallerDetailsWidget = ({
@@ -83,31 +95,17 @@ const InstallerDetailsWidget = ({
softwareName,
installerType,
addedTimestamp,
sha256,
version,
isFma,
isLatestFmaVersion = false,
isScriptPackage,
androidPlayStoreId,
customDetails,
hideInstallerType = false,
disableTooltips = false,
}: IInstallerDetailsWidgetProps) => {
const classNames = classnames(baseClass, className);
const [copyMessage, setCopyMessage] = useState("");
const onCopySha256 = (evt: React.MouseEvent) => {
evt.preventDefault();
stringToClipboard(sha256)
.then(() => setCopyMessage("Copied!"))
.catch(() => setCopyMessage("Copy failed"));
// Clear message after 1 second
setTimeout(() => setCopyMessage(""), 1000);
return false;
};
const renderIcon = () => {
if (installerType === "app-store") {
if (androidPlayStoreId) {
@@ -123,41 +121,31 @@ const InstallerDetailsWidget = ({
return <>{customDetails}</>;
}
const renderVersionInfo = () => {
// Renders just the version chip (or null when hidden). The leading " · "
// separator is added by the caller so that callers who suppress the
// preceding type label don't get a stray middot.
const renderVersionChip = (): React.ReactNode => {
// Hide version info from script package and Android Play Store web apps
if (isScriptPackage || isAndroidWebApp(androidPlayStoreId)) {
return null;
}
let versionInfo = <span>{version}</span>;
if (isFma) {
versionInfo = (
<TooltipWrapper
tipContent={
<span>
You can change the version in <strong>Actions &gt; Edit</strong>{" "}
software.
</span>
}
>
<span>
{version} {isLatestFmaVersion ? "(latest)" : ""}
</span>
</TooltipWrapper>
);
}
if (installerType === "app-store") {
versionInfo = (
<TooltipWrapper tipContent={<span>Updated every hour.</span>}>
<span>{version}</span>
</TooltipWrapper>
if (androidPlayStoreId) {
// AndroidLatestVersionWithTooltip has no disable-tooltip prop, so for
// inactive rows we render the plain "Latest" text instead — keeps the
// chip readable without bringing in the Play Store hover tooltip.
if (disableTooltips) return <span>Latest</span>;
return (
<AndroidLatestVersionWithTooltip
androidPlayStoreId={androidPlayStoreId}
/>
);
}
if (!version) {
versionInfo = (
return (
<TooltipWrapper
disableTooltip={disableTooltips}
tipContent={
<span>
Fleet couldn&apos;t read the version from {softwareName}.
@@ -180,86 +168,74 @@ const InstallerDetailsWidget = ({
);
}
if (androidPlayStoreId) {
versionInfo = (
<AndroidLatestVersionWithTooltip
androidPlayStoreId={androidPlayStoreId}
/>
if (isFma) {
return (
<TooltipWrapper
disableTooltip={disableTooltips}
tipContent={
<span>
You can change the version in <strong>Actions &gt; Edit</strong>{" "}
software.
</span>
}
>
<span>
{version} {isLatestFmaVersion ? "(latest)" : ""}
</span>
</TooltipWrapper>
);
}
return <> &bull; {versionInfo}</>;
};
const renderTimeStamp = () =>
addedTimestamp ? (
<>
{" "}
&bull;{" "}
if (installerType === "app-store") {
return (
<TooltipWrapper
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
underline={false}
disableTooltip={disableTooltips}
tipContent={<span>Updated every hour.</span>}
>
{addedFromNow(addedTimestamp)}
<span>{version}</span>
</TooltipWrapper>
</>
) : (
""
);
);
}
const renderSha256 = () => {
return sha256 ? (
<>
{" "}
&bull;{" "}
<span className={`${baseClass}__sha256`}>
<TooltipWrapper
tipContent={<>The software&apos;s SHA-256 hash.</>}
position="top"
showArrow
underline={false}
>
{sha256.slice(0, 7)}&hellip;
</TooltipWrapper>
<div className={`${baseClass}__sha-copy-button`}>
<Button
variant="icon"
size="small"
iconStroke
onClick={onCopySha256}
>
<Icon name="copy" />
</Button>
</div>
<div className={`${baseClass}__copy-overlay`}>
{copyMessage && (
<div
className={`${baseClass}__copy-message`}
>{`${copyMessage} `}</div>
)}
</div>
</span>
</>
) : (
""
);
return <span>{version}</span>;
};
return (
<>
{renderInstallerDisplayText(installerType, isFma, androidPlayStoreId)}
{renderVersionInfo()}
{renderTimeStamp()}
{renderSha256()}
</>
);
const renderTimeStampChip = (): React.ReactNode =>
addedTimestamp ? (
<TooltipWrapper
disableTooltip={disableTooltips}
tipContent={internationalTimeFormat(new Date(addedTimestamp))}
underline={false}
>
{addedFromNow(addedTimestamp)}
</TooltipWrapper>
) : null;
const parts: React.ReactNode[] = [];
if (!hideInstallerType) {
parts.push(
renderInstallerDisplayText(installerType, isFma, androidPlayStoreId)
);
}
const versionChip = renderVersionChip();
if (versionChip) parts.push(versionChip);
const timeStampChip = renderTimeStampChip();
if (timeStampChip) parts.push(timeStampChip);
return parts.map((part, i) => (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={i}>
{i > 0 && <> &bull; </>}
{part}
</React.Fragment>
));
};
return (
<div className={classNames}>
{renderIcon()}
<div className={`${baseClass}__info`}>
<InstallerName name={softwareName} />
<InstallerName name={softwareName} disableTooltip={disableTooltips} />
<div className={`${baseClass}__details`}>{renderDetails()}</div>
</div>
</div>
@@ -6,7 +6,11 @@
font-size: $x-small;
font-weight: $bold;
@include ellipse-text;
max-width: 48vw;
max-width: 60vw;
@media (max-width: $break-md) {
max-width: 48vw;
}
}
&__info {
@@ -22,25 +26,4 @@
font-size: $xx-small;
align-items: center;
}
&__sha256 {
display: flex;
align-items: center;
margin: -$pad-small 0; // Remove vertical padding but keep clickable area
}
&__copy-overlay {
display: flex;
position: relative;
left: -95px;
}
&__sha-copy-button {
display: flex;
align-items: center;
}
&__copy-message {
@include copy-message;
}
}
@@ -208,7 +208,6 @@ const SoftwareInstallerCard = ({
isLatestFmaVersion,
isCustomPackage,
isIosOrIpadosApp,
sha256,
androidPlayStoreId,
patchPolicy,
automaticInstallPolicies,
@@ -282,7 +281,6 @@ const SoftwareInstallerCard = ({
installerType={installerType}
version={version}
addedTimestamp={addedTimestamp}
sha256={sha256}
isFma={isFleetMaintainedApp}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
@@ -9,13 +9,16 @@ import { AxiosError } from "axios";
import paths from "router/paths";
import useTeamIdParam from "hooks/useTeamIdParam";
import useGitOpsMode from "hooks/useGitOpsMode";
import { useSoftwareInstaller } from "hooks/useSoftwareInstallerMeta";
import { AppContext } from "context/app";
import { ignoreAxiosError } from "interfaces/errors";
import { ILabelSoftwareTitle } from "interfaces/label";
import { ISoftwareTitleDetails } from "interfaces/software";
import {
APP_CONTEXT_ALL_TEAMS_ID,
APP_CONTEXT_NO_TEAM_ID,
} from "interfaces/team";
import { canWriteSoftware } from "utilities/permissions/permissions";
import softwareAPI, {
ISoftwareTitleResponse,
IGetSoftwareTitleQueryKey,
@@ -30,6 +33,12 @@ import TeamsHeader from "components/TeamsHeader";
import DetailsNoHosts from "../components/cards/DetailsNoHosts";
import SoftwareSummaryCard from "./SoftwareSummaryCard";
import SoftwareInstallerCard from "./SoftwareInstallerCard";
import LibraryItemAccordion, {
LibraryItemLabelKind,
} from "./LibraryItemAccordion/LibraryItemAccordion";
import LibraryItemAccordionList from "./LibraryItemAccordion/LibraryItemAccordionList";
import EditSoftwareModal from "./EditSoftwareModal";
import { getDisplayedSoftwareName } from "../helpers";
const baseClass = "software-title-details-page";
@@ -54,6 +63,7 @@ const SoftwareTitleDetailsPage = ({
isTeamMaintainer,
isTeamObserver,
isTeamTechnician,
currentUser,
config,
} = useContext(AppContext);
const handlePageError = useErrorHandler();
@@ -77,12 +87,18 @@ const SoftwareTitleDetailsPage = ({
includeNoTeam: true,
});
const canEditSoftware = canWriteSoftware(currentUser, currentTeamId ?? null);
// gitOpsYamlParam URL Param controls whether the View Yaml modal is opened on page load
// as it automatically opens from adding flow of custom software in gitOps mode
const [showViewYamlModal, setShowViewYamlModal] = useState(
autoOpenGitOpsYamlModal || false
);
// TODO #47622 preview — page-level state for opening the EditSoftwareModal
// from the LibraryItemAccordion. Remove with the preview block.
const [showLibraryEditModal, setShowLibraryEditModal] = useState(false);
const {
data: softwareTitle,
isLoading: isSoftwareTitleLoading,
@@ -111,6 +127,12 @@ const SoftwareTitleDetailsPage = ({
const isAvailableForInstall =
!!softwareTitle?.software_package || !!softwareTitle?.app_store_app;
// TODO #47622 preview — installer meta used to wire the EditSoftwareModal
// from the accordion's label-count click. Remove with the preview block.
const installerResult = useSoftwareInstaller(
softwareTitle ?? ({} as ISoftwareTitleDetails)
);
const onToggleViewYaml = () => {
setShowViewYamlModal(!showViewYamlModal);
};
@@ -187,6 +209,164 @@ const SoftwareTitleDetailsPage = ({
);
};
// TODO #47622 preview — remove before merging into main.
// Renders a single LibraryItemAccordion from the active software_package or
// app_store_app so design can review with real data; multi-row rendering
// lands in #47623.
const renderLibraryItemAccordionPreview = (title: ISoftwareTitleDetails) => {
const pkg = title.software_package;
const appStore = title.app_store_app;
const statusPath = (software_status: "installed" | "pending" | "failed") =>
getPathWithQueryParams(paths.MANAGE_HOSTS, {
software_title_id: softwareId,
software_status,
fleet_id: currentTeamId ?? APP_CONTEXT_NO_TEAM_ID,
});
const installerMeta = installerResult?.meta;
const isFma = installerMeta?.isFleetMaintainedApp ?? false;
const isLatestFmaVersion = installerMeta?.isLatestFmaVersion ?? false;
const isScriptPackage = installerResult?.cardInfo.isScriptPackage ?? false;
interface ILabeledSource {
labels_include_any: ILabelSoftwareTitle[] | null;
labels_include_all: ILabelSoftwareTitle[] | null;
labels_exclude_any: ILabelSoftwareTitle[] | null;
}
interface IPickedLabels {
labels: ILabelSoftwareTitle[] | null;
kind: LibraryItemLabelKind;
}
const pickLabels = (source: ILabeledSource): IPickedLabels => {
if (source.labels_include_all?.length) {
return { labels: source.labels_include_all, kind: "includeAll" };
}
if (source.labels_exclude_any?.length) {
return { labels: source.labels_exclude_any, kind: "excludeAny" };
}
return { labels: source.labels_include_any, kind: "includeAny" };
};
if (appStore) {
const { labels, kind } = pickLabels(appStore);
const isAndroidPlayStoreApp = appStore.platform === "android";
return (
<LibraryItemAccordionList>
<LibraryItemAccordion
filename={appStore.name}
version={appStore.latest_version}
addedAt={appStore.created_at}
installerType="app-store"
androidPlayStoreId={
isAndroidPlayStoreApp ? appStore.app_store_id : undefined
}
isScriptPackage={isScriptPackage}
isActive
badgeState="latest"
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={appStore.status?.installed ?? 0}
pending={appStore.status?.pending ?? 0}
failed={appStore.status?.failed ?? 0}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
onLabelCountClick={() => setShowLibraryEditModal(true)}
onLabelsClick={() => setShowLibraryEditModal(true)}
/>
</LibraryItemAccordionList>
);
}
if (!pkg) return null;
const { labels, kind } = pickLabels(pkg);
return (
<LibraryItemAccordionList>
<LibraryItemAccordion
filename={pkg.name}
version={pkg.version}
addedAt={pkg.uploaded_at}
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
isActive
badgeState="latest"
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={pkg.status?.installed ?? 0}
pending={
(pkg.status?.pending_install ?? 0) +
(pkg.status?.pending_uninstall ?? 0)
}
failed={
(pkg.status?.failed_install ?? 0) +
(pkg.status?.failed_uninstall ?? 0)
}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
hashSha256={pkg.hash_sha256 ?? null}
downloadUrl={pkg.url}
onLabelCountClick={() => setShowLibraryEditModal(true)}
onLabelsClick={() => setShowLibraryEditModal(true)}
/>
<LibraryItemAccordion
filename="example-package-v2-really-long-package-name-to-see-what-happens-responsive-design.pkg"
version="2.0.0"
addedAt="2024-01-01T12:00:00Z"
isFma={isFma}
isLatestFmaVersion={isLatestFmaVersion}
isScriptPackage={isScriptPackage}
isActive={false}
labels={labels}
labelKind={kind}
canEditSoftware={canEditSoftware}
installed={pkg.status?.installed ?? 0}
pending={
(pkg.status?.pending_install ?? 0) +
(pkg.status?.pending_uninstall ?? 0)
}
failed={
(pkg.status?.failed_install ?? 0) +
(pkg.status?.failed_uninstall ?? 0)
}
installedPath={statusPath("installed")}
pendingPath={statusPath("pending")}
failedPath={statusPath("failed")}
hashSha256={pkg.hash_sha256 ?? null}
downloadUrl={pkg.url}
onLabelCountClick={() => setShowLibraryEditModal(true)}
onLabelsClick={() => setShowLibraryEditModal(true)}
/>
</LibraryItemAccordionList>
);
};
const renderLibraryEditModal = (title: ISoftwareTitleDetails) => {
if (!showLibraryEditModal || !installerResult) return null;
const { meta } = installerResult;
return (
<EditSoftwareModal
softwareId={softwareId}
teamId={currentTeamId ?? APP_CONTEXT_NO_TEAM_ID}
softwareInstaller={meta.softwareInstaller}
refetchSoftwareTitle={refetchSoftwareTitle}
onExit={() => setShowLibraryEditModal(false)}
installerType={meta.installerType}
openViewYamlModal={onToggleViewYaml}
isFleetMaintainedApp={meta.isFleetMaintainedApp}
isIosOrIpadosApp={meta.isIosOrIpadosApp}
name={title.name}
displayName={getDisplayedSoftwareName(title.name, title.display_name)}
source={title.source}
iconUrl={title.icon_url}
/>
);
};
const renderContent = () => {
if (isSoftwareTitleLoading) {
return <Spinner />;
@@ -205,7 +385,9 @@ const SoftwareTitleDetailsPage = ({
return (
<>
{renderSoftwareSummaryCard(softwareTitle)}
{renderLibraryItemAccordionPreview(softwareTitle)}
{renderSoftwareInstallerCard(softwareTitle)}
{renderLibraryEditModal(softwareTitle)}
</>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
/** Storybook decorator factory that wraps a story in a fixed-width bordered
* frame with generous vertical padding. The padding leaves room for tooltips
* (and other above/below affordances) that would otherwise be clipped by the
* Storybook canvas. Use one frame per story to avoid nested wrappers do not
* apply both a meta-level and a story-level decorator. */
const withFrame = (width: number) => (Story: React.ComponentType) => (
<div style={{ width, border: "1px dashed #ccc", padding: "80px 8px" }}>
<Story />
</div>
);
export default withFrame;
@@ -108,3 +108,70 @@ describe("permissions - isAdminForAllUserTeams", () => {
);
});
});
describe("permissions - canWriteSoftware", () => {
// Mirrors backend WRITE on `SoftwareInstaller` (policy.rego L827-832, L842-848):
// admin | maintainer | gitops are allowed. The UI doesn't surface gitops users,
// so the helper returns true only for admin / maintainer (global or team-scoped).
const TEAM_ID = 1;
it("returns false when there is no user", () => {
expect(permissions.canWriteSoftware(null, TEAM_ID)).toBe(false);
});
it("allows a global admin regardless of team", () => {
const user = createMockUser({ global_role: "admin", teams: [] });
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(true);
expect(permissions.canWriteSoftware(user, null)).toBe(true);
});
it("allows a global maintainer regardless of team", () => {
const user = createMockUser({ global_role: "maintainer", teams: [] });
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(true);
});
it("allows a team admin on their team", () => {
const user = createMockUser({
global_role: null,
teams: [{ id: TEAM_ID, name: "Team 1", role: "admin" }],
});
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(true);
});
it("allows a team maintainer on their team", () => {
const user = createMockUser({
global_role: null,
teams: [{ id: TEAM_ID, name: "Team 1", role: "maintainer" }],
});
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(true);
});
it("denies a team admin on a different team", () => {
const user = createMockUser({
global_role: null,
teams: [{ id: 2, name: "Team 2", role: "admin" }],
});
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(false);
});
it.each([
["technician", "technician"],
["observer", "observer"],
["observer_plus", "observer_plus"],
] as const)("denies a global %s", (_label, role) => {
const user = createMockUser({ global_role: role, teams: [] });
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(false);
});
it.each([
["technician", "technician"],
["observer", "observer"],
["observer_plus", "observer_plus"],
] as const)("denies a team %s on their team", (_label, role) => {
const user = createMockUser({
global_role: null,
teams: [{ id: TEAM_ID, name: "Team 1", role }],
});
expect(permissions.canWriteSoftware(user, TEAM_ID)).toBe(false);
});
});
@@ -192,6 +192,22 @@ const isNoAccess = (user: IUser): boolean => {
return user.global_role === null && user.teams.length === 0;
};
// Mirrors backend WRITE on `SoftwareInstaller` (rego: admin | maintainer |
// gitops). The UI doesn't surface gitops users — admin/maintainer is the full
// set. Use to gate edit/delete affordances on software rows.
export const canWriteSoftware = (
user: IUser | null,
teamId: number | null
): boolean => {
if (!user) return false;
return (
isGlobalAdmin(user) ||
isGlobalMaintainer(user) ||
isTeamAdmin(user, teamId) ||
isTeamMaintainer(user, teamId)
);
};
export default {
isSandboxMode,
isFreeTier,
@@ -219,4 +235,5 @@ export default {
isOnlyObserver,
isObserverPlus,
isNoAccess,
canWriteSoftware,
};