From f82467f7075f43a605c62a137d1ec8d2ac77ffab Mon Sep 17 00:00:00 2001 From: Gabriel Tira Date: Mon, 3 Nov 2025 10:12:02 +0100 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 Added provisory getSortedImagesBySight function Removed CleanupObserver Added UploadSuccessPayload --- .../src/PhotoCapture/PhotoCapture.tsx | 9 +- .../src/hooks/useAdaptiveCameraConfig.ts | 6 +- .../src/hooks/useBadConnectionWarning.ts | 5 +- .../src/hooks/useImagesCleanup.ts | 101 ++++++++++++++++ .../src/hooks/useUploadQueue.ts | 33 +++++- .../hooks/useAdaptiveCameraConfig.test.ts | 4 +- .../hooks/useBadConnectionWarning.test.tsx | 18 ++- .../test/hooks/useImagesCleanup.test.ts | 112 ++++++++++++++++++ .../test/hooks/useUploadQueue.test.ts | 5 +- 9 files changed, 273 insertions(+), 20 deletions(-) create mode 100644 packages/inspection-capture-web/src/hooks/useImagesCleanup.ts create mode 100644 packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index b2c7d531c..a01c3db97 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -43,7 +43,7 @@ import { usePhotoCaptureSightTutorial, useInspectionComplete, } from './hooks'; -// import { SessionTimeTrackerDemo } from '../components/SessionTimeTrackerDemo'; +import { useImagesCleanup } from '../hooks/useImagesCleanup'; /** * Props of the PhotoCapture component. @@ -218,12 +218,17 @@ export function PhotoCapture({ closeBadConnectionWarningDialog, uploadEventHandlers: badConnectionWarningUploadEventHandlers, } = useBadConnectionWarning({ maxUploadDurationWarning }); + const { cleanupEventHandlers } = useImagesCleanup({ inspectionId, apiConfig }); const uploadQueue = useUploadQueue({ inspectionId, apiConfig, additionalTasks, complianceOptions, - eventHandlers: [adaptiveUploadEventHandlers, badConnectionWarningUploadEventHandlers], + eventHandlers: [ + adaptiveUploadEventHandlers, + badConnectionWarningUploadEventHandlers, + cleanupEventHandlers, + ], }); const images = usePhotoCaptureImages(inspectionId); const handlePictureTaken = usePictureTaken({ diff --git a/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts b/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts index 95c60b2d8..6e629f52c 100644 --- a/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts +++ b/packages/inspection-capture-web/src/hooks/useAdaptiveCameraConfig.ts @@ -6,7 +6,7 @@ import { } from '@monkvision/types'; import { useCallback, useMemo, useState } from 'react'; import { useObjectMemo } from '@monkvision/common'; -import { UploadEventHandlers } from './useUploadQueue'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; const DEFAULT_CAMERA_CONFIG: Required = { quality: 0.6, @@ -75,8 +75,8 @@ export function useAdaptiveCameraConfig({ setIsImageUpscalingAllowed(false); }; - const onUploadSuccess = useCallback((durationMs: number) => { - if (durationMs > MAX_UPLOAD_DURATION_MS) { + const onUploadSuccess = useCallback(({ durationMs }: UploadSuccessPayload) => { + if (durationMs && durationMs > MAX_UPLOAD_DURATION_MS) { lowerMaxImageQuality(); } }, []); diff --git a/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts b/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts index d5714394d..438dd47e8 100644 --- a/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts +++ b/packages/inspection-capture-web/src/hooks/useBadConnectionWarning.ts @@ -1,7 +1,7 @@ import { useCallback, useRef, useState } from 'react'; import { useObjectMemo } from '@monkvision/common'; import { PhotoCaptureAppConfig } from '@monkvision/types'; -import { UploadEventHandlers } from './useUploadQueue'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; /** * Parameters accepted by the useBadConnectionWarning hook. @@ -44,8 +44,9 @@ export function useBadConnectionWarning({ ); const onUploadSuccess = useCallback( - (durationMs: number) => { + ({ durationMs }: UploadSuccessPayload) => { if ( + durationMs && maxUploadDurationWarning >= 0 && durationMs > maxUploadDurationWarning && !hadDialogBeenDisplayed.current diff --git a/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts b/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts new file mode 100644 index 000000000..a54604b79 --- /dev/null +++ b/packages/inspection-capture-web/src/hooks/useImagesCleanup.ts @@ -0,0 +1,101 @@ +import { useMonkState, useObjectMemo } from '@monkvision/common'; +import { MonkApiConfig, useMonkApi } from '@monkvision/network'; +import { useCallback } from 'react'; +import { Image } from '@monkvision/types'; +import { UploadEventHandlers, UploadSuccessPayload } from './useUploadQueue'; + +/** + * Parameters accepted by the useImagesCleanup hook. + */ +export interface ImagesCleanupParams { + /** + * The inspection ID. + */ + inspectionId: string; + /** + * The api config used to communicate with the API. + */ + apiConfig: MonkApiConfig; +} + +/** + * Handle used to manage the images cleanup after a new one uploads. + */ +export interface ImagesCleanupHandle { + /** + * A set of event handlers listening to upload events. + */ + cleanupEventHandlers: UploadEventHandlers; +} + +function extractOtherImagesToDelete(imagesBySight: Record): Image[] { + const imagesToDelete: Image[] = []; + + Object.values(imagesBySight) + .filter((images) => images.length > 1) + .forEach((images) => { + const sortedImages = images.sort((a, b) => + b.createdAt && a.createdAt ? b.createdAt - a.createdAt : 0, + ); + imagesToDelete.push(...sortedImages.slice(1)); + }); + + return imagesToDelete; +} + +function groupImagesBySightId(images: Image[], sightIdToSkip: string): Record { + return images.reduce((acc, image) => { + if (!image.sightId || image.sightId === sightIdToSkip) { + return acc; + } + if (!acc[image.sightId]) { + acc[image.sightId] = []; + } + + acc[image.sightId].push(image); + return acc; + }, {} as Record); +} + +/** + * Custom hook used to cleanup sights' images of the inspection by deleting the old ones + * when a new image is added. + */ +export function useImagesCleanup(props: ImagesCleanupParams): ImagesCleanupHandle { + const { deleteImage } = useMonkApi(props.apiConfig); + const { state } = useMonkState(); + + const onUploadSuccess = useCallback( + ({ sightId, imageId }: UploadSuccessPayload) => { + if (!sightId) { + return; + } + + const otherImagesToDelete = extractOtherImagesToDelete( + groupImagesBySightId(state.images, sightId), + ); + + const sightImagesToDelete = state.images.filter( + (image) => + image.inspectionId === props.inspectionId && + image.sightId === sightId && + image.id !== imageId, + ); + + const imagesToDelete = [...otherImagesToDelete, ...sightImagesToDelete]; + + if (imagesToDelete.length > 0) { + imagesToDelete.forEach((image) => + deleteImage({ imageId: image.id, id: props.inspectionId }), + ); + } + }, + [state.images, props.inspectionId], + ); + + return useObjectMemo({ + cleanupEventHandlers: { + onUploadSuccess, + }, + }); +} diff --git a/packages/inspection-capture-web/src/hooks/useUploadQueue.ts b/packages/inspection-capture-web/src/hooks/useUploadQueue.ts index ff033d8a7..fdca38f8f 100644 --- a/packages/inspection-capture-web/src/hooks/useUploadQueue.ts +++ b/packages/inspection-capture-web/src/hooks/useUploadQueue.ts @@ -11,16 +11,32 @@ import { useRef } from 'react'; import { useMonitoring } from '@monkvision/monitoring'; import { CaptureMode } from '../types'; +/** + * Payload for the onUploadSuccess event handler. + */ +export interface UploadSuccessPayload { + /** + * The total elapsed time in milliseconds between the start of the upload and the end of the upload. + */ + durationMs?: number; + /** + * The sight ID associated with the uploaded picture, if applicable. + */ + sightId?: string; + /** + * The ID of the uploaded image. + */ + imageId?: string; +} + /** * Type definition for upload event handlers. */ export interface UploadEventHandlers { /** * Callback called when a picture upload successfully completes. - * - * @param durationMs The total elapsed time in milliseconds between the start of the upload and the end of the upload. */ - onUploadSuccess?: (durationMs: number) => void; + onUploadSuccess?: (payload: UploadSuccessPayload) => void; /** * Callback called when a picture upload fails because of a timeout. */ @@ -193,7 +209,7 @@ export function useUploadQueue({ } try { const startTs = Date.now(); - await addImage( + const result = await addImage( createAddImageOptions( upload, inspectionId, @@ -205,7 +221,14 @@ export function useUploadQueue({ ), ); const uploadDurationMs = Date.now() - startTs; - eventHandlers?.forEach((handlers) => handlers.onUploadSuccess?.(uploadDurationMs)); + const sightId = upload.mode === CaptureMode.SIGHT ? upload.sightId : undefined; + eventHandlers?.forEach((handlers) => + handlers.onUploadSuccess?.({ + durationMs: uploadDurationMs, + sightId, + imageId: result?.image?.id, + }), + ); } catch (err) { if ( err instanceof Error && diff --git a/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts b/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts index 957daa203..b64f5adcb 100644 --- a/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts +++ b/packages/inspection-capture-web/test/hooks/useAdaptiveCameraConfig.test.ts @@ -45,7 +45,7 @@ describe('useAdaptiveCameraConfigTest hook', () => { expect(result.current.adaptiveCameraConfig.resolution).toEqual( initialProps.initialCameraConfig.resolution, ); - act(() => result.current.uploadEventHandlers.onUploadSuccess?.(15001)); + act(() => result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 15001 })); expect(result.current.adaptiveCameraConfig.resolution).toEqual(CameraResolution.QHD_2K); expect(result.current.adaptiveCameraConfig.quality).toEqual(0.6); expect(result.current.adaptiveCameraConfig.allowImageUpscaling).toEqual(false); @@ -63,7 +63,7 @@ describe('useAdaptiveCameraConfigTest hook', () => { expect(result.current.adaptiveCameraConfig.resolution).toEqual( initialProps.initialCameraConfig.resolution, ); - act(() => result.current.uploadEventHandlers.onUploadSuccess?.(200)); + act(() => result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 200 })); expect(result.current.adaptiveCameraConfig).toEqual( expect.objectContaining(initialProps.initialCameraConfig), ); diff --git a/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx b/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx index aa0c2d121..94f1a85eb 100644 --- a/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx +++ b/packages/inspection-capture-web/test/hooks/useBadConnectionWarning.test.tsx @@ -26,7 +26,9 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); @@ -40,7 +42,9 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning - 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning - 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); @@ -68,7 +72,7 @@ describe('useBadConnectionWarning hook', () => { }); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(100000); + result.current.uploadEventHandlers.onUploadSuccess?.({ durationMs: 100000 }); result.current.uploadEventHandlers.onUploadTimeout?.(); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); @@ -87,7 +91,9 @@ describe('useBadConnectionWarning hook', () => { }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(true); @@ -131,7 +137,9 @@ describe('useBadConnectionWarning hook', () => { }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); act(() => { - result.current.uploadEventHandlers.onUploadSuccess?.(maxUploadDurationWarning + 1); + result.current.uploadEventHandlers.onUploadSuccess?.({ + durationMs: maxUploadDurationWarning + 1, + }); }); expect(result.current.isBadConnectionWarningDialogDisplayed).toBe(false); diff --git a/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts b/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts new file mode 100644 index 000000000..6ad3f9341 --- /dev/null +++ b/packages/inspection-capture-web/test/hooks/useImagesCleanup.test.ts @@ -0,0 +1,112 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { useImagesCleanup } from '../../src/hooks/useImagesCleanup'; +import { act } from '@testing-library/react'; +import { useMonkApi } from '@monkvision/network'; +import { useMonkState } from '@monkvision/common'; + +const apiConfig = { + apiDomain: 'apiDomain', + authToken: 'authToken', + thumbnailDomain: 'thumbnailDomain', +}; +const inspectionId = 'inspection-123'; +const state = { + images: [ + { sightId: 'sight-1', id: 'id-1', inspectionId }, + { sightId: 'sight-1', id: 'id-2', inspectionId }, + { sightId: 'sight-1', id: 'id-3', inspectionId }, + { sightId: 'sight-2', id: 'id-4', inspectionId }, + { sightId: 'sight-2', id: 'id-5', inspectionId }, + { sightId: 'sight-3', id: 'id-6', inspectionId }, + ], +}; + +describe('useImagesCleanup hook', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should properly clean up images', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + const uploadedSightId = 'sight-1'; + + act(() => { + result.current.cleanupEventHandlers.onUploadSuccess?.({ sightId: uploadedSightId }); + }); + expect(deleteImage).toHaveBeenCalled(); + expect(deleteImage.mock.calls.length).toBe(4); + + unmount(); + }); + + it('should not clean up images if no upload success event is triggered', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + expect(deleteImage).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should leave every sight with 1 image if sightId is not matched or undefined', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + const uploadedSightId = 'sight-non-matching'; + + act(() => { + result.current.cleanupEventHandlers.onUploadSuccess?.({ sightId: uploadedSightId }); + }); + + expect(deleteImage.mock.calls.length).toBe(3); + + unmount(); + }); + + it('should not clean up images if timeout occurs', () => { + const deleteImage = jest.fn(() => Promise.resolve()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ deleteImage })); + (useMonkState as jest.Mock).mockImplementation(() => ({ state })); + + const { result, unmount } = renderHook(useImagesCleanup, { + initialProps: { + inspectionId, + apiConfig, + }, + }); + + act(() => { + result.current.cleanupEventHandlers.onUploadTimeout?.(); + }); + + expect(deleteImage).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts b/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts index 68cc81c80..5813cd3d4 100644 --- a/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts +++ b/packages/inspection-capture-web/test/hooks/useUploadQueue.test.ts @@ -236,7 +236,10 @@ describe('useUploadQueue hook', () => { jest.advanceTimersByTime(durationMs); await promise; initialProps.eventHandlers?.forEach((eventHandlers) => { - expect(eventHandlers.onUploadSuccess).toHaveBeenCalledWith(durationMs); + expect(eventHandlers.onUploadSuccess).toHaveBeenCalledWith({ + durationMs, + sightId: defaultUploadOptions.sightId, + }); }); unmount();