diff --git a/configs/test-utils/src/__mocks__/ky.ts b/configs/test-utils/src/__mocks__/ky.ts index eff491f70..ce2f8a448 100644 --- a/configs/test-utils/src/__mocks__/ky.ts +++ b/configs/test-utils/src/__mocks__/ky.ts @@ -28,4 +28,5 @@ export = { get: createMockKyRequestFn(), post: createMockKyRequestFn(), patch: createMockKyRequestFn(), + delete: createMockKyRequestFn(), }; diff --git a/packages/common/src/state/actions/deletedOneImage.ts b/packages/common/src/state/actions/deletedOneImage.ts new file mode 100644 index 000000000..cfd8ebcb9 --- /dev/null +++ b/packages/common/src/state/actions/deletedOneImage.ts @@ -0,0 +1,80 @@ +import { MonkState } from '../state'; +import { MonkAction, MonkActionType } from './monkAction'; + +/** + * The payload of a MonkDeletedOneImagePayload. + */ +export interface MonkDeletedOneImagePayload { + /** + * The ID of the inspection to which the image was deleted. + */ + inspectionId: string; + /** + * The image ID deleted. + */ + imageId: string; +} + +/** + * Action dispatched when an image have been deleted. + */ +export interface MonkDeletedOneImageAction extends MonkAction { + /** + * The type of the action : `MonkActionType.DELETED_ONE_IMAGE`. + */ + type: MonkActionType.DELETED_ONE_IMAGE; + /** + * The payload of the action containing the fetched entities. + */ + payload: MonkDeletedOneImagePayload; +} + +/** + * Matcher function that matches a DeletedOneImage while also inferring its type using TypeScript's type predicate + * feature. + */ +export function isDeletedOneImageAction(action: MonkAction): action is MonkDeletedOneImageAction { + return action.type === MonkActionType.DELETED_ONE_IMAGE; +} + +/** + * Reducer function for a deletedOneImage action. + */ +export function deletedOneImage(state: MonkState, action: MonkDeletedOneImageAction): MonkState { + const { images, inspections, damages, parts, renderedOutputs, views } = state; + const { payload } = action; + + const inspection = inspections.find((value) => value.id === payload.inspectionId); + if (inspection) { + inspection.images = inspection.images?.filter((imageId) => imageId !== payload.imageId); + } + const deletedImage = images.find((image) => image.id === payload.imageId); + const newImages = images.filter((image) => image.id !== payload.imageId); + const newDamages = damages.map((damage) => ({ + ...damage, + relatedImages: damage.relatedImages.filter((imageId) => imageId !== payload.imageId), + })); + const newParts = parts.map((part) => ({ + ...part, + relatedImages: part.relatedImages.filter((imageId) => imageId !== payload.imageId), + })); + const newViews = views.map((view) => ({ + ...view, + renderedOutputs: view.renderedOutputs.filter( + (outputId) => !deletedImage?.renderedOutputs.includes(outputId), + ), + })); + const newRenderedOutputs = renderedOutputs.filter( + (output) => !deletedImage?.renderedOutputs.includes(output.id), + ); + + return { + ...state, + images: [...newImages], + inspections: [...inspections], + damages: [...newDamages], + parts: [...newParts], + renderedOutputs: [...newRenderedOutputs], + views: [...newViews], + }; +} diff --git a/packages/common/src/state/actions/index.ts b/packages/common/src/state/actions/index.ts index bb1704645..97e2ca4cd 100644 --- a/packages/common/src/state/actions/index.ts +++ b/packages/common/src/state/actions/index.ts @@ -2,6 +2,7 @@ export * from './monkAction'; export * from './resetState'; export * from './gotOneInspection'; export * from './createdOneImage'; +export * from './deletedOneImage'; export * from './updatedManyTasks'; export * from './updatedVehicle'; export * from './createdOnePricing'; diff --git a/packages/common/src/state/actions/monkAction.ts b/packages/common/src/state/actions/monkAction.ts index f6ad0ddad..269e15551 100644 --- a/packages/common/src/state/actions/monkAction.ts +++ b/packages/common/src/state/actions/monkAction.ts @@ -14,6 +14,10 @@ export enum MonkActionType { * An image has been uploaded to the API. */ CREATED_ONE_IMAGE = 'created_one_image', + /** + * An image has been deleted. + */ + DELETED_ONE_IMAGE = 'deleted_one_image', /** * One or more tasks have been updated. */ diff --git a/packages/common/src/state/reducer.ts b/packages/common/src/state/reducer.ts index 13b67a119..42d88c577 100644 --- a/packages/common/src/state/reducer.ts +++ b/packages/common/src/state/reducer.ts @@ -24,6 +24,8 @@ import { isDeletedOneDamageAction, isGotOneInspectionPdfAction, gotOneInspectionPdf, + deletedOneImage, + isDeletedOneImageAction, } from './actions'; import { MonkState } from './state'; @@ -43,6 +45,9 @@ export function monkReducer(state: MonkState, action: MonkAction): MonkState { if (isCreatedOneImageAction(action)) { return createdOneImage(state, action); } + if (isDeletedOneImageAction(action)) { + return deletedOneImage(state, action); + } if (isUpdatedManyTasksAction(action)) { return updatedManyTasks(state, action); } diff --git a/packages/common/test/state/actions/deletedOneImage.test.ts b/packages/common/test/state/actions/deletedOneImage.test.ts new file mode 100644 index 000000000..9111496ff --- /dev/null +++ b/packages/common/test/state/actions/deletedOneImage.test.ts @@ -0,0 +1,97 @@ +import { Damage, Image, Inspection, Part, RenderedOutput, View } from '@monkvision/types'; +import { + createEmptyMonkState, + deletedOneImage, + isDeletedOneImageAction, + MonkActionType, + MonkDeletedOneImageAction, +} from '../../../src'; + +const action: MonkDeletedOneImageAction = { + type: MonkActionType.DELETED_ONE_IMAGE, + payload: { + inspectionId: 'inspections-test', + imageId: 'image-id-test', + }, +}; + +describe('DeletedOneImage action handlers', () => { + describe('Action matcher', () => { + it('should return true if the action has the proper type', () => { + expect(isDeletedOneImageAction({ type: MonkActionType.DELETED_ONE_IMAGE })).toBe(true); + }); + + it('should return false if the action does not have the proper type', () => { + expect(isDeletedOneImageAction({ type: MonkActionType.RESET_STATE })).toBe(false); + }); + }); + + describe('Action handler', () => { + it('should return a new state', () => { + const state = createEmptyMonkState(); + expect(Object.is(deletedOneImage(state, action), state)).toBe(false); + }); + + it('should delete image in the state', () => { + const state = createEmptyMonkState(); + const outputId = 'rendered-output-id-test'; + const damageId = 'damage-id-test'; + const partId = 'part-id-test'; + const viewId = 'view-id-test'; + state.inspections.push({ + id: action.payload.inspectionId, + images: [action.payload.imageId] as string[], + } as Inspection); + state.images.push({ + id: action.payload.imageId, + inspectionId: action.payload.inspectionId, + views: [viewId], + renderedOutputs: [outputId], + } as Image); + state.damages.push({ + id: damageId, + relatedImages: [action.payload.imageId], + inspectionId: action.payload.inspectionId, + } as Damage); + state.parts.push({ + id: partId, + relatedImages: [action.payload.imageId], + inspectionId: action.payload.inspectionId, + } as Part); + state.renderedOutputs.push({ + id: outputId, + baseImageId: action.payload.imageId, + } as RenderedOutput); + state.views.push({ + id: viewId, + renderedOutputs: [outputId], + } as View); + const newState = deletedOneImage(state, action); + const inspectionImages = newState.inspections.find( + (ins) => ins.id === action.payload.inspectionId, + )?.images; + + expect(inspectionImages?.length).toBe(0); + expect(inspectionImages).not.toContainEqual(action.payload.imageId); + expect(newState.images.length).toBe(0); + expect(newState.damages.length).toBe(1); + expect(newState.parts.length).toBe(1); + expect(newState.views.length).toBe(1); + expect( + newState.damages.find((damage) => damage.relatedImages.includes(action.payload.imageId)), + ).toBeUndefined(); + expect( + newState.parts.find((part) => part.relatedImages.includes(action.payload.imageId)), + ).toBeUndefined(); + expect(newState.renderedOutputs.length).toBe(0); + expect( + newState.renderedOutputs.find((output) => output.baseImageId === action.payload.imageId), + ).toBeUndefined(); + expect( + newState.views.find((view) => + view.renderedOutputs.find((id) => id === action.payload.imageId), + ), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/common/test/state/reducer.test.ts b/packages/common/test/state/reducer.test.ts index d5db587aa..ebd78acbb 100644 --- a/packages/common/test/state/reducer.test.ts +++ b/packages/common/test/state/reducer.test.ts @@ -1,5 +1,6 @@ jest.mock('../../src/state/actions', () => ({ isCreatedOneImageAction: jest.fn(() => false), + isDeletedOneImageAction: jest.fn(() => false), isGotOneInspectionAction: jest.fn(() => false), isResetStateAction: jest.fn(() => false), isUpdatedManyTasksAction: jest.fn(() => false), @@ -12,6 +13,7 @@ jest.mock('../../src/state/actions', () => ({ isUpdatedVehicleAction: jest.fn(() => false), isGotOneInspectionPdfAction: jest.fn(() => false), createdOneImage: jest.fn(() => null), + deletedOneImage: jest.fn(() => null), gotOneInspection: jest.fn(() => null), resetState: jest.fn(() => null), updatedManyTasks: jest.fn(() => null), @@ -27,6 +29,7 @@ jest.mock('../../src/state/actions', () => ({ import { createdOneImage, + deletedOneImage, gotOneInspection, createdOnePricing, deletedOnePricing, @@ -37,6 +40,7 @@ import { updatedVehicle, gotOneInspectionPdf, isCreatedOneImageAction, + isDeletedOneImageAction, isGotOneInspectionAction, isResetStateAction, isUpdatedManyTasksAction, @@ -59,6 +63,7 @@ const actions = [ { matcher: isResetStateAction, handler: resetState, noParams: true }, { matcher: isGotOneInspectionAction, handler: gotOneInspection }, { matcher: isCreatedOneImageAction, handler: createdOneImage }, + { matcher: isDeletedOneImageAction, handler: deletedOneImage }, { matcher: isUpdatedManyTasksAction, handler: updatedManyTasks }, { matcher: isCreatedOnePricingAction, handler: createdOnePricing }, { matcher: isDeletedOnePricingAction, handler: deletedOnePricing }, diff --git a/packages/network/README.md b/packages/network/README.md index 8266ad580..a0f2de6cc 100644 --- a/packages/network/README.md +++ b/packages/network/README.md @@ -70,6 +70,21 @@ been created in the API. |-----------|-----------------|----------------------------------------------------------|----------| | options | AddImageOptions | The options used to specify how to upload the image. | ✔️ | +### deleteImage + +```typescript +import { MonkApi } from '@monkvision/network'; + +MonkApi.deleteImage(options,apiConfig, dispatch); +``` + +Delete an image from an inspection. The resulting action of this request will contain the ID of the image that has +been deleted in the API. + +| Parameter | Type | Description | Required | +| ----------|--------------------|----------------------------------------------------|----------| +| options | DeleteImageOptions | The options used to specify which image to delete. | ✔️ | + ### updateTaskStatus ```typescript import { MonkApi } from '@monkvision/network'; diff --git a/packages/network/src/api/api.ts b/packages/network/src/api/api.ts index a6228a37a..bcca8d6fb 100644 --- a/packages/network/src/api/api.ts +++ b/packages/network/src/api/api.ts @@ -5,7 +5,7 @@ import { getAllInspections, getAllInspectionsCount, } from './inspection'; -import { addImage } from './image'; +import { addImage, deleteImage } from './image'; import { startInspectionTasks, updateTaskStatus } from './task'; import { getLiveConfig } from './liveConfigs'; import { updateInspectionVehicle } from './vehicle'; @@ -23,6 +23,7 @@ export const MonkApi = { getAllInspectionsCount, createInspection, addImage, + deleteImage, updateTaskStatus, startInspectionTasks, getLiveConfig, diff --git a/packages/network/src/api/image/requests.ts b/packages/network/src/api/image/requests.ts index 7c6710cc6..e67c3bf82 100644 --- a/packages/network/src/api/image/requests.ts +++ b/packages/network/src/api/image/requests.ts @@ -5,6 +5,7 @@ import { MonkActionType, MonkCreatedOneImageAction, vehiclePartLabels, + MonkDeletedOneImageAction, } from '@monkvision/common'; import { ComplianceOptions, @@ -15,6 +16,7 @@ import { ImageType, MonkEntityType, MonkPicture, + ProgressStatus, TaskName, TranslationObject, VehiclePart, @@ -22,7 +24,13 @@ import { import { v4 } from 'uuid'; import { labels, sights } from '@monkvision/sights'; import { getDefaultOptions, MonkApiConfig } from '../config'; -import { ApiCenterOnElement, ApiImage, ApiImagePost, ApiImagePostTask } from '../models'; +import { + ApiCenterOnElement, + ApiIdColumn, + ApiImage, + ApiImagePost, + ApiImagePostTask, +} from '../models'; import { MonkApiResponse } from '../types'; import { mapApiImage } from './mappers'; @@ -593,3 +601,55 @@ export async function addImage( throw err; } } + +/** + * Options specified when deleting an image from an inspection. + */ +export interface DeleteImageOptions { + /** + * The ID of the inspection to update via the API. + */ + id: string; + /** + * Image ID that will be deleted. + */ + imageId: string; +} + +/** + * Delete an image from an inspection. + * + * @param options Deletion options for the image. + * @param config The API config. + * @param [dispatch] Optional MonkState dispatch function that you can pass if you want this request to handle React + * state management for you. + */ +export async function deleteImage( + options: DeleteImageOptions, + config: MonkApiConfig, + dispatch?: Dispatch, +): Promise { + const kyOptions = getDefaultOptions(config); + try { + const response = await ky.delete(`inspections/${options.id}/images/${options.imageId}`, { + ...kyOptions, + json: { authorized_tasks_statuses: [ProgressStatus.NOT_STARTED] }, + }); + const body = await response.json(); + dispatch?.({ + type: MonkActionType.DELETED_ONE_IMAGE, + payload: { + inspectionId: options.id, + imageId: body.id, + }, + }); + return { + id: body.id, + response, + body, + }; + } catch (err) { + console.error(`Failed to delete image: ${(err as Error).message}`); + throw err; + } +} diff --git a/packages/network/src/api/index.ts b/packages/network/src/api/index.ts index b520d0888..627486234 100644 --- a/packages/network/src/api/index.ts +++ b/packages/network/src/api/index.ts @@ -18,6 +18,7 @@ export { type AddBeautyShotImageOptions, type Add2ShotCloseUpImageOptions, type AddImageOptions, + type DeleteImageOptions, type AddVideoFrameOptions, ImageUploadType, } from './image'; diff --git a/packages/network/src/api/react.ts b/packages/network/src/api/react.ts index 68a2b14e2..21eeb4ec7 100644 --- a/packages/network/src/api/react.ts +++ b/packages/network/src/api/react.ts @@ -81,6 +81,12 @@ export function useMonkApi(config: MonkApiConfig) { * @param options Upload options for the image. */ addImage: reactify(MonkApi.addImage, config, dispatch, handleError), + /** + * Delete an image from an inspection. + * + * @param options The options of the request. + */ + deleteImage: reactify(MonkApi.deleteImage, config, dispatch, handleError), /** * Update the progress status of an inspection task. * diff --git a/packages/network/test/api/image/requests.test.ts b/packages/network/test/api/image/requests.test.ts index 040af18eb..337016570 100644 --- a/packages/network/test/api/image/requests.test.ts +++ b/packages/network/test/api/image/requests.test.ts @@ -9,6 +9,17 @@ jest.mock('../../../src/api/image/mappers', () => ({ mapApiImage: jest.fn(() => ({ test: 'hello' })), })); +jest.mock('ky', () => ({ + get: jest.fn(() => Promise.resolve({ json: jest.fn(() => Promise.resolve({ id: 'test-id' })) })), + post: jest.fn(() => Promise.resolve({ json: jest.fn(() => Promise.resolve({ id: 'test-id' })) })), + delete: jest.fn(() => + Promise.resolve({ json: jest.fn(() => Promise.resolve({ id: 'delete-test-fake-id' })) }), + ), + patch: jest.fn(() => + Promise.resolve({ json: jest.fn(() => Promise.resolve({ id: 'patch-test-fake-id' })) }), + ), +})); + import { labels, sights } from '@monkvision/sights'; import ky from 'ky'; import { @@ -16,6 +27,7 @@ import { ImageStatus, ImageSubtype, ImageType, + ProgressStatus, TaskName, VehiclePart, } from '@monkvision/types'; @@ -25,10 +37,12 @@ import { Add2ShotCloseUpImageOptions, AddBeautyShotImageOptions, addImage, + deleteImage, AddPartSelectCloseUpImageOptions, AddVideoFrameOptions, AddVideoManualPhotoOptions, ImageUploadType, + DeleteImageOptions, } from '../../../src/api/image'; import { mapApiImage } from '../../../src/api/image/mappers'; @@ -137,6 +151,13 @@ function createVideoManualPhotoOptions(): AddVideoManualPhotoOptions { }; } +function createDeleteImageOptions(): DeleteImageOptions { + return { + id: 'test-inspection-id', + imageId: 'test-image-id', + }; +} + describe('Image requests', () => { let fileMock: File; let fileConstructorSpy: jest.SpyInstance; @@ -555,4 +576,46 @@ describe('Image requests', () => { expect(ky.get).not.toHaveBeenCalled(); }); }); + + describe('deleteImage request', () => { + it('should properly make a request to the proper URL', async () => { + const options = createDeleteImageOptions(); + const dispatch = jest.fn(); + const result = await deleteImage(options, apiConfig, dispatch); + const response = await (ky.delete as jest.Mock).mock.results[0].value; + const body = await response.json(); + + expect(getDefaultOptions).toHaveBeenCalledWith(apiConfig); + const kyOptions = getDefaultOptions(apiConfig); + expect(ky.delete).toHaveBeenCalledWith( + `inspections/${options.id}/images/${options.imageId}`, + expect.objectContaining({ + ...kyOptions, + json: { authorized_tasks_statuses: [ProgressStatus.NOT_STARTED] }, + }), + ); + expect(dispatch).toHaveBeenCalledWith({ + type: MonkActionType.DELETED_ONE_IMAGE, + payload: { + inspectionId: options.id, + imageId: body.id, + }, + }); + expect(result).toEqual({ + id: body.id, + response, + body, + }); + }); + + it('should display an error if deletion fails', async () => { + const err = new Error('test-delete-error'); + jest.spyOn(ky, 'delete').mockImplementationOnce(() => { + throw err; + }); + const options = createDeleteImageOptions(); + const dispatch = jest.fn(); + await expect(deleteImage(options, apiConfig, dispatch)).rejects.toThrow(err); + }); + }); });