diff --git a/packages/inspection-capture-web/README.md b/packages/inspection-capture-web/README.md index e6098a20b..198bc4f34 100644 --- a/packages/inspection-capture-web/README.md +++ b/packages/inspection-capture-web/README.md @@ -5,6 +5,7 @@ There are two main workflows for capturing pictures of a vehicle for a Monk insp to take pictures of the vehicle by aligning the vehicle with the Sight overlays. - The **VideoCapture** workflow : the user is asked to record a quick video of their vehicle by filming it and rotating in a full circle around it. +- The **DamageDisclosure** workflow : The user is guided to capture close-up pictures of specific damaged parts of the vehicle. Before taking the picture, the user must first select the damaged part on the vehicle wireframe. # Installing To install the package, you can run the following command : @@ -96,3 +97,55 @@ export function MonkPhotoCapturePage({ authToken }) { | thumbnailDomain | `string` | The API domain used to communicate with the resize micro service. | ✔️ | | +# DamageDisclosure + +The DamageDisclosure workflow is designed to guide users in documenting and disclosing damage to their vehicles during a Monk inspection. Once the damaged areas are identified, the user is prompted to take close-up photos of each selected area, ensuring accurate documentation for the inspection. +This workflow is ideal for capturing detailed images of specific damages such as dents, scratches, or other issues that need to be highlighted in the inspection report. + +Please refer to the [official MonkJs documentation](https://monkvision.github.io/monkjs/docs/photo-capture-workflow) for a comprehensive overview of the Add damage workflow. + +## DamageDisclosure component + +This package exports a ready-to-use single-page component called DamageDisclosure that implements the DamageDisclosure workflow. You can integrate it into your application by creating a new page containing only this component. Before using it, you must generate an Auth0 authentication token and create a new inspection using the Monk API. Ensure that all task statuses in the inspection are set to NOT_STARTED. This component will automatically handle starting tasks after the capture process is complete. + +You can then pass the inspection ID, API configuration (including the auth token), and a list of sights to be displayed to the user. Once the user completes the workflow, the onComplete callback is triggered, allowing you to navigate to another page or perform additional actions. + +The following example demonstrates how to use the DamageDisclosure component: + +```tsx +import { DamageDisclosure } from '@monkvision/inspection-capture-web'; + +const apiDomain = 'api.preview.monk.ai/v1'; + +export function MonkDamageDisclosurePage({ authToken }) { + return ( + { /* Navigate to another page */ }} + /> + ); +} +``` + +Props + +| Prop | Type | Description | Required | Default Value | +|------------------------------------|----------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|----------------------------------------------| +| inspectionId | string | The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as the one described in the auth token in the `apiConfig` prop. | ✔️ | | +| apiConfig | ApiConfig | The api config used to communicate with the API. Make sure that the user described in the auth token is the same one as the one that created the inspection provided in the `inspectionId` prop. | ✔️ | | +| onClose | `() => void` | Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be displayed on the screen. | | | +| onComplete | `() => void` | Callback called when inspection capture is complete. | | | +| onPictureTaken | `(picture: MonkPicture) => void` | Callback called when the user has taken a picture in the Capture process. | | | +| lang | string | null | The language to be used by this component. | | `'en'` | +| enforceOrientation | `DeviceOrientation` | Use this prop to enforce a specific device orientation for the Camera screen. | | | +| maxUploadDurationWarning | `number` | Max upload duration in milliseconds before showing a bad connection warning to the user. Use `-1` to never display the warning. | | `15000` | +| useAdaptiveImageQuality | `boolean` | Boolean indicating if the image quality should be downgraded automatically in case of low connection. | | `true` | +| showCloseButton | `boolean` | Indicates if the close button should be displayed in the HUD on top of the Camera preview. | | `false` | +| format | `CompressionFormat` | The output format of the compression. | | `CompressionFormat.JPEG` | +| quality | `number` | Value indicating image quality for the compression output. | | `0.6` | +| resolution | `CameraResolution` | Indicates the resolution of the pictures taken by the Camera. | | `CameraResolution.UHD_4K` | +| allowImageUpscaling | `boolean` | Allow images to be scaled up if the device does not support the specified resolution in the `resolution` prop. | | `false` | +| useLiveCompliance | `boolean` | Indicates if live compliance should be enabled or not. | | `false` | +| validateButtonLabel | `string` | Custom label for validate button in gallery view. | | | +| thumbnailDomain | `string` | The API domain used to communicate with the resize micro service. | ✔️ | | diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.styles.ts b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.styles.ts new file mode 100644 index 000000000..64345479c --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.styles.ts @@ -0,0 +1,32 @@ +import { Styles } from '@monkvision/types'; + +export const styles: Styles = { + container: { + height: '100%', + width: '100%', + }, + orientationErrorContainer: { + height: '100%', + width: '100%', + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + flexDirection: 'column', + boxSizing: 'border-box', + padding: '50px 10%', + }, + orientationErrorTitleContainer: { + display: 'flex', + alignItems: 'center', + }, + orientationErrorTitle: { + fontSize: 18, + marginLeft: 16, + }, + orientationErrorDescription: { + fontSize: 16, + paddingTop: 16, + opacity: 0.8, + textAlign: 'center', + }, +}; diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.tsx b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.tsx new file mode 100644 index 000000000..db64b5732 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosure.tsx @@ -0,0 +1,221 @@ +import { useAnalytics } from '@monkvision/analytics'; +import { Camera, CameraHUDProps, CameraProps } from '@monkvision/camera-web'; +import { + useI18nSync, + useLoadingState, + useObjectMemo, + useWindowDimensions, +} from '@monkvision/common'; +import { BackdropDialog, Icon, InspectionGallery } from '@monkvision/common-ui-web'; +import { MonkApiConfig } from '@monkvision/network'; +import { + AddDamage, + CameraConfig, + CaptureAppConfig, + ComplianceOptions, + CompressionOptions, + DeviceOrientation, + MonkPicture, +} from '@monkvision/types'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { styles } from './DamageDisclosure.styles'; +import { DamageDisclosureHUD, DamageDisclosureHUDProps } from './DamageDisclosureHUD'; +import { useDamageDisclosureState } from './hooks'; +import { + useAdaptiveCameraConfig, + useAddDamageMode, + usePhotoCaptureImages, + usePictureTaken, + useUploadQueue, + useBadConnectionWarning, + useTracking, +} from '../hooks'; +import { CaptureScreen } from '../types'; + +/** + * Props of the DamageDisclosure component. + */ +export interface DamageDisclosureProps + extends Pick, 'resolution' | 'allowImageUpscaling'>, + Pick< + CaptureAppConfig, + | keyof CameraConfig + | 'maxUploadDurationWarning' + | 'useAdaptiveImageQuality' + | 'showCloseButton' + | 'enforceOrientation' + | 'addDamage' + >, + Partial, + Partial { + /** + * The ID of the inspection to add images to. Make sure that the user that created the inspection if the same one as + * the one described in the auth token in the `apiConfig` prop. + */ + inspectionId: string; + /** + * The api config used to communicate with the API. Make sure that the user described in the auth token is the same + * one as the one that created the inspection provided in the `inspectionId` prop. + */ + apiConfig: MonkApiConfig; + /** + * Callback called when the user clicks on the Close button. If this callback is not provided, the button will not be + * displayed on the screen. + */ + onClose?: () => void; + /** + * Callback called when inspection capture is complete. + */ + onComplete?: () => void; + /** + * Callback called when a picture has been taken by the user. + */ + onPictureTaken?: (picture: MonkPicture) => void; + /** + * The language to be used by this component. + * + * @default en + */ + lang?: string | null; +} + +// No ts-doc for this component : the component exported is DamageDisclosureHOC +export function DamageDisclosure({ + inspectionId, + apiConfig, + onClose, + onComplete, + onPictureTaken, + useLiveCompliance = false, + maxUploadDurationWarning = 15000, + showCloseButton = false, + addDamage = AddDamage.PART_SELECT, + useAdaptiveImageQuality = true, + lang, + enforceOrientation, + ...initialCameraConfig +}: DamageDisclosureProps) { + useI18nSync(lang); + const complianceOptions: ComplianceOptions = useObjectMemo({ + useLiveCompliance, + }); + const { t } = useTranslation(); + const [currentScreen, setCurrentScreen] = useState(CaptureScreen.CAMERA); + const dimensions = useWindowDimensions(); + const analytics = useAnalytics(); + const loading = useLoadingState(); + const handleOpenGallery = () => { + setCurrentScreen(CaptureScreen.GALLERY); + analytics.trackEvent('Gallery Opened'); + }; + const addDamageHandle = useAddDamageMode({ + addDamage, + currentScreen, + damageDisclosure: true, + handleOpenGallery, + }); + const disclosureState = useDamageDisclosureState({ + inspectionId, + apiConfig, + loading, + complianceOptions, + }); + useTracking({ inspectionId, authToken: apiConfig.authToken }); + const { adaptiveCameraConfig, uploadEventHandlers: adaptiveUploadEventHandlers } = + useAdaptiveCameraConfig({ + initialCameraConfig, + useAdaptiveImageQuality, + }); + const { + isBadConnectionWarningDialogDisplayed, + closeBadConnectionWarningDialog, + uploadEventHandlers: badConnectionWarningUploadEventHandlers, + } = useBadConnectionWarning({ maxUploadDurationWarning }); + const uploadQueue = useUploadQueue({ + inspectionId, + apiConfig, + complianceOptions, + eventHandlers: [adaptiveUploadEventHandlers, badConnectionWarningUploadEventHandlers], + }); + const images = usePhotoCaptureImages(inspectionId); + const handlePictureTaken = usePictureTaken({ + sightState: disclosureState, + addDamageHandle, + uploadQueue, + onPictureTaken, + }); + const handleGalleryBack = () => { + setCurrentScreen(CaptureScreen.CAMERA); + }; + const isViolatingEnforcedOrientation = + enforceOrientation && + (enforceOrientation === DeviceOrientation.PORTRAIT) !== dimensions.isPortrait; + const hudProps: Omit = { + inspectionId, + mode: addDamageHandle.mode, + vehicleParts: addDamageHandle.vehicleParts, + lastPictureTakenUri: disclosureState.lastPictureTakenUri, + onOpenGallery: handleOpenGallery, + onAddDamage: addDamageHandle.handleAddDamage, + onAddDamagePartsSelected: addDamageHandle.handleAddDamagePartsSelected, + onCancelAddDamage: addDamageHandle.handleCancelAddDamage, + onRetry: disclosureState.retryLoadingInspection, + loading, + onClose, + showCloseButton, + images, + addDamage, + onValidateVehicleParts: addDamageHandle.handleValidateVehicleParts, + }; + + return ( +
+ {currentScreen === CaptureScreen.CAMERA && isViolatingEnforcedOrientation && ( +
+
+ +
{t('photo.orientationError.title')}
+
+
+ {t('photo.orientationError.description')} +
+
+ )} + {currentScreen === CaptureScreen.CAMERA && !isViolatingEnforcedOrientation && ( + + )} + {currentScreen === CaptureScreen.GALLERY && ( + + )} + +
+ ); +} diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHOC.tsx b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHOC.tsx new file mode 100644 index 000000000..f3aafd699 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHOC.tsx @@ -0,0 +1,40 @@ +import { i18nWrap, MonkProvider } from '@monkvision/common'; +import { i18nInspectionCaptureWeb } from '../i18n'; +import { DamageDisclosure, DamageDisclosureProps } from './DamageDisclosure'; + +/** + * The DamageDisclosure component is a ready-to-use, single page component that implements a Camera app, allowing users + * to capture photos of damaged parts of their vehicle for the purpose of disclosing damage. In order to use this + * component, you first need to generate an Auth0 authentication token, and create an inspection using the Monk Api. + * When creating the inspection, don't forget to set the tasks statuses to `NOT_STARTED`. This component will handle the + * starting of the tasks at the end of the capturing process. You can then pass the inspection ID, the api config (with + * the auth token), as well as the list of sights to be taken by the user to this component, and everything will be + * handled automatically for you. + * + * @example + * import { DamageDisclosure } from '@monkvision/inspection-capture-web'; + * + * export function PhotoCaptureScreen({ inspectionId, apiConfig }: PhotoCaptureScreenProps) { + * const { i18n } = useTranslation(); + * + * return ( + * { / * Navigate to another page * / }} + * lang={i18n.language} + * /> + * ); + * } + */ +export const DamageDisclosureHOC = i18nWrap(function DamageDisclosureHOC( + props: DamageDisclosureProps, +) { + return ( + + + + ); +}, +i18nInspectionCaptureWeb); diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.styles.ts b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.styles.ts new file mode 100644 index 000000000..3e94800ea --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.styles.ts @@ -0,0 +1,23 @@ +import { Styles } from '@monkvision/types'; + +export const styles: Styles = { + container: { + width: '100%', + height: '100%', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + position: 'relative', + alignSelf: 'stretch', + }, + containerPortrait: { + __media: { portrait: true }, + flexDirection: 'column', + }, + previewContainer: { + position: 'relative', + width: '100%', + height: '100%', + }, +}; diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.tsx b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.tsx new file mode 100644 index 000000000..9fe84f162 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUD.tsx @@ -0,0 +1,168 @@ +import { useMemo, useState } from 'react'; +import { CaptureAppConfig, Image, ImageStatus, VehiclePart } from '@monkvision/types'; +import { useTranslation } from 'react-i18next'; +import { BackdropDialog } from '@monkvision/common-ui-web'; +import { CameraHUDProps } from '@monkvision/camera-web'; +import { LoadingState } from '@monkvision/common'; +import { useAnalytics } from '@monkvision/analytics'; +import { styles } from './DamageDisclosureHUD.styles'; +import { CaptureMode } from '../../types'; +import { HUDButtons } from '../../components/HUDButtons'; +import { DamageDisclosureHUDElements } from './DamageDisclosureHUDElements'; +import { HUDOverlay } from '../../components/HUDOverlay'; + +/** + * Props of the DamageDisclosureHUD component. + */ +export interface DamageDisclosureHUDProps + extends CameraHUDProps, + Pick { + /** + * The inspection ID. + */ + inspectionId: string; + /** + * The current mode of the component. + */ + mode: CaptureMode; + /** + * Global loading state of the DamageDisclosure component. + */ + loading: LoadingState; + /** + * Current vehicle parts selected to take a picture of. + */ + vehicleParts: VehiclePart[]; + /** + * Value storing the last picture taken by the user. If no picture has been taken yet, this value is null. + */ + lastPictureTakenUri: string | null; + /** + * Callback called when the user clicks on the "Add Damage" button. + */ + onAddDamage: () => void; + /** + * Callback called when the user selects the parts to take a picture of. + */ + onAddDamagePartsSelected?: (parts: VehiclePart[]) => void; + /** + * Callback called when the user clicks on the "Cancel" button of the Add Damage mode. + */ + onCancelAddDamage: () => void; + /** + * Callback that can be used to retry fetching this state object from the API in case the previous fetch failed. + */ + onRetry: () => void; + /** + * Callback called when the user clicks on the gallery icon. + */ + onOpenGallery: () => void; + /** + * Callback called when the user clicks on the "Validate" button of the Add Damage mode. + */ + onValidateVehicleParts: () => void; + /** + * Callback called when the user clicks on the close button. If this callback is not provided, the close button is not + * displayed. + */ + onClose?: () => void; + /** + * The current images taken by the user (ignoring retaken pictures etc.). + */ + images: Image[]; +} + +/** + * This component implements the Camera HUD (head-up display) displayed in the DamageDisclosure component + * over the Camera preview. It implements elements such as buttons to interact with + * the camera, DamageDisclosure indicators, error messages, loaders etc. + */ +export function DamageDisclosureHUD({ + inspectionId, + lastPictureTakenUri, + mode, + vehicleParts, + onAddDamage, + onAddDamagePartsSelected, + onValidateVehicleParts, + onCancelAddDamage, + onOpenGallery, + onClose, + onRetry, + showCloseButton, + loading, + handle, + cameraPreview, + images, +}: DamageDisclosureHUDProps) { + const { t } = useTranslation(); + const [showCloseModal, setShowCloseModal] = useState(false); + const { trackEvent } = useAnalytics(); + const retakeCount = useMemo( + () => + images.filter((image) => + [ImageStatus.NOT_COMPLIANT, ImageStatus.UPLOAD_FAILED, ImageStatus.UPLOAD_ERROR].includes( + image.status, + ), + ).length, + [images], + ); + + const handleCloseConfirm = () => { + setShowCloseModal(false); + trackEvent('Capture Closed'); + onClose?.(); + }; + + return ( +
+
+ {cameraPreview} + { + + } +
+ {mode !== CaptureMode.ADD_DAMAGE_PART_SELECT && ( + setShowCloseModal(true)} + galleryPreview={lastPictureTakenUri ?? undefined} + closeDisabled={!!loading.error || !!handle.error} + galleryDisabled={!!loading.error || !!handle.error} + takePictureDisabled={ + !!loading.error || !!handle.error || handle.isLoading || loading.isLoading + } + showCloseButton={showCloseButton} + showGalleryBadge={retakeCount > 0} + retakeCount={retakeCount} + /> + )} + + setShowCloseModal(false)} + onConfirm={handleCloseConfirm} + /> +
+ ); +} diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/DamageDisclosureHUDElements.tsx b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/DamageDisclosureHUDElements.tsx new file mode 100644 index 000000000..19c78ee9c --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/DamageDisclosureHUDElements.tsx @@ -0,0 +1,75 @@ +import { CaptureAppConfig, PixelDimensions, VehiclePart } from '@monkvision/types'; +import { CaptureMode } from '../../../types'; +import { CloseUpShot, PartSelection, ZoomOutShot } from '../../../components'; +/** + * Props of the DamageDisclosureHUDElements component. + */ +export interface DamageDisclosureHUDElementsProps extends Pick { + /** + * The current mode of the component. + */ + mode: CaptureMode; + /** + * Current vehicle parts selected to take a picture of. + */ + vehicleParts: VehiclePart[]; + /** + * Callback called when the user presses the Add Damage button. + */ + onAddDamage: () => void; + /** + * Callback called when the user selects the parts to take a picture of. + */ + onAddDamagePartsSelected?: (parts: VehiclePart[]) => void; + /** + * Callback called when the user cancels the Add Damage mode. + */ + onCancelAddDamage: () => void; + /** + * Callback called when the user clicks on the "Validate" button of the Add Damage mode. + */ + onValidateVehicleParts: () => void; + /** + * The effective pixel dimensions of the Camera video stream on the screen. + */ + previewDimensions: PixelDimensions | null; + /** + * Boolean indicating if the global loading state of the DamageDisclosure component is loading or not. + */ + isLoading?: boolean; + /** + * The error that occurred in the DamageDisclosure component. Set this value to `null` if there is no error. + */ + error?: unknown | null; +} + +/** + * Component implementing an HUD displayed on top of the Camera preview during the DamageDisclosure process. + */ +export function DamageDisclosureHUDElements(params: DamageDisclosureHUDElementsProps) { + if (params.isLoading || !!params.error) { + return null; + } + if (params.mode === CaptureMode.ADD_DAMAGE_1ST_SHOT) { + return ; + } + if ( + [CaptureMode.ADD_DAMAGE_2ND_SHOT, CaptureMode.ADD_DAMAGE_PART_SELECT_SHOT].includes(params.mode) + ) { + return ( + + ); + } + return ( + + ); +} diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/index.ts b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/index.ts new file mode 100644 index 000000000..012d4a0e9 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements/index.ts @@ -0,0 +1,4 @@ +export { + DamageDisclosureHUDElements, + type DamageDisclosureHUDElementsProps, +} from './DamageDisclosureHUDElements'; diff --git a/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/index.ts b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/index.ts new file mode 100644 index 000000000..c326515de --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/DamageDisclosureHUD/index.ts @@ -0,0 +1,2 @@ +export { DamageDisclosureHUD, type DamageDisclosureHUDProps } from './DamageDisclosureHUD'; +export * from './DamageDisclosureHUDElements'; diff --git a/packages/inspection-capture-web/src/DamageDisclosure/hooks/index.ts b/packages/inspection-capture-web/src/DamageDisclosure/hooks/index.ts new file mode 100644 index 000000000..b36c9ecf9 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/hooks/index.ts @@ -0,0 +1 @@ +export * from './useDamageDisclosureState'; diff --git a/packages/inspection-capture-web/src/DamageDisclosure/hooks/useDamageDisclosureState.ts b/packages/inspection-capture-web/src/DamageDisclosure/hooks/useDamageDisclosureState.ts new file mode 100644 index 000000000..0d65577d1 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/hooks/useDamageDisclosureState.ts @@ -0,0 +1,97 @@ +import { useCallback, useState } from 'react'; +import { + GetInspectionResponse, + MonkApiConfig, + MonkApiResponse, + useMonkApi, +} from '@monkvision/network'; +import { useMonitoring } from '@monkvision/monitoring'; +import { LoadingState, useAsyncEffect, useObjectMemo } from '@monkvision/common'; +import { ComplianceOptions, Image } from '@monkvision/types'; +import { DamageDisclosureState } from '../../types'; + +/** + * Parameters of the useDamageDisclosureSightState hook. + */ +export interface DamageDisclosureParams { + /** + * The inspection ID. + */ + inspectionId: string; + /** + * The api config used to communicate with the API. + */ + apiConfig: MonkApiConfig; + /** + * Global loading state of the DamageDisclosure component. + */ + loading: LoadingState; + /** + * The options for the compliance conf + */ + complianceOptions: ComplianceOptions; +} + +function getLastPictureTakenUri( + inspectionId: string, + response: MonkApiResponse, +): string | null { + const images = response.entities.images.filter( + (image: Image) => image.inspectionId === inspectionId, + ); + return images && images.length > 0 ? images[images.length - 1].path : null; +} + +/** + * Custom hook used to manage the state of the DamageDisclosure. This state is automatically fetched from the API at + * the start of the DamageDisclosure process in order to allow users to start the inspection where they left it before. + */ +export function useDamageDisclosureState({ + inspectionId, + apiConfig, + loading, + complianceOptions, +}: DamageDisclosureParams): DamageDisclosureState { + const [retryCount, setRetryCount] = useState(0); + const [lastPictureTakenUri, setLastPictureTakenUri] = useState(null); + const { getInspection } = useMonkApi(apiConfig); + const { handleError } = useMonitoring(); + + const onFetchInspection = (response: MonkApiResponse) => { + try { + setLastPictureTakenUri(getLastPictureTakenUri(inspectionId, response)); + loading.onSuccess(); + } catch (err) { + handleError(err); + loading.onError(err); + } + }; + + useAsyncEffect( + () => { + loading.start(); + return getInspection({ + id: inspectionId, + compliance: complianceOptions, + }); + }, + [inspectionId, retryCount, complianceOptions], + { + onResolve: onFetchInspection, + onReject: (err) => { + handleError(err); + loading.onError(err); + }, + }, + ); + + const retryLoadingInspection = useCallback(() => { + setRetryCount((value) => value + 1); + }, []); + + return useObjectMemo({ + lastPictureTakenUri, + setLastPictureTakenUri, + retryLoadingInspection, + }); +} diff --git a/packages/inspection-capture-web/src/DamageDisclosure/index.ts b/packages/inspection-capture-web/src/DamageDisclosure/index.ts new file mode 100644 index 000000000..ccd5868f9 --- /dev/null +++ b/packages/inspection-capture-web/src/DamageDisclosure/index.ts @@ -0,0 +1,3 @@ +export * from './DamageDisclosureHUD'; +export { type DamageDisclosureProps } from './DamageDisclosure'; +export { DamageDisclosureHOC as DamageDisclosure } from './DamageDisclosureHOC'; diff --git a/packages/inspection-capture-web/src/index.ts b/packages/inspection-capture-web/src/index.ts index 462180cd3..030f92a31 100644 --- a/packages/inspection-capture-web/src/index.ts +++ b/packages/inspection-capture-web/src/index.ts @@ -1,2 +1,3 @@ export * from './PhotoCapture'; +export * from './DamageDisclosure'; export * from './i18n'; diff --git a/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosure.test.tsx b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosure.test.tsx new file mode 100644 index 000000000..4b32c33ce --- /dev/null +++ b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosure.test.tsx @@ -0,0 +1,339 @@ +import { AddDamage, CameraResolution, CompressionFormat, TaskName } from '@monkvision/types'; + +const { CaptureMode } = jest.requireActual('../../src/types'); + +jest.mock('../../src/DamageDisclosure/hooks', () => ({ + useDamageDisclosureState: jest.fn(() => ({ + lastPictureTakenUri: 'test-uri-test', + setLastPictureTakenUri: jest.fn(), + retryLoadingInspection: jest.fn(), + })), +})); + +jest.mock('../../src/hooks', () => ({ + useAddDamageMode: jest.fn(() => ({ + mode: CaptureMode.SIGHT, + handleAddDamage: jest.fn(), + updatePhotoCaptureModeAfterPictureTaken: jest.fn(), + handleCancelAddDamage: jest.fn(), + })), + usePhotoCaptureImages: jest.fn(() => [{ id: 'test' }]), + usePictureTaken: jest.fn(() => jest.fn()), + useUploadQueue: jest.fn(() => ({ + length: 3, + })), + useBadConnectionWarning: jest.fn(() => ({ + isBadConnectionWarningDialogDisplayed: true, + closeBadConnectionWarningDialog: jest.fn(), + uploadEventHandlers: { + onUploadSuccess: jest.fn(), + onUploadTimeout: jest.fn(), + }, + })), + useAdaptiveCameraConfig: jest.fn(() => ({ + adaptiveCameraConfig: { + resolution: CameraResolution.QHD_2K, + format: CompressionFormat.JPEG, + allowImageUpscaling: false, + quality: 0.9, + }, + uploadEventHandlers: { + onUploadSuccess: jest.fn(), + onUploadTimeout: jest.fn(), + }, + })), + useTracking: jest.fn(), +})); +import { Camera } from '@monkvision/camera-web'; +import { useI18nSync, useLoadingState } from '@monkvision/common'; +import { BackdropDialog, InspectionGallery } from '@monkvision/common-ui-web'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { act, render } from '@testing-library/react'; +import { DamageDisclosure, DamageDisclosureHUD, DamageDisclosureProps } from '../../src'; +import { useDamageDisclosureState } from '../../src/DamageDisclosure/hooks'; +import { + useAdaptiveCameraConfig, + useAddDamageMode, + useBadConnectionWarning, + usePictureTaken, + useUploadQueue, + usePhotoCaptureImages, +} from '../../src/hooks'; + +function createProps(): DamageDisclosureProps { + return { + inspectionId: 'test-inspection-test', + apiConfig: { + apiDomain: 'test-api-domain-test', + authToken: 'test-auth-token-test', + thumbnailDomain: 'test-thumbnail-domain', + }, + + useLiveCompliance: true, + onClose: jest.fn(), + onComplete: jest.fn(), + onPictureTaken: jest.fn(), + resolution: CameraResolution.NHD_360P, + format: CompressionFormat.JPEG, + quality: 0.4, + showCloseButton: true, + lang: 'de', + allowImageUpscaling: true, + useAdaptiveImageQuality: false, + addDamage: AddDamage.PART_SELECT, + maxUploadDurationWarning: 456, + }; +} + +describe('DamageDisclosure component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should pass the proper params to the useAdaptiveCameraConfig hook', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useAdaptiveCameraConfig).toHaveBeenCalledWith({ + initialCameraConfig: expect.objectContaining({ + quality: props.quality, + format: props.format, + resolution: props.resolution, + allowImageUpscaling: props.allowImageUpscaling, + }), + useAdaptiveImageQuality: props.useAdaptiveImageQuality, + }); + + unmount(); + }); + + it('should use adaptive image quality by default', () => { + const props = createProps(); + props.useAdaptiveImageQuality = undefined; + const { unmount } = render(); + + expect(useAdaptiveCameraConfig).toHaveBeenCalledWith( + expect.objectContaining({ + useAdaptiveImageQuality: true, + }), + ); + + unmount(); + }); + + it('should pass the proper params to the useDamageDisclosureState hooks', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useLoadingState).toHaveBeenCalled(); + const loading = (useLoadingState as jest.Mock).mock.results[0].value; + expect(useDamageDisclosureState).toHaveBeenCalledWith({ + inspectionId: props.inspectionId, + apiConfig: props.apiConfig, + loading, + complianceOptions: { + useLiveCompliance: props.useLiveCompliance, + }, + }); + + unmount(); + }); + + it('should pass the proper params to the useBadConnectionWarning hooks', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useBadConnectionWarning).toHaveBeenCalledWith({ + maxUploadDurationWarning: props.maxUploadDurationWarning, + }); + + unmount(); + }); + + it('should pass the proper params to the useUploadQueue hook', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useAdaptiveCameraConfig).toHaveBeenCalled(); + const adaptiveConfigEventHandlers = (useAdaptiveCameraConfig as jest.Mock).mock.results[0].value + .uploadEventHandlers; + expect(useBadConnectionWarning).toHaveBeenCalled(); + const badConnectionEventHandlers = (useBadConnectionWarning as jest.Mock).mock.results[0].value + .uploadEventHandlers; + expect(useUploadQueue).toHaveBeenCalledWith({ + inspectionId: props.inspectionId, + apiConfig: props.apiConfig, + complianceOptions: { + enableCompliance: props.enableCompliance, + enableCompliancePerSight: props.enableCompliancePerSight, + complianceIssues: props.complianceIssues, + complianceIssuesPerSight: props.complianceIssuesPerSight, + useLiveCompliance: props.useLiveCompliance, + customComplianceThresholds: props.customComplianceThresholds, + customComplianceThresholdsPerSight: props.customComplianceThresholdsPerSight, + }, + eventHandlers: expect.arrayContaining([ + adaptiveConfigEventHandlers, + badConnectionEventHandlers, + ]), + }); + + unmount(); + }); + + it('should pass the proper params to the usePictureTaken hook', () => { + const props = { ...createProps(), tasksBySight: { test: [TaskName.PRICING] } }; + const { unmount } = render(); + + expect(useAddDamageMode).toHaveBeenCalled(); + const addDamageHandle = (useAddDamageMode as jest.Mock).mock.results[0].value; + expect(useDamageDisclosureState).toHaveBeenCalled(); + const sightState = (useDamageDisclosureState as jest.Mock).mock.results[0].value; + expect(useUploadQueue).toHaveBeenCalled(); + const uploadQueue = (useUploadQueue as jest.Mock).mock.results[0].value; + expect(usePictureTaken).toHaveBeenCalledWith({ + addDamageHandle, + sightState, + uploadQueue, + onPictureTaken: props.onPictureTaken, + }); + + unmount(); + }); + + it('should display a Camera with the config obtained from the useAdaptiveCameraConfig', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useAdaptiveCameraConfig).toHaveBeenCalled(); + const { adaptiveCameraConfig } = (useAdaptiveCameraConfig as jest.Mock).mock.results[0].value; + expectPropsOnChildMock(Camera, adaptiveCameraConfig); + + unmount(); + }); + + it('should use the PhotoCaptureHUD component as the Camera HUD', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(Camera, { HUDComponent: DamageDisclosureHUD }); + + unmount(); + }); + + it('should pass the callback from the usePictureTaken hook to the Camera component', () => { + const props = createProps(); + const { unmount } = render(); + + expect(usePictureTaken).toHaveBeenCalled(); + const handlePictureTaken = (usePictureTaken as jest.Mock).mock.results[0].value; + expectPropsOnChildMock(Camera, { onPictureTaken: handlePictureTaken }); + + unmount(); + }); + + it('should pass the proper props to the HUD component', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useAddDamageMode).toHaveBeenCalled(); + const addDamageHandle = (useAddDamageMode as jest.Mock).mock.results[0].value; + expect(useDamageDisclosureState).toHaveBeenCalled(); + const disclosureState = (useDamageDisclosureState as jest.Mock).mock.results[0].value; + expect(useLoadingState).toHaveBeenCalled(); + const loading = (useLoadingState as jest.Mock).mock.results[0].value; + const images = (usePhotoCaptureImages as jest.Mock).mock.results[0].value; + expectPropsOnChildMock(Camera, { + hudProps: { + inspectionId: props.inspectionId, + mode: addDamageHandle.mode, + vehicleParts: addDamageHandle.vehicleParts, + lastPictureTakenUri: disclosureState.lastPictureTakenUri, + onOpenGallery: expect.any(Function), + onAddDamage: addDamageHandle.handleAddDamage, + onAddDamagePartsSelected: addDamageHandle.handleAddDamagePartsSelected, + onCancelAddDamage: addDamageHandle.handleCancelAddDamage, + loading, + onClose: props.onClose, + showCloseButton: props.showCloseButton, + images, + addDamage: props.addDamage, + onRetry: disclosureState.retryLoadingInspection, + onValidateVehicleParts: addDamageHandle.handleValidateVehicleParts, + }, + }); + + unmount(); + }); + + it('should sync the local i18n language with the one passed as a prop', () => { + const props = createProps(); + props.lang = 'fr'; + const { unmount } = render(); + + expect(useI18nSync).toHaveBeenCalledWith(props.lang); + + unmount(); + }); + + it('should display the gallery when the gallery button is pressed', () => { + const props = createProps(); + const { unmount } = render(); + + expect(InspectionGallery).not.toHaveBeenCalled(); + expectPropsOnChildMock(Camera, { + hudProps: expect.objectContaining({ onOpenGallery: expect.any(Function) }), + }); + const { onOpenGallery } = (Camera as unknown as jest.Mock).mock.calls[0][0].hudProps; + expect(InspectionGallery).not.toHaveBeenCalled(); + act(() => onOpenGallery()); + expectPropsOnChildMock(InspectionGallery, { + inspectionId: props.inspectionId, + sights: [], + apiConfig: props.apiConfig, + captureMode: true, + lang: props.lang, + showBackButton: true, + onBack: expect.any(Function), + onNavigateToCapture: expect.any(Function), + onValidate: expect.any(Function), + disableSightPicture: true, + addDamage: props.addDamage, + isInspectionCompleted: false, + validateButtonLabel: 'photo.gallery.next', + }); + const { onBack } = (InspectionGallery as unknown as jest.Mock).mock.calls[0][0]; + (InspectionGallery as unknown as jest.Mock).mockClear(); + (Camera as unknown as jest.Mock).mockClear(); + expect(Camera).not.toHaveBeenCalled(); + act(() => onBack()); + expect(InspectionGallery).not.toHaveBeenCalled(); + expect(Camera).toHaveBeenCalled(); + + unmount(); + }); + + it('should pass the proper params to the BackdropDialog component', () => { + const props = createProps(); + const { unmount } = render(); + + expect(useBadConnectionWarning).toHaveBeenCalled(); + const { isBadConnectionWarningDialogDisplayed, closeBadConnectionWarningDialog } = ( + useBadConnectionWarning as jest.Mock + ).mock.results[0].value; + expectPropsOnChildMock(BackdropDialog, { + show: isBadConnectionWarningDialogDisplayed, + dialogIcon: 'warning-outline', + dialogIconPrimaryColor: 'caution-base', + message: 'photo.badConnectionWarning.message', + confirmLabel: 'photo.badConnectionWarning.confirm', + onConfirm: expect.any(Function), + }); + const { onConfirm } = (BackdropDialog as unknown as jest.Mock).mock.calls[0][0]; + expect(closeBadConnectionWarningDialog).not.toHaveBeenCalled(); + onConfirm(); + expect(closeBadConnectionWarningDialog).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements.test.tsx b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements.test.tsx new file mode 100644 index 000000000..5a4fd8cea --- /dev/null +++ b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements.test.tsx @@ -0,0 +1,108 @@ +import { AddDamage } from '@monkvision/types'; + +jest.mock('../../../src/components', () => ({ + ZoomOutShot: jest.fn(() => <>), + CloseUpShot: jest.fn(() => <>), + PartSelection: jest.fn(() => <>), +})); + +import '@testing-library/jest-dom'; +import { render } from '@testing-library/react'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { CaptureMode } from '../../../src/types'; +import { DamageDisclosureHUDElements, DamageDisclosureHUDElementsProps } from '../../../src'; +import { ZoomOutShot, CloseUpShot, PartSelection } from '../../../src/components'; + +function createProps(): DamageDisclosureHUDElementsProps { + return { + mode: CaptureMode.SIGHT, + onAddDamage: jest.fn(), + onCancelAddDamage: jest.fn(), + previewDimensions: { height: 1234, width: 45678 }, + isLoading: false, + error: null, + onValidateVehicleParts: jest.fn(), + vehicleParts: [], + addDamage: AddDamage.PART_SELECT, + }; +} + +describe('DamageDisclosureHUDElements component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return null if the capture is loading', () => { + const props = createProps(); + props.isLoading = true; + const { container, unmount } = render(); + + expect(container).toBeEmptyDOMElement(); + + unmount(); + }); + + it('should return null if the capture is in error', () => { + const props = createProps(); + props.error = true; + const { container, unmount } = render(); + + expect(container).toBeEmptyDOMElement(); + + unmount(); + }); + + it('should return the ZoomOutShot component if the mode is AD 1st Shot', () => { + const props = createProps(); + props.mode = CaptureMode.ADD_DAMAGE_1ST_SHOT; + const { unmount } = render(); + + expectPropsOnChildMock(ZoomOutShot, { + onCancel: props.onCancelAddDamage, + }); + expect(CloseUpShot).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should return the ClosuUpShot component if the mode is AD 2nd Shot', () => { + const props = createProps(); + props.mode = CaptureMode.ADD_DAMAGE_2ND_SHOT; + const { unmount } = render(); + + expectPropsOnChildMock(CloseUpShot, { + onCancel: props.onCancelAddDamage, + }); + expect(ZoomOutShot).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should return the ClosuUpShot component if the mode is AD PartSelect Shot', () => { + const props = createProps(); + props.mode = CaptureMode.ADD_DAMAGE_PART_SELECT_SHOT; + const { unmount } = render(); + + expectPropsOnChildMock(CloseUpShot, { + onCancel: props.onCancelAddDamage, + }); + expect(ZoomOutShot).not.toHaveBeenCalled(); + expect(PartSelection).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should return the PartSelection component if the mode is AD PartSelect', () => { + const props = createProps(); + props.mode = CaptureMode.ADD_DAMAGE_PART_SELECT; + const { unmount } = render(); + + expectPropsOnChildMock(PartSelection, { + onCancel: props.onCancelAddDamage, + }); + expect(ZoomOutShot).not.toHaveBeenCalled(); + expect(CloseUpShot).not.toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDislosureHUD.test.tsx b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDislosureHUD.test.tsx new file mode 100644 index 000000000..8f30cb2f2 --- /dev/null +++ b/packages/inspection-capture-web/test/DamageDisclosure/DamageDisclosureHUD/DamageDislosureHUD.test.tsx @@ -0,0 +1,165 @@ +import { Image, ImageStatus } from '@monkvision/types'; + +jest.mock('../../../src/components/HUDButtons', () => ({ + HUDButtons: jest.fn(() => <>), +})); +jest.mock('../../../src/DamageDisclosure/DamageDisclosureHUD/DamageDisclosureHUDElements', () => ({ + DamageDisclosureHUDElements: jest.fn(() => <>), +})); + +import { useTranslation } from 'react-i18next'; +import { act, render, screen } from '@testing-library/react'; +import { LoadingState } from '@monkvision/common'; +import { CameraHandle } from '@monkvision/camera-web'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { BackdropDialog } from '@monkvision/common-ui-web'; +import { + DamageDisclosureHUD, + DamageDisclosureHUDElements, + DamageDisclosureHUDProps, +} from '../../../src'; +import { HUDButtons } from '../../../src/components'; +import { CaptureMode } from '../../../src/types'; + +const cameraTestId = 'camera-test-id'; + +function createProps(): DamageDisclosureHUDProps { + return { + inspectionId: 'test-inspection-id-test', + lastPictureTakenUri: 'test-last-pic-taken', + mode: CaptureMode.SIGHT, + loading: { isLoading: false, error: null } as unknown as LoadingState, + onAddDamage: jest.fn(), + onCancelAddDamage: jest.fn(), + onRetry: jest.fn(), + onClose: jest.fn(), + onOpenGallery: jest.fn(), + showCloseButton: true, + handle: { + isLoading: false, + error: null, + dimensions: { height: 2, width: 4 }, + previewDimensions: { height: 111, width: 2222 }, + } as unknown as CameraHandle, + cameraPreview:
, + images: [{ sightId: 'test-sight-1', status: ImageStatus.NOT_COMPLIANT }] as Image[], + onValidateVehicleParts: jest.fn(), + vehicleParts: [], + }; +} + +describe('DamageDisclosureHUD component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display the camera preview on the screen', () => { + const props = createProps(); + const { unmount } = render(); + + expect(screen.queryByTestId(cameraTestId)).not.toBeNull(); + + unmount(); + }); + + it('should display the DamageDisclosureHUDElements component with the proper props', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(DamageDisclosureHUDElements, { + mode: props.mode, + vehicleParts: props.vehicleParts, + onAddDamage: props.onAddDamage, + onCancelAddDamage: props.onCancelAddDamage, + onAddDamagePartsSelected: props.onAddDamagePartsSelected, + onValidateVehicleParts: props.onValidateVehicleParts, + isLoading: props.loading.isLoading || props.handle.isLoading, + error: props.loading.error ?? props.handle.error, + previewDimensions: props.handle.previewDimensions, + }); + + unmount(); + }); + + it('should display the HUDButtons component with the proper props', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(HUDButtons, { + onTakePicture: props.handle?.takePicture, + galleryPreview: props.lastPictureTakenUri ?? undefined, + closeDisabled: !!props.loading.error || !!props.handle.error, + galleryDisabled: !!props.loading.error || !!props.handle.error, + takePictureDisabled: !!props.loading.error || !!props.handle.error, + showCloseButton: props.showCloseButton, + onOpenGallery: props.onOpenGallery, + }); + + unmount(); + }); + + it('should display the BackdropDialog component with the proper props', () => { + (useTranslation as jest.Mock).mockImplementationOnce(() => ({ t: jest.fn((v) => v) })); + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(BackdropDialog, { + message: 'photo.hud.closeConfirm.message', + cancelLabel: 'photo.hud.closeConfirm.cancel', + confirmLabel: 'photo.hud.closeConfirm.confirm', + }); + + unmount(); + }); + + it('should properly handle the click on close event', () => { + const props = createProps(); + const { unmount } = render(); + + const { onClose } = (HUDButtons as jest.Mock).mock.calls[0][0]; + expectPropsOnChildMock(BackdropDialog, { show: false }); + jest.clearAllMocks(); + + act(() => onClose()); + expectPropsOnChildMock(BackdropDialog, { show: true }); + const { onConfirm } = (BackdropDialog as jest.Mock).mock.calls[0][0]; + jest.clearAllMocks(); + + expect(props.onClose).not.toHaveBeenCalled(); + act(() => onConfirm()); + expectPropsOnChildMock(BackdropDialog, { show: false }); + expect(props.onClose).toHaveBeenCalled(); + + unmount(); + }); + + const RETAKE_STATUSES = [ + ImageStatus.NOT_COMPLIANT, + ImageStatus.UPLOAD_FAILED, + ImageStatus.UPLOAD_ERROR, + ]; + + RETAKE_STATUSES.forEach((status) => { + it(`should display the gallery badge if there are images with the ${status} status`, () => { + const props = createProps(); + props.images = [{ status }, { status }, { status: 'test' }] as Image[]; + const { unmount } = render(); + + expectPropsOnChildMock(HUDButtons, { showGalleryBadge: true, retakeCount: 2 }); + + unmount(); + }); + }); + + it('should not display the gallery badge if there are no images with retake statuses', () => { + const props = createProps(); + props.images = Object.values(ImageStatus) + .filter((status) => !RETAKE_STATUSES.includes(status)) + .map((status) => ({ status } as Image)); + const { unmount } = render(); + + expectPropsOnChildMock(HUDButtons, { showGalleryBadge: false, retakeCount: 0 }); + + unmount(); + }); +}); diff --git a/packages/inspection-capture-web/test/DamageDisclosure/hooks/useDamageDisclosureState.test.ts b/packages/inspection-capture-web/test/DamageDisclosure/hooks/useDamageDisclosureState.test.ts new file mode 100644 index 000000000..db50dce75 --- /dev/null +++ b/packages/inspection-capture-web/test/DamageDisclosure/hooks/useDamageDisclosureState.test.ts @@ -0,0 +1,115 @@ +import { renderHook } from '@testing-library/react-hooks'; +import { LoadingState, useAsyncEffect } from '@monkvision/common'; +import { ComplianceIssue } from '@monkvision/types'; +import { useMonitoring } from '@monkvision/monitoring'; +import { useMonkApi } from '@monkvision/network'; +import { act } from '@testing-library/react'; +import { + DamageDisclosureParams, + useDamageDisclosureState, +} from '../../../src/DamageDisclosure/hooks'; +// import { DamageDisclosureErrorName } from '../../../src/PhotoCapture/errors'; + +function createParams(): DamageDisclosureParams { + return { + inspectionId: 'test-inspection-id', + apiConfig: { + apiDomain: 'test-api-domain', + authToken: 'test-auth-token', + thumbnailDomain: 'test-thumbnail-domain', + }, + loading: { + start: jest.fn(), + onSuccess: jest.fn(), + onError: jest.fn(), + } as unknown as LoadingState, + complianceOptions: { + enableCompliance: true, + complianceIssues: [ComplianceIssue.INTERIOR_NOT_SUPPORTED], + enableCompliancePerSight: ['test-sight'], + complianceIssuesPerSight: { test: [ComplianceIssue.OVEREXPOSURE] }, + useLiveCompliance: true, + }, + }; +} + +describe('useDamageDisclosureSightState hook', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should properly initialize the state', () => { + const initialProps = createParams(); + const { result, unmount } = renderHook(useDamageDisclosureState, { initialProps }); + expect(result.current.lastPictureTakenUri).toBeNull(); + unmount(); + }); + + it('should start a request to fetch the inspection state from the API', () => { + const initialProps = createParams(); + const { unmount } = renderHook(useDamageDisclosureState, { initialProps }); + + expect(useMonkApi).toHaveBeenCalledWith(initialProps.apiConfig); + const getInspectionMock = (useMonkApi as jest.Mock).mock.results[0].value.getInspection; + expect(initialProps.loading.start).not.toHaveBeenCalled(); + expect(getInspectionMock).not.toHaveBeenCalled(); + expect(useAsyncEffect).toHaveBeenCalled(); + const effect = (useAsyncEffect as jest.Mock).mock.calls[0][0]; + effect(); + expect(initialProps.loading.start).toHaveBeenCalled(); + expect(getInspectionMock).toHaveBeenCalledWith({ + id: initialProps.inspectionId, + compliance: initialProps.complianceOptions, + }); + + unmount(); + }); + + it('should properly handle the error returned from the getInspection API call', () => { + const initialProps = createParams(); + const { unmount } = renderHook(useDamageDisclosureState, { initialProps }); + + expect(useMonitoring).toHaveBeenCalled(); + const handleErrorMock = (useMonitoring as jest.Mock).mock.results[0].value.handleError; + expect(handleErrorMock).not.toHaveBeenCalled(); + expect(initialProps.loading.onError).not.toHaveBeenCalled(); + expect(useAsyncEffect).toHaveBeenCalled(); + const { onReject } = (useAsyncEffect as jest.Mock).mock.calls[0][2]; + const err = { test: 'hello' }; + act(() => onReject(err)); + + expect(handleErrorMock).toHaveBeenCalledWith(err); + expect(initialProps.loading.onError).toHaveBeenCalledWith(err); + + unmount(); + }); + + it('should fetch the inspection state again when the inspectionId changes', () => { + const initialProps = createParams(); + const { unmount } = renderHook(useDamageDisclosureState, { initialProps }); + + expect(useAsyncEffect).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([initialProps.inspectionId]), + expect.anything(), + ); + + unmount(); + }); + + it('should fetch the inspection state again when the retry function is called', () => { + const initialProps = createParams(); + const { result, unmount } = renderHook(useDamageDisclosureState, { initialProps }); + + const retryDep = (useAsyncEffect as jest.Mock).mock.calls[0][1].filter( + (dep: any) => dep !== initialProps.inspectionId, + )[0]; + act(() => result.current.retryLoadingInspection()); + const newRetryDep = (useAsyncEffect as jest.Mock).mock.calls[1][1].filter( + (dep: any) => dep !== initialProps.inspectionId, + )[0]; + expect(Object.is(retryDep, newRetryDep)).toBe(false); + + unmount(); + }); +});