diff --git a/frontend/components/CustomLink/CustomLink.tsx b/frontend/components/CustomLink/CustomLink.tsx index 164fb5a1cd..5369abf00d 100644 --- a/frontend/components/CustomLink/CustomLink.tsx +++ b/frontend/components/CustomLink/CustomLink.tsx @@ -5,7 +5,7 @@ import classnames from "classnames"; import { Colors } from "styles/var/colors"; interface ICustomLinkProps { - url: string; + url?: string; text: string; className?: string; /** open the link in a new tab @@ -22,6 +22,7 @@ interface ICustomLinkProps { * @default "default" */ variant?: "tooltip-link" | "banner-link" | "flash-message-link" | "default"; + customClickHandler?: (e: React.MouseEvent) => void; } const baseClass = "custom-link"; @@ -34,6 +35,7 @@ const CustomLink = ({ multiline = false, disableKeyboardNavigation = false, variant = "default", + customClickHandler, }: ICustomLinkProps): JSX.Element => { const getIconColor = (): Colors => { switch (variant) { @@ -56,6 +58,27 @@ const CustomLink = ({ // e.g. cell/row handlers with a tooltip that has a custom link inside const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); + + // prevent navigation when we’re handling it ourselves + // e.g. designed underline links opening modals + if (customClickHandler) { + e.preventDefault(); + customClickHandler(e); + } + }; + + // Handle "Enter" key presses for accessibility when a custom click handler is provided + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!customClickHandler) { + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + // Reuse the same logic as click + customClickHandler((e as unknown) as React.MouseEvent); + } }; const target = newTab ? "_blank" : ""; @@ -98,6 +121,7 @@ const CustomLink = ({ className={customLinkClass} tabIndex={disableKeyboardNavigation ? -1 : 0} onClick={handleClick} + onKeyDown={handleKeyDown} > {content} diff --git a/frontend/components/DataError/DataError.stories.tsx b/frontend/components/DataError/DataError.stories.tsx index a1dfe1aa36..ff606c52a7 100644 --- a/frontend/components/DataError/DataError.stories.tsx +++ b/frontend/components/DataError/DataError.stories.tsx @@ -19,3 +19,11 @@ export const WithChildren: Story = { children:

this is custom JSX

, }, }; + +export const SingleCustomLine: Story = { + args: { + singleCustomLine: true, + description: + "We can't retrieve data from Apple right now. Please try again later.", + }, +}; diff --git a/frontend/components/DataError/DataError.tsx b/frontend/components/DataError/DataError.tsx index 58bd049063..7bd3db5b4f 100644 --- a/frontend/components/DataError/DataError.tsx +++ b/frontend/components/DataError/DataError.tsx @@ -25,6 +25,8 @@ interface IDataErrorProps { className?: string; /** Flag to use the updated DataError design */ useNew?: boolean; + /** Overrides something gone wrong line with description text to condense error onto one line */ + singleCustomLine?: boolean; } const DEFAULT_DESCRIPTION = "Refresh the page or log in again."; @@ -36,9 +38,30 @@ const DataError = ({ verticalPaddingSize, className, useNew = false, + singleCustomLine = false, }: IDataErrorProps): JSX.Element => { const classes = classnames(baseClass, className); + if (singleCustomLine) { + return ( +
+
+
+ + + {description} + +
+
+
+ ); + } + if (useNew) { return (
diff --git a/frontend/components/DataError/_styles.scss b/frontend/components/DataError/_styles.scss index 703bc65e09..dfd00814a2 100644 --- a/frontend/components/DataError/_styles.scss +++ b/frontend/components/DataError/_styles.scss @@ -46,6 +46,15 @@ gap: $pad-small; margin-bottom: $pad-small; } + + &__header-single-line { + display: flex; + color: $core-fleet-black; + font-size: $x-small; + gap: $pad-small; + padding: $pad-large; + } + &__data { display: block; color: $core-fleet-black; diff --git a/frontend/components/List/List.stories.tsx b/frontend/components/List/List.stories.tsx new file mode 100644 index 0000000000..d0e4d9e969 --- /dev/null +++ b/frontend/components/List/List.stories.tsx @@ -0,0 +1,90 @@ +// components/List.stories.tsx +import type { Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import List, { IListProps } from "./List"; + +interface IStoryItem { + id: number; + name: string; + detail?: string; +} + +const meta: Meta> = { + title: "Components/List", + component: List, + args: { + data: [ + { id: 1, name: "First item", detail: "Some extra details" }, + { id: 2, name: "Second item", detail: "Other details" }, + { id: 3, name: "Third item" }, + ], + renderItemRow: (item: IStoryItem) => ( +
+ {item.name} + {item.detail && {item.detail}} +
+ ), + } as Partial>, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithHeading: Story = { + args: { + heading:
Items heading
, + }, +}; + +export const WithHelpText: Story = { + args: { + helpText: "This is some contextual help text below the list.", + }, +}; + +export const ClickableRows: Story = { + args: { + onClickRow: (item: IStoryItem) => { + // eslint-disable-next-line no-console + console.log("Row clicked:", item); + }, + }, +}; + +export const Loading: Story = { + args: { + isLoading: true, + }, +}; + +export const CustomIdKey: Story = { + render: () => { + interface CustomItem { + customId: string; + name: string; + } + + const data: CustomItem[] = [ + { customId: "alpha", name: "Alpha" }, + { customId: "beta", name: "Beta" }, + ]; + + return ( + + data={data} + idKey="customId" + renderItemRow={(item) => {item.name}} + /> + ); + }, +}; diff --git a/frontend/components/List/List.tests.tsx b/frontend/components/List/List.tests.tsx new file mode 100644 index 0000000000..afe65046d4 --- /dev/null +++ b/frontend/components/List/List.tests.tsx @@ -0,0 +1,113 @@ +// components/__tests__/List.test.tsx +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; + +import List, { IListProps } from "./List"; + +interface ITestItem { + id: number | string; + name: string; +} + +const renderList = (props: Partial> = {}) => { + const defaultProps: IListProps = { + data: [], + renderItemRow: (item) => {item.name}, + ...props, + }; + + return render(); +}; + +describe("List", () => { + it("renders heading when provided", () => { + const headingText = "My heading"; + renderList({ heading:
{headingText}
}); + + expect(screen.getByText(headingText)).toBeInTheDocument(); + }); + + it("renders help text when provided", () => { + const helpText = "Some help text"; + renderList({ helpText }); + + expect(screen.getByText(helpText)).toBeInTheDocument(); + }); + + it("renders list items with data", () => { + const data: ITestItem[] = [ + { id: 1, name: "Row 1" }, + { id: 2, name: "Row 2" }, + ]; + + renderList({ data }); + + expect(screen.getByText("Row 1")).toBeInTheDocument(); + expect(screen.getByText("Row 2")).toBeInTheDocument(); + }); + + it("shows loading overlay when isLoading is true", () => { + renderList({ isLoading: true }); + + const overlay = document.querySelector(".loading-overlay"); + expect(overlay).toBeInTheDocument(); + }); + + it("calls onClickRow when row is clicked", () => { + const handleClick = jest.fn(); + const data: ITestItem[] = [{ id: 1, name: "Clickable row" }]; + + renderList({ + data, + onClickRow: handleClick, + }); + + fireEvent.click(screen.getByText("Clickable row")); + expect(handleClick).toHaveBeenCalledTimes(1); + expect(handleClick).toHaveBeenCalledWith(data[0]); + }); + + it("applies clickable class when onClickRow is provided", () => { + const data: ITestItem[] = [{ id: 1, name: "Clickable row" }]; + + const { container } = renderList({ + data, + onClickRow: jest.fn(), + }); + + const row = container.querySelector(".list__row"); + expect(row).toHaveClass("list__row--clickable"); + }); + + it("uses custom idKey when provided", () => { + interface ICustomItem { + customId: string; + name: string; + } + + const data: ICustomItem[] = [ + { customId: "alpha", name: "Alpha" }, + { customId: "beta", name: "Beta" }, + ]; + + const { container } = render( + + data={data} + idKey="customId" + renderItemRow={(item) => {item.name}} + /> + ); + + const listItems = container.querySelectorAll("li.list__row"); + // first li is potentially the header; filter by text content to be safe + const alphaLi = Array.from(listItems).find((li) => + li.textContent?.includes("Alpha") + ); + const betaLi = Array.from(listItems).find((li) => + li.textContent?.includes("Beta") + ); + + expect(alphaLi?.getAttribute("key")).toBeNull(); // React doesn't expose "key" to the DOM + expect(betaLi).toBeInTheDocument(); + }); +}); diff --git a/frontend/components/List/List.tsx b/frontend/components/List/List.tsx new file mode 100644 index 0000000000..4433ffa5b0 --- /dev/null +++ b/frontend/components/List/List.tsx @@ -0,0 +1,70 @@ +import React, { ReactElement } from "react"; +import classnames from "classnames"; + +const baseClass = "list"; + +type WithIdKey = Record; + +export interface IListProps { + data: TItem[]; + isLoading?: boolean; + idKey?: TKey; + renderItemRow?: (item: TItem) => ReactElement | false | null | undefined; + onClickRow?: (item: TItem) => void; + isRowClickable?: (item: TItem) => boolean; + heading?: JSX.Element; + helpText?: React.ReactNode; +} + +function List, TKey extends string = "id">({ + data, + isLoading = false, + idKey: _idKey, + renderItemRow, + onClickRow, + isRowClickable, + heading, + helpText, +}: IListProps): JSX.Element { + const idKey = (_idKey ?? "id") as TKey; + + return ( +
+ {isLoading &&
} +
    + {heading && ( +
  • + {heading} +
  • + )} + {data.map((item) => { + if (!item) return null; + + const clickable = isRowClickable?.(item) ?? !!onClickRow; + + const rowClasses = classnames(`${baseClass}__row`, { + [`${baseClass}__row--clickable`]: clickable, + }); + + return ( + // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions +
  • { + if (clickable) { + onClickRow?.(item); + } + }} + > + {renderItemRow?.(item) ?? null} +
  • + ); + })} +
+ {helpText &&
{helpText}
} +
+ ); +} + +export default List; diff --git a/frontend/components/List/_styles.scss b/frontend/components/List/_styles.scss new file mode 100644 index 0000000000..609b544ee9 --- /dev/null +++ b/frontend/components/List/_styles.scss @@ -0,0 +1,73 @@ +.list { + display: flex; + flex-direction: column; + gap: $pad-xsmall; // For help text + font-size: $x-small; + + &__header { + padding: $pad-medium $pad-large; + border-bottom: 1px solid $ui-fleet-black-10; + background-color: $ui-off-white; + min-height: 24px; // Include padding: 40px; + } + + .loading-overlay { + display: flex; + flex-grow: 1; + position: absolute; + inset: 0; + background-color: rgba(255, 255, 255, 0.8); + z-index: 1; + + .loading-spinner { + position: sticky; + top: 0; + left: 0; + } + } + + &__list { + display: flex; + flex-direction: column; + align-items: flex-start; + align-self: stretch; + border-radius: 4px; + border: 1px solid $ui-fleet-black-10; + padding-left: 0; // negate ul padding + margin: 0; + } + + &__row { + display: flex; + max-width: 100%; + padding: 8px 12px; + justify-content: space-between; + align-items: center; + align-self: stretch; + border-bottom: 1px solid $ui-fleet-black-10; + gap: 20px; + + // Generic hover (no pointer) – safe for tooltips + &:hover { + background: $ui-off-white; + } + + // Only clickable rows show pointer + extra hover affordances + &.list__row--clickable:hover { + cursor: pointer; + + label { + cursor: pointer; + } + } + + &:first-child { + border-radius: 4px 4px 0 0; + } + + &:last-child { + border-radius: 0 0 4px 4px; + border-bottom: none; + } + } +} diff --git a/frontend/components/List/index.ts b/frontend/components/List/index.ts new file mode 100644 index 0000000000..8e34bd3970 --- /dev/null +++ b/frontend/components/List/index.ts @@ -0,0 +1 @@ +export { default } from "./List"; diff --git a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx index bbe6a62e7d..b593e1010e 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx +++ b/frontend/pages/hosts/details/cards/HostSummary/HostSummary.tsx @@ -207,6 +207,7 @@ const HostSummary = ({ hostSettings && hostSettings.length > 0 && ( - + /> ); }; diff --git a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss index 29cca74e81..c72f408dda 100644 --- a/frontend/pages/hosts/details/cards/HostSummary/_styles.scss +++ b/frontend/pages/hosts/details/cards/HostSummary/_styles.scss @@ -8,6 +8,13 @@ align-items: baseline; } + &__os-settings { + dd { + padding: 6px; // See underline under link and border with keyboard nav + margin: -6px; // See underline under link and border with keyboard nav + } + } + // Properly vertically aligns host issue icon .host-issue { display: flex;