From a85ba0d59fe23c77d8168fbfea9c623422a6d5fc Mon Sep 17 00:00:00 2001 From: Tom Chapman Date: Thu, 15 Aug 2024 11:32:47 -0700 Subject: [PATCH] image cropping before upload and tests --- .../EngagementFormTabs/EngagementForm.tsx | 3 +- .../form/EngagementFormTabs/constants.ts | 2 + .../components/image/listing/ImageContext.tsx | 66 +++++--- .../components/image/listing/ImageListing.tsx | 43 +++-- .../src/components/imageUpload/Uploader.tsx | 20 +-- .../src/components/imageUpload/cropModal.tsx | 12 +- met-web/src/components/imageUpload/index.tsx | 7 +- .../components/image/ImageListing.test.tsx | 155 ++++++++++++++++++ 8 files changed, 251 insertions(+), 57 deletions(-) create mode 100644 met-web/tests/unit/components/image/ImageListing.test.tsx diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx index bc508c604..fd74d5ea5 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx +++ b/met-web/src/components/engagement/form/EngagementFormTabs/EngagementForm.tsx @@ -9,7 +9,7 @@ import { If, Then, Else } from 'react-if'; import { EngagementTabsContext } from './EngagementTabsContext'; import { EngagementStatus, SUBMISSION_STATUS } from 'constants/engagementStatus'; import DayCalculatorModal from '../DayCalculator'; -import { ENGAGEMENT_CROPPER_ASPECT_RATIO, ENGAGEMENT_UPLOADER_HEIGHT } from './constants'; +import { ENGAGEMENT_CROPPER_ASPECT_RATIO, ENGAGEMENT_CROPPER_TEXT, ENGAGEMENT_UPLOADER_HEIGHT } from './constants'; import RichTextEditor from 'components/common/RichTextEditor'; import { getTextFromDraftJsContentState } from 'components/common/RichTextEditor/utils'; @@ -304,6 +304,7 @@ const EngagementForm = () => { savedImageName={savedEngagement.banner_filename} height={ENGAGEMENT_UPLOADER_HEIGHT} cropAspectRatio={ENGAGEMENT_CROPPER_ASPECT_RATIO} + cropText={ENGAGEMENT_CROPPER_TEXT} /> diff --git a/met-web/src/components/engagement/form/EngagementFormTabs/constants.ts b/met-web/src/components/engagement/form/EngagementFormTabs/constants.ts index b355252a4..27fddc5de 100644 --- a/met-web/src/components/engagement/form/EngagementFormTabs/constants.ts +++ b/met-web/src/components/engagement/form/EngagementFormTabs/constants.ts @@ -9,3 +9,5 @@ export const ENGAGEMENT_FORM_TABS: { [x: string]: EngagementFormTabValues } = { export const ENGAGEMENT_UPLOADER_HEIGHT = '360px'; export const ENGAGEMENT_CROPPER_ASPECT_RATIO = 1920 / 700; +export const ENGAGEMENT_CROPPER_TEXT = + 'The image will be cropped at the correct ratio to display as a banner on MET. You can zoom in or out and move the image around. Please note that part of the image could be hidden depending on the display size.'; diff --git a/met-web/src/components/image/listing/ImageContext.tsx b/met-web/src/components/image/listing/ImageContext.tsx index cc542a830..7d55f5c1f 100644 --- a/met-web/src/components/image/listing/ImageContext.tsx +++ b/met-web/src/components/image/listing/ImageContext.tsx @@ -10,7 +10,8 @@ import { useAppDispatch } from 'hooks'; export interface ImageListingContext { images: ImageInfo[]; - handleUploadImage: (_files: File[]) => void; + handleTempUpload: (_files: File[]) => void; + handleUploadImage: () => void; searchText: string; setSearchText: (value: string) => void; paginationOptions: PaginationOptions; @@ -19,11 +20,15 @@ export interface ImageListingContext { setPageInfo: (value: PageInfo) => void; tableLoading: boolean; imageToDisplay: ImageInfo | undefined; + imageToUpload: File | null; } export const ImageContext = createContext({ images: [], - handleUploadImage: (_files: File[]) => { + handleTempUpload: (_files: File[]) => { + /* empty default method */ + }, + handleUploadImage: () => { /* empty default method */ }, searchText: '', @@ -45,6 +50,7 @@ export const ImageContext = createContext({ }, tableLoading: false, imageToDisplay: undefined, + imageToUpload: null, }); export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Element[] }) => { @@ -64,17 +70,31 @@ export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Elemen const [tableLoading, setTableLoading] = useState(true); const [pageInfo, setPageInfo] = useState(createDefaultPageInfo()); const [images, setImages] = useState>([]); + const [imageToUpload, setImageToUpload] = useState(null); const dispatch = useAppDispatch(); const { page, size, sort_key, sort_order } = paginationOptions; - const handleUploadImage = async (files: File[]) => { + const handleTempUpload = async (files: File[]) => { if (files.length > 0) { - const [uniquefilename, fileName]: string[] = (await handleSaveImage(files[0])) || []; + setImageToDisplay(undefined); + setImageToUpload(files[0]); + return; + } + setImageToUpload(null); + }; + + const handleUploadImage = async () => { + if (!imageToUpload) return; + try { + const [uniquefilename, fileName]: string[] = (await handleSaveImageToS3(imageToUpload)) || []; createImage(uniquefilename, fileName); + setImageToUpload(null); + } catch (error) { + console.log(error); } }; - const handleSaveImage = async (file: File) => { + const handleSaveImageToS3 = async (file: File) => { if (!file) { return; } @@ -87,6 +107,22 @@ export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Elemen } }; + const createImage = async (unqiueFilename: string, fileName: string) => { + setImageToDisplay(undefined); + const date_uploaded = new Date(); + try { + const image: ImageInfo = await postImage({ + unique_name: unqiueFilename, + display_name: fileName, + date_uploaded, + }); + setPaginationOptions({ page: 1, size: 10 }); + setImageToDisplay(image); + } catch (err) { + dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating image' })); + } + }; + const fetchImages = async () => { try { setTableLoading(true); @@ -108,22 +144,6 @@ export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Elemen } }; - const createImage = async (unqiueFilename: string, fileName: string) => { - setImageToDisplay(undefined); - const date_uploaded = new Date(); - try { - const image: ImageInfo = await postImage({ - unique_name: unqiueFilename, - display_name: fileName, - date_uploaded, - }); - setPaginationOptions({ page: 1, size: 10 }); - setImageToDisplay(image); - } catch (err) { - dispatch(openNotification({ severity: 'error', text: 'Error occurred while creating image' })); - } - }; - useEffect(() => { updateURLWithPagination(paginationOptions); fetchImages(); @@ -133,7 +153,7 @@ export const ImageProvider = ({ children }: { children: JSX.Element | JSX.Elemen {children} diff --git a/met-web/src/components/image/listing/ImageListing.tsx b/met-web/src/components/image/listing/ImageListing.tsx index 5818f5b0c..936921f64 100644 --- a/met-web/src/components/image/listing/ImageListing.tsx +++ b/met-web/src/components/image/listing/ImageListing.tsx @@ -13,11 +13,12 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import { Else, If, Then } from 'react-if'; import { openNotification } from 'services/notificationService/notificationSlice'; import { useAppDispatch } from 'hooks'; +import { formatDate } from 'components/common/dateHelper'; const ImageListing = () => { const { images, - handleUploadImage, + handleTempUpload, paginationOptions, searchText, setSearchText, @@ -25,6 +26,8 @@ const ImageListing = () => { pageInfo, setPaginationOptions, imageToDisplay, + handleUploadImage, + imageToUpload, } = useContext(ImageContext); const dispatch = useAppDispatch(); @@ -36,7 +39,7 @@ const ImageListing = () => { const headCells: HeadCell[] = [ { - key: 'display_name', + key: 'url', label: '', disablePadding: true, allowSort: true, @@ -69,13 +72,9 @@ const ImageListing = () => { allowSort: true, numeric: false, renderCell: (row: ImageInfo) => { - const date = new Date(row.date_uploaded); - const year = date.getFullYear(); - const month = (date.getMonth() + 1).toString().padStart(2, '0'); // Months are 0-indexed, pad with leading zero - const day = date.getDate().toString().padStart(2, '0'); // Pad with leading zero return ( - {`${year}-${month}-${day}`} + {formatDate(row.date_uploaded)} ); }, @@ -115,15 +114,31 @@ const ImageListing = () => { - + + + + + { + handleUploadImage(); + }} + size="small" + > + Upload + + + + + + - + { - Uploaded Files @@ -171,7 +185,10 @@ const ImageListing = () => { onChange={(e) => setSearchText(e.target.value)} size="small" /> - setPaginationOptions({ page: 1, size: 10 })}> + setPaginationOptions({ page: 1, size: 10 })} + > diff --git a/met-web/src/components/imageUpload/Uploader.tsx b/met-web/src/components/imageUpload/Uploader.tsx index 7ba0dc6d1..228c769a8 100644 --- a/met-web/src/components/imageUpload/Uploader.tsx +++ b/met-web/src/components/imageUpload/Uploader.tsx @@ -3,7 +3,6 @@ import { Grid, Stack, Typography } from '@mui/material'; import Dropzone, { Accept } from 'react-dropzone'; import { PrimaryButton, SecondaryButton } from 'components/common'; import { ImageUploadContext } from './imageUploadContext'; -import { When } from 'react-if'; interface UploaderProps { margin?: number; @@ -17,7 +16,6 @@ const Uploader = ({ helpText = 'Drag and drop some files here, or click to select files', height = '10em', accept = {}, - canCrop = true, }: UploaderProps) => { const { handleAddFile, @@ -89,16 +87,14 @@ const Uploader = ({ > Remove - - { - setCropModalOpen(true); - }} - size="small" - > - Crop - - + { + setCropModalOpen(true); + }} + size="small" + > + Crop + diff --git a/met-web/src/components/imageUpload/cropModal.tsx b/met-web/src/components/imageUpload/cropModal.tsx index c925802e5..e1abb8364 100644 --- a/met-web/src/components/imageUpload/cropModal.tsx +++ b/met-web/src/components/imageUpload/cropModal.tsx @@ -8,7 +8,11 @@ import { Box } from '@mui/system'; import getCroppedImg from './cropImage'; import { blobToFile } from 'utils'; -export const CropModal = () => { +interface CropModalProps { + cropText?: string; +} + +export const CropModal = ({ cropText }: CropModalProps) => { const { existingImageUrl, addedImageFileUrl, @@ -92,11 +96,7 @@ export const CropModal = () => { > - - The image will be cropped at the correct ratio to display as a banner on MET. You - can zoom in or out and move the image around. Please note that part of the image - could be hidden depending on the display size. - + {cropText} { return ( - - + + ); }; diff --git a/met-web/tests/unit/components/image/ImageListing.test.tsx b/met-web/tests/unit/components/image/ImageListing.test.tsx new file mode 100644 index 000000000..b21ca16b2 --- /dev/null +++ b/met-web/tests/unit/components/image/ImageListing.test.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { ReactNode } from 'react'; +import { USER_ROLES } from 'services/userService/constants'; +import * as reactRedux from 'react-redux'; +import * as notificationSlice from 'services/notificationService/notificationSlice'; +import * as imageService from 'services/imageService'; +import { setupEnv } from '../setEnvVars'; +import ImageListing from 'components/image/listing/ImageListing'; +import { ImageProvider } from 'components/image/listing/ImageContext'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { formatDate } from 'components/common/dateHelper'; +import assert from 'assert'; + +const mockImageOne = { + id: 1, + display_name: 'Pond.png', + unique_name: '123456789', + date_uploaded: '2024-08-14 10:00:00', + url: 'randomurl1.com', +}; + +const mockImageTwo = { + id: 2, + display_name: 'Tree.png', + unique_name: 'abcdefg', + date_uploaded: '2024-08-13 10:00:00', + url: 'randomurl2.com', +}; + +jest.mock('axios'); + +jest.mock('@mui/material', () => ({ + ...jest.requireActual('@mui/material'), + Link: ({ children }: { children: ReactNode }) => { + return {children}; + }, + useMediaQuery: () => false, +})); + +jest.mock('components/common', () => ({ + ...jest.requireActual('components/common'), + PrimaryButton: ({ children, ...rest }: { children: ReactNode; [prop: string]: unknown }) => { + return ; + }, +})); + +jest.mock('components/permissionsGate', () => ({ + ...jest.requireActual('components/permissionsGate'), + PermissionsGate: ({ children }: { children: ReactNode }) => { + return <>{children}; + }, +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: jest.fn(), + useLocation: jest.fn(() => ({ + search: '', + })), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(() => { + return { + roles: [USER_ROLES.CREATE_IMAGES], + }; + }), +})); + +describe('Image listing page tests', () => { + jest.spyOn(reactRedux, 'useDispatch').mockImplementation(() => jest.fn()); + jest.spyOn(notificationSlice, 'openNotification').mockImplementation(jest.fn()); + const getImagesMock = jest.spyOn(imageService, 'getImages'); + + beforeEach(() => { + setupEnv(); + }); + + test('Image table is rendered and images are fetched', async () => { + getImagesMock.mockReturnValue( + Promise.resolve({ + items: [mockImageOne, mockImageTwo], + total: 2, + }), + ); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(mockImageOne.display_name)).toBeInTheDocument(); + expect(screen.getByText(formatDate(mockImageOne.date_uploaded))).toBeInTheDocument(); + expect(screen.getByText(mockImageOne.url)).toBeInTheDocument(); + + expect(screen.getByText(mockImageTwo.display_name)).toBeInTheDocument(); + expect(screen.getByText(formatDate(mockImageTwo.date_uploaded))).toBeInTheDocument(); + expect(screen.getByText(mockImageTwo.url)).toBeInTheDocument(); + }); + }); + + test('Search bar works and fetches images with search text', async () => { + getImagesMock.mockReturnValue( + Promise.resolve({ + items: [mockImageOne], + total: 1, + }), + ); + + const { container } = render( + + + , + ); + + await waitFor(() => { + expect(screen.getByText(mockImageOne.display_name)).toBeInTheDocument(); + expect(screen.getByText(formatDate(mockImageOne.date_uploaded))).toBeInTheDocument(); + expect(screen.getByText(mockImageOne.url)).toBeInTheDocument(); + }); + + const searchField = container.querySelector('input[name="searchText"]'); + assert(searchField, 'Unable to find search field that matches the given query'); + + fireEvent.change(searchField, { target: { value: 'Pond' } }); + fireEvent.click(screen.getByTestId('image/listing/searchButton')); + + await waitFor(() => { + expect(getImagesMock).lastCalledWith({ + page: 1, + size: 10, + search_text: 'Pond', + }); + }); + }); + + test('Image uploader renders in image listing', async () => { + render( + + + , + ); + + await waitFor(() => { + expect( + screen.getByText( + 'Drag and drop an image here, or click to select an image from your device. Formats accepted are: jpg, png, webp.', + ), + ).toBeInTheDocument(); + }); + }); +});