diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index 50422794d..f56357498 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -710,6 +710,30 @@ describe('Items', () => { cy.findByText('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.findAllByAltText('Image: stfc-logo-blue-text').should( + 'have.length', + 7 + ); + 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('Image: stfc-logo-blue-text').should('not.exist'); + cy.findByRole('button', { name: 'Clear Filters' }).click(); + cy.findAllByAltText('Image: stfc-logo-blue-text').should( + 'have.length', + 7 + ); + cy.findByText('Hide Filters').click(); + cy.findByText('Show Filters').should('exist'); + }); + it('opens full-size image when thumbnail is clicked and navigates to the next image', () => { cy.findByText('5YUQDDjKpz2z').click(); cy.findByText( diff --git a/src/api/api.types.tsx b/src/api/api.types.tsx index 8b33e6c39..02339634d 100644 --- a/src/api/api.types.tsx +++ b/src/api/api.types.tsx @@ -250,7 +250,7 @@ export interface ImagePost { description?: string | null; } -export interface Image +export interface APIImage extends Required>, CreatedModifiedMixin { id: string; @@ -258,6 +258,6 @@ export interface Image thumbnail_base64: string; } -export interface ImageGet extends Image { +export interface ImageGet extends APIImage { download_url: string; } diff --git a/src/api/images.tsx b/src/api/images.tsx index 6d3dd45dc..b9d49e35a 100644 --- a/src/api/images.tsx +++ b/src/api/images.tsx @@ -1,7 +1,7 @@ import { useQuery, UseQueryResult } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { storageApi } from './api'; -import { Image, ImageGet } from './api.types'; +import { APIImage, ImageGet } from './api.types'; export const getImage = async (id: string): Promise => { return storageApi.get(`/images/${id}`).then((response) => { @@ -12,7 +12,7 @@ export const getImage = async (id: string): Promise => { const getImages = async ( entityId: string, primary?: boolean -): Promise => { +): Promise => { const queryParams = new URLSearchParams(); queryParams.append('entity_id', entityId); @@ -27,7 +27,7 @@ const getImages = async ( export const useGetImages = ( entityId?: string, primary?: boolean -): UseQueryResult => { +): UseQueryResult => { return useQuery({ queryKey: ['Images', entityId, primary], queryFn: () => getImages(entityId ?? '', primary), diff --git a/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap b/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap index 2d8f9938e..19a7cd039 100644 --- a/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap +++ b/src/common/images/__snapshots__/imageGallery.component.test.tsx.snap @@ -4,1826 +4,2435 @@ exports[`Image Gallery > renders correctly 1`] = `
- - - - - -
-
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: logo1 -
-
-
- -
-
- -
-
-
-
-
-
-
- - - + + + Clear Filters + +
+ - - - - + + + + + + + + + +
+
+ +
+ +
+
+
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+ +
+ +
+
+
+
+
+
+
+
+
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: logo1 -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: logo1 -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: logo1 -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: logo1 -
-
-
- -
-
- -
-
-
-
-
-
-
- - - - - -
-
-
- Image: stfc-logo-blue-text -
-
-
- -
-
- -
-
-
+

+ Show Filters +

- - - - + + + + +
+
+
+ Image: stfc-logo-blue-text - -
-
-
- Image: logo1 -
-
-
- +
+
+ +
+
- +
- -
-
-
-
-
-
-
- + + + + + +
+
+
- -
+
- - - + + + +
+
+ +
+
- +
-
-
- Image: stfc-logo-blue-text -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- stfc-logo-blue-text.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- - + + + + + +
+
+
+ Image: logo1 -
+
+ +
+
- - - + logo1.png +

+
+
- +
-
-
- Image: logo1 -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- logo1.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- +
+ + + + + +
+
+
- -
+
- - - + + + +
+
+ +
+
- +
-
-
- Image: stfc-logo-blue-text -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- stfc-logo-blue-text.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- +
+ + + + + +
+
+
- -
+
+ +
+
- - - + logo1.png +

+
+
- +
-
-
- Image: logo1 -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- logo1.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- +
+ + + + + +
+
+
- -
+
- - - + + + +
+
+ +
+
- +
-
-
- Image: stfc-logo-blue-text -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- stfc-logo-blue-text.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- - + + + + + +
+
+
+ Image: logo1 -
+
+ +
+
- - - + logo1.png +

+
+
- +
-
-
- Image: logo1 -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- logo1.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- +
+ + + + + +
+
+
- -
+
- - - + + + +
+
+ +
+
- +
-
-
- Image: stfc-logo-blue-text -
-
-
+
+
+ Image: stfc-logo-blue-text - -
-
-
- stfc-logo-blue-text.png -

+
+ +
+
+ +
+
+
-
-
-
-
-
- +
+ + + + + +
+
+
- -
+
+ +
+
- - - + logo1.png +

+
+
- +
-
-
- +
@@ -1834,9 +2443,6 @@ exports[`Image Gallery > renders correctly 1`] = ` exports[`Image Gallery > renders no results page correctly 1`] = `
-
diff --git a/src/common/images/imageGallery.component.test.tsx b/src/common/images/imageGallery.component.test.tsx index c1a2638e1..2ef539082 100644 --- a/src/common/images/imageGallery.component.test.tsx +++ b/src/common/images/imageGallery.component.test.tsx @@ -93,7 +93,7 @@ describe('Image Gallery', () => { expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() ); - expect(screen.getAllByText('logo1.png').length).toEqual(10); + expect(screen.getAllByText('logo1.png').length).toEqual(8); expect(baseElement).toMatchSnapshot(); }); @@ -116,6 +116,85 @@ describe('Image Gallery', () => { expect(baseElement).toMatchSnapshot(); }); + it('changes page correctly and rerenders data', async () => { + const { router } = createView(); + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); + + expect(screen.getAllByText('logo1.png').length).toEqual(8); + expect(router.state.location.search).toBe(''); + + await user.click(screen.getByRole('button', { name: 'Go to page 2' })); + + await waitFor(() => { + expect(screen.getAllByText('logo1.png').length).toEqual(2); + }); + + expect(router.state.location.search).toBe( + '?imageState=N4IgDiBcpghg5gUwMoEsBeioEYBsAacBRASQDsATRADxwF86g' + ); + + await user.click(screen.getByRole('button', { name: 'Go to page 1' })); + + await waitFor(() => { + expect(screen.getAllByText('logo1.png').length).toEqual(8); + }); + expect(router.state.location.search).toBe(''); + }); + + it('can change the table filters and clear the table filters', async () => { + createView(); + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); + + expect((await screen.findAllByText('logo1.png')).length).toEqual(8); + + await user.click(screen.getByText('Show Filters')); + + expect(await screen.findByText('Hide Filters')).toBeInTheDocument(); + + const nameInput = screen.getByLabelText('Filter by File name'); + await user.type(nameInput, 'stfc-logo-blue-text.png'); + await waitFor(() => { + expect(screen.queryByText('logo1.png')).not.toBeInTheDocument(); + }); + const clearFiltersButton = screen.getByRole('button', { + name: 'Clear Filters', + }); + + await user.click(clearFiltersButton); + expect((await screen.findAllByText('logo1.png')).length).toEqual(8); + + expect(clearFiltersButton).toBeDisabled(); + }); + + it('toggles filter visibility when clicking the toggle button', async () => { + createView(); + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); + + expect((await screen.findAllByText('logo1.png')).length).toEqual(8); + + expect(screen.queryByText('Hide Filters')).not.toBeInTheDocument(); + expect(screen.getByText('Show Filters')).toBeInTheDocument(); + + await user.click(screen.getByText('Show Filters')); + + expect(screen.getByText('Hide Filters')).toBeInTheDocument(); + expect(screen.getByText('Images per page')).toBeInTheDocument(); + + await user.click(screen.getByText('Hide Filters')); + + expect(screen.queryByText('Hide Filters')).not.toBeInTheDocument(); + expect(screen.getByText('Show Filters')).toBeInTheDocument(); + }); + it('opens full-size image when thumbnail is clicked, navigates to the next image, and then navigates to a third image that failed to upload, falling back to a placeholder', async () => { createView(); @@ -178,5 +257,16 @@ describe('Image Gallery', () => { await waitFor(() => { expect(axiosGetSpy).toHaveBeenCalledTimes(4); }); + + await user.click( + within(screen.getByRole('dialog')).getByLabelText('Close') + ); + + await waitFor( + () => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }, + { timeout: 5000 } + ); }, 20000); }); diff --git a/src/common/images/imageGallery.component.tsx b/src/common/images/imageGallery.component.tsx index 10ff0bbbc..a30138a42 100644 --- a/src/common/images/imageGallery.component.tsx +++ b/src/common/images/imageGallery.component.tsx @@ -1,22 +1,35 @@ import { MoreHoriz } from '@mui/icons-material'; +import ClearIcon from '@mui/icons-material/Clear'; import { Box, + Button, Card, Checkbox, + Collapse, Grid, IconButton, LinearProgress, Typography, } from '@mui/material'; import { useQueryClient } from '@tanstack/react-query'; +import { + MRT_BottomToolbar, + MRT_ColumnDef, + useMaterialReactTable, +} from 'material-react-table'; import PhotoSwipe, { SlideData } from 'photoswipe'; import 'photoswipe/dist/photoswipe.css'; import React from 'react'; import { Gallery, GalleryProps, Item } from 'react-photoswipe-gallery'; import { getImage, useGetImages } from '../../api/images'; -import { OverflowTip } from '../../utils'; +import { displayTableRowCountText, OverflowTip } from '../../utils'; +import { usePreservedTableState } from '../preservedTableState.component'; import ThumbnailImage from './thumbnailImage.component'; +import { MRT_Localization_EN } from 'material-react-table/locales/en'; +import { APIImage } from '../../api/api.types'; +import CardViewFilters from '../cardView/cardViewFilters.component'; + const MAX_HEIGHT_THUMBNAIL = 300; export interface ImageGalleryProps { @@ -27,6 +40,14 @@ const ImageGallery = (props: ImageGalleryProps) => { const { entityId } = props; const { data: images, isLoading: imageIsLoading } = useGetImages(entityId); const queryClient = useQueryClient(); + const { preservedState, onPreservedStatesChange } = usePreservedTableState({ + initialState: { + pagination: { pageSize: 16, pageIndex: 0 }, + }, + storeInUrl: true, + paginationOnly: true, + urlParamName: 'imageState', + }); const onBeforeOpen = React.useCallback( (pswpInstance: PhotoSwipe) => { @@ -128,114 +149,140 @@ const ImageGallery = (props: ImageGalleryProps) => { }, ]; - return ( - <> - {images && ( - - - {images.map((image, index) => { - return ( - - - - - - + const titles = Array.from( + new Set( + images + ?.map((image) => image.title) + .filter((title): title is string => Boolean(title)) + ) + ); - - - {({ ref, open }) => { - return ( - - ); - }} - - + const descriptions = Array.from( + new Set( + images + ?.map((image) => image.description) + .filter((description): description is string => Boolean(description)) + ) + ); + const columns = React.useMemo[]>(() => { + return [ + { + header: 'File name', + accessorFn: (row) => row.file_name, + id: 'name', + size: 300, + }, + { + header: 'Last modified', + accessorFn: (row) => new Date(row.modified_time), + id: 'modified_time', + filterVariant: 'datetime-range', + filterFn: 'betweenInclusive', + size: 500, + enableGrouping: false, + }, - - - - - - - - - {image.file_name} - - - - - - ); - })} - - - )} + { + header: 'Created', + accessorFn: (row) => new Date(row.modified_time), + id: 'created', + filterVariant: 'datetime-range', + filterFn: 'betweenInclusive', + size: 500, + enableGrouping: false, + }, + { + header: 'Title', + accessorFn: (row) => row.title, + id: 'title', + size: 350, + filterVariant: 'autocomplete', + filterSelectOptions: titles, + enableGrouping: false, + }, + { + header: 'Description', + accessorFn: (row) => row.description, + id: 'description', + size: 350, + filterVariant: 'autocomplete', + filterSelectOptions: descriptions, + enableGrouping: false, + }, + ]; + }, [descriptions, titles]); + const table = useMaterialReactTable({ + // Data + columns: columns, + data: images ?? [], + // Features + enableColumnOrdering: false, + enableColumnPinning: false, + enableTopToolbar: true, + enableFacetedValues: true, + enableRowActions: false, + enableGlobalFilter: false, + enableStickyHeader: true, + enableRowSelection: false, + enableDensityToggle: false, + enableTableFooter: true, + enableColumnFilters: true, + enableHiding: false, + enableFullScreenToggle: false, + enablePagination: true, + // Other settings + paginationDisplayMode: 'pages', + positionToolbarAlertBanner: 'bottom', + autoResetPageIndex: false, + // Localisation + localization: { + ...MRT_Localization_EN, + rowsPerPage: 'Images per page', + }, + // State + initialState: { + showColumnFilters: true, + showGlobalFilter: true, + }, + state: { + ...preservedState, + }, + muiSearchTextFieldProps: { + size: 'small', + variant: 'outlined', + }, + muiPaginationProps: { + color: 'secondary', + rowsPerPageOptions: [16, 24, 32], + shape: 'rounded', + variant: 'outlined', + }, + // Functions + ...onPreservedStatesChange, + renderBottomToolbarCustomActions: ({ table }) => + displayTableRowCountText(table, images, 'Images', { + paddingLeft: '8px', + }), + }); + + const [isCollapsed, setIsCollapsed] = React.useState(true); + + const handleToggle = () => { + setIsCollapsed(!isCollapsed); + }; + const data = table + .getSortedRowModel() + .rows.map( + (row) => row.getVisibleCells().map((cell) => cell.row.original)[0] + ); + const displayedImages = table + .getPaginationRowModel() + .rows.map( + (row) => row.getVisibleCells().map((cell) => cell.row.original)[0] + ); + + return ( + <> {!imageIsLoading ? ( (!images || images.length === 0) && ( { )} + {images && images.length !== 0 && ( + + + + + + + + + + + {isCollapsed ? 'Show Filters' : 'Hide Filters'} + + + + + + {data.map((image, index) => { + const isUndisplayed = !displayedImages?.some( + (img) => img.id === image.id + ); + + return isUndisplayed ? ( + + {({ ref }) => { + return ; + }} + + ) : ( + + + + + + + + + + {({ ref, open }) => { + return ( + + ); + }} + + + + + + + + + + + + {image.file_name} + + + + + + ); + })} + + + + + + + + )} ); }; diff --git a/src/common/images/thumbnailImage.component.tsx b/src/common/images/thumbnailImage.component.tsx index a7b91a977..427c636ed 100644 --- a/src/common/images/thumbnailImage.component.tsx +++ b/src/common/images/thumbnailImage.component.tsx @@ -1,10 +1,10 @@ import { Box, Typography } from '@mui/material'; import React from 'react'; -import { Image } from '../../api/api.types'; +import { APIImage } from '../../api/api.types'; export interface ThumbnailImageProps { open: (e: React.MouseEvent) => void; - image: Image; + image: APIImage; index: number; maxHeightThumbnail: number; } diff --git a/src/common/preservedTableState.component.tsx b/src/common/preservedTableState.component.tsx index 94213b3dd..4770c3814 100644 --- a/src/common/preservedTableState.component.tsx +++ b/src/common/preservedTableState.component.tsx @@ -1,8 +1,8 @@ import { ColumnFilter } from '@tanstack/react-table'; import LZString from 'lz-string'; import { - MRT_ColumnFiltersState, MRT_ColumnFilterFnsState, + MRT_ColumnFiltersState, MRT_ColumnOrderState, MRT_GroupingState, MRT_PaginationState, @@ -46,7 +46,7 @@ interface StateSearchParams extends StatePartial { /* This matches the definition found in tanstack table (couldn't be directly imported as its a dependency of MRT) */ -type Updater = T | ((old: T) => T); +export type Updater = T | ((old: T) => T); /* Returns correctly types value from an updater */ const getValueFromUpdater = (updater: Updater, currentValue: T) =>