diff --git a/__tests__/src/components/AttributionPanel.test.js b/__tests__/src/components/AttributionPanel.test.js index 5219ebb35..9ece92a51 100644 --- a/__tests__/src/components/AttributionPanel.test.js +++ b/__tests__/src/components/AttributionPanel.test.js @@ -1,55 +1,58 @@ /** * @jest-environment-options { "resources": "usable" } */ -import { render, screen, waitFor } from '@tests/utils/test-utils'; +import { render, screen, waitFor } from "@tests/utils/test-utils"; -import { AttributionPanel } from '../../../src/components/AttributionPanel'; +import { AttributionPanel } from "../../../src/components/AttributionPanel"; /** * Helper function to create a shallow wrapper around AttributionPanel */ function createWrapper(props) { - return render( - , - { preloadedState: { companionWindows: { xyz: { content: 'attribution' } } } }, - ); + return render(, { + preloadedState: { companionWindows: { xyz: { content: "attribution" } } }, + }); } -describe('AttributionPanel', () => { - it('renders the required statement', () => { +describe("AttributionPanel", () => { + it("renders the required statement", () => { const requiredStatement = [ - { label: 'required statement', values: ['must be shown'] }, + { label: "required statement", values: ["must be shown"] }, ]; createWrapper({ requiredStatement }); - expect(screen.getByText('required statement')).toBeInTheDocument(); - expect(screen.getByText('must be shown')).toBeInTheDocument(); + expect(screen.getByText("required statement")).toBeInTheDocument(); + expect(screen.getByText("must be shown")).toBeInTheDocument(); }); - it('renders the rights statement', () => { - createWrapper({ rights: ['http://example.com', 'http://stanford.edu'] }); + it("renders the rights statement", () => { + createWrapper({ rights: ["http://example.com", "http://stanford.edu"] }); - expect(screen.getByText('License')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'http://example.com' })).toHaveAttribute('href', 'http://example.com'); - expect(screen.getByRole('link', { name: 'http://stanford.edu' })).toHaveAttribute('href', 'http://stanford.edu'); + expect(screen.getByText("License")).toBeInTheDocument(); + expect( + screen.getByRole("link", { name: "http://example.com" }), + ).toHaveAttribute("href", "http://example.com"); + expect( + screen.getByRole("link", { name: "http://stanford.edu" }), + ).toHaveAttribute("href", "http://stanford.edu"); }); - it('does not render the rights statement if it is empty', () => { + it("does not render the rights statement if it is empty", () => { createWrapper({ rights: [] }); - expect(screen.queryByText('License')).not.toBeInTheDocument(); + expect(screen.queryByText("License")).not.toBeInTheDocument(); }); // Requires canvas to handle img loading. - it.skip('renders the manifest logo', async () => { - const manifestLogo = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mMMDQmtBwADgwF/Op8FmAAAAABJRU5ErkJggg=='; + it("renders the manifest logo", async () => { + const manifestLogo = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mMMDQmtBwADgwF/Op8FmAAAAABJRU5ErkJggg=="; const { container } = createWrapper({ manifestLogo }); - await waitFor(() => { expect(container.querySelector('img')).toBeInTheDocument(); }); // eslint-disable-line testing-library/no-container, testing-library/no-node-access + await waitFor(() => { + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector("img")).toBeInTheDocument(); + }); - expect(container.querySelector('img')).toHaveAttribute('src', manifestLogo); // eslint-disable-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector("img")).toHaveAttribute("src", manifestLogo); // eslint-disable-line testing-library/no-container, testing-library/no-node-access }); }); diff --git a/package.json b/package.json index a2d71a1c0..801046abf 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,6 @@ "react-dnd-touch-backend": "^16.0.0", "react-error-boundary": "^5.0.0", "react-full-screen": "^1.1.1", - "react-image": "^4.0.1", "react-intersection-observer": "^10.0.0", "react-mosaic-component2": "^7.0.0", "react-redux": "^8.0.0 || ^9.0.0", diff --git a/src/components/AttributionPanel.jsx b/src/components/AttributionPanel.jsx index d15288a99..d01f77fe5 100644 --- a/src/components/AttributionPanel.jsx +++ b/src/components/AttributionPanel.jsx @@ -1,18 +1,18 @@ -import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; -import Typography from '@mui/material/Typography'; -import Link from '@mui/material/Link'; -import Skeleton from '@mui/material/Skeleton'; -import { useTranslation } from 'react-i18next'; -import { Img } from 'react-image'; -import CompanionWindow from '../containers/CompanionWindow'; -import { CompanionWindowSection } from './CompanionWindowSection'; -import LabelValueMetadata from '../containers/LabelValueMetadata'; -import ns from '../config/css-ns'; -import { PluginHook } from './PluginHook'; +import PropTypes from "prop-types"; +import { styled } from "@mui/material/styles"; +import Typography from "@mui/material/Typography"; +import Link from "@mui/material/Link"; +import Skeleton from "@mui/material/Skeleton"; +import { useTranslation } from "react-i18next"; +import CompanionWindow from "../containers/CompanionWindow"; +import { CompanionWindowSection } from "./CompanionWindowSection"; +import LabelValueMetadata from "../containers/LabelValueMetadata"; +import ns from "../config/css-ns"; +import { PluginHook } from "./PluginHook"; +import { ImageWithFallback } from "./ImageWithFallback"; -const StyledLogo = styled(Img)(() => ({ - maxWidth: '100%', +const StyledLogo = styled(ImageWithFallback)(() => ({ + maxWidth: "100%", })); const StyledPlaceholder = styled(Skeleton)(({ theme }) => ({ @@ -35,42 +35,45 @@ export function AttributionPanel({ return ( - { requiredStatement && ( - + {requiredStatement && ( + + )} + {rights && rights.length > 0 && ( +
+ + {t("rights")} + + {rights.map((v) => ( + + + {v} + + + ))} +
)} - { - rights && rights.length > 0 && ( -
- {t('rights')} - { rights.map(v => ( - - - {v} - - - )) } -
- ) - }
- { manifestLogo && ( - - - } - /> - + {manifestLogo && ( + + + } + /> + )} @@ -81,10 +84,12 @@ export function AttributionPanel({ AttributionPanel.propTypes = { id: PropTypes.string.isRequired, manifestLogo: PropTypes.string, - requiredStatement: PropTypes.arrayOf(PropTypes.shape({ - label: PropTypes.string, - value: PropTypes.string, - })), + requiredStatement: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string, + value: PropTypes.string, + }), + ), rights: PropTypes.arrayOf(PropTypes.string), windowId: PropTypes.string.isRequired, }; diff --git a/src/components/ImageWithFallback.jsx b/src/components/ImageWithFallback.jsx new file mode 100644 index 000000000..c27954935 --- /dev/null +++ b/src/components/ImageWithFallback.jsx @@ -0,0 +1,24 @@ +import { useState } from "react"; +import PropTypes from "prop-types"; + +/** + * A plain img element that renders a fallback node when the image fails to load. + */ +export function ImageWithFallback({ alt, fallback, src, ...props }) { + const [hasError, setHasError] = useState(false); + + if (hasError) return fallback; + + return ( + {alt} setHasError(true)} {...props} /> + ); +} + +ImageWithFallback.propTypes = { + /** Alt text for the underlying img element. */ + alt: PropTypes.string.isRequired, + /** Node rendered in place of the image when it fails to load. */ + fallback: PropTypes.node.isRequired, + /** URL of the image to display. */ + src: PropTypes.string.isRequired, +}; diff --git a/src/components/ManifestListItem.jsx b/src/components/ManifestListItem.jsx index 66b45b1c1..ed20d5dde 100644 --- a/src/components/ManifestListItem.jsx +++ b/src/components/ManifestListItem.jsx @@ -1,75 +1,76 @@ -import { useEffect } from 'react'; -import PropTypes from 'prop-types'; -import { styled } from '@mui/material/styles'; -import ListItem from '@mui/material/ListItem'; -import ButtonBase from '@mui/material/ButtonBase'; -import Grid from '@mui/material/Grid'; -import Typography from '@mui/material/Typography'; -import Skeleton from '@mui/material/Skeleton'; -import { useTranslation } from 'react-i18next'; -import { Img } from 'react-image'; -import ManifestListItemError from '../containers/ManifestListItemError'; -import ns from '../config/css-ns'; +import { useEffect } from "react"; +import PropTypes from "prop-types"; +import { styled } from "@mui/material/styles"; +import ListItem from "@mui/material/ListItem"; +import ButtonBase from "@mui/material/ButtonBase"; +import Grid from "@mui/material/Grid"; +import Typography from "@mui/material/Typography"; +import Skeleton from "@mui/material/Skeleton"; +import { useTranslation } from "react-i18next"; +import ManifestListItemError from "../containers/ManifestListItemError"; +import { ImageWithFallback } from "./ImageWithFallback"; +import ns from "../config/css-ns"; -const Root = styled(ListItem, { name: 'ManifestListItem', slot: 'root' })( +const Root = styled(ListItem, { name: "ManifestListItem", slot: "root" })( ({ ownerState, theme }) => ({ - '&:hover,&:focus-within': { + "&:hover,&:focus-within": { backgroundColor: theme.palette.action.hover, borderLeftColor: ownerState?.active ? theme.palette.primary.main : theme.palette.action.hover, }, - borderLeft: '4px solid', + borderLeft: "4px solid", borderLeftColor: ownerState?.active ? theme.palette.primary.main - : 'transparent', + : "transparent", paddingLeft: theme.spacing(2), paddingRight: theme.spacing(2), - [theme.breakpoints.up('sm')]: { + [theme.breakpoints.up("sm")]: { paddingLeft: theme.spacing(3), paddingRight: theme.spacing(3), }, }), ); -const StyledThumbnail = styled(Img, { - name: 'ManifestListItem', - slot: 'thumbnail', -})(({ theme }) => ({ - maxWidth: '100%', - objectFit: 'contain', +const StyledThumbnail = styled(ImageWithFallback, { + name: "ManifestListItem", + slot: "thumbnail", +})(() => ({ + maxWidth: "100%", + objectFit: "contain", })); -const StyledLogo = styled(Img, { name: 'ManifestListItem', slot: 'logo' })( - ({ theme }) => ({ - height: '2.5rem', - maxWidth: '100%', - objectFit: 'contain', - paddingRight: 1, - }), -); +const StyledLogo = styled(ImageWithFallback, { + name: "ManifestListItem", + slot: "logo", +})(() => ({ + height: "2.5rem", + maxWidth: "100%", + objectFit: "contain", + paddingRight: 1, +})); /** */ const Placeholder = () => ( - + - + - - + + { - if (!ready && !error && !isFetching && provider !== 'file') fetchManifest(manifestId); + if (!ready && !error && !isFetching && provider !== "file") + fetchManifest(manifestId); }, [manifestId, provider, fetchManifest, ready, error, isFetching]); const ownerState = arguments[0]; // eslint-disable-line prefer-rest-params @@ -120,7 +122,7 @@ export function ManifestListItem({ ownerState={ownerState} divider selected={active} - className={active ? 'active' : ''} + className={active ? "active" : ""} data-manifestid={manifestId} > @@ -132,65 +134,76 @@ export function ManifestListItem({ {ready ? ( - - + + {thumbnail ? ( - )} + } /> ) : ( )} - + {isCollection && ( - {t(isMultipart ? 'multipartCollection' : 'collection')} + {t(isMultipart ? "multipartCollection" : "collection")} )} {title || manifestId} @@ -200,29 +213,29 @@ export function ManifestListItem({ - + {provider} - {t('numItems', { count: size, number: size })} + {t("numItems", { count: size, number: size })} {manifestLogo && ( - )} + } /> )}