diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index 3d349837b..0e46a41fd 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -699,6 +699,200 @@ describe('Items', () => { cy.findByText('Upload failed').should('exist'); }); + + it('falls back to placeholder thumbnail', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + cy.findByAltText('The image cannot be loaded').should('exist'); + }); + + it('displays and hides filters, applies and clears name filter on gallery view', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + cy.findAllByText('stfc-logo-blue-text.png').should('have.length', 8); + cy.findByText('Show Filters').click(); + cy.findByRole('button', { name: 'Clear Filters' }).should('be.disabled'); + cy.findByLabelText('Filter by File name').type('logo1.png'); + cy.findByAltText('test').should('not.exist'); + cy.findByRole('button', { name: 'Clear Filters' }).click(); + cy.findAllByText('stfc-logo-blue-text.png').should('have.length', 8); + cy.findByText('Hide Filters').click(); + cy.findByText('Show Filters').should('exist'); + }); + + it('opens information dialog from card view', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + + cy.findAllByLabelText('Card Actions').first().click(); + cy.findAllByText('Information').last().click(); + + cy.findByRole('dialog').within(() => { + cy.findByText('Image Information').should('exist'); + cy.findByText('stfc-logo-blue-text').should('exist'); + cy.findByText('stfc-logo-blue-text.png').should('exist'); + cy.findByText('test').should('exist'); + cy.findByText('No').should('exist'); + + cy.findByRole('button', { name: 'Close' }).click(); + }); + + cy.findByText('Image information').should('not.exist'); + }); + + it('opens full-size image when thumbnail is clicked and navigates to the next image', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + + cy.findAllByAltText('test').first().click(); + cy.findByTestId('galleryLightBox').within(() => { + cy.findByText('File name: stfc-logo-blue-text.png').should('exist'); + cy.findByText('Title: stfc-logo-blue-text').should('exist'); + cy.findByText('test').should('exist'); + + cy.findByAltText('test').should('exist'); + + cy.findByAltText('test') + .should('have.attr', 'src') + .and( + 'include', + 'http://localhost:3000/images/stfc-logo-blue-text.png?text=1' + ); + + cy.findByLabelText('Next').click(); + + cy.findByText('File name: logo1.png').should('exist'); + cy.findByText('Title: logo1').should('exist'); + cy.findByText('test').should('exist'); + + cy.findByAltText('test').should('exist'); + + cy.findByAltText('test') + .should('have.attr', 'src') + .and('include', 'http://localhost:3000/logo192.png?text=2'); + cy.findByLabelText('Close').click(); + }); + + cy.findByTestId('galleryLightBox').should('not.exist'); + }); + + it('opens corrupted image, and navigates back to previous image (invalid url)', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + + cy.findByAltText('The image cannot be loaded').click(); + cy.findByTestId('galleryLightBox').within(() => { + cy.findByText('File name: stfc-logo-blue-text.png').should('exist'); + cy.findByText('Title: stfc-logo-blue-text').should('exist'); + cy.findByText('No description available').should('exist'); + + cy.findByText('The image cannot be loaded').should('exist'); + + cy.findByLabelText('Previous').click(); + + cy.findByText('File name: logo1.png').should('exist'); + cy.findByText('Title: logo1').should('exist'); + cy.findByText('test').should('exist'); + + cy.findByAltText('test').should('exist'); + + cy.findByAltText('test') + .should('have.attr', 'src') + .and('include', 'http://localhost:3000/logo192.png?text=2'); + cy.findByLabelText('Close').click(); + }); + + cy.findByTestId('galleryLightBox').should('not.exist'); + }); + + it('opens corrupted image (network error)', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + + cy.findAllByAltText('test').eq(3).click(); + cy.findByTestId('galleryLightBox').within(() => { + cy.findByText('The image cannot be loaded', { timeout: 10000 }).should( + 'exist' + ); + + cy.findByText('File name: stfc-logo-blue-text.png').should('exist'); + cy.findByText('Title: stfc-logo-blue-text').should('exist'); + cy.findByText('test').should('exist'); + + cy.findByLabelText('Close').click(); + }); + + cy.findByTestId('galleryLightBox').should('not.exist'); + }); + + it('opens information dialog in lightbox', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); + + cy.findByText('Gallery').click(); + + cy.findAllByAltText('test').first().click(); + cy.findByTestId('galleryLightBox').within(() => { + cy.findByText('File name: stfc-logo-blue-text.png').should('exist'); + cy.findByText('Title: stfc-logo-blue-text').should('exist'); + cy.findByText('test').should('exist'); + + cy.findByAltText('test').should('exist'); + + cy.findByAltText('test') + .should('have.attr', 'src') + .and( + 'include', + 'http://localhost:3000/images/stfc-logo-blue-text.png?text=1' + ); + + cy.findByLabelText('Image Actions').click(); + }); + + cy.findAllByText('Information').last().click(); + + cy.findByRole('dialog', { timeout: 10000 }).should('exist'); + + cy.findByRole('dialog').within(() => { + cy.findByText('Image Information').should('exist'); + }); + + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Close' }).click(); + }); + + cy.findByRole('dialog').should('not.exist'); + + cy.findByLabelText('Close').click(); + + cy.findByTestId('galleryLightBox').should('not.exist'); + }); }); it('delete an item', () => { diff --git a/src/api/api.types.tsx b/src/api/api.types.tsx index d137c843c..1cbd1dd83 100644 --- a/src/api/api.types.tsx +++ b/src/api/api.types.tsx @@ -250,10 +250,14 @@ export interface ImagePost { description?: string | null; } -export interface Image +export interface APIImage extends Required>, CreatedModifiedMixin { id: string; primary: boolean; thumbnail_base64: string; } + +export interface APIImageWithURL extends APIImage { + url: string; +} diff --git a/src/api/images.test.tsx b/src/api/images.test.tsx new file mode 100644 index 000000000..e4a20b8ce --- /dev/null +++ b/src/api/images.test.tsx @@ -0,0 +1,65 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import ImagesJSON from '../mocks/Images.json'; +import { hooksWrapperWithProviders } from '../testUtils'; +import { useGetImage, useGetImages } from './images'; + +describe('images api functions', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('useGetImages', () => { + it('sends request to fetch image data and returns successful response', async () => { + const { result } = renderHook(() => useGetImages('1'), { + wrapper: hooksWrapperWithProviders(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data?.length).toEqual(20); + }); + + it('sends request to fetch primary image data and returns successful response', async () => { + const { result } = renderHook(() => useGetImages('1', true), { + wrapper: hooksWrapperWithProviders(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data?.length).toEqual(1); + }); + + it('sends request to fetch primary image data and returns successful empty list response', async () => { + const { result } = renderHook(() => useGetImages('90', true), { + wrapper: hooksWrapperWithProviders(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data?.length).toEqual(0); + }); + }); + + describe('useGetImage', () => { + it('sends request to fetch image data and returns successful response', async () => { + const { result } = renderHook(() => useGetImage('1'), { + wrapper: hooksWrapperWithProviders(), + }); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + expect(result.current.data).toEqual({ + ...ImagesJSON[1], + url: 'http://localhost:3000/images/stfc-logo-blue-text.png?text=1', + }); + }); + }); +}); diff --git a/src/api/images.tsx b/src/api/images.tsx new file mode 100644 index 000000000..e876c6441 --- /dev/null +++ b/src/api/images.tsx @@ -0,0 +1,45 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { storageApi } from './api'; +import { APIImage, APIImageWithURL } from './api.types'; + +export const getImage = async (id: string): Promise => { + return storageApi.get(`/images/${id}`).then((response) => { + return response.data; + }); +}; + +export const useGetImage = ( + id: string +): UseQueryResult => { + return useQuery({ + queryKey: ['Image', id], + queryFn: () => getImage(id), + }); +}; + +const getImages = async ( + entityId: string, + primary?: boolean +): Promise => { + const queryParams = new URLSearchParams(); + queryParams.append('entity_id', entityId); + + if (primary !== undefined) queryParams.append('primary', String(primary)); + return storageApi + .get(`/images`, { + params: queryParams, + }) + .then((response) => response.data); +}; + +export const useGetImages = ( + entityId?: string, + primary?: boolean +): UseQueryResult => { + return useQuery({ + queryKey: ['Images', entityId, primary], + queryFn: () => getImages(entityId ?? '', primary), + enabled: !!entityId, + }); +}; diff --git a/src/common/actionMenu.component.test.tsx b/src/common/actionMenu.component.test.tsx index 0d44099a0..e26745d14 100644 --- a/src/common/actionMenu.component.test.tsx +++ b/src/common/actionMenu.component.test.tsx @@ -1,4 +1,3 @@ -import '@testing-library/jest-dom'; import { screen, waitFor } from '@testing-library/react'; import userEvent, { UserEvent } from '@testing-library/user-event'; import { vi } from 'vitest'; diff --git a/src/common/delayedLoader.component.test.tsx b/src/common/delayedLoader.component.test.tsx new file mode 100644 index 000000000..c428d42bd --- /dev/null +++ b/src/common/delayedLoader.component.test.tsx @@ -0,0 +1,88 @@ +import { act, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import DelayedLoader from './delayedLoader.component'; + +vi.useFakeTimers(); + +describe('DelayedLoader', () => { + it('should render the loader after the specified delay', () => { + const timeMS = 2000; + + render( + + ); + expect(screen.queryByRole('progressbar')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(timeMS); + }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('should not render the loader if isLoading is false', () => { + render( + + ); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('should remove the loader if isLoading changes to false before the delay', () => { + const timeMS = 2000; + const { rerender } = render( + + ); + expect(screen.queryByRole('progressbar')).toBeNull(); + + rerender( + + ); + act(() => { + vi.advanceTimersByTime(timeMS); + }); + + expect(screen.queryByRole('progressbar')).toBeNull(); + }); + + it('should remove the loader when isLoading changes to false after being displayed', () => { + const timeMS = 2000; + const { rerender } = render( + + ); + + act(() => { + vi.advanceTimersByTime(timeMS); + }); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + + rerender( + + ); + expect(screen.queryByRole('progressbar')).toBeNull(); + }); +}); diff --git a/src/common/delayedLoader.component.tsx b/src/common/delayedLoader.component.tsx new file mode 100644 index 000000000..7a0bea4da --- /dev/null +++ b/src/common/delayedLoader.component.tsx @@ -0,0 +1,34 @@ +import { SxProps, Theme } from '@mui/material'; +import CircularProgress from '@mui/material/CircularProgress'; +import React from 'react'; + +interface DelayedLoaderProps { + timeMS: number; + isLoading: boolean; + sx: SxProps; +} + +const DelayedLoader = (props: DelayedLoaderProps) => { + const { timeMS, isLoading, sx } = props; + const [showLoader, setShowLoader] = React.useState(false); + + React.useEffect(() => { + let timeout: NodeJS.Timeout | null = null; + + if (isLoading) { + timeout = setTimeout(() => { + setShowLoader(true); + }, timeMS); + } else { + setShowLoader(false); + } + + return () => { + if (timeout) clearTimeout(timeout); + }; + }, [isLoading, timeMS]); + + return <>{showLoader && }; +}; + +export default DelayedLoader; diff --git a/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap b/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap new file mode 100644 index 000000000..376275632 --- /dev/null +++ b/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap @@ -0,0 +1,2574 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Image Gallery > renders correctly 1`] = ` + +
+
+
+
+
+
+
+ +
+ + + + + + + + + + +
+
+ +
+ +
+
+
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+

+ Show Filters +

+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ No photo description available. +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+ + + + + +
+
+
+ test +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+

+ Total Images: 20 +

+
+
+
+ +
+ + + +
+
+ +
+
+
+
+
+
+
+ +`; + +exports[`Image Gallery > renders no results page correctly 1`] = ` + +
+
+
+ No images available +
+

+ Please add an image by opening the Action Menu and clicking the Upload Images menu item. +

+
+
+ +`; diff --git a/src/common/images/__snapshots__/imageInformationDialog.component.test.tsx.snap b/src/common/images/__snapshots__/imageInformationDialog.component.test.tsx.snap new file mode 100644 index 000000000..a836d92bc --- /dev/null +++ b/src/common/images/__snapshots__/imageInformationDialog.component.test.tsx.snap @@ -0,0 +1,155 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Image Information dialog Component > renders dialog correctly 1`] = ` + +