From a346b53a158221fe4c342bb11c9ee5ec7e5fd891 Mon Sep 17 00:00:00 2001 From: Joshua Kitenge Date: Tue, 26 Mar 2024 14:08:55 +0000 Subject: [PATCH 1/8] create mulitple items #453 --- src/api/items.test.tsx | 105 ++++++++++++ src/api/items.tsx | 61 +++++++ src/app.types.tsx | 10 ++ src/items/itemDialog.component.test.tsx | 189 +++++++++++++++++++++- src/items/itemDialog.component.tsx | 206 +++++++++++++++++++++--- 5 files changed, 545 insertions(+), 26 deletions(-) diff --git a/src/api/items.test.tsx b/src/api/items.test.tsx index 82215a2cd..a3fd3af35 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, @@ -298,4 +300,107 @@ describe('catalogue items api functions', () => { ); }); }); + + describe('useAddItems', () => { + let addItems: AddItems; + + // Use patch 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/itemDialog.component.test.tsx b/src/items/itemDialog.component.test.tsx index 37efdae4a..121075074 100644 --- a/src/items/itemDialog.component.test.tsx +++ b/src/items/itemDialog.component.test.tsx @@ -72,6 +72,7 @@ describe('ItemDialog', () => { const modifyDetailsValues = async (values: { serialNumber?: string; + serialNumberAdvancedOptions?: { quantity?: string; startingValue?: string }; assetNumber?: string; purchaseOrderNumber?: string; warrantyEndDate?: string; @@ -85,6 +86,20 @@ describe('ItemDialog', () => { target: { value: values.serialNumber }, }); + if (Object.values(values.serialNumberAdvancedOptions ?? {}).length !== 0) { + await user.click(screen.getByText('Show advanced options')); + expect(screen.getByText('Close advanced options')).toBeInTheDocument(); + + values.serialNumberAdvancedOptions?.quantity !== undefined && + fireEvent.change(screen.getByLabelText('Quantity'), { + target: { value: values.serialNumberAdvancedOptions.quantity }, + }); + values.serialNumberAdvancedOptions?.startingValue !== undefined && + fireEvent.change(screen.getByLabelText('Starting value'), { + target: { value: values.serialNumberAdvancedOptions.startingValue }, + }); + } + values.assetNumber !== undefined && fireEvent.change(screen.getByLabelText('Asset number'), { target: { value: values.assetNumber }, @@ -244,6 +259,103 @@ describe('ItemDialog', () => { }); }); + it('adds multiple items with just the default values', async () => { + createView(); + + await modifyDetailsValues({ + serialNumber: 'test12 %s', + serialNumberAdvancedOptions: { quantity: '2', startingValue: '10' }, + }); + + //navigate through stepper + await user.click(screen.getByRole('button', { name: 'Next' })); + await user.click(screen.getByRole('button', { name: 'Next' })); + + await modifySystemValue({ + system: 'Giant laser', + }); + + await user.click(screen.getByRole('button', { name: 'Finish' })); + + expect(axiosPostSpy).toHaveBeenCalledWith('/v1/items', { + asset_number: null, + catalogue_item_id: '1', + delivered_date: null, + is_defective: false, + 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, + }, + ], + purchase_order_number: null, + serial_number: 'test12 10', + system_id: '65328f34a40ff5301575a4e3', + usage_status: 0, + warranty_end_date: null, + }); + + expect(axiosPostSpy).toHaveBeenCalledWith('/v1/items', { + asset_number: null, + catalogue_item_id: '1', + delivered_date: null, + is_defective: false, + 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, + }, + ], + purchase_order_number: null, + serial_number: 'test12 11', + system_id: '65328f34a40ff5301575a4e3', + usage_status: 0, + warranty_end_date: null, + }); + }); + it('navigates through the stepper using the labels', async () => { createView(); @@ -410,7 +522,7 @@ describe('ItemDialog', () => { }); expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); - expect(screen.getByText('Invalid date')).toBeInTheDocument(); + expect(screen.getByText('Invalid item details')).toBeInTheDocument(); await user.click(screen.getByText('Add item properties')); @@ -451,6 +563,81 @@ describe('ItemDialog', () => { expect(screen.getByRole('button', { name: 'Finish' })).not.toBeDisabled(); }, 10000); + it('displays error messages for serial number advance options', async () => { + createView(); + await modifyDetailsValues({ + serialNumberAdvancedOptions: { startingValue: '10' }, + }); + + expect( + screen.getByText('Please enter a quantity value') + ).toBeInTheDocument(); + + await user.click(screen.getByText('Close advanced options')); + await modifyDetailsValues({ + serialNumberAdvancedOptions: { quantity: '10a', startingValue: '10a' }, + }); + + expect(screen.getAllByText('Please enter a valid number').length).toEqual( + 2 + ); + + await user.click(screen.getByText('Close advanced options')); + await modifyDetailsValues({ + serialNumberAdvancedOptions: { + quantity: '10.5', + startingValue: '10.5', + }, + }); + + expect(screen.getAllByText('Quantity must be an integer').length).toEqual( + 2 + ); + + await user.click(screen.getByText('Close advanced options')); + await modifyDetailsValues({ + serialNumberAdvancedOptions: { + quantity: '-1', + startingValue: '-1', + }, + }); + + expect( + screen.getByText('Quantity must be greater than 1') + ).toBeInTheDocument(); + expect( + screen.getByText('Quantity must be greater than or equal to 0') + ).toBeInTheDocument(); + + await user.click(screen.getByText('Close advanced options')); + await modifyDetailsValues({ + serialNumberAdvancedOptions: { + quantity: '100', + startingValue: '2', + }, + }); + + expect( + screen.getByText('Quantity must be less than 100') + ).toBeInTheDocument(); + expect( + screen.getByText( + 'Please use %s to specify the location you want to append the number to serial number' + ) + ).toBeInTheDocument(); + + await user.click(screen.getByText('Close advanced options')); + await modifyDetailsValues({ + serialNumber: 'test %s', + serialNumberAdvancedOptions: { + quantity: '4', + startingValue: '2', + }, + }); + + expect(screen.getByText('e.g. test 2')).toBeInTheDocument(); + }, 10000); + it('adds an item (case empty string with spaces returns null and change property boolean values)', async () => { createView(); diff --git a/src/items/itemDialog.component.tsx b/src/items/itemDialog.component.tsx index 90fd72660..54ab2bfdd 100644 --- a/src/items/itemDialog.component.tsx +++ b/src/items/itemDialog.component.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { Box, Button, + Collapse, Dialog, DialogActions, DialogContent, @@ -23,6 +24,7 @@ import { } from '@mui/material'; import { AddItem, + AdvancedSerialNumberOptionsType, CatalogueCategory, CatalogueCategoryFormData, CatalogueItem, @@ -35,13 +37,14 @@ import { import { DatePicker } from '@mui/x-date-pickers'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import { matchCatalogueItemProperties } from '../catalogue/catalogue.component'; -import { useAddItem, useEditItem } from '../api/items'; +import { useAddItem, useAddItems, useEditItem } from '../api/items'; import { AxiosError } from 'axios'; import handleIMS_APIError from '../handleIMS_APIError'; import { SystemsTableView } from '../systems/systemsTableView.component'; import { useSystems, useSystemsBreadcrumbs } from '../api/systems'; import Breadcrumbs from '../view/breadcrumbs.component'; import { trimStringValues } from '../utils'; +import handleTransferState from '../handleTransferState'; const maxYear = 2100; export function isValidDateTime(input: Date | string | null) { // Attempt to create a Date object from the input @@ -135,6 +138,14 @@ function ItemDialog(props: ItemDialogProps) { [] ); + const [advancedSerialNumberOptions, setAdvancedSerialNumberOptions] = + React.useState({ + quantity: null, + startingValue: null, + }); + const [showAdvancedSerialNumberOptions, setShowAdvancedSerialNumberOptions] = + React.useState(false); + const [propertyErrors, setPropertyErrors] = React.useState( new Array(parentCatalogueItemPropertiesInfo.length).fill(false) ); @@ -143,8 +154,9 @@ function ItemDialog(props: ItemDialogProps) { string | undefined >(undefined); - const { mutateAsync: addItem, isPending: isAddPending } = useAddItem(); - const { mutateAsync: editItem, isPending: isEditPending } = useEditItem(); + const { mutateAsync: addItem, isPending: isAddItemPending } = useAddItem(); + const { mutateAsync: addItems, isPending: isAddItemsPending } = useAddItems(); + const { mutateAsync: editItem, isPending: isEditItemPending } = useEditItem(); React.useEffect(() => { if (type === 'create' && open) { @@ -351,12 +363,30 @@ function ItemDialog(props: ItemDialogProps) { properties: updatedProperties, }; - addItem(trimStringValues(item)) - .then(() => handleClose()) - .catch((error: AxiosError) => { - handleIMS_APIError(error); + if (advancedSerialNumberOptions.quantity) { + addItems({ + quantity: Number(advancedSerialNumberOptions.quantity), + startingValue: Number(advancedSerialNumberOptions.startingValue ?? 1), + item: trimStringValues(item), + }).then((response) => { + handleTransferState(response); + handleClose(); }); - }, [handleFormPropertiesErrorStates, details, addItem, handleClose]); + } else { + addItem(trimStringValues(item)) + .then(() => handleClose()) + .catch((error: AxiosError) => { + handleIMS_APIError(error); + }); + } + }, [ + handleFormPropertiesErrorStates, + details, + advancedSerialNumberOptions, + addItems, + addItem, + handleClose, + ]); const handleEditItem = React.useCallback(() => { if (selectedItem) { @@ -482,12 +512,47 @@ function ItemDialog(props: ItemDialogProps) { setFormErrorMessage(undefined); }, [parentSystemId]); + const hasSerialNumberErrors = + advancedSerialNumberOptions.quantity && + !itemDetails.serial_number?.trim().includes('%s') && + 'Please use %s to specify the location you want to append the number to serial number'; + + const hasQuantityErrors = !advancedSerialNumberOptions.quantity + ? '' + : isNaN(Number(advancedSerialNumberOptions.quantity)) + ? 'Please enter a valid number' + : !Number.isInteger(Number(advancedSerialNumberOptions.quantity)) + ? 'Quantity must be an integer' + : Number(advancedSerialNumberOptions.quantity) <= 1 + ? 'Quantity must be greater than 1' + : Number(advancedSerialNumberOptions.quantity) >= 100 + ? 'Quantity must be less than 100' + : ''; + + const hasStartingValueErrors = + !advancedSerialNumberOptions.quantity && + !!advancedSerialNumberOptions.startingValue + ? 'Please enter a quantity value' + : isNaN(Number(advancedSerialNumberOptions.startingValue)) + ? 'Please enter a valid number' + : !Number.isInteger(Number(advancedSerialNumberOptions.startingValue)) + ? 'Quantity must be an integer' + : Number(advancedSerialNumberOptions.startingValue) < 0 + ? 'Quantity must be greater than or equal to 0' + : ''; + const isStepFailed = React.useCallback( (step: number) => { switch (step) { case 0: - return Object.values(hasDateErrors).some( - (value: boolean) => value === true + return ( + // Date error + Object.values(hasDateErrors).some( + (value: boolean) => value === true + ) || + !!hasSerialNumberErrors || + !!hasQuantityErrors || + !!hasStartingValueErrors ); case 1: return propertyErrors.some((value) => value === true); @@ -495,7 +560,13 @@ function ItemDialog(props: ItemDialogProps) { return false; } }, - [hasDateErrors, propertyErrors] + [ + hasDateErrors, + hasQuantityErrors, + hasSerialNumberErrors, + hasStartingValueErrors, + propertyErrors, + ] ); const renderStepContent = (step: number) => { @@ -503,7 +574,7 @@ function ItemDialog(props: ItemDialogProps) { case 0: return ( - + + + {type !== 'edit' && ( + <> + + setShowAdvancedSerialNumberOptions( + !showAdvancedSerialNumberOptions + ) + } + > + + {showAdvancedSerialNumberOptions + ? 'Close advanced options' + : 'Show advanced options'} + + + + + + + { + setAdvancedSerialNumberOptions( + (prev: AdvancedSerialNumberOptionsType) => ({ + ...prev, + quantity: event.target.value + ? event.target.value + : null, + }) + ); + }} + error={!!hasQuantityErrors} + helperText={hasQuantityErrors} + /> + + + { + setAdvancedSerialNumberOptions( + (prev: AdvancedSerialNumberOptionsType) => ({ + ...prev, + startingValue: event.target.value + ? event.target.value + : null, + }) + ); + }} + error={!!hasStartingValueErrors} + helperText={hasStartingValueErrors} + /> + + + + + + )} @@ -890,7 +1049,7 @@ function ItemDialog(props: ItemDialogProps) { labelProps.optional = ( {index === 1 && 'Invalid item properties'} - {index === 0 && 'Invalid date'} + {index === 0 && 'Invalid item details'} ); labelProps.error = true; @@ -919,14 +1078,18 @@ function ItemDialog(props: ItemDialogProps) { {activeStep === STEPS.length - 1 ? ( ) : ( @@ -3828,9 +3825,6 @@ exports[`Items Table > renders the dense table correctly 1`] = ` d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" /> - @@ -3924,9 +3918,6 @@ exports[`Items Table > renders the dense table correctly 1`] = ` d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" /> - @@ -4020,9 +4011,6 @@ exports[`Items Table > renders the dense table correctly 1`] = ` d="M16.59 8.59 12 13.17 7.41 8.59 6 10l6 6 6-6z" /> - @@ -4174,7 +4162,7 @@ exports[`Items Table > renders the dense table correctly 1`] = ` class="MuiInputBase-root MuiInput-root MuiInputBase-colorPrimary css-1mmm5cp-MuiInputBase-root-MuiInput-root-MuiSelect-root" >