diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index 0e46a41fd..677340851 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -893,6 +893,139 @@ describe('Items', () => { cy.findByTestId('galleryLightBox').should('not.exist'); }); + + it('edits an image successfully', () => { + 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('Edit').last().click(); + + cy.findByRole('dialog') + .should('be.visible') + .within(() => { + cy.findByLabelText('File Name *').clear(); + cy.findByLabelText('File Name *').type('test'); + + cy.findByLabelText('Title').clear(); + cy.findByLabelText('Title').type('test'); + + cy.findByLabelText('Description').clear(); + cy.findByLabelText('Description').type('test'); + cy.findByLabelText('Description') + .invoke('val') + .should('equal', 'test'); + }); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Save' }).click(); + cy.findByRole('dialog').should('not.exist'); + + cy.findBrowserMockedRequests({ + method: 'PATCH', + url: '/images/:id', + }).should(async (patchRequests) => { + expect(patchRequests.length).equal(1); + const request = patchRequests[0]; + expect(JSON.stringify(await request.json())).equal( + '{"file_name":"test","title": "test","description":"test"}' + ); + }); + }); + + it('not changing any fields shows error', () => { + 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('Edit').last().click(); + + cy.findByRole('dialog') + .should('be.visible') + .within(() => { + cy.findByRole('button', { name: 'Save' }).click(); + cy.contains( + "There have been no changes made. Please change a field's value or press Cancel to exit." + ); + }); + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); + }); + + it('Required fields that are cleared are not allowed and show error message', () => { + 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('Edit').last().click(); + + cy.findByRole('dialog') + .should('be.visible') + .within(() => { + cy.findByLabelText('File Name *').clear(); + + cy.findByRole('button', { name: 'Save' }).click(); + cy.contains('Please enter a file name.'); + }); + cy.findByRole('button', { name: 'Save' }).should('be.disabled'); + }); + + it('opens edit 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('Edit').last().click(); + + cy.findByRole('dialog', { timeout: 10000 }).should('exist'); + + cy.findByRole('dialog').within(() => { + cy.findByText('Edit Image').should('exist'); + }); + + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Cancel' }).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/images.test.tsx b/src/api/images.test.tsx index e4a20b8ce..23ad0b777 100644 --- a/src/api/images.test.tsx +++ b/src/api/images.test.tsx @@ -1,7 +1,8 @@ import { renderHook, waitFor } from '@testing-library/react'; import ImagesJSON from '../mocks/Images.json'; import { hooksWrapperWithProviders } from '../testUtils'; -import { useGetImage, useGetImages } from './images'; +import { ImagePatch } from './api.types'; +import { useGetImage, useGetImages, usePatchImage } from './images'; describe('images api functions', () => { afterEach(() => { @@ -62,4 +63,28 @@ describe('images api functions', () => { }); }); }); + + describe('usePatchImage', () => { + let mockDataPatch: ImagePatch; + beforeEach(() => { + mockDataPatch = { + file_name: 'edited_image.jpeg', + title: 'an edited title', + description: 'an edited description', + }; + }); + it('sends a patch request to edit an image and returns a successful response', async () => { + const { result } = renderHook(() => usePatchImage(), { + wrapper: hooksWrapperWithProviders(), + }); + + result.current.mutate({ id: '1', image: mockDataPatch }); + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); + + expect(result.current.data).toEqual({ + ...ImagesJSON[0], + ...mockDataPatch, + }); + }); + }); }); diff --git a/src/common/images/editImageDialog.component.test.tsx b/src/common/images/editImageDialog.component.test.tsx new file mode 100644 index 000000000..3e4b26480 --- /dev/null +++ b/src/common/images/editImageDialog.component.test.tsx @@ -0,0 +1,155 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent, { UserEvent } from '@testing-library/user-event'; +import { http } from 'msw'; +import { MockInstance } from 'vitest'; +import { storageApi } from '../../api/api'; +import handleIMS_APIError from '../../handleIMS_APIError'; +import ImagesJSON from '../../mocks/Images.json'; +import { server } from '../../mocks/server'; +import { renderComponentWithRouterProvider } from '../../testUtils'; +import EditImageDialog, { ImageDialogProps } from './editImageDialog.component'; + +vi.mock('../../handleIMS_APIError'); + +describe('Edit image dialog', () => { + const onClose = vi.fn(); + let props: ImageDialogProps; + let user: UserEvent; + const createView = () => { + return renderComponentWithRouterProvider(); + }; + + beforeEach(() => { + props = { + open: true, + onClose: onClose, + }; + user = userEvent.setup(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + const modifyImageValues = (values: { + file_name?: string; + title?: string; + description?: string; + }) => { + if (values.file_name !== undefined) + fireEvent.change(screen.getByLabelText('File Name *'), { + target: { value: values.file_name }, + }); + + if (values.title !== undefined) + fireEvent.change(screen.getByLabelText('Title'), { + target: { value: values.title }, + }); + + if (values.description !== undefined) + fireEvent.change(screen.getByLabelText('Description'), { + target: { value: values.description }, + }); + }; + + describe('Edit an image', () => { + let axiosPatchSpy: MockInstance; + beforeEach(() => { + props = { + ...props, + selectedImage: ImagesJSON[0], + }; + + axiosPatchSpy = vi.spyOn(storageApi, 'patch'); + }); + + it('disables save button and shows circular progress indicator when request is pending', async () => { + server.use( + http.patch('/images/:id', () => { + return new Promise(() => {}); + }) + ); + + createView(); + + modifyImageValues({ + file_name: 'Image A', + }); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + await user.click(saveButton); + + expect(saveButton).toBeDisabled(); + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + }); + + it('Edits an image correctly', async () => { + createView(); + modifyImageValues({ + file_name: 'test_file_name.jpeg', + title: 'Test Title', + description: 'Test Description', + }); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + + await user.click(saveButton); + + expect(axiosPatchSpy).toHaveBeenCalledWith('/images/1', { + file_name: 'test_file_name.jpeg', + title: 'Test Title', + description: 'Test Description', + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it('No values changed shows correct error message', async () => { + createView(); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + + await user.click(saveButton); + + expect( + screen.getByText( + "There have been no changes made. Please change a field's value or press Cancel to exit." + ) + ).toBeInTheDocument(); + }); + + it('Required fields show error if they are whitespace or current value just removed', async () => { + createView(); + modifyImageValues({ + file_name: '', + }); + const saveButton = screen.getByRole('button', { name: 'Save' }); + + await user.click(saveButton); + + expect(screen.getByText('Please enter a file name.')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('CatchAllError request works correctly and displays refresh page message', async () => { + createView(); + modifyImageValues({ + file_name: 'Error 500', + }); + const saveButton = screen.getByRole('button', { name: 'Save' }); + + await user.click(saveButton); + + expect(handleIMS_APIError).toHaveBeenCalled(); + }); + + it('calls onClose when Close button is clicked', async () => { + createView(); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/common/images/editImageDialog.component.tsx b/src/common/images/editImageDialog.component.tsx index 627a02486..500807561 100644 --- a/src/common/images/editImageDialog.component.tsx +++ b/src/common/images/editImageDialog.component.tsx @@ -24,7 +24,7 @@ import handleIMS_APIError from '../../handleIMS_APIError'; export interface ImageDialogProps { open: boolean; onClose: () => void; - selectedImage: APIImage; + selectedImage?: APIImage; } const EditImageDialog = (props: ImageDialogProps) => { @@ -33,7 +33,12 @@ const EditImageDialog = (props: ImageDialogProps) => { const { mutateAsync: patchImage, isPending: isEditPending } = usePatchImage(); const initalImage: ImagePatch = React.useMemo( - () => selectedImage, + () => + selectedImage ?? { + file_name: '', + title: '', + description: '', + }, [selectedImage] ); diff --git a/src/common/images/imageGallery.component.test.tsx b/src/common/images/imageGallery.component.test.tsx index 0b32ce35f..8f4087079 100644 --- a/src/common/images/imageGallery.component.test.tsx +++ b/src/common/images/imageGallery.component.test.tsx @@ -167,6 +167,32 @@ describe('Image Gallery', () => { }); }); + it('opens image edit dialog and can close the dialog', async () => { + createView(); + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); + + expect((await screen.findAllByText('logo1.png')).length).toEqual(8); + + const actionMenus = screen.getAllByLabelText(`Card Actions`); + await user.click(actionMenus[0]); + + const editButton = await screen.findByText(`Edit`); + await user.click(editButton); + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + it('opens full-size image when thumbnail is clicked and navigates to the next image', async () => { createView(); @@ -362,4 +388,57 @@ describe('Image Gallery', () => { expect(screen.queryByTestId('galleryLightBox')).not.toBeInTheDocument(); }); }); + + it('opens edit dialog in lightbox', async () => { + createView(); + + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument() + ); + const thumbnail = await screen.findAllByAltText('test'); + await user.click(thumbnail[0]); + + expect(axiosGetSpy).toHaveBeenCalledWith('/images/1'); + await waitFor(() => { + expect( + screen.getByText('File name: stfc-logo-blue-text.png') + ).toBeInTheDocument(); + }); + expect(screen.getByText('Title: stfc-logo-blue-text')).toBeInTheDocument(); + expect(screen.getByText('test')).toBeInTheDocument(); + + const galleryLightBox = within(screen.getByTestId('galleryLightBox')); + + const imageElement1 = await galleryLightBox.findByAltText(`test`); + + expect(imageElement1).toBeInTheDocument(); + + expect(imageElement1).toHaveAttribute( + 'src', + `http://localhost:3000/images/stfc-logo-blue-text.png?text=1` + ); + + await user.click(galleryLightBox.getByLabelText('Image Actions')); + + const editButton = await screen.findByText(`Edit`); + + await user.click(editButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + expect( + within(screen.getByRole('dialog')).getByText('Edit Image') + ).toBeInTheDocument(); + await user.click( + within(screen.getByRole('dialog')).getByRole('button', { name: 'Cancel' }) + ); + + await user.click(screen.getByLabelText('Close')); + + await waitFor(() => { + expect(screen.queryByTestId('galleryLightBox')).not.toBeInTheDocument(); + }); + }); });