diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index cb1db74fd..15109a324 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -575,7 +575,7 @@ describe('Items', () => { cy.findByRole('button', { name: 'Remove file' }).click(); - //TODO: Assert axios delete request was called + // TODO: Assert axios delete request was called cy.findByText('Upload 1 file').should('not.exist'); }); @@ -930,7 +930,93 @@ describe('Items', () => { cy.findByTestId('galleryLightBox').should('not.exist'); }); - it('deletes an image', () => { + 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.findByText('.png').should('exist'); + cy.findByLabelText('File Name *').type('test file'); + + cy.findByLabelText('Title').clear(); + cy.findByLabelText('Title').type('test title'); + + cy.findByLabelText('Description').clear(); + cy.findByLabelText('Description').type('test description'); + }); + + 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 file.png","description":"test description","title":"test title"}' + ); + }); + }); + + it('shows error message when no fields have been changed', () => { + 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('shows error message when required fields are cleared', () => { + 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' @@ -955,234 +1041,278 @@ describe('Items', () => { cy.findByLabelText('Image Actions').click(); }); + cy.findAllByText('Edit').last().click(); - cy.findAllByText('Delete').last().click(); - - cy.startSnoopingBrowserMockedRequest(); + cy.findByRole('dialog', { timeout: 10000 }).should('exist'); - cy.findByRole('button', { name: 'Continue' }).click(); + cy.findByRole('dialog').within(() => { + cy.findByText('Edit Image').should('exist'); + }); - cy.findBrowserMockedRequests({ - method: 'DELETE', - url: '/images/:id', - }).should((patchRequests) => { - expect(patchRequests.length).equal(1); - const request = patchRequests[0]; - expect(request.url.toString()).to.contain('1'); + cy.findByRole('dialog').within(() => { + cy.findByRole('button', { name: 'Cancel' }).click(); }); - }); - }); - it('deletes an item', () => { - cy.findAllByLabelText('Row Actions').first().click(); - cy.findByText('Delete').click(); + cy.findByRole('dialog').should('not.exist'); - cy.findByText('Serial Number: 5YUQDDjKpz2z').should('exist'); + cy.findByLabelText('Close').click(); - cy.startSnoopingBrowserMockedRequest(); + cy.findByTestId('galleryLightBox').should('not.exist'); - cy.findByRole('button', { name: 'Continue' }).click(); + it('deletes an image', () => { + cy.findByText('5YUQDDjKpz2z').click(); + cy.findByText( + 'High-resolution cameras for beam characterization. 1' + ).should('exist'); - cy.findBrowserMockedRequests({ - method: 'DELETE', - url: '/v1/items/:id', - }).should((patchRequests) => { - expect(patchRequests.length).equal(1); - const request = patchRequests[0]; - expect(request.url.toString()).to.contain('KvT2Ox7n'); - }); - }); + cy.findByText('Gallery').click(); - it('duplicates an item', () => { - cy.findAllByLabelText('Row Actions').first().click(); - cy.findByText('Duplicate').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.startSnoopingBrowserMockedRequest(); + cy.findByAltText('test').should('exist'); - cy.findByRole('button', { name: 'Next' }).click(); - cy.findByRole('button', { name: 'Next' }).click(); - cy.findByRole('button', { name: 'Finish' }).click(); - cy.findByRole('dialog').should('not.exist'); + cy.findByAltText('test') + .should('have.attr', 'src') + .and( + 'include', + 'http://localhost:3000/images/stfc-logo-blue-text.png?text=1' + ); - cy.findBrowserMockedRequests({ - method: 'POST', - url: '/v1/items', - }).should(async (postRequests) => { - expect(postRequests.length).eq(1); - expect(JSON.stringify(await postRequests[0].json())).equal( - JSON.stringify({ - purchase_order_number: '6JYHEjwN', - is_defective: false, - usage_status_id: '1', - warranty_end_date: '2023-04-04T23:00:00.000Z', - asset_number: 'LyH8yp1FHf', - serial_number: '5YUQDDjKpz2z', - delivered_date: '2023-03-17T00:00:00.000Z', - notes: - '6Y5XTJfBrNNx8oltI9HE\n\nThis is a copy of the item with this Serial Number: 5YUQDDjKpz2z', - properties: [ - { id: '1', value: 0 }, - { id: '2', value: null }, - { id: '3', value: 'CMOS' }, - { id: '4', value: null }, - { id: '5', value: true }, - { id: '6', value: null }, - ], - catalogue_item_id: '1', - system_id: '65328f34a40ff5301575a4e3', - }) - ); + cy.findByLabelText('Image Actions').click(); + }); + + cy.findAllByText('Delete').last().click(); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Continue' }).click(); + + cy.findBrowserMockedRequests({ + method: 'DELETE', + url: '/images/:id', + }).should((patchRequests) => { + expect(patchRequests.length).equal(1); + const request = patchRequests[0]; + expect(request.url.toString()).to.contain('1'); + }); + }); }); - }); - it('should display a link a system in the delete dialog when the item has a system id', () => { - cy.findAllByLabelText('Row Actions').last().click(); - cy.findByText('Delete').click(); + it('deletes an item', () => { + cy.findAllByLabelText('Row Actions').first().click(); + cy.findByText('Delete').click(); - cy.findByRole('link', { name: 'Pico Laser' }).should('exist'); - }); + cy.findByText('Serial Number: 5YUQDDjKpz2z').should('exist'); - it('edits an item with all fields altered', () => { - cy.findAllByLabelText('Row Actions').last().click(); - cy.findByText('Edit').click(); + cy.startSnoopingBrowserMockedRequest(); - cy.findByLabelText('Serial number').type('test1234'); - cy.findByLabelText('Asset number').type('test13221'); - cy.findByLabelText('Purchase order number').type('test23'); - cy.findByLabelText('Warranty end date').type('12/02/2028'); - cy.findByLabelText('Delivered date').type('12/02/2024'); - cy.findByLabelText('Is defective *').click(); - cy.findByRole('option', { name: 'Yes' }).click(); - cy.findByLabelText('Usage status *').click(); - cy.findByText('Scrapped').click(); - cy.findByLabelText('Notes').type('test'); + cy.findByRole('button', { name: 'Continue' }).click(); - cy.findByRole('button', { name: 'Next' }).click(); + cy.findBrowserMockedRequests({ + method: 'DELETE', + url: '/v1/items/:id', + }).should((patchRequests) => { + expect(patchRequests.length).equal(1); + const request = patchRequests[0]; + expect(request.url.toString()).to.contain('KvT2Ox7n'); + }); + }); - cy.findByLabelText('Resolution (megapixels) *').type('18'); - cy.findByLabelText('Frame Rate (fps)').type('60'); - cy.findByLabelText('Sensor Type *').type('IO'); - cy.findByLabelText('Sensor brand').type('pixel'); - cy.findByLabelText('Broken *').click(); - cy.findByRole('option', { name: 'False' }).click(); - cy.findByLabelText('Older than five years').click(); - cy.findByRole('option', { name: 'True' }).click(); + it('duplicates an item', () => { + cy.findAllByLabelText('Row Actions').first().click(); + cy.findByText('Duplicate').click(); - cy.findByRole('button', { name: 'Next' }).click(); + cy.startSnoopingBrowserMockedRequest(); - cy.findByRole('button', { name: 'navigate to systems home' }).click(); - cy.findByText('Giant laser').click(); + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('dialog').should('not.exist'); - cy.startSnoopingBrowserMockedRequest(); + cy.findBrowserMockedRequests({ + method: 'POST', + url: '/v1/items', + }).should(async (postRequests) => { + expect(postRequests.length).eq(1); + expect(JSON.stringify(await postRequests[0].json())).equal( + JSON.stringify({ + purchase_order_number: '6JYHEjwN', + is_defective: false, + usage_status_id: '1', + warranty_end_date: '2023-04-04T23:00:00.000Z', + asset_number: 'LyH8yp1FHf', + serial_number: '5YUQDDjKpz2z', + delivered_date: '2023-03-17T00:00:00.000Z', + notes: + '6Y5XTJfBrNNx8oltI9HE\n\nThis is a copy of the item with this Serial Number: 5YUQDDjKpz2z', + properties: [ + { id: '1', value: 0 }, + { id: '2', value: null }, + { id: '3', value: 'CMOS' }, + { id: '4', value: null }, + { id: '5', value: true }, + { id: '6', value: null }, + ], + catalogue_item_id: '1', + system_id: '65328f34a40ff5301575a4e3', + }) + ); + }); + }); - cy.findByRole('button', { name: 'Finish' }).click(); - cy.findByRole('dialog').should('not.exist'); + it('should display a link a system in the delete dialog when the item has a system id', () => { + cy.findAllByLabelText('Row Actions').last().click(); + cy.findByText('Delete').click(); - cy.findBrowserMockedRequests({ - method: 'PATCH', - url: '/v1/items/:id', - }).should(async (postRequests) => { - expect(postRequests.length).eq(1); - expect(JSON.stringify(await postRequests[0].json())).equal( - JSON.stringify({ - serial_number: 'Zf7P8Qu8TD8ctest1234', - purchase_order_number: 'hpGBgi0dtest23', - usage_status_id: '3', - warranty_end_date: '2028-02-12T23:00:00.000Z', - asset_number: '75YWiLwy54test13221', - delivered_date: '2024-02-12T00:00:00.000Z', - notes: 'zolZDKKuvAoTFRUWeZNAtest', - system_id: '65328f34a40ff5301575a4e3', - properties: [ - { id: '1', value: 1218 }, - { id: '2', value: 3060 }, - { id: '3', value: 'CMOSIO' }, - { id: '4', value: 'pixel' }, - { id: '5', value: false }, - { id: '6', value: true }, - ], - }) - ); + cy.findByRole('link', { name: 'Pico Laser' }).should('exist'); }); - }); - it('edits an item (just the serial number)', () => { - cy.findAllByLabelText('Row Actions').last().click(); - cy.findByText('Edit').click(); + it('edits an item with all fields altered', () => { + cy.findAllByLabelText('Row Actions').last().click(); + cy.findByText('Edit').click(); + + cy.findByLabelText('Serial number').type('test1234'); + cy.findByLabelText('Asset number').type('test13221'); + cy.findByLabelText('Purchase order number').type('test23'); + cy.findByLabelText('Warranty end date').type('12/02/2028'); + cy.findByLabelText('Delivered date').type('12/02/2024'); + cy.findByLabelText('Is defective *').click(); + cy.findByRole('option', { name: 'Yes' }).click(); + cy.findByLabelText('Usage status *').click(); + cy.findByText('Scrapped').click(); + cy.findByLabelText('Notes').type('test'); + + cy.findByRole('button', { name: 'Next' }).click(); + + cy.findByLabelText('Resolution (megapixels) *').type('18'); + cy.findByLabelText('Frame Rate (fps)').type('60'); + cy.findByLabelText('Sensor Type *').type('IO'); + cy.findByLabelText('Sensor brand').type('pixel'); + cy.findByLabelText('Broken *').click(); + cy.findByRole('option', { name: 'False' }).click(); + cy.findByLabelText('Older than five years').click(); + cy.findByRole('option', { name: 'True' }).click(); + + cy.findByRole('button', { name: 'Next' }).click(); + + cy.findByRole('button', { name: 'navigate to systems home' }).click(); + cy.findByText('Giant laser').click(); - cy.findByLabelText('Serial number').type('test1234'); + cy.startSnoopingBrowserMockedRequest(); - cy.findByRole('button', { name: 'Next' }).click(); - cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('dialog').should('not.exist'); - cy.startSnoopingBrowserMockedRequest(); + cy.findBrowserMockedRequests({ + method: 'PATCH', + url: '/v1/items/:id', + }).should(async (postRequests) => { + expect(postRequests.length).eq(1); + expect(JSON.stringify(await postRequests[0].json())).equal( + JSON.stringify({ + serial_number: 'Zf7P8Qu8TD8ctest1234', + purchase_order_number: 'hpGBgi0dtest23', + usage_status_id: '3', + warranty_end_date: '2028-02-12T23:00:00.000Z', + asset_number: '75YWiLwy54test13221', + delivered_date: '2024-02-12T00:00:00.000Z', + notes: 'zolZDKKuvAoTFRUWeZNAtest', + system_id: '65328f34a40ff5301575a4e3', + properties: [ + { id: '1', value: 1218 }, + { id: '2', value: 3060 }, + { id: '3', value: 'CMOSIO' }, + { id: '4', value: 'pixel' }, + { id: '5', value: false }, + { id: '6', value: true }, + ], + }) + ); + }); + }); - cy.findByRole('button', { name: 'Finish' }).click(); - cy.findByRole('dialog').should('not.exist'); + it('edits an item (just the serial number)', () => { + cy.findAllByLabelText('Row Actions').last().click(); + cy.findByText('Edit').click(); - cy.findBrowserMockedRequests({ - method: 'PATCH', - url: '/v1/items/:id', - }).should(async (postRequests) => { - expect(postRequests.length).eq(1); - expect(JSON.stringify(await postRequests[0].json())).equal( - JSON.stringify({ serial_number: 'Zf7P8Qu8TD8ctest1234' }) - ); + cy.findByLabelText('Serial number').type('test1234'); + + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('dialog').should('not.exist'); + + cy.findBrowserMockedRequests({ + method: 'PATCH', + url: '/v1/items/:id', + }).should(async (postRequests) => { + expect(postRequests.length).eq(1); + expect(JSON.stringify(await postRequests[0].json())).equal( + JSON.stringify({ serial_number: 'Zf7P8Qu8TD8ctest1234' }) + ); + }); }); - }); - it('edits an item (just the properties)', () => { - cy.findAllByLabelText('Row Actions').last().click(); - cy.findByText('Edit').click(); + it('edits an item (just the properties)', () => { + cy.findAllByLabelText('Row Actions').last().click(); + cy.findByText('Edit').click(); - cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); - cy.findByLabelText('Resolution (megapixels) *').type('18'); - cy.findByLabelText('Frame Rate (fps)').type('60'); - cy.findByLabelText('Sensor Type *').type('IO'); - cy.findByLabelText('Sensor brand').type('pixel'); - cy.findByLabelText('Broken *').click(); - cy.findByRole('option', { name: 'False' }).click(); - cy.findByLabelText('Older than five years').click(); - cy.findByRole('option', { name: 'True' }).click(); + cy.findByLabelText('Resolution (megapixels) *').type('18'); + cy.findByLabelText('Frame Rate (fps)').type('60'); + cy.findByLabelText('Sensor Type *').type('IO'); + cy.findByLabelText('Sensor brand').type('pixel'); + cy.findByLabelText('Broken *').click(); + cy.findByRole('option', { name: 'False' }).click(); + cy.findByLabelText('Older than five years').click(); + cy.findByRole('option', { name: 'True' }).click(); - cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); - cy.startSnoopingBrowserMockedRequest(); + cy.startSnoopingBrowserMockedRequest(); - cy.findByRole('button', { name: 'Finish' }).click(); - cy.findByRole('dialog').should('not.exist'); + cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('dialog').should('not.exist'); - cy.findBrowserMockedRequests({ - method: 'PATCH', - url: '/v1/items/:id', - }).should(async (postRequests) => { - expect(postRequests.length).eq(1); - expect(JSON.stringify(await postRequests[0].json())).equal( - JSON.stringify({ - properties: [ - { id: '1', value: 1218 }, - { id: '2', value: 3060 }, - { id: '3', value: 'CMOSIO' }, - { id: '4', value: 'pixel' }, - { id: '5', value: false }, - { id: '6', value: true }, - ], - }) - ); + cy.findBrowserMockedRequests({ + method: 'PATCH', + url: '/v1/items/:id', + }).should(async (postRequests) => { + expect(postRequests.length).eq(1); + expect(JSON.stringify(await postRequests[0].json())).equal( + JSON.stringify({ + properties: [ + { id: '1', value: 1218 }, + { id: '2', value: 3060 }, + { id: '3', value: 'CMOSIO' }, + { id: '4', value: 'pixel' }, + { id: '5', value: false }, + { id: '6', value: true }, + ], + }) + ); + }); }); - }); - it('should display an error message if values have not been updated', () => { - cy.findAllByLabelText('Row Actions').last().click(); - cy.findByText('Edit').click(); + it('should display an error message if values have not been updated', () => { + cy.findAllByLabelText('Row Actions').last().click(); + cy.findByText('Edit').click(); - cy.findByRole('button', { name: 'Next' }).click(); - cy.findByRole('button', { name: 'Next' }).click(); - cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Finish' }).click(); - cy.findByText( - "There have been no changes made. Please change a field's value or press Cancel to exit." - ).should('exist'); + cy.findByText( + "There have been no changes made. Please change a field's value or press Cancel to exit." + ).should('exist'); + }); }); }); diff --git a/src/api/api.types.tsx b/src/api/api.types.tsx index 4d96f0145..807c0774a 100644 --- a/src/api/api.types.tsx +++ b/src/api/api.types.tsx @@ -249,6 +249,12 @@ export interface ImagePost { description?: string | null; } +export interface ObjectFilePatch { + file_name?: string; + title?: string | null; + description?: string | null; +} + export interface APIImage extends Required>, CreatedModifiedMixin { diff --git a/src/api/images.test.tsx b/src/api/images.test.tsx index afb29c86b..826dbb48a 100644 --- a/src/api/images.test.tsx +++ b/src/api/images.test.tsx @@ -4,8 +4,13 @@ import { CREATED_MODIFIED_TIME_VALUES, hooksWrapperWithProviders, } from '../testUtils'; -import { APIImage } from './api.types'; -import { useDeleteImage, useGetImage, useGetImages } from './images'; +import { APIImage, ObjectFilePatch } from './api.types'; +import { + useDeleteImage, + useGetImage, + useGetImages, + usePatchImage, +} from './images'; describe('images api functions', () => { afterEach(() => { @@ -67,6 +72,30 @@ describe('images api functions', () => { }); }); + describe('usePatchImage', () => { + let mockDataPatch: ObjectFilePatch; + 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', fileMetadata: mockDataPatch }); + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()); + + expect(result.current.data).toEqual({ + ...ImagesJSON[0], + ...mockDataPatch, + }); + }); + }); + describe('useDeleteImage', () => { let mockDataView: APIImage; beforeEach(() => { diff --git a/src/api/images.tsx b/src/api/images.tsx index 7f545cbaa..c080fdf82 100644 --- a/src/api/images.tsx +++ b/src/api/images.tsx @@ -8,7 +8,7 @@ import { import { AxiosError } from 'axios'; import { storageApi } from './api'; -import { APIImage, APIImageWithURL } from './api.types'; +import { APIImage, APIImageWithURL, ObjectFilePatch } from './api.types'; export const getImage = async (id: string): Promise => { return storageApi.get(`/images/${id}`).then((response) => { @@ -51,6 +51,32 @@ export const useGetImages = ( }); }; +const patchImage = async ( + id: string, + fileMetadata: ObjectFilePatch +): Promise => { + return storageApi + .patch(`/images/${id}`, fileMetadata) + .then((response) => response.data); +}; + +export const usePatchImage = (): UseMutationResult< + APIImage, + AxiosError, + { id: string; fileMetadata: ObjectFilePatch } +> => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, fileMetadata }) => patchImage(id, fileMetadata), + onSuccess: (updatedImage: APIImage) => { + queryClient.invalidateQueries({ queryKey: ['Images'] }); + queryClient.invalidateQueries({ + queryKey: ['Image', updatedImage.id], + }); + }, + }); +}; + const deleteImage = async (id: string): Promise => { return storageApi .delete(`/images/${id}`, {}) diff --git a/src/common/attachments/uploadAttachmentsDialog.component.tsx b/src/common/attachments/uploadAttachmentsDialog.component.tsx index 6db11acba..084630776 100644 --- a/src/common/attachments/uploadAttachmentsDialog.component.tsx +++ b/src/common/attachments/uploadAttachmentsDialog.component.tsx @@ -5,7 +5,7 @@ import '@uppy/core/dist/style.css'; import '@uppy/dashboard/dist/style.css'; import ProgressBar from '@uppy/progress-bar'; import { DashboardModal } from '@uppy/react'; -import React from 'react'; +import React, { useRef } from 'react'; import { usePostAttachmentMetadata } from '../../api/attachments'; import { getNonEmptyTrimmedString } from '../../utils'; @@ -90,6 +90,9 @@ const UploadAttachmentsDialog = (props: UploadAttachmentsDialogProps) => { uppy.on('file-removed', (file) => updateFileMetadata(file, true)); uppy.on('upload-success', (file) => updateFileMetadata(file)); + const inputEl = useRef(null); + const divEl = useRef(null); + return ( { id: 'name', name: 'File name', placeholder: 'Enter file name', + render({ value, onChange, fieldCSSClasses, required }, h) { + const point = value.lastIndexOf('.'); + const name = value.slice(0, point); + const extension = value.slice(point + 1); + console.log(fieldCSSClasses.text); + return h( + 'div', + { + class: fieldCSSClasses.text, + onClick: () => inputEl.current && inputEl.current.focus(), + tabIndex: 0, + ref: divEl, + style: { + padding: 0, + display: 'inline-flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'space-between', + alignItems: 'center', + }, + }, + [ + h('input', { + type: 'text', + id: 'uppy-Dashboard-FileCard-input-name', + value: name, + ref: inputEl, + class: fieldCSSClasses.text, + style: { border: 0, 'box-shadow': 'none', outline: 'none' }, + required: required, + onFocus: () => { + if (divEl.current) { + divEl.current.style['boxShadow'] = + 'rgba(18, 105, 207, 0.15) 0px 0px 0px 3px '; + divEl.current.style['borderColor'] = + 'rgba(18, 105, 207, 0.6)'; + } + }, + onBlur: () => { + if (divEl.current) { + divEl.current.style['boxShadow'] = ''; + divEl.current.style['borderColor'] = ''; + } + }, + onChange: (event: { currentTarget: { value: string } }) => + onChange(event.currentTarget.value + '.' + extension), + }), + h( + 'label', + { + for: 'uppy-Dashboard-FileCard-input-name', + style: { + color: 'rgb(82, 82, 82)', + colorScheme: 'light', + height: '31px', + padding: '5px', + }, + }, + '.' + extension + ), + ] + ); + }, }, { id: 'title', name: 'Title', placeholder: 'Enter file title', }, - { id: 'description', name: 'Description', diff --git a/src/common/editFileDialog.component.test.tsx b/src/common/editFileDialog.component.test.tsx new file mode 100644 index 000000000..38b2744f8 --- /dev/null +++ b/src/common/editFileDialog.component.test.tsx @@ -0,0 +1,162 @@ +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 { usePatchImage } from '../api/images'; +import handleIMS_APIError from '../handleIMS_APIError'; +import ImagesJSON from '../mocks/Images.json'; +import { server } from '../mocks/server'; +import { renderComponentWithRouterProvider } from '../testUtils'; +import EditFileDialog, { FileDialogProps } from './editFileDialog.component'; + +vi.mock('../handleIMS_APIError'); + +describe('Edit file dialog', () => { + const onClose = vi.fn(); + let props: FileDialogProps; + let user: UserEvent; + const createView = () => { + return renderComponentWithRouterProvider(); + }; + + beforeEach(() => { + props = { + open: true, + onClose: onClose, + fileType: 'Image', + usePatchFile: usePatchImage, + }; + user = userEvent.setup(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + const modifyFileValues = (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, + selectedFile: 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(); + + modifyFileValues({ + 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(); + + // Checks if file extension is displayed. If it's editable, actual value will not match expected. + expect(screen.getByText('.png')).toBeInTheDocument(); + + modifyFileValues({ + 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.png', + title: 'Test Title', + description: 'Test Description', + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it('shows correct error message when no values are changed', 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('shows error message if required fields are whitespace or their current value was removed', async () => { + createView(); + modifyFileValues({ + 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('displays refresh page message and a CatchAllError request works correctly', async () => { + createView(); + modifyFileValues({ + 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/editFileDialog.component.tsx b/src/common/editFileDialog.component.tsx new file mode 100644 index 000000000..4b0e58026 --- /dev/null +++ b/src/common/editFileDialog.component.tsx @@ -0,0 +1,228 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormHelperText, + Grid, + InputAdornment, + TextField, +} from '@mui/material'; +import { useForm } from 'react-hook-form'; + +import React from 'react'; + +import { UseMutationResult } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; +import { APIImage, ObjectFilePatch } from '../api/api.types'; +import { FileSchema } from '../form.schemas'; +import handleIMS_APIError from '../handleIMS_APIError'; + +export interface BaseFileDialogProps { + open: boolean; + onClose: () => void; + fileType: 'Image' | 'Attachment'; +} + +export interface ImageDialogProps extends BaseFileDialogProps { + fileType: 'Image'; + selectedFile?: APIImage; + usePatchFile: () => UseMutationResult< + APIImage, + AxiosError, + { id: string; fileMetadata: ObjectFilePatch } + >; +} + +export type FileDialogProps = ImageDialogProps; + +const EditFileDialog = (props: FileDialogProps) => { + const { open, onClose, selectedFile, fileType, usePatchFile } = props; + + const { mutateAsync: patchFile, isPending: isEditPending } = usePatchFile(); + + const point = selectedFile?.file_name?.lastIndexOf('.') ?? 0; + const extension = selectedFile?.file_name?.slice(point) ?? ''; + const initialName = selectedFile?.file_name?.slice(0, point) ?? ''; + + console.log(extension); + + const selectedFileCopy: ObjectFilePatch = React.useMemo( + () => (selectedFile ? { ...selectedFile, file_name: initialName } : {}), + [selectedFile, initialName] + ); + + const initialFile: ObjectFilePatch = React.useMemo( + () => + selectedFileCopy ?? { + file_name: '', + title: '', + description: '', + }, + [selectedFileCopy] + ); + + const { + handleSubmit, + register, + formState: { errors }, + watch, + setError, + clearErrors, + reset, + } = useForm({ + resolver: zodResolver(FileSchema('patch')), + defaultValues: initialFile, + }); + + // Load the values for editing + React.useEffect(() => { + reset(initialFile); + }, [initialFile, reset]); + + // Clears form errors when a value has been changed + React.useEffect(() => { + if (errors.root?.formError) { + const subscription = watch(() => clearErrors('root.formError')); + return () => subscription.unsubscribe(); + } + }, [clearErrors, errors, selectedFile, watch]); + + const handleClose = React.useCallback(() => { + reset(); + clearErrors(); + onClose(); + }, [clearErrors, onClose, reset]); + + const handleEditFile = React.useCallback( + (fileData: ObjectFilePatch) => { + if (selectedFile) { + const isFileNameUpdated = + fileData.file_name !== selectedFileCopy.file_name; + + const isDescriptionUpdated = + fileData.description !== selectedFileCopy.description; + + const isTitleUpdated = fileData.title !== selectedFileCopy.title; + + const fileToEdit: ObjectFilePatch = {}; + + if (isFileNameUpdated) + fileToEdit.file_name = fileData.file_name + extension; + if (isDescriptionUpdated) fileToEdit.description = fileData.description; + if (isTitleUpdated) fileToEdit.title = fileData.title; + + if (isFileNameUpdated || isDescriptionUpdated || isTitleUpdated) { + patchFile({ + id: selectedFile.id, + fileMetadata: fileToEdit, + }) + .then(() => handleClose()) + .catch((error: AxiosError) => { + handleIMS_APIError(error); + }); + } else { + setError('root.formError', { + message: + "There have been no changes made. Please change a field's value or press Cancel to exit.", + }); + } + } + }, + [ + selectedFile, + selectedFileCopy, + extension, + patchFile, + handleClose, + setError, + ] + ); + + return ( + + {`Edit ${fileType}`} + + + + {extension} + ), + }} + fullWidth + /> + + + + + + + + + + + + + + + + {errors.root?.formError && ( + + {errors.root?.formError.message} + + )} + + + ); +}; + +export default EditFileDialog; diff --git a/src/common/images/imageGallery.component.test.tsx b/src/common/images/imageGallery.component.test.tsx index 99ade3116..3e0b968f6 100644 --- a/src/common/images/imageGallery.component.test.tsx +++ b/src/common/images/imageGallery.component.test.tsx @@ -168,6 +168,33 @@ 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 image delete dialog and can close the dialog', async () => { createView(); @@ -182,12 +209,13 @@ describe('Image Gallery', () => { const deleteButton = await screen.findByText(`Delete`); await user.click(deleteButton); + await waitFor(() => { expect(screen.getByRole('dialog')).toBeInTheDocument(); }); - const closeButton = screen.getByRole('button', { name: 'Cancel' }); - await user.click(closeButton); + const cancelButton = screen.getByRole('button', { name: 'Cancel' }); + await user.click(cancelButton); await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); @@ -390,6 +418,59 @@ describe('Image Gallery', () => { }); }); + 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(); + }); + }); + it('opens delete dialog in lightbox', async () => { createView(); diff --git a/src/common/images/imageGallery.component.tsx b/src/common/images/imageGallery.component.tsx index 049e0c748..fd14a7e8a 100644 --- a/src/common/images/imageGallery.component.tsx +++ b/src/common/images/imageGallery.component.tsx @@ -27,9 +27,10 @@ import { import { MRT_Localization_EN } from 'material-react-table/locales/en'; import React from 'react'; import { APIImage } from '../../api/api.types'; -import { useGetImages } from '../../api/images'; +import { useGetImages, usePatchImage } from '../../api/images'; import { displayTableRowCountText, OverflowTip } from '../../utils'; import CardViewFilters from '../cardView/cardViewFilters.component'; +import EditFileDialog from '../editFileDialog.component'; import { usePreservedTableState } from '../preservedTableState.component'; import DeleteImageDialog from './deleteImageDialog.component'; import GalleryLightBox from './galleryLightbox.component'; @@ -189,6 +190,11 @@ const ImageGallery = (props: ImageGalleryProps) => { { + setSelectedImage(row.original); + setOpenMenuDialog('edit'); + closeMenu(); + }} sx={{ m: 0 }} > @@ -431,6 +437,13 @@ const ImageGallery = (props: ImageGalleryProps) => { onClose={() => setOpenMenuDialog(false)} image={selectedImage} /> + setOpenMenuDialog(false)} + fileType="Image" + usePatchFile={usePatchImage} + selectedFile={selectedImage} + /> { diff --git a/src/common/images/uploadImagesDialog.component.tsx b/src/common/images/uploadImagesDialog.component.tsx index 52673585e..c268ec4e8 100644 --- a/src/common/images/uploadImagesDialog.component.tsx +++ b/src/common/images/uploadImagesDialog.component.tsx @@ -8,7 +8,7 @@ import '@uppy/image-editor/dist/style.css'; import ProgressBar from '@uppy/progress-bar'; // Import the ProgressBar plugin import { DashboardModal } from '@uppy/react'; import XHR from '@uppy/xhr-upload'; -import React from 'react'; +import React, { useRef } from 'react'; import { uppyOnAfterResponse, uppyOnBeforeRequest } from '../../api/api'; import { settings } from '../../settings'; import { getNonEmptyTrimmedString } from '../../utils'; @@ -93,6 +93,9 @@ const UploadImagesDialog = (props: UploadImagesDialogProps) => { } }); + const inputEl = useRef(null); + const divEl = useRef(null); + return ( { id: 'name', name: 'File name', placeholder: 'Enter file name', + render({ value, onChange, fieldCSSClasses, required }, h) { + const point = value.lastIndexOf('.'); + const name = value.slice(0, point); + const extension = value.slice(point + 1); + console.log(fieldCSSClasses.text); + return h( + 'div', + { + class: fieldCSSClasses.text, + onClick: () => inputEl.current && inputEl.current.focus(), + tabIndex: 0, + ref: divEl, + style: { + padding: 0, + display: 'inline-flex', + flexDirection: 'row', + flexWrap: 'nowrap', + justifyContent: 'space-between', + alignItems: 'center', + }, + }, + [ + h('input', { + type: 'text', + id: 'uppy-Dashboard-FileCard-input-name', + value: name, + ref: inputEl, + class: fieldCSSClasses.text, + style: { border: 0, 'box-shadow': 'none', outline: 'none' }, + required: required, + onFocus: () => { + if (divEl.current) { + divEl.current.style['boxShadow'] = + 'rgba(18, 105, 207, 0.15) 0px 0px 0px 3px '; + divEl.current.style['borderColor'] = + 'rgba(18, 105, 207, 0.6)'; + } + }, + onBlur: () => { + if (divEl.current) { + divEl.current.style['boxShadow'] = ''; + divEl.current.style['borderColor'] = ''; + } + }, + onChange: (event: { currentTarget: { value: string } }) => + onChange(event.currentTarget.value + '.' + extension), + }), + h( + 'label', + { + for: 'uppy-Dashboard-FileCard-input-name', + style: { + color: 'rgb(82, 82, 82)', + colorScheme: 'light', + height: '31px', + padding: '5px', + }, + }, + '.' + extension + ), + ] + ); + }, }, { id: 'title', diff --git a/src/form.schemas.tsx b/src/form.schemas.tsx index 9d4c42466..65a5402e4 100644 --- a/src/form.schemas.tsx +++ b/src/form.schemas.tsx @@ -616,3 +616,14 @@ export const ItemDetailsStepSchema = (requestType: RequestType) => { notes: OptionalOrNullableStringSchema({ requestType }), }); }; + +// ------------------------------------ FILES ------------------------------------ + +export const FileSchema = (requestType: RequestType) => + z.object({ + file_name: MandatoryStringSchema({ + errorMessage: 'Please enter a file name.', + }), + title: OptionalOrNullableStringSchema({ requestType }), + description: OptionalOrNullableStringSchema({ requestType }), + }); diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 2cb60b9b0..297943a94 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -1,5 +1,6 @@ import { DefaultBodyType, delay, http, HttpResponse, PathParams } from 'msw'; import { + APIImage, AttachmentPostMetadata, AttachmentPostMetadataResponse, AttachmentUploadInfo, @@ -19,6 +20,7 @@ import { Manufacturer, ManufacturerPatch, ManufacturerPost, + ObjectFilePatch, System, SystemPatch, SystemPost, @@ -1116,6 +1118,26 @@ export const handlers = [ } }), + http.patch<{ id: string }, ObjectFilePatch, APIImage | ErrorResponse>( + '/images/:id', + async ({ request, params }) => { + const { id } = params; + + const obj = ImagesJSON.find((image) => image.id === id); + const body = await request.json(); + + const fullBody = { ...obj, ...body }; + + if (fullBody.file_name === 'Error 500.png') { + return HttpResponse.json( + { detail: 'Something went wrong' }, + { status: 500 } + ); + } + return HttpResponse.json(fullBody as APIImage, { status: 200 }); + } + ), + http.delete< { id: string }, DefaultBodyType,