From aaec99e254e4e1f93e007035c76aee3ffebd6e95 Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Mon, 20 Oct 2025 10:58:50 +0200 Subject: [PATCH] Implemented deleteImage API, state and tests Reset state objects containing the deleted image Updated objects to be deleted in State Updated API export and added more tests Added docs Updated Tests --- configs/test-utils/src/__mocks__/ky.ts | 1 + .../src/state/actions/deletedOneImage.ts | 80 +++++++++++++++ packages/common/src/state/actions/index.ts | 1 + .../common/src/state/actions/monkAction.ts | 4 + packages/common/src/state/reducer.ts | 5 + .../state/actions/deletedOneImage.test.ts | 97 +++++++++++++++++++ packages/common/test/state/reducer.test.ts | 5 + packages/network/README.md | 15 +++ packages/network/src/api/api.ts | 3 +- packages/network/src/api/image/requests.ts | 62 +++++++++++- packages/network/src/api/index.ts | 1 + packages/network/src/api/react.ts | 6 ++ .../network/test/api/image/requests.test.ts | 63 ++++++++++++ 13 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 packages/common/src/state/actions/deletedOneImage.ts create mode 100644 packages/common/test/state/actions/deletedOneImage.test.ts 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); + }); + }); });