FE: MDM Status Modal component updates only (#42496)
This commit is contained in:
@@ -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<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;
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+6
-7
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user