diff --git a/src/i18n/en.json b/src/i18n/en.json index 35546d87d..2a7b60c8a 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -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", diff --git a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js new file mode 100644 index 000000000..ad788a1c6 --- /dev/null +++ b/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js @@ -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(); + + 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( + + ); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + + rerender(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + test("sets explicit aria-label for accessibility", () => { + render(); + + expect( + screen.getByRole("button", { name: previewActionAlt }) + ).toBeInTheDocument(); + }); + + test("shows fallback text if image fails to load", async () => { + const user = userEvent.setup(); + + render(); + + 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(); + + 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" + ); + }); + }); +}); diff --git a/src/pages/sponsors-global/inventory/inventory-list-page.js b/src/pages/sponsors-global/inventory/inventory-list-page.js index 4d351229e..d212570bf 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -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 @@ -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"; @@ -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 ( + <> + openPreview(event.currentTarget)} + onMouseLeave={closePreview} + onFocus={(event) => openPreview(event.currentTarget)} + onBlur={closePreview} + onClick={() => window.open(imageUrl, "_blank", "noopener,noreferrer")} + > + + + + + + {!imageLoadError ? ( + setImageLoadError(true)} + sx={{ + width: "100%", + height: "100%", + display: "block", + objectFit: "contain" + }} + /> + ) : ( + + {T.translate("inventory_item_list.image_preview_unavailable")} + + )} + + + + ); + } +); + +InventoryImagePreviewCell.displayName = "InventoryImagePreviewCell"; + const InventoryListPage = ({ inventoryItems, currentInventoryItem, @@ -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); @@ -167,21 +286,17 @@ const InventoryListPage = ({ header: "", width: 40, align: "center", - render: (row) => - row.images.length > 0 ? ( - - - window.open( - row.images[0].file_url, - "_blank", - "noopener,noreferrer" - ) - } - /> - - ) : 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 ( + + ); + } } ];