diff --git a/src/actions/form-template-item-actions.js b/src/actions/form-template-item-actions.js index 710fdd911..53086a433 100644 --- a/src/actions/form-template-item-actions.js +++ b/src/actions/form-template-item-actions.js @@ -91,7 +91,7 @@ export const getFormTemplateItems = const params = { page, - fields: "id,code,name,is_archived,images,images.file_url", + fields: "id,code,name,is_archived,images,images.file_url,images.created", expand: "images", per_page: perPage, access_token: accessToken diff --git a/src/actions/inventory-item-actions.js b/src/actions/inventory-item-actions.js index 45814f39e..1b6277390 100644 --- a/src/actions/inventory-item-actions.js +++ b/src/actions/inventory-item-actions.js @@ -119,7 +119,7 @@ export const getInventoryItems = const params = { page, fields: - "id,code,name,images,images.file_url,is_archived,early_bird_rate,standard_rate,onsite_rate,default_quantity", + "id,code,name,images,images.file_url,images.created,is_archived,early_bird_rate,standard_rate,onsite_rate,default_quantity", expand: "images", per_page: perPage, access_token: accessToken diff --git a/src/actions/sponsor-forms-actions.js b/src/actions/sponsor-forms-actions.js index 2df0b36a6..c2ae2fb61 100644 --- a/src/actions/sponsor-forms-actions.js +++ b/src/actions/sponsor-forms-actions.js @@ -1056,8 +1056,8 @@ export const getSponsorFormItems = const params = { page, fields: - "id,code,name,early_bird_rate,standard_rate,onsite_rate,default_quantity,images,is_archived", - relations: "images", + "id,code,name,early_bird_rate,standard_rate,onsite_rate,default_quantity,images,images.file_url,images.created,is_archived", + expand: "images", per_page: perPage, access_token: accessToken }; @@ -1095,7 +1095,7 @@ export const getSponsorFormItem = const params = { access_token: accessToken, - expands: "meta_fields,meta_fields.values,images" + expand: "meta_fields,meta_fields.values,images" }; return getRequest( diff --git a/src/app.js b/src/app.js index 3f459ed2c..b649b2985 100644 --- a/src/app.js +++ b/src/app.js @@ -107,7 +107,8 @@ window.CFP_APP_BASE_URL = process.env.CFP_APP_BASE_URL; window.DROPBOX_MATERIALIZER_API_BASE_URL = process.env.DROPBOX_MATERIALIZER_API_BASE_URL; window.FILE_UPLOAD_ALLOWED_EXTENSIONS = - process.env.FILE_UPLOAD_ALLOWED_EXTENSIONS || ALLOWED_INVENTORY_IMAGE_FORMATS; + process.env.FILE_UPLOAD_ALLOWED_EXTENSIONS || + ALLOWED_INVENTORY_IMAGE_FORMATS.join(","); if (exclusiveSections.hasOwnProperty(process.env.APP_CLIENT_NAME)) { window.EXCLUSIVE_SECTIONS = exclusiveSections[process.env.APP_CLIENT_NAME]; diff --git a/src/components/__tests__/image-preview-cell.test.js b/src/components/__tests__/image-preview-cell.test.js new file mode 100644 index 000000000..8f16dc042 --- /dev/null +++ b/src/components/__tests__/image-preview-cell.test.js @@ -0,0 +1,68 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import T from "i18n-react/dist/i18n-react"; +import { ImagePreviewCell } from "../image-preview-cell"; + +describe("ImagePreviewCell", () => { + const imageUrl = "https://example.com/path/sponsor_banner.png"; + const title = T.translate("preview_modal.title"); + + 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("renders a trigger button", () => { + render(); + expect(screen.getByRole("button", { name: title })).toBeInTheDocument(); + }); + + test("opens PreviewModal on click", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: title })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + test("passes itemName as dialog title", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: title })); + expect(screen.getByText("Sponsor Banner")).toBeInTheDocument(); + }); + + test("extracts filename from URL when no fileName prop", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: title })); + expect(screen.getByText("sponsor_banner.png")).toBeInTheDocument(); + }); + + test("uses fileName prop over URL extraction", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: title })); + expect(screen.getByText("custom.png")).toBeInTheDocument(); + }); + + test("closes dialog when X is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: title })); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "close" })); + await waitFor(() => + expect(screen.queryByRole("dialog")).not.toBeInTheDocument() + ); + }); +}); diff --git a/src/components/image-preview-cell.js b/src/components/image-preview-cell.js new file mode 100644 index 000000000..82b1bb082 --- /dev/null +++ b/src/components/image-preview-cell.js @@ -0,0 +1,61 @@ +/** + * 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 + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useState } from "react"; +import IconButton from "@mui/material/IconButton"; +import ImageIcon from "@mui/icons-material/Image"; +import T from "i18n-react/dist/i18n-react"; +import PreviewModal from "./mui/PreviewModal"; +import { isImageUrl } from "../utils/methods"; + +export const ImagePreviewCell = React.memo( + ({ imageUrl, itemName, fileName, uploadDate }) => { + const [open, setOpen] = useState(false); + + if (!imageUrl || !isImageUrl(imageUrl)) return null; + + const rawSegment = imageUrl.split("/").pop().split("?")[0]; + let decoded = rawSegment; + try { + decoded = decodeURIComponent(rawSegment); + } catch { + /* malformed encoding — keep raw */ + } + const resolvedFileName = fileName || decoded; + + return ( + <> + setOpen(true)} + > + + + + {open && ( + setOpen(false)} + url={imageUrl} + filename={resolvedFileName} + uploadDate={uploadDate} + /> + )} + + ); + } +); + +ImagePreviewCell.displayName = "ImagePreviewCell"; diff --git a/src/components/mui/PreviewModal/index.jsx b/src/components/mui/PreviewModal/index.jsx index b0cbd50a4..23b038794 100644 --- a/src/components/mui/PreviewModal/index.jsx +++ b/src/components/mui/PreviewModal/index.jsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import PropTypes from "prop-types"; import T from "i18n-react/dist/i18n-react"; import { @@ -13,90 +13,111 @@ import CloseIcon from "@mui/icons-material/Close"; import BrokenImageOutlinedIcon from "@mui/icons-material/BrokenImageOutlined"; import { formatDate } from "../../../utils/methods"; -const PreviewModal = ({ title, open, onClose, url, filename, uploadDate }) => ( - - - {title} - - ({ - position: "absolute", - right: 12, - top: 12, - color: theme.palette.grey[500] - })} - > - - - - ( + + + +); + +const PreviewModal = ({ title, open, onClose, url, filename, uploadDate }) => { + const [imageError, setImageError] = useState(false); + + useEffect(() => { + if (open) setImageError(false); + }, [open]); + + return ( + + - {url ? ( - - ) : ( - - - - )} - - - {filename && ( - - - {T.translate("preview_modal.file_name")} - - {filename} - - )} - {uploadDate && ( - - - {T.translate("preview_modal.uploaded")} - - {formatDate(uploadDate)} - - )} - - - -); + {title} + + ({ + position: "absolute", + right: 12, + top: 12, + color: theme.palette.grey[500] + })} + > + + + + + {!url || imageError ? ( + + ) : ( + setImageError(true)} + sx={{ + maxWidth: "100%", + maxHeight: 400, + display: "block", + objectFit: "contain" + }} + /> + )} + + + {filename && ( + + + {T.translate("preview_modal.file_name")} + + + {filename} + + + )} + {!!uploadDate && ( + + + {T.translate("preview_modal.uploaded")} + + {formatDate(uploadDate)} + + )} + + + + ); +}; PreviewModal.propTypes = { title: PropTypes.string.isRequired, diff --git a/src/components/mui/__tests__/preview-modal.test.js b/src/components/mui/__tests__/preview-modal.test.js new file mode 100644 index 000000000..1b0dd1a99 --- /dev/null +++ b/src/components/mui/__tests__/preview-modal.test.js @@ -0,0 +1,132 @@ +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import T from "i18n-react/dist/i18n-react"; +import PreviewModal from "../PreviewModal"; + +describe("PreviewModal", () => { + const baseProps = { + title: "Test Image", + open: true, + onClose: jest.fn() + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders title", () => { + render(); + expect(screen.getByText("Test Image")).toBeInTheDocument(); + }); + + test("shows image when url is provided", () => { + render( + + ); + expect(screen.getByRole("img")).toHaveAttribute( + "src", + "https://example.com/img.png" + ); + }); + + test("shows broken image icon when url is not provided", () => { + render(); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + test("shows filename when provided", () => { + render(); + expect( + screen.getByText(T.translate("preview_modal.file_name")) + ).toBeInTheDocument(); + expect(screen.getByText("sponsor.png")).toBeInTheDocument(); + }); + + test("does not show filename row when filename is empty", () => { + render(); + expect( + screen.queryByText(T.translate("preview_modal.file_name")) + ).not.toBeInTheDocument(); + }); + + test("shows formatted uploadDate when provided", () => { + render(); + expect( + screen.getByText(T.translate("preview_modal.uploaded")) + ).toBeInTheDocument(); + }); + + test("does not show uploaded row when uploadDate is 0", () => { + render(); + expect( + screen.queryByText(T.translate("preview_modal.uploaded")) + ).not.toBeInTheDocument(); + }); + + test("calls onClose when X button is clicked", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "close" })); + expect(baseProps.onClose).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when clicking outside the dialog", async () => { + render(); + fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + expect(baseProps.onClose).toHaveBeenCalledTimes(1); + }); + + test("does not render dialog when open is false", () => { + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + test("shows broken image icon when image fails to load", () => { + render( + + ); + fireEvent.error(screen.getByRole("img")); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + }); + + test("resets image error when modal is reopened", () => { + const { rerender } = render( + + ); + + fireEvent.error(screen.getByRole("img")); + expect(screen.queryByRole("img")).not.toBeInTheDocument(); + + rerender( + + ); + rerender( + + ); + expect(screen.getByRole("img")).toBeInTheDocument(); + }); +}); diff --git a/src/i18n/en.json b/src/i18n/en.json index 1ea40d141..1b707dac5 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -4229,6 +4229,7 @@ "cancel": "Cancel" }, "preview_modal": { + "title": "Image Preview", "file_name": "File name", "uploaded": "Uploaded" } diff --git a/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js b/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js index 2a6f6cb06..86496b0ad 100644 --- a/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js +++ b/src/pages/sponsors-global/form-templates/add-form-template-item-popup.js @@ -18,9 +18,9 @@ import { } from "@mui/material"; import SwapVertIcon from "@mui/icons-material/SwapVert"; import CloseIcon from "@mui/icons-material/Close"; -import ImageIcon from "@mui/icons-material/Image"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import { ImagePreviewCell } from "../../../components/image-preview-cell"; import MenuButton from "../../../components/mui/menu-button"; import { clearAllSelectedInventoryItems, @@ -159,21 +159,18 @@ const AddFormTemplateItemDialog = ({ 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 img = row.images?.[0]; + const url = img?.file_url ?? img?.file_path; + if (!url) return null; + return ( + + ); + } } ]; diff --git a/src/pages/sponsors-global/form-templates/form-template-item-list-page.js b/src/pages/sponsors-global/form-templates/form-template-item-list-page.js index 95e6bcd47..90263fa87 100644 --- a/src/pages/sponsors-global/form-templates/form-template-item-list-page.js +++ b/src/pages/sponsors-global/form-templates/form-template-item-list-page.js @@ -24,13 +24,10 @@ import { Grid2 } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import ImageIcon from "@mui/icons-material/Image"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; +import { ImagePreviewCell } from "../../../components/image-preview-cell"; import { cloneFromInventoryItem, - deleteFormTemplateItem, getFormTemplateItem, getFormTemplateItems, saveFormTemplateItem, @@ -208,23 +205,18 @@ const FormTemplateItemListPage = ({ 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 img = row.images?.[0]; + const url = img?.file_url ?? img?.file_path; + if (!url) return null; + return ( + + ); + } } ]; @@ -349,7 +341,6 @@ const mapStateToProps = ({ export default connect(mapStateToProps, { cloneFromInventoryItem, - deleteFormTemplateItem, getFormTemplateItems, getFormTemplate, getInventoryItems, 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 deleted file mode 100644 index ad788a1c6..000000000 --- a/src/pages/sponsors-global/inventory/__tests__/inventory-list-page.test.js +++ /dev/null @@ -1,106 +0,0 @@ -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 b6e0d072f..2dfb4964a 100644 --- a/src/pages/sponsors-global/inventory/inventory-list-page.js +++ b/src/pages/sponsors-global/inventory/inventory-list-page.js @@ -18,21 +18,16 @@ import { Checkbox, FormControlLabel, FormGroup, - Grid2, - Popover, - Typography + Grid2 } from "@mui/material"; import Box from "@mui/material/Box"; import AddIcon from "@mui/icons-material/Add"; -import IconButton from "@mui/material/IconButton"; -import ImageIcon from "@mui/icons-material/Image"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; import { connect } from "react-redux"; import T from "i18n-react/dist/i18n-react"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import { archiveInventoryItem, - deleteInventoryItem, deleteInventoryItemImage, deleteInventoryItemMetaFieldType, deleteInventoryItemMetaFieldTypeValue, @@ -43,118 +38,9 @@ import { unarchiveInventoryItem } from "../../../actions/inventory-item-actions"; import SponsorInventoryDialog from "../form-templates/sponsor-inventory-popup"; +import { ImagePreviewCell } from "../../../components/image-preview-cell"; 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, @@ -284,14 +170,17 @@ const InventoryListPage = ({ width: 40, align: "center", render: (row) => { - const hasImages = Array.isArray(row.images) && row.images.length > 0; - const imageUrl = row.images?.[0]?.file_url; - const itemName = row.name; + const img = row.images?.[0]; + const imageUrl = img?.file_url ?? img?.file_path; - if (!hasImages || !imageUrl) return null; + if (!imageUrl) return null; return ( - + ); } } @@ -425,7 +314,7 @@ export default connect(mapStateToProps, { getInventoryItem, resetInventoryItemForm, saveInventoryItem, - deleteInventoryItem, + deleteInventoryItemImage, deleteInventoryItemMetaFieldType, deleteInventoryItemMetaFieldTypeValue, diff --git a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js index 0b3d89101..e2b40d06e 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/components/sponsor-form-add-item-from-inventory-popup.js @@ -16,11 +16,10 @@ import { Typography } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import Tooltip from "@mui/material/Tooltip"; -import ImageIcon from "@mui/icons-material/Image"; import Box from "@mui/material/Box"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import { ImagePreviewCell } from "../../../../components/image-preview-cell"; import { formatRateFromCents } from "../../../../utils/rate-helpers"; import { addInventoryItems } from "../../../../actions/sponsor-forms-actions"; import { getInventoryItems } from "../../../../actions/inventory-item-actions"; @@ -146,23 +145,18 @@ const SponsorFormAddItemFromInventoryPopup = ({ 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 img = row.images?.[0]; + const url = img?.file_url ?? img?.file_path; + if (!url) return null; + return ( + + ); + } } ]; diff --git a/src/pages/sponsors/sponsor-form-item-list-page/index.js b/src/pages/sponsors/sponsor-form-item-list-page/index.js index b8b2822a0..03768bc3a 100644 --- a/src/pages/sponsors/sponsor-form-item-list-page/index.js +++ b/src/pages/sponsors/sponsor-form-item-list-page/index.js @@ -25,10 +25,8 @@ import { Grid2 } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import ImageIcon from "@mui/icons-material/Image"; import MuiTableEditable from "openstack-uicore-foundation/lib/components/mui/editable-table"; +import { ImagePreviewCell } from "../../../components/image-preview-cell"; import { deleteSponsorFormItem, getSponsorFormItem, @@ -186,23 +184,18 @@ const SponsorFormItemListPage = ({ 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 img = row.images?.[0]; + const url = img?.file_url ?? img?.file_path; + if (!url) return null; + return ( + + ); + } } ]; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js index ddeb525d5..2b65abe0a 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-forms-tab/components/manage-items/sponsor-form-item-from-inventory.js @@ -14,14 +14,13 @@ import { FormControlLabel, Grid2, IconButton, - Tooltip, Typography } from "@mui/material"; import CloseIcon from "@mui/icons-material/Close"; -import ImageIcon from "@mui/icons-material/Image"; import SwapVertIcon from "@mui/icons-material/SwapVert"; import MuiTable from "openstack-uicore-foundation/lib/components/mui/table"; import SearchInput from "openstack-uicore-foundation/lib/components/mui/search-input"; +import { ImagePreviewCell } from "../../../../../../../components/image-preview-cell"; import { formatRateFromCents } from "../../../../../../../utils/rate-helpers"; import { DEFAULT_CURRENT_PAGE, @@ -145,23 +144,18 @@ const SponsorFormItemFromInventoryPopup = ({ 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 img = row.images?.[0]; + const url = img?.file_url ?? img?.file_path; + if (!url) return null; + return ( + + ); + } } ]; diff --git a/src/utils/__tests__/methods.test.js b/src/utils/__tests__/methods.test.js index ac5275c43..0200a1756 100644 --- a/src/utils/__tests__/methods.test.js +++ b/src/utils/__tests__/methods.test.js @@ -1,4 +1,8 @@ -import { getMediaInputValue, normalizeSelectAllField } from "../methods"; +import { + getMediaInputValue, + isImageUrl, + normalizeSelectAllField +} from "../methods"; const FIXED_NOW = 1_772_551_911_231; beforeAll(() => jest.spyOn(Date, "now").mockReturnValue(FIXED_NOW)); @@ -120,4 +124,33 @@ describe("getMediaInputValue", () => { }); }); }); + + describe("isImageUrl", () => { + it.each(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp"])( + "returns true for .%s extension", + (ext) => { + expect(isImageUrl(`https://example.com/file.${ext}`)).toBe(true); + } + ); + + it("is case-insensitive", () => { + expect(isImageUrl("https://example.com/file.JPG")).toBe(true); + expect(isImageUrl("https://example.com/file.PNG")).toBe(true); + }); + + it("works with query strings", () => { + expect(isImageUrl("https://example.com/file.png?v=123")).toBe(true); + }); + + it.each(["pdf", "mp4", "doc", "csv", "pptx"])( + "returns false for .%s extension", + (ext) => { + expect(isImageUrl(`https://example.com/file.${ext}`)).toBe(false); + } + ); + + it("returns false for empty string", () => { + expect(isImageUrl("")).toBe(false); + }); + }); }); diff --git a/src/utils/methods.js b/src/utils/methods.js index dd426a3dd..a2bc7edab 100644 --- a/src/utils/methods.js +++ b/src/utils/methods.js @@ -634,5 +634,13 @@ export const formatDate = ( .format(format); }; -export const getFileUploadAllowedExtensions = () => - window.FILE_UPLOAD_ALLOWED_EXTENSIONS?.split(",").filter(Boolean) ?? []; +export const getFileUploadAllowedExtensions = () => { + const ext = window.FILE_UPLOAD_ALLOWED_EXTENSIONS; + if (!ext) return []; + return (Array.isArray(ext) ? ext : String(ext).split(",")) + .map((s) => String(s).trim()) + .filter(Boolean); +}; + +export const isImageUrl = (url) => + /\.(jpe?g|png|gif|webp|svg|bmp)(\?|$)/i.test(url);