Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3744,6 +3744,9 @@
"inventory_item_list": {
"inventory_items": "Inventory",
"alert_info": "You can create or archive items from the list. To edit an item, click pencil icon.",
"image_preview_alt": "Inventory item preview",
"image_preview_action_alt": "Open image preview for {itemName} in a new tab",
"image_preview_unavailable": "Image preview unavailable",
"code_column_label": "Code",
"name_column_label": "Name",
"archive_button": "Archive",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from "react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import T from "i18n-react/dist/i18n-react";
import { InventoryImagePreviewCell } from "../inventory-list-page";

describe("InventoryListPage", () => {
describe("InventoryImagePreviewCell", () => {
const imageUrl = "https://example.com/image.png";
const previewAlt = T.translate("inventory_item_list.image_preview_alt");
const previewActionAlt = T.translate(
"inventory_item_list.image_preview_action_alt"
);
const previewUnavailable = T.translate(
"inventory_item_list.image_preview_unavailable"
);

beforeEach(() => {
jest.restoreAllMocks();
});

test("opens preview on hover/focus and closes on leave/blur", async () => {
const user = userEvent.setup();

render(<InventoryImagePreviewCell imageUrl={imageUrl} />);

const button = screen.getByRole("button");

await user.hover(button);
await waitFor(() => {
expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
});

await user.unhover(button);
await waitFor(() => {
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});

button.focus();
await waitFor(() => {
expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
});

button.blur();
await waitFor(() => {
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
});

test("does not render when imageUrl is null or empty", () => {
const { rerender } = render(
<InventoryImagePreviewCell imageUrl={null} />
);

expect(screen.queryByRole("button")).not.toBeInTheDocument();

rerender(<InventoryImagePreviewCell imageUrl="" />);

expect(screen.queryByRole("button")).not.toBeInTheDocument();
});

test("sets explicit aria-label for accessibility", () => {
render(<InventoryImagePreviewCell imageUrl={imageUrl} />);

expect(
screen.getByRole("button", { name: previewActionAlt })
).toBeInTheDocument();
});

test("shows fallback text if image fails to load", async () => {
const user = userEvent.setup();

render(<InventoryImagePreviewCell imageUrl={imageUrl} />);

const button = screen.getByRole("button");
await user.hover(button);

const image = screen.getByAltText(previewAlt);
fireEvent.error(image);

expect(screen.getByText(previewUnavailable)).toBeInTheDocument();
});

test("keeps click action while preview is visible", async () => {
const openSpy = jest.spyOn(window, "open").mockImplementation(() => null);
const user = userEvent.setup();

render(<InventoryImagePreviewCell imageUrl={imageUrl} />);

const button = screen.getByRole("button");
await user.hover(button);

await waitFor(() => {
expect(screen.getByAltText(previewAlt)).toBeInTheDocument();
});

await user.click(button);

expect(openSpy).toHaveBeenCalledWith(
imageUrl,
"_blank",
"noopener,noreferrer"
);
});
});
});
153 changes: 134 additions & 19 deletions src/pages/sponsors-global/inventory/inventory-list-page.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Copyright 2024 OpenStack Foundation
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
Expand All @@ -19,7 +19,9 @@ import {
FormControlLabel,
FormGroup,
Grid2,
TextField
Popover,
TextField,
Typography
} from "@mui/material";
import Box from "@mui/material/Box";
import AddIcon from "@mui/icons-material/Add";
Expand All @@ -44,6 +46,116 @@ import MuiTable from "../../../components/mui/table/mui-table";
import SponsorInventoryDialog from "../form-templates/sponsor-inventory-popup";
import { DEFAULT_CURRENT_PAGE } from "../../../utils/constants";

const PREVIEW_BOX_SIZE = 220;
const PREVIEW_MARGIN_BOTTOM = 1;
const PREVIEW_TRANSITION_DURATION = { enter: 120, exit: 90 };
const PREVIEW_POINTER_EVENTS = "none";

export const InventoryImagePreviewCell = React.memo(
({ imageUrl, itemName }) => {
const [anchorEl, setAnchorEl] = useState(null);
const [imageLoadError, setImageLoadError] = useState(false);

const previewActionLabel = itemName
? T.translate("inventory_item_list.image_preview_action_alt", {
itemName
})
: T.translate("inventory_item_list.image_preview_action_alt");

const previewImgAlt = itemName
? T.translate("inventory_item_list.image_preview_alt", { itemName })
: T.translate("inventory_item_list.image_preview_alt");

if (!imageUrl) return null;

const isOpen = Boolean(anchorEl);

const openPreview = (target) => {
setAnchorEl(target);
setImageLoadError(false);
};

const closePreview = () => setAnchorEl(null);

return (
<>
<IconButton
size="small"
aria-label={previewActionLabel}
onMouseEnter={(event) => openPreview(event.currentTarget)}
onMouseLeave={closePreview}
onFocus={(event) => openPreview(event.currentTarget)}
onBlur={closePreview}
onClick={() => window.open(imageUrl, "_blank", "noopener,noreferrer")}
>
<ImageIcon fontSize="small" />
</IconButton>

<Popover
open={isOpen}
anchorEl={anchorEl}
onClose={closePreview}
disableRestoreFocus
transitionDuration={PREVIEW_TRANSITION_DURATION}
sx={{
pointerEvents: PREVIEW_POINTER_EVENTS
}}
anchorOrigin={{
vertical: "top",
horizontal: "center"
}}
transformOrigin={{
vertical: "bottom",
horizontal: "center"
}}
slotProps={{
paper: {
sx: {
p: 1,
mb: PREVIEW_MARGIN_BOTTOM,
pointerEvents: PREVIEW_POINTER_EVENTS
}
}
}}
>
<Box
sx={{
width: PREVIEW_BOX_SIZE,
height: PREVIEW_BOX_SIZE,
display: "flex",
alignItems: "center",
justifyContent: "center",
overflow: "hidden"
}}
>
{!imageLoadError ? (
<Box
component="img"
src={imageUrl}
alt={previewImgAlt}
loading="lazy"
onError={() => setImageLoadError(true)}
sx={{
width: "100%",
height: "100%",
display: "block",
objectFit: "contain"
}}
/>
) : (
<Typography variant="body2" color="text.secondary" align="center">
{T.translate("inventory_item_list.image_preview_unavailable")}
</Typography>
)}
</Box>
</Popover>
</>
);
}
);

InventoryImagePreviewCell.displayName = "InventoryImagePreviewCell";

const InventoryListPage = ({
inventoryItems,
currentInventoryItem,
Expand Down Expand Up @@ -74,8 +186,15 @@ const InventoryListPage = ({
};

useEffect(() => {
getInventoryItems(term, 1, perPage, order, orderDir, hideArchived);
}, []);
getInventoryItems(
term,
DEFAULT_CURRENT_PAGE,
perPage,
order,
orderDir,
hideArchived
);
}, [getInventoryItems]);

const handlePageChange = (page) => {
getInventoryItems(term, page, perPage, order, orderDir, hideArchived);
Expand Down Expand Up @@ -167,21 +286,17 @@ const InventoryListPage = ({
header: "",
width: 40,
align: "center",
render: (row) =>
row.images.length > 0 ? (
<IconButton size="small">
<ImageIcon
fontSize="small"
onClick={() =>
window.open(
row.images[0].file_url,
"_blank",
"noopener,noreferrer"
)
}
/>
</IconButton>
) : null
render: (row) => {
const hasImages = Array.isArray(row.images) && row.images.length > 0;
const imageUrl = row.images?.[0]?.file_url;
const itemName = row.name;

if (!hasImages || !imageUrl) return null;

return (
<InventoryImagePreviewCell imageUrl={imageUrl} itemName={itemName} />
);
}
}
];

Expand Down
Loading