Fleet UI: APRF library item accordion component (#47944)
This commit is contained in:
@@ -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,5 +1,6 @@
|
||||
import React, { useEffect } from "react";
|
||||
import "../frontend/index.scss";
|
||||
import "./preview.scss";
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
+151
@@ -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: [],
|
||||
},
|
||||
};
|
||||
+398
@@ -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.
|
||||
});
|
||||
});
|
||||
});
|
||||
+544
@@ -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 > 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 "Installed" status won'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;
|
||||
+623
@@ -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"
|
||||
/>
|
||||
</>
|
||||
),
|
||||
};
|
||||
+18
@@ -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;
|
||||
+289
@@ -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;
|
||||
}
|
||||
}
|
||||
+84
@@ -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";
|
||||
-11
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+89
-113
@@ -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 > 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'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 > Edit</strong>{" "}
|
||||
software.
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{version} {isLatestFmaVersion ? "(latest)" : ""}
|
||||
</span>
|
||||
</TooltipWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return <> • {versionInfo}</>;
|
||||
};
|
||||
|
||||
const renderTimeStamp = () =>
|
||||
addedTimestamp ? (
|
||||
<>
|
||||
{" "}
|
||||
•{" "}
|
||||
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 ? (
|
||||
<>
|
||||
{" "}
|
||||
•{" "}
|
||||
<span className={`${baseClass}__sha256`}>
|
||||
<TooltipWrapper
|
||||
tipContent={<>The software's SHA-256 hash.</>}
|
||||
position="top"
|
||||
showArrow
|
||||
underline={false}
|
||||
>
|
||||
{sha256.slice(0, 7)}…
|
||||
</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 && <> • </>}
|
||||
{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>
|
||||
|
||||
+5
-22
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-2
@@ -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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user