diff --git a/cypress/e2e/with_mock_data/items.cy.ts b/cypress/e2e/with_mock_data/items.cy.ts index 8815a176b..20773661e 100644 --- a/cypress/e2e/with_mock_data/items.cy.ts +++ b/cypress/e2e/with_mock_data/items.cy.ts @@ -104,6 +104,112 @@ describe('Items', () => { }); }); + it('adds an item with only mandatory fields (serial number advanced options)', () => { + cy.findByRole('button', { name: 'Add Item' }).click(); + cy.findByText('Show advanced options').click(); + cy.findByLabelText('Serial number').type('test %s'); + cy.findByLabelText('Quantity').type('3'); + cy.findByLabelText('Starting value').type('2'); + + cy.startSnoopingBrowserMockedRequest(); + + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByRole('button', { name: 'Next' }).click(); + cy.findByText('Giant laser').click(); + + cy.findByRole('button', { name: 'Finish' }).click(); + cy.findByRole('dialog').should('not.exist'); + + cy.findBrowserMockedRequests({ + method: 'POST', + url: '/v1/items', + }).should(async (postRequests) => { + expect(postRequests.length).eq(3); + + for (let i = 0; i < 3; i++) { + expect(JSON.stringify(await postRequests[i].json())).equal( + JSON.stringify({ + catalogue_item_id: '1', + system_id: '65328f34a40ff5301575a4e3', + purchase_order_number: null, + is_defective: false, + usage_status: 0, + warranty_end_date: null, + asset_number: null, + serial_number: `test ${i + 2}`, + delivered_date: null, + notes: null, + properties: [ + { name: 'Resolution', value: 12 }, + { name: 'Frame Rate', value: 30 }, + { name: 'Sensor Type', value: 'CMOS' }, + { name: 'Sensor brand', value: null }, + { name: 'Broken', value: true }, + { name: 'Older than five years', value: false }, + ], + }) + ); + } + }); + }); + + it('displays error messages for serial number advanced options', () => { + cy.findByRole('button', { name: 'Add Item' }).click(); + + cy.findByText('Show advanced options').click(); + + cy.findByLabelText('Starting value').type('10'); + + cy.findByText('Please enter a quantity value').should('exist'); + + cy.findByLabelText('Starting value').clear(); + + cy.findByLabelText('Quantity').type('10a'); + cy.findByLabelText('Starting value').type('10a'); + + cy.findAllByText('Please enter a valid number').should('have.length', 2); + + cy.findByLabelText('Quantity').clear(); + cy.findByLabelText('Starting value').clear(); + + cy.findByLabelText('Quantity').type('10.5'); + cy.findByLabelText('Starting value').type('10.5'); + + cy.findByText('Quantity must be an integer').should('exist'); + cy.findByText('Starting value must be an integer').should('exist'); + + cy.findByLabelText('Quantity').clear(); + cy.findByLabelText('Starting value').clear(); + + cy.findByLabelText('Quantity').type('-1'); + cy.findByLabelText('Starting value').type('-1'); + + cy.findByText('Quantity must be greater than 1').should('exist'); + cy.findByText('Starting value must be greater than or equal to 0').should( + 'exist' + ); + + cy.findByLabelText('Quantity').clear(); + cy.findByLabelText('Starting value').clear(); + + cy.findByLabelText('Quantity').type('100'); + cy.findByLabelText('Starting value').type('2'); + + cy.findByText( + 'Please use %s to specify the location you want to append the number to serial number' + ).should('exist'); + cy.findByText('Quantity must be less than 100').should('exist'); + + cy.findByLabelText('Quantity').clear(); + cy.findByLabelText('Starting value').clear(); + + cy.findByLabelText('Serial number').type('test %s'); + cy.findByLabelText('Quantity').type('4'); + cy.findByLabelText('Starting value').type('2'); + + cy.findByText('e.g. test 2').should('exist'); + }); + it('adds an item with only mandatory fields (allowed list of values)', () => { cy.visit('/catalogue/item/17/items'); cy.findByRole('button', { name: 'Add Item' }).click(); diff --git a/src/api/items.test.tsx b/src/api/items.test.tsx index 82215a2cd..d68416c20 100644 --- a/src/api/items.test.tsx +++ b/src/api/items.test.tsx @@ -6,6 +6,7 @@ import { useItem, useItems, useMoveItemsToSystem, + useAddItems, } from './items'; import { getItemById, @@ -15,6 +16,7 @@ import { } from '../testUtils'; import { AddItem, + AddItems, EditItem, Item, MoveItemsToSystem, @@ -23,7 +25,7 @@ import { import SystemsJSON from '../mocks/Systems.json'; import { imsApi } from './api'; -describe('catalogue items api functions', () => { +describe('items api functions', () => { afterEach(() => { vi.clearAllMocks(); }); @@ -298,4 +300,107 @@ describe('catalogue items api functions', () => { ); }); }); + + describe('useAddItems', () => { + let addItems: AddItems; + + // Use post spy for testing since response is not actual data in this case + // so can't test the underlying use of editSystem otherwise + let axiosPostSpy; + const { _id, ...item } = getItemById('KvT2Ox7n'); + beforeEach(() => { + addItems = { + quantity: 2, + startingValue: 10, + item: { + ...item, + serial_number: item.serial_number + '_%s', + }, + }; + + axiosPostSpy = vi.spyOn(imsApi, 'post'); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('sends requests to add multiple items and returns a successful response for each', async () => { + const { result } = renderHook(() => useAddItems(), { + wrapper: hooksWrapperWithProviders(), + }); + + expect(result.current.isIdle).toBe(true); + + result.current.mutate(addItems); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + for ( + let i = addItems.startingValue; + i < addItems.startingValue + addItems.quantity; + i++ + ) { + expect(axiosPostSpy).toHaveBeenCalledWith('/v1/items', { + ...item, + serial_number: item.serial_number + `_${i}`, + }); + } + + expect(result.current.data).toEqual([ + { + message: 'Successfully created 5YUQDDjKpz2z_10', + name: '5YUQDDjKpz2z_10', + state: 'success', + }, + { + message: 'Successfully created 5YUQDDjKpz2z_11', + name: '5YUQDDjKpz2z_11', + state: 'success', + }, + ]); + }); + + it('handles failed requests when adding multiple items correctly', async () => { + addItems.item.serial_number = 'Error 500'; + + const { result } = renderHook(() => useAddItems(), { + wrapper: hooksWrapperWithProviders(), + }); + + expect(result.current.isIdle).toBe(true); + + result.current.mutate(addItems); + + await waitFor(() => { + expect(result.current.isSuccess).toBeTruthy(); + }); + + for ( + let i = addItems.startingValue; + i < addItems.startingValue + addItems.quantity; + i++ + ) { + expect(axiosPostSpy).toHaveBeenCalledWith('/v1/items', { + ...item, + serial_number: 'Error 500', + }); + } + + expect(result.current.data).toEqual([ + { + message: 'Something went wrong', + name: 'Error 500', + state: 'error', + }, + { + message: 'Something went wrong', + name: 'Error 500', + state: 'error', + }, + ]); + }); + }); }); diff --git a/src/api/items.tsx b/src/api/items.tsx index 4da46deb0..b415ff3c5 100644 --- a/src/api/items.tsx +++ b/src/api/items.tsx @@ -8,6 +8,7 @@ import { } from '@tanstack/react-query'; import { AddItem, + AddItems, EditItem, ErrorParsing, Item, @@ -30,6 +31,66 @@ export const useAddItem = (): UseMutationResult => { }); }; +export const useAddItems = (): UseMutationResult< + TransferState[], + AxiosError, + AddItems +> => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (addItems: AddItems) => { + const transferStates: TransferState[] = []; + const successfulSerialNumbers: string[] = []; + + const promises = []; + + for ( + let i = addItems.startingValue; + i < addItems.startingValue + addItems.quantity; + i++ + ) { + const item: AddItem = { + ...addItems.item, + serial_number: + addItems.item.serial_number?.replace('%s', String(i)) ?? null, + }; + + const promise = addItem(item) + .then((result: Item) => { + transferStates.push({ + name: result.serial_number ?? '', + message: `Successfully created ${result.serial_number ?? ''}`, + state: 'success', + }); + successfulSerialNumbers.push(result.serial_number ?? ''); + }) + .catch((error) => { + const response = error.response?.data as ErrorParsing; + transferStates.push({ + name: item.serial_number ?? '', + message: response.detail, + state: 'error', + }); + }); + + promises.push(promise); + } + + await Promise.all(promises); + if (successfulSerialNumbers.length > 0) { + queryClient.invalidateQueries({ + queryKey: ['Items', undefined, addItems.item.catalogue_item_id], + }); + queryClient.invalidateQueries({ + queryKey: ['Items', addItems.item.system_id, undefined], + }); + } + + return transferStates; + }, + }); +}; + const fetchItems = async ( system_id?: string, catalogue_item_id?: string diff --git a/src/app.types.tsx b/src/app.types.tsx index 59adb395c..adb5e7887 100644 --- a/src/app.types.tsx +++ b/src/app.types.tsx @@ -257,6 +257,12 @@ export interface AddItem extends ItemDetails { properties: CatalogueItemProperty[]; } +export interface AddItems { + quantity: number; + startingValue: number; + item: AddItem; +} + export interface Item extends ItemDetails { properties: CatalogueItemPropertyResponse[]; id: string; @@ -281,6 +287,10 @@ export interface CatalogueItemPropertiesErrorsType { } | null; } +export interface AdvancedSerialNumberOptionsType { + quantity: string | null; + startingValue: string | null; +} export interface AllowedValuesListErrorsType { index: number | null; errors: { index: number; errorMessage: string }[] | null; diff --git a/src/items/__snapshots__/itemsTable.component.test.tsx.snap b/src/items/__snapshots__/itemsTable.component.test.tsx.snap index ab94ffd55..a793713e4 100644 --- a/src/items/__snapshots__/itemsTable.component.test.tsx.snap +++ b/src/items/__snapshots__/itemsTable.component.test.tsx.snap @@ -2801,7 +2801,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Filter by Serial Number" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjd:" + id=":rjn:" placeholder="Filter by Serial Number" title="Filter by Serial Number" type="text" @@ -2942,7 +2942,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Min" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjh:" + id=":rjr:" inputmode="text" placeholder="Min" title="Min" @@ -2987,7 +2987,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Max" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjj:" + id=":rjt:" inputmode="text" placeholder="Max" title="Max" @@ -3126,7 +3126,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Min" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjn:" + id=":rk1:" inputmode="text" placeholder="Min" title="Min" @@ -3171,7 +3171,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Max" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjp:" + id=":rk3:" inputmode="text" placeholder="Max" title="Max" @@ -3310,7 +3310,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Min" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjt:" + id=":rk7:" inputmode="text" placeholder="Min" title="Min" @@ -3355,7 +3355,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` aria-label="Max" autocomplete="new-password" class="MuiInputBase-input MuiInput-input MuiInputBase-inputAdornedEnd css-929hxt-MuiInputBase-input-MuiInput-input" - id=":rjv:" + id=":rk9:" inputmode="text" placeholder="Max" title="Max" @@ -3487,13 +3487,13 @@ exports[`Items Table > renders the dense table correctly 1`] = ` class="MuiInputBase-root MuiInput-root MuiInput-underline MuiInputBase-colorPrimary MuiInputBase-fullWidth MuiInputBase-formControl MuiInputBase-adornedEnd css-953pxc-MuiInputBase-root-MuiInput-root" >