From 87bc98dc2566f02746efaa037fc8170222dd4393 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Sun, 17 Mar 2024 14:30:08 +0100 Subject: [PATCH 1/3] Added demo app and removed test app --- .../{monk-test-app => demo-app}/.eslintignore | 0 apps/{monk-test-app => demo-app}/.eslintrc.js | 0 apps/{monk-test-app => demo-app}/.gitignore | 0 apps/demo-app/README.md | 74 ++++ apps/demo-app/env.txt | 15 + apps/demo-app/jest.config.js | 13 + apps/{monk-test-app => demo-app}/package.json | 52 +-- apps/demo-app/public/favicon.ico | Bin 0 -> 15086 bytes apps/demo-app/public/index.html | 20 ++ apps/demo-app/public/logo192.png | Bin 0 -> 9806 bytes apps/demo-app/public/logo512.png | Bin 0 -> 44238 bytes .../public/manifest.json | 8 +- .../public/robots.txt | 0 apps/demo-app/src/components/App.tsx | 22 ++ apps/demo-app/src/components/AppRouter.tsx | 36 ++ .../src/components/AuthGuard/AuthGuard.tsx | 20 ++ .../src/components/AuthGuard/index.ts | 1 + apps/demo-app/src/components/index.ts | 3 + apps/demo-app/src/config/auth.ts | 12 + apps/demo-app/src/config/index.ts | 2 + apps/demo-app/src/config/sights.ts | 147 ++++++++ apps/{monk-test-app => demo-app}/src/i18n.ts | 7 +- .../{monk-test-app => demo-app}/src/index.css | 4 +- apps/demo-app/src/index.tsx | 28 ++ .../CreateInspectionPage.module.css | 12 + .../CreateInspectionPage.tsx | 61 ++++ .../src/pages/CreateInspectionPage/index.ts | 1 + .../src/pages/LogInPage/LogInPage.module.css | 13 + .../src/pages/LogInPage/LogInPage.tsx | 76 ++++ apps/demo-app/src/pages/LogInPage/index.ts | 1 + .../PhotoCapturePage.module.css | 12 + .../PhotoCapturePage/PhotoCapturePage.tsx | 48 +++ .../src/pages/PhotoCapturePage/index.ts | 1 + apps/demo-app/src/pages/index.ts | 4 + apps/demo-app/src/pages/pages.ts | 5 + .../src/react-app-env.d.ts | 0 .../{monk-test-app => demo-app}/src/sentry.ts | 2 +- .../src/setupTests.ts | 0 apps/demo-app/src/translations/de.json | 20 ++ apps/demo-app/src/translations/en.json | 20 ++ apps/demo-app/src/translations/fr.json | 20 ++ .../test/components/AuthGuard.test.tsx | 90 +++++ .../test/pages/CreateInspectionPage.test.tsx | 93 +++++ apps/demo-app/test/pages/LogInPage.test.tsx | 177 +++++++++ .../test/pages/PhotoCapturePage.test.tsx | 45 +++ apps/demo-app/tsconfig.build.json | 5 + .../{monk-test-app => demo-app}/tsconfig.json | 3 +- apps/monk-test-app/README.md | 1 - apps/monk-test-app/jest.config.js | 7 - apps/monk-test-app/public/favicon.ico | Bin 3870 -> 0 bytes apps/monk-test-app/public/index.html | 43 --- apps/monk-test-app/public/logo192.png | Bin 5347 -> 0 bytes apps/monk-test-app/public/logo512.png | Bin 9664 -> 0 bytes apps/monk-test-app/src/index.tsx | 17 - apps/monk-test-app/src/translations/de.json | 1 - apps/monk-test-app/src/translations/en.json | 1 - apps/monk-test-app/src/translations/fr.json | 1 - apps/monk-test-app/src/views/App.tsx | 13 - .../src/views/CameraView/CameraView.css | 8 - .../src/views/CameraView/CameraView.tsx | 36 -- .../views/CameraView/components/TestPanel.tsx | 56 --- .../components/TestPanelLastPic.tsx | 81 ----- .../CameraView/components/TestPanelRow.tsx | 46 --- .../components/TestPanelSettings.tsx | 57 --- .../src/views/CameraView/components/index.ts | 4 - .../views/CameraView/components/styles.css | 72 ---- .../src/views/CameraView/hooks/index.ts | 1 - .../CameraView/hooks/useTestPanelStyle.ts | 23 -- .../src/views/CameraView/index.ts | 1 - .../src/views/CameraView/utils.ts | 42 --- .../src/views/TestView/TestView.css | 16 - .../src/views/TestView/TestView.tsx | 39 -- .../monk-test-app/src/views/TestView/index.ts | 1 - apps/monk-test-app/src/views/index.ts | 1 - configs/jest-config/react.js | 4 +- .../src/__mocks__/@auth0/auth0-react.ts | 8 + .../__mocks__/@monkvision/common-ui-web.tsx | 9 +- .../src/__mocks__/@monkvision/common.tsx | 17 +- .../src/__mocks__/@monkvision/network.ts | 8 + .../test-utils/src/__mocks__/imports/file.ts | 1 + .../test-utils/src/__mocks__/imports/style.ts | 1 + .../src/__mocks__/react-router-dom.tsx | 7 + configs/typescript-config/types.d.ts | 28 ++ documentation/package.json | 3 +- package.json | 1 + packages/camera-web/package.json | 3 +- packages/camera-web/src/Camera/Camera.tsx | 4 +- .../camera-web/src/Camera/CameraHUD.types.ts | 2 +- packages/common-ui-web/README.md | 18 +- packages/common-ui-web/package.json | 4 +- .../src/components/Button/Button.tsx | 7 +- .../src/components/Button/hooks.ts | 26 +- .../src/components/Spinner/Spinner.tsx | 2 +- .../src/components/SwitchButton/hooks.ts | 2 +- packages/common-ui-web/src/icons/Icon.tsx | 2 +- .../test/components/Button/Button.test.tsx | 15 +- packages/common/README.md | 1 + packages/common/README/APP_UTILS.md | 152 ++++++++ packages/common/README/HOOKS.md | 198 +++------- .../common/README/INTERNATIONALIZATION.md | 2 +- packages/common/README/STATE_MANAGEMENT.md | 2 +- packages/common/README/THEMING.md | 2 +- packages/common/README/UTILITIES.md | 18 +- packages/common/package.json | 9 +- packages/common/src/apps/index.ts | 1 + packages/common/src/apps/params.tsx | 270 ++++++++++++++ packages/common/src/index.ts | 1 + packages/common/src/theme/theme.ts | 9 + packages/common/src/utils/env.utils.ts | 11 + packages/common/src/utils/index.ts | 1 + packages/common/test/apps/params.test.tsx | 260 ++++++++++++++ packages/common/test/theme/theme.test.ts | 33 ++ packages/common/test/utils/env.utils.test.ts | 27 ++ packages/inspection-capture-web/README.md | 1 + packages/inspection-capture-web/package.json | 3 +- .../src/PhotoCapture/PhotoCapture.tsx | 16 +- .../PhotoCaptureHUD/PhotoCaptureHUD.tsx | 8 + .../PhotoCaptureHUDButtons.tsx | 14 + .../PhotoCaptureHUDButtons/hooks.ts | 3 +- .../PhotoCaptureHUDOverlay/hooks.ts | 36 +- .../src/PhotoCapture/errors.ts | 3 + .../hooks/usePhotoCaptureSightState.ts | 67 +++- .../src/PhotoCapture/hooks/useUploadQueue.ts | 9 +- .../src/translations/de.json | 1 + .../src/translations/en.json | 1 + .../src/translations/fr.json | 1 + .../test/PhotoCapture/PhotoCapture.test.tsx | 8 +- .../PhotoCaptureHUD/PhotoCaptureHUD.test.tsx | 157 ++++++++ .../PhotoCaptureHUDButtons.test.tsx | 39 +- .../PhotoCaptureHUDOverlay.test.tsx | 6 + .../hooks/usePhotoCaptureSightState.test.ts | 43 ++- .../PhotoCapture/hooks/useUploadQueue.test.ts | 4 +- packages/network/README.md | 55 ++- packages/network/jest.config.js | 1 + packages/network/package.json | 4 +- packages/network/src/api/api.ts | 3 +- packages/network/src/api/config.ts | 1 + packages/network/src/api/error.ts | 15 +- packages/network/src/api/index.ts | 2 +- packages/network/src/api/inspection/index.ts | 5 + .../network/src/api/inspection/mappers.ts | 150 ++++++++ .../network/src/api/inspection/requests.ts | 35 +- packages/network/src/api/models/inspection.ts | 26 +- packages/network/src/api/models/task.ts | 55 +++ packages/network/src/api/models/vehicle.ts | 33 ++ packages/network/src/api/react.ts | 22 +- packages/network/src/api/task/requests.ts | 4 +- packages/network/src/api/types.ts | 13 +- packages/network/src/auth/hooks.ts | 96 +++++ packages/network/src/auth/index.ts | 1 + packages/network/src/auth/token.ts | 31 ++ packages/network/test/api/config.test.ts | 8 + packages/network/test/api/error.test.ts | 16 +- .../{ => data}/apiInspectionGet.data.json | 0 .../{ => data}/apiInspectionGet.data.ts | 0 .../data/apiInspectionPost.data.json | 29 ++ .../inspection/data/apiInspectionPost.data.ts | 16 + .../test/api/inspection/mappers.test.ts | 26 +- .../test/api/inspection/requests.test.ts | 30 +- packages/network/test/api/react.test.ts | 36 +- packages/network/test/auth/hooks.test.ts | 127 +++++++ packages/network/test/auth/token.test.ts | 109 +++++- packages/types/src/theme/theme.ts | 5 + yarn.lock | 340 ++++++++++++++---- 164 files changed, 3659 insertions(+), 965 deletions(-) rename apps/{monk-test-app => demo-app}/.eslintignore (100%) rename apps/{monk-test-app => demo-app}/.eslintrc.js (100%) rename apps/{monk-test-app => demo-app}/.gitignore (100%) create mode 100644 apps/demo-app/README.md create mode 100644 apps/demo-app/env.txt create mode 100644 apps/demo-app/jest.config.js rename apps/{monk-test-app => demo-app}/package.json (75%) create mode 100644 apps/demo-app/public/favicon.ico create mode 100644 apps/demo-app/public/index.html create mode 100644 apps/demo-app/public/logo192.png create mode 100644 apps/demo-app/public/logo512.png rename apps/{monk-test-app => demo-app}/public/manifest.json (72%) rename apps/{monk-test-app => demo-app}/public/robots.txt (100%) create mode 100644 apps/demo-app/src/components/App.tsx create mode 100644 apps/demo-app/src/components/AppRouter.tsx create mode 100644 apps/demo-app/src/components/AuthGuard/AuthGuard.tsx create mode 100644 apps/demo-app/src/components/AuthGuard/index.ts create mode 100644 apps/demo-app/src/components/index.ts create mode 100644 apps/demo-app/src/config/auth.ts create mode 100644 apps/demo-app/src/config/index.ts create mode 100644 apps/demo-app/src/config/sights.ts rename apps/{monk-test-app => demo-app}/src/i18n.ts (76%) rename apps/{monk-test-app => demo-app}/src/index.css (75%) create mode 100644 apps/demo-app/src/index.tsx create mode 100644 apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.module.css create mode 100644 apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.tsx create mode 100644 apps/demo-app/src/pages/CreateInspectionPage/index.ts create mode 100644 apps/demo-app/src/pages/LogInPage/LogInPage.module.css create mode 100644 apps/demo-app/src/pages/LogInPage/LogInPage.tsx create mode 100644 apps/demo-app/src/pages/LogInPage/index.ts create mode 100644 apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.module.css create mode 100644 apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx create mode 100644 apps/demo-app/src/pages/PhotoCapturePage/index.ts create mode 100644 apps/demo-app/src/pages/index.ts create mode 100644 apps/demo-app/src/pages/pages.ts rename apps/{monk-test-app => demo-app}/src/react-app-env.d.ts (100%) rename apps/{monk-test-app => demo-app}/src/sentry.ts (68%) rename apps/{monk-test-app => demo-app}/src/setupTests.ts (100%) create mode 100644 apps/demo-app/src/translations/de.json create mode 100644 apps/demo-app/src/translations/en.json create mode 100644 apps/demo-app/src/translations/fr.json create mode 100644 apps/demo-app/test/components/AuthGuard.test.tsx create mode 100644 apps/demo-app/test/pages/CreateInspectionPage.test.tsx create mode 100644 apps/demo-app/test/pages/LogInPage.test.tsx create mode 100644 apps/demo-app/test/pages/PhotoCapturePage.test.tsx create mode 100644 apps/demo-app/tsconfig.build.json rename apps/{monk-test-app => demo-app}/tsconfig.json (50%) delete mode 100644 apps/monk-test-app/README.md delete mode 100644 apps/monk-test-app/jest.config.js delete mode 100644 apps/monk-test-app/public/favicon.ico delete mode 100644 apps/monk-test-app/public/index.html delete mode 100644 apps/monk-test-app/public/logo192.png delete mode 100644 apps/monk-test-app/public/logo512.png delete mode 100644 apps/monk-test-app/src/index.tsx delete mode 100644 apps/monk-test-app/src/translations/de.json delete mode 100644 apps/monk-test-app/src/translations/en.json delete mode 100644 apps/monk-test-app/src/translations/fr.json delete mode 100644 apps/monk-test-app/src/views/App.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/CameraView.css delete mode 100644 apps/monk-test-app/src/views/CameraView/CameraView.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/components/TestPanel.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/components/TestPanelLastPic.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/components/TestPanelRow.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/components/TestPanelSettings.tsx delete mode 100644 apps/monk-test-app/src/views/CameraView/components/index.ts delete mode 100644 apps/monk-test-app/src/views/CameraView/components/styles.css delete mode 100644 apps/monk-test-app/src/views/CameraView/hooks/index.ts delete mode 100644 apps/monk-test-app/src/views/CameraView/hooks/useTestPanelStyle.ts delete mode 100644 apps/monk-test-app/src/views/CameraView/index.ts delete mode 100644 apps/monk-test-app/src/views/CameraView/utils.ts delete mode 100644 apps/monk-test-app/src/views/TestView/TestView.css delete mode 100644 apps/monk-test-app/src/views/TestView/TestView.tsx delete mode 100644 apps/monk-test-app/src/views/TestView/index.ts delete mode 100644 apps/monk-test-app/src/views/index.ts create mode 100644 configs/test-utils/src/__mocks__/@auth0/auth0-react.ts create mode 100644 configs/test-utils/src/__mocks__/react-router-dom.tsx create mode 100644 packages/common/README/APP_UTILS.md create mode 100644 packages/common/src/apps/index.ts create mode 100644 packages/common/src/apps/params.tsx create mode 100644 packages/common/src/utils/env.utils.ts create mode 100644 packages/common/test/apps/params.test.tsx create mode 100644 packages/common/test/utils/env.utils.test.ts create mode 100644 packages/inspection-capture-web/src/PhotoCapture/errors.ts create mode 100644 packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx create mode 100644 packages/network/src/auth/hooks.ts rename packages/network/test/api/inspection/{ => data}/apiInspectionGet.data.json (100%) rename packages/network/test/api/inspection/{ => data}/apiInspectionGet.data.ts (100%) create mode 100644 packages/network/test/api/inspection/data/apiInspectionPost.data.json create mode 100644 packages/network/test/api/inspection/data/apiInspectionPost.data.ts create mode 100644 packages/network/test/auth/hooks.test.ts diff --git a/apps/monk-test-app/.eslintignore b/apps/demo-app/.eslintignore similarity index 100% rename from apps/monk-test-app/.eslintignore rename to apps/demo-app/.eslintignore diff --git a/apps/monk-test-app/.eslintrc.js b/apps/demo-app/.eslintrc.js similarity index 100% rename from apps/monk-test-app/.eslintrc.js rename to apps/demo-app/.eslintrc.js diff --git a/apps/monk-test-app/.gitignore b/apps/demo-app/.gitignore similarity index 100% rename from apps/monk-test-app/.gitignore rename to apps/demo-app/.gitignore diff --git a/apps/demo-app/README.md b/apps/demo-app/README.md new file mode 100644 index 000000000..070f15652 --- /dev/null +++ b/apps/demo-app/README.md @@ -0,0 +1,74 @@ +# Monk Demo App +This application is a demo app used to showcase how to implement the Monk workflow (authentication, inspection creation, +inspection capture and inspection report) using the MonkJs SDK. + +# Features +This app contains the following features : +- Authentication guards to enforce user log in +- User log in with browser pop-up using Auth0 and token caching in the local storage +- Automatic creation of a Monk inspection +- Inspection capture using the PhotoCapture workflow +- Redirection to the Monk inspection report app (since the inspection report component is not yet available in MonkJs + 4.0) +- Possiblity of passing the following configuration in the URL search params : + - Encrypted authentication token using ZLib (the user does not have to log in) + - Inspection ID (instead of creating a new one automatically) + - Vehicle type used for the Sights (default one is Crossover) + - Application language (English / French / German) + +# Running the App +In order to run the app, you will need to have [NodeJs](https://nodejs.org/en) >= 16 and +[Yarn 3](https://yarnpkg.com/getting-started/install) installed. Then, you'll need to install the required dependencies +using the following command : + +```bash +yarn install +``` + +You then need to copy the local environment configuration available in the `env.txt` file at the root of the directory +into an env file called `.env` : + +```bash +cp env.txt .env +``` + +You can then start the app by running : + +```bash +yarn start +``` + +The application is by default available at `https://localhost:17200/`. + +# Building the App +To build the app, you simply need to run the following command : + +```bash +yarn build +``` + +Don't forget to update the environment variables defined in your `.env` file for the target website. + +# Testing +## Running the Tests +To run the tests of the app, simply run the following command : + +```bash +yarn test +``` + +To run the tests as well as collecgt coverage, run the following command : + +```bash +yarn test:coverage +``` + +## Analyzing Bundle Size +After building the app using the `yarn build` command, you can analyze the bundle size using the following command : + +```bash +yarn analyze +``` + +This will open a new window on your desktop browser where you'll be able to see the sizes of each module in the final +app. diff --git a/apps/demo-app/env.txt b/apps/demo-app/env.txt new file mode 100644 index 000000000..e963363d7 --- /dev/null +++ b/apps/demo-app/env.txt @@ -0,0 +1,15 @@ +# App +PORT=17200 +HTTPS=true +REACT_APP_ENVIRONMENT=staging + +# Authentication +REACT_APP_AUTH_DOMAIN=idp.preview.monk.ai +REACT_APP_AUTH_AUDIENCE=https://api.monk.ai/v1/ +REACT_APP_AUTH_CLIENT_ID=O7geYcPM6zEJrHw0WvQVzSIzw4WzrAtH + +# Sentry +REACT_APP_SENTRY_DSN=https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720 + +# ESLint config +ESLINT_NO_DEV_ERRORS=true diff --git a/apps/demo-app/jest.config.js b/apps/demo-app/jest.config.js new file mode 100644 index 000000000..82e82ae00 --- /dev/null +++ b/apps/demo-app/jest.config.js @@ -0,0 +1,13 @@ +const { react } = require('@monkvision/jest-config'); + +module.exports = { + ...react, + coverageThreshold: { + global: { + branches: 0, + functions: 0, + lines: 0, + statements: 0, + }, + }, +}; diff --git a/apps/monk-test-app/package.json b/apps/demo-app/package.json similarity index 75% rename from apps/monk-test-app/package.json rename to apps/demo-app/package.json index d2d25b329..582f75c8b 100644 --- a/apps/monk-test-app/package.json +++ b/apps/demo-app/package.json @@ -1,5 +1,5 @@ { - "name": "monk-test-app", + "name": "monk-demo-app", "version": "4.0.0", "license": "BSD-3-Clause-Clear", "packageManager": "yarn@3.2.4", @@ -7,9 +7,11 @@ "author": "monkvision", "private": true, "scripts": { - "start": "HTTPS=true react-scripts start", + "start": "react-scripts start", "build": "react-scripts build", - "test": "react-scripts test --no-watch --no-watchAll --passWithNoTests", + "test": "jest", + "test:coverage": "jest --coverage", + "analyze": "source-map-explorer 'build/static/js/*.js'", "eject": "react-scripts eject", "prettier": "prettier --check ./src", "prettier:fix": "prettier --write ./src", @@ -19,49 +21,55 @@ "lint:fix": "yarn run prettier:fix && yarn run eslint:fix" }, "dependencies": { - "@monkvision/camera-web": "4.0.0", + "@auth0/auth0-react": "^2.2.4", "@monkvision/common": "4.0.0", "@monkvision/common-ui-web": "4.0.0", "@monkvision/inspection-capture-web": "4.0.0", "@monkvision/monitoring": "4.0.0", + "@monkvision/network": "4.0.0", "@monkvision/sentry": "4.0.0", "@monkvision/sights": "4.0.0", "@monkvision/types": "4.0.0", + "@types/babel__core": "^7", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.18", + "@types/react": "^17.0.2", + "@types/react-dom": "^17.0.2", + "@types/react-router-dom": "^5.3.3", + "@types/sort-by": "^1", "axios": "^1.5.0", "i18next": "^23.4.5", "i18next-browser-languagedetector": "^7.1.0", "jest-watch-typeahead": "^2.2.2", + "localforage": "^1.10.0", + "match-sorter": "^6.3.4", "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^13.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "sort-by": "^1.2.0", + "source-map-explorer": "^2.5.3", "typescript": "^4.9.5", "web-vitals": "^2.1.4" }, "devDependencies": { "@babel/core": "^7.22.9", + "@monkvision/eslint-config-base": "4.0.0", + "@monkvision/eslint-config-typescript": "4.0.0", + "@monkvision/eslint-config-typescript-react": "4.0.0", + "@monkvision/jest-config": "4.0.0", + "@monkvision/prettier-config": "4.0.0", + "@monkvision/test-utils": "4.0.0", + "@monkvision/typescript-config": "4.0.0", "@testing-library/dom": "^8.20.0", "@testing-library/jest-dom": "^5.16.5", "@testing-library/react": "^12.1.5", "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^12.1.5", - "@types/babel__core": "^7", - "@types/jest": "^27.5.2", - "@types/node": "^16.18.18", - "@types/react": "^17.0.2", - "@types/react-dom": "^17.0.2", - "eslint": "^8.29.0", - "eslint-plugin-jsx-a11y": "^6.7.1", - "eslint-plugin-react": "^7.33.1", - "eslint-plugin-react-hooks": "^4.6.0", - "jest": "^29.3.1", - "prettier": "^2.7.1", - "regexpp": "^3.2.0", - "ts-jest": "^29.0.3" - }, - "peerDependencies": { "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", + "eslint": "^8.29.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-prettier": "^8.5.0", "eslint-formatter-pretty": "^4.1.0", @@ -73,7 +81,11 @@ "eslint-plugin-promise": "^6.1.1", "eslint-plugin-react": "^7.27.1", "eslint-plugin-react-hooks": "^4.3.0", - "eslint-utils": "^3.0.0" + "eslint-utils": "^3.0.0", + "jest": "^29.3.1", + "prettier": "^2.7.1", + "regexpp": "^3.2.0", + "ts-jest": "^29.0.3" }, "prettier": "@monkvision/prettier-config", "browserslist": { diff --git a/apps/demo-app/public/favicon.ico b/apps/demo-app/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..6e6a629b0a776a416796f25b11f1bcb332275af4 GIT binary patch literal 15086 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 + + + + + + + + + + React App + + + +
+ + diff --git a/apps/demo-app/public/logo192.png b/apps/demo-app/public/logo192.png new file mode 100644 index 0000000000000000000000000000000000000000..0690e33a3b960a7aa27253d8653a6995e1f1738e GIT binary patch literal 9806 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%0AU8{7cYv(+ebNW4*Nn<;AcV9_gk$eN zC7mU5V*~pDt3KS+#iVKT>yjJj`d&zxhG8ACScBTpySEeZs%<6>|H$K{R? zTuGV>%Ct(>KxM3Nod!tNi7zI);)us2HdUayuFch{Pa&(@q=7QpK*+N zWOOOEC!IoFWvztR6@szp4)=tF0DOB4ro1M&lXRaIdh~;vj>MI}3+XuF1WN&+2zLu6 z309Ck`xxh)<=a4cs{)UEgL{BqztmECw3s)v?is~IceQur@UDPN2V5ld8+9T*y9yTp zM?Rsp0Jp)egZssn!e?05irL%Wtb6D}nrIeqT+1LZ%ld&} zCF!(kiluCDD~c$v!So^PJ9DgSz_p;}v6yr#GOo2bX#z5LrFW%zV(kvdih#lejd(cy z1}qi`q$gRsM5G{=bgC8Y0yxnS)zfSXm5D13TFRYeeMX{XbWn1z>7f%A0X&YS9Z0${ z@FMHlaE<80cj-o4@sE%m(mB~uHtY~}1s9Xfq@ZFtKroW@0T16yk z>oY(oJcP7ch6y9|ZYgU;fZsjM o`nyxC&%$ROZ|z2=Z#QV<|MYJ!ZfNl=6aWAK07*qoM6N<$f{i}DZU6uP literal 0 HcmV?d00001 diff --git a/apps/demo-app/public/logo512.png b/apps/demo-app/public/logo512.png new file mode 100644 index 0000000000000000000000000000000000000000..03813dd12c762c4a2b1cf2b33c3a1343af1fe14a GIT binary patch literal 44238 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 TeV;%N0zP_L|7pHgw~728)fz1- literal 0 HcmV?d00001 diff --git a/apps/monk-test-app/public/manifest.json b/apps/demo-app/public/manifest.json similarity index 72% rename from apps/monk-test-app/public/manifest.json rename to apps/demo-app/public/manifest.json index 080d6c77a..5634c25b3 100644 --- a/apps/monk-test-app/public/manifest.json +++ b/apps/demo-app/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Monk Demo App", + "name": "Monk Inspection Demo Application", "icons": [ { "src": "favicon.ico", @@ -20,6 +20,6 @@ ], "start_url": ".", "display": "standalone", - "theme_color": "#000000", - "background_color": "#ffffff" + "theme_color": "#274B9F", + "background_color": "#202020" } diff --git a/apps/monk-test-app/public/robots.txt b/apps/demo-app/public/robots.txt similarity index 100% rename from apps/monk-test-app/public/robots.txt rename to apps/demo-app/public/robots.txt diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx new file mode 100644 index 000000000..972738f60 --- /dev/null +++ b/apps/demo-app/src/components/App.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from 'react-i18next'; +import { Outlet, useNavigate } from 'react-router-dom'; +import { i18nInspectionCaptureWeb } from '@monkvision/inspection-capture-web'; +import { MonkAppParamsProvider, MonkProvider, useI18nLink, useMonkTheme } from '@monkvision/common'; +import { Page } from '../pages'; + +export function App() { + const { i18n } = useTranslation(); + const navigate = useNavigate(); + const { rootStyles } = useMonkTheme(); + useI18nLink(i18n, [i18nInspectionCaptureWeb]); + + return ( + navigate(Page.CREATE_INSPECTION)}> + +
+ +
+
+
+ ); +} diff --git a/apps/demo-app/src/components/AppRouter.tsx b/apps/demo-app/src/components/AppRouter.tsx new file mode 100644 index 000000000..7e07e850e --- /dev/null +++ b/apps/demo-app/src/components/AppRouter.tsx @@ -0,0 +1,36 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; +import { CreateInspectionPage, LogInPage, Page, PhotoCapturePage } from '../pages'; +import { AuthGuard } from './AuthGuard'; +import { App } from './App'; + +export function AppRouter() { + return ( + + + }> + } /> + } /> + + + + } + index + /> + + + + } + index + /> + } /> + + + + ); +} diff --git a/apps/demo-app/src/components/AuthGuard/AuthGuard.tsx b/apps/demo-app/src/components/AuthGuard/AuthGuard.tsx new file mode 100644 index 000000000..da36ab20e --- /dev/null +++ b/apps/demo-app/src/components/AuthGuard/AuthGuard.tsx @@ -0,0 +1,20 @@ +import { PropsWithChildren } from 'react'; +import { Navigate } from 'react-router-dom'; +import { useMonkAppParams } from '@monkvision/common'; +import { isTokenExpired, isUserAuthorized } from '@monkvision/network'; +import { Page } from '../../pages'; +import { REQUIRED_AUTHORIZATIONS } from '../../config'; + +export function AuthGuard({ children }: PropsWithChildren) { + const { authToken } = useMonkAppParams(); + + if ( + !authToken || + !isUserAuthorized(authToken, REQUIRED_AUTHORIZATIONS) || + isTokenExpired(authToken) + ) { + return ; + } + + return <>{children}; +} diff --git a/apps/demo-app/src/components/AuthGuard/index.ts b/apps/demo-app/src/components/AuthGuard/index.ts new file mode 100644 index 000000000..f9e6428b5 --- /dev/null +++ b/apps/demo-app/src/components/AuthGuard/index.ts @@ -0,0 +1 @@ +export * from './AuthGuard'; diff --git a/apps/demo-app/src/components/index.ts b/apps/demo-app/src/components/index.ts new file mode 100644 index 000000000..082a17647 --- /dev/null +++ b/apps/demo-app/src/components/index.ts @@ -0,0 +1,3 @@ +export * from './App'; +export * from './AppRouter'; +export * from './AuthGuard'; diff --git a/apps/demo-app/src/config/auth.ts b/apps/demo-app/src/config/auth.ts new file mode 100644 index 000000000..41eed9849 --- /dev/null +++ b/apps/demo-app/src/config/auth.ts @@ -0,0 +1,12 @@ +import { MonkApiPermission } from '@monkvision/network'; + +export const REQUIRED_AUTHORIZATIONS = [ + MonkApiPermission.TASK_COMPLIANCES, + MonkApiPermission.TASK_DAMAGE_DETECTION, + MonkApiPermission.TASK_DAMAGE_IMAGES_OCR, + MonkApiPermission.TASK_WHEEL_ANALYSIS, + MonkApiPermission.INSPECTION_CREATE, + MonkApiPermission.INSPECTION_READ, + MonkApiPermission.INSPECTION_UPDATE, + MonkApiPermission.INSPECTION_WRITE, +]; diff --git a/apps/demo-app/src/config/index.ts b/apps/demo-app/src/config/index.ts new file mode 100644 index 000000000..10abf5b48 --- /dev/null +++ b/apps/demo-app/src/config/index.ts @@ -0,0 +1,2 @@ +export * from './auth'; +export * from './sights'; diff --git a/apps/demo-app/src/config/sights.ts b/apps/demo-app/src/config/sights.ts new file mode 100644 index 000000000..d61a93b2f --- /dev/null +++ b/apps/demo-app/src/config/sights.ts @@ -0,0 +1,147 @@ +import { Sight, VehicleType } from '@monkvision/types'; +import { sights } from '@monkvision/sights'; + +const APP_SIGHTS_BY_VEHICLE_TYPE: Partial> = { + [VehicleType.SUV]: [ + sights['jgc21-QIvfeg0X'], + sights['jgc21-KyUUVU2P'], + sights['jgc21-zCrDwYWE'], + sights['jgc21-z15ZdJL6'], + sights['jgc21-RE3li6rE'], + sights['jgc21-omlus7Ui'], + sights['jgc21-m2dDoMup'], + sights['jgc21-3gjMwvQG'], + sights['jgc21-ezXzTRkj'], + sights['jgc21-tbF2Ax8v'], + sights['jgc21-3JJvM7_B'], + sights['jgc21-RAVpqaE4'], + sights['jgc21-F-PPd4qN'], + sights['jgc21-XXh8GWm8'], + sights['jgc21-TRN9Des4'], + sights['jgc21-s7WDTRmE'], + sights['jgc21-__JKllz9'], + ], + [VehicleType.CROSSOVER]: [ + sights['fesc20-H1dfdfvH'], + sights['fesc20-WMUaKDp1'], + sights['fesc20-LTe3X2bg'], + sights['fesc20-WIQsf_gX'], + sights['fesc20-hp3Tk53x'], + sights['fesc20-fOt832UV'], + sights['fesc20-NLdqASzl'], + sights['fesc20-4Wqx52oU'], + sights['fesc20-dfICsfSV'], + sights['fesc20-X8k7UFGf'], + sights['fesc20-LZc7p2kK'], + sights['fesc20-5Ts1UkPT'], + sights['fesc20-gg1Xyrpu'], + sights['fesc20-P0oSEh8p'], + sights['fesc20-j3H8Z415'], + sights['fesc20-dKVLig1i'], + sights['fesc20-Wzdtgqqz'], + ], + [VehicleType.SEDAN]: [ + sights['haccord-8YjMcu0D'], + sights['haccord-DUPnw5jj'], + sights['haccord-hsCc_Nct'], + sights['haccord-GQcZz48C'], + sights['haccord-QKfhXU7o'], + sights['haccord-mdZ7optI'], + sights['haccord-bSAv3Hrj'], + sights['haccord-W-Bn3bU1'], + sights['haccord-GdWvsqrm'], + sights['haccord-ps7cWy6K'], + sights['haccord-Jq65fyD4'], + sights['haccord-OXYy5gET'], + sights['haccord-5LlCuIfL'], + sights['haccord-Gtt0JNQl'], + sights['haccord-cXSAj2ez'], + sights['haccord-KN23XXkX'], + sights['haccord-Z84erkMb'], + ], + [VehicleType.HATCHBACK]: [ + sights['ffocus18-XlfgjQb9'], + sights['ffocus18-3TiCVAaN'], + sights['ffocus18-43ljK5xC'], + sights['ffocus18-x_1SE7X-'], + sights['ffocus18-QKfhXU7o'], + sights['ffocus18-yo9eBDW6'], + sights['ffocus18-cPUyM28L'], + sights['ffocus18-S3kgFOBb'], + sights['ffocus18-9MeSIqp7'], + sights['ffocus18-X2LDjCvr'], + sights['ffocus18-jWOq2CNN'], + sights['ffocus18-P2jFq1Ea'], + sights['ffocus18-U3Bcfc2Q'], + sights['ffocus18-ts3buSD1'], + sights['ffocus18-cXSAj2ez'], + sights['ffocus18-KkeGvT-F'], + sights['ffocus18-lRDlWiwR'], + ], + [VehicleType.VAN]: [ + sights['ftransit18-wyXf7MTv'], + sights['ftransit18-UNAZWJ-r'], + sights['ftransit18-5SiNC94w'], + sights['ftransit18-Y0vPhBVF'], + sights['ftransit18-xyp1rU0h'], + sights['ftransit18-6khKhof0'], + sights['ftransit18-eXJDDYmE'], + sights['ftransit18-3Sbfx_KZ'], + sights['ftransit18-iu1Vj2Oa'], + sights['ftransit18-aA2K898S'], + sights['ftransit18-NwBMLo3Z'], + sights['ftransit18-cf0e-pcB'], + sights['ftransit18-FFP5b34o'], + sights['ftransit18-RJ2D7DNz'], + sights['ftransit18-3fnjrISV'], + sights['ftransit18-eztNpSRX'], + sights['ftransit18-TkXihCj4'], + sights['ftransit18-4NMPqEV6'], + sights['ftransit18-IIVI_pnX'], + ], + [VehicleType.MINIVAN]: [ + sights['tsienna20-YwrRNr9n'], + sights['tsienna20-HykkFbXf'], + sights['tsienna20-TI4TVvT9'], + sights['tsienna20-65mfPdRD'], + sights['tsienna20-Ia0SGJ6z'], + sights['tsienna20-1LNxhgCR'], + sights['tsienna20-U_FqYq-a'], + sights['tsienna20-670P2H2V'], + sights['tsienna20-1n_z8bYy'], + sights['tsienna20-qA3aAUUq'], + sights['tsienna20--a2RmRcs'], + sights['tsienna20-SebsoqJm'], + sights['tsienna20-u57qDaN_'], + sights['tsienna20-Rw0Gtt7O'], + sights['tsienna20-TibS83Qr'], + sights['tsienna20-cI285Gon'], + sights['tsienna20-KHB_Cd9k'], + ], + [VehicleType.PICKUP]: [ + sights['ff150-zXbg0l3z'], + sights['ff150-3he9UOwy'], + sights['ff150-KgHVkQBW'], + sights['ff150-FqbrFVr2'], + sights['ff150-g_xBOOS2'], + sights['ff150-vwE3yqdh'], + sights['ff150-V-xzfWsx'], + sights['ff150-ouGGtRnf'], + sights['ff150--xPZZd83'], + sights['ff150-nF_oFvhI'], + sights['ff150-t3KBMPeD'], + sights['ff150-3rM9XB0Z'], + sights['ff150-eOjyMInj'], + sights['ff150-18YVVN-G'], + sights['ff150-BmXfb-qD'], + sights['ff150-gFp78fQO'], + sights['ff150-7nvlys8r'], + ], +}; + +export function getSights(vehicleType: VehicleType | null): Sight[] { + return ( + APP_SIGHTS_BY_VEHICLE_TYPE[vehicleType ?? VehicleType.CROSSOVER] ?? + (APP_SIGHTS_BY_VEHICLE_TYPE[VehicleType.CROSSOVER] as Sight[]) + ); +} diff --git a/apps/monk-test-app/src/i18n.ts b/apps/demo-app/src/i18n.ts similarity index 76% rename from apps/monk-test-app/src/i18n.ts rename to apps/demo-app/src/i18n.ts index 4683460e9..79580d139 100644 --- a/apps/monk-test-app/src/i18n.ts +++ b/apps/demo-app/src/i18n.ts @@ -2,7 +2,8 @@ import i18n from 'i18next'; import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; import { i18nLinkSDKInstances } from '@monkvision/common'; -import { i18nCamera } from '@monkvision/camera-web'; +import { i18nInspectionCaptureWeb } from '@monkvision/inspection-capture-web'; +import { monkLanguages } from '@monkvision/types'; import en from './translations/en.json'; import fr from './translations/fr.json'; import de from './translations/de.json'; @@ -14,7 +15,7 @@ i18n compatibilityJSON: 'v3', fallbackLng: 'en', interpolation: { escapeValue: false }, - supportedLngs: ['en', 'fr'], + supportedLngs: monkLanguages, nonExplicitSupportedLngs: true, resources: { en: { translation: en }, @@ -24,6 +25,6 @@ i18n }) .catch(console.error); -i18nLinkSDKInstances(i18n, [i18nCamera]); +i18nLinkSDKInstances(i18n, [i18nInspectionCaptureWeb]); export default i18n; diff --git a/apps/monk-test-app/src/index.css b/apps/demo-app/src/index.css similarity index 75% rename from apps/monk-test-app/src/index.css rename to apps/demo-app/src/index.css index 04ef95dc0..75fa5734e 100644 --- a/apps/monk-test-app/src/index.css +++ b/apps/demo-app/src/index.css @@ -1,6 +1,7 @@ html, body, -#root { +#root, +.app-container { height: 100%; width: 100%; } @@ -9,4 +10,5 @@ body { margin: 0; background-color: #000000; font-family: sans-serif; + color: white; } diff --git a/apps/demo-app/src/index.tsx b/apps/demo-app/src/index.tsx new file mode 100644 index 000000000..80681b31d --- /dev/null +++ b/apps/demo-app/src/index.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { MonitoringProvider } from '@monkvision/monitoring'; +import { Auth0Provider } from '@auth0/auth0-react'; +import { getEnvOrThrow, MonkThemeProvider } from '@monkvision/common'; +import { sentryMonitoringAdapter } from './sentry'; +import { AppRouter } from './components'; +import './index.css'; +import './i18n'; + +ReactDOM.render( + + + + + + + , + document.getElementById('root'), +); diff --git a/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.module.css b/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.module.css new file mode 100644 index 000000000..8b5fde5ec --- /dev/null +++ b/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.module.css @@ -0,0 +1,12 @@ +.container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.error-message { + text-align: center; + padding-bottom: 20px; +} diff --git a/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.tsx b/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.tsx new file mode 100644 index 000000000..708375655 --- /dev/null +++ b/apps/demo-app/src/pages/CreateInspectionPage/CreateInspectionPage.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from 'react-i18next'; +import { useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import { Button, Spinner } from '@monkvision/common-ui-web'; +import { useMonkApi } from '@monkvision/network'; +import { getEnvOrThrow, useLoadingState, useMonkAppParams } from '@monkvision/common'; +import { useMonitoring } from '@monkvision/monitoring'; +import { TaskName } from '@monkvision/types'; +import { Page } from '../pages'; +import styles from './CreateInspectionPage.module.css'; + +export function CreateInspectionPage() { + const loading = useLoadingState(); + const { t } = useTranslation(); + const { handleError } = useMonitoring(); + const { authToken, inspectionId, setInspectionId } = useMonkAppParams(); + const { createInspection } = useMonkApi({ + authToken: authToken ?? '', + apiDomain: getEnvOrThrow('REACT_APP_API_DOMAIN'), + }); + + const handleCreateInspection = () => { + loading.start(); + createInspection({ tasks: [TaskName.DAMAGE_DETECTION, TaskName.WHEEL_ANALYSIS] }) + .then((res) => { + loading.onSuccess(); + setInspectionId(res.id); + }) + .catch((err) => { + loading.onError(err); + handleError(err); + }); + }; + + useEffect(() => { + if (!inspectionId) { + loading.start(); + handleCreateInspection(); + } + }, [inspectionId]); + + if (inspectionId) { + return ; + } + + return ( +
+ {loading.isLoading && } + {!loading.isLoading && loading.error && ( + <> +
+ {t('create-inspection.errors.create-inspection')} +
+ + + )} +
+ ); +} diff --git a/apps/demo-app/src/pages/CreateInspectionPage/index.ts b/apps/demo-app/src/pages/CreateInspectionPage/index.ts new file mode 100644 index 000000000..cd6c862e8 --- /dev/null +++ b/apps/demo-app/src/pages/CreateInspectionPage/index.ts @@ -0,0 +1 @@ +export * from './CreateInspectionPage'; diff --git a/apps/demo-app/src/pages/LogInPage/LogInPage.module.css b/apps/demo-app/src/pages/LogInPage/LogInPage.module.css new file mode 100644 index 000000000..a1b302ab1 --- /dev/null +++ b/apps/demo-app/src/pages/LogInPage/LogInPage.module.css @@ -0,0 +1,13 @@ +.container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.error-message { + text-align: center; + padding-bottom: 20px; +} diff --git a/apps/demo-app/src/pages/LogInPage/LogInPage.tsx b/apps/demo-app/src/pages/LogInPage/LogInPage.tsx new file mode 100644 index 000000000..5b5fd5cb1 --- /dev/null +++ b/apps/demo-app/src/pages/LogInPage/LogInPage.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; +import { Button } from '@monkvision/common-ui-web'; +import { isTokenExpired, isUserAuthorized, useAuth } from '@monkvision/network'; +import { useLoadingState, useMonkAppParams } from '@monkvision/common'; +import { useMonitoring } from '@monkvision/monitoring'; +import styles from './LogInPage.module.css'; +import { Page } from '../pages'; +import { REQUIRED_AUTHORIZATIONS } from '../../config'; + +function getLoginErrorMessage(err: unknown): string { + if (err instanceof Error) { + if (err.message === 'Popup closed') { + return 'login.errors.popup-closed'; + } + } + return 'login.errors.unknown'; +} + +export function LogInPage() { + const [isExpired, setIsExpired] = useState(false); + const loading = useLoadingState(); + const { authToken, inspectionId, setAuthToken } = useMonkAppParams(); + const { handleError } = useMonitoring(); + const { login, logout } = useAuth(); + const { t } = useTranslation(); + const navigate = useNavigate(); + + useEffect(() => { + if (authToken && !isUserAuthorized(authToken, REQUIRED_AUTHORIZATIONS)) { + loading.onError('login.errors.insufficient-authorization'); + } + if (authToken && isTokenExpired(authToken)) { + setIsExpired(true); + setAuthToken(null); + } + }, [authToken, loading]); + + const handleLogin = () => { + setIsExpired(false); + loading.start(); + login() + .then((token) => { + if (isUserAuthorized(token, REQUIRED_AUTHORIZATIONS)) { + loading.onSuccess(); + navigate(inspectionId ? Page.PHOTO_CAPTURE : Page.CREATE_INSPECTION); + } else { + loading.onError('login.errors.insufficient-authorization'); + } + }) + .catch((err) => { + const message = getLoginErrorMessage(err); + loading.onError(message); + handleError(err); + }); + }; + + return ( +
+ {isExpired && ( +
{t('login.errors.token-expired')}
+ )} + {loading.error &&
{t(loading.error)}
} + {authToken ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/demo-app/src/pages/LogInPage/index.ts b/apps/demo-app/src/pages/LogInPage/index.ts new file mode 100644 index 000000000..98c073f29 --- /dev/null +++ b/apps/demo-app/src/pages/LogInPage/index.ts @@ -0,0 +1 @@ +export * from './LogInPage'; diff --git a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.module.css b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.module.css new file mode 100644 index 000000000..8b5fde5ec --- /dev/null +++ b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.module.css @@ -0,0 +1,12 @@ +.container { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; +} + +.error-message { + text-align: center; + padding-bottom: 20px; +} diff --git a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx new file mode 100644 index 000000000..a2fbb617d --- /dev/null +++ b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx @@ -0,0 +1,48 @@ +import { useTranslation } from 'react-i18next'; +import { + getEnvOrThrow, + useMonkAppParams, + zlibCompress, + getSearchParamFromVehicleType, +} from '@monkvision/common'; +import { VehicleType } from '@monkvision/types'; +import { PhotoCapture } from '@monkvision/inspection-capture-web'; +import { getSights } from '../../config'; +import styles from './PhotoCapturePage.module.css'; + +function createInspectionReportLink( + authToken: string | null, + inspectionId: string | null, + language: string, + vehicleType: VehicleType | null, +): string { + const url = getEnvOrThrow('REACT_APP_INSPECTION_REPORT_URL'); + const token = encodeURIComponent(zlibCompress(authToken ?? '')); + const vType = getSearchParamFromVehicleType(vehicleType); + return `${url}?c=e5j&lang=${language}&i=${inspectionId}&t=${token}&v=${vType}`; +} + +export function PhotoCapturePage() { + const { i18n } = useTranslation(); + const { authToken, inspectionId, vehicleType } = useMonkAppParams({ required: true }); + + const handleComplete = () => { + window.location.href = createInspectionReportLink( + authToken, + inspectionId, + i18n.language, + vehicleType, + ); + }; + + return ( +
+ +
+ ); +} diff --git a/apps/demo-app/src/pages/PhotoCapturePage/index.ts b/apps/demo-app/src/pages/PhotoCapturePage/index.ts new file mode 100644 index 000000000..294d0d80a --- /dev/null +++ b/apps/demo-app/src/pages/PhotoCapturePage/index.ts @@ -0,0 +1 @@ +export * from './PhotoCapturePage'; diff --git a/apps/demo-app/src/pages/index.ts b/apps/demo-app/src/pages/index.ts new file mode 100644 index 000000000..9b225bf10 --- /dev/null +++ b/apps/demo-app/src/pages/index.ts @@ -0,0 +1,4 @@ +export * from './pages'; +export * from './LogInPage'; +export * from './CreateInspectionPage'; +export * from './PhotoCapturePage'; diff --git a/apps/demo-app/src/pages/pages.ts b/apps/demo-app/src/pages/pages.ts new file mode 100644 index 000000000..7f8470166 --- /dev/null +++ b/apps/demo-app/src/pages/pages.ts @@ -0,0 +1,5 @@ +export enum Page { + LOG_IN = '/log-in', + CREATE_INSPECTION = '/create-inspection', + PHOTO_CAPTURE = '/photo-capture', +} diff --git a/apps/monk-test-app/src/react-app-env.d.ts b/apps/demo-app/src/react-app-env.d.ts similarity index 100% rename from apps/monk-test-app/src/react-app-env.d.ts rename to apps/demo-app/src/react-app-env.d.ts diff --git a/apps/monk-test-app/src/sentry.ts b/apps/demo-app/src/sentry.ts similarity index 68% rename from apps/monk-test-app/src/sentry.ts rename to apps/demo-app/src/sentry.ts index 1859da98d..0ed36a029 100644 --- a/apps/monk-test-app/src/sentry.ts +++ b/apps/demo-app/src/sentry.ts @@ -1,7 +1,7 @@ import { SentryMonitoringAdapter } from '@monkvision/sentry'; export const sentryMonitoringAdapter = new SentryMonitoringAdapter({ - dsn: 'https://669efe3ef7b359aa4c4bdcf6761ba861@o4505669501648896.ingest.sentry.io/4505861275975680', + dsn: 'https://74f50bfe6f11de7aefd54acfa5dfed96@o4505669501648896.ingest.us.sentry.io/4506863461662720', environment: 'development', debug: true, tracesSampleRate: 0.025, diff --git a/apps/monk-test-app/src/setupTests.ts b/apps/demo-app/src/setupTests.ts similarity index 100% rename from apps/monk-test-app/src/setupTests.ts rename to apps/demo-app/src/setupTests.ts diff --git a/apps/demo-app/src/translations/de.json b/apps/demo-app/src/translations/de.json new file mode 100644 index 000000000..06a007882 --- /dev/null +++ b/apps/demo-app/src/translations/de.json @@ -0,0 +1,20 @@ +{ + "login": { + "actions": { + "log-in": "Einloggen", + "log-out": "Sich abmelden" + }, + "errors": { + "popup-closed": "Huch! Wir konnten Sie nicht anmelden, weil das Popup geschlossen wurde. Versuchen wir es noch einmal!", + "token-expired": "Ihr Authentifizierungstoken ist abgelaufen. Bitte melden Sie sich erneut an.", + "insufficient-authorization": "Sie haben nicht die erforderlichen Berechtigungen, um diese Anwendung zu nutzen. Bitte melden Sie sich ab und verwenden Sie ein anderes Konto.", + "unknown": "Huch! Beim Einloggen ist ein unerwarteter Fehler aufgetreten. Versuchen wir es noch einmal!" + } + }, + "create-inspection": { + "errors": { + "retry": "Wiederholung", + "create-inspection": "Bei der Erstellung der Inspektion ist ein unerwarteter Fehler aufgetreten." + } + } +} diff --git a/apps/demo-app/src/translations/en.json b/apps/demo-app/src/translations/en.json new file mode 100644 index 000000000..14b657bc3 --- /dev/null +++ b/apps/demo-app/src/translations/en.json @@ -0,0 +1,20 @@ +{ + "login": { + "actions": { + "log-in": "Log In", + "log-out": "Log Out" + }, + "errors": { + "popup-closed": "Oops! We couldn't log you in because the popup was closed. Let's try again!", + "token-expired": "Your authentication token is expired. Please log-in again.", + "insufficient-authorization": "You do not have the required authorizations to use this application. Please log out and use a different account.", + "unknown": "Oops! An unexpected error occurred during the log in. Let's try again!" + } + }, + "create-inspection": { + "errors": { + "retry": "Retry", + "create-inspection": "An unexpected error occurred while creating the inspection." + } + } +} diff --git a/apps/demo-app/src/translations/fr.json b/apps/demo-app/src/translations/fr.json new file mode 100644 index 000000000..780914b86 --- /dev/null +++ b/apps/demo-app/src/translations/fr.json @@ -0,0 +1,20 @@ +{ + "login": { + "actions": { + "log-in": "Se Connecter", + "log-out": "Se Déconnecter" + }, + "errors": { + "popup-closed": "Oups ! Nous n'avons pas pu vous connecter car la pop-up s'est fermée. Essayons à nouveau !", + "token-expired": "Votre token d'authentification est expiré. Veuillez vous reconnecter.", + "insufficient-authorization": "Vous n'avez pas les autorisations nécessaires pour utiliser cette application. Veuillez vous déconnecter et utiliser un autre compte.", + "unknown": "Oups ! Une erreur inattendue est survenue lors de la connection. Essayons à nouveau !" + } + }, + "create-inspection": { + "errors": { + "retry": "Réessayer", + "create-inspection": "Une erreur inattendue est survenue lors de la création de l'inspection." + } + } +} diff --git a/apps/demo-app/test/components/AuthGuard.test.tsx b/apps/demo-app/test/components/AuthGuard.test.tsx new file mode 100644 index 000000000..30fef078d --- /dev/null +++ b/apps/demo-app/test/components/AuthGuard.test.tsx @@ -0,0 +1,90 @@ +jest.mock('react-router-dom', () => ({ + Navigate: jest.fn(({ to, replace }) => ( +
{`Redirect to ${to}${replace ? ' with replace' : ''}`}
+ )), +})); + +import { useMonkAppParams } from '@monkvision/common'; +import { render, screen } from '@testing-library/react'; +import { isTokenExpired, isUserAuthorized, MonkApiPermission } from '@monkvision/network'; +import { AuthGuard } from '../../src/components'; +import { Page } from '../../src/pages'; + +const redirectText = `Redirect to ${Page.LOG_IN} with replace`; +const childTestId = 'child-test-id'; +const child =
; + +function expectRedirect() { + expect(screen.queryByTestId(childTestId)).toBeNull(); + expect(screen.queryByText(redirectText)).not.toBeNull(); +} + +function expectNoRedirect() { + expect(screen.queryByTestId(childTestId)).not.toBeNull(); + expect(screen.queryByText(redirectText)).toBeNull(); +} + +function mockAuthToken(params: { + defined: boolean; + authorized: boolean; + expired: boolean; +}): string | null { + const authToken = params.defined ? 'test-auth-token-test' : null; + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ authToken })); + (isUserAuthorized as jest.Mock).mockImplementation(() => params.authorized); + (isTokenExpired as jest.Mock).mockImplementation(() => params.expired); + return authToken; +} + +describe('AuthGuard component', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should redirect the user if the authToken is not defined', () => { + mockAuthToken({ defined: false, authorized: true, expired: false }); + const { unmount } = render({child}); + + expectRedirect(); + + unmount(); + }); + + it('should redirect the user if the user does not have the proper authorizations', () => { + const token = mockAuthToken({ defined: true, authorized: false, expired: false }); + const { unmount } = render({child}); + + expect(isUserAuthorized).toHaveBeenCalledWith(token, [ + MonkApiPermission.TASK_COMPLIANCES, + MonkApiPermission.TASK_DAMAGE_DETECTION, + MonkApiPermission.TASK_DAMAGE_IMAGES_OCR, + MonkApiPermission.TASK_WHEEL_ANALYSIS, + MonkApiPermission.INSPECTION_CREATE, + MonkApiPermission.INSPECTION_READ, + MonkApiPermission.INSPECTION_UPDATE, + MonkApiPermission.INSPECTION_WRITE, + ]); + expectRedirect(); + + unmount(); + }); + + it('should redirect the user if the token is expired', () => { + const token = mockAuthToken({ defined: true, authorized: true, expired: true }); + const { unmount } = render({child}); + + expect(isTokenExpired).toHaveBeenCalledWith(token); + expectRedirect(); + + unmount(); + }); + + it('should not redirect the user if the token is valid', () => { + mockAuthToken({ defined: true, authorized: true, expired: false }); + const { unmount } = render({child}); + + expectNoRedirect(); + + unmount(); + }); +}); diff --git a/apps/demo-app/test/pages/CreateInspectionPage.test.tsx b/apps/demo-app/test/pages/CreateInspectionPage.test.tsx new file mode 100644 index 000000000..051f5a844 --- /dev/null +++ b/apps/demo-app/test/pages/CreateInspectionPage.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { Navigate } from 'react-router-dom'; +import { act } from 'react-dom/test-utils'; +import { useLoadingState, useMonkAppParams } from '@monkvision/common'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { useMonkApi } from '@monkvision/network'; +import { TaskName } from '@monkvision/types'; +import { Button } from '@monkvision/common-ui-web'; +import { CreateInspectionPage, Page } from '../../src/pages'; + +const appParams = { + authToken: 'test-auth-token', + inspectionId: null, + setInspectionId: jest.fn(), +}; + +describe('CreateInspection page', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should redirect to the PhotoCapture page if the inspectionId is defined', () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ + ...appParams, + inspectionId: 'test', + })); + const { unmount } = render(); + + expectPropsOnChildMock(Navigate, { to: Page.PHOTO_CAPTURE }); + + unmount(); + }); + + it('should create the inspection and then redirect to the PhotoCapture page if the inspectionId is not defined', async () => { + const id = 'test-id-test'; + const createInspection = jest.fn(() => Promise.resolve({ id })); + (useMonkApi as jest.Mock).mockImplementation(() => ({ createInspection })); + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + const { unmount } = render(); + + expect(createInspection).toHaveBeenCalledWith({ + tasks: [TaskName.DAMAGE_DETECTION, TaskName.WHEEL_ANALYSIS], + }); + await waitFor(() => { + expect(appParams.setInspectionId).toHaveBeenCalledWith(id); + }); + + unmount(); + }); + + it('should display an error message if the API call fails', async () => { + const createInspection = jest.fn(() => Promise.reject()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ createInspection })); + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + const onError = jest.fn(); + const error = 'test-error'; + (useLoadingState as jest.Mock).mockImplementation(() => ({ onError, error, start: jest.fn() })); + const { unmount } = render(); + + await waitFor(() => { + expect(appParams.setInspectionId).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalled(); + expect(screen.queryByText('create-inspection.errors.create-inspection')).not.toBeNull(); + }); + + unmount(); + }); + + it('should display a retry button if the API call fails', async () => { + const createInspection = jest.fn(() => Promise.reject()); + (useMonkApi as jest.Mock).mockImplementation(() => ({ createInspection })); + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + (useLoadingState as jest.Mock).mockImplementation(() => ({ + onError: jest.fn(), + error: 'test-error', + start: jest.fn(), + })); + const { unmount } = render(); + + expectPropsOnChildMock(Button, { + variant: 'outline', + icon: 'refresh', + onClick: expect.any(Function), + }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + + createInspection.mockClear(); + act(() => onClick()); + expect(createInspection).toHaveBeenCalled(); + + unmount(); + }); +}); diff --git a/apps/demo-app/test/pages/LogInPage.test.tsx b/apps/demo-app/test/pages/LogInPage.test.tsx new file mode 100644 index 000000000..1ab2088e2 --- /dev/null +++ b/apps/demo-app/test/pages/LogInPage.test.tsx @@ -0,0 +1,177 @@ +import { act } from 'react-dom/test-utils'; +import { useNavigate } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { useLoadingState, useMonkAppParams } from '@monkvision/common'; +import { isTokenExpired, isUserAuthorized, useAuth } from '@monkvision/network'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { Button } from '@monkvision/common-ui-web'; +import { LogInPage, Page } from '../../src/pages'; + +const appParams = { + authToken: 'test-auth-token', + inspectionId: 'test-inspection-id', + setAuthToken: jest.fn(), +}; + +describe('Log In page', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should display a login button on the screen', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ ...appParams, authToken: null })); + const { unmount } = render(); + + expectPropsOnChildMock(Button, { + onClick: expect.any(Function), + children: 'login.actions.log-in', + }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(useAuth).toHaveBeenCalled(); + const { login } = (useAuth as jest.Mock).mock.results[0].value; + + act(() => onClick()); + expect(login).toHaveBeenCalled(); + + unmount(); + }); + + it('should redirect to the PhotoCapture page after the login if the inspectionId is defined', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ ...appParams, authToken: null })); + const { unmount } = render(); + + expect(Button).toHaveBeenCalled(); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(useNavigate).toHaveBeenCalled(); + const navigate = (useNavigate as jest.Mock).mock.results[0].value; + + act(() => onClick()); + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(Page.PHOTO_CAPTURE); + }); + + unmount(); + }); + + it('should redirect to the CreateInspection page after the login if the inspectionId is not defined', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ + ...appParams, + authToken: null, + inspectionId: null, + })); + const { unmount } = render(); + + expect(Button).toHaveBeenCalled(); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(useNavigate).toHaveBeenCalled(); + const navigate = (useNavigate as jest.Mock).mock.results[0].value; + + act(() => onClick()); + await waitFor(() => { + expect(navigate).toHaveBeenCalledWith(Page.CREATE_INSPECTION); + }); + + unmount(); + }); + + it('should not redirect after log in if the user does not have sufficient authorization', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ ...appParams, authToken: null })); + (isUserAuthorized as jest.Mock).mockImplementation(() => false); + const onError = jest.fn(); + const error = 'test-error'; + (useLoadingState as jest.Mock).mockImplementation(() => ({ onError, error, start: jest.fn() })); + const { unmount } = render(); + + expect(Button).toHaveBeenCalled(); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(useNavigate).toHaveBeenCalled(); + const navigate = (useNavigate as jest.Mock).mock.results[0].value; + + act(() => onClick()); + await waitFor(() => { + expect(onError).toHaveBeenCalledWith('login.errors.insufficient-authorization'); + expect(navigate).not.toHaveBeenCalled(); + expect(screen.queryByText(error)).not.toBeNull(); + }); + + unmount(); + }); + + it('should display an error message and a log out button if the user does not have sufficient authorizations', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + (isUserAuthorized as jest.Mock).mockImplementation(() => false); + const onError = jest.fn(); + const error = 'test-error'; + (useLoadingState as jest.Mock).mockImplementation(() => ({ onError, error })); + const { unmount } = render(); + + expect(onError).toHaveBeenCalledWith('login.errors.insufficient-authorization'); + expect(screen.queryByText(error)).not.toBeNull(); + + expectPropsOnChildMock(Button, { + primaryColor: 'alert', + onClick: expect.any(Function), + children: 'login.actions.log-out', + }); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + expect(useAuth).toHaveBeenCalled(); + const { logout } = (useAuth as jest.Mock).mock.results[0].value; + expect(logout).not.toHaveBeenCalled(); + + await act(() => onClick()); + expect(logout).toHaveBeenCalled(); + + unmount(); + }); + + it('should display an error message on the screen if the token was expired', async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + (isTokenExpired as jest.Mock).mockImplementation(() => true); + const { unmount } = render(); + + expect(isTokenExpired).toHaveBeenCalledWith(appParams.authToken); + expect(appParams.setAuthToken).toHaveBeenCalledWith(null); + expect(screen.queryByText('login.errors.token-expired')).not.toBeNull(); + + unmount(); + }); + + [ + { + testCase: 'when the user closes the log in pop up', + err: new Error('Popup closed'), + label: 'login.errors.popup-closed', + }, + { + testCase: 'when an unexpected error occurrs during the log in', + err: new Error(), + label: 'login.errors.unknown', + }, + ].forEach(({ testCase, err, label }) => { + it(`should not redirect and display the proper error message ${testCase}`, async () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => ({ ...appParams, authToken: null })); + (useAuth as jest.Mock).mockImplementation(() => ({ + login: jest.fn(() => Promise.reject(err)), + })); + const onError = jest.fn(); + const error = 'test-error'; + (useLoadingState as jest.Mock).mockImplementation(() => ({ + onError, + error, + start: jest.fn(), + })); + const { unmount } = render(); + + expect(Button).toHaveBeenCalled(); + const { onClick } = (Button as unknown as jest.Mock).mock.calls[0][0]; + + act(() => onClick()); + await waitFor(() => { + expect(onError).toHaveBeenCalledWith(label); + expect(screen.queryByText(error)).not.toBeNull(); + }); + + unmount(); + }); + }); +}); diff --git a/apps/demo-app/test/pages/PhotoCapturePage.test.tsx b/apps/demo-app/test/pages/PhotoCapturePage.test.tsx new file mode 100644 index 000000000..b7e8251a6 --- /dev/null +++ b/apps/demo-app/test/pages/PhotoCapturePage.test.tsx @@ -0,0 +1,45 @@ +jest.mock('../../src/config', () => ({ + getSights: jest.fn(() => [{ id: 'test' }]), +})); + +import { getSights } from '../../src/config'; +import { PhotoCapturePage } from '../../src/pages'; +import { render } from '@testing-library/react'; +import { expectPropsOnChildMock } from '@monkvision/test-utils'; +import { PhotoCapture } from '@monkvision/inspection-capture-web'; +import { useMonkAppParams } from '@monkvision/common'; +import { VehicleType } from '@monkvision/types'; + +const appParams = { + authToken: 'test-auth-token', + inspectionId: 'test-inspection-id', + vehicleType: '0', +}; + +describe('PhotoCapture page', () => { + it('should pass the proper props to the PhotoCapture component', () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + const { unmount } = render(); + + expectPropsOnChildMock(PhotoCapture, { + apiConfig: { authToken: appParams.authToken, apiDomain: 'REACT_APP_API_DOMAIN' }, + inspectionId: appParams.inspectionId, + sights: expect.any(Array), + onComplete: expect.any(Function), + }); + + unmount(); + }); + + it('should use the proper sights for all vehicle types', () => { + (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); + const { unmount } = render(); + + expect(getSights).toHaveBeenCalledWith(appParams.vehicleType); + expectPropsOnChildMock(PhotoCapture, { + sights: getSights(VehicleType.SUV), + }); + + unmount(); + }); +}); diff --git a/apps/demo-app/tsconfig.build.json b/apps/demo-app/tsconfig.build.json new file mode 100644 index 000000000..73e2cff13 --- /dev/null +++ b/apps/demo-app/tsconfig.build.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": ["test"] +} diff --git a/apps/monk-test-app/tsconfig.json b/apps/demo-app/tsconfig.json similarity index 50% rename from apps/monk-test-app/tsconfig.json rename to apps/demo-app/tsconfig.json index d875bca91..60b0893a5 100644 --- a/apps/monk-test-app/tsconfig.json +++ b/apps/demo-app/tsconfig.json @@ -1,5 +1,4 @@ { "extends": "@monkvision/typescript-config/tsconfig.react.json", - "include": ["src"], - "exclude": ["node_modules", "**/*.spec.ts"] + "include": ["src", "test"] } diff --git a/apps/monk-test-app/README.md b/apps/monk-test-app/README.md deleted file mode 100644 index 8664c8b65..000000000 --- a/apps/monk-test-app/README.md +++ /dev/null @@ -1 +0,0 @@ -# Monk Test App diff --git a/apps/monk-test-app/jest.config.js b/apps/monk-test-app/jest.config.js deleted file mode 100644 index 96473b904..000000000 --- a/apps/monk-test-app/jest.config.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/test/**/*.test.ts'], - coverageDirectory: 'coverage', - coverageReporters: ['lcov'], -}; diff --git a/apps/monk-test-app/public/favicon.ico b/apps/monk-test-app/public/favicon.ico deleted file mode 100644 index a11777cc471a4344702741ab1c8a588998b1311a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3870 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 baj7?x%VD!5Cxk2YbYLNVeiXvvpMCWYo=by@ diff --git a/apps/monk-test-app/public/index.html b/apps/monk-test-app/public/index.html deleted file mode 100644 index aa069f27c..000000000 --- a/apps/monk-test-app/public/index.html +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - React App - - - -
- - - diff --git a/apps/monk-test-app/public/logo192.png b/apps/monk-test-app/public/logo192.png deleted file mode 100644 index fc44b0a3796c0e0a64c3d858ca038bd4570465d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5347 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 b{|{}U^kRn2wo}j7|0ATu<;8xA7zX}7|B6mN diff --git a/apps/monk-test-app/src/index.tsx b/apps/monk-test-app/src/index.tsx deleted file mode 100644 index cae5785b9..000000000 --- a/apps/monk-test-app/src/index.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { DebugMonitoringAdapter, MonitoringProvider } from '@monkvision/monitoring'; -import React, { StrictMode } from 'react'; -import ReactDOM from 'react-dom'; - -import './i18n'; -import './index.css'; -// import { sentryMonitoringAdapter } from './sentry'; -import { App } from './views'; - -ReactDOM.render( - - - - - , - document.getElementById('root'), -); diff --git a/apps/monk-test-app/src/translations/de.json b/apps/monk-test-app/src/translations/de.json deleted file mode 100644 index 0967ef424..000000000 --- a/apps/monk-test-app/src/translations/de.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/monk-test-app/src/translations/en.json b/apps/monk-test-app/src/translations/en.json deleted file mode 100644 index 0967ef424..000000000 --- a/apps/monk-test-app/src/translations/en.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/monk-test-app/src/translations/fr.json b/apps/monk-test-app/src/translations/fr.json deleted file mode 100644 index 0967ef424..000000000 --- a/apps/monk-test-app/src/translations/fr.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/monk-test-app/src/views/App.tsx b/apps/monk-test-app/src/views/App.tsx deleted file mode 100644 index 9695a1547..000000000 --- a/apps/monk-test-app/src/views/App.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { i18nCamera } from '@monkvision/camera-web'; -import { useI18nLink } from '@monkvision/common'; -import React from 'react'; -import { useTranslation } from 'react-i18next'; -// import { CameraView } from './CameraView'; -import { TestView } from './TestView'; - -export function App() { - const { i18n } = useTranslation(); - useI18nLink(i18n, [i18nCamera]); - - return ; -} diff --git a/apps/monk-test-app/src/views/CameraView/CameraView.css b/apps/monk-test-app/src/views/CameraView/CameraView.css deleted file mode 100644 index b0a181055..000000000 --- a/apps/monk-test-app/src/views/CameraView/CameraView.css +++ /dev/null @@ -1,8 +0,0 @@ -#root { - height: 100%; -} - -.camera-view-container { - height: 100%; - width: 100%; -} diff --git a/apps/monk-test-app/src/views/CameraView/CameraView.tsx b/apps/monk-test-app/src/views/CameraView/CameraView.tsx deleted file mode 100644 index e6cf99eb9..000000000 --- a/apps/monk-test-app/src/views/CameraView/CameraView.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { - Camera, - CameraResolution, - CompressionFormat, - MonkPicture, - SimpleCameraHUD, -} from '@monkvision/camera-web'; -import React, { useState } from 'react'; -import './CameraView.css'; -// import { LastPictureDetails, TestPanel } from './components'; - -export function CameraView() { - const [state] = useState({ - resolution: CameraResolution.UHD_4K, - compressionFormat: CompressionFormat.JPEG, - quality: '0.8', - }); - // const [lastPicture, setLastPicture] = useState(null); - const handlePictureTaken = (picture: MonkPicture) => { - console.log('Picture Taken :', picture); - // setLastPicture({ picture, state }); - }; - - return ( -
- - {/* */} -
- ); -} diff --git a/apps/monk-test-app/src/views/CameraView/components/TestPanel.tsx b/apps/monk-test-app/src/views/CameraView/components/TestPanel.tsx deleted file mode 100644 index 2ceb5e7a8..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/TestPanel.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { Button } from '@monkvision/common-ui-web'; -import React, { useEffect, useState } from 'react'; -import { useTestPanelStyle } from '../hooks'; -import './styles.css'; -import { TestPanelLastPic, TestPanelLastPicProps } from './TestPanelLastPic'; -import { TestPanelSettings, TestPanelSettingsProps } from './TestPanelSettings'; - -export interface TestPanelProps extends TestPanelSettingsProps, TestPanelLastPicProps {} - -export function TestPanel({ state, onChange, lastPicture }: TestPanelProps) { - const { panel } = useTestPanelStyle(); - const [showTestPanel, setShowTestPanel] = useState(false); - const [showSettings, setShowSettings] = useState(false); - useEffect(() => { - setShowTestPanel(true); - }, [lastPicture]); - - return ( -
- {showTestPanel ? ( -
-
-
- {showSettings ? ( - - ) : ( - - )} -
- ) : ( -
- ); -} diff --git a/apps/monk-test-app/src/views/CameraView/components/TestPanelLastPic.tsx b/apps/monk-test-app/src/views/CameraView/components/TestPanelLastPic.tsx deleted file mode 100644 index 59308bbe5..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/TestPanelLastPic.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { MonkPicture } from '@monkvision/camera-web'; -import { Button } from '@monkvision/common-ui-web'; -import axios from 'axios'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { format } from '../utils'; -import './styles.css'; -import { TestPanelRow } from './TestPanelRow'; -import { TestPanelState } from './TestPanelSettings'; - -export interface LastPictureDetails { - picture: MonkPicture; - state: TestPanelState; -} - -export interface TestPanelLastPicProps { - lastPicture: LastPictureDetails | null; -} - -function createFileName(lastPicture: LastPictureDetails): string { - const date = new Date(); - const timestamp = `${date.getHours()}${date.getMinutes()}${date.getSeconds()}`; - const quality = `${lastPicture.state.resolution}_${lastPicture.state.quality}`; - const extension = lastPicture.picture.mimetype.split('/')[1]; - return `pic_${quality}_${timestamp}.${extension}`; -} - -export function TestPanelLastPic({ lastPicture }: TestPanelLastPicProps) { - const anchorRef = useRef(null); - const [downloadLink, setDownloadLink] = useState(null); - - const requestedResolution = useMemo(() => lastPicture?.state.resolution ?? null, [lastPicture]); - const outputResolution = useMemo( - () => (lastPicture ? `${lastPicture.picture.width}x${lastPicture.picture.height}` : null), - [lastPicture], - ); - const compression = useMemo( - () => - lastPicture - ? `${format(lastPicture.state.compressionFormat)} (${lastPicture.state.quality})` - : null, - [lastPicture], - ); - - useEffect(() => { - if (lastPicture) { - axios - .get(lastPicture.picture.uri, { responseType: 'blob' }) - .then((res) => { - if (anchorRef.current) { - const link = URL.createObjectURL(res.data); - setDownloadLink(link); - anchorRef.current.href = link; - anchorRef.current.download = createFileName(lastPicture); - } - }) - .catch(console.error); - } - }, [lastPicture]); - - return ( - <> - - - -
- - ); -} diff --git a/apps/monk-test-app/src/views/CameraView/components/TestPanelRow.tsx b/apps/monk-test-app/src/views/CameraView/components/TestPanelRow.tsx deleted file mode 100644 index d1cade26d..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/TestPanelRow.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import { useTestPanelStyle } from '../hooks'; -import { format, TestOptionType } from '../utils'; -import './styles.css'; - -export interface TestPanelRowCommonProps { - label: string; -} - -export interface TestPanelRowValueProps extends TestPanelRowCommonProps { - type: 'value'; - value: TestOptionType | null; -} - -export interface TestPanelRowSelectProps extends TestPanelRowCommonProps { - type: 'select'; - availableValues: T[]; - defaultValue: T; - onChange: (value: T) => void; -} - -export type TestPanelRowProps = - | TestPanelRowValueProps - | TestPanelRowSelectProps; - -export function TestPanelRow(props: TestPanelRowProps) { - const { col, colNoValue } = useTestPanelStyle(); - return ( -
-
{props.label}
-
- {props.type === 'value' ? ( - format(props.value ?? 'No Value') - ) : ( - - )} -
-
- ); -} diff --git a/apps/monk-test-app/src/views/CameraView/components/TestPanelSettings.tsx b/apps/monk-test-app/src/views/CameraView/components/TestPanelSettings.tsx deleted file mode 100644 index 9d86990d7..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/TestPanelSettings.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { CameraFacingMode, CameraResolution, CompressionFormat } from '@monkvision/camera-web'; -import React from 'react'; -import './styles.css'; -import { TestPanelRow } from './TestPanelRow'; - -export interface TestPanelState { - facingMode: CameraFacingMode; - resolution: CameraResolution; - compressionFormat: CompressionFormat; - quality: string; -} - -export interface TestPanelSettingsProps { - state: TestPanelState; - onChange: (state: TestPanelState) => void; -} - -export function TestPanelSettings({ state, onChange }: TestPanelSettingsProps) { - const handleChange = (modifiedState: Partial) => - onChange({ ...state, ...modifiedState }); - - return ( - <> - handleChange({ facingMode: value })} - /> - handleChange({ resolution: value })} - /> - handleChange({ compressionFormat: value })} - /> - (((i + 1) * 2) / 10).toString()), - ]} - defaultValue={state.quality} - onChange={(value) => handleChange({ quality: value })} - /> - - ); -} diff --git a/apps/monk-test-app/src/views/CameraView/components/index.ts b/apps/monk-test-app/src/views/CameraView/components/index.ts deleted file mode 100644 index 3428374e9..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './TestPanel'; -export * from './TestPanelLastPic'; -export * from './TestPanelSettings'; -export * from './TestPanelRow'; diff --git a/apps/monk-test-app/src/views/CameraView/components/styles.css b/apps/monk-test-app/src/views/CameraView/components/styles.css deleted file mode 100644 index 9a23322c5..000000000 --- a/apps/monk-test-app/src/views/CameraView/components/styles.css +++ /dev/null @@ -1,72 +0,0 @@ -.start { - justify-content: flex-start; -} - -.center { - justify-content: center; -} - -.space-between { - justify-content: space-between; -} - -.end { - justify-content: flex-end; -} - -.panel-container { - z-index: 9999; - position: absolute; - bottom: 50px; - left: 50px; -} - -.panel { - border-style: solid; - border-width: 2px; - border-radius: 25px; - padding: 5px 20px 20px 20px; - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - opacity: 0.8; -} - -.row { - min-width: 100%; - display: flex; - align-items: center; -} - -.header-item { - padding: 10px !important; -} - -.col { - min-width: 50%; - padding: 10px; - display: flex; - align-items: center; - font-size: 12px; -} - -.test-col.start { - justify-content: flex-start; -} - -.test-col.end { - justify-content: flex-end; -} - -.hidden-download { - display: none; -} - -@media screen and (orientation: portrait) { - .panel-container { - bottom: auto; - top: 50px; - left: 50px; - } -} diff --git a/apps/monk-test-app/src/views/CameraView/hooks/index.ts b/apps/monk-test-app/src/views/CameraView/hooks/index.ts deleted file mode 100644 index 6bb2cc0b4..000000000 --- a/apps/monk-test-app/src/views/CameraView/hooks/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './useTestPanelStyle'; diff --git a/apps/monk-test-app/src/views/CameraView/hooks/useTestPanelStyle.ts b/apps/monk-test-app/src/views/CameraView/hooks/useTestPanelStyle.ts deleted file mode 100644 index 9997a9192..000000000 --- a/apps/monk-test-app/src/views/CameraView/hooks/useTestPanelStyle.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { useMonkTheme } from '@monkvision/common'; -import { useMemo } from 'react'; - -export function useTestPanelStyle() { - const { palette } = useMonkTheme(); - return useMemo( - () => ({ - panel: { - backgroundColor: palette.surface.s1, - borderColor: palette.primary.xlight, - color: palette.primary.xlight, - }, - col: { - color: palette.text.white, - }, - colNoValue: { - color: palette.text.disable, - fontStyle: 'italic', - }, - }), - [palette], - ); -} diff --git a/apps/monk-test-app/src/views/CameraView/index.ts b/apps/monk-test-app/src/views/CameraView/index.ts deleted file mode 100644 index 956c77cf6..000000000 --- a/apps/monk-test-app/src/views/CameraView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './CameraView'; diff --git a/apps/monk-test-app/src/views/CameraView/utils.ts b/apps/monk-test-app/src/views/CameraView/utils.ts deleted file mode 100644 index 91735ba87..000000000 --- a/apps/monk-test-app/src/views/CameraView/utils.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { CameraFacingMode, CameraResolution, CompressionFormat } from '@monkvision/camera-web'; - -export type TestOptionType = CameraFacingMode | CameraResolution | CompressionFormat | string; - -function formatLabel(value: string | TestOptionType): string { - switch (value) { - case CameraFacingMode.USER: - return 'Front'; - case CameraFacingMode.ENVIRONMENT: - return 'Back'; - case CameraResolution.QNHD_180P: - return '180p (320x180)'; - case CameraResolution.NHD_360P: - return '360p (640x360)'; - case CameraResolution.HD_720P: - return '720p (1280x720)'; - case CameraResolution.FHD_1080P: - return '1080p (1920x1080)'; - case CameraResolution.QHD_2K: - return '2K (2560x1440)'; - case CameraResolution.UHD_4K: - return '4K (3840x2160)'; - case CompressionFormat.JPEG: - return 'JPEG'; - default: - return value; - } -} - -export function format(value: string | TestOptionType): string; -export function format(value: TestOptionType[] | Record): string[]; -export function format( - value: string | TestOptionType | TestOptionType[] | Record, -): string | string[] { - if (Array.isArray(value)) { - return value.map((v) => formatLabel(v)); - } - if (typeof value === 'object') { - return Object.values(value).map((v) => formatLabel(v)); - } - return formatLabel(value); -} diff --git a/apps/monk-test-app/src/views/TestView/TestView.css b/apps/monk-test-app/src/views/TestView/TestView.css deleted file mode 100644 index ac2ec2d04..000000000 --- a/apps/monk-test-app/src/views/TestView/TestView.css +++ /dev/null @@ -1,16 +0,0 @@ -.test-view-container { - height: 100%; - width: 100%; - background-color: black; - display: flex; - align-items: center; - justify-content: center; - color: white; -} - -.select-container { - position: fixed; - top: 50px; - left: 50px; - z-index: 9999; -} diff --git a/apps/monk-test-app/src/views/TestView/TestView.tsx b/apps/monk-test-app/src/views/TestView/TestView.tsx deleted file mode 100644 index 9a8740219..000000000 --- a/apps/monk-test-app/src/views/TestView/TestView.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Camera, CameraResolution, MonkPicture, SimpleCameraHUD } from '@monkvision/camera-web'; -import './TestView.css'; -import { useState } from 'react'; - -export function TestView() { - const [resolution, setResolution] = useState(CameraResolution.UHD_4K); - - const handlePictureTaken = (picture: MonkPicture) => { - const link = document.createElement('a'); - link.href = picture.uri; - const now = new Date(); - link.download = `pic-${resolution}-${now.getHours()}-${now.getMinutes()}-${now.getSeconds()}.jpg`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; - - return ( -
- -
- -
-
- ); -} diff --git a/apps/monk-test-app/src/views/TestView/index.ts b/apps/monk-test-app/src/views/TestView/index.ts deleted file mode 100644 index a441237d4..000000000 --- a/apps/monk-test-app/src/views/TestView/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './TestView'; diff --git a/apps/monk-test-app/src/views/index.ts b/apps/monk-test-app/src/views/index.ts deleted file mode 100644 index ac7ba3b3a..000000000 --- a/apps/monk-test-app/src/views/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './App'; diff --git a/configs/jest-config/react.js b/configs/jest-config/react.js index b4dbefb1a..f2dfc636c 100644 --- a/configs/jest-config/react.js +++ b/configs/jest-config/react.js @@ -5,7 +5,7 @@ module.exports = { testEnvironment: 'jsdom', testMatch: ['**/test/**/*.test.{ts,tsx}'], moduleNameMapper:{ - '\\.(css|less|sass|scss)$': '@monkvision/test-utils/src/__mocks__/imports/style.mock', - '\\.(gif|ttf|eot|svg)$': '@monkvision/test-utils/src/__mocks__/imports/file.mock' + '\\.(css|less|sass|scss)$': '@monkvision/test-utils/src/__mocks__/imports/style', + '\\.(gif|ttf|eot|svg)$': '@monkvision/test-utils/src/__mocks__/imports/file' }, }; diff --git a/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts b/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts new file mode 100644 index 000000000..3cae079dd --- /dev/null +++ b/configs/test-utils/src/__mocks__/@auth0/auth0-react.ts @@ -0,0 +1,8 @@ +export = { + /* Actual exports */ + /* Mocks */ + useAuth0: jest.fn(() => ({ + getAccessTokenWithPopup: jest.fn(() => Promise.resolve('')), + logout: jest.fn(), + })), +}; diff --git a/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx b/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx index f1e3809e9..64e152e30 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common-ui-web.tsx @@ -5,11 +5,16 @@ export = { iconNames, /* Mocks */ - Icon: jest.fn(() => <>), + BackdropDialog: jest.fn(() => <>), Button: jest.fn(() => <>), DynamicSVG: jest.fn(() => <>), - SVGElement: jest.fn(() => <>), + FullscreenImageModal: jest.fn(() => <>), + FullscreenModal: jest.fn(() => <>), + Icon: jest.fn(() => <>), SightOverlay: jest.fn(() => <>), + Slider: jest.fn(() => <>), Spinner: jest.fn(() => <>), + SVGElement: jest.fn(() => <>), + SwitchButton: jest.fn(() => <>), TakePictureButton: jest.fn(() => <>), }; diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index 6f625bd6c..ebb7fca39 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -13,8 +13,6 @@ const { shadeColor, InteractiveVariation, getInteractiveVariants, - zlibCompress, - zlibDecompress, MonkDefaultPalette, createTheme, createEmptyMonkState, @@ -22,6 +20,7 @@ const { getFileExtensions, uniq, flatMap, + STORAGE_KEY_AUTH_TOKEN, } = jest.requireActual('@monkvision/common'); export = { @@ -38,14 +37,13 @@ export = { shadeColor, InteractiveVariation, getInteractiveVariants, - zlibCompress, - zlibDecompress, MonkDefaultPalette, createTheme, MonkActionType, getFileExtensions, uniq, flatMap, + STORAGE_KEY_AUTH_TOKEN, /* Mocks */ useMonkTheme: jest.fn(() => createTheme()), @@ -91,4 +89,15 @@ export = { })), useLangProp: jest.fn(), isMobileDevice: jest.fn(() => false), + zlibCompress: jest.fn(() => ''), + zlibDecompress: jest.fn(() => ''), + useMonkAppParams: jest.fn(() => ({ + authToken: null, + inspectionId: null, + vehicleType: null, + setAuthToken: jest.fn(), + setInspectionId: jest.fn(), + setVehicleType: jest.fn(), + })), + getEnvOrThrow: jest.fn((name) => name), }; diff --git a/configs/test-utils/src/__mocks__/@monkvision/network.ts b/configs/test-utils/src/__mocks__/@monkvision/network.ts index facbbfe87..8b7cfa39d 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/network.ts +++ b/configs/test-utils/src/__mocks__/@monkvision/network.ts @@ -2,6 +2,7 @@ const { MonkApiPermission, MonkNetworkError } = jest.requireActual('@monkvision/ const MonkApi = { getInspection: jest.fn(() => Promise.resolve()), + createInspection: jest.fn(() => Promise.resolve()), addImage: jest.fn(() => Promise.resolve()), updateTaskStatus: jest.fn(() => Promise.resolve()), startInspectionTasks: jest.fn(() => Promise.resolve()), @@ -14,6 +15,13 @@ export = { /* Mocks */ decodeMonkJwt: jest.fn((str) => str), + isUserAuthorized: jest.fn(() => true), + isTokenExpired: jest.fn(() => false), + useAuth: jest.fn(() => ({ + authToken: null, + login: jest.fn(() => Promise.resolve('')), + logout: jest.fn(() => Promise.resolve()), + })), MonkApi, useMonkApi: jest.fn(() => MonkApi), }; diff --git a/configs/test-utils/src/__mocks__/imports/file.ts b/configs/test-utils/src/__mocks__/imports/file.ts index 86059f362..01a91cbf7 100644 --- a/configs/test-utils/src/__mocks__/imports/file.ts +++ b/configs/test-utils/src/__mocks__/imports/file.ts @@ -1 +1,2 @@ +export {}; module.exports = 'test-file-stub'; diff --git a/configs/test-utils/src/__mocks__/imports/style.ts b/configs/test-utils/src/__mocks__/imports/style.ts index f053ebf79..739457a9b 100644 --- a/configs/test-utils/src/__mocks__/imports/style.ts +++ b/configs/test-utils/src/__mocks__/imports/style.ts @@ -1 +1,2 @@ +export {}; module.exports = {}; diff --git a/configs/test-utils/src/__mocks__/react-router-dom.tsx b/configs/test-utils/src/__mocks__/react-router-dom.tsx new file mode 100644 index 000000000..1ab99c4d4 --- /dev/null +++ b/configs/test-utils/src/__mocks__/react-router-dom.tsx @@ -0,0 +1,7 @@ +export = { + /* Actual exports */ + /* Mocks */ + Navigate: jest.fn(() => <>), + useSearchParams: jest.fn(() => [{ get: jest.fn() }]), + useNavigate: jest.fn(() => jest.fn()), +}; diff --git a/configs/typescript-config/types.d.ts b/configs/typescript-config/types.d.ts index d59fccad5..dbe9f9463 100644 --- a/configs/typescript-config/types.d.ts +++ b/configs/typescript-config/types.d.ts @@ -4,3 +4,31 @@ declare module '*.svg' { const src: string; export default src; } + +// Network Information API +declare interface Navigator extends NavigatorNetworkInformation {} +declare interface WorkerNavigator extends NavigatorNetworkInformation {} +declare interface NavigatorNetworkInformation { + readonly connection?: NetworkInformation; +} +type Megabit = number; +type Millisecond = number; +type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g'; +type ConnectionType = + | 'bluetooth' + | 'cellular' + | 'ethernet' + | 'mixed' + | 'none' + | 'other' + | 'unknown' + | 'wifi' + | 'wimax'; +interface NetworkInformation extends EventTarget { + readonly type?: ConnectionType; + readonly effectiveType?: EffectiveConnectionType; + readonly downlinkMax?: Megabit; + readonly downlink?: Megabit; + readonly rtt?: Millisecond; + readonly saveData?: boolean; +} diff --git a/documentation/package.json b/documentation/package.json index a58455028..2ee71f419 100644 --- a/documentation/package.json +++ b/documentation/package.json @@ -26,7 +26,8 @@ "clsx": "^1.2.1", "prism-react-renderer": "^1.3.5", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@docusaurus/module-type-aliases": "2.4.3", diff --git a/package.json b/package.json index b4be0d730..e3aa99d11 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "test:coverage": "lerna run test:coverage --parallel", "lint": "lerna run --parallel lint", "lint:fix": "lerna run --parallel lint:fix", + "ci": "yarn && yarn build && yarn test && yarn lint", "svgo": "lerna run svgo --parallel" }, "dependencies": { diff --git a/packages/camera-web/package.json b/packages/camera-web/package.json index fb708a56e..a8984034d 100644 --- a/packages/camera-web/package.json +++ b/packages/camera-web/package.json @@ -34,7 +34,8 @@ }, "peerDependencies": { "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@monkvision/eslint-config-base": "4.0.0", diff --git a/packages/camera-web/src/Camera/Camera.tsx b/packages/camera-web/src/Camera/Camera.tsx index 4e97d2121..7f1d69901 100644 --- a/packages/camera-web/src/Camera/Camera.tsx +++ b/packages/camera-web/src/Camera/Camera.tsx @@ -61,14 +61,14 @@ export type CameraProps = Partial & * Note: If the specified resolution is higher than the best resolution available on the current device, output * pictures will only be scaled up to the specified resolution if the `allowImageUpscaling` property is set to `true`. * - * @default `CameraResolution.UHD_4K` + * @default CameraResolution.UHD_4K */ resolution?: CameraResolution; /** * When the native resolution of the device Camera is smaller than the resolution asked in the `resolution` prop, * resulting pictures will only be scaled up if this property is set to `true`. * - * @default `false` + * @default false */ allowImageUpscaling?: boolean; /** diff --git a/packages/camera-web/src/Camera/CameraHUD.types.ts b/packages/camera-web/src/Camera/CameraHUD.types.ts index f36bc7769..e62fe3984 100644 --- a/packages/camera-web/src/Camera/CameraHUD.types.ts +++ b/packages/camera-web/src/Camera/CameraHUD.types.ts @@ -59,6 +59,6 @@ export interface CameraHUDProps { /** * Component type definition for a Camera HUD component. */ -export type CameraHUDComponent> = ComponentType< +export type CameraHUDComponent> = ComponentType< CameraHUDProps & T >; diff --git a/packages/common-ui-web/README.md b/packages/common-ui-web/README.md index 86017b09f..8c7e82f03 100644 --- a/packages/common-ui-web/README.md +++ b/packages/common-ui-web/README.md @@ -82,15 +82,15 @@ function App() { ``` ### Props -| Prop | Type | Description | Required | Default Value | -|------------------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------------------------------------------------------------------------| -| variant | ButtonVariant | Variant describing the look of the button (outlined, fill...) | | `'fill'` | -| size | ButtonSize | Prop describing the size of the button. | | `'normal'` | -| primaryColor | ColorProp | The primary color of the button. For filled buttons, it corresponds to the background color, for other buttons, it corresponds to the text color. | | `'primary-xlight'` for outline buttons and `'primary'` for other buttons | -| secondaryColor | ColorProp | The secondary color of the button. For filled buttons, it corresponds to the text color and for outline buttons, it corresponds to the background color. This property is ignored for text and text-link buttons. | | `'text-white'` for filled buttons and `'surface-s1'` for outline buttons | -| icon | IconName | The icon to place on the left of the button text. No icon will be placed if not provided. | | | -| loading | boolean | Boolean indicating if the button is loading. When the button is loading, it is automatically disabled and its content is replaced by a spinner. | | | -| preserveWidthOnLoading | boolean | Boolean indicating if the button should keep its original width when loading. | | `false` | +| Prop | Type | Description | Required | Default Value | +|------------------------|------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------|--------------------------------------------------------------------------| +| variant | ButtonVariant | Variant describing the look of the button (outlined, fill...) | | `'fill'` | +| size | ButtonSize | Prop describing the size of the button. | | `'normal'` | +| primaryColor | ColorProp | The primary color of the button. For filled buttons, it corresponds to the background color, for other buttons, it corresponds to the text color. | | `'primary-xlight'` for outline buttons and `'primary'` for other buttons | +| secondaryColor | ColorProp | The secondary color of the button. For filled buttons, it corresponds to the text color and for outline buttons, it corresponds to the background color. This property is ignored for text and text-link buttons. | | `'text-white'` for filled buttons and `'surface-s1'` for outline buttons | +| icon | IconName | The icon to place on the left of the button text. No icon will be placed if not provided. | | | +| loading | boolean | LoadingState | This prop specifies if the Button is loading. A loading button is automatically disabled and its content is replaced by a spinner. | | | +| preserveWidthOnLoading | boolean | Boolean indicating if the button should keep its original width when loading. | | `false` | --- diff --git a/packages/common-ui-web/package.json b/packages/common-ui-web/package.json index 9cf4f80a7..10b94fa9d 100644 --- a/packages/common-ui-web/package.json +++ b/packages/common-ui-web/package.json @@ -30,13 +30,15 @@ }, "peerDependencies": { "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@babel/core": "^7.22.9", "@monkvision/eslint-config-base": "4.0.0", "@monkvision/eslint-config-typescript": "4.0.0", "@monkvision/eslint-config-typescript-react": "4.0.0", + "@monkvision/jest-config": "4.0.0", "@monkvision/prettier-config": "4.0.0", "@monkvision/typescript-config": "4.0.0", "@testing-library/dom": "^8.20.0", diff --git a/packages/common-ui-web/src/components/Button/Button.tsx b/packages/common-ui-web/src/components/Button/Button.tsx index 725bd22b0..7e3767c09 100644 --- a/packages/common-ui-web/src/components/Button/Button.tsx +++ b/packages/common-ui-web/src/components/Button/Button.tsx @@ -39,7 +39,8 @@ export const Button = forwardRef { - const isDisabled = !!disabled || !!loading; + const isLoading = typeof loading === 'object' ? loading.isLoading : loading ?? false; + const isDisabled = !!disabled || isLoading; const handleMouseDown = (event: MouseEvent) => { event.preventDefault(); if (onMouseDown) { @@ -60,7 +61,7 @@ export const Button = forwardRef - {loading ? loadingContent : content} + {isLoading ? loadingContent : content} ); }, diff --git a/packages/common-ui-web/src/components/Button/hooks.ts b/packages/common-ui-web/src/components/Button/hooks.ts index 3e8b8e6fd..767542ee3 100644 --- a/packages/common-ui-web/src/components/Button/hooks.ts +++ b/packages/common-ui-web/src/components/Button/hooks.ts @@ -1,4 +1,9 @@ -import { getInteractiveVariants, InteractiveVariation, useMonkTheme } from '@monkvision/common'; +import { + getInteractiveVariants, + InteractiveVariation, + LoadingState, + useMonkTheme, +} from '@monkvision/common'; import { Color, ColorProp, @@ -37,27 +42,27 @@ export interface MonkButtonProps { /** * The variant of the button. * - * @default fill + * @default 'fill' */ variant?: ButtonVariant; /** * The size of the button. * - * @default normal + * @default 'normal' */ size?: ButtonSize; /** * The primary color of the button. For filled buttons, it corresponds to the background color, for other buttons, it * corresponds to the text color. * - * @default primary-xlight for outline buttons and primary for other buttons + * @default 'primary-xlight' for outline buttons and 'primary' for other buttons */ primaryColor?: ColorProp; /** * The secondary color of the button. For filled buttons, it corresponds to the text color and for outline buttons, it * corresponds to the background color. This property is ignored for text and text-link buttons. * - * @default text-white for filled buttons and surface-s1 for outline buttons. + * @default 'text-white' for filled buttons and 'surface-s1' for outline buttons. */ secondaryColor?: ColorProp; /** @@ -65,10 +70,13 @@ export interface MonkButtonProps { */ icon?: IconName; /** - * Boolean indicating if the button is loading. When the button is loading, it is automatically disabled and its - * content is replaced by a spinner. + * This prop specifies if the Button is loading. A loading button is automatically disabled and its content is + * replaced by a spinner. This prop can either be a simple boolean indicating if the button is loading or not, or a + * `LoadingState` object created using the `useLoadingState` hook. + * + * @see useLoadingState */ - loading?: boolean; + loading?: boolean | LoadingState; /** * Boolean indicating if the button should keep its original width when loading. Set this property to `true` if you * want a button with a `width: fit-content` that keeps its content width when the content is replaced by a spinner. @@ -83,7 +91,7 @@ export interface MonkButtonProps { /** * Value indicating if the button is a light color button or a dark color button (used for interactive colors). * - * @default: dark + * @default: 'dark' */ shade?: ButtonShade; } diff --git a/packages/common-ui-web/src/components/Spinner/Spinner.tsx b/packages/common-ui-web/src/components/Spinner/Spinner.tsx index 48207506f..bee1587f0 100644 --- a/packages/common-ui-web/src/components/Spinner/Spinner.tsx +++ b/packages/common-ui-web/src/components/Spinner/Spinner.tsx @@ -16,7 +16,7 @@ export interface SpinnerProps extends SVGProps { /** * The name or hexcode of the spinner's color. * - * @default text-white + * @default 'text-white' */ primaryColor?: ColorProp; } diff --git a/packages/common-ui-web/src/components/SwitchButton/hooks.ts b/packages/common-ui-web/src/components/SwitchButton/hooks.ts index f1eae1cf6..ebca9a89f 100644 --- a/packages/common-ui-web/src/components/SwitchButton/hooks.ts +++ b/packages/common-ui-web/src/components/SwitchButton/hooks.ts @@ -16,7 +16,7 @@ export interface SwitchButtonProps { * The size of the button. Normal buttons are bigger and have their icon and labels inside the button. Small buttons * are smaller, accept no label and have their icon inside the knob. * - * @default normal + * @default 'normal' */ size?: SwitchButtonSize; /** diff --git a/packages/common-ui-web/src/icons/Icon.tsx b/packages/common-ui-web/src/icons/Icon.tsx index 258f53209..ea27855c6 100644 --- a/packages/common-ui-web/src/icons/Icon.tsx +++ b/packages/common-ui-web/src/icons/Icon.tsx @@ -25,7 +25,7 @@ export interface IconProps extends Omit, 'width' | 'heig /** * The name or the hexcode of the color to apply to the icon. * - * @default black + * @default '#000000' */ primaryColor?: ColorProp; } diff --git a/packages/common-ui-web/test/components/Button/Button.test.tsx b/packages/common-ui-web/test/components/Button/Button.test.tsx index 151b1f8df..bb38ca3cf 100644 --- a/packages/common-ui-web/test/components/Button/Button.test.tsx +++ b/packages/common-ui-web/test/components/Button/Button.test.tsx @@ -5,7 +5,7 @@ mockButtonDependencies(); import '@testing-library/jest-dom'; import { createEvent, fireEvent, render, screen } from '@testing-library/react'; import { expectPropsOnChildMock, getNumberFromCSSProperty } from '@monkvision/test-utils'; -import { useInteractiveStatus } from '@monkvision/common'; +import { LoadingState, useInteractiveStatus } from '@monkvision/common'; import { Button, Spinner, Icon, IconProps } from '../../../src'; import { InteractiveStatus } from '@monkvision/types'; @@ -184,6 +184,19 @@ describe('Button component', () => { expect(screen.queryByTestId(testId)).toBeNull(); unmount(); }); + + it('should also be displayed when passed a LoadingState object', () => { + const testId = 'test-not-id'; + const loading = { isLoading: true } as unknown as LoadingState; + const { unmount } = render( + , + ); + expect(Spinner).toHaveBeenCalled(); + expect(screen.queryByTestId(testId)).toBeNull(); + unmount(); + }); }); describe('Button icon', () => { diff --git a/packages/common/README.md b/packages/common/README.md index e4353ca49..03eedee65 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -20,3 +20,4 @@ you can refer to their own README directly : - [Internationalization](README/INTERNATIONALIZATION.md). - [Hooks](README/HOOKS.md). - [Utilities](README/UTILITIES.md). +- [Application Utilities](README/APP_UTILS.md). diff --git a/packages/common/README/APP_UTILS.md b/packages/common/README/APP_UTILS.md new file mode 100644 index 000000000..f98de5b98 --- /dev/null +++ b/packages/common/README/APP_UTILS.md @@ -0,0 +1,152 @@ +# Application Utils +This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the +application utilities. You can refer to [this page](README.md) for more general information on the package. + +This package exports various custom hooks used throughout the MonkJs SDK. + +### useAsyncEffect +```tsx +import { useAsyncEffect } from '@monkvision/common'; + +function TestComponent() { + useAsyncEffect( + async () => { + const result = myCustomAsyncFunc(); + return result.value; + }, + [exampleDependency], + { + onResolve: (value) => { + console.log(value); + }, + onReject: (err) => { + console.error(err); + }, + onComplete: () => { + console.log('Done.'); + } + }, + ); +} +``` +Custom hook that can be used to run asyncrhonous effects. It is similar to `useEffect` but makes sure to not execute the +effect handlers if the effect's Promise resolves after the current component as been dismounted. + +### useInteractiveStatus +```tsx +import { useInteractiveStatus } from '@monkvision/common'; + +function TestComponent() { + const { status, events } = useInteractiveStatus(); + useEffect(() => console.log('Button status :', status), [status]); + + return ; +} +``` +This hook allows the tracking of the interactive status (hovered, active, disabled...) of a React element. It returns +the interactive status of the element, as well as a set of MouseEvent listeners used to update the status accordingly. + +### useLangProp +```tsx +import { useLangProp } from '@monkvision/common'; + +function TestComponent(props: { lang?: string }) { + useLangProp(lang); +} +``` +Custom hook used internally by the Monk SDK components to handle the `lang` prop tha tcan be passed to them to manage +the current language displayed by the component. + +### useLoadingState +```tsx +import { useEffect } from 'react'; +import { useLoadingState } from '@monkvision/common'; + +function useCustomApiCall() { + const loading = useLoadingState(); + useEffect(() => { + loading.start(); + myApiCall().then(() => loading.onSuccess()).catch((err) => loading.onError(err)); + }, []); +} +``` +Custom hook used to create a `LoadingState` object. This object can be used to track the processing of a task in the +component. For instance, you can use this hook to handle the loading and errors of API calls in your components. + +### useObjectTranslation +```tsx +import { useObjectTranslation } from '@monkvision/common'; + +const translationObject = { en: 'Hello', fr: 'Salut', de: 'Hallo' }; + +function TestComponent() { + const { tObj } = useObjectTranslation(); + return
{tObj(translationObject)}
; +} +``` +Custom hook used to get a translation function tObj that translates TranslationObjects. + +### useQueue +```tsx +import { useQueue } from '@monkvision/common'; + +function TestComponent() { + const queue = useQueue((item) => { + console.log(item); + return Promise.resolve(); + }); + ... +} +``` + +This hook is used to create a processing queue. The `process` function passed as a parameter is an async function +that is used to process items in the queue. You can find more details on how the queue works by taking a look at the +TSDoc of the `Queue` interface. + +### useResponsiveStyle +```tsx +import { useResponsiveStyle } from '@monkvision/common'; +import { Styles } from '@monkvision/types'; + +const styles: Styles = { + div: { + width: 100, + height: 100, + }, + divMobile: { + __media: { maxWidth: 500 }, + backgroundColor: '#ff0000', + }, +}; + +function TestComponent() { + const { responsive } = useResponsiveStyle(); + return
Hello
+} +``` +This hook returns takes a `ResponsiveStyleProperties` declarations object (see the definition of this type in the +`@monkvision/types` package for more details) containing a media query and returns either the CSSProperties contained in +the type, or `null` if the query conditions are not met. Note that if there are no query, the style will be applied. + +### useSightLabel +```tsx +import { sights } from '@monkvision/sights'; +import { useSightLabel } from '@monkvision/common'; + +function TestComponent() { + const { label } = useSightLabel(); + return
{label(sights['fesc20-0mJeXBDf'])}
; +} +``` +Custom hook used to get the label of a sight with the currently selected language. + +### useWindowDimensions +```tsx +import { useWindowDimensions } from '@monkvision/common'; + +function TestComponent() { + const { width, height, isPortrait } = useWindowDimensions(); +} +``` +This hook returns the current window dimensions in pixels, and a boolean indicating if the window is in portrait mode +(width < height) or not. diff --git a/packages/common/README/HOOKS.md b/packages/common/README/HOOKS.md index 361dfa50c..b34a067ba 100644 --- a/packages/common/README/HOOKS.md +++ b/packages/common/README/HOOKS.md @@ -1,152 +1,50 @@ # Hooks This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the -React hooks. You can refer to [this page](README.md). for more general information on the package. - -This package exports various custom hooks used throughout the MonkJs SDK. - -### useAsyncEffect -```tsx -import { useAsyncEffect } from '@monkvision/common'; - -function TestComponent() { - useAsyncEffect( - async () => { - const result = myCustomAsyncFunc(); - return result.value; - }, - [exampleDependency], - { - onResolve: (value) => { - console.log(value); - }, - onReject: (err) => { - console.error(err); - }, - onComplete: () => { - console.log('Done.'); - } - }, - ); -} -``` -Custom hook that can be used to run asyncrhonous effects. It is similar to `useEffect` but makes sure to not execute the -effect handlers if the effect's Promise resolves after the current component as been dismounted. - -### useInteractiveStatus -```tsx -import { useInteractiveStatus } from '@monkvision/common'; - -function TestComponent() { - const { status, events } = useInteractiveStatus(); - useEffect(() => console.log('Button status :', status), [status]); - - return ; -} -``` -This hook allows the tracking of the interactive status (hovered, active, disabled...) of a React element. It returns -the interactive status of the element, as well as a set of MouseEvent listeners used to update the status accordingly. - -### useLangProp -```tsx -import { useLangProp } from '@monkvision/common'; - -function TestComponent(props: { lang?: string }) { - useLangProp(lang); -} -``` -Custom hook used internally by the Monk SDK components to handle the `lang` prop tha tcan be passed to them to manage -the current language displayed by the component. - -### useLoadingState -```tsx -import { useEffect } from 'react'; -import { useLoadingState } from '@monkvision/common'; - -function useCustomApiCall() { - const loading = useLoadingState(); - useEffect(() => { - loading.start(); - myApiCall().then(() => loading.onSuccess()).catch((err) => loading.onError(err)); - }, []); -} -``` -Custom hook used to create a `LoadingState` object. This object can be used to track the processing of a task in the -component. For instance, you can use this hook to handle the loading and errors of API calls in your components. - -### useObjectTranslation -```tsx -import { useObjectTranslation } from '@monkvision/common'; - -const translationObject = { en: 'Hello', fr: 'Salut', de: 'Hallo' }; - -function TestComponent() { - const { tObj } = useObjectTranslation(); - return
{tObj(translationObject)}
; -} -``` -Custom hook used to get a translation function tObj that translates TranslationObjects. - -### useQueue -```tsx -import { useQueue } from '@monkvision/common'; - -function TestComponent() { - const queue = useQueue((item) => { - console.log(item); - return Promise.resolve(); - }); - ... -} -``` - -This hook is used to create a processing queue. The `process` function passed as a parameter is an async function -that is used to process items in the queue. You can find more details on how the queue works by taking a look at the -TSDoc of the `Queue` interface. - -### useResponsiveStyle -```tsx -import { useResponsiveStyle } from '@monkvision/common'; -import { Styles } from '@monkvision/types'; - -const styles: Styles = { - div: { - width: 100, - height: 100, - }, - divMobile: { - __media: { maxWidth: 500 }, - backgroundColor: '#ff0000', - }, -}; - -function TestComponent() { - const { responsive } = useResponsiveStyle(); - return
Hello
-} -``` -This hook returns takes a `ResponsiveStyleProperties` declarations object (see the definition of this type in the -`@monkvision/types` package for more details) containing a media query and returns either the CSSProperties contained in -the type, or `null` if the query conditions are not met. Note that if there are no query, the style will be applied. - -### useSightLabel -```tsx -import { sights } from '@monkvision/sights'; -import { useSightLabel } from '@monkvision/common'; - -function TestComponent() { - const { label } = useSightLabel(); - return
{label(sights['fesc20-0mJeXBDf'])}
; -} -``` -Custom hook used to get the label of a sight with the currently selected language. - -### useWindowDimensions -```tsx -import { useWindowDimensions } from '@monkvision/common'; - -function TestComponent() { - const { width, height, isPortrait } = useWindowDimensions(); -} -``` -This hook returns the current window dimensions in pixels, and a boolean indicating if the window is in portrait mode -(width < height) or not. +React hooks. You can refer to [this page](README.md) for more general information on the package. + +This package exports various utilities shared accross the different web and native applications integrating the Monk +workflow using the MonkJs SDK. + +# Monk App Params +Most of the time, applications integrating the Monk SDK will need the same basic parameters to function : +- An authentication token to make API requests +- An inspection ID to know which inspection it should capture or visualize the reports of +- A vehicle type to know which sights or car 360 wireframes to use in the capture screen or in the inspection report +- A preferred language to use instead of the default English + +This package export some utilies that can be used to more easily handle the state management and the initial values of +these app parameters. + +## MonkAppParamsProvider component +The `MonkAppParamsProvider` component is a React context provider that declares the application parameters described +above. It declares a `MonkAppParamsContext` that contains the current auhtentication token, inspection ID and vehicle +type and setters functions to update them. Using some configuration props, this context provider also initializes the +parameters with values that can be fetched from the URL search parameters or the local storage : + +- If the `fetchFromSearchParams` prop is set to `true`, during the first render of the component, it will look in the + URL for search parameters described in the `MonkSearchParams` enum in order to update the app params accordingly. + - Auth tokens need be compressed using ZLib (ex: using the `zlibCompress` utility function available in this + package) and properly URL-encoded before being passed as a URL param. No Monk app should ever use auth tokens + obtained from URL search params without compression and encoding. + - If `fetchTokenFromStorage` is also set to `true`, the token fetched from the search params will always be + used in priority over the one fetched from the local storage. +- If the `fetchTokenFromStorage` prop is set to `true`, during the first render of the MonkAppParamsProvider component, + it will look in the local storage for a valid authentication token to use. + - The storage key used to read / write the auth token in the local storage is the same throughout the Monk SDK and + is defined in the `STORAGE_KEY_AUTH_TOKEN` variable exported by this package. + - If `fetchFromSearchParams` is also set to `true`, the token fetched from the search params will always be + used in priority over the one fetched from the local storage. + +| Prop | Type | Description | Required | Default Value | +|-----------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------|----------|---------------| +| fetchFromSearchParams | boolean | Boolean indicating if the app params should be initialized using values from the URL search params. | | `true` | +| fetchTokenFromStorage | boolean | Boolean indicating if the auth token should be initialized using the value from the URL search params. | | `true` | +| updateLanguage | boolean | Boolean indicating if the app language should be updated using the value from the URL search params. | | `true` | +| onFetchAuthToken | boolean | Callback called when an authentication token has successfully been fetched from either the local storage, or the URL search params. | | | + +## useMonkAppParams hook +This hook simply returns the current value of the `MonkAppParamsContext` declared by the `MonkAppParamsProvider` +component. It means that it can only be called within the render function of a child of this component. This hook +accepts an optional parameter called `required` which specifies if the `authToken` and the `inspectionId` params are +required. If they are, this hook will guarantee that the return values for these params is not null (even in TypeScript +typings), but will throw an error if they are. diff --git a/packages/common/README/INTERNATIONALIZATION.md b/packages/common/README/INTERNATIONALIZATION.md index 277f20039..ac4bea38a 100644 --- a/packages/common/README/INTERNATIONALIZATION.md +++ b/packages/common/README/INTERNATIONALIZATION.md @@ -1,6 +1,6 @@ # Internationalization This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the -internationalization. You can refer to [this page](README.md). for more general information on the package. +internationalization. You can refer to [this page](README.md) for more general information on the package. This package exports utility functions and hooks tools that help you manage the internationalization support of the Monk SDK, as well as common translations that can be useful when interacting with the SDK. diff --git a/packages/common/README/STATE_MANAGEMENT.md b/packages/common/README/STATE_MANAGEMENT.md index a06338b2f..701ad1c97 100644 --- a/packages/common/README/STATE_MANAGEMENT.md +++ b/packages/common/README/STATE_MANAGEMENT.md @@ -1,6 +1,6 @@ # State Management This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the state -management. You can refer to [this page](README.md). for more general information on the package. +management. You can refer to [this page](README.md) for more general information on the package. This package exports tools that help you manage the state of MonkJs applications. In Monk projects, the state of an inspection and everything that goes with it is represented by a set of different entities. The complete list of entities diff --git a/packages/common/README/THEMING.md b/packages/common/README/THEMING.md index 73ef284a5..b00b5a8aa 100644 --- a/packages/common/README/THEMING.md +++ b/packages/common/README/THEMING.md @@ -1,6 +1,6 @@ # Theming This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the -theme customization. You can refer to [this page](README.md). for more general information on the package. +theme customization. You can refer to [this page](README.md) for more general information on the package. This package exports tools that help you customize the look and feel of MonkJs applications. diff --git a/packages/common/README/UTILITIES.md b/packages/common/README/UTILITIES.md index e9a90cd53..ef7086cc5 100644 --- a/packages/common/README/UTILITIES.md +++ b/packages/common/README/UTILITIES.md @@ -1,6 +1,6 @@ # Utilities This README page is aimed at providing documentation on a specific part of the `@monkvision/common` package : the -utility functions. You can refer to [this page](README.md). for more general information on the package. +utility functions. You can refer to [this page](README.md) for more general information on the package. This package exports various utility functions used throughout the MonkJs SDK. @@ -111,6 +111,22 @@ type of variation to use for the interactive colors (lighten or darken the color --- +# Environment Utils +### getEnvOrThrow +```typescript +import { getEnvOrThrow } from '@monkvision/common'; + +try { + const example = getEnvOrThrow('REACT_APP_EXAMPLE'); + console.log('Env var is defined :', example); +} catch (err) { + console.log('Env var is not defined'); +} +``` +Returns the value of a given environment variable. If the value does not exist, it throws an error. + +--- + # Mimetype Utils ### MIMETYPE_FILE_EXTENSIONS ```typescript diff --git a/packages/common/package.json b/packages/common/package.json index 0c004c50a..d4c86e372 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -27,12 +27,16 @@ "dependencies": { "@monkvision/types": "4.0.0", "i18next": "^23.4.5", + "localforage": "^1.10.0", + "match-sorter": "^6.3.4", "pako": "^2.1.0", - "react-i18next": "^13.2.0" + "react-i18next": "^13.2.0", + "sort-by": "^1.2.0" }, "peerDependencies": { "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@monkvision/eslint-config-base": "4.0.0", @@ -47,6 +51,7 @@ "@types/jest": "^29.2.2", "@types/node": "^18.11.9", "@types/pako": "^2", + "@types/sort-by": "^1", "@typescript-eslint/eslint-plugin": "^5.43.0", "@typescript-eslint/parser": "^5.43.0", "eslint": "^8.29.0", diff --git a/packages/common/src/apps/index.ts b/packages/common/src/apps/index.ts new file mode 100644 index 000000000..a08b2d9b6 --- /dev/null +++ b/packages/common/src/apps/index.ts @@ -0,0 +1 @@ +export * from './params'; diff --git a/packages/common/src/apps/params.tsx b/packages/common/src/apps/params.tsx new file mode 100644 index 000000000..e220b417c --- /dev/null +++ b/packages/common/src/apps/params.tsx @@ -0,0 +1,270 @@ +import React, { + createContext, + Dispatch, + PropsWithChildren, + SetStateAction, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { monkLanguages, VehicleType } from '@monkvision/types'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useMonitoring } from '@monkvision/monitoring'; +import { zlibDecompress } from '../utils'; + +/** + * Local storage key used within Monk web applications to store the authentication token. + */ +export const STORAGE_KEY_AUTH_TOKEN = '@monk_authToken'; + +/** + * Parameters usually used by Monk applications to specify the user journey. + */ +export interface MonkAppParams { + /** + * The authentication token representing the currently logged-in user. If this param is `null`, it means the user is + * not logged in. + */ + authToken: string | null; + /** + * The ID of the current inspection being handled (picture taking, report viewing...) by the application. If this + * param is `null`, it probably means that the inspection must be created by the app. + */ + inspectionId: string | null; + /** + * The current vehicle type of the app. This value usually helps to choose which sights to display to the user, or + * which car 360 wireframes to use for the inspection report. + */ + vehicleType: VehicleType | null; + /** + * Callback used to set the current auth token. + * + * @see authToken + */ + setAuthToken: Dispatch>; + /** + * Callback used to set the current inspection ID. + * + * @see inspectionId + */ + setInspectionId: Dispatch>; + /** + * Callback used to set the current vehicle type. + * + * @see vehicleType + */ + setVehicleType: Dispatch>; +} + +/** + * Enumeration of the usual search parameters used by Monk applications. These parameters help configure the application + * via URL directly. + */ +export enum MonkSearchParams { + /** + * Search parameter used to provide an authentication token directly via URL. Note : auth tokens need be compressed + * using ZLib (ex: using the `zlibCompress` utility function available in this package) and properly URL-encoded + * before being passed as a URL param. No Monk app should ever use auth tokens obtained from URL search params without + * compression and encoding. + * + * @see zlibCompress + * @see encodeURIComponent + */ + TOKEN = 't', + /** + * Search parameter used to provide an inspection ID to the app to use directly. + */ + INSPECTION_ID = 'i', + /** + * Search parameter used to specify the vehicle type that the application should use. The list of vehicle types + * available and supported by the Monk SDK is described in the `VehicleType` enum exported by the `@monkvision/types` + * package. + * + * @see VehicleType + */ + VEHICLE_TYPE = 'v', + /** + * Search parameter used to specify the language used by the application. The list of languages supported by the Monk + * SDK is available in the `monkLanguages` array exported by the `@monkvision/types` package. + * + * @see monkLanguages + */ + LANGUAGE = 'l', +} + +/** + * React context used to store the common Monk application parameters. + * + * @see MonkAppParams + */ +export const MonkAppParamsContext = createContext({ + authToken: null, + inspectionId: null, + vehicleType: null, + setAuthToken: () => {}, + setInspectionId: () => {}, + setVehicleType: () => {}, +}); + +/** + * Props accepted by the MonkAppParamsProvider component. + */ +export interface MonkAppParamsProviderProps { + /** + * Boolean used to indicate if the application parameters should be fetched from the URL search parameters. If this + * prop is set to `true`, during the first render of the MonkAppParamsProvider component, it will look in the URL for + * search parameters described in the `MonkSearchParams` enum in order to update the app params accordingly. + * + * Notes : + * - Auth tokens need be compressed using ZLib (ex: using the `zlibCompress` utility function available in this + * package) and properly URL-encoded before being passed as a URL param. No Monk app should ever use auth tokens + * obtained from URL search params without compression and encoding. + * - If `fetchTokenFromStorage` is also set to `true`, the token fetched from the search params will always be + * used in priority over the one fetched from the local storage. + * + * @default true + * @see fetchTokenFromStorage + * @see zlibCompress + * @see encodeURIComponent + */ + fetchFromSearchParams?: boolean; + /** + * Boolean used to indicate if the authentication token should be fetched from the local storage. If this prop is set + * to `true`, during the first render of the MonkAppParamsProvider component, it will look in the local storage for a + * valid authentication token to use. + * + * Notes : + * - The storage key used to read / write the auth token in the local storage is the same throughout the Monk SDK and + * is defined in the `STORAGE_KEY_AUTH_TOKEN` variable exported by this package. + * - If `fetchFromSearchParams` is also set to `true`, the token fetched from the search params will always be + * used in priority over the one fetched from the local storage. + * + * @default true + * @see fetchFromSearchParams + * @see STORAGE_KEY_AUTH_TOKEN + */ + fetchTokenFromStorage?: boolean; + /** + * Boolean used to indicate the application language should be updated automatically upon fetching a valid language + * from the URL search params. If `fetchFromSearchParams` is set to `false`, this prop is ignored. + * + * @default true + * @see monkLanguages + */ + updateLanguage?: boolean; + /** + * Callback called when an authentication token has successfully been fetched from either the local storage, or the + * URL search params. + * + * @see fetchFromSearchParams + * @see fetchTokenFromStorage + */ + onFetchAuthToken?: () => void; +} + +/** + * A React context provider that declares the state for the common parameters used by Monk applications. The parameters + * are described in the `MonkAppParams` interface. Using the `fetchFromSearchParams` and `fetchTokenFromStorage` props, + * this component can also fetch initial values for these params directly from the URL search params and the web local + * storage. See the TSDoc for these props for more details. + * + * @see MonkAppParams + * @see MonkAppParamsProviderProps + */ +export function MonkAppParamsProvider({ + fetchFromSearchParams = true, + fetchTokenFromStorage = true, + updateLanguage = true, + onFetchAuthToken, + children, +}: PropsWithChildren) { + const [authToken, setAuthToken] = useState(null); + const [inspectionId, setInspectionId] = useState(null); + const [vehicleType, setVehicleType] = useState(null); + const [searchParams] = useSearchParams(); + const { i18n } = useTranslation(); + const { handleError } = useMonitoring(); + + useEffect(() => { + let fetchedToken: string | null = null; + if (fetchTokenFromStorage) { + fetchedToken = localStorage.getItem(STORAGE_KEY_AUTH_TOKEN); + } + if (fetchFromSearchParams) { + setInspectionId((param) => searchParams.get(MonkSearchParams.INSPECTION_ID) ?? param); + const vehicleTypeParam = searchParams.get(MonkSearchParams.VEHICLE_TYPE); + const newVehicleType = Object.values(VehicleType).includes(vehicleTypeParam) + ? (vehicleTypeParam as VehicleType) + : null; + setVehicleType((param) => newVehicleType ?? param); + const compressedToken = searchParams.get(MonkSearchParams.TOKEN); + if (compressedToken) { + fetchedToken = zlibDecompress(compressedToken); + } + + if (fetchedToken) { + setAuthToken(fetchedToken); + onFetchAuthToken?.(); + } + + const lang = searchParams.get(MonkSearchParams.LANGUAGE); + if (updateLanguage && lang && (monkLanguages as readonly string[]).includes(lang)) { + i18n.changeLanguage(lang).catch(handleError); + } + } + }, [searchParams, i18n.changeLanguage]); + + const appParams = useMemo( + () => ({ + authToken, + inspectionId, + vehicleType, + setAuthToken, + setInspectionId, + setVehicleType, + }), + [authToken, inspectionId, vehicleType, setAuthToken, setInspectionId, setVehicleType], + ); + + return ( + {children} + ); +} + +/** + * Params accepted by the `useMonkAppParams` hook. + */ +export interface UseMonkAppParamsParams { + /** + * Boolean indicating if the `authToken` and the `inspectionId` params are required. If this value is set to `true`, + * the hook will return non-null values (even in TypeScript typings) values for the `authToken` and the `inspectionId` + * params, at the cost of throwing an error if either one of these param is `null`. + */ + required?: boolean; +} + +/** + * Custom hook used to get the common Monk application params (described in the `MonkAppParams` interface) for the + * current `MonkAppParamsContext`. This hook must be called within a child of the `MonkAppParamsProvider` component. + * + * @see MonkAppParams + * @see MonkAppParamsContext + * @see MonkAppParamsProvider + */ +export function useMonkAppParams(): MonkAppParams; +export function useMonkAppParams(options: { required: false | undefined }): MonkAppParams; +export function useMonkAppParams(options: { + required: true; +}): MonkAppParams & { authToken: string; inspectionId: string }; +export function useMonkAppParams(options?: UseMonkAppParamsParams): MonkAppParams { + const context = useContext(MonkAppParamsContext); + if (options?.required && !context.authToken) { + throw new Error('Authentication token is null but was required by the current component.'); + } + if (options?.required && !context.inspectionId) { + throw new Error('Inspection ID is null but was required by the current component.'); + } + return context; +} diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 46549b218..c93c4d6a0 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,3 +3,4 @@ export * from './state'; export * from './theme'; export * from './utils'; export * from './hooks'; +export * from './apps'; diff --git a/packages/common/src/theme/theme.ts b/packages/common/src/theme/theme.ts index 07f7d5669..c030afb6c 100644 --- a/packages/common/src/theme/theme.ts +++ b/packages/common/src/theme/theme.ts @@ -1,4 +1,5 @@ import { Color, ColorProp, MonkPalette, MonkTheme, ThemeUtils } from '@monkvision/types'; +import { CSSProperties } from 'react'; import { MonkDefaultPalette } from './default'; function createGetColors(palette: MonkPalette): (prop: ColorProp) => Color { @@ -18,6 +19,13 @@ function createThemeUtils(palette: MonkPalette): ThemeUtils { }; } +function createRootStyles(palette: MonkPalette): CSSProperties { + return { + backgroundColor: palette.surface.bg, + color: palette.text.white, + }; +} + /** * Optional parameters that can be passed to the createTheme function. */ @@ -42,5 +50,6 @@ export function createTheme({ palette }: CreateThemeParams = {}): MonkTheme { return { palette: themePalette, utils: createThemeUtils(themePalette), + rootStyles: createRootStyles(themePalette), }; } diff --git a/packages/common/src/utils/env.utils.ts b/packages/common/src/utils/env.utils.ts new file mode 100644 index 000000000..d69f3b8e3 --- /dev/null +++ b/packages/common/src/utils/env.utils.ts @@ -0,0 +1,11 @@ +/** + * Utility function that returns the value of a given environment variable. If the value does not exist, it throws an + * error. + */ +export function getEnvOrThrow(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Required environment variable ${name} is not defined.`); + } + return value; +} diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index d1527e3ad..3a8d5c17e 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -5,3 +5,4 @@ export * from './mimetype.utils'; export * from './promise.utils'; export * from './zlib.utils'; export * from './browser.utils'; +export * from './env.utils'; diff --git a/packages/common/test/apps/params.test.tsx b/packages/common/test/apps/params.test.tsx new file mode 100644 index 000000000..de4c2098a --- /dev/null +++ b/packages/common/test/apps/params.test.tsx @@ -0,0 +1,260 @@ +jest.mock('../../src/utils/zlib.utils', () => ({ + zlibDecompress: jest.fn(() => ''), +})); + +import { render, screen } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import React, { useContext, useEffect } from 'react'; +import { VehicleType } from '@monkvision/types'; +import { + MonkAppParams, + MonkAppParamsContext, + MonkAppParamsProvider, + MonkSearchParams, + useMonkAppParams, + zlibDecompress, +} from '../../src'; +import { useSearchParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; + +let params: MonkAppParams | null = null; +function TestComponent() { + const context = useContext(MonkAppParamsContext); + useEffect(() => { + params = context; + }); + return <>; +} + +describe('Monk App Params', () => { + afterEach(() => { + jest.clearAllMocks(); + params = null; + }); + + describe('MonkAppParams context', () => { + it('should return the proper default values', () => { + const { result, unmount } = renderHook(() => { + return useContext(MonkAppParamsContext); + }); + + expect(result.current.authToken).toBeNull(); + expect(result.current.inspectionId).toBeNull(); + expect(result.current.vehicleType).toBeNull(); + expect(typeof result.current.setAuthToken).toEqual('function'); + expect(typeof result.current.setInspectionId).toEqual('function'); + expect(typeof result.current.setVehicleType).toEqual('function'); + + unmount(); + }); + }); + + describe('MonkAppParamsProvider component', () => { + it('should pass the children to the MonkAppParamsContext', () => { + const testId = 'test-id-test'; + const { unmount } = render( + +
+ , + ); + + expect(screen.queryByTestId(testId)).not.toBeNull(); + + unmount(); + }); + + it('should set the proper default context values', () => { + const { unmount } = render( + + + , + ); + + expect(params?.authToken).toBeNull(); + expect(params?.inspectionId).toBeNull(); + expect(params?.vehicleType).toBeNull(); + expect(typeof params?.setAuthToken).toEqual('function'); + expect(typeof params?.setInspectionId).toEqual('function'); + expect(typeof params?.setVehicleType).toEqual('function'); + + unmount(); + }); + + it('should fetch the token from the local storage if asked to', () => { + const token = 'test-token-test'; + const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => token); + const onFetchAuthToken = jest.fn(); + const { unmount } = render( + + + , + ); + + expect(spy).toHaveBeenCalled(); + expect(params?.authToken).toEqual(token); + expect(onFetchAuthToken).toHaveBeenCalled(); + + unmount(); + }); + + it('should fetch the token from the search params if asked to', () => { + const tokenCompressed = 'test-token-test-compressed'; + const tokenDecompressed = 'test-token-test-decompressed'; + (useSearchParams as jest.Mock).mockImplementationOnce(() => [ + { + get: jest.fn((name) => (name === MonkSearchParams.TOKEN ? tokenCompressed : null)), + }, + ]); + (zlibDecompress as jest.Mock).mockImplementationOnce(() => tokenDecompressed); + const onFetchAuthToken = jest.fn(); + const { unmount } = render( + + + , + ); + + expect(zlibDecompress).toHaveBeenCalledWith(tokenCompressed); + expect(params?.authToken).toEqual(tokenDecompressed); + expect(onFetchAuthToken).toHaveBeenCalled(); + + unmount(); + }); + + it('should prioritize the param obtained from the search params rather than from the storage', () => { + const storageToken = 'test-token-test-storage'; + jest.spyOn(Storage.prototype, 'getItem').mockImplementationOnce(() => storageToken); + const tokenCompressed = 'test-token-test-compressed-searchparams'; + const tokenDecompressed = 'test-token-test-decompressed-searchparams'; + (useSearchParams as jest.Mock).mockImplementationOnce(() => [ + { + get: jest.fn((name) => (name === MonkSearchParams.TOKEN ? tokenCompressed : null)), + }, + ]); + (zlibDecompress as jest.Mock).mockImplementationOnce(() => tokenDecompressed); + const { unmount } = render( + + + , + ); + + expect(params?.authToken).toEqual(tokenDecompressed); + + unmount(); + }); + + it('should fetch the inspection ID from the search params if asked to', () => { + const inspectionId = 'test-inspection-id-test'; + (useSearchParams as jest.Mock).mockImplementationOnce(() => [ + { + get: jest.fn((name) => (name === MonkSearchParams.INSPECTION_ID ? inspectionId : null)), + }, + ]); + const { unmount } = render( + + + , + ); + + expect(params?.inspectionId).toEqual(inspectionId); + + unmount(); + }); + + Object.values(VehicleType).forEach((vehicleType) => + it(`should properly fetch the ${vehicleType} vehicle type from the search params if asked to`, () => { + (useSearchParams as jest.Mock).mockImplementationOnce(() => [ + { + get: jest.fn((name) => (name === MonkSearchParams.VEHICLE_TYPE ? vehicleType : null)), + }, + ]); + const { unmount } = render( + + + , + ); + + expect(params?.vehicleType).toEqual(vehicleType); + + unmount(); + }), + ); + + it('should update the language from the search params if asked to', () => { + const lang = 'fr'; + (useSearchParams as jest.Mock).mockImplementationOnce(() => [ + { + get: jest.fn((name) => (name === MonkSearchParams.LANGUAGE ? lang : null)), + }, + ]); + const { unmount } = render( + + + , + ); + + expect(useTranslation).toHaveBeenCalled(); + const { i18n } = (useTranslation as jest.Mock).mock.results[0].value; + expect(i18n.changeLanguage).toHaveBeenCalledWith(lang); + + unmount(); + }); + }); + + describe('useMonkAppParams hook', () => { + it('should return the current value of the MonkAppParamsContext', () => { + const value = { test: 'hello' }; + const spy = jest.spyOn(React, 'useContext').mockImplementationOnce(() => value); + + const { result, unmount } = renderHook(useMonkAppParams); + + expect(spy).toHaveBeenCalledWith(MonkAppParamsContext); + expect(result.current).toEqual(value); + + unmount(); + }); + + it('should throw an error if required is true and the auth token is null', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const value = { inspectionId: 'hello' }; + jest.spyOn(React, 'useContext').mockImplementationOnce(() => value); + + const { result, unmount } = renderHook(useMonkAppParams, { + initialProps: { required: true }, + }); + + expect(result.error).toBeDefined(); + + unmount(); + jest.spyOn(console, 'error').mockRestore(); + }); + + it('should throw an error if required is true and the inspection ID is null', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const value = { authToken: 'hello' }; + jest.spyOn(React, 'useContext').mockImplementationOnce(() => value); + + const { result, unmount } = renderHook(useMonkAppParams, { + initialProps: { required: true }, + }); + + expect(result.error).toBeDefined(); + + unmount(); + jest.spyOn(console, 'error').mockRestore(); + }); + + it('should not throw an error if required is true and neither the auth token nor the inspection id is null', () => { + const value = { authToken: 'hello', inspectionId: 'hi' }; + jest.spyOn(React, 'useContext').mockImplementationOnce(() => value); + + const { result, unmount } = renderHook(useMonkAppParams, { + initialProps: { required: true }, + }); + + expect(result.current).toEqual(value); + expect(result.error).not.toBeDefined(); + + unmount(); + }); + }); +}); diff --git a/packages/common/test/theme/theme.test.ts b/packages/common/test/theme/theme.test.ts index 80cef76d4..ea446948d 100644 --- a/packages/common/test/theme/theme.test.ts +++ b/packages/common/test/theme/theme.test.ts @@ -11,6 +11,7 @@ describe('createTheme function', () => { expect(themeNoParam).toEqual({ palette: MonkDefaultPalette, utils: expect.any(Object), + rootStyles: expect.any(Object), }); }); @@ -33,6 +34,38 @@ describe('createTheme function', () => { ...partialPalette, }, utils: expect.any(Object), + rootStyles: expect.any(Object), + }); + }); + + it('should return root styles based on the palette', () => { + const partialPalette: Partial = { + surface: { + bg: 'test-bg', + s1: 'test-s1', + s2: 'test-s2', + s3: 'test-s3', + s4: 'test-s4', + s5: 'test-s5', + }, + text: { + primary: 'test-primary', + secondary: 'test-secondary', + tertiary: 'test-tertiary', + disable: 'test-disable', + white: 'test-white', + }, + }; + + const theme = createTheme({ palette: partialPalette }); + + expect(theme).toEqual({ + palette: expect.any(Object), + utils: expect.any(Object), + rootStyles: { + backgroundColor: partialPalette.surface?.bg, + color: partialPalette.text?.white, + }, }); }); diff --git a/packages/common/test/utils/env.utils.test.ts b/packages/common/test/utils/env.utils.test.ts new file mode 100644 index 000000000..e127aeb58 --- /dev/null +++ b/packages/common/test/utils/env.utils.test.ts @@ -0,0 +1,27 @@ +import { getEnvOrThrow } from '../../src'; + +describe('Env utils', () => { + describe('getEnvOrThrow util function', () => { + it('should return the value if it is defined in the environment', () => { + const name = 'TEST_VAR_NAME_1'; + const value = 'test value'; + Object.defineProperty(global.process.env, name, { value }); + + expect(getEnvOrThrow(name)).toEqual(value); + }); + + it('should throw if the environment variable is not defined', () => { + const name = 'TEST_VAR_NAME_2'; + Object.defineProperty(global.process.env, name, { value: undefined }); + + expect(() => getEnvOrThrow(name)).toThrowError(); + }); + + it('should throw if the environment variable is empty', () => { + const name = 'TEST_VAR_NAME_3'; + Object.defineProperty(global.process.env, name, { value: '' }); + + expect(() => getEnvOrThrow(name)).toThrowError(); + }); + }); +}); diff --git a/packages/inspection-capture-web/README.md b/packages/inspection-capture-web/README.md index 22b4ee9fb..8f43c93a9 100644 --- a/packages/inspection-capture-web/README.md +++ b/packages/inspection-capture-web/README.md @@ -80,3 +80,4 @@ export function MonkPhotoCapturePage() { | startTasksOnComplete | boolean | TaskName[] | Value indicating if tasks should be started at the end of the inspection :
If not provided or if value is set to `false`, no tasks will be started.
If set to `true`, the tasks described by the `tasksBySight` param (or, if not provided, the default tasks of each sight) will be started.
If an array of tasks is provided, the tasks started will be the ones contained in the array. | | | | 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. | | | +| showCloseButton | boolean | Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. | | `false` | diff --git a/packages/inspection-capture-web/package.json b/packages/inspection-capture-web/package.json index 3593a0849..2245bd52a 100644 --- a/packages/inspection-capture-web/package.json +++ b/packages/inspection-capture-web/package.json @@ -34,7 +34,8 @@ }, "peerDependencies": { "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@monkvision/eslint-config-base": "4.0.0", diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 14e81720a..210775362 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -1,4 +1,4 @@ -import { Camera, CameraConfig, CameraHUDProps, CompressionOptions } from '@monkvision/camera-web'; +import { Camera, CameraHUDProps, CompressionOptions, CameraProps } from '@monkvision/camera-web'; import { Sight, TaskName } from '@monkvision/types'; import { useLoadingState } from '@monkvision/common'; import { ComplianceOptions, MonkAPIConfig } from '@monkvision/network'; @@ -16,7 +16,9 @@ import { /** * Props of the PhotoCapture component. */ -export interface PhotoCaptureProps extends Partial, Partial { +export interface PhotoCaptureProps + extends Pick, 'resolution' | 'allowImageUpscaling'>, + Partial { /** * The list of sights to take pictures of. The values in this array should be retreived from the `@monkvision/sights` * package. @@ -58,6 +60,12 @@ export interface PhotoCaptureProps extends Partial, Partial void; + /** + * Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. + * + * @default false + */ + showCloseButton?: boolean; } // No ts-doc for this component : the component exported is PhotoCaptureHOC @@ -69,6 +77,7 @@ export function PhotoCapture({ startTasksOnComplete = true, onClose, onComplete, + showCloseButton = false, compliances, ...cameraConfig }: PhotoCaptureProps) { @@ -99,12 +108,12 @@ export function PhotoCapture({ apiConfig, loading, onLastSightTaken, + tasksBySight, }); const uploadQueue = useUploadQueue({ inspectionId, apiConfig, compliances, - loading, }); const { handlePictureTaken } = usePictureTaken({ sightState, @@ -126,6 +135,7 @@ export function PhotoCapture({ loading, onClose, inspectionId, + showCloseButton, }; return ( diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx index 6f949c5bd..df94007ca 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.tsx @@ -63,6 +63,12 @@ export interface PhotoCaptureHUDProps extends CameraHUDProps { * displayed. */ onClose?: () => void; + /** + * Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. + * + * @default false + */ + showCloseButton?: boolean; } /** @@ -82,6 +88,7 @@ export function PhotoCaptureHUD({ onCancelAddDamage, onRetry, onClose, + showCloseButton, loading, handle, cameraPreview, @@ -119,6 +126,7 @@ export function PhotoCaptureHUD({ closeDisabled={!!loading.error || !!handle.error} galleryDisabled={!!loading.error || !!handle.error} takePictureDisabled={!!loading.error || !!handle.error} + showCloseButton={showCloseButton} /> void; /** * Boolean indicating if the gallery button is disabled. + * + * @default false */ galleryDisabled?: boolean; /** * Boolean indicating if the take picture button is disabled. + * + * @default false */ takePictureDisabled?: boolean; /** * Boolean indicating if the close button is disabled. + * + * @default false */ closeDisabled?: boolean; + /** + * Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. + * + * @default false + */ + showCloseButton?: boolean; } /** @@ -53,6 +65,7 @@ export function PhotoCaptureHUDButtons({ galleryDisabled = false, takePictureDisabled = false, closeDisabled = false, + showCloseButton = false, }: PhotoCaptureHUDButtonsProps) { const { status: galleryStatus, eventHandlers: galleryEventHandlers } = useInteractiveStatus({ disabled: galleryDisabled, @@ -65,6 +78,7 @@ export function PhotoCaptureHUDButtons({ closeStatus, closeBtnAvailable: !!onClose, galleryPreviewUrl: galleryPreview?.uri, + showCloseButton, }); return ( diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons/hooks.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons/hooks.ts index 86b2b62ca..4e5ae2566 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons/hooks.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons/hooks.ts @@ -12,6 +12,7 @@ interface PhotoCaptureHUDButtonsStylesParams { closeStatus: InteractiveStatus; closeBtnAvailable: boolean; galleryPreviewUrl?: string; + showCloseButton?: boolean; } interface PhotoCaptureHUDButtonsStyles { @@ -66,7 +67,7 @@ export function useCaptureHUDButtonsStyles( backgroundColor: captureButtonBackgroundColors[params.closeStatus], borderColor: captureButtonForegroundColors[params.closeStatus], ...(params.closeStatus === InteractiveStatus.DISABLED ? styles['buttonDisabled'] : {}), - visibility: params.closeBtnAvailable ? 'visible' : 'hidden', + visibility: params.showCloseButton ? 'visible' : 'hidden', }, iconColor: captureButtonForegroundColors[params.closeStatus], }, diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay/hooks.ts b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay/hooks.ts index b76f52c56..cc6f8675b 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay/hooks.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay/hooks.ts @@ -2,6 +2,7 @@ import { CameraHandle, getCameraErrorLabel } from '@monkvision/camera-web'; import { useTranslation } from 'react-i18next'; import { useObjectTranslation } from '@monkvision/common'; import { MonkNetworkError } from '@monkvision/network'; +import { PhotoCaptureErrorName } from '../../errors'; /** * Props of the PhotoCaptureHUDOverlay component. @@ -41,23 +42,25 @@ export function usePhotoCaptureErrorLabel( if (handle.error && cameraErrorLabel) { return tObj(cameraErrorLabel); } - if ( - captureError instanceof Error && - [MonkNetworkError.MISSING_TOKEN, MonkNetworkError.INVALID_TOKEN].includes( - captureError.name as MonkNetworkError, - ) - ) { - return t('photo.hud.error.invalidToken'); - } - if (captureError instanceof Error && captureError.name === MonkNetworkError.EXPIRED_TOKEN) { - return t('photo.hud.error.expiredToken'); - } - if ( - captureError instanceof Error && - captureError.name === MonkNetworkError.INSUFFICIENT_AUTHORIZATION - ) { - return t('photo.hud.error.insufficientAuth'); + if (captureError instanceof Error) { + if (captureError.name === PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION) { + return t('photo.hud.error.missingTasks'); + } + if ( + [MonkNetworkError.MISSING_TOKEN, MonkNetworkError.INVALID_TOKEN].includes( + captureError.name as MonkNetworkError, + ) + ) { + return t('photo.hud.error.invalidToken'); + } + if (captureError.name === MonkNetworkError.EXPIRED_TOKEN) { + return t('photo.hud.error.expiredToken'); + } + if (captureError.name === MonkNetworkError.INSUFFICIENT_AUTHORIZATION) { + return t('photo.hud.error.insufficientAuth'); + } } + if (captureError) { return `${t('photo.hud.error.inspectionLoading')} ${inspectionId}`; } @@ -75,6 +78,7 @@ export function useRetry({ if ( captureError instanceof Error && [ + PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION, MonkNetworkError.MISSING_TOKEN, MonkNetworkError.INVALID_TOKEN, MonkNetworkError.EXPIRED_TOKEN, diff --git a/packages/inspection-capture-web/src/PhotoCapture/errors.ts b/packages/inspection-capture-web/src/PhotoCapture/errors.ts new file mode 100644 index 000000000..ddd92ec89 --- /dev/null +++ b/packages/inspection-capture-web/src/PhotoCapture/errors.ts @@ -0,0 +1,3 @@ +export enum PhotoCaptureErrorName { + MISSING_TASK_IN_INSPECTION = 'PhotoCaptureMissingTaskInInspection', +} diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts index e6481e1ec..7088a5eb9 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts @@ -2,9 +2,10 @@ import { useState } from 'react'; import { MonkAPIConfig, MonkApiResponse, useMonkApi } from '@monkvision/network'; import { useMonitoring } from '@monkvision/monitoring'; import { LoadingState, MonkGotOneInspectionAction, useAsyncEffect } from '@monkvision/common'; -import { Image, Sight } from '@monkvision/types'; +import { Image, Sight, TaskName } from '@monkvision/types'; import { sights } from '@monkvision/sights'; import { MonkPicture } from '@monkvision/camera-web'; +import { PhotoCaptureErrorName } from '../errors'; /** * Object containing state management utilities for the PhotoCapture sights. @@ -64,6 +65,49 @@ export interface PhotoCaptureSightsParams { * Callback called when the last sight has been taken by the user. */ onLastSightTaken: () => void; + tasksBySight?: Record; +} + +function getCaptureTasks( + captureSights: Sight[], + tasksBySight?: Record, +): TaskName[] { + const tasks: TaskName[] = []; + captureSights.forEach((sight) => { + const sightTasks = tasksBySight ? tasksBySight[sight.id] : sight.tasks; + sightTasks.forEach((task) => { + if (!tasks.includes(task)) { + tasks.push(task); + } + }); + }); + return tasks; +} + +function assertInspectionIsValid( + inspectionId: string, + response: MonkApiResponse, + captureSights: Sight[], + tasksBySight?: Record, +): void { + const inspectionTasks = response.action?.payload?.tasks + ?.filter((task) => task.inspectionId === inspectionId) + ?.map((task) => task.name); + if (inspectionTasks) { + const missingTasks: TaskName[] = []; + getCaptureTasks(captureSights, tasksBySight).forEach((captureTask) => { + if (!inspectionTasks.includes(captureTask)) { + missingTasks.push(captureTask); + } + }); + if (missingTasks.length > 0) { + const error = new Error( + `The provided inspection is missing the following tasks required by the current capture configuration : ${missingTasks}`, + ); + error.name = PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION; + throw error; + } + } } function getSightsTaken( @@ -71,7 +115,7 @@ function getSightsTaken( response: MonkApiResponse, ): Sight[] { return ( - response.action.payload?.images + response.action?.payload?.images ?.filter( (image: Image) => image.inspectionId === inspectionId && image.additionalData?.['sight_id'], ) @@ -83,7 +127,7 @@ function getLastPictureTaken( inspectionId: string, response: MonkApiResponse, ): MonkPicture | null { - const images = response.action.payload?.images?.filter( + const images = response.action?.payload?.images?.filter( (image: Image) => image.inspectionId === inspectionId, ); if (images && images.length > 0) { @@ -107,6 +151,7 @@ export function usePhotoCaptureSightState({ apiConfig, loading, onLastSightTaken, + tasksBySight, }: PhotoCaptureSightsParams): PhotoCaptureSightState { if (captureSights.length === 0) { throw new Error('Empty sight list given to the Monk PhotoCapture component.'); @@ -126,11 +171,17 @@ export function usePhotoCaptureSightState({ [inspectionId, retryCount], { onResolve: (response) => { - const alreadyTakenSights = getSightsTaken(inspectionId, response); - setSightsTaken(alreadyTakenSights); - setSelectedSight(captureSights.filter((s) => !alreadyTakenSights.includes(s))[0]); - setLastPictureTaken(getLastPictureTaken(inspectionId, response)); - loading.onSuccess(); + try { + const alreadyTakenSights = getSightsTaken(inspectionId, response); + setSightsTaken(alreadyTakenSights); + setSelectedSight(captureSights.filter((s) => !alreadyTakenSights.includes(s))[0]); + setLastPictureTaken(getLastPictureTaken(inspectionId, response)); + assertInspectionIsValid(inspectionId, response, captureSights, tasksBySight); + loading.onSuccess(); + } catch (err) { + handleError(err); + loading.onError(err); + } }, onReject: (err) => { handleError(err); diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts index dde2a02a0..38d20a8e0 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useUploadQueue.ts @@ -1,4 +1,4 @@ -import { LoadingState, Queue, useQueue } from '@monkvision/common'; +import { Queue, useQueue } from '@monkvision/common'; import { MonkPicture } from '@monkvision/camera-web'; import { AddImageOptions, ComplianceOptions, MonkAPIConfig, useMonkApi } from '@monkvision/network'; import { ImageType, TaskName } from '@monkvision/types'; @@ -18,10 +18,6 @@ export interface UploadQueueParams { * The api config used to communicate with the API. */ apiConfig: MonkAPIConfig; - /** - * Global loading state of the PhotoCapture component. - */ - loading: LoadingState; /** * Compliance options used to enable or not certain compliance checks. */ @@ -118,7 +114,6 @@ function createAddImageOptions( export function useUploadQueue({ inspectionId, apiConfig, - loading, compliances, }: UploadQueueParams): Queue { const { handleError } = useMonitoring(); @@ -136,7 +131,7 @@ export function useUploadQueue({ ); } catch (err) { handleError(err); - loading.onError(err); + // TODO : Handle upload errors in compliance throw err; } }, diff --git a/packages/inspection-capture-web/src/translations/de.json b/packages/inspection-capture-web/src/translations/de.json index 30cb007c1..44c6fca5c 100644 --- a/packages/inspection-capture-web/src/translations/de.json +++ b/packages/inspection-capture-web/src/translations/de.json @@ -13,6 +13,7 @@ }, "error": { "retry": "Erneut versuchen", + "missingTasks": "In der vorliegenden Inspektion fehlen einige Aufgaben, die für das aktuelle Erfassungsmodul erforderlich sind.", "invalidToken": "Das verwendete Authentifizierungstoken ist ungültig.", "expiredToken": "Das verwendete Authentifizierungstoken ist abgelaufen.", "insufficientAuth": "Sie haben nicht die erforderlichen Berechtigungen, um diese Aktion durchzuführen.", diff --git a/packages/inspection-capture-web/src/translations/en.json b/packages/inspection-capture-web/src/translations/en.json index a0c38ebd1..dfe798ffe 100644 --- a/packages/inspection-capture-web/src/translations/en.json +++ b/packages/inspection-capture-web/src/translations/en.json @@ -13,6 +13,7 @@ }, "error": { "retry": "Retry", + "missingTasks": "The inspection provided is missing some tasks required by the current capture module.", "invalidToken": "The authentication token used is invalid.", "expiredToken": "The authentication token used is expired.", "insufficientAuth": "You do not have the required autorizations to perform this action.", diff --git a/packages/inspection-capture-web/src/translations/fr.json b/packages/inspection-capture-web/src/translations/fr.json index 073f8390c..ce4d04137 100644 --- a/packages/inspection-capture-web/src/translations/fr.json +++ b/packages/inspection-capture-web/src/translations/fr.json @@ -13,6 +13,7 @@ }, "error": { "retry": "Réessayer", + "missingTasks": "L'inspection fournie ne possède pas certaines tâches requises par le module de capture actuel.", "invalidToken": "Le token d'authentification utilisé est invalide.", "expiredToken": "Le token d'authentification utilisé est expiré.", "insufficientAuth": "Vous n'avez pas les autorisations nécessaires pour effectuer cette action.", diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 6539c34c8..c56a4983c 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -60,6 +60,7 @@ function createProps(): PhotoCaptureProps { resolution: CameraResolution.NHD_360P, format: CompressionFormat.JPEG, quality: 0.4, + showCloseButton: true, }; } @@ -102,6 +103,7 @@ describe('PhotoCapture component', () => { apiConfig: props.apiConfig, loading, onLastSightTaken: expect.any(Function), + tasksBySight: props.tasksBySight, }); unmount(); @@ -173,13 +175,10 @@ describe('PhotoCapture component', () => { const props = createProps(); const { unmount } = render(); - expect(useLoadingState).toHaveBeenCalled(); - const loading = (useLoadingState as jest.Mock).mock.results[0].value; expect(useUploadQueue).toHaveBeenCalledWith({ inspectionId: props.inspectionId, apiConfig: props.apiConfig, compliances: props.compliances, - loading, }); unmount(); @@ -240,7 +239,7 @@ describe('PhotoCapture component', () => { it('should pass the proper props to the HUD component', () => { const props = createProps(); - const { unmount } = render(); + const { unmount } = render(); expect(useAddDamageMode).toHaveBeenCalled(); const addDamageHandle = (useAddDamageMode as jest.Mock).mock.results[0].value; @@ -262,6 +261,7 @@ describe('PhotoCapture component', () => { loading, onClose: props.onClose, inspectionId: props.inspectionId, + showCloseButton: props.showCloseButton, }, }); diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx new file mode 100644 index 000000000..5c831dc04 --- /dev/null +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUD.test.tsx @@ -0,0 +1,157 @@ +jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons', () => ({ + PhotoCaptureHUDButtons: jest.fn(() => <>), +})); +jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay', () => ({ + PhotoCaptureHUDOverlay: jest.fn(() => <>), +})); +jest.mock('../../../src/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDPreview', () => ({ + PhotoCaptureHUDPreview: jest.fn(() => <>), +})); + +import { useTranslation } from 'react-i18next'; +import { act, render, screen } from '@testing-library/react'; +import { sights } from '@monkvision/sights'; +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 { + PhotoCaptureHUD, + PhotoCaptureHUDButtons, + PhotoCaptureHUDOverlay, + PhotoCaptureHUDPreview, + PhotoCaptureHUDProps, +} from '../../../src'; +import { PhotoCaptureMode } from '../../../src/PhotoCapture/hooks'; + +const cameraTestId = 'camera-test-id'; + +function createProps(): PhotoCaptureHUDProps { + return { + inspectionId: 'test-inspection-id-test', + sights: [ + sights['test-sight-1'], + sights['test-sight-2'], + sights['test-sight-3'], + sights['test-sight-4'], + ], + selectedSight: sights['test-sight-2'], + sightsTaken: [sights['test-sight-1']], + lastPictureTaken: { uri: 'test-last-pic-taken', height: 1, width: 2, mimetype: 'test' }, + mode: PhotoCaptureMode.SIGHT, + loading: { isLoading: false, error: null } as unknown as LoadingState, + onSelectSight: jest.fn(), + onAddDamage: jest.fn(), + onCancelAddDamage: jest.fn(), + onRetry: jest.fn(), + onClose: jest.fn(), + showCloseButton: true, + handle: { + isLoading: false, + error: null, + dimensions: { height: 2, width: 4 }, + } as unknown as CameraHandle, + cameraPreview:
, + }; +} + +describe('PhotoCaptureHUD 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 PhotoCaptureHUDPreview component with the proper props', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(PhotoCaptureHUDPreview, { + selectedSight: props.selectedSight, + sights: props.sights, + sightsTaken: props.sightsTaken, + mode: props.mode, + onAddDamage: props.onAddDamage, + onCancelAddDamage: props.onCancelAddDamage, + onSelectSight: props.onSelectSight, + isLoading: props.loading.isLoading || props.handle.isLoading, + error: props.loading.error ?? props.handle.error, + streamDimensions: props.handle.dimensions, + }); + + unmount(); + }); + + it('should display the PhotoCaptureHUDButtons component with the proper props', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(PhotoCaptureHUDButtons, { + onTakePicture: props.handle?.takePicture, + galleryPreview: props.lastPictureTaken ?? 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, + }); + + unmount(); + }); + + it('should display the PhotoCaptureHUDOverlay component with the proper props', () => { + const props = createProps(); + const { unmount } = render(); + + expectPropsOnChildMock(PhotoCaptureHUDOverlay, { + inspectionId: props.inspectionId, + handle: props.handle, + isCaptureLoading: props.loading.isLoading, + captureError: props.loading.error, + onRetry: props.onRetry, + }); + + 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 } = (PhotoCaptureHUDButtons 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(); + }); +}); diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons.test.tsx index 0e4d37af0..c8fd76f93 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDButtons.test.tsx @@ -1,3 +1,4 @@ +import '@testing-library/jest-dom'; import { expectPropsOnChildMock } from '@monkvision/test-utils'; import { InteractiveStatus } from '@monkvision/types'; import { MonkPicture } from '@monkvision/camera-web'; @@ -120,30 +121,48 @@ describe('CaptureHUDButtons component', () => { }); describe('Close button', () => { - it('should not be disabled by default', () => { + it('should not be displayed by default', () => { const { unmount } = render(); - const galleryBtnEl = screen.getByTestId(CLOSE_BTN_TEST_ID); - expect(galleryBtnEl.getAttribute('disabled')).toBeNull(); + const closeBtn = screen.getByTestId(CLOSE_BTN_TEST_ID); + expect(closeBtn).toHaveStyle({ visibility: 'hidden' }); + + unmount(); + }); + + it('should displayed when showCloseButton is true', () => { + const { unmount } = render(); + + const closeBtn = screen.getByTestId(CLOSE_BTN_TEST_ID); + expect(closeBtn).toHaveStyle({ visibility: 'visible' }); + + unmount(); + }); + + it('should not be disabled by default', () => { + const { unmount } = render(); + + const closeBtn = screen.getByTestId(CLOSE_BTN_TEST_ID); + expect(closeBtn.getAttribute('disabled')).toBeNull(); unmount(); }); it('should be disabled when the closeDisabled prop is true', () => { - const { unmount } = render(); + const { unmount } = render(); - const galleryBtnEl = screen.getByTestId(CLOSE_BTN_TEST_ID); - expect(galleryBtnEl.getAttribute('disabled')).toBeDefined(); + const closeBtn = screen.getByTestId(CLOSE_BTN_TEST_ID); + expect(closeBtn.getAttribute('disabled')).toBeDefined(); unmount(); }); it('should get passed the onClose callback', () => { const onClose = jest.fn(); - const { unmount } = render(); + const { unmount } = render(); - const galleryBtnEl = screen.getByTestId(CLOSE_BTN_TEST_ID); - fireEvent.click(galleryBtnEl); + const closeBtn = screen.getByTestId(CLOSE_BTN_TEST_ID); + fireEvent.click(closeBtn); expect(onClose).toHaveBeenCalled(); unmount(); @@ -151,7 +170,7 @@ describe('CaptureHUDButtons component', () => { it('should display an image icon', () => { const expectedIcon = 'close'; - const { unmount } = render(); + const { unmount } = render(); expect((Icon as jest.Mock).mock.calls).toContainEqual([ { diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay.test.tsx index b003d7c81..ef57a43af 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCaptureHUD/PhotoCaptureHUDOverlay.test.tsx @@ -12,6 +12,7 @@ import { useObjectTranslation } from '@monkvision/common'; import { MonkNetworkError } from '@monkvision/network'; import { expectPropsOnChildMock } from '@monkvision/test-utils'; import { PhotoCaptureHUDOverlay, PhotoCaptureHUDOverlayProps } from '../../../src'; +import { PhotoCaptureErrorName } from '../../../src/PhotoCapture/errors'; const OVERLAY_TEST_ID = 'overlay'; @@ -92,6 +93,10 @@ describe('PhotoCaptureHUDOverlay component', () => { }); [ + { + errors: [PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION], + label: 'photo.hud.error.missingTasks', + }, { errors: [MonkNetworkError.MISSING_TOKEN, MonkNetworkError.INVALID_TOKEN], label: 'photo.hud.error.invalidToken', @@ -155,6 +160,7 @@ describe('PhotoCaptureHUDOverlay component', () => { }); [ + PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION, MonkNetworkError.MISSING_TOKEN, MonkNetworkError.INVALID_TOKEN, MonkNetworkError.EXPIRED_TOKEN, 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 b11e85d5e..ebcd1281b 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePhotoCaptureSightState.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react-hooks'; import { LoadingState, useAsyncEffect } from '@monkvision/common'; -import { Sight } from '@monkvision/types'; +import { Sight, TaskName } from '@monkvision/types'; import { useMonitoring } from '@monkvision/monitoring'; import { sights } from '@monkvision/sights'; import { useMonkApi } from '@monkvision/network'; @@ -9,6 +9,7 @@ import { PhotoCaptureSightsParams, usePhotoCaptureSightState, } from '../../../src/PhotoCapture/hooks'; +import { PhotoCaptureErrorName } from '../../../src/PhotoCapture/errors'; function createParams(): PhotoCaptureSightsParams { return { @@ -29,7 +30,7 @@ function createParams(): PhotoCaptureSightsParams { }; } -function mockGetInspectionResponse(inspectionId: string, takenSights: Sight[]) { +function mockGetInspectionResponse(inspectionId: string, takenSights: Sight[], tasks?: TaskName[]) { return { action: { payload: { @@ -41,6 +42,7 @@ function mockGetInspectionResponse(inspectionId: string, takenSights: Sight[]) { width: index * 2000, height: index * 1000, })), + tasks: tasks?.map((name) => ({ inspectionId, name })), }, }, }; @@ -51,7 +53,7 @@ describe('usePhotoCaptureSightState hook', () => { jest.clearAllMocks(); }); - it('should throw an error if the no sights are passed', () => { + it('should throw an error if there are no sights are passed', () => { jest.spyOn(console, 'error').mockImplementation(() => {}); const initialProps = { ...createParams(), captureSights: [] }; const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); @@ -60,6 +62,41 @@ describe('usePhotoCaptureSightState hook', () => { jest.spyOn(console, 'error').mockRestore(); }); + it('should throw an error if the inspection is missing some tasks', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const initialProps = { + ...createParams(), + tasksBySight: { + 'test-sight-1': [TaskName.DAMAGE_DETECTION, TaskName.WHEEL_ANALYSIS], + 'test-sight-2': [TaskName.DAMAGE_DETECTION], + 'test-sight-3': [TaskName.DAMAGE_DETECTION], + 'test-sight-4': [TaskName.DAMAGE_DETECTION], + }, + }; + const apiResponse = mockGetInspectionResponse( + initialProps.inspectionId, + [], + [TaskName.DAMAGE_DETECTION], + ); + const { unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); + + expect(useMonitoring).toHaveBeenCalled(); + const handleErrorMock = (useMonitoring as jest.Mock).mock.results[0].value.handleError; + expect(handleErrorMock).not.toHaveBeenCalled(); + expect(initialProps.loading.onSuccess).not.toHaveBeenCalled(); + expect(useAsyncEffect).toHaveBeenCalled(); + const { onResolve } = (useAsyncEffect as jest.Mock).mock.calls[0][2]; + act(() => onResolve(apiResponse)); + + expect(initialProps.loading.onSuccess).not.toHaveBeenCalled(); + expect(initialProps.loading.onError).toHaveBeenCalled(); + const error = (initialProps.loading.onError as jest.Mock).mock.calls[0][0]; + expect(error.name).toEqual(PhotoCaptureErrorName.MISSING_TASK_IN_INSPECTION); + expect(handleErrorMock).toHaveBeenCalledWith(error); + unmount(); + jest.spyOn(console, 'error').mockRestore(); + }); + it('should properly initialize the state', () => { const initialProps = createParams(); const { result, unmount } = renderHook(usePhotoCaptureSightState, { initialProps }); diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts index bea8c3e85..892eca0ed 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/useUploadQueue.test.ts @@ -1,4 +1,4 @@ -import { LoadingState, useQueue } from '@monkvision/common'; +import { useQueue } from '@monkvision/common'; import { renderHook } from '@testing-library/react-hooks'; import { AddDamage1stShotPictureUpload, @@ -17,7 +17,6 @@ function createParams(): UploadQueueParams { return { inspectionId: 'test-inspection-id', apiConfig: { apiDomain: 'test-api-domain', authToken: 'test-auth-token' }, - loading: { onError: jest.fn() } as unknown as LoadingState, compliances: { iqa: false }, }; } @@ -172,7 +171,6 @@ describe('useUploadQueue hook', () => { }), ).rejects.toBe(err); expect(handleErrorMock).toHaveBeenCalledWith(err); - expect(initialProps.loading.onError).toHaveBeenCalledWith(err); unmount(); }); diff --git a/packages/network/README.md b/packages/network/README.md index eb66c00fb..b56531904 100644 --- a/packages/network/README.md +++ b/packages/network/README.md @@ -121,7 +121,34 @@ function App() { # Authentication This package also exports tools for dealing with authentication within the Monk SDK : -### JWT token decoding +## useAuth hook +This package exports a custom hook called `useAuth` used to easily handle authentication in Monk applications. It stores +the current user's authentication token, and returns callbacks used to log in and out of the application using Auth0 +pop-ups. It accepts a config option called `storeToken` that indicates if the token should be fetched and stored from +the browser local storage (default : `true`). + +- For this hook to work properly, you must use it in a component that is a child of an `Auth0Provider` component. +- If, like in most Monk apps, you plan on using both the `useMonkAppParams` and the `useAuth` hooks, then + only the token stored and returned by the `useMonkAppParams` should be used. The token of this hook must only be + used when using the `useAuth` hook only. This hook will automatically synchronize both tokens for you. + +```tsx +function MyAuthComponent() { + const { login } = useAuth(); + const navigate = useNavigate(); + + const handleLogIn = () => { + login().then(() => { + navigate('/home'); + }); + }; + + return ; +} +``` + +## JWT Utils +### Token decoding You can decode Monk JWT token issued by Auth0 using the `decodeMonkJwt` util function provided by this package : ```typescript @@ -130,4 +157,28 @@ import { decodeMonkJwt, MonkJwtPayload } from '@monkvision/network'; const decodedToken: MonkJwtPayload = decodeMonkJwt(token); ``` -The available properties in the Monk JWT token payload are described in the MonkJwtPayload typescript interface. +The available properties in the Monk JWT token payload are described in the `MonkJwtPayload` typescript interface. + +### isUserAuthorized +This utility function checks if the given user has all the required authroizations. You can either pass an auth token +to be decoded or the JWT payload directly. + +```typescript +import { isUserAuthorized, MonkApiPermission } from '@monkvision/network'; + +const requiredPermissions = [MonkApiPermission.INSPECTION_CREATE, MonkApiPermission.INSPECTION_READ]; +console.log(isUserAuthorized(value, requiredPermissions)); +// value can either be an auth token as a string or a decoded JWT payload +``` + + +### isTokenExpired +This utility function checks if an authorization token is expired or not. You can either pass an auth token to be +decoded or the JWT payload directly. + +```typescript +import { isTokenExpired } from '@monkvision/network'; + +console.log(isTokenExpired(value)); +// value can either be an auth token as a string or a decoded JWT payload +``` diff --git a/packages/network/jest.config.js b/packages/network/jest.config.js index a171eb578..c555a0547 100644 --- a/packages/network/jest.config.js +++ b/packages/network/jest.config.js @@ -2,4 +2,5 @@ const { react } = require('@monkvision/jest-config'); module.exports = { ...react, + modulePathIgnorePatterns: ['/lib/'], }; diff --git a/packages/network/package.json b/packages/network/package.json index c52dc887f..669aeda15 100644 --- a/packages/network/package.json +++ b/packages/network/package.json @@ -32,8 +32,10 @@ "ky": "^1.2.0" }, "peerDependencies": { + "@auth0/auth0-react": "^2.2.4", "react": "^17.0.2", - "react-dom": "^17.0.2" + "react-dom": "^17.0.2", + "react-router-dom": "^6.22.3" }, "devDependencies": { "@monkvision/camera-web": "4.0.0", diff --git a/packages/network/src/api/api.ts b/packages/network/src/api/api.ts index e565deca3..73d3590a3 100644 --- a/packages/network/src/api/api.ts +++ b/packages/network/src/api/api.ts @@ -1,4 +1,4 @@ -import { getInspection } from './inspection'; +import { getInspection, createInspection } from './inspection'; import { addImage } from './image'; import { startInspectionTasks, updateTaskStatus } from './task'; @@ -8,6 +8,7 @@ import { startInspectionTasks, updateTaskStatus } from './task'; */ export const MonkApi = { getInspection, + createInspection, addImage, updateTaskStatus, startInspectionTasks, diff --git a/packages/network/src/api/config.ts b/packages/network/src/api/config.ts index e0b811375..f4b66c7a1 100644 --- a/packages/network/src/api/config.ts +++ b/packages/network/src/api/config.ts @@ -28,6 +28,7 @@ export function getDefaultOptions(config: MonkAPIConfig): Options { return { prefixUrl: `https://${apiDomain}`, headers: { + 'Accept': 'application/json, text/plain, */*', 'Access-Control-Allow-Origin': '*', 'Authorization': authorizationHeader, 'X-Monk-SDK-Version': sdkVersion, diff --git a/packages/network/src/api/error.ts b/packages/network/src/api/error.ts index 319844f41..9a8085b7d 100644 --- a/packages/network/src/api/error.ts +++ b/packages/network/src/api/error.ts @@ -64,11 +64,22 @@ function getErrorName(status: number, message: string): MonkNetworkError | null return null; } +/** + * Type definition for a network error catched by the Monk SDK. Requests made by this package will usually process the + * error returned by the API and will include the request body (containing the error message) with the Error object + * itself. + */ +export interface MonkHTTPError extends HTTPError { + body?: ApiError; +} + /* eslint-disable no-param-reassign */ export const beforeError: BeforeErrorHook = async (error: HTTPError) => { const { response } = error; - const body = (await response.json()) as ApiError; - error.name = getErrorName(response.status, body.message) ?? error.name; + const clone = response.clone(); + const body = (await clone.json()) as ApiError; + error.name = getErrorName(clone.status, body.message) ?? error.name; error.message = getErrorMessage(error.name) ?? error.message; + Object.assign(error, { body }); return error; }; diff --git a/packages/network/src/api/index.ts b/packages/network/src/api/index.ts index d7faba55a..e26a13497 100644 --- a/packages/network/src/api/index.ts +++ b/packages/network/src/api/index.ts @@ -1,7 +1,7 @@ export { type MonkAPIConfig } from './config'; export { type MonkApiResponse, type MonkAPIRequest } from './types'; export { useMonkApi } from './react'; -export { MonkNetworkError } from './error'; +export { MonkNetworkError, type MonkHTTPError } from './error'; export { MonkApi } from './api'; export { diff --git a/packages/network/src/api/inspection/index.ts b/packages/network/src/api/inspection/index.ts index c3dff2f3f..a2bfcb932 100644 --- a/packages/network/src/api/inspection/index.ts +++ b/packages/network/src/api/inspection/index.ts @@ -1 +1,6 @@ export * from './requests'; +export { + type CreateDamageDetectionTaskOptions, + type InspectionCreateTask, + type CreateInspectionOptions, +} from './mappers'; diff --git a/packages/network/src/api/inspection/mappers.ts b/packages/network/src/api/inspection/mappers.ts index ee816ca59..3de8c2758 100644 --- a/packages/network/src/api/inspection/mappers.ts +++ b/packages/network/src/api/inspection/mappers.ts @@ -24,21 +24,28 @@ import { TaskName, Vehicle, VehiclePart, + VehicleType, View, WheelAnalysis, WheelName, } from '@monkvision/types'; import { ApiCommentSeverityValue, + ApiDamageDetectionTaskPostComponent, ApiImageRegion, + ApiImagesOCRTaskPostComponent, ApiInspectionGet, + ApiInspectionPost, ApiPartSeverityValue, ApiPricingV2Details, ApiRenderedOutput, ApiSeverityResult, + ApiTasksComponent, ApiView, + ApiWheelAnalysisTaskPostComponent, } from '../models'; import { mapApiImage } from '../image/mappers'; +import { sdkVersion } from '../config'; function mapDamages(response: ApiInspectionGet): { damages: Damage[]; damageIds: string[] } { const damages: Damage[] = []; @@ -384,3 +391,146 @@ export function mapApiInspectionGet(response: ApiInspectionGet): Partial typeof task === 'object' && task.name === TaskName.DAMAGE_DETECTION, + ) as CreateDamageDetectionTaskOptions | undefined; + return taskOptions + ? { + status: ProgressStatus.NOT_STARTED, + damage_score_threshold: taskOptions.damageScoreThreshold, + + generate_visual_output: { + generate_damages: taskOptions.generateDamageVisualOutput, + }, + generate_subimages_damages: taskOptions.generateSubimageDamages ? {} : undefined, + generate_subimages_parts: taskOptions.generateSubimageParts + ? { generate_tight: false } + : undefined, + } + : undefined; +} + +function getWheelAnalysisOptions( + options: CreateInspectionOptions, +): ApiWheelAnalysisTaskPostComponent | undefined { + return options.tasks.includes(TaskName.WHEEL_ANALYSIS) + ? { + status: ProgressStatus.NOT_STARTED, + use_longshots: true, + } + : undefined; +} + +function getImagesOCROptions( + options: CreateInspectionOptions, +): ApiImagesOCRTaskPostComponent | undefined { + return options.tasks.includes(TaskName.IMAGES_OCR) + ? { + status: ProgressStatus.NOT_STARTED, + } + : undefined; +} + +function getTasksOptions(options: CreateInspectionOptions): ApiTasksComponent { + return { + damage_detection: getDamageDetectionOptions(options), + wheel_analysis: getWheelAnalysisOptions(options), + images_ocr: getImagesOCROptions(options), + }; +} + +export function mapApiInspectionPost(options: CreateInspectionOptions): ApiInspectionPost { + return { + tasks: getTasksOptions(options), + vehicle: options.vehicleType ? { vehicle_type: options.vehicleType } : undefined, + damage_severity: { output_format: 'toyota' }, + additional_data: { + user_agent: navigator.userAgent, + connection: navigator.connection, + monk_sdk_version: sdkVersion, + damage_detection_version: 'v2', + use_dynamic_crops: options.useDynamicCrops ?? true, + }, + }; +} diff --git a/packages/network/src/api/inspection/requests.ts b/packages/network/src/api/inspection/requests.ts index c58b34285..5a0fbd9fc 100644 --- a/packages/network/src/api/inspection/requests.ts +++ b/packages/network/src/api/inspection/requests.ts @@ -1,8 +1,8 @@ import ky from 'ky'; import { MonkActionType, MonkGotOneInspectionAction } from '@monkvision/common'; import { getDefaultOptions, MonkAPIConfig } from '../config'; -import { ApiInspectionGet } from '../models'; -import { mapApiInspectionGet } from './mappers'; +import { ApiIdColumn, ApiInspectionGet } from '../models'; +import { CreateInspectionOptions, mapApiInspectionPost, mapApiInspectionGet } from './mappers'; import { MonkAPIRequest } from '../types'; /** @@ -17,8 +17,8 @@ export const getInspection: MonkAPIRequest< MonkGotOneInspectionAction, ApiInspectionGet > = async (id: string, config: MonkAPIConfig) => { - const options = getDefaultOptions(config); - const response = await ky.get(`inspections/${id}`, options); + const kyOptions = getDefaultOptions(config); + const response = await ky.get(`inspections/${id}`, kyOptions); const body = await response.json(); return { action: { @@ -29,3 +29,30 @@ export const getInspection: MonkAPIRequest< body, }; }; + +/** + * Create a new inspection with the given options. See the `CreateInspectionOptions` interface for more details. + * + * @param options The options of the inspection. + * @param config The API config. + * @see CreateInspectionOptions + */ +export const createInspection: MonkAPIRequest< + [options: CreateInspectionOptions], + null, + ApiIdColumn, + { id: string } +> = async (options: CreateInspectionOptions, config: MonkAPIConfig) => { + const kyOptions = getDefaultOptions(config); + const response = await ky.post('inspections', { + ...kyOptions, + json: mapApiInspectionPost(options), + }); + const body = await response.json(); + return { + action: null, + id: body.id, + response, + body, + }; +}; diff --git a/packages/network/src/api/models/inspection.ts b/packages/network/src/api/models/inspection.ts index 58f456d02..db7128293 100644 --- a/packages/network/src/api/models/inspection.ts +++ b/packages/network/src/api/models/inspection.ts @@ -1,15 +1,22 @@ import type { ApiAdditionalData } from './common'; import type { ApiDamages } from './damage'; -import type { ApiImages } from './image'; +import type { ApiImagePost, ApiImages } from './image'; import type { ApiParts } from './part'; import type { ApiPricingV2 } from './pricingV2'; import type { ApiSeverityResults } from './severityResult'; import type { ApiTasks } from './task'; import type { ApiVehicleComponent } from './vehicle'; import type { ApiWheelAnalysis } from './wheelAnalysis'; +import { ApiVehiclePostPatch } from './vehicle'; +import { ApiTasksComponent } from './task'; + +export interface ApiInspectioAdditionalData extends ApiAdditionalData { + is_video_capture?: boolean; + use_3d_projection?: boolean; +} export interface ApiInspectionGet { - additional_data?: ApiAdditionalData; + additional_data?: ApiInspectioAdditionalData; damages: ApiDamages; id: string; images: ApiImages; @@ -21,3 +28,18 @@ export interface ApiInspectionGet { vehicle?: ApiVehicleComponent; wheel_analysis?: ApiWheelAnalysis; } + +export type ApiBusinessClients = 'default' | 'toyota' | 'veb'; + +export interface ApiDamageSeverity { + output_format: ApiBusinessClients; +} + +export interface ApiInspectionPost { + additional_data?: ApiInspectioAdditionalData; + tasks: ApiTasksComponent; + images?: ApiImagePost[]; + vehicle?: ApiVehiclePostPatch; + damage_severity?: ApiDamageSeverity; + pricing?: ApiDamageSeverity; +} diff --git a/packages/network/src/api/models/task.ts b/packages/network/src/api/models/task.ts index 09deb893b..001ec6a94 100644 --- a/packages/network/src/api/models/task.ts +++ b/packages/network/src/api/models/task.ts @@ -33,3 +33,58 @@ export interface ApiTaskGet { } export type ApiTasks = ApiTaskGet[]; + +export type ApiCallbackEventEnum = 'STATUS_SET_TO_DONE' | 'STATUS_SET_TO_ERROR'; + +export type ApiCallbackEvent = ApiCallbackEventEnum | ApiCallbackEventEnum[]; + +export interface ApiCallback { + url: string; + headers: Record; + callback_event?: ApiCallbackEvent; + params: Record; +} + +export type ApiCallbacks = ApiCallback[]; + +export type ApiTaskPostProgressStatus = 'NOT_STARTED' | 'TODO' | 'DONE' | 'VALIDATED'; + +export interface ApiGenerateSubImages { + margin?: number; + damage_view_part_interpolation?: number; + ratio?: number; + quality?: number; + generate_tight?: boolean; +} + +export interface GenerateVisualOutput { + generate_parts?: boolean; + generate_damages?: boolean; +} + +export interface ApiDamageDetectionTaskPostComponent { + status?: ApiTaskPostProgressStatus; + callbacks?: ApiCallbacks; + damage_score_threshold?: number; + generate_subimages_parts?: ApiGenerateSubImages; + generate_subimages_damages?: ApiGenerateSubImages; + generate_visual_output?: GenerateVisualOutput; + scoring?: Record; +} + +export interface ApiWheelAnalysisTaskPostComponent { + status?: ApiTaskPostProgressStatus; + callbacks?: ApiCallbacks; + use_longshots?: boolean; +} + +export interface ApiImagesOCRTaskPostComponent { + status?: ApiTaskPostProgressStatus; + callbacks?: ApiCallbacks; +} + +export interface ApiTasksComponent { + damage_detection?: ApiDamageDetectionTaskPostComponent; + wheel_analysis?: ApiWheelAnalysisTaskPostComponent; + images_ocr?: ApiImagesOCRTaskPostComponent; +} diff --git a/packages/network/src/api/models/vehicle.ts b/packages/network/src/api/models/vehicle.ts index c27a71f3c..a7a4f52d8 100644 --- a/packages/network/src/api/models/vehicle.ts +++ b/packages/network/src/api/models/vehicle.ts @@ -31,3 +31,36 @@ export interface ApiVehicleComponent { vehicle_type?: string; vin?: string; } + +export interface ApiMileage { + value: number; + unit: ApiMileageUnit; +} + +export interface ApiMarketValue { + value: number; + unit: ApiMarketValueUnit; +} + +export interface ApiVehiclePostPatch { + brand?: string; + model?: string; + plate?: string; + vehicle_type?: string; + mileage?: ApiMileage; + market_value?: ApiMarketValue; + serie?: string; + vehicle_style?: string; + vehicle_age?: string; + vin?: string; + color?: string; + exterior_cleanliness?: string; + interior_cleanliness?: string; + date_of_circulation?: string; + owner_info?: ApiOwnerInfo; + duplicate_keys?: boolean; + expertise_requested?: boolean; + car_registration?: boolean; + vehicle_quotation?: number; + trade_in_offer?: number; +} diff --git a/packages/network/src/api/react.ts b/packages/network/src/api/react.ts index 3d78d6211..28ab9b04b 100644 --- a/packages/network/src/api/react.ts +++ b/packages/network/src/api/react.ts @@ -5,14 +5,21 @@ import { MonkAPIRequest, MonkApiResponse } from './types'; import { ApiIdColumn } from './models'; import { MonkApi } from './api'; -function reactifyRequest
( - request: MonkAPIRequest, +function reactifyRequest< + A extends unknown[], + T extends MonkAction | null, + K extends object = ApiIdColumn, + P extends object = Record, +>( + request: MonkAPIRequest, config: MonkAPIConfig, dispatch: Dispatch, -): (...args: A) => Promise> { +): (...args: A) => Promise> { return async (...args: A) => { const result = await request(...args, config); - dispatch(result.action); + if (result.action) { + dispatch(result.action); + } return result; }; } @@ -37,6 +44,13 @@ export function useMonkApi(config: MonkAPIConfig) { * @param id The ID of the inspection. */ getInspection: reactifyRequest(MonkApi.getInspection, config, dispatch), + /** + * Create a new inspection with the given options. See the `CreateInspectionOptions` interface for more details. + * + * @param options The options of the inspection. + * @see CreateInspectionOptions + */ + createInspection: reactifyRequest(MonkApi.createInspection, config, dispatch), /** * Add a new image to an inspection. The resulting action of this request will contain the details of the image that * has been created in the API. diff --git a/packages/network/src/api/task/requests.ts b/packages/network/src/api/task/requests.ts index 812e1db25..2f79c19af 100644 --- a/packages/network/src/api/task/requests.ts +++ b/packages/network/src/api/task/requests.ts @@ -1,5 +1,5 @@ import ky from 'ky'; -import { MonkActionType, MonkUpdatedManyTasksAction } from '@monkvision/common'; +import { MonkActionType, MonkUpdatedManyTasksAction, UpdatedTask } from '@monkvision/common'; import { ProgressStatus, TaskName } from '@monkvision/types'; import { getDefaultOptions, MonkAPIConfig } from '../config'; import { ApiIdColumn } from '../models'; @@ -79,7 +79,7 @@ export const startInspectionTasks: MonkAPIRequest< return { action: { type: MonkActionType.UPDATED_MANY_TASKS, - payload: responses.map((res) => res.action.payload[0]), + payload: responses.map((res) => res.action?.payload[0] as UpdatedTask), }, response: responses[0].response, body: responses[0].body, diff --git a/packages/network/src/api/types.ts b/packages/network/src/api/types.ts index 2d1fa815c..7fcbcd715 100644 --- a/packages/network/src/api/types.ts +++ b/packages/network/src/api/types.ts @@ -6,7 +6,11 @@ import { MonkAPIConfig } from './config'; /** * Type definition for the response of a Monk Api request. */ -export interface MonkApiResponse { +export type MonkApiResponse< + T extends MonkAction | null, + K extends object = ApiIdColumn, + P extends object = Record, +> = P & { /** * The MonkAction to be dispatched in the MonkState if you want to synchronize the local state with the distant state * after this API call has been made. @@ -20,13 +24,14 @@ export interface MonkApiResponse = (...args: [...A, MonkAPIConfig]) => Promise>; + P extends object = Record, +> = (...args: [...A, MonkAPIConfig]) => Promise>; diff --git a/packages/network/src/auth/hooks.ts b/packages/network/src/auth/hooks.ts new file mode 100644 index 000000000..f3837f1b4 --- /dev/null +++ b/packages/network/src/auth/hooks.ts @@ -0,0 +1,96 @@ +import { useAuth0 } from '@auth0/auth0-react'; +import { useEffect, useState } from 'react'; +import { STORAGE_KEY_AUTH_TOKEN, useMonkAppParams } from '@monkvision/common'; + +/** + * Parameters of the `useAuth` hook. + */ +export interface UseAuthParams { + /** + * Boolean indicating if the authentication token should be stored in the local storage or not. + * + * @default true + */ + storeToken?: boolean; +} + +/** + * Handle used to manage the authentication state of a Monk app using the `useAuth` hook. + */ +export interface MonkAuthHandle { + /** + * The current authentication token. This value is `null` if the user is not logged in. + * + * **Warning : If, like in most Monk apps, you plan on using both the `useMonkAppParams` and the `useAuth` hooks, then + * only the token stored and returned by the `useMonkAppParams` should be used. The token of this hook must only be + * used when using the `useAuth` hook only.** + */ + authToken: string | null; + /** + * Callback used to ask the user to log in using an Auth0 pop-up window. This callback returns the resulting token if + * the process was successful, but it also automatically stores the token in the local storage (if `storeToken` is + * `true`) and updates the current auth token value (both in this hook and in the `useMonkAppParams` hook.) + */ + login: () => Promise; + /** + * Callback used to log out the user, both from this application and from Auth0. It also automatically removes the + * token in the local storage (if `storeToken` is `true`) and clears the current auth token value (both in this hook + * and in the `useMonkAppParams` hook.) + */ + logout: () => Promise; +} + +const defaultOptions = { + storeToken: true, +}; + +/** + * Custom hook used to easily handle authentication in Monk applications. It stores the current user's authentication + * token, and returns callbacks used to log in and out of the application using Auth0 pop-ups. + * + * **Warning : If, like in most Monk apps, you plan on using both the `useMonkAppParams` and the `useAuth` hooks, then + * only the token stored and returned by the `useMonkAppParams` should be used. The token of this hook must only be + * used when using the `useAuth` hook only.** + */ +export function useAuth(params?: UseAuthParams): MonkAuthHandle { + const options = { ...defaultOptions, ...(params ?? {}) }; + const { getAccessTokenWithPopup, logout } = useAuth0(); + const { setAuthToken: setAuthTokenParam } = useMonkAppParams(); + const [authToken, setAuthToken] = useState(null); + + useEffect(() => { + if (options.storeToken) { + const token = localStorage.getItem(STORAGE_KEY_AUTH_TOKEN); + if (token) { + setAuthTokenParam(token); + setAuthToken(token); + } + } + }, []); + + const handleLogin = async () => { + const token = await getAccessTokenWithPopup(); + if (token) { + setAuthTokenParam(token); + setAuthToken(token); + if (options.storeToken) { + localStorage.setItem(STORAGE_KEY_AUTH_TOKEN, token); + } + return token; + } + return null; + }; + + const handleLogout = async () => { + setAuthTokenParam(null); + setAuthToken(null); + localStorage.removeItem(STORAGE_KEY_AUTH_TOKEN); + await logout({ logoutParams: { returnTo: window.location.origin } }); + }; + + return { + authToken, + login: handleLogin, + logout: handleLogout, + }; +} diff --git a/packages/network/src/auth/index.ts b/packages/network/src/auth/index.ts index 6b36029d1..538b5c29a 100644 --- a/packages/network/src/auth/index.ts +++ b/packages/network/src/auth/index.ts @@ -1 +1,2 @@ export * from './token'; +export * from './hooks'; diff --git a/packages/network/src/auth/token.ts b/packages/network/src/auth/token.ts index 7afc5a773..54427a7e7 100644 --- a/packages/network/src/auth/token.ts +++ b/packages/network/src/auth/token.ts @@ -40,3 +40,34 @@ export interface MonkJwtPayload extends JwtPayload { export function decodeMonkJwt(token: string): MonkJwtPayload { return jwtDecode(token); } + +/** + * Utility function that checks if the given user has all the required authroizations. You can either pass an auth token + * to be decoded or the JWT payload directly. + */ +export function isUserAuthorized( + tokenOrPayload: MonkJwtPayload | string | null, + permissions: MonkApiPermission[], +): boolean { + if (!tokenOrPayload) { + return false; + } + const payload = + typeof tokenOrPayload === 'object' ? tokenOrPayload : decodeMonkJwt(tokenOrPayload); + return permissions.every((requiredPermission) => + payload.permissions?.includes(requiredPermission), + ); +} + +/** + * Utility function that checks if an authorization token is expired or not. You can either pass an auth token to be + * decoded or the JWT payload directly. + */ +export function isTokenExpired(tokenOrPayload: MonkJwtPayload | string | null): boolean { + if (!tokenOrPayload) { + return false; + } + const payload = + typeof tokenOrPayload === 'object' ? tokenOrPayload : decodeMonkJwt(tokenOrPayload); + return !payload.exp || Math.round(Date.now() / 1000) >= payload.exp; +} diff --git a/packages/network/test/api/config.test.ts b/packages/network/test/api/config.test.ts index aecc35b8a..8565a689d 100644 --- a/packages/network/test/api/config.test.ts +++ b/packages/network/test/api/config.test.ts @@ -25,6 +25,14 @@ describe('Network package API global config utils', () => { ).toEqual(`https://${baseConfig.apiDomain}`); }); + it('should set the Accept header', () => { + expect(getDefaultOptions(baseConfig).headers).toEqual( + expect.objectContaining({ + Accept: 'application/json, text/plain, */*', + }), + ); + }); + it('should set the Access-Control-Allow-Origin header', () => { expect(getDefaultOptions(baseConfig).headers).toEqual( expect.objectContaining({ diff --git a/packages/network/test/api/error.test.ts b/packages/network/test/api/error.test.ts index 06107066c..8bfa627dc 100644 --- a/packages/network/test/api/error.test.ts +++ b/packages/network/test/api/error.test.ts @@ -6,8 +6,10 @@ function createMockError(status: number, message: string): HTTPError { name: 'test-name', message: 'test-message', response: { - status, - json: jest.fn(() => Promise.resolve({ message })), + clone: jest.fn(() => ({ + status, + json: jest.fn(() => Promise.resolve({ message })), + })), }, } as unknown as HTTPError; } @@ -58,6 +60,16 @@ describe('Network Api Error utils', () => { }); }); + it('should put the body of the response in the error object', async () => { + const message = 'test'; + const result = await beforeError(createMockError(111, message)); + expect(result).toEqual( + expect.objectContaining({ + body: { message }, + }), + ); + }); + it('should leave the name and message untouched if the error is not recognized', async () => { const result = await beforeError(createMockError(111, 'test')); const error = createMockError(111, 'test'); diff --git a/packages/network/test/api/inspection/apiInspectionGet.data.json b/packages/network/test/api/inspection/data/apiInspectionGet.data.json similarity index 100% rename from packages/network/test/api/inspection/apiInspectionGet.data.json rename to packages/network/test/api/inspection/data/apiInspectionGet.data.json diff --git a/packages/network/test/api/inspection/apiInspectionGet.data.ts b/packages/network/test/api/inspection/data/apiInspectionGet.data.ts similarity index 100% rename from packages/network/test/api/inspection/apiInspectionGet.data.ts rename to packages/network/test/api/inspection/data/apiInspectionGet.data.ts diff --git a/packages/network/test/api/inspection/data/apiInspectionPost.data.json b/packages/network/test/api/inspection/data/apiInspectionPost.data.json new file mode 100644 index 000000000..7c788e18d --- /dev/null +++ b/packages/network/test/api/inspection/data/apiInspectionPost.data.json @@ -0,0 +1,29 @@ +{ + "tasks": { + "damage_detection": { + "status": "NOT_STARTED", + "damage_score_threshold": 0.5, + "generate_visual_output": { + "generate_damages": true + }, + "generate_subimages_damages": {}, + "generate_subimages_parts": { + "generate_tight": false + } + }, + "wheel_analysis": { + "status": "NOT_STARTED", + "use_longshots": true + } + }, + "vehicle": { + "vehicle_type": "hatchback" + }, + "damage_severity": { + "output_format": "toyota" + }, + "additional_data": { + "damage_detection_version": "v2", + "use_dynamic_crops": true + } +} diff --git a/packages/network/test/api/inspection/data/apiInspectionPost.data.ts b/packages/network/test/api/inspection/data/apiInspectionPost.data.ts new file mode 100644 index 000000000..e1203e144 --- /dev/null +++ b/packages/network/test/api/inspection/data/apiInspectionPost.data.ts @@ -0,0 +1,16 @@ +import { TaskName } from '@monkvision/types'; + +export default { + tasks: [ + TaskName.WHEEL_ANALYSIS, + { + name: TaskName.DAMAGE_DETECTION, + damageScoreThreshold: 0.5, + generateDamageVisualOutput: true, + generateSubimageDamages: true, + generateSubimageParts: true, + }, + ], + vehicleType: 'hatchback', + useDynamicCrops: true, +}; diff --git a/packages/network/test/api/inspection/mappers.test.ts b/packages/network/test/api/inspection/mappers.test.ts index 58c5a0ac8..167c72cdf 100644 --- a/packages/network/test/api/inspection/mappers.test.ts +++ b/packages/network/test/api/inspection/mappers.test.ts @@ -1,13 +1,29 @@ -import data from './apiInspectionGet.data.json'; -import apiInspectionGetParsed from './apiInspectionGet.data'; +import apiInspectionGetData from './data/apiInspectionGet.data.json'; +import apiInspectionGetMapped from './data/apiInspectionGet.data'; +import apiInspectionPostData from './data/apiInspectionPost.data'; +import apiInspectionPostMapped from './data/apiInspectionPost.data.json'; import { ApiInspectionGet } from '../../../src/api/models'; -import { mapApiInspectionGet } from '../../../src/api/inspection/mappers'; +import { + CreateInspectionOptions, + mapApiInspectionGet, + mapApiInspectionPost, +} from '../../../src/api/inspection/mappers'; +import { sdkVersion } from '../../../src/api/config'; describe('Inspection API Mappers', () => { describe('ApiInspectionGet mapper', () => { it('should properly map the ApiInspectionGet object', () => { - const result = mapApiInspectionGet(data as unknown as ApiInspectionGet); - expect(result).toEqual(apiInspectionGetParsed); + const result = mapApiInspectionGet(apiInspectionGetData as unknown as ApiInspectionGet); + expect(result).toEqual(apiInspectionGetMapped); + }); + }); + + describe('ApiInspectionPost mapper', () => { + it('should properly map the ApiInspectionGet object', () => { + const result = mapApiInspectionPost(apiInspectionPostData as CreateInspectionOptions); + (apiInspectionPostMapped.additional_data as any).user_agent = expect.any(String); + (apiInspectionPostMapped.additional_data as any).monk_sdk_version = sdkVersion; + expect(result).toEqual(apiInspectionPostMapped); }); }); }); diff --git a/packages/network/test/api/inspection/requests.test.ts b/packages/network/test/api/inspection/requests.test.ts index f3330d3f4..1f220e79d 100644 --- a/packages/network/test/api/inspection/requests.test.ts +++ b/packages/network/test/api/inspection/requests.test.ts @@ -3,13 +3,15 @@ jest.mock('../../../src/api/config', () => ({ })); jest.mock('../../../src/api/inspection/mappers', () => ({ mapApiInspectionGet: jest.fn(() => ({ test: 'hello' })), + mapApiInspectionPost: jest.fn(() => ({ test: 'ok-ok-ok' })), })); +import { TaskName } from '@monkvision/types'; import ky from 'ky'; import { MonkActionType } from '@monkvision/common'; import { getDefaultOptions } from '../../../src/api/config'; -import { getInspection } from '../../../src/api/inspection'; -import { mapApiInspectionGet } from '../../../src/api/inspection/mappers'; +import { createInspection, getInspection } from '../../../src/api/inspection'; +import { mapApiInspectionGet, mapApiInspectionPost } from '../../../src/api/inspection/mappers'; const apiConfig = { apiDomain: 'apiDomain', authToken: 'authToken' }; @@ -37,4 +39,28 @@ describe('Inspection requests', () => { }); }); }); + + describe('createInspection request', () => { + it('should make the proper API call and map the request payload', async () => { + const body = { id: 'test-fake-id' }; + const response = { json: jest.fn(() => Promise.resolve(body)) }; + (ky.post as jest.Mock).mockImplementationOnce(() => Promise.resolve(response)); + const options = { tasks: [TaskName.DAMAGE_DETECTION] }; + const result = await createInspection(options, apiConfig); + + expect(getDefaultOptions).toHaveBeenCalledWith(apiConfig); + expect(mapApiInspectionPost).toHaveBeenCalledWith(options); + const apiInspectionPost = (mapApiInspectionPost as jest.Mock).mock.results[0].value; + expect(ky.post).toHaveBeenCalledWith('inspections', { + ...getDefaultOptions(apiConfig), + json: apiInspectionPost, + }); + expect(result).toEqual({ + action: null, + id: body.id, + response, + body, + }); + }); + }); }); diff --git a/packages/network/test/api/react.test.ts b/packages/network/test/api/react.test.ts index 441e57e01..2d6f0b67d 100644 --- a/packages/network/test/api/react.test.ts +++ b/packages/network/test/api/react.test.ts @@ -3,10 +3,7 @@ jest.mock('../../src/api/api', () => ({ getInspection: jest.fn(() => Promise.resolve({ action: { test: 'getInspection' }, test: 'getInspection' }), ), - addImage: jest.fn(() => Promise.resolve({ action: { test: 'addImage' }, test: 'addImage' })), - updateTaskStatus: jest.fn(() => - Promise.resolve({ action: { test: 'updateTaskStatus' }, test: 'updateTaskStatus' }), - ), + createInspection: jest.fn(() => Promise.resolve({ action: null, test: 'createInspection' })), }, })); @@ -20,7 +17,7 @@ describe('Monk API React utilities', () => { }); describe('useMonkApi hook', () => { - it('should properly reactify each request in the MonkApi object', () => { + it('should properly reactify each request in the MonkApi object', async () => { const config: MonkAPIConfig = { apiDomain: 'wow-test', authToken: 'yessss' }; const { result, unmount } = renderHook(useMonkApi, { initialProps: config, @@ -29,17 +26,26 @@ describe('Monk API React utilities', () => { expect(useMonkState).toHaveBeenCalledTimes(1); const dispatchMock = (useMonkState as jest.Mock).mock.results[0].value.dispatch as jest.Mock; - Object.keys(MonkApi).forEach(async (requestKey, index) => { - dispatchMock.mockClear(); - expect(typeof (result.current as any)[requestKey]).toBe('function'); + expect(typeof result.current.getInspection).toBe('function'); + + let param = 'test-getInspection'; + let resultMock = await result.current.getInspection(param); + let requestMock = MonkApi.getInspection as jest.Mock; + expect(requestMock).toHaveBeenCalledWith(param, config); + let requestResultMock = await requestMock.mock.results[0].value; + expect(dispatchMock).toHaveBeenCalledWith(requestResultMock.action); + expect(resultMock).toBe(requestResultMock); + + dispatchMock.mockClear(); + + param = 'test-createInspection'; + resultMock = await (result.current.createInspection as any)(param); + requestMock = MonkApi.createInspection as jest.Mock; + expect(requestMock).toHaveBeenCalledWith(param, config); + requestResultMock = await requestMock.mock.results[0].value; + expect(dispatchMock).not.toHaveBeenCalled(); + expect(resultMock).toBe(requestResultMock); - const resultMock = await (result.current as any)[requestKey](index, index * 2); - const requestMock = MonkApi[requestKey as keyof typeof MonkApi] as jest.Mock; - expect(requestMock).toHaveBeenCalledWith(index, index * 2, config); - const requestResultMock = await requestMock.mock.results[0].value; - expect(dispatchMock).toHaveBeenCalledWith(requestResultMock.action); - expect(resultMock).toBe(requestResultMock); - }); unmount(); }); }); diff --git a/packages/network/test/auth/hooks.test.ts b/packages/network/test/auth/hooks.test.ts new file mode 100644 index 000000000..6a5333d04 --- /dev/null +++ b/packages/network/test/auth/hooks.test.ts @@ -0,0 +1,127 @@ +import { STORAGE_KEY_AUTH_TOKEN, useMonkAppParams } from '@monkvision/common'; +import { useAuth0 } from '@auth0/auth0-react'; +import { act, waitFor } from '@testing-library/react'; +import { renderHook } from '@testing-library/react-hooks'; +import { useAuth } from '../../src'; + +describe('Authentication hooks', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('useAuth hook', () => { + it('should fetch the token from the local storage if asked to', () => { + const token = 'test-token-test'; + const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => token); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: true } }); + + expect(useMonkAppParams).toHaveBeenCalled(); + const { setAuthToken } = (useMonkAppParams as jest.Mock).mock.results[0].value; + expect(spy).toHaveBeenCalledWith(STORAGE_KEY_AUTH_TOKEN); + expect(setAuthToken).toHaveBeenCalledWith(token); + expect(result.current.authToken).toEqual(token); + + unmount(); + }); + + it('should not fetch the token from the local storage if not asked to', () => { + const spy = jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => 'test'); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: false } }); + + expect(useMonkAppParams).toHaveBeenCalled(); + const { setAuthToken } = (useMonkAppParams as jest.Mock).mock.results[0].value; + expect(setAuthToken).not.toHaveBeenCalled(); + expect(spy).not.toHaveBeenCalled(); + expect(result.current.authToken).toBeNull(); + + unmount(); + }); + + it('should properly set the token after the login function is called', async () => { + const token = 'test-token-test'; + const getAccessTokenWithPopup = jest.fn(() => Promise.resolve(token)); + (useAuth0 as jest.Mock).mockImplementation(() => ({ getAccessTokenWithPopup })); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: false } }); + + expect(useMonkAppParams).toHaveBeenCalled(); + const { setAuthToken } = (useMonkAppParams as jest.Mock).mock.results[0].value; + expect(setAuthToken).not.toHaveBeenCalled(); + expect(result.current.authToken).toBeNull(); + expect(getAccessTokenWithPopup).not.toHaveBeenCalled(); + + let resultToken = null; + await act(async () => { + resultToken = await result.current.login(); + }); + + expect(getAccessTokenWithPopup).toHaveBeenCalled(); + expect(resultToken).toEqual(token); + expect(setAuthToken).toHaveBeenCalledWith(token); + expect(result.current.authToken).toEqual(token); + + unmount(); + }); + + it('should store the token in the local storage after a login if asked to', async () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null); + const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); + const token = 'test-token-test'; + const getAccessTokenWithPopup = jest.fn(() => Promise.resolve(token)); + (useAuth0 as jest.Mock).mockImplementation(() => ({ getAccessTokenWithPopup })); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: true } }); + + expect(spy).not.toHaveBeenCalled(); + + await act(async () => { + await result.current.login(); + }); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith(STORAGE_KEY_AUTH_TOKEN, token); + }); + + unmount(); + }); + + it('should not store the token in the local storage after a login if not asked to', async () => { + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => null); + const spy = jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {}); + const getAccessTokenWithPopup = jest.fn(() => Promise.resolve('')); + (useAuth0 as jest.Mock).mockImplementation(() => ({ getAccessTokenWithPopup })); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: false } }); + + await act(async () => { + await result.current.login(); + }); + + expect(spy).not.toHaveBeenCalled(); + + unmount(); + }); + + it('should properly clear the token after the logout function is called', async () => { + const token = 'test-token-test'; + jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => token); + const spy = jest.spyOn(Storage.prototype, 'removeItem').mockImplementation(() => {}); + const logout = jest.fn(() => Promise.resolve()); + (useAuth0 as jest.Mock).mockImplementation(() => ({ logout })); + const { result, unmount } = renderHook(useAuth, { initialProps: { storeToken: true } }); + + expect(result.current.authToken).toEqual(token); + + await act(async () => { + await result.current.logout(); + }); + + expect((useMonkAppParams as jest.Mock).mock.results.length).toBeGreaterThan(1); + const { setAuthToken } = (useMonkAppParams as jest.Mock).mock.results[1].value; + expect(logout).toHaveBeenCalledWith({ logoutParams: { returnTo: window.location.origin } }); + expect(result.current.authToken).toBeNull(); + expect(setAuthToken).toHaveBeenCalledWith(null); + expect(spy).toHaveBeenCalledWith(STORAGE_KEY_AUTH_TOKEN); + + unmount(); + (useAuth0 as jest.Mock).mockRestore(); + }); + }); +}); diff --git a/packages/network/test/auth/token.test.ts b/packages/network/test/auth/token.test.ts index 6b561143e..fd3ab03a3 100644 --- a/packages/network/test/auth/token.test.ts +++ b/packages/network/test/auth/token.test.ts @@ -1,9 +1,17 @@ jest.mock('jwt-decode', () => ({ - jwtDecode: jest.fn(), + jwtDecode: jest.fn(() => ({ + permissions: ['monk_core_api:inspections:create', 'monk_core_api:inspections:read'], + })), })); import { jwtDecode } from 'jwt-decode'; -import { decodeMonkJwt } from '../../src'; +import { + decodeMonkJwt, + isTokenExpired, + isUserAuthorized, + MonkApiPermission, + MonkJwtPayload, +} from '../../src'; describe('Network package JWT utils', () => { afterEach(() => { @@ -14,7 +22,7 @@ describe('Network package JWT utils', () => { it('should call the jwtDecode function with the given token', () => { const encoded = 'testestest'; const decoded = { test: 'coucou' }; - (jwtDecode as jest.Mock).mockImplementation(() => decoded); + (jwtDecode as jest.Mock).mockImplementationOnce(() => decoded); const result = decodeMonkJwt(encoded); @@ -22,4 +30,99 @@ describe('Network package JWT utils', () => { expect(jwtDecode).toHaveBeenCalledWith(encoded); }); }); + + describe('isUserAuthorized function', () => { + it('should return false if the token is undefined', () => { + expect(isUserAuthorized(null, [MonkApiPermission.INSPECTION_CREATE])).toBe(false); + }); + + it('should return true if the permission list is empty', () => { + expect(isUserAuthorized('token', [])).toBe(true); + }); + + it('should return true if the user had the proper permissions for a string param', () => { + const { permissions } = jwtDecode(''); + expect(isUserAuthorized('token', permissions as any)).toBe(true); + }); + + it('should return false if the user is missing a permission for a string param', () => { + const { permissions } = jwtDecode(''); + expect( + isUserAuthorized('token', [ + ...(permissions as any), + MonkApiPermission.INSPECTION_UPDATE_ORGANIZATION, + ]), + ).toBe(false); + }); + + it('should return true if the user had the proper permissions for an object param', () => { + const permissions = [MonkApiPermission.INSPECTION_CREATE, MonkApiPermission.INSPECTION_READ]; + const payload = { permissions }; + expect(isUserAuthorized(payload, permissions)).toBe(true); + }); + + it('should return false if the user is missing a permission for an object param', () => { + const permissions = [MonkApiPermission.INSPECTION_CREATE, MonkApiPermission.INSPECTION_READ]; + const payload = { permissions }; + expect( + isUserAuthorized(payload, [ + ...permissions, + MonkApiPermission.INSPECTION_UPDATE_ORGANIZATION, + ]), + ).toBe(false); + }); + }); + + describe('isTokenExpired function', () => { + it('should return false if the token is undefined', () => { + expect(isTokenExpired(null)).toBe(false); + }); + + it('should return true if the exp field is undefined for a string param', () => { + (jwtDecode as jest.Mock).mockImplementationOnce(() => ({ + exp: undefined, + })); + const token = 'test-token-test'; + expect(isTokenExpired(token)).toBe(true); + expect(jwtDecode).toHaveBeenCalledWith(token); + }); + + it('should return true if the token is expired for a string param', () => { + (jwtDecode as jest.Mock).mockImplementationOnce(() => ({ + exp: Date.now() / 1000 - 10000, + })); + expect(isTokenExpired('test')).toBe(true); + }); + + it('should return false if the token is not expired for a string param', () => { + (jwtDecode as jest.Mock).mockImplementationOnce(() => ({ + exp: Date.now() / 1000 + 10000, + })); + expect(isTokenExpired('test')).toBe(false); + }); + + it('should return true if the exp field is undefined for an object param', () => { + expect( + isTokenExpired({ + exp: undefined, + }), + ).toBe(true); + }); + + it('should return true if the token is expired for an object param', () => { + expect( + isTokenExpired({ + exp: Date.now() / 1000 - 10000, + }), + ).toBe(true); + }); + + it('should return false if the token is not expired for an object param', () => { + expect( + isTokenExpired({ + exp: Date.now() / 1000 + 10000, + }), + ).toBe(false); + }); + }); }); diff --git a/packages/types/src/theme/theme.ts b/packages/types/src/theme/theme.ts index 0400e3df3..e5b91846b 100644 --- a/packages/types/src/theme/theme.ts +++ b/packages/types/src/theme/theme.ts @@ -1,3 +1,4 @@ +import { CSSProperties } from 'react'; import { MonkPalette } from './palette'; import { ThemeUtils } from './utils'; @@ -13,4 +14,8 @@ export interface MonkTheme { * Theme utils. */ utils: ThemeUtils; + /** + * Root styles of the application that define global styles such as font color etc. + */ + rootStyles: CSSProperties; } diff --git a/yarn.lock b/yarn.lock index f7f726d12..59faeeee1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -231,6 +231,25 @@ __metadata: languageName: node linkType: hard +"@auth0/auth0-react@npm:^2.2.4": + version: 2.2.4 + resolution: "@auth0/auth0-react@npm:2.2.4" + dependencies: + "@auth0/auth0-spa-js": ^2.1.3 + peerDependencies: + react: ^16.11.0 || ^17 || ^18 + react-dom: ^16.11.0 || ^17 || ^18 + checksum: 10b8d4e9eaf6fe4f1e5bdeaae61bfade9a8439f0bfb5008aea34691ce45924bc92b046757884bcae99dd8902dcb62e77295e35692d51df2ed8eca6d0d7bbd335 + languageName: node + linkType: hard + +"@auth0/auth0-spa-js@npm:^2.1.3": + version: 2.1.3 + resolution: "@auth0/auth0-spa-js@npm:2.1.3" + checksum: aff5f3ab8a15ad63e9956e638ea13955dea22268b0d46d93561ae7accce0147198fc01203cbfe65637d709836c7ecf6a7efc5d38742dd0e251eb1387849efff5 + languageName: node + linkType: hard + "@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.16.0, @babel/code-frame@npm:^7.22.13, @babel/code-frame@npm:^7.8.3": version: 7.22.13 resolution: "@babel/code-frame@npm:7.22.13" @@ -1840,6 +1859,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.23.8": + version: 7.24.0 + resolution: "@babel/runtime@npm:7.24.0" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 7a6a5d40fbdd68491ec183ba2e631c07415119960083b4fd76564cce3751e9acd2f12ab89575e38496fa389fa06d458732776e69ee1858e366cc3fbdb049f847 + languageName: node + linkType: hard + "@babel/template@npm:^7.12.7, @babel/template@npm:^7.22.15, @babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3": version: 7.22.15 resolution: "@babel/template@npm:7.22.15" @@ -3464,6 +3492,7 @@ __metadata: peerDependencies: react: ^17.0.2 react-dom: ^17.0.2 + react-router-dom: ^6.22.3 languageName: unknown linkType: soft @@ -3476,6 +3505,7 @@ __metadata: "@monkvision/eslint-config-base": 4.0.0 "@monkvision/eslint-config-typescript": 4.0.0 "@monkvision/eslint-config-typescript-react": 4.0.0 + "@monkvision/jest-config": 4.0.0 "@monkvision/prettier-config": 4.0.0 "@monkvision/types": 4.0.0 "@monkvision/typescript-config": 4.0.0 @@ -3513,6 +3543,7 @@ __metadata: peerDependencies: react: ^17.0.2 react-dom: ^17.0.2 + react-router-dom: ^6.22.3 languageName: unknown linkType: soft @@ -3533,6 +3564,7 @@ __metadata: "@types/jest": ^29.2.2 "@types/node": ^18.11.9 "@types/pako": ^2 + "@types/sort-by": ^1 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 eslint: ^8.29.0 @@ -3551,17 +3583,21 @@ __metadata: i18next: ^23.4.5 jest: ^29.3.1 jest-environment-jsdom: ^29.6.2 + localforage: ^1.10.0 + match-sorter: ^6.3.4 mkdirp: ^1.0.4 pako: ^2.1.0 prettier: ^2.7.1 react-i18next: ^13.2.0 regexpp: ^3.2.0 rimraf: ^3.0.2 + sort-by: ^1.2.0 ts-jest: ^29.0.3 typescript: ^4.8.4 peerDependencies: react: ^17.0.2 react-dom: ^17.0.2 + react-router-dom: ^6.22.3 languageName: unknown linkType: soft @@ -3664,6 +3700,7 @@ __metadata: peerDependencies: react: ^17.0.2 react-dom: ^17.0.2 + react-router-dom: ^6.22.3 languageName: unknown linkType: soft @@ -3756,8 +3793,10 @@ __metadata: ts-jest: ^29.0.3 typescript: ^4.8.4 peerDependencies: + "@auth0/auth0-react": ^2.2.4 react: ^17.0.2 react-dom: ^17.0.2 + react-router-dom: ^6.22.3 languageName: unknown linkType: soft @@ -4355,6 +4394,13 @@ __metadata: languageName: node linkType: hard +"@remix-run/router@npm:1.15.3": + version: 1.15.3 + resolution: "@remix-run/router@npm:1.15.3" + checksum: 9e70bd334d99fdf9285f0885c10353d7e25f66369080f551d997e3ce204e1af3a12d6f12b091f94a2dc9a54c80598bbe3c5194b57cbae17b7b40ab815dcd49a0 + languageName: node + linkType: hard + "@rollup/plugin-babel@npm:^5.2.0": version: 5.3.1 resolution: "@rollup/plugin-babel@npm:5.3.1" @@ -5520,7 +5566,7 @@ __metadata: languageName: node linkType: hard -"@types/react-router-dom@npm:*": +"@types/react-router-dom@npm:*, @types/react-router-dom@npm:^5.3.3": version: 5.3.3 resolution: "@types/react-router-dom@npm:5.3.3" dependencies: @@ -5650,6 +5696,13 @@ __metadata: languageName: node linkType: hard +"@types/sort-by@npm:^1": + version: 1.2.3 + resolution: "@types/sort-by@npm:1.2.3" + checksum: edf61bad1538c5861e8187f45ea86476420cfd2e51e5d089e42eb21cf231afa91973e77e956069d85b4d67b21df4932ecd994062ca2155560db20b6a5ecea793 + languageName: node + linkType: hard + "@types/stack-utils@npm:^2.0.0": version: 2.0.1 resolution: "@types/stack-utils@npm:2.0.1" @@ -7198,6 +7251,15 @@ __metadata: languageName: node linkType: hard +"btoa@npm:^1.2.1": + version: 1.2.1 + resolution: "btoa@npm:1.2.1" + bin: + btoa: bin/btoa.js + checksum: afbf004fb1b1d530e053ffa66ef5bd3878b101c59d808ac947fcff96810b4452abba2b54be687adadea2ba9efc7af48b04228742789bf824ef93f103767e690c + languageName: node + linkType: hard + "buffer-equal-constant-time@npm:1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -9286,7 +9348,7 @@ __metadata: languageName: node linkType: hard -"ejs@npm:^3.1.6, ejs@npm:^3.1.7": +"ejs@npm:^3.1.5, ejs@npm:^3.1.6, ejs@npm:^3.1.7": version: 3.1.9 resolution: "ejs@npm:3.1.9" dependencies: @@ -11838,6 +11900,13 @@ __metadata: languageName: node linkType: hard +"immediate@npm:~3.0.5": + version: 3.0.6 + resolution: "immediate@npm:3.0.6" + checksum: f9b3486477555997657f70318cc8d3416159f208bec4cca3ff3442fd266bc23f50f0c9bd8547e1371a6b5e82b821ec9a7044a4f7b944798b25aa3cc6d5e63e62 + languageName: node + linkType: hard + "immer@npm:^9.0.7": version: 9.0.21 resolution: "immer@npm:9.0.21" @@ -12540,7 +12609,7 @@ __metadata: languageName: node linkType: hard -"is-wsl@npm:^2.2.0": +"is-wsl@npm:^2.1.1, is-wsl@npm:^2.2.0": version: 2.2.0 resolution: "is-wsl@npm:2.2.0" dependencies: @@ -14304,6 +14373,15 @@ __metadata: languageName: node linkType: hard +"lie@npm:3.1.1": + version: 3.1.1 + resolution: "lie@npm:3.1.1" + dependencies: + immediate: ~3.0.5 + checksum: 6da9f2121d2dbd15f1eca44c0c7e211e66a99c7b326ec8312645f3648935bc3a658cf0e9fa7b5f10144d9e2641500b4f55bd32754607c3de945b5f443e50ddd1 + languageName: node + linkType: hard + "lilconfig@npm:^2.0.3, lilconfig@npm:^2.0.5, lilconfig@npm:^2.1.0": version: 2.1.0 resolution: "lilconfig@npm:2.1.0" @@ -14374,6 +14452,15 @@ __metadata: languageName: node linkType: hard +"localforage@npm:^1.10.0": + version: 1.10.0 + resolution: "localforage@npm:1.10.0" + dependencies: + lie: 3.1.1 + checksum: f2978b434dafff9bcb0d9498de57d97eba165402419939c944412e179cab1854782830b5ec196212560b22712d1dd03918939f59cf1d4fc1d756fca7950086cf + languageName: node + linkType: hard + "locate-path@npm:^2.0.0": version: 2.0.0 resolution: "locate-path@npm:2.0.0" @@ -14741,6 +14828,16 @@ __metadata: languageName: node linkType: hard +"match-sorter@npm:^6.3.4": + version: 6.3.4 + resolution: "match-sorter@npm:6.3.4" + dependencies: + "@babel/runtime": ^7.23.8 + remove-accents: 0.5.0 + checksum: 950c1600173a639e216947559a389b64258d52f33aea3a6ddb97500589888b83c976a028f731f40bc08d9d8af20de7916992fabb403f38330183a1df44c7634b + languageName: node + linkType: hard + "mdast-squeeze-paragraphs@npm:^4.0.0": version: 4.0.0 resolution: "mdast-squeeze-paragraphs@npm:4.0.0" @@ -15134,16 +15231,7 @@ __metadata: languageName: node linkType: hard -"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": - version: 1.0.4 - resolution: "mkdirp@npm:1.0.4" - bin: - mkdirp: bin/cmd.js - checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f - languageName: node - linkType: hard - -"mkdirp@npm:~0.5.1": +"mkdirp@npm:^0.5.1, mkdirp@npm:~0.5.1": version: 0.5.6 resolution: "mkdirp@npm:0.5.6" dependencies: @@ -15154,6 +15242,15 @@ __metadata: languageName: node linkType: hard +"mkdirp@npm:^1.0.3, mkdirp@npm:^1.0.4": + version: 1.0.4 + resolution: "mkdirp@npm:1.0.4" + bin: + mkdirp: bin/cmd.js + checksum: a96865108c6c3b1b8e1d5e9f11843de1e077e57737602de1b82030815f311be11f96f09cce59bd5b903d0b29834733e5313f9301e3ed6d6f6fba2eae0df4298f + languageName: node + linkType: hard + "modify-values@npm:^1.0.1": version: 1.0.1 resolution: "modify-values@npm:1.0.1" @@ -15161,61 +15258,27 @@ __metadata: languageName: node linkType: hard -"monk-documentation@workspace:documentation": +"monk-demo-app@workspace:apps/demo-app": version: 0.0.0-use.local - resolution: "monk-documentation@workspace:documentation" + resolution: "monk-demo-app@workspace:apps/demo-app" dependencies: - "@docusaurus/core": 2.4.3 - "@docusaurus/module-type-aliases": 2.4.3 - "@docusaurus/plugin-content-pages": ^2.4.3 - "@docusaurus/preset-classic": 2.4.3 - "@docusaurus/theme-common": ^2.4.3 - "@mdx-js/react": ^1.6.22 + "@auth0/auth0-react": ^2.2.4 + "@babel/core": ^7.22.9 + "@monkvision/common": 4.0.0 "@monkvision/common-ui-web": 4.0.0 "@monkvision/eslint-config-base": 4.0.0 "@monkvision/eslint-config-typescript": 4.0.0 "@monkvision/eslint-config-typescript-react": 4.0.0 - "@monkvision/prettier-config": 4.0.0 - "@monkvision/sights": 4.0.0 - "@monkvision/svgo-config": 4.0.0 - "@monkvision/types": 4.0.0 - "@tsconfig/docusaurus": ^1.0.5 - "@typescript-eslint/eslint-plugin": ^5.43.0 - "@typescript-eslint/parser": ^5.43.0 - clsx: ^1.2.1 - eslint: ^8.29.0 - eslint-config-airbnb-base: ^15.0.0 - eslint-config-prettier: ^8.5.0 - eslint-plugin-eslint-comments: ^3.2.0 - eslint-plugin-import: ^2.26.0 - eslint-plugin-jest: ^25.3.0 - eslint-plugin-jsx-a11y: ^6.7.1 - eslint-plugin-prettier: ^4.2.1 - eslint-plugin-promise: ^6.1.1 - eslint-plugin-react: ^7.33.1 - eslint-plugin-react-hooks: ^4.6.0 - prettier: ^2.7.1 - prism-react-renderer: ^1.3.5 - react: ^17.0.2 - react-dom: ^17.0.2 - svgo: ^3.0.2 - typescript: ^4.8.4 - languageName: unknown - linkType: soft - -"monk-test-app@workspace:apps/monk-test-app": - version: 0.0.0-use.local - resolution: "monk-test-app@workspace:apps/monk-test-app" - dependencies: - "@babel/core": ^7.22.9 - "@monkvision/camera-web": 4.0.0 - "@monkvision/common": 4.0.0 - "@monkvision/common-ui-web": 4.0.0 "@monkvision/inspection-capture-web": 4.0.0 + "@monkvision/jest-config": 4.0.0 "@monkvision/monitoring": 4.0.0 + "@monkvision/network": 4.0.0 + "@monkvision/prettier-config": 4.0.0 "@monkvision/sentry": 4.0.0 "@monkvision/sights": 4.0.0 + "@monkvision/test-utils": 4.0.0 "@monkvision/types": 4.0.0 + "@monkvision/typescript-config": 4.0.0 "@testing-library/dom": ^8.20.0 "@testing-library/jest-dom": ^5.16.5 "@testing-library/react": ^12.1.5 @@ -15226,39 +15289,85 @@ __metadata: "@types/node": ^16.18.18 "@types/react": ^17.0.2 "@types/react-dom": ^17.0.2 + "@types/react-router-dom": ^5.3.3 + "@types/sort-by": ^1 + "@typescript-eslint/eslint-plugin": ^5.43.0 + "@typescript-eslint/parser": ^5.43.0 axios: ^1.5.0 eslint: ^8.29.0 + eslint-config-airbnb-base: ^15.0.0 + eslint-config-prettier: ^8.5.0 + eslint-formatter-pretty: ^4.1.0 + eslint-plugin-eslint-comments: ^3.2.0 + eslint-plugin-import: ^2.26.0 + eslint-plugin-jest: ^25.3.0 eslint-plugin-jsx-a11y: ^6.7.1 - eslint-plugin-react: ^7.33.1 - eslint-plugin-react-hooks: ^4.6.0 + eslint-plugin-prettier: ^4.2.1 + eslint-plugin-promise: ^6.1.1 + eslint-plugin-react: ^7.27.1 + eslint-plugin-react-hooks: ^4.3.0 + eslint-utils: ^3.0.0 i18next: ^23.4.5 i18next-browser-languagedetector: ^7.1.0 jest: ^29.3.1 jest-watch-typeahead: ^2.2.2 + localforage: ^1.10.0 + match-sorter: ^6.3.4 prettier: ^2.7.1 react: ^17.0.2 react-dom: ^17.0.2 react-i18next: ^13.2.0 + react-router-dom: ^6.22.3 react-scripts: 5.0.1 regexpp: ^3.2.0 + sort-by: ^1.2.0 + source-map-explorer: ^2.5.3 ts-jest: ^29.0.3 typescript: ^4.9.5 web-vitals: ^2.1.4 - peerDependencies: + languageName: unknown + linkType: soft + +"monk-documentation@workspace:documentation": + version: 0.0.0-use.local + resolution: "monk-documentation@workspace:documentation" + dependencies: + "@docusaurus/core": 2.4.3 + "@docusaurus/module-type-aliases": 2.4.3 + "@docusaurus/plugin-content-pages": ^2.4.3 + "@docusaurus/preset-classic": 2.4.3 + "@docusaurus/theme-common": ^2.4.3 + "@mdx-js/react": ^1.6.22 + "@monkvision/common-ui-web": 4.0.0 + "@monkvision/eslint-config-base": 4.0.0 + "@monkvision/eslint-config-typescript": 4.0.0 + "@monkvision/eslint-config-typescript-react": 4.0.0 + "@monkvision/prettier-config": 4.0.0 + "@monkvision/sights": 4.0.0 + "@monkvision/svgo-config": 4.0.0 + "@monkvision/types": 4.0.0 + "@tsconfig/docusaurus": ^1.0.5 "@typescript-eslint/eslint-plugin": ^5.43.0 "@typescript-eslint/parser": ^5.43.0 + clsx: ^1.2.1 + eslint: ^8.29.0 eslint-config-airbnb-base: ^15.0.0 eslint-config-prettier: ^8.5.0 - eslint-formatter-pretty: ^4.1.0 eslint-plugin-eslint-comments: ^3.2.0 eslint-plugin-import: ^2.26.0 eslint-plugin-jest: ^25.3.0 eslint-plugin-jsx-a11y: ^6.7.1 eslint-plugin-prettier: ^4.2.1 eslint-plugin-promise: ^6.1.1 - eslint-plugin-react: ^7.27.1 - eslint-plugin-react-hooks: ^4.3.0 - eslint-utils: ^3.0.0 + eslint-plugin-react: ^7.33.1 + eslint-plugin-react-hooks: ^4.6.0 + prettier: ^2.7.1 + prism-react-renderer: ^1.3.5 + react: ^17.0.2 + react-dom: ^17.0.2 + react-router-dom: ^6.22.3 + svgo: ^3.0.2 + typescript: ^4.8.4 languageName: unknown linkType: soft @@ -15869,6 +15978,13 @@ __metadata: languageName: node linkType: hard +"object-path@npm:0.6.0": + version: 0.6.0 + resolution: "object-path@npm:0.6.0" + checksum: e9d7a901bcfcfe39b195cdac5bb2b8c1f030abdf86f45ed6d0385c5d0c38a8c5e4b4a30c88bf483d6b3b37b5a8d620d004dbfc6db6be8565f88e9cf0b078e945 + languageName: node + linkType: hard + "object.assign@npm:^4.1.0, object.assign@npm:^4.1.2, object.assign@npm:^4.1.4": version: 4.1.4 resolution: "object.assign@npm:4.1.4" @@ -15990,6 +16106,16 @@ __metadata: languageName: node linkType: hard +"open@npm:^7.3.1": + version: 7.4.2 + resolution: "open@npm:7.4.2" + dependencies: + is-docker: ^2.0.0 + is-wsl: ^2.1.1 + checksum: 3333900ec0e420d64c23b831bc3467e57031461d843c801f569b2204a1acc3cd7b3ec3c7897afc9dde86491dfa289708eb92bba164093d8bd88fb2c231843c91 + languageName: node + linkType: hard + "open@npm:^8.0.9, open@npm:^8.4.0": version: 8.4.2 resolution: "open@npm:8.4.2" @@ -18085,6 +18211,19 @@ __metadata: languageName: node linkType: hard +"react-router-dom@npm:^6.22.3": + version: 6.22.3 + resolution: "react-router-dom@npm:6.22.3" + dependencies: + "@remix-run/router": 1.15.3 + react-router: 6.22.3 + peerDependencies: + react: ">=16.8" + react-dom: ">=16.8" + checksum: 5ae3759a70e4123cd4b8efbb82199a69f5d8c4a7a434d186d2ec7b532b6ef3302df2a98e5c27db977d3f0d725c7a279010a16ae77a3bf6257f1fee96123d8b77 + languageName: node + linkType: hard + "react-router@npm:5.3.4, react-router@npm:^5.3.3": version: 5.3.4 resolution: "react-router@npm:5.3.4" @@ -18104,6 +18243,17 @@ __metadata: languageName: node linkType: hard +"react-router@npm:6.22.3": + version: 6.22.3 + resolution: "react-router@npm:6.22.3" + dependencies: + "@remix-run/router": 1.15.3 + peerDependencies: + react: ">=16.8" + checksum: 1f7d9a5a849761ff69ef8f3d3131b4c1c25d18b76317ba5ad6f0d9421192c0b8b71ab0cc818c57aad7b81ada725559e513307d0ab43296a460262f0358602672 + languageName: node + linkType: hard + "react-scripts@npm:5.0.1": version: 5.0.1 resolution: "react-scripts@npm:5.0.1" @@ -18550,6 +18700,13 @@ __metadata: languageName: node linkType: hard +"remove-accents@npm:0.5.0": + version: 0.5.0 + resolution: "remove-accents@npm:0.5.0" + checksum: 7045b37015acb03df406d21f9cbe93c3fcf2034189f5d2e33b1dace9c7d6bdcd839929905ced21a5d76c58553557e1a42651930728702312a5774179d5b9147b + languageName: node + linkType: hard + "renderkid@npm:^3.0.0": version: 3.0.0 resolution: "renderkid@npm:3.0.0" @@ -18777,6 +18934,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:~2.6.2": + version: 2.6.3 + resolution: "rimraf@npm:2.6.3" + dependencies: + glob: ^7.1.3 + bin: + rimraf: ./bin.js + checksum: 3ea587b981a19016297edb96d1ffe48af7e6af69660e3b371dbfc73722a73a0b0e9be5c88089fbeeb866c389c1098e07f64929c7414290504b855f54f901ab10 + languageName: node + linkType: hard + "rollup-plugin-terser@npm:^7.0.0": version: 7.0.2 resolution: "rollup-plugin-terser@npm:7.0.2" @@ -19394,6 +19562,15 @@ __metadata: languageName: node linkType: hard +"sort-by@npm:^1.2.0": + version: 1.2.0 + resolution: "sort-by@npm:1.2.0" + dependencies: + object-path: 0.6.0 + checksum: 82c9812aa318eff68669fe25cc0168d172ccbff9d34d52449c345631118b5e29608e3524e9028b23dc4959c8a7601b19a40ddbe3eb05234c0a54792912c5ad2f + languageName: node + linkType: hard + "sort-css-media-queries@npm:2.1.0": version: 2.1.0 resolution: "sort-css-media-queries@npm:2.1.0" @@ -19417,6 +19594,29 @@ __metadata: languageName: node linkType: hard +"source-map-explorer@npm:^2.5.3": + version: 2.5.3 + resolution: "source-map-explorer@npm:2.5.3" + dependencies: + btoa: ^1.2.1 + chalk: ^4.1.0 + convert-source-map: ^1.7.0 + ejs: ^3.1.5 + escape-html: ^1.0.3 + glob: ^7.1.6 + gzip-size: ^6.0.0 + lodash: ^4.17.20 + open: ^7.3.1 + source-map: ^0.7.4 + temp: ^0.9.4 + yargs: ^16.2.0 + bin: + sme: bin/cli.js + source-map-explorer: bin/cli.js + checksum: 1d4e619d7eb224f38a3dadfb20eb34a56cfc29bd237b4815b60257e7fe5ee9f791fda3e0bba91318e0f2beffec5cca573abb8b5030a95f305ce4abee93296065 + languageName: node + linkType: hard + "source-map-js@npm:^1.0.1, source-map-js@npm:^1.0.2": version: 1.0.2 resolution: "source-map-js@npm:1.0.2" @@ -19481,7 +19681,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.7.3": +"source-map@npm:^0.7.3, source-map@npm:^0.7.4": version: 0.7.4 resolution: "source-map@npm:0.7.4" checksum: 01cc5a74b1f0e1d626a58d36ad6898ea820567e87f18dfc9d24a9843a351aaa2ec09b87422589906d6ff1deed29693e176194dc88bcae7c9a852dc74b311dbf5 @@ -20165,6 +20365,16 @@ __metadata: languageName: node linkType: hard +"temp@npm:^0.9.4": + version: 0.9.4 + resolution: "temp@npm:0.9.4" + dependencies: + mkdirp: ^0.5.1 + rimraf: ~2.6.2 + checksum: 8709d4d63278bd309ca0e49e80a268308dea543a949e71acd427b3314cd9417da9a2cc73425dd9c21c6780334dbffd67e05e7be5aaa73e9affe8479afc6f20e3 + languageName: node + linkType: hard + "tempy@npm:^0.6.0": version: 0.6.0 resolution: "tempy@npm:0.6.0" From 1fe33739333afaa3dfff6a18acc78c7c24e71328 Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Sun, 17 Mar 2024 15:48:06 +0100 Subject: [PATCH 2/3] Removed useless i18n sync logic in the SDK --- .github/workflows/analyze.yml | 8 +- apps/demo-app/src/components/App.tsx | 6 +- apps/demo-app/src/i18n.ts | 4 - .../PhotoCapturePage/PhotoCapturePage.tsx | 29 ++++-- .../test/pages/PhotoCapturePage.test.tsx | 5 + .../src/__mocks__/@monkvision/common.tsx | 3 +- packages/camera-web/README.md | 8 +- .../src/SimpleCameraHUD/SimpleCameraHUD.tsx | 8 +- .../camera-web/test/SimpleCameraHUD.test.tsx | 4 +- .../common/README/INTERNATIONALIZATION.md | 56 ++---------- packages/common/src/hooks/index.ts | 1 - packages/common/src/hooks/useLangProp.ts | 24 ----- packages/common/src/i18n/utils.tsx | 71 ++++----------- .../common/test/hooks/useLangProp.test.ts | 75 --------------- packages/common/test/i18n/utils.test.tsx | 91 ++++++------------- packages/inspection-capture-web/README.md | 1 + .../src/PhotoCapture/PhotoCapture.tsx | 10 +- .../src/PhotoCapture/PhotoCaptureHOC.tsx | 8 +- packages/inspection-capture-web/src/i18n.ts | 5 +- .../test/PhotoCapture/PhotoCapture.test.tsx | 12 ++- packages/sentry/test/adapter.test.ts | 2 + 21 files changed, 127 insertions(+), 304 deletions(-) delete mode 100644 packages/common/src/hooks/useLangProp.ts delete mode 100644 packages/common/test/hooks/useLangProp.test.ts diff --git a/.github/workflows/analyze.yml b/.github/workflows/analyze.yml index 758910857..ce354a4db 100644 --- a/.github/workflows/analyze.yml +++ b/.github/workflows/analyze.yml @@ -17,16 +17,16 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Node.Js with Yarn - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: - node-version: "16" + node-version: "20" - name: Printing tools versions run: echo NodeJs $(node -v), NPM v$(npm -v), Yarn v$(yarn -v) - name: Installing dependencies run: | - yarn config set enableImmutableInstalls false + yarn config set enableImmutableInstalls true yarn install - name: Building packages run: yarn build diff --git a/apps/demo-app/src/components/App.tsx b/apps/demo-app/src/components/App.tsx index 972738f60..a2ecdd079 100644 --- a/apps/demo-app/src/components/App.tsx +++ b/apps/demo-app/src/components/App.tsx @@ -1,14 +1,10 @@ -import { useTranslation } from 'react-i18next'; import { Outlet, useNavigate } from 'react-router-dom'; -import { i18nInspectionCaptureWeb } from '@monkvision/inspection-capture-web'; -import { MonkAppParamsProvider, MonkProvider, useI18nLink, useMonkTheme } from '@monkvision/common'; +import { MonkAppParamsProvider, MonkProvider, useMonkTheme } from '@monkvision/common'; import { Page } from '../pages'; export function App() { - const { i18n } = useTranslation(); const navigate = useNavigate(); const { rootStyles } = useMonkTheme(); - useI18nLink(i18n, [i18nInspectionCaptureWeb]); return ( navigate(Page.CREATE_INSPECTION)}> diff --git a/apps/demo-app/src/i18n.ts b/apps/demo-app/src/i18n.ts index 79580d139..3585481f8 100644 --- a/apps/demo-app/src/i18n.ts +++ b/apps/demo-app/src/i18n.ts @@ -1,8 +1,6 @@ import i18n from 'i18next'; import I18nextBrowserLanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import { i18nLinkSDKInstances } from '@monkvision/common'; -import { i18nInspectionCaptureWeb } from '@monkvision/inspection-capture-web'; import { monkLanguages } from '@monkvision/types'; import en from './translations/en.json'; import fr from './translations/fr.json'; @@ -25,6 +23,4 @@ i18n }) .catch(console.error); -i18nLinkSDKInstances(i18n, [i18nInspectionCaptureWeb]); - export default i18n; diff --git a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx index a2fbb617d..29a2f37bd 100644 --- a/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx +++ b/apps/demo-app/src/pages/PhotoCapturePage/PhotoCapturePage.tsx @@ -1,15 +1,31 @@ import { useTranslation } from 'react-i18next'; -import { - getEnvOrThrow, - useMonkAppParams, - zlibCompress, - getSearchParamFromVehicleType, -} from '@monkvision/common'; +import { getEnvOrThrow, useMonkAppParams, zlibCompress } from '@monkvision/common'; import { VehicleType } from '@monkvision/types'; import { PhotoCapture } from '@monkvision/inspection-capture-web'; import { getSights } from '../../config'; import styles from './PhotoCapturePage.module.css'; +function getSearchParamFromVehicleType(vehicleType: VehicleType | null): string { + switch (vehicleType) { + case VehicleType.SUV: + return '0'; + case VehicleType.CROSSOVER: + return '1'; + case VehicleType.SEDAN: + return '2'; + case VehicleType.HATCHBACK: + return '3'; + case VehicleType.VAN: + return '4'; + case VehicleType.MINIVAN: + return '5'; + case VehicleType.PICKUP: + return '6'; + default: + return '1'; + } +} + function createInspectionReportLink( authToken: string | null, inspectionId: string | null, @@ -42,6 +58,7 @@ export function PhotoCapturePage() { inspectionId={inspectionId} sights={getSights(vehicleType)} onComplete={handleComplete} + lang={i18n.language} />
); diff --git a/apps/demo-app/test/pages/PhotoCapturePage.test.tsx b/apps/demo-app/test/pages/PhotoCapturePage.test.tsx index b7e8251a6..e4dd056cd 100644 --- a/apps/demo-app/test/pages/PhotoCapturePage.test.tsx +++ b/apps/demo-app/test/pages/PhotoCapturePage.test.tsx @@ -1,3 +1,5 @@ +import { useTranslation } from 'react-i18next'; + jest.mock('../../src/config', () => ({ getSights: jest.fn(() => [{ id: 'test' }]), })); @@ -18,12 +20,15 @@ const appParams = { describe('PhotoCapture page', () => { it('should pass the proper props to the PhotoCapture component', () => { + const language = 'test'; + (useTranslation as jest.Mock).mockImplementation(() => ({ i18n: { language } })); (useMonkAppParams as jest.Mock).mockImplementation(() => appParams); const { unmount } = render(); expectPropsOnChildMock(PhotoCapture, { apiConfig: { authToken: appParams.authToken, apiDomain: 'REACT_APP_API_DOMAIN' }, inspectionId: appParams.inspectionId, + lang: language, sights: expect.any(Array), onComplete: expect.any(Function), }); diff --git a/configs/test-utils/src/__mocks__/@monkvision/common.tsx b/configs/test-utils/src/__mocks__/@monkvision/common.tsx index ebb7fca39..b36f4f438 100644 --- a/configs/test-utils/src/__mocks__/@monkvision/common.tsx +++ b/configs/test-utils/src/__mocks__/@monkvision/common.tsx @@ -55,8 +55,7 @@ export = { useMonkState: jest.fn(() => ({ state: createEmptyMonkState(), dispatch: jest.fn() })), monkReducer: jest.fn(createEmptyMonkState), MonkProvider: jest.fn(({ children }) => <>{children}), - i18nLinkSDKInstances: jest.fn(), - useI18nLink: jest.fn(), + useI18nSync: jest.fn(), i18nCreateSDKInstance: jest.fn(), i18nWrap: jest.fn((component) => component), useInteractiveStatus: jest.fn(({ componentHandlers }) => ({ diff --git a/packages/camera-web/README.md b/packages/camera-web/README.md index 2f771c445..2459d26ca 100644 --- a/packages/camera-web/README.md +++ b/packages/camera-web/README.md @@ -92,12 +92,8 @@ function MyCameraPreviewWithHUD() { ``` The text displayed by this component (error messages, retry button label...) is by default in english. If you want to -customize the display language, you have two options : -- Use the `i18next` and `react-i18next` packages to set up internationalization support in your app, and then linking - your `i18n` instance with the one used by the Camera package. To do so, we highly recommend using the `i18n` utility - tools provided by the `@monkvision/common` package. More information on this - [here](https://github.com/monkvision/monkjs/blob/main/packages/common/README/INTERNATIONALIZATION.md). -- Simply specify the fixed language you want to use by using the `lang` prop of the component like this : +customize the display language, you can specify the fixed language you want to use by using the `lang` prop of the +component like this : ```tsx import { Camera, SimpleCameraHUD } from '@monkvision/camera-web'; diff --git a/packages/camera-web/src/SimpleCameraHUD/SimpleCameraHUD.tsx b/packages/camera-web/src/SimpleCameraHUD/SimpleCameraHUD.tsx index cf49757a5..5c10150f7 100644 --- a/packages/camera-web/src/SimpleCameraHUD/SimpleCameraHUD.tsx +++ b/packages/camera-web/src/SimpleCameraHUD/SimpleCameraHUD.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { i18nWrap, - useLangProp, + useI18nSync, useObjectTranslation, useResponsiveStyle, } from '@monkvision/common'; @@ -17,8 +17,10 @@ import { getCameraErrorLabel } from '../utils'; export type SimpleCameraHUDProps = CameraHUDProps & { /** * This prop can be used to specify the language to be used by the SimpleCameraHUD component. + * + * @default: en */ - lang?: string; + lang?: string | null; }; /** @@ -26,7 +28,7 @@ export type SimpleCameraHUDProps = CameraHUDProps & { * messages (and a retry button) in case of errors with the Camera stream. */ export const SimpleCameraHUD = i18nWrap(({ cameraPreview, handle, lang }: SimpleCameraHUDProps) => { - useLangProp(lang); + useI18nSync(lang); const { t } = useTranslation(); const { tObj } = useObjectTranslation(); const { responsive } = useResponsiveStyle(); diff --git a/packages/camera-web/test/SimpleCameraHUD.test.tsx b/packages/camera-web/test/SimpleCameraHUD.test.tsx index 883bc3d14..570ad024e 100644 --- a/packages/camera-web/test/SimpleCameraHUD.test.tsx +++ b/packages/camera-web/test/SimpleCameraHUD.test.tsx @@ -9,7 +9,7 @@ jest.mock('../src/utils', () => ({ })), })); -import { i18nWrap, useLangProp, useObjectTranslation } from '@monkvision/common'; +import { i18nWrap, useI18nSync, useObjectTranslation } from '@monkvision/common'; import { expectPropsOnChildMock } from '@monkvision/test-utils'; import { render, screen } from '@testing-library/react'; import { Button, TakePictureButton } from '@monkvision/common-ui-web'; @@ -171,7 +171,7 @@ describe('SimpleCameraHUD component', () => { } handle={{} as CameraHandle} lang={lang} />, ); - expect(useLangProp).toHaveBeenCalledWith(lang); + expect(useI18nSync).toHaveBeenCalledWith(lang); unmount(); }); diff --git a/packages/common/README/INTERNATIONALIZATION.md b/packages/common/README/INTERNATIONALIZATION.md index ac4bea38a..29f6690c6 100644 --- a/packages/common/README/INTERNATIONALIZATION.md +++ b/packages/common/README/INTERNATIONALIZATION.md @@ -23,59 +23,23 @@ specify to it which language you want it to use. The following languages are sup - German # i18n Utilities -## i18nLinkSDKInstances function +## useI18nSync hook ### Description -This function is used to synchronize your i18n instance with the ones used and provided by the Monk SDK packages. This -will allow Monk SDK packages to use the same language as your app and change their language everytime your app changes -its language. - -**IMPORTANT NOTE : It is highly recommended to also use the `useI18nLink` hook (described below) in pair with this -function for optimal results.** - -This function takes two parameters : -- The i18n instance of your application -- An array of i18n instances used by the Monk SDK packages used in your app +This hook is used mostly by MonkJs packages internally to synchronize their own i18n instance with the language param +or prop that they are provided. ### Example of usage + ```typescript import i18n from 'i18next'; -import { i18nInspectionCapture } from '@monkvision/inspection-capture-web'; -import { i18nInspectionReport } from '@monkvision/inspection-report-web'; - -i18n.use(initReactI18next).init(...); - -// Use the function right after initializing your i18n instance. -i18nLinkSDKInstances(i18n, [i18nInspectionCapture, i18nInspectionReport]); - -export default i18n; -``` +import { useI18nSync } from '@monkvision/common'; -## useI18nLink hook -### Description -This hook is used to synchronize your i18n instance with the ones used and provided by the Monk SDK packages. This -will allow Monk SDK packages to use the same language as your app and change their language everytime your app changes -its language. - -**IMPORTANT NOTE : It is highly recommended to also use the `i18nLinkSDKInstances` hook (described above) in pair with -this hook for optimal results.** - -This hook takes two parameters : -- The i18n instance of your application, obtained using the `useTranslation` hook. -- An array of i18n instances used by the Monk SDK packages used in your app - -### Example of usage -```tsx -import React from 'react'; -import { useI18nLink } from '@monkvision/common'; -import { i18nInspectionCapture } from '@monkvision/inspection-capture-web'; -import { i18nInspectionReport } from '@monkvision/inspection-report-web'; -import { useTranslation } from 'react-i18next'; - -export function App() { - const { i18n } = useTranslation(); +interface MyComponentProps { + lang?: string | null; +} - // Use the hook in your App component - useI18nLink(i18n, [i18nInspectionCapture, i18nInspectionReport]); +function MyComponent(props: MyComponentProps) { + useI18nSync(props.lang); ... } ``` diff --git a/packages/common/src/hooks/index.ts b/packages/common/src/hooks/index.ts index fbf6c2ee7..a443f24ae 100644 --- a/packages/common/src/hooks/index.ts +++ b/packages/common/src/hooks/index.ts @@ -6,4 +6,3 @@ export * from './useObjectTranslation'; export * from './useSightLabel'; export * from './useLoadingState'; export * from './useAsyncEffect'; -export * from './useLangProp'; diff --git a/packages/common/src/hooks/useLangProp.ts b/packages/common/src/hooks/useLangProp.ts deleted file mode 100644 index c0f4f444d..000000000 --- a/packages/common/src/hooks/useLangProp.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useTranslation } from 'react-i18next'; -import { useEffect } from 'react'; -import { useMonitoring } from '@monkvision/monitoring'; -import { MonkLanguage, monkLanguages } from '@monkvision/types'; - -/** - * Custom hook used internally by the Monk SDK components to handle the `lang` prop tha tcan be passed to them to - * manage the current language displayed by the component. - */ -export function useLangProp(lang?: string): void { - if (lang && !monkLanguages.includes(lang as MonkLanguage)) { - throw new Error( - `Unsupported language : "${lang}". Languages supported by the Monk SDK are : ${monkLanguages}.`, - ); - } - const { i18n } = useTranslation(); - const { handleError } = useMonitoring(); - - useEffect(() => { - if (lang) { - i18n.changeLanguage(lang).catch(handleError); - } - }, [i18n, lang]); -} diff --git a/packages/common/src/i18n/utils.tsx b/packages/common/src/i18n/utils.tsx index 3f3e6826d..5eb301bfd 100644 --- a/packages/common/src/i18n/utils.tsx +++ b/packages/common/src/i18n/utils.tsx @@ -8,64 +8,31 @@ import { RefAttributes, useEffect, } from 'react'; -import { I18nextProvider, initReactI18next } from 'react-i18next'; +import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'; +import { monkLanguages } from '@monkvision/types'; /** - * Use this function during your i18n initialization in order to synchronize your i18n instance with the ones used and - * provided by the Monk SDK packages. - * - * **IMPORTANT NOTE : It is highly recommended to also use the `useI18nLink` hook in pair with this function for - * optimal results.** - * - * @param instance The i18n instance of your application. - * @param sdkInstances The array of i18n instances used by the Monk SDK packages used in your app. - * @see useI18nLink - * @example - * import i18n from 'i18next'; - * import { i18nInspectionCapture } from '@monkvision/inspection-capture-web'; - * import { i18nInspectionReport } from '@monkvision/inspection-report-web'; - * - * i18n.use(initReactI18next).init(...); - * i18nLinkSDKInstances(i18n, [i18nInspectionCapture, i18nInspectionReport]); - * export default i18n; - */ -export function i18nLinkSDKInstances(instance: i18n, sdkInstances: i18n[]): void { - instance.on('languageChanged', (lng: string) => { - sdkInstances.forEach((sdkInstance) => sdkInstance.changeLanguage(lng).catch(console.error)); - }); -} - -/** - * Use this hook inside your App component in order to synchronize your i18n instance with the ones used and provided - * by the Monk SDK packages. - * - * **IMPORTANT NOTE : It is highly recommended to also use the `i18nLinkSDKInstances` function in pair with this hook - * for optimal results.** - * - * @param instance The i18n instance of your application, obtained using the `useTranslation` hook. - * @param sdkInstances The array of i18n instances used by the Monk SDK packages used in your app. - * @see i18nLinkSDKInstances - * @example - * import React from 'react'; - * import { useI18nLink } from '@monkvision/common'; - * import { i18nInspectionCapture } from '@monkvision/inspection-capture-web'; - * import { i18nInspectionReport } from '@monkvision/inspection-report-web'; - * import { useTranslation } from 'react-i18next'; - * - * export function App() { - * const { i18n } = useTranslation(); - * useI18nLink(i18n, [i18nInspectionCapture, i18nInspectionReport]); - * ... - * } + * This custom hook automatically updates the current i18n instance's language with the given language if is it not null + * and supported by the MonkJs SDK. */ -export function useI18nLink(instance: i18n, sdkInstances: i18n[]): void { +export function useI18nSync(language?: string | null): void { + const { i18n: instance } = useTranslation(); const { handleError } = useMonitoring(); useEffect(() => { - sdkInstances.forEach((sdkInstance) => - sdkInstance.changeLanguage(instance.language).catch(handleError), - ); - }, [instance.language]); + if (!language) { + return; + } + if (!(monkLanguages as readonly string[]).includes(language)) { + handleError( + new Error( + `Unsupported language passed to the MonkJs SDK : ${language}. Currently supported languages are : ${monkLanguages}.`, + ), + ); + return; + } + instance.changeLanguage(language).catch(handleError); + }, [language, instance.changeLanguage]); } /** diff --git a/packages/common/test/hooks/useLangProp.test.ts b/packages/common/test/hooks/useLangProp.test.ts deleted file mode 100644 index 76b30f271..000000000 --- a/packages/common/test/hooks/useLangProp.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { renderHook } from '@testing-library/react-hooks'; -import { useTranslation } from 'react-i18next'; -import { useLangProp } from '../../src/hooks/useLangProp'; -import { useMonitoring } from '@monkvision/monitoring'; -import { waitFor } from '@testing-library/react'; - -function getChangeLanguage(): jest.Mock { - expect(useTranslation).toHaveBeenCalled(); - const lastCall = (useTranslation as jest.Mock).mock.results.length - 1; - return (useTranslation as jest.Mock).mock.results[lastCall].value.i18n - .changeLanguage as jest.Mock; -} - -describe('useLangProp hook', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should update the language on the first render', () => { - const lang = 'fr'; - const { unmount } = renderHook(useLangProp, { initialProps: lang }); - - expect(getChangeLanguage()).toHaveBeenCalledWith(lang); - - unmount(); - }); - - it('should change the language everytime the lang param changes', () => { - let lang = 'fr'; - const { rerender, unmount } = renderHook(useLangProp, { initialProps: lang }); - - expect(getChangeLanguage()).toHaveBeenCalledWith(lang); - - lang = 'en'; - expect(getChangeLanguage()).not.toHaveBeenCalledWith(lang); - rerender(lang); - expect(getChangeLanguage()).toHaveBeenCalledWith(lang); - - unmount(); - }); - - it('should not change the language if the lang parameter is not provided', () => { - const { unmount } = renderHook(useLangProp); - - expect(getChangeLanguage()).not.toHaveBeenCalled(); - - unmount(); - }); - - it('should call handleError if the changeLanguage call fails', async () => { - const err = 'test-salut'; - (useTranslation as jest.Mock).mockImplementationOnce(() => ({ - i18n: { changeLanguage: jest.fn(() => Promise.reject(err)) }, - })); - const { unmount } = renderHook(useLangProp, { initialProps: 'fr' }); - - expect(useMonitoring).toHaveBeenCalled(); - const { handleError } = (useMonitoring as jest.Mock).mock.results[0].value; - await waitFor(() => { - expect(handleError).toHaveBeenCalledWith(err); - }); - - unmount(); - }); - - it('should throw an error if the given language is not supported', () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - const { result, unmount } = renderHook(useLangProp, { initialProps: 'unknown-lang' }); - - expect(result.error).toBeDefined(); - - unmount(); - jest.spyOn(console, 'error').mockRestore(); - }); -}); diff --git a/packages/common/test/i18n/utils.test.tsx b/packages/common/test/i18n/utils.test.tsx index d3599f275..dac1ebdaa 100644 --- a/packages/common/test/i18n/utils.test.tsx +++ b/packages/common/test/i18n/utils.test.tsx @@ -2,84 +2,47 @@ import { expectComponentToPassDownRefToHTMLElement, expectPropsOnChildMock, } from '@monkvision/test-utils'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { renderHook } from '@testing-library/react-hooks'; import { createInstance, i18n, Resource } from 'i18next'; import { FC, forwardRef } from 'react'; -import { I18nextProvider, initReactI18next } from 'react-i18next'; -import { useMonitoring } from '@monkvision/monitoring'; -import { i18nCreateSDKInstance, i18nLinkSDKInstances, i18nWrap, useI18nLink } from '../../src'; +import { I18nextProvider, initReactI18next, useTranslation } from 'react-i18next'; +import { i18nCreateSDKInstance, i18nWrap, useI18nSync } from '../../src'; describe('Monkvision i18n utils', () => { afterEach(() => { jest.clearAllMocks(); }); - describe('i18nLinkSDKInstances function', () => { - it('should add an event listener to the languageChanged event of the main instance', () => { - const instance = { on: jest.fn() }; - const sdkInstances = [ - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - ]; - - i18nLinkSDKInstances(instance as unknown as i18n, sdkInstances as unknown as i18n[]); - expect(instance.on).toHaveBeenCalledWith('languageChanged', expect.any(Function)); - const newLanguage = 'newLang'; - instance.on.mock.calls[0][1](newLanguage); - sdkInstances.forEach((sdkInstance) => { - expect(sdkInstance.changeLanguage).toHaveBeenCalledWith(newLanguage); - }); + describe('useI18nSync hook', () => { + it('should update the language every time the language prop changes', () => { + let lang = 'fr'; + const { rerender, unmount } = renderHook(useI18nSync, { initialProps: lang }); + let instance = (useTranslation as jest.Mock).mock.results[0].value.i18n; + expect(instance.changeLanguage).toHaveBeenCalledWith(lang); + + (useTranslation as jest.Mock).mockClear(); + lang = 'de'; + rerender(lang); + instance = (useTranslation as jest.Mock).mock.results[0].value.i18n; + expect(instance.changeLanguage).toHaveBeenCalledWith(lang); + + unmount(); }); - }); - describe('useI18nLink hook', () => { - it('should create an effect changing the languages of the SDK libraries', () => { - const instance = { language: 'fr' }; - const sdkInstances = [ - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - ]; - const { unmount, rerender } = renderHook( - ({ instance: inst, sdkInstances: sdkInst }) => - useI18nLink(inst as unknown as i18n, sdkInst as unknown as i18n[]), - { initialProps: { instance, sdkInstances } }, - ); - - sdkInstances.forEach((sdkInstance) => { - expect(sdkInstance.changeLanguage).toHaveBeenCalledWith(instance.language); - }); - instance.language = 'test-lg-2'; - rerender({ instance, sdkInstances }); - sdkInstances.forEach((sdkInstance) => { - expect(sdkInstance.changeLanguage).toHaveBeenCalledWith(instance.language); - }); + it('should not update the language if the value is null', () => { + const { unmount } = renderHook(useI18nSync, { initialProps: null }); + const instance = (useTranslation as jest.Mock).mock.results[0].value.i18n; + expect(instance.changeLanguage).not.toHaveBeenCalled(); + unmount(); }); - it('should call handleError if an error occurs when changing language', async () => { - const err1 = 'err1'; - const err2 = 'err2'; - const instance = { language: 'fr' }; - const sdkInstances = [ - { changeLanguage: jest.fn(() => Promise.resolve(undefined)) }, - { changeLanguage: jest.fn(() => Promise.reject(err1)) }, - { changeLanguage: jest.fn(() => Promise.reject(err2)) }, - ]; - const { unmount } = renderHook( - ({ instance: inst, sdkInstances: sdkInst }) => - useI18nLink(inst as unknown as i18n, sdkInst as unknown as i18n[]), - { initialProps: { instance, sdkInstances } }, - ); - - const handleErrorMock = (useMonitoring as jest.Mock).mock.results[0].value.handleError; - await waitFor(() => { - expect(handleErrorMock).toHaveBeenCalledTimes(2); - expect(handleErrorMock).toHaveBeenCalledWith(err1); - expect(handleErrorMock).toHaveBeenCalledWith(err2); - }); + it('should not update the language if the value is not a supported monk language', () => { + const { unmount } = renderHook(useI18nSync, { initialProps: 'test' }); + const instance = (useTranslation as jest.Mock).mock.results[0].value.i18n; + expect(instance.changeLanguage).not.toHaveBeenCalled(); + unmount(); }); }); diff --git a/packages/inspection-capture-web/README.md b/packages/inspection-capture-web/README.md index 8f43c93a9..9f164da79 100644 --- a/packages/inspection-capture-web/README.md +++ b/packages/inspection-capture-web/README.md @@ -81,3 +81,4 @@ export function MonkPhotoCapturePage() { | 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. | | | | showCloseButton | boolean | Boolean indicating if the close button should be displayed in the HUD on top of the Camera preview. | | `false` | +| lang | string | null | The language to be used by this component. | | `'en'` | diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index 210775362..ba1ee2967 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -1,6 +1,6 @@ import { Camera, CameraHUDProps, CompressionOptions, CameraProps } from '@monkvision/camera-web'; import { Sight, TaskName } from '@monkvision/types'; -import { useLoadingState } from '@monkvision/common'; +import { useI18nSync, useLoadingState } from '@monkvision/common'; import { ComplianceOptions, MonkAPIConfig } from '@monkvision/network'; import { useMonitoring } from '@monkvision/monitoring'; import { PhotoCaptureHUD, PhotoCaptureHUDProps } from './PhotoCaptureHUD'; @@ -66,6 +66,12 @@ export interface PhotoCaptureProps * @default false */ showCloseButton?: boolean; + /** + * The language to be used by this component. + * + * @default en + */ + lang?: string | null; } // No ts-doc for this component : the component exported is PhotoCaptureHOC @@ -79,8 +85,10 @@ export function PhotoCapture({ onComplete, showCloseButton = false, compliances, + lang, ...cameraConfig }: PhotoCaptureProps) { + useI18nSync(lang); const { handleError } = useMonitoring(); const loading = useLoadingState(); const addDamageHandle = useAddDamageMode(); diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHOC.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHOC.tsx index 15a55cedc..f0ad0ffad 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHOC.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCaptureHOC.tsx @@ -1,5 +1,4 @@ -import { i18nWrap, MonkProvider, useI18nLink } from '@monkvision/common'; -import { i18nCamera } from '@monkvision/camera-web'; +import { i18nWrap, MonkProvider } from '@monkvision/common'; import { i18nInspectionCaptureWeb } from '../i18n'; import { PhotoCapture, PhotoCaptureProps } from './PhotoCapture'; @@ -27,6 +26,8 @@ import { PhotoCapture, PhotoCaptureProps } from './PhotoCapture'; * ]; * * export function PhotoCaptureScreen({ inspectionId, apiConfig }: PhotoCaptureScreenProps) { + * const { i18n } = useTranslation(); + * * return ( * { / * Navigate to another page * / }} + * lang={i18n.language} * /> * ); * } */ export const PhotoCaptureHOC = i18nWrap((props: PhotoCaptureProps) => { - useI18nLink(i18nInspectionCaptureWeb, [i18nCamera]); - return ( diff --git a/packages/inspection-capture-web/src/i18n.ts b/packages/inspection-capture-web/src/i18n.ts index bbc15fa7e..630b993f3 100644 --- a/packages/inspection-capture-web/src/i18n.ts +++ b/packages/inspection-capture-web/src/i18n.ts @@ -1,5 +1,4 @@ -import { i18nCreateSDKInstance, i18nLinkSDKInstances } from '@monkvision/common'; -import { i18nCamera } from '@monkvision/camera-web'; +import { i18nCreateSDKInstance } from '@monkvision/common'; import en from './translations/en.json'; import fr from './translations/fr.json'; import de from './translations/de.json'; @@ -16,6 +15,4 @@ const i18nInspectionCaptureWeb = i18nCreateSDKInstance({ }, }); -i18nLinkSDKInstances(i18nInspectionCaptureWeb, [i18nCamera]); - export { i18nInspectionCaptureWeb }; diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index c56a4983c..5d4cfbf2d 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -45,7 +45,7 @@ import { useStartTasksOnComplete, useUploadQueue, } from '../../src/PhotoCapture/hooks'; -import { useLoadingState } from '@monkvision/common'; +import { useI18nSync, useLoadingState } from '@monkvision/common'; import { TaskName } from '@monkvision/types'; import { useMonitoring } from '@monkvision/monitoring'; @@ -267,4 +267,14 @@ describe('PhotoCapture component', () => { unmount(); }); + + it('should sync the local i18n language with the one passed as a prop', () => { + const lang = 'fr'; + const props = createProps(); + const { unmount } = render(); + + expect(useI18nSync).toHaveBeenCalledWith(lang); + + unmount(); + }); }); diff --git a/packages/sentry/test/adapter.test.ts b/packages/sentry/test/adapter.test.ts index 1f65731b8..d3a594822 100644 --- a/packages/sentry/test/adapter.test.ts +++ b/packages/sentry/test/adapter.test.ts @@ -1,4 +1,6 @@ jest.mock('@sentry/react'); +Object.defineProperty(global.console, 'info', { value: jest.fn() }); +Object.defineProperty(global.console, 'error', { value: jest.fn() }); import { MeasurementContext, TransactionContext, TransactionStatus } from '@monkvision/monitoring'; import { Transaction } from '@sentry/react'; From c9cf7b7657ce8ed9736834e76a218627a42770ef Mon Sep 17 00:00:00 2001 From: Samy Ouyahia Date: Mon, 18 Mar 2024 16:18:29 +0100 Subject: [PATCH 3/3] Added memoization optimizations in the SDK --- apps/demo-app/env.txt | 3 + packages/camera-web/src/Camera/Camera.tsx | 4 +- .../src/Camera/hooks/useCameraCanvas.ts | 17 ++-- .../src/Camera/hooks/useCameraPreview.ts | 13 +-- .../src/Camera/hooks/useCameraScreenshot.ts | 60 ++++++------- .../src/Camera/hooks/useCompression.ts | 45 +++++----- .../src/Camera/hooks/useTakePicture.ts | 8 +- .../src/Camera/hooks/useUserMedia.ts | 13 +-- .../camera-web/test/Camera/Camera.test.tsx | 13 ++- .../Camera/hooks/useCameraScreenshot.test.ts | 22 ++--- .../test/Camera/hooks/useCompression.test.ts | 14 +-- .../common/src/hooks/useInteractiveStatus.ts | 81 +++++++++++------- packages/common/src/hooks/useLoadingState.ts | 31 ++++--- .../common/src/hooks/useObjectTranslation.ts | 12 ++- packages/common/src/hooks/useQueue.ts | 85 +++++++++++-------- .../common/src/hooks/useResponsiveStyle.ts | 17 ++-- packages/common/src/hooks/useSightLabel.ts | 10 ++- packages/common/src/i18n/utils.tsx | 6 +- .../common/test/hooks/useSightLabel.test.ts | 7 +- .../src/PhotoCapture/PhotoCapture.tsx | 4 +- .../PhotoCapture/hooks/useAddDamageMode.ts | 36 ++++---- .../hooks/usePhotoCaptureSightState.ts | 39 ++++++--- .../src/PhotoCapture/hooks/usePictureTaken.ts | 59 +++++++------ .../hooks/useStartTasksOnComplete.ts | 37 +++----- .../test/PhotoCapture/PhotoCapture.test.tsx | 14 +-- .../hooks/usePictureTaken.test.ts | 14 +-- .../hooks/useStartTasksOnComplete.test.ts | 12 +-- packages/monitoring/src/react/hooks.ts | 18 ++-- packages/network/src/api/react.ts | 6 +- packages/network/src/auth/hooks.ts | 23 ++--- 30 files changed, 400 insertions(+), 323 deletions(-) diff --git a/apps/demo-app/env.txt b/apps/demo-app/env.txt index e963363d7..c57591228 100644 --- a/apps/demo-app/env.txt +++ b/apps/demo-app/env.txt @@ -3,6 +3,9 @@ PORT=17200 HTTPS=true REACT_APP_ENVIRONMENT=staging +# API +REACT_APP_API_DOMAIN=api.preview.monk.ai/v1 + # Authentication REACT_APP_AUTH_DOMAIN=idp.preview.monk.ai REACT_APP_AUTH_AUDIENCE=https://api.monk.ai/v1/ diff --git a/packages/camera-web/src/Camera/Camera.tsx b/packages/camera-web/src/Camera/Camera.tsx index 7f1d69901..ee6c5a88a 100644 --- a/packages/camera-web/src/Camera/Camera.tsx +++ b/packages/camera-web/src/Camera/Camera.tsx @@ -112,12 +112,12 @@ export function Camera({ streamDimensions, allowImageUpscaling, }); - const { takeScreenshot } = useCameraScreenshot({ + const takeScreenshot = useCameraScreenshot({ videoRef, canvasRef, dimensions: canvasDimensions, }); - const { compress } = useCompression({ canvasRef, options: { format, quality } }); + const compress = useCompression({ canvasRef, options: { format, quality } }); const { takePicture, isLoading: isTakePictureLoading } = useTakePicture({ compress, takeScreenshot, diff --git a/packages/camera-web/src/Camera/hooks/useCameraCanvas.ts b/packages/camera-web/src/Camera/hooks/useCameraCanvas.ts index 98793f59c..f4fdaeec1 100644 --- a/packages/camera-web/src/Camera/hooks/useCameraCanvas.ts +++ b/packages/camera-web/src/Camera/hooks/useCameraCanvas.ts @@ -84,17 +84,20 @@ export function useCameraCanvas({ allowImageUpscaling, }: CameraCanvasConfig): CameraCanvasHandle { const ref = useRef(null); - const dimensions = useMemo( - () => getCanvasDimensions({ resolution, streamDimensions, allowImageUpscaling }), + const handle = useMemo( + () => ({ + ref, + dimensions: getCanvasDimensions({ resolution, streamDimensions, allowImageUpscaling }), + }), [resolution, streamDimensions], ); useEffect(() => { - if (dimensions && ref.current) { - ref.current.width = dimensions.width; - ref.current.height = dimensions.height; + if (handle.dimensions && ref.current) { + ref.current.width = handle.dimensions.width; + ref.current.height = handle.dimensions.height; } - }, [dimensions]); + }, [handle.dimensions]); - return { ref, dimensions }; + return handle; } diff --git a/packages/camera-web/src/Camera/hooks/useCameraPreview.ts b/packages/camera-web/src/Camera/hooks/useCameraPreview.ts index c06d28531..20b7beb41 100644 --- a/packages/camera-web/src/Camera/hooks/useCameraPreview.ts +++ b/packages/camera-web/src/Camera/hooks/useCameraPreview.ts @@ -1,5 +1,5 @@ import { useMonitoring } from '@monkvision/monitoring'; -import { RefObject, useEffect, useRef } from 'react'; +import { RefObject, useEffect, useMemo, useRef } from 'react'; import { CameraConfig, getMediaConstraints } from './utils'; import { UserMediaResult, useUserMedia } from './useUserMedia'; @@ -31,8 +31,11 @@ export function useCameraPreview(config: CameraConfig): CameraPreviewHandle { } }, [userMediaResult.stream]); - return { - ref, - ...userMediaResult, - }; + return useMemo( + () => ({ + ref, + ...userMediaResult, + }), + [userMediaResult], + ); } diff --git a/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts b/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts index d1c8f6afc..ddaac2ca4 100644 --- a/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts +++ b/packages/camera-web/src/Camera/hooks/useCameraScreenshot.ts @@ -1,4 +1,4 @@ -import { RefObject } from 'react'; +import { RefObject, useCallback } from 'react'; import { TransactionStatus } from '@monkvision/monitoring'; import { PixelDimensions } from '@monkvision/types'; import { @@ -27,16 +27,11 @@ export interface CameraScreenshotConfig { } /** - * Interface describing a handle that can be used to take a screenshot of a video element. + * Callback used to take a screenshot. + * + * @return A ImageData object that contains the raw pixel's data. */ -export interface CameraScreenshotHandle { - /** - * Callback used to take a screenshot. - * - * @return A ImageData object that contains the raw pixel's data. - */ - takeScreenshot: (monitoring: InternalCameraMonitoringConfig) => ImageData; -} +export type TakeScreenshotFunction = (monitoring: InternalCameraMonitoringConfig) => ImageData; function startScreenshotMeasurement( monitoring: InternalCameraMonitoringConfig, @@ -77,26 +72,27 @@ export function useCameraScreenshot({ videoRef, canvasRef, dimensions, -}: CameraScreenshotConfig): CameraScreenshotHandle { - const takeScreenshot = (monitoring: InternalCameraMonitoringConfig) => { - startScreenshotMeasurement(monitoring, dimensions); - const { context } = getCanvasHandle(canvasRef, () => - stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR), - ); - if (!dimensions) { - stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); - throw new Error('Unable to take a picture because the video stream has no dimension.'); - } - if (!videoRef.current) { - stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); - throw new Error('Unable to take a picture because the video element is null.'); - } - context.drawImage(videoRef.current, 0, 0, dimensions.width, dimensions.height); - const imageData = context.getImageData(0, 0, dimensions.width, dimensions.height); - setScreeshotSizeMeasurement(monitoring, imageData); - stopScreenshotMeasurement(monitoring, TransactionStatus.OK); - return imageData; - }; - - return { takeScreenshot }; +}: CameraScreenshotConfig): TakeScreenshotFunction { + return useCallback( + (monitoring: InternalCameraMonitoringConfig) => { + startScreenshotMeasurement(monitoring, dimensions); + const { context } = getCanvasHandle(canvasRef, () => + stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR), + ); + if (!dimensions) { + stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); + throw new Error('Unable to take a picture because the video stream has no dimension.'); + } + if (!videoRef.current) { + stopScreenshotMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); + throw new Error('Unable to take a picture because the video element is null.'); + } + context.drawImage(videoRef.current, 0, 0, dimensions.width, dimensions.height); + const imageData = context.getImageData(0, 0, dimensions.width, dimensions.height); + setScreeshotSizeMeasurement(monitoring, imageData); + stopScreenshotMeasurement(monitoring, TransactionStatus.OK); + return imageData; + }, + [dimensions], + ); } diff --git a/packages/camera-web/src/Camera/hooks/useCompression.ts b/packages/camera-web/src/Camera/hooks/useCompression.ts index 931e1628c..6e2b1d72e 100644 --- a/packages/camera-web/src/Camera/hooks/useCompression.ts +++ b/packages/camera-web/src/Camera/hooks/useCompression.ts @@ -1,5 +1,5 @@ import { TransactionStatus } from '@monkvision/monitoring'; -import { RefObject } from 'react'; +import { RefObject, useCallback } from 'react'; import { CompressionMeasurement, CompressionSizeRatioMeasurement, @@ -74,14 +74,12 @@ export interface MonkPicture { } /** - * Handle used to compress images. + * Function used to compress images and create DataURI objects. */ -export interface CompressionHandle { - /** - * Function used to compress images and create DataURI objects. - */ - compress: (image: ImageData, monitoring: InternalCameraMonitoringConfig) => MonkPicture; -} +export type CompressFunction = ( + image: ImageData, + monitoring: InternalCameraMonitoringConfig, +) => MonkPicture; function startCompressionMeasurement( monitoring: InternalCameraMonitoringConfig, @@ -140,19 +138,20 @@ function compressUsingBrowser( /** * Custom hook used to manage the camera element used to take video screenshots and encode images. */ -export function useCompression({ canvasRef, options }: UseCompressionParams): CompressionHandle { - const compress = (image: ImageData, monitoring: InternalCameraMonitoringConfig) => { - startCompressionMeasurement(monitoring, options, image); - try { - const picture = compressUsingBrowser(image, canvasRef, options); - setCustomMeasurements(monitoring, image, picture); - stopCompressionMeasurement(monitoring, TransactionStatus.OK); - return picture; - } catch (err) { - stopCompressionMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); - throw err; - } - }; - - return { compress }; +export function useCompression({ canvasRef, options }: UseCompressionParams): CompressFunction { + return useCallback( + (image: ImageData, monitoring: InternalCameraMonitoringConfig) => { + startCompressionMeasurement(monitoring, options, image); + try { + const picture = compressUsingBrowser(image, canvasRef, options); + setCustomMeasurements(monitoring, image, picture); + stopCompressionMeasurement(monitoring, TransactionStatus.OK); + return picture; + } catch (err) { + stopCompressionMeasurement(monitoring, TransactionStatus.UNKNOWN_ERROR); + throw err; + } + }, + [options.format, options.quality], + ); } diff --git a/packages/camera-web/src/Camera/hooks/useTakePicture.ts b/packages/camera-web/src/Camera/hooks/useTakePicture.ts index 8c3514295..f6082c5c2 100644 --- a/packages/camera-web/src/Camera/hooks/useTakePicture.ts +++ b/packages/camera-web/src/Camera/hooks/useTakePicture.ts @@ -1,5 +1,5 @@ import { useMonitoring } from '@monkvision/monitoring'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { CameraMonitoringConfig, InternalCameraMonitoringConfig, @@ -41,7 +41,7 @@ export function useTakePicture({ const { createTransaction } = useMonitoring(); const [isLoading, setIsLoading] = useState(false); - const takePicture = () => { + const takePicture = useCallback(() => { setIsLoading(true); const transaction = createTransaction({ ...TakePictureTransaction, ...monitoring }); const childMonitoring: InternalCameraMonitoringConfig = { @@ -57,7 +57,7 @@ export function useTakePicture({ onPictureTaken(picture); } return picture; - }; + }, [createTransaction, monitoring, takeScreenshot, compress, onPictureTaken]); - return { takePicture, isLoading }; + return useMemo(() => ({ takePicture, isLoading }), [takePicture, isLoading]); } diff --git a/packages/camera-web/src/Camera/hooks/useUserMedia.ts b/packages/camera-web/src/Camera/hooks/useUserMedia.ts index f6c0fab3d..b41ed6c84 100644 --- a/packages/camera-web/src/Camera/hooks/useUserMedia.ts +++ b/packages/camera-web/src/Camera/hooks/useUserMedia.ts @@ -1,6 +1,6 @@ import { useMonitoring } from '@monkvision/monitoring'; import deepEqual from 'fast-deep-equal'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { PixelDimensions } from '@monkvision/types'; import { isMobileDevice } from '@monkvision/common'; import { getValidCameraDeviceIds } from './utils'; @@ -189,14 +189,14 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu setIsLoading(false); }; - const retry = () => { + const retry = useCallback(() => { if (error && !isLoading) { setError(null); setStream(null); setIsLoading(false); setLastConstraintsApplied(null); } - }; + }, [error, isLoading]); useEffect(() => { if (error || isLoading || deepEqual(lastConstraintsApplied, constraints)) { @@ -232,7 +232,7 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu } }; getUserMedia().catch(handleError); - }, [constraints, stream, error, isLoading, lastConstraintsApplied, onStreamInactive]); + }, [constraints, stream, error, isLoading, lastConstraintsApplied]); useEffect(() => { const portrait = window.matchMedia('(orientation: portrait)'); @@ -250,5 +250,8 @@ export function useUserMedia(constraints: MediaStreamConstraints): UserMediaResu }; }, [stream]); - return { stream, dimensions, error, retry, isLoading }; + return useMemo( + () => ({ stream, dimensions, error, retry, isLoading }), + [stream, dimensions, error, retry, isLoading], + ); } diff --git a/packages/camera-web/test/Camera/Camera.test.tsx b/packages/camera-web/test/Camera/Camera.test.tsx index 0e0b4e1a0..44b85c25d 100644 --- a/packages/camera-web/test/Camera/Camera.test.tsx +++ b/packages/camera-web/test/Camera/Camera.test.tsx @@ -10,8 +10,8 @@ jest.mock('../../src/Camera/hooks', () => ({ isLoading: false, })), useCameraCanvas: jest.fn(() => ({ ref: createRef() })), - useCameraScreenshot: jest.fn(() => ({ takeScreenshot: jest.fn() })), - useCompression: jest.fn(() => ({ compress: jest.fn() })), + useCameraScreenshot: jest.fn(() => jest.fn()), + useCompression: jest.fn(() => jest.fn()), useTakePicture: jest.fn(() => ({ takePicture: jest.fn(), isLoading: false, @@ -146,12 +146,11 @@ describe('Camera component', () => { const onPictureTaken = () => {}; const { unmount } = render(); - const takeScreenshotMock = (useCameraScreenshot as jest.Mock).mock.results[0].value - .takeScreenshot; - const compressMock = (useCompression as jest.Mock).mock.results[0].value.compress; + const takeScreenshot = (useCameraScreenshot as jest.Mock).mock.results[0].value; + const compress = (useCompression as jest.Mock).mock.results[0].value; expect(useTakePicture).toHaveBeenCalledWith({ - compress: compressMock, - takeScreenshot: takeScreenshotMock, + compress, + takeScreenshot, onPictureTaken, monitoring, }); diff --git a/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts b/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts index e79e554af..7bce598cc 100644 --- a/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts +++ b/packages/camera-web/test/Camera/hooks/useCameraScreenshot.test.ts @@ -32,7 +32,7 @@ describe('useCameraScreenshot hook', () => { const { result, unmount } = renderHook(useCameraScreenshot, { initialProps: { videoRef, canvasRef, dimensions }, }); - const imageData = result.current.takeScreenshot(monitoringMock); + const imageData = result.current(monitoringMock); expect(getCanvasHandleMock).toHaveBeenCalledWith(canvasRef, expect.anything()); const { context } = getCanvasHandleMock.mock.results[0].value; @@ -53,7 +53,7 @@ describe('useCameraScreenshot hook', () => { initialProps: { videoRef, canvasRef, dimensions: null }, }); - expect(() => result.current.takeScreenshot(monitoringMock)).toThrowError(); + expect(() => result.current(monitoringMock)).toThrowError(); unmount(); }); @@ -62,7 +62,7 @@ describe('useCameraScreenshot hook', () => { initialProps: { videoRef: { current: null }, canvasRef, dimensions }, }); - expect(() => result.current.takeScreenshot(monitoringMock)).toThrowError(); + expect(() => result.current(monitoringMock)).toThrowError(); unmount(); }); @@ -70,7 +70,7 @@ describe('useCameraScreenshot hook', () => { const { result, unmount } = renderHook(useCameraScreenshot, { initialProps: { videoRef, canvasRef, dimensions }, }); - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); expect(monitoringMock.transaction?.startMeasurement).toHaveBeenCalledWith( ScreenshotMeasurement.operation, @@ -90,7 +90,7 @@ describe('useCameraScreenshot hook', () => { const { result, unmount } = renderHook(useCameraScreenshot, { initialProps: { videoRef, canvasRef, dimensions }, }); - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); expect(monitoringMock.transaction?.stopMeasurement).toHaveBeenCalledWith( ScreenshotMeasurement.operation, @@ -109,7 +109,7 @@ describe('useCameraScreenshot hook', () => { initialProps: { videoRef, canvasRef, dimensions }, }); try { - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); } catch (err) { if (err !== error) { throw err; @@ -128,12 +128,12 @@ describe('useCameraScreenshot hook', () => { initialProps: { videoRef, canvasRef, dimensions: null }, }); try { - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); } catch (err) { /* empty */ } - expect(() => result.current.takeScreenshot(monitoringMock)).toThrowError(); + expect(() => result.current(monitoringMock)).toThrowError(); expect(monitoringMock.transaction?.stopMeasurement).toHaveBeenCalledWith( ScreenshotMeasurement.operation, TransactionStatus.UNKNOWN_ERROR, @@ -146,12 +146,12 @@ describe('useCameraScreenshot hook', () => { initialProps: { videoRef: { current: null }, canvasRef, dimensions }, }); try { - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); } catch (err) { /* empty */ } - expect(() => result.current.takeScreenshot(monitoringMock)).toThrowError(); + expect(() => result.current(monitoringMock)).toThrowError(); expect(monitoringMock.transaction?.stopMeasurement).toHaveBeenCalledWith( ScreenshotMeasurement.operation, TransactionStatus.UNKNOWN_ERROR, @@ -163,7 +163,7 @@ describe('useCameraScreenshot hook', () => { const { result, unmount } = renderHook(useCameraScreenshot, { initialProps: { videoRef, canvasRef, dimensions }, }); - result.current.takeScreenshot(monitoringMock); + result.current(monitoringMock); expect(monitoringMock.transaction?.setMeasurement).toHaveBeenCalledWith( ScreenshotSizeMeasurement.name, diff --git a/packages/camera-web/test/Camera/hooks/useCompression.test.ts b/packages/camera-web/test/Camera/hooks/useCompression.test.ts index 232c645c1..8c45e9624 100644 --- a/packages/camera-web/test/Camera/hooks/useCompression.test.ts +++ b/packages/camera-web/test/Camera/hooks/useCompression.test.ts @@ -45,7 +45,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - const picture = result.current.compress(mockImageData, monitoringMock); + const picture = result.current(mockImageData, monitoringMock); const canvasHandleMock = (getCanvasHandle as jest.Mock).mock.results[0].value; expect(getCanvasHandle).toHaveBeenCalledWith(canvasRef); @@ -75,7 +75,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - expect(() => result.current.compress(mockImageData, monitoringMock)).toThrow(error); + expect(() => result.current(mockImageData, monitoringMock)).toThrow(error); unmount(); }); @@ -87,7 +87,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - result.current.compress(mockImageData, monitoringMock); + result.current(mockImageData, monitoringMock); expect(monitoringMock.transaction?.startMeasurement).toHaveBeenCalledWith( CompressionMeasurement.operation, @@ -113,7 +113,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - result.current.compress(mockImageData, monitoringMock); + result.current(mockImageData, monitoringMock); expect(monitoringMock.transaction?.stopMeasurement).toHaveBeenCalledWith( CompressionMeasurement.operation, @@ -135,7 +135,7 @@ describe('useCompression hook', () => { }); try { - result.current.compress(mockImageData, monitoringMock); + result.current(mockImageData, monitoringMock); } catch (err) { if (err !== error) { throw err; @@ -157,7 +157,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - result.current.compress(mockImageData, monitoringMock); + result.current(mockImageData, monitoringMock); const canvasHandleMock = (getCanvasHandle as jest.Mock).mock.results[0].value; const atobMock = jest.spyOn(global.window, 'atob'); @@ -178,7 +178,7 @@ describe('useCompression hook', () => { initialProps: { canvasRef, options }, }); - result.current.compress(mockImageData, monitoringMock); + result.current(mockImageData, monitoringMock); const canvasHandleMock = (getCanvasHandle as jest.Mock).mock.results[0].value; const atobMock = jest.spyOn(global.window, 'atob'); diff --git a/packages/common/src/hooks/useInteractiveStatus.ts b/packages/common/src/hooks/useInteractiveStatus.ts index 9b6addbbd..3f04f9f9b 100644 --- a/packages/common/src/hooks/useInteractiveStatus.ts +++ b/packages/common/src/hooks/useInteractiveStatus.ts @@ -1,4 +1,4 @@ -import { MouseEventHandler, useState } from 'react'; +import { MouseEventHandler, useCallback, useMemo, useState } from 'react'; import { InteractiveStatus } from '@monkvision/types'; /** @@ -79,33 +79,56 @@ export function useInteractiveStatus( const [hovered, setHovered] = useState(false); const [active, setActive] = useState(false); - return { - status: getInteractiveStatus({ hovered, active, disabled: params?.disabled }), - eventHandlers: { - onMouseEnter: (event) => { - setHovered(true); - if (params?.componentHandlers?.onMouseEnter) { - params.componentHandlers.onMouseEnter(event); - } - }, - onMouseLeave: (event) => { - setHovered(false); - if (params?.componentHandlers?.onMouseLeave) { - params.componentHandlers.onMouseLeave(event); - } - }, - onMouseDown: (event) => { - setActive(true); - if (params?.componentHandlers?.onMouseDown) { - params.componentHandlers.onMouseDown(event); - } - }, - onMouseUp: (event) => { - setActive(false); - if (params?.componentHandlers?.onMouseUp) { - params.componentHandlers.onMouseUp(event); - } - }, + const onMouseEnter = useCallback( + (event) => { + setHovered(true); + if (params?.componentHandlers?.onMouseEnter) { + params.componentHandlers.onMouseEnter(event); + } + }, + [params?.componentHandlers?.onMouseEnter], + ); + + const onMouseLeave = useCallback( + (event) => { + setHovered(false); + if (params?.componentHandlers?.onMouseLeave) { + params.componentHandlers.onMouseLeave(event); + } }, - }; + [params?.componentHandlers?.onMouseLeave], + ); + + const onMouseDown = useCallback( + (event) => { + setActive(true); + if (params?.componentHandlers?.onMouseDown) { + params.componentHandlers.onMouseDown(event); + } + }, + [params?.componentHandlers?.onMouseDown], + ); + + const onMouseUp = useCallback( + (event) => { + setActive(false); + if (params?.componentHandlers?.onMouseUp) { + params.componentHandlers.onMouseUp(event); + } + }, + [params?.componentHandlers?.onMouseUp], + ); + + return useMemo( + () => ({ + status: getInteractiveStatus({ hovered, active, disabled: params?.disabled }), + eventHandlers: { + onMouseEnter, + onMouseLeave, + onMouseDown, + onMouseUp, + }, + }), + [hovered, active, params?.disabled, onMouseEnter, onMouseLeave, onMouseDown, onMouseUp], + ); } diff --git a/packages/common/src/hooks/useLoadingState.ts b/packages/common/src/hooks/useLoadingState.ts index 16f7a8f97..1cb61b0ca 100644 --- a/packages/common/src/hooks/useLoadingState.ts +++ b/packages/common/src/hooks/useLoadingState.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; /** * An object containing data about a task. @@ -35,26 +35,29 @@ export function useLoadingState(): LoadingState { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const start = () => { + const start = useCallback(() => { setError(null); setIsLoading(true); - }; + }, []); - const onSuccess = () => { + const onSuccess = useCallback(() => { setError(null); setIsLoading(false); - }; + }, []); - const onError = (err?: unknown) => { + const onError = useCallback((err?: unknown) => { setError(err); setIsLoading(false); - }; + }, []); - return { - isLoading, - error, - start, - onSuccess, - onError, - }; + return useMemo( + () => ({ + isLoading, + error, + start, + onSuccess, + onError, + }), + [isLoading, error, start, onSuccess, onError], + ); } diff --git a/packages/common/src/hooks/useObjectTranslation.ts b/packages/common/src/hooks/useObjectTranslation.ts index f6bc524f2..4b50a050e 100644 --- a/packages/common/src/hooks/useObjectTranslation.ts +++ b/packages/common/src/hooks/useObjectTranslation.ts @@ -1,5 +1,6 @@ import { MonkLanguage, TranslationObject } from '@monkvision/types'; import { useTranslation } from 'react-i18next'; +import { useCallback } from 'react'; /** * The result of the useObjectTranslation. It contains a function which takes a LabelTranslation object and return the @@ -17,9 +18,12 @@ export interface UseObjectTranslationResult { */ export function useObjectTranslation(): UseObjectTranslationResult { const { i18n } = useTranslation(); - const tObj = (obj: TranslationObject) => { - const lang = i18n.language.slice(0, 2) as MonkLanguage; - return obj[lang] ?? 'translation-not-found'; - }; + const tObj = useCallback( + (obj: TranslationObject) => { + const lang = i18n.language.slice(0, 2) as MonkLanguage; + return obj[lang] ?? 'translation-not-found'; + }, + [i18n.language], + ); return { tObj }; } diff --git a/packages/common/src/hooks/useQueue.ts b/packages/common/src/hooks/useQueue.ts index 61658ff50..b8fee551e 100644 --- a/packages/common/src/hooks/useQueue.ts +++ b/packages/common/src/hooks/useQueue.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef, useState } from 'react'; +import { useCallback, useMemo, useRef, useState } from 'react'; /** * Type definition for the processing function of a queue. @@ -179,24 +179,25 @@ export function useQueue( const stateRef = useRef>(); stateRef.current = { processedItems, itemsOnHold, canceledItems, options }; - const clear = (cancelProcessing = false) => { + const clear = useCallback((cancelProcessing = false) => { if (cancelProcessing) { - setCanceledItems((items) => [...items, ...processedItems]); + setCanceledItems((items) => [...items, ...(stateRef.current?.processedItems ?? [])]); } setProcessedItems([]); setItemsOnHold([]); setFailedItems([]); setTotalItems(0); - }; + }, []); - const clearFailedItems = (itemsToClear: T[]) => { + const clearFailedItems = useCallback((itemsToClear: T[]) => { setFailedItems((items) => items.filter((i) => !itemsToClear.includes(i.item))); - }; + }, []); - const processItem = (item: T) => { - setProcessedItems((items) => [...items, item]); - return process(item) - .then(() => { + const processItem = useCallback( + async (item: T) => { + setProcessedItems((items) => [...items, item]); + try { + await process(item); if (stateRef.current?.canceledItems.includes(item)) { setCanceledItems((items) => items.filter((i) => i !== item)); return; @@ -204,8 +205,7 @@ export function useQueue( if (stateRef.current?.options.onItemComplete) { stateRef.current.options.onItemComplete(item); } - }) - .catch((error) => { + } catch (error) { if (stateRef.current?.canceledItems.includes(item)) { setCanceledItems((items) => items.filter((i) => i !== item)); return; @@ -216,13 +216,14 @@ export function useQueue( if (stateRef.current?.options.onItemFail) { stateRef.current.options.onItemFail(item); } - }) - .finally(() => { + } finally { setProcessedItems((items) => items.filter((i) => i !== item)); - }); - }; + } + }, + [process], + ); - const shiftQueue = () => { + const shiftQueue = useCallback(() => { if (stateRef.current) { if (stateRef.current.itemsOnHold.length === 0) { return; @@ -245,27 +246,37 @@ export function useQueue( }); setItemsOnHold((items) => items.filter((item) => !itemsToShift.includes(item))); } - }; + }, [processItem]); - const push = (item: T) => { - if ( - options.maxItems !== undefined && - processedItems.length + itemsOnHold.length >= options.maxItems - ) { - throw new Error('Queue is full.'); - } - setTotalItems((total) => total + 1); - if ( - options.maxProcessingItems !== undefined && - processedItems.length >= options.maxProcessingItems - ) { - setItemsOnHold((items) => [...items, item]); - return; - } - processItem(item) - .catch(() => {}) - .finally(() => shiftQueue()); - }; + const push = useCallback( + (item: T) => { + if ( + options.maxItems !== undefined && + processedItems.length + itemsOnHold.length >= options.maxItems + ) { + throw new Error('Queue is full.'); + } + setTotalItems((total) => total + 1); + if ( + options.maxProcessingItems !== undefined && + processedItems.length >= options.maxProcessingItems + ) { + setItemsOnHold((items) => [...items, item]); + return; + } + processItem(item) + .catch(() => {}) + .finally(() => shiftQueue()); + }, + [ + options.maxItems, + processedItems.length, + itemsOnHold.length, + options.maxProcessingItems, + processItem, + shiftQueue, + ], + ); return { length: processedItems.length + itemsOnHold.length, diff --git a/packages/common/src/hooks/useResponsiveStyle.ts b/packages/common/src/hooks/useResponsiveStyle.ts index d17ae9157..5eb43e69f 100644 --- a/packages/common/src/hooks/useResponsiveStyle.ts +++ b/packages/common/src/hooks/useResponsiveStyle.ts @@ -1,4 +1,4 @@ -import { CSSProperties } from 'react'; +import { CSSProperties, useCallback } from 'react'; import { CSSMediaQuery, ResponsiveStyleProperties } from '@monkvision/types'; import { useWindowDimensions, WindowDimensions } from './useWindowDimensions'; @@ -57,12 +57,15 @@ export function useResponsiveStyle(): { responsive: (style: ResponsiveStyleProperties | null) => CSSProperties | null; } { const dimensions = useWindowDimensions(); - const responsive = (style: ResponsiveStyleProperties | null) => { - if (areQueryConditionsMet(style?.__media, dimensions)) { - return style; - } - return null; - }; + const responsive = useCallback( + (style: ResponsiveStyleProperties | null) => { + if (areQueryConditionsMet(style?.__media, dimensions)) { + return style; + } + return null; + }, + [dimensions], + ); return { responsive }; } diff --git a/packages/common/src/hooks/useSightLabel.ts b/packages/common/src/hooks/useSightLabel.ts index 38379e017..38b735d31 100644 --- a/packages/common/src/hooks/useSightLabel.ts +++ b/packages/common/src/hooks/useSightLabel.ts @@ -1,4 +1,5 @@ import { LabelDictionary, Sight } from '@monkvision/types'; +import { useCallback } from 'react'; import { useObjectTranslation } from './useObjectTranslation'; /** @@ -24,10 +25,13 @@ export interface UseSightLabelParams { */ export function useSightLabel({ labels }: UseSightLabelParams): UseSightLabelResult { const { tObj } = useObjectTranslation(); - return { - label: (sight) => { + const label = useCallback( + (sight: Sight) => { const translationObject = labels[sight.label]; return translationObject ? tObj(translationObject) : `translation-not-found[${sight.label}]`; }, - }; + [tObj, labels], + ); + + return { label }; } diff --git a/packages/common/src/i18n/utils.tsx b/packages/common/src/i18n/utils.tsx index 5eb301bfd..233f8def0 100644 --- a/packages/common/src/i18n/utils.tsx +++ b/packages/common/src/i18n/utils.tsx @@ -23,7 +23,11 @@ export function useI18nSync(language?: string | null): void { if (!language) { return; } - if (!(monkLanguages as readonly string[]).includes(language)) { + if ( + monkLanguages.every( + (supportedLang) => !language.toLowerCase().startsWith(supportedLang.toLowerCase()), + ) + ) { handleError( new Error( `Unsupported language passed to the MonkJs SDK : ${language}. Currently supported languages are : ${monkLanguages}.`, diff --git a/packages/common/test/hooks/useSightLabel.test.ts b/packages/common/test/hooks/useSightLabel.test.ts index 83067d5c6..3e3661984 100644 --- a/packages/common/test/hooks/useSightLabel.test.ts +++ b/packages/common/test/hooks/useSightLabel.test.ts @@ -1,3 +1,7 @@ +jest.mock('../../src/hooks/useObjectTranslation', () => ({ + useObjectTranslation: jest.fn(() => ({ tObj: jest.fn() })), +})); + import { renderHook } from '@testing-library/react-hooks'; import { LabelDictionary, Sight, TranslationObject } from '@monkvision/types'; import { useObjectTranslation, useSightLabel } from '../../src'; @@ -18,7 +22,8 @@ describe('useSightLabel hook', () => { const { result, unmount } = renderHook(() => useSightLabel({ labels })); const sight = { id: 'id', label: 'rear-back' } as unknown as Sight; - const { tObj } = useObjectTranslation(); + expect(useObjectTranslation).toHaveBeenCalled(); + const { tObj } = (useObjectTranslation as jest.Mock).mock.results[0].value; const myValue = { en: 'english translation', fr: 'fr', de: 'de' } as TranslationObject; expect(result.current.label(sight)).toBe(tObj(myValue)); diff --git a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx index ba1ee2967..6ddd3b11e 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx +++ b/packages/inspection-capture-web/src/PhotoCapture/PhotoCapture.tsx @@ -92,7 +92,7 @@ export function PhotoCapture({ const { handleError } = useMonitoring(); const loading = useLoadingState(); const addDamageHandle = useAddDamageMode(); - const { startTasks } = useStartTasksOnComplete({ + const startTasks = useStartTasksOnComplete({ inspectionId, apiConfig, sights, @@ -123,7 +123,7 @@ export function PhotoCapture({ apiConfig, compliances, }); - const { handlePictureTaken } = usePictureTaken({ + const handlePictureTaken = usePictureTaken({ sightState, addDamageHandle, uploadQueue, diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useAddDamageMode.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useAddDamageMode.ts index 07d083de8..5662fd1b8 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useAddDamageMode.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useAddDamageMode.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; /** * Enum of the different picture taking modes that the PhotoCapture component can be in. @@ -46,21 +46,27 @@ export interface AddDamageHandle { export function useAddDamageMode(): AddDamageHandle { const [mode, setMode] = useState(PhotoCaptureMode.SIGHT); - const handleAddDamage = () => setMode(PhotoCaptureMode.ADD_DAMAGE_1ST_SHOT); + const handleAddDamage = useCallback(() => setMode(PhotoCaptureMode.ADD_DAMAGE_1ST_SHOT), []); - const updatePhotoCaptureModeAfterPictureTaken = () => - setMode((currentMode) => - currentMode === PhotoCaptureMode.ADD_DAMAGE_1ST_SHOT - ? PhotoCaptureMode.ADD_DAMAGE_2ND_SHOT - : PhotoCaptureMode.SIGHT, - ); + const updatePhotoCaptureModeAfterPictureTaken = useCallback( + () => + setMode((currentMode) => + currentMode === PhotoCaptureMode.ADD_DAMAGE_1ST_SHOT + ? PhotoCaptureMode.ADD_DAMAGE_2ND_SHOT + : PhotoCaptureMode.SIGHT, + ), + [], + ); - const handleCancelAddDamage = () => setMode(PhotoCaptureMode.SIGHT); + const handleCancelAddDamage = useCallback(() => setMode(PhotoCaptureMode.SIGHT), []); - return { - mode, - handleAddDamage, - updatePhotoCaptureModeAfterPictureTaken, - handleCancelAddDamage, - }; + return useMemo( + () => ({ + mode, + handleAddDamage, + updatePhotoCaptureModeAfterPictureTaken, + handleCancelAddDamage, + }), + [mode, handleAddDamage, updatePhotoCaptureModeAfterPictureTaken, handleCancelAddDamage], + ); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts index 7088a5eb9..752aa2749 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePhotoCaptureSightState.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { MonkAPIConfig, MonkApiResponse, useMonkApi } from '@monkvision/network'; import { useMonitoring } from '@monkvision/monitoring'; import { LoadingState, MonkGotOneInspectionAction, useAsyncEffect } from '@monkvision/common'; @@ -190,11 +190,11 @@ export function usePhotoCaptureSightState({ }, ); - const retryLoadingInspection = () => { + const retryLoadingInspection = useCallback(() => { setRetryCount((value) => value + 1); - }; + }, []); - const takeSelectedSight = () => { + const takeSelectedSight = useCallback(() => { const updatedSightsTaken = [...sightsTaken, selectedSight]; setSightsTaken(updatedSightsTaken); const nextSight = captureSights.filter((s) => !updatedSightsTaken.includes(s))[0]; @@ -203,15 +203,26 @@ export function usePhotoCaptureSightState({ } else { onLastSightTaken(); } - }; + }, [sightsTaken, selectedSight, captureSights, onLastSightTaken]); - return { - selectedSight, - sightsTaken, - selectSight: setSelectedSight, - takeSelectedSight, - lastPictureTaken, - setLastPictureTaken, - retryLoadingInspection, - }; + return useMemo( + () => ({ + selectedSight, + sightsTaken, + selectSight: setSelectedSight, + takeSelectedSight, + lastPictureTaken, + setLastPictureTaken, + retryLoadingInspection, + }), + [ + selectedSight, + sightsTaken, + setSelectedSight, + takeSelectedSight, + lastPictureTaken, + setLastPictureTaken, + retryLoadingInspection, + ], + ); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePictureTaken.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePictureTaken.ts index 25143e8c2..56eebdffe 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/usePictureTaken.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/usePictureTaken.ts @@ -1,6 +1,7 @@ import { MonkPicture } from '@monkvision/camera-web'; import { TaskName } from '@monkvision/types'; import { Queue } from '@monkvision/common'; +import { useCallback } from 'react'; import { PictureUpload } from './useUploadQueue'; import { AddDamageHandle, PhotoCaptureMode } from './useAddDamageMode'; import { PhotoCaptureSightState } from './usePhotoCaptureSightState'; @@ -29,14 +30,9 @@ export interface UseTakePictureParams { } /** - * Result returned by the usePictureTaken hook. + * Callback called when the user has taken a picture. */ -export interface UseTakePictureResult { - /** - * Callback called when the user has taken a picture. - */ - handlePictureTaken: (picture: MonkPicture) => void; -} +export type HandleTakePictureFunction = (picture: MonkPicture) => void; /** * Custom hook used to generate the callback called when the user has taken a picture to handle picture upload etc. @@ -46,24 +42,33 @@ export function usePictureTaken({ addDamageHandle, uploadQueue, tasksBySight, -}: UseTakePictureParams): UseTakePictureResult { - const handlePictureTaken = (picture: MonkPicture) => { - sightState.setLastPictureTaken(picture); - const upload: PictureUpload = - addDamageHandle.mode === PhotoCaptureMode.SIGHT - ? { - mode: addDamageHandle.mode, - picture, - sightId: sightState.selectedSight.id, - tasks: tasksBySight?.[sightState.selectedSight.id] ?? sightState.selectedSight.tasks, - } - : { mode: addDamageHandle.mode, picture }; - uploadQueue.push(upload); - if (addDamageHandle.mode === PhotoCaptureMode.SIGHT) { - sightState.takeSelectedSight(); - } - addDamageHandle.updatePhotoCaptureModeAfterPictureTaken(); - }; - - return { handlePictureTaken }; +}: UseTakePictureParams): HandleTakePictureFunction { + return useCallback( + (picture: MonkPicture) => { + sightState.setLastPictureTaken(picture); + const upload: PictureUpload = + addDamageHandle.mode === PhotoCaptureMode.SIGHT + ? { + mode: addDamageHandle.mode, + picture, + sightId: sightState.selectedSight.id, + tasks: tasksBySight?.[sightState.selectedSight.id] ?? sightState.selectedSight.tasks, + } + : { mode: addDamageHandle.mode, picture }; + uploadQueue.push(upload); + if (addDamageHandle.mode === PhotoCaptureMode.SIGHT) { + sightState.takeSelectedSight(); + } + addDamageHandle.updatePhotoCaptureModeAfterPictureTaken(); + }, + [ + sightState.setLastPictureTaken, + addDamageHandle.mode, + sightState.selectedSight.id, + tasksBySight, + uploadQueue.push, + sightState.takeSelectedSight, + addDamageHandle.updatePhotoCaptureModeAfterPictureTaken, + ], + ); } diff --git a/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts b/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts index cd8629d2d..dd8ac7686 100644 --- a/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts +++ b/packages/inspection-capture-web/src/PhotoCapture/hooks/useStartTasksOnComplete.ts @@ -2,7 +2,7 @@ import { Sight, TaskName } from '@monkvision/types'; import { flatMap, LoadingState, uniq } from '@monkvision/common'; import { MonkAPIConfig, useMonkApi } from '@monkvision/network'; import { useMonitoring } from '@monkvision/monitoring'; -import { useMemo } from 'react'; +import { useCallback } from 'react'; /** * Parameters of the useStartTasksOnComplete hook. @@ -40,14 +40,9 @@ export interface UseStartTasksOnCompleteParams { } /** - * Result of the useStartTasksOnComplete hook. + * Callback to be called when the PhotoCapture inspection is complete in order to start (or not) to inspection tasks. */ -export interface StartTasksOnCompleteHandle { - /** - * Callback to be called when the PhotoCapture inspection is complete in order to start (or not) to inspection tasks. - */ - startTasks: () => Promise; -} +export type StartTasksFunction = () => Promise; function getTasksToStart({ sights, @@ -77,27 +72,23 @@ export function useStartTasksOnComplete({ tasksBySight, startTasksOnComplete, loading, -}: UseStartTasksOnCompleteParams): StartTasksOnCompleteHandle { +}: UseStartTasksOnCompleteParams): StartTasksFunction { const { startInspectionTasks } = useMonkApi(apiConfig); const { handleError } = useMonitoring(); - return useMemo(() => { + return useCallback(async () => { if (!startTasksOnComplete) { - return { startTasks: () => Promise.resolve() }; + return; } const tasks = getTasksToStart({ sights, tasksBySight, startTasksOnComplete }); - return { - startTasks: async () => { - loading.start(); - try { - await startInspectionTasks(inspectionId, tasks); - loading.onSuccess(); - } catch (err) { - handleError(err); - loading.onError(err); - } - }, - }; + loading.start(); + try { + await startInspectionTasks(inspectionId, tasks); + loading.onSuccess(); + } catch (err) { + handleError(err); + loading.onError(err); + } }, [startTasksOnComplete, loading, sights, tasksBySight, inspectionId, handleError]); } diff --git a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx index 5d4cfbf2d..9d280d141 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx +++ b/packages/inspection-capture-web/test/PhotoCapture/PhotoCapture.test.tsx @@ -23,15 +23,11 @@ jest.mock('../../src/PhotoCapture/hooks', () => ({ setLastPictureTaken: jest.fn(), retryLoadingInspection: jest.fn(), })), - usePictureTaken: jest.fn(() => ({ - handlePictureTaken: jest.fn(), - })), + usePictureTaken: jest.fn(() => jest.fn()), useUploadQueue: jest.fn(() => ({ length: 3, })), - useStartTasksOnComplete: jest.fn(() => ({ - startTasks: jest.fn(), - })), + useStartTasksOnComplete: jest.fn(() => jest.fn()), })); import { render, waitFor } from '@testing-library/react'; @@ -111,9 +107,7 @@ describe('PhotoCapture component', () => { it('should call start tasks on the last sight and handle the promise correctly', async () => { const startTasksMock = jest.fn(() => Promise.resolve()); - (useStartTasksOnComplete as jest.Mock).mockImplementation(() => ({ - startTasks: startTasksMock, - })); + (useStartTasksOnComplete as jest.Mock).mockImplementation(() => startTasksMock); const props = createProps(); const { unmount } = render(); @@ -231,7 +225,7 @@ describe('PhotoCapture component', () => { const { unmount } = render(); expect(usePictureTaken).toHaveBeenCalled(); - const { handlePictureTaken } = (usePictureTaken as jest.Mock).mock.results[0].value; + const handlePictureTaken = (usePictureTaken as jest.Mock).mock.results[0].value; expectPropsOnChildMock(Camera, { onPictureTaken: handlePictureTaken }); unmount(); diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePictureTaken.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePictureTaken.test.ts index b5a15ccbb..0b4d9c18b 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/usePictureTaken.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/usePictureTaken.test.ts @@ -45,7 +45,7 @@ describe('usePictureTaken hook', () => { expect(initialProps.sightState.setLastPictureTaken).not.toHaveBeenCalled(); const picture = createMonkPicture(); - result.current.handlePictureTaken(picture); + result.current(picture); expect(initialProps.sightState.setLastPictureTaken).toHaveBeenCalledWith(picture); unmount(); @@ -57,7 +57,7 @@ describe('usePictureTaken hook', () => { expect(initialProps.uploadQueue.push).not.toHaveBeenCalled(); const picture = createMonkPicture(); - result.current.handlePictureTaken(picture); + result.current(picture); expect(initialProps.uploadQueue.push).toHaveBeenCalledWith({ mode: initialProps.addDamageHandle.mode, picture, @@ -80,7 +80,7 @@ describe('usePictureTaken hook', () => { expect(initialProps.uploadQueue.push).not.toHaveBeenCalled(); const picture = createMonkPicture(); - result.current.handlePictureTaken(picture); + result.current(picture); expect(initialProps.uploadQueue.push).toHaveBeenCalledWith(expect.objectContaining({ tasks })); unmount(); @@ -99,7 +99,7 @@ describe('usePictureTaken hook', () => { expect(initialProps.uploadQueue.push).not.toHaveBeenCalled(); const picture = createMonkPicture(); - result.current.handlePictureTaken(picture); + result.current(picture); expect(initialProps.uploadQueue.push).toHaveBeenCalledWith({ mode: initialProps.addDamageHandle.mode, picture, @@ -113,7 +113,7 @@ describe('usePictureTaken hook', () => { const { result, unmount } = renderHook(usePictureTaken, { initialProps }); expect(initialProps.sightState.takeSelectedSight).not.toHaveBeenCalled(); - result.current.handlePictureTaken(createMonkPicture()); + result.current(createMonkPicture()); expect(initialProps.sightState.takeSelectedSight).toHaveBeenCalled(); unmount(); @@ -130,7 +130,7 @@ describe('usePictureTaken hook', () => { }; const { result, unmount } = renderHook(usePictureTaken, { initialProps }); - result.current.handlePictureTaken(createMonkPicture()); + result.current(createMonkPicture()); expect(initialProps.sightState.takeSelectedSight).not.toHaveBeenCalled(); unmount(); @@ -143,7 +143,7 @@ describe('usePictureTaken hook', () => { expect( initialProps.addDamageHandle.updatePhotoCaptureModeAfterPictureTaken, ).not.toHaveBeenCalled(); - result.current.handlePictureTaken(createMonkPicture()); + result.current(createMonkPicture()); expect(initialProps.addDamageHandle.updatePhotoCaptureModeAfterPictureTaken).toHaveBeenCalled(); unmount(); diff --git a/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts b/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts index 8ffeede73..7515a23b4 100644 --- a/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts +++ b/packages/inspection-capture-web/test/PhotoCapture/hooks/useStartTasksOnComplete.test.ts @@ -44,7 +44,7 @@ describe('useStartTasksOnComplete hook', () => { const startInspectionTasksMock = (useMonkApi as jest.Mock).mock.results[0].value .startInspectionTasks; - result.current.startTasks(); + result.current(); expect(initialProps.loading.start).not.toHaveBeenCalled(); expect(initialProps.loading.onSuccess).not.toHaveBeenCalled(); expect(initialProps.loading.onError).not.toHaveBeenCalled(); @@ -62,7 +62,7 @@ describe('useStartTasksOnComplete hook', () => { const startInspectionTasksMock = (useMonkApi as jest.Mock).mock.results[0].value .startInspectionTasks; - result.current.startTasks(); + result.current(); expect(startInspectionTasksMock).toHaveBeenCalledWith( initialProps.inspectionId, initialProps.startTasksOnComplete, @@ -88,7 +88,7 @@ describe('useStartTasksOnComplete hook', () => { const startInspectionTasksMock = (useMonkApi as jest.Mock).mock.results[0].value .startInspectionTasks; - result.current.startTasks(); + result.current(); expect(startInspectionTasksMock).toHaveBeenCalledWith(initialProps.inspectionId, [ TaskName.DAMAGE_DETECTION, TaskName.PRICING, @@ -108,7 +108,7 @@ describe('useStartTasksOnComplete hook', () => { const startInspectionTasksMock = (useMonkApi as jest.Mock).mock.results[0].value .startInspectionTasks; - result.current.startTasks(); + result.current(); const tasks = uniq(flatMap(initialProps.sights, (sight) => sight.tasks)); expect(startInspectionTasksMock).toHaveBeenCalledWith(initialProps.inspectionId, tasks); @@ -129,7 +129,7 @@ describe('useStartTasksOnComplete hook', () => { expect(initialProps.loading.start).not.toHaveBeenCalled(); expect(startInspectionTasksMock).not.toHaveBeenCalled(); - result.current.startTasks(); + result.current(); expect(initialProps.loading.start).toHaveBeenCalled(); expect(startInspectionTasksMock).toHaveBeenCalled(); expect(initialProps.loading.onSuccess).not.toHaveBeenCalled(); @@ -158,7 +158,7 @@ describe('useStartTasksOnComplete hook', () => { expect(initialProps.loading.start).not.toHaveBeenCalled(); expect(startInspectionTasksMock).not.toHaveBeenCalled(); - result.current.startTasks().catch((e) => console.log('beuh', e)); + result.current().catch((e) => console.log('beuh', e)); expect(initialProps.loading.start).toHaveBeenCalled(); expect(startInspectionTasksMock).toHaveBeenCalled(); expect(initialProps.loading.onError).not.toHaveBeenCalled(); diff --git a/packages/monitoring/src/react/hooks.ts b/packages/monitoring/src/react/hooks.ts index c24dfb308..04f1b3cda 100644 --- a/packages/monitoring/src/react/hooks.ts +++ b/packages/monitoring/src/react/hooks.ts @@ -1,4 +1,4 @@ -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; import { MonitoringAdapter } from '../adapters'; import { MonitoringContext } from './context'; @@ -7,10 +7,14 @@ import { MonitoringContext } from './context'; */ export function useMonitoring(): MonitoringAdapter { const adapter = useContext(MonitoringContext); - return { - setUserId: adapter.setUserId.bind(adapter), - log: adapter.log.bind(adapter), - handleError: adapter.handleError.bind(adapter), - createTransaction: adapter.createTransaction.bind(adapter), - }; + + return useMemo( + () => ({ + setUserId: adapter.setUserId.bind(adapter), + log: adapter.log.bind(adapter), + handleError: adapter.handleError.bind(adapter), + createTransaction: adapter.createTransaction.bind(adapter), + }), + [], + ); } diff --git a/packages/network/src/api/react.ts b/packages/network/src/api/react.ts index 28ab9b04b..29b27cdda 100644 --- a/packages/network/src/api/react.ts +++ b/packages/network/src/api/react.ts @@ -1,4 +1,4 @@ -import { Dispatch } from 'react'; +import { Dispatch, useCallback } from 'react'; import { MonkAction, useMonkState } from '@monkvision/common'; import { MonkAPIConfig } from './config'; import { MonkAPIRequest, MonkApiResponse } from './types'; @@ -15,13 +15,13 @@ function reactifyRequest< config: MonkAPIConfig, dispatch: Dispatch, ): (...args: A) => Promise> { - return async (...args: A) => { + return useCallback(async (...args: A) => { const result = await request(...args, config); if (result.action) { dispatch(result.action); } return result; - }; + }, []); } /** diff --git a/packages/network/src/auth/hooks.ts b/packages/network/src/auth/hooks.ts index f3837f1b4..50f3cfec7 100644 --- a/packages/network/src/auth/hooks.ts +++ b/packages/network/src/auth/hooks.ts @@ -1,5 +1,5 @@ import { useAuth0 } from '@auth0/auth0-react'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { STORAGE_KEY_AUTH_TOKEN, useMonkAppParams } from '@monkvision/common'; /** @@ -68,7 +68,7 @@ export function useAuth(params?: UseAuthParams): MonkAuthHandle { } }, []); - const handleLogin = async () => { + const handleLogin = useCallback(async () => { const token = await getAccessTokenWithPopup(); if (token) { setAuthTokenParam(token); @@ -79,18 +79,21 @@ export function useAuth(params?: UseAuthParams): MonkAuthHandle { return token; } return null; - }; + }, [getAccessTokenWithPopup, options.storeToken, setAuthTokenParam]); - const handleLogout = async () => { + const handleLogout = useCallback(async () => { setAuthTokenParam(null); setAuthToken(null); localStorage.removeItem(STORAGE_KEY_AUTH_TOKEN); await logout({ logoutParams: { returnTo: window.location.origin } }); - }; + }, [logout, setAuthTokenParam]); - return { - authToken, - login: handleLogin, - logout: handleLogout, - }; + return useMemo( + () => ({ + authToken, + login: handleLogin, + logout: handleLogout, + }), + [authToken, handleLogin, handleLogout], + ); }