FE: MDM Status Modal component updates only (#42496)

This commit is contained in:
RachelElysia
2026-03-26 16:30:53 -04:00
committed by GitHub
parent a6c77efd4b
commit 1fa2e4fe09
12 changed files with 426 additions and 8 deletions
+25 -1
View File
@@ -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 were 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<HTMLAnchorElement>) => {
if (!customClickHandler) {
return;
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
// Reuse the same logic as click
customClickHandler((e as unknown) as React.MouseEvent<HTMLAnchorElement>);
}
};
const target = newTab ? "_blank" : "";
@@ -98,6 +121,7 @@ const CustomLink = ({
className={customLinkClass}
tabIndex={disableKeyboardNavigation ? -1 : 0}
onClick={handleClick}
onKeyDown={handleKeyDown}
>
{content}
</a>
@@ -19,3 +19,11 @@ export const WithChildren: Story = {
children: <p>this is custom JSX</p>,
},
};
export const SingleCustomLine: Story = {
args: {
singleCustomLine: true,
description:
"We can't retrieve data from Apple right now. Please try again later.",
},
};
@@ -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 (
<div className={classes}>
<div
className={`${baseClass}__inner ${
verticalPaddingSize &&
`${baseClass}__vertical-${verticalPaddingSize}`
}`}
>
<div className="info">
<span className="info__header-single-line">
<Icon name="error" />
{description}
</span>
</div>
</div>
</div>
);
}
if (useNew) {
return (
<div className={classes}>
@@ -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;
+90
View File
@@ -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<IListProps<IStoryItem>> = {
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) => (
<div
style={{
display: "flex",
justifyContent: "space-between",
width: "100%",
}}
>
<span>{item.name}</span>
{item.detail && <span>{item.detail}</span>}
</div>
),
} as Partial<IListProps<IStoryItem>>,
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithHeading: Story = {
args: {
heading: <div>Items heading</div>,
},
};
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 (
<List<CustomItem, "customId">
data={data}
idKey="customId"
renderItemRow={(item) => <span>{item.name}</span>}
/>
);
},
};
+113
View File
@@ -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<IListProps<ITestItem>> = {}) => {
const defaultProps: IListProps<ITestItem> = {
data: [],
renderItemRow: (item) => <span>{item.name}</span>,
...props,
};
return render(<List {...defaultProps} />);
};
describe("List", () => {
it("renders heading when provided", () => {
const headingText = "My heading";
renderList({ heading: <div>{headingText}</div> });
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(
<List<ICustomItem, "customId">
data={data}
idKey="customId"
renderItemRow={(item) => <span>{item.name}</span>}
/>
);
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();
});
});
+70
View File
@@ -0,0 +1,70 @@
import React, { ReactElement } from "react";
import classnames from "classnames";
const baseClass = "list";
type WithIdKey<TKey extends string = "id"> = Record<TKey, React.Key>;
export interface IListProps<TItem, TKey extends string = "id"> {
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<TItem extends WithIdKey<TKey>, TKey extends string = "id">({
data,
isLoading = false,
idKey: _idKey,
renderItemRow,
onClickRow,
isRowClickable,
heading,
helpText,
}: IListProps<TItem, TKey>): JSX.Element {
const idKey = (_idKey ?? "id") as TKey;
return (
<div className={baseClass}>
{isLoading && <div className="loading-overlay" />}
<ul className={`${baseClass}__list`}>
{heading && (
<li className={`${baseClass}__row ${baseClass}__header`}>
{heading}
</li>
)}
{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
<li
className={rowClasses}
key={item[idKey]}
onClick={() => {
if (clickable) {
onClickRow?.(item);
}
}}
>
{renderItemRow?.(item) ?? null}
</li>
);
})}
</ul>
{helpText && <div className="form-field__help-text">{helpText}</div>}
</div>
);
}
export default List;
+73
View File
@@ -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;
}
}
}
+1
View File
@@ -0,0 +1 @@
export { default } from "./List";
@@ -207,6 +207,7 @@ const HostSummary = ({
hostSettings &&
hostSettings.length > 0 && (
<DataSet
className={`${baseClass}__os-settings`}
title="OS settings"
value={
<OSSettingsIndicator
@@ -3,8 +3,8 @@ import React from "react";
import { IHostMdmProfile, MdmProfileStatus } from "interfaces/mdm";
import Icon from "components/Icon";
import Button from "components/buttons/Button";
import { IconNames } from "components/icons";
import CustomLink from "components/CustomLink";
const baseClass = "os-settings-indicator";
@@ -122,16 +122,15 @@ const OSSettingsIndicator = ({
const statusDisplayOption = STATUS_DISPLAY_OPTIONS[displayStatus];
// Using custom link for underline styling
return (
<span className={`${baseClass} info-flex__data`}>
<Icon name={statusDisplayOption.iconName} />
<Button
onClick={onClick}
variant="text-link"
<CustomLink
text={displayStatus}
customClickHandler={onClick}
className={`${baseClass}__button`}
>
{displayStatus}
</Button>
/>
</span>
);
};
@@ -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;