diff --git a/.storybook/main.ts b/.storybook/main.ts index 700f989c69..c6d8e21e66 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -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: [ diff --git a/.storybook/preview.js b/.storybook/preview.js index 46c7643049..8bbc054fa6 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,5 +1,6 @@ import React, { useEffect } from "react"; import "../frontend/index.scss"; +import "./preview.scss"; export const globalTypes = { theme: { diff --git a/.storybook/preview.scss b/.storybook/preview.scss new file mode 100644 index 0000000000..55ab697514 --- /dev/null +++ b/.storybook/preview.scss @@ -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; +} diff --git a/frontend/components/Icon/Icon.stories.tsx b/frontend/components/Icon/Icon.stories.tsx index 55eb12c20e..1f9083b97f 100644 --- a/frontend/components/Icon/Icon.stories.tsx +++ b/frontend/components/Icon/Icon.stories.tsx @@ -1,11 +1,19 @@ import { Meta, StoryObj } from "@storybook/react"; +import { ICON_MAP } from "components/icons"; + import Icon from "."; const meta: Meta = { title: "Components/Icon", component: Icon, args: { name: "plus" }, + argTypes: { + name: { + control: { type: "select" }, + options: Object.keys(ICON_MAP).sort(), + }, + }, }; export default meta; diff --git a/frontend/components/TooltipTruncatedText/TooltipTruncatedText.tsx b/frontend/components/TooltipTruncatedText/TooltipTruncatedText.tsx index 77d46ee860..cd61e9239b 100644 --- a/frontend/components/TooltipTruncatedText/TooltipTruncatedText.tsx +++ b/frontend/components/TooltipTruncatedText/TooltipTruncatedText.tsx @@ -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 ( = { + title: "Components/TruncatedTextList", + component: TruncatedTextList, + args: { + items: [ + "Engineering", + "Product", + "Quality Assurance", + "Marketing", + "Sales", + "Support", + "Operations", + ], + }, +}; + +export default meta; + +type Story = StoryObj; + +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)], +}; diff --git a/frontend/components/TruncatedTextList/TruncatedTextList.tsx b/frontend/components/TruncatedTextList/TruncatedTextList.tsx new file mode 100644 index 0000000000..1f473d784b --- /dev/null +++ b/frontend/components/TruncatedTextList/TruncatedTextList.tsx @@ -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) => ( + + {name} + {i < list.length - 1 &&
} +
+ ))} + +); + +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 = ( + <> + + {truncatedFirst} + + {separator} + + +{items.length - 1} more + + + ); + + const standardContent = ( + <> + {visible.join(separator)} + {hidden.length > 0 && ( + <> + {visible.length > 0 ? separator : ""} + + +{hidden.length} more + + + )} + + ); + + 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 ( + + ); + } + + return ( + + {content} + + ); +}; + +const TruncatedTextList = ({ + items, + separator = ", ", + tooltipPosition = "top", + truncatedFirstMaxChars = 30, + onClick, + className, +}: ITruncatedTextListProps) => { + const containerRef = useRef(null); + const itemRefs = useRef<(HTMLSpanElement | null)[]>([]); + const moreRef = useRef(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 ``) 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 ( +
+ {/* Hidden measurement layer — same font/size as the visible row */} +
+ {items.map((item, i) => ( + { + itemRefs.current[i] = el; + }} + > + {i > 0 ? separator : ""} + {item} + + ))} + + {separator}+{items.length} more + +
+ + {/* Visible row */} + {renderVisibleRow({ + visibleCount, + visible, + hidden, + items, + separator, + tooltipPosition, + truncatedFirstMaxChars, + onClick, + })} +
+ ); +}; + +export default TruncatedTextList; diff --git a/frontend/components/TruncatedTextList/_styles.scss b/frontend/components/TruncatedTextList/_styles.scss new file mode 100644 index 0000000000..9dfb69ccf2 --- /dev/null +++ b/frontend/components/TruncatedTextList/_styles.scss @@ -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; + } +} diff --git a/frontend/components/TruncatedTextList/index.ts b/frontend/components/TruncatedTextList/index.ts new file mode 100644 index 0000000000..1236f617ea --- /dev/null +++ b/frontend/components/TruncatedTextList/index.ts @@ -0,0 +1 @@ +export { default } from "./TruncatedTextList"; diff --git a/frontend/components/buttons/Button/Button.tsx b/frontend/components/buttons/Button/Button.tsx index 8f655b07fe..8e6e197a87 100644 --- a/frontend/components/buttons/Button/Button.tsx +++ b/frontend/components/buttons/Button/Button.tsx @@ -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" diff --git a/frontend/components/icons/Pin.tsx b/frontend/components/icons/Pin.tsx new file mode 100644 index 0000000000..b1c4e94c55 --- /dev/null +++ b/frontend/components/icons/Pin.tsx @@ -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 ( + + + + ); +}; + +export default Pin; diff --git a/frontend/components/icons/Tag.tsx b/frontend/components/icons/Tag.tsx new file mode 100644 index 0000000000..84ee29a9f7 --- /dev/null +++ b/frontend/components/icons/Tag.tsx @@ -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 ( + + + + ); +}; + +export default Tag; diff --git a/frontend/components/icons/index.ts b/frontend/components/icons/index.ts index a9644e2583..52d77e239a 100644 --- a/frontend/components/icons/index.ts +++ b/frontend/components/icons/index.ts @@ -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; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.stories.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.stories.tsx new file mode 100644 index 0000000000..df653e0815 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.stories.tsx @@ -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; +const CustomQueryClientProvider: React.FC = 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 = { + 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) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +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: [], + }, +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx new file mode 100644 index 0000000000..9bcb59ee9f --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tests.tsx @@ -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 = {}) => + renderWithSetup(); + +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 `` and the names as + // sibling text nodes separated by `
`. 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 `
` 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. + }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx new file mode 100644 index 0000000000..2d5f55f556 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordion.tsx @@ -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 = { + 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 Actions > Versions and pin this version to + rollback. + + ); + + const sortedLabelNames = (labels ?? []) + .map((l) => l.name) + .sort((a, b) => a.localeCompare(b)); + + const renderLabelCountTooltip = () => ( +
+ {LABEL_KIND_HEADING[labelKind]}: +
+ {sortedLabelNames.map((name, i) => ( + + {name} + {i < sortedLabelNames.length - 1 &&
} +
+ ))} +
+ ); + + const handleBadgeClick = (handler?: () => void) => ( + e: React.MouseEvent | React.KeyboardEvent + ) => { + e.stopPropagation(); + handler?.(); + }; + + const renderHeaderBadges = () => { + if (!isActive) return null; + + return ( +
+ {badgeState === "latest" && ( + + )} + {badgeState === "pinned" && ( + + )} + {badgeState === "majorVersion" && ( + + )} + {hasLabelScope && ( + + {canEditSoftware ? ( + + ) : ( + + + {labelCount} + + )} + + )} + {showAllHostsBadge && ( + + + {ALL_HOSTS_LABEL} + + )} +
+ ); + }; + + const renderStatusCount = ( + iconName: "success" | "pending-outline" | "error", + count: number, + label: string, + iconTooltip: React.ReactNode, + path: string, + trailing?: React.ReactNode + ) => ( +
+ + + + + {trailing} +
+ ); + + const statusCountsTooltip = ( + <> + Latest status from policy automation, +
+ setup experience, or manual install. + + ); + + const installedIconTooltip = ( + <> + Software is installed on these hosts +
+ (install script finished with exit code 0). +
+ Currently, if the software is uninstalled, +
+ the "Installed" status won't be updated. + + ); + + const pendingIconTooltip = ( + <> + Fleet is installing/uninstalling or will +
+ do so when the host comes online. + + ); + + const failedIconTooltip = ( + <> + These hosts failed to install/uninstall +
+ software. Click on a host to view error(s). + + ); + + const renderLabelsBlock = () => { + if (!hasLabelScope) return null; + + return ( +
+

+ {LABEL_KIND_HEADING[labelKind]} +

+ +
+ ); + }; + + const renderHashBlock = () => { + if (!hashSha256) return null; + + return ( +
+

Hash

+
+ +
+ {copyMessage && ( + + {copyMessage} + + )} + +
+
+
+ ); + }; + + const renderTrashButtonBody = (disabled: boolean) => ( + + ); + + // 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 ? ( + + renderTrashButtonBody(!!gitOpsDisabled) + } + /> + ) : ( + renderTrashButtonBody(false) + ); + + // `
` rather than ` + )} + {canEditSoftware && renderTrashButton()} +
+ + )} + + ); +}; + +export default LibraryItemAccordion; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.stories.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.stories.tsx new file mode 100644 index 0000000000..dd7fe3dde9 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.stories.tsx @@ -0,0 +1,623 @@ +/** + * Multi-row list stories. Single-row prop variants live in + * `LibraryItemAccordion.stories.tsx`. + * + * Two pieces of indirection: + * - `` 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 `` 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; +const CustomQueryClientProvider: React.FC = 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 ``: 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) => ( + +); + +const LibraryItemAccordionListDemo = ({ + labelKind, + labelCount, + badgeState, + children, +}: ILibraryItemAccordionListDemoProps) => { + const labels = generateLabels(labelCount); + const rows = flattenFragments(children); + return ( + + {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, + { + labels, + labelKind, + ...badgeProps, + key: child.key ?? i, + } + ); + })} + + ); +}; + +const meta: Meta = { + 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) => ( + + + + ), + ], +}; + +export default meta; + +type Story = StoryObj; + +// 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 +) => ( + {rows} +); + +/** 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, + <> + + + + + ), +}; + +// 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, + + ), +}; + +/** 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, + <> + + + + + ), +}; + +/** 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, + <> + + + + + ), +}; + +/** **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, + <> + + + + + + ), +}; + +/** **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, + <> + + + + + + ), +}; + +/** 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, + + ), +}; + +/** 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, + <> + + + + + ), +}; + +/** **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, + <> + + + + ), +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.tsx new file mode 100644 index 0000000000..4cc094504e --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/LibraryItemAccordionList.tsx @@ -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
{children}
; +}; + +export default LibraryItemAccordionList; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/_styles.scss new file mode 100644 index 0000000000..a654aedf22 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/_styles.scss @@ -0,0 +1,289 @@ +.library-item-accordion { + display: flex; + flex-direction: column; + + &__header { + // Explicit border-box: this is a `
` (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; + } +} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.tests.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.tests.ts new file mode 100644 index 0000000000..e02d2a54ee --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.tests.ts @@ -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 }); + }); +}); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.ts new file mode 100644 index 0000000000..3369877f6e --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/helpers.ts @@ -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" }; +}; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/index.ts b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/index.ts new file mode 100644 index 0000000000..1827031b72 --- /dev/null +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/LibraryItemAccordion/index.ts @@ -0,0 +1 @@ +export { default } from "./LibraryItemAccordion"; diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx index 51fd69edd8..f13ef1a7fb 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tests.tsx @@ -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(); - // 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(); - }); }); diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx index f0410f4f88..fbaa1a3aa4 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/InstallerDetailsWidget.tsx @@ -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(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 >
@@ -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 = {version}; - - if (isFma) { - versionInfo = ( - - You can change the version in Actions > Edit{" "} - software. - - } - > - - {version} {isLatestFmaVersion ? "(latest)" : ""} - - - ); - } - if (installerType === "app-store") { - versionInfo = ( - Updated every hour.}> - {version} - + 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 Latest; + return ( + ); } if (!version) { - versionInfo = ( + return ( Fleet couldn't read the version from {softwareName}. @@ -180,86 +168,74 @@ const InstallerDetailsWidget = ({ ); } - if (androidPlayStoreId) { - versionInfo = ( - + if (isFma) { + return ( + + You can change the version in Actions > Edit{" "} + software. + + } + > + + {version} {isLatestFmaVersion ? "(latest)" : ""} + + ); } - return <> • {versionInfo}; - }; - - const renderTimeStamp = () => - addedTimestamp ? ( - <> - {" "} - •{" "} + if (installerType === "app-store") { + return ( Updated every hour.} > - {addedFromNow(addedTimestamp)} + {version} - - ) : ( - "" - ); + ); + } - const renderSha256 = () => { - return sha256 ? ( - <> - {" "} - •{" "} - - The software's SHA-256 hash.} - position="top" - showArrow - underline={false} - > - {sha256.slice(0, 7)}… - -
- -
-
- {copyMessage && ( -
{`${copyMessage} `}
- )} -
-
- - ) : ( - "" - ); + return {version}; }; - return ( - <> - {renderInstallerDisplayText(installerType, isFma, androidPlayStoreId)} - {renderVersionInfo()} - {renderTimeStamp()} - {renderSha256()} - - ); + const renderTimeStampChip = (): React.ReactNode => + addedTimestamp ? ( + + {addedFromNow(addedTimestamp)} + + ) : 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 + + {i > 0 && <> • } + {part} + + )); }; return (
{renderIcon()}
- +
{renderDetails()}
diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/_styles.scss b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/_styles.scss index 946f1ddf9a..b9490b808a 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/_styles.scss +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/InstallerDetailsWidget/_styles.scss @@ -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; - } } diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx index 94fca8a18f..e2632a9f56 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareInstallerCard/SoftwareInstallerCard.tsx @@ -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} diff --git a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx index fcc03059b0..39b10f7384 100644 --- a/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx +++ b/frontend/pages/SoftwarePage/SoftwareTitleDetailsPage/SoftwareTitleDetailsPage.tsx @@ -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 ( + + setShowLibraryEditModal(true)} + onLabelsClick={() => setShowLibraryEditModal(true)} + /> + + ); + } + + if (!pkg) return null; + const { labels, kind } = pickLabels(pkg); + return ( + + setShowLibraryEditModal(true)} + onLabelsClick={() => setShowLibraryEditModal(true)} + /> + setShowLibraryEditModal(true)} + onLabelsClick={() => setShowLibraryEditModal(true)} + /> + + ); + }; + + const renderLibraryEditModal = (title: ISoftwareTitleDetails) => { + if (!showLibraryEditModal || !installerResult) return null; + const { meta } = installerResult; + return ( + 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 ; @@ -205,7 +385,9 @@ const SoftwareTitleDetailsPage = ({ return ( <> {renderSoftwareSummaryCard(softwareTitle)} + {renderLibraryItemAccordionPreview(softwareTitle)} {renderSoftwareInstallerCard(softwareTitle)} + {renderLibraryEditModal(softwareTitle)} ); } diff --git a/frontend/test/storybook-utils.tsx b/frontend/test/storybook-utils.tsx new file mode 100644 index 0000000000..edde7b629d --- /dev/null +++ b/frontend/test/storybook-utils.tsx @@ -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) => ( +
+ +
+); + +export default withFrame; diff --git a/frontend/utilities/permissions/permissions.tests.ts b/frontend/utilities/permissions/permissions.tests.ts index 5bb0616a5a..dd3f66e577 100644 --- a/frontend/utilities/permissions/permissions.tests.ts +++ b/frontend/utilities/permissions/permissions.tests.ts @@ -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); + }); +}); diff --git a/frontend/utilities/permissions/permissions.ts b/frontend/utilities/permissions/permissions.ts index aac19f794d..29363ed0df 100644 --- a/frontend/utilities/permissions/permissions.ts +++ b/frontend/utilities/permissions/permissions.ts @@ -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, };