diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index b2c7d531c..65920e9b0 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. @@ -194,6 +194,12 @@ export function PhotoCapture({ const onLastSightTaken = () => { setCurrentScreen(PhotoCaptureScreen.GALLERY); }; + const { currentTutorialStep, goToNextTutorialStep, closeTutorial } = usePhotoCaptureTutorial({ + enableTutorial, + enableSightGuidelines, + enableSightTutorial, + }); + const { showSightTutorial, toggleSightTutorial } = usePhotoCaptureSightTutorial(); const sightState = usePhotoCaptureSightState({ inspectionId, captureSights: sights, @@ -203,13 +209,8 @@ export function PhotoCapture({ tasksBySight, complianceOptions, setIsInitialInspectionFetched, + toggleSightTutorial, }); - const { currentTutorialStep, goToNextTutorialStep, closeTutorial } = usePhotoCaptureTutorial({ - enableTutorial, - enableSightGuidelines, - enableSightTutorial, - }); - const { showSightTutorial, toggleSightTutorial } = usePhotoCaptureSightTutorial(); const { showSightGuidelines, handleDisableSightGuidelines } = usePhotoCaptureSightGuidelines({ enableSightGuidelines, }); @@ -218,12 +219,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/PhotoCapture/hooks/usePhotoCaptureSightState.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts index 84eabacab..562bf24a4 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts @@ -22,6 +22,7 @@ import { TaskName, ImageStatus, ProgressStatus, + CAR_COVERAGE_COMPLIANCE_ISSUES, } from '@monkvision/types'; import { sights } from '@monkvision/sights'; import { useAnalytics } from '@monkvision/analytics'; @@ -114,6 +115,8 @@ export interface PhotoCaptureSightsParams { * sight will be used. */ tasksBySight?: Record; + /** Callback called to show the tutorial when retaking a non Car Coverage compliant sight. */ + toggleSightTutorial: () => void; } function getCaptureTasks( @@ -209,6 +212,7 @@ export function usePhotoCaptureSightState({ tasksBySight, setIsInitialInspectionFetched, complianceOptions, + toggleSightTutorial, }: PhotoCaptureSightsParams): PhotoCaptureSightState { if (captureSights.length === 0) { throw new Error('Empty sight list given to the Monk PhotoCapture component.'); @@ -334,6 +338,17 @@ export function usePhotoCaptureSightState({ if (sightToRetake) { setSelectedSight(sightToRetake); } + + const hasCarCoverageComplianceIssues = state.images.some((image) => { + return ( + image.sightId === id && + image.complianceIssues && + image.complianceIssues.some((issue) => CAR_COVERAGE_COMPLIANCE_ISSUES.includes(issue)) + ); + }); + if (hasCarCoverageComplianceIssues) { + toggleSightTutorial(); + } }, [captureSights], ); 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/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 49876a329..77525b8a0 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -234,6 +234,7 @@ describe('PhotoCapture component', () => { apiConfig: props.apiConfig, loading, onLastSightTaken: expect.any(Function), + toggleSightTutorial: expect.any(Function), tasksBySight: props.tasksBySight, complianceOptions: { enableCompliance: props.enableCompliance, diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts index 762caf340..abdef6d32 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts @@ -48,6 +48,7 @@ function createParams(): PhotoCaptureSightsParams { useLiveCompliance: true, }, setIsInitialInspectionFetched: jest.fn(), + toggleSightTutorial: jest.fn(), }; } @@ -332,6 +333,35 @@ describe('usePhotoCaptureSightState hook', () => { unmount(); }); + it('should not trigger tutorial when a new sight is taken', () => { + const initialProps = createParams(); + const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); + + act(() => result.current.selectSight(initialProps.captureSights[0])); + expect((initialProps.toggleSightTutorial as jest.Mock).mock.calls.length).toBe(0); + + unmount(); + }); + + it('should trigger tutorial only when retaking a non-compliant Car Coverage sight', () => { + const initialProps = createParams(); + (useMonkState as jest.Mock).mockImplementationOnce(() => ({ + state: { + images: [ + { sightId: 'test-sight-1', complianceIssues: [ComplianceIssue.NO_VEHICLE] }, // Car Coverage issue + { sightId: 'test-sight-2', complianceIssues: [ComplianceIssue.TOO_ZOOMED] }, // Non Car Coverage issue + ], + }, + })); + const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); + + act(() => result.current.retakeSight('test-sight-1')); + act(() => result.current.retakeSight('test-sight-2')); + expect((initialProps.toggleSightTutorial as jest.Mock).mock.calls.length).toBe(1); + + unmount(); + }); + it('should change the sightSelected when retaking a sight', () => { const initialProps = createParams(); const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); 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();