diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 60ba16164eb6..04fdb7e80d6a 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -205,22 +205,61 @@ jobs: cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log || true cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" || true + - name: Announce failed workflow in Slack + if: failure() + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#expensify-margelo', + attachments: [{ + color: 'danger', + text: `💥 ${process.env.AS_REPO} E2E Test run failed failed on workflow 💥`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + - name: Unzip AWS Device Farm results - if: ${{ always() }} + if: always() run: unzip "Customer Artifacts.zip" - name: Print AWS Device Farm run results - if: ${{ always() }} + if: always() run: cat "./Host_Machine_Files/\$WORKING_DIRECTORY/output.md" - name: Check if test failed, if so post the results and add the DeployBlocker label + id: checkIfRegressionDetected run: | if grep -q '🔴' ./output.md; then + # Create an output to the GH action that the test failed: + echo "performanceRegressionDetected=true" >> "$GITHUB_OUTPUT" + gh pr edit ${{ inputs.PR_NUMBER }} --add-label DeployBlockerCash gh pr comment ${{ inputs.PR_NUMBER }} -F ./output.md gh pr comment ${{ inputs.PR_NUMBER }} -b "@Expensify/mobile-deployers 📣 Please look into this performance regression as it's a deploy blocker." else + echo "performanceRegressionDetected=false" >> "$GITHUB_OUTPUT" echo '✅ no performance regression detected' fi env: GITHUB_TOKEN: ${{ github.token }} + + - name: 'Announce regression in Slack' + if: ${{ steps.checkIfRegressionDetected.outputs.performanceRegressionDetected == 'true' }} + uses: 8398a7/action-slack@v3 + with: + status: custom + custom_payload: | + { + channel: '#newdot-performance', + attachments: [{ + color: 'danger', + text: `🔴 Performance regression detected in PR ${{ inputs.PR_NUMBER }}\nDetected in workflow.`, + }] + } + env: + GITHUB_TOKEN: ${{ github.token }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml index 17e18e6e53f0..d2e0ec4f38e5 100644 --- a/.github/workflows/failureNotifier.yml +++ b/.github/workflows/failureNotifier.yml @@ -83,13 +83,13 @@ jobs: `1. **Why the PR caused the job to fail?**\n` + `2. **Address any underlying issues.**\n\n` + `🐛 We appreciate your help in squashing this bug!`; - github.rest.issues.create({ + await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: issueTitle, body: issueBody, labels: [failureLabel, 'Daily'], - assignees: [prMerger, prAuthor] + assignees: [prMerger] }); } } diff --git a/__mocks__/react-freeze.js b/__mocks__/react-freeze.js deleted file mode 100644 index 51294f40f9ca..000000000000 --- a/__mocks__/react-freeze.js +++ /dev/null @@ -1,6 +0,0 @@ -const Freeze = (props) => props.children; - -export { - // eslint-disable-next-line import/prefer-default-export - Freeze, -}; diff --git a/__mocks__/react-freeze.ts b/__mocks__/react-freeze.ts new file mode 100644 index 000000000000..d87abe01acfb --- /dev/null +++ b/__mocks__/react-freeze.ts @@ -0,0 +1,8 @@ +import type {Freeze as FreezeComponent} from 'react-freeze'; + +const Freeze: typeof FreezeComponent = (props) => props.children as JSX.Element; + +export { + // eslint-disable-next-line import/prefer-default-export + Freeze, +}; diff --git a/__mocks__/react-native-dev-menu.js b/__mocks__/react-native-dev-menu.js deleted file mode 100644 index 49cb4c61a209..000000000000 --- a/__mocks__/react-native-dev-menu.js +++ /dev/null @@ -1,3 +0,0 @@ -export default { - addItem: jest.fn(), -}; diff --git a/__mocks__/react-native-dev-menu.ts b/__mocks__/react-native-dev-menu.ts new file mode 100644 index 000000000000..0d35d5c32723 --- /dev/null +++ b/__mocks__/react-native-dev-menu.ts @@ -0,0 +1,11 @@ +import type {addItem} from 'react-native-dev-menu'; + +type ReactNativeDevMenuMock = { + addItem: typeof addItem; +}; + +const reactNativeDevMenuMock: ReactNativeDevMenuMock = { + addItem: jest.fn(), +}; + +export default reactNativeDevMenuMock; diff --git a/__mocks__/react-native-pdf.js b/__mocks__/react-native-pdf.js deleted file mode 100644 index 4d179e730903..000000000000 --- a/__mocks__/react-native-pdf.js +++ /dev/null @@ -1,4 +0,0 @@ -export default { - DocumentDir: jest.fn(), - ImageCache: jest.fn(), -}; diff --git a/__mocks__/react-native-reanimated.js b/__mocks__/react-native-reanimated.js deleted file mode 100644 index dfd96838caa7..000000000000 --- a/__mocks__/react-native-reanimated.js +++ /dev/null @@ -1,7 +0,0 @@ -// This mock is required as per setup instructions for react-navigation testing -// https://reactnavigation.org/docs/testing/#mocking-native-modules - -const Reanimated = require('react-native-reanimated/mock'); - -Reanimated.default.call = () => {}; -module.exports = Reanimated; diff --git a/android/app/build.gradle b/android/app/build.gradle index 4b7bfca10fe7..c0d157da2afd 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -98,8 +98,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001043802 - versionName "1.4.38-2" + versionCode 1001043900 + versionName "1.4.39-0" } flavorDimensions "default" diff --git a/assets/images/google-meet.svg b/assets/images/google-meet.svg deleted file mode 100644 index 8def88aa6edc..000000000000 --- a/assets/images/google-meet.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/images/zoom-icon.svg b/assets/images/zoom-icon.svg deleted file mode 100644 index 81f025aedf79..000000000000 --- a/assets/images/zoom-icon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index 543b133fe62b..5bb6dfb85851 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -30,7 +30,7 @@ When creating RHP flows, you have to remember a couple things: - Since you can deeplink to different pages inside the RHP navigator, it is important to provide the possibility for the user to properly navigate back from any page with UP press (`HeaderWithBackButton` component). -- An example can be deeplinking to `/settings/profile/personal-details`. From there, when pressing the UP button, you should navigate to `/settings/profile`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). +- An example can be deeplinking to `/settings/profile/timezone/select`. From there, when pressing the UP button, you should navigate to `/settings/profile/timezone`, so in order for it to work, you should provide the correct route in `onBackButtonPress` prop of `HeaderWithBackButton` (`Navigation.goBack(ROUTES.SETTINGS_PROFILE)` in this example). - We use a custom `goBack` function to handle the browser and the `react-navigation` history stack. Under the hood, it resolves to either replacing the current screen with the one we navigate to (deeplinking scenario) or just going back if we reached the current page by navigating in App (pops the screen). It ensures the requested behaviors on web, which is navigating back to the place from where you deeplinked when going into the RHP flow by it. diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index b265fa31c0c4..c23bcfef9657 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.38 + 1.4.39 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.38.2 + 1.4.39.0 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 402f463a66eb..61cf39e17bc7 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.38 + 1.4.39 CFBundleSignature ???? CFBundleVersion - 1.4.38.2 + 1.4.39.0 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index aab8170f1a87..27ca75150241 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 1.4.38 + 1.4.39 CFBundleVersion - 1.4.38.2 + 1.4.39.0 NSExtension NSExtensionPointIdentifier diff --git a/jest.config.js b/jest.config.js index de7ed4b1f974..b347db593d83 100644 --- a/jest.config.js +++ b/jest.config.js @@ -22,8 +22,8 @@ module.exports = { doNotFake: ['nextTick'], }, testEnvironment: 'jsdom', - setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'], + setupFiles: ['/jest/setup.ts', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], + setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.ts', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.js', diff --git a/jest/setup.js b/jest/setup.ts similarity index 90% rename from jest/setup.js rename to jest/setup.ts index e82bf678941d..68d904fac5be 100644 --- a/jest/setup.js +++ b/jest/setup.ts @@ -1,6 +1,7 @@ import mockClipboard from '@react-native-clipboard/clipboard/jest/clipboard-mock'; import '@shopify/flash-list/jestSetup'; import 'react-native-gesture-handler/jestSetup'; +import mockStorage from 'react-native-onyx/dist/storage/__mocks__'; import * as reanimatedJestUtils from 'react-native-reanimated/src/reanimated2/jestUtils'; import 'setimmediate'; import setupMockImages from './setupMockImages'; @@ -19,7 +20,7 @@ jest.mock('@react-native-clipboard/clipboard', () => mockClipboard); // Mock react-native-onyx storage layer because the SQLite storage layer doesn't work in jest. // Mocking this file in __mocks__ does not work because jest doesn't support mocking files that are not directly used in the testing project, // and we only want to mock the storage layer, not the whole Onyx module. -jest.mock('react-native-onyx/dist/storage', () => require('react-native-onyx/dist/storage/__mocks__')); +jest.mock('react-native-onyx/dist/storage', () => mockStorage); // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params) => { @@ -34,6 +35,6 @@ jest.spyOn(console, 'debug').mockImplementation((...params) => { // This mock is required for mocking file systems when running tests jest.mock('react-native-fs', () => ({ - unlink: jest.fn(() => new Promise((res) => res())), + unlink: jest.fn(() => new Promise((res) => res())), CachesDirectoryPath: jest.fn(), })); diff --git a/jest/setupAfterEnv.js b/jest/setupAfterEnv.ts similarity index 100% rename from jest/setupAfterEnv.js rename to jest/setupAfterEnv.ts diff --git a/jest/setupMockImages.js b/jest/setupMockImages.ts similarity index 87% rename from jest/setupMockImages.js rename to jest/setupMockImages.ts index 10925aca8736..c48797b3c07b 100644 --- a/jest/setupMockImages.js +++ b/jest/setupMockImages.ts @@ -1,14 +1,10 @@ import fs from 'fs'; import path from 'path'; -import _ from 'underscore'; -/** - * @param {String} imagePath - */ -function mockImages(imagePath) { +function mockImages(imagePath: string) { const imageFilenames = fs.readdirSync(path.resolve(__dirname, `../assets/${imagePath}/`)); // eslint-disable-next-line rulesdir/prefer-early-return - _.each(imageFilenames, (fileName) => { + imageFilenames.forEach((fileName) => { if (/\.svg/.test(fileName)) { jest.mock(`../assets/${imagePath}/${fileName}`, () => () => ''); } diff --git a/package-lock.json b/package-lock.json index c2a92a64b0dd..da90ce02044c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.38-2", + "version": "1.4.39-0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.38-2", + "version": "1.4.39-0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -96,7 +96,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.1", + "react-native-onyx": "2.0.2", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -199,7 +199,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.9.4", + "electron": "^26.6.8", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -232,6 +232,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-node": "^10.9.2", "type-fest": "^3.12.0", "typescript": "^5.3.2", "wait-port": "^0.2.9", @@ -2887,6 +2888,28 @@ "node": ">=0.1.90" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "devOptional": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "devOptional": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -20161,6 +20184,30 @@ "node": ">=10.13.0" } }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "devOptional": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "devOptional": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "devOptional": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "devOptional": true + }, "node_modules/@turf/along": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/@turf/along/-/along-6.5.0.tgz", @@ -22780,7 +22827,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -27256,6 +27303,12 @@ "sha.js": "^2.4.8" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "devOptional": true + }, "node_modules/cross-fetch": { "version": "3.1.5", "license": "MIT", @@ -28151,6 +28204,15 @@ "detect-port": "bin/detect-port.js" } }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "devOptional": true, + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/diff-sequences": { "version": "29.4.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.4.3.tgz", @@ -28529,9 +28591,9 @@ } }, "node_modules/electron": { - "version": "25.9.4", - "resolved": "https://registry.npmjs.org/electron/-/electron-25.9.4.tgz", - "integrity": "sha512-5pDU8a7o7ZIPTZHAqjflGMq764Favdsc271KXrAT3oWvFTHs5Ve9+IOt5EUVPrwvC2qRWKpCIEM47WzwkTlENQ==", + "version": "26.6.8", + "resolved": "https://registry.npmjs.org/electron/-/electron-26.6.8.tgz", + "integrity": "sha512-nuzJ5nVButL1jErc97IVb+A6jbContMg5Uuz5fhmZ4NLcygLkSW8FZpnOT7A4k8Saa95xDJOvqGZyQdI/OPNFw==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -39902,6 +39964,12 @@ "semver": "bin/semver" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "devOptional": true + }, "node_modules/make-event-props": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-1.6.1.tgz", @@ -45044,9 +45112,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.1.tgz", - "integrity": "sha512-o6QNvq91qg8hFXIhmHjBqlNXD/YZxBZSRN8Vkq7xD2NYskzxK2mLqhBdhB8yMMwe6Cd8sVUK4vlZax/JU79xYw==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.2.tgz", + "integrity": "sha512-24kcG3ChBXp+uSSCXudFvZTdCnKLRHQRgvTcnh2eA7COtKvbL8ggEJNkglSYmcf5WoDzLgYyWiKvcjcXQnmBvw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -50519,6 +50587,70 @@ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "devOptional": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "devOptional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ts-node/node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "devOptional": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ts-object-utils": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/ts-object-utils/-/ts-object-utils-0.0.5.tgz", @@ -50760,7 +50892,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.2.tgz", "integrity": "sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==", - "dev": true, + "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -51510,6 +51642,12 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "devOptional": true + }, "node_modules/v8-to-istanbul": { "version": "9.0.1", "license": "ISC", @@ -53360,6 +53498,15 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "devOptional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 156e30d35a21..8d1255f59ff9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.38-2", + "version": "1.4.39-0", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -19,15 +19,15 @@ "ipad-sm": "concurrently \"npx react-native run-ios --simulator=\\\"iPad Pro (11-inch) (4th generation)\\\" --mode=\\\"DebugDevelopment\\\" --scheme=\\\"New Expensify Dev\\\"\"", "start": "npx react-native start", "web": "scripts/set-pusher-suffix.sh && concurrently npm:web-proxy npm:web-server", - "web-proxy": "node web/proxy.js", + "web-proxy": "ts-node web/proxy.js", "web-server": "webpack-dev-server --open --config config/webpack/webpack.dev.js", "build": "webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "build-staging": "webpack --config config/webpack/webpack.common.js --env envFile=.env.staging", "build-adhoc": "webpack --config config/webpack/webpack.common.js --env envFile=.env.adhoc", - "desktop": "scripts/set-pusher-suffix.sh && node desktop/start.js", + "desktop": "scripts/set-pusher-suffix.sh && ts-node desktop/start.js", "desktop-build": "scripts/build-desktop.sh production", "desktop-build-staging": "scripts/build-desktop.sh staging", - "createDocsRoutes": "node .github/scripts/createDocsRoutes.js", + "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.js", "desktop-build-adhoc": "scripts/build-desktop.sh adhoc", "ios-build": "fastlane ios build", "android-build": "fastlane android build", @@ -50,11 +50,11 @@ "analyze-packages": "ANALYZE_BUNDLE=true webpack --config config/webpack/webpack.common.js --env envFile=.env.production", "symbolicate:android": "npx metro-symbolicate android/app/build/generated/sourcemaps/react/release/index.android.bundle.map", "symbolicate:ios": "npx metro-symbolicate main.jsbundle.map", - "test:e2e": "node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", - "test:e2e:dev": "node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", + "test:e2e": "ts-node tests/e2e/testRunner.js --development --skipCheckout --skipInstallDeps --buildMode none", + "test:e2e:dev": "ts-node tests/e2e/testRunner.js --development --skipCheckout --config ./config.dev.js --buildMode skip --skipInstallDeps", "gh-actions-unused-styles": "./.github/scripts/findUnusedKeys.sh", "workflow-test": "./workflow_tests/scripts/runWorkflowTests.sh", - "workflow-test:generate": "node workflow_tests/utils/preGenerateTest.js", + "workflow-test:generate": "ts-node workflow_tests/utils/preGenerateTest.js", "setup-https": "mkcert -install && mkcert -cert-file config/webpack/certificate.pem -key-file config/webpack/key.pem dev.new.expensify.com localhost 127.0.0.1" }, "dependencies": { @@ -144,7 +144,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.1", + "react-native-onyx": "2.0.2", "react-native-pager-view": "6.2.2", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -247,7 +247,7 @@ "css-loader": "^6.7.2", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", - "electron": "^25.9.4", + "electron": "^26.6.8", "electron-builder": "24.6.4", "eslint": "^7.6.0", "eslint-config-airbnb-typescript": "^17.1.0", @@ -280,6 +280,7 @@ "shellcheck": "^1.1.0", "style-loader": "^2.0.0", "time-analytics-webpack-plugin": "^0.1.17", + "ts-node": "^10.9.2", "type-fest": "^3.12.0", "typescript": "^5.3.2", "wait-port": "^0.2.9", diff --git a/src/App.js b/src/App.tsx similarity index 93% rename from src/App.js rename to src/App.tsx index b750d12e8c28..7c1ead1d86d3 100644 --- a/src/App.js +++ b/src/App.tsx @@ -1,5 +1,4 @@ import {PortalProvider} from '@gorhom/portal'; -import PropTypes from 'prop-types'; import React from 'react'; import {LogBox} from 'react-native'; import {GestureHandlerRootView} from 'react-native-gesture-handler'; @@ -32,14 +31,11 @@ import * as Session from './libs/actions/Session'; import * as Environment from './libs/Environment/Environment'; import InitialUrlContext from './libs/InitialUrlContext'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; +import type {Route} from './ROUTES'; -const propTypes = { - /** Initial url that may be passed as deeplink from Hybrid App */ - url: PropTypes.string, -}; - -const defaultProps = { - url: undefined, +type AppProps = { + /** If we have an authToken this is true */ + url?: Route; }; // For easier debugging and development, when we are in web we expose Onyx to the window, so you can more easily set data into Onyx @@ -57,7 +53,7 @@ LogBox.ignoreLogs([ const fill = {flex: 1}; -function App({url}) { +function App({url}: AppProps) { useDefaultDragAndDrop(); OnyxUpdateManager(); return ( @@ -88,6 +84,7 @@ function App({url}) { + {/* @ts-expect-error TODO: Remove this once Expensify (https://github.com/Expensify/App/issues/25231) is migrated to TypeScript. */} @@ -97,8 +94,6 @@ function App({url}) { ); } -App.propTypes = propTypes; -App.defaultProps = defaultProps; App.displayName = 'App'; export default App; diff --git a/src/CONST.ts b/src/CONST.ts index d086eed45a13..73375043bc50 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -462,8 +462,6 @@ const CONST = { EMPTY_ARRAY, EMPTY_OBJECT, USE_EXPENSIFY_URL, - NEW_ZOOM_MEETING_URL: 'https://zoom.us/start/videomeeting', - NEW_GOOGLE_MEET_MEETING_URL: 'https://meet.google.com/new', GOOGLE_MEET_URL_ANDROID: 'https://meet.google.com', GOOGLE_DOC_IMAGE_LINK_MATCH: 'googleusercontent.com', IMAGE_BASE64_MATCH: 'base64', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 28ffaa7d5865..e3a78cbff39d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -125,13 +125,12 @@ const ROUTES = { route: 'settings/wallet/card/:domain/activate', getRoute: (domain: string) => `settings/wallet/card/${domain}/activate` as const, }, - SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', - SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', - SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH: 'settings/profile/personal-details/date-of-birth', - SETTINGS_PERSONAL_DETAILS_ADDRESS: 'settings/profile/personal-details/address', - SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY: { - route: 'settings/profile/personal-details/address/country', - getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/personal-details/address/country?country=${country}`, backTo), + SETTINGS_LEGAL_NAME: 'settings/profile/legal-name', + SETTINGS_DATE_OF_BIRTH: 'settings/profile/date-of-birth', + SETTINGS_ADDRESS: 'settings/profile/address', + SETTINGS_ADDRESS_COUNTRY: { + route: 'settings/profile/address/country', + getRoute: (country: string, backTo?: string) => getUrlWithBackToParam(`settings/profile/address/country?country=${country}`, backTo), }, SETTINGS_CONTACT_METHODS: { route: 'settings/profile/contact-methods', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index fe4fe3df628d..cd80937a3864 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -49,14 +49,10 @@ const SCREENS = { PRONOUNS: 'Settings_Pronouns', TIMEZONE: 'Settings_Timezone', TIMEZONE_SELECT: 'Settings_Timezone_Select', - - PERSONAL_DETAILS: { - INITIAL: 'Settings_PersonalDetails_Initial', - LEGAL_NAME: 'Settings_PersonalDetails_LegalName', - DATE_OF_BIRTH: 'Settings_PersonalDetails_DateOfBirth', - ADDRESS: 'Settings_PersonalDetails_Address', - ADDRESS_COUNTRY: 'Settings_PersonalDetails_Address_Country', - }, + LEGAL_NAME: 'Settings_LegalName', + DATE_OF_BIRTH: 'Settings_DateOfBirth', + ADDRESS: 'Settings_Address', + ADDRESS_COUNTRY: 'Settings_Address_Country', }, PREFERENCES: { diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js index 68d451e5c7c8..aee1b652b22c 100644 --- a/src/components/AddressForm.js +++ b/src/components/AddressForm.js @@ -67,7 +67,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS const styles = useThemeStyles(); const {translate} = useLocalize(); const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], ''); - const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); + const zipFormat = ['common.zipCodeExampleFormat', {zipSampleFormat}]; const isUSAForm = country === CONST.COUNTRY.US; /** @@ -103,7 +103,7 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS if (countrySpecificZipRegex) { if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { - errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}]; + errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', countryZipFormat]; } else { errors.zipPostCode = 'common.error.fieldRequired'; } diff --git a/src/components/AddressSearch/types.ts b/src/components/AddressSearch/types.ts index 8016f1b2ea39..9b4254a9bc45 100644 --- a/src/components/AddressSearch/types.ts +++ b/src/components/AddressSearch/types.ts @@ -1,6 +1,7 @@ import type {RefObject} from 'react'; import type {NativeSyntheticEvent, StyleProp, TextInputFocusEventData, View, ViewStyle} from 'react-native'; import type {Place} from 'react-native-google-places-autocomplete'; +import type {MaybePhraseKey} from '@libs/Localize'; import type Locale from '@src/types/onyx/Locale'; type CurrentLocationButtonProps = { @@ -43,7 +44,7 @@ type AddressSearchProps = { onBlur?: () => void; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Hint text to display */ hint?: string; diff --git a/src/components/AttachmentModal.tsx b/src/components/AttachmentModal.tsx index 78ef72ac3536..ab39e5379230 100755 --- a/src/components/AttachmentModal.tsx +++ b/src/components/AttachmentModal.tsx @@ -436,7 +436,7 @@ function AttachmentModal({ }, }); } - if (!isOffline) { + if (!isOffline && allowDownload) { menuItems.push({ icon: Expensicons.Download, text: translate('common.download'), diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 010d074d1da6..f55db3dd0620 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -421,7 +421,7 @@ function AvatarWithImagePicker({ {errorData.validationError && ( )} diff --git a/src/components/CheckboxWithLabel.tsx b/src/components/CheckboxWithLabel.tsx index 602fb154deba..2919debe9cb1 100644 --- a/src/components/CheckboxWithLabel.tsx +++ b/src/components/CheckboxWithLabel.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import variables from '@styles/variables'; import Checkbox from './Checkbox'; import FormHelpMessage from './FormHelpMessage'; @@ -40,7 +41,7 @@ type CheckboxWithLabelProps = RequiredLabelProps & { style?: StyleProp; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Value for checkbox. This prop is intended to be set by FormProvider only */ value?: boolean; diff --git a/src/components/CommunicationsLink.js b/src/components/CommunicationsLink.js deleted file mode 100644 index 01ae0354a66d..000000000000 --- a/src/components/CommunicationsLink.js +++ /dev/null @@ -1,51 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Clipboard from '@libs/Clipboard'; -import ContextMenuItem from './ContextMenuItem'; -import * as Expensicons from './Icon/Expensicons'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; - -const propTypes = { - /** Children to wrap in CommunicationsLink. */ - children: PropTypes.node.isRequired, - - /** Styles to be assigned to Container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), - - /** Value to be copied or passed via tap. */ - value: PropTypes.string.isRequired, - - ...withLocalizePropTypes, -}; - -const defaultProps = { - containerStyles: [], -}; - -function CommunicationsLink(props) { - const styles = useThemeStyles(); - return ( - - - {props.children} - Clipboard.setString(props.value)} - /> - - - ); -} - -CommunicationsLink.propTypes = propTypes; -CommunicationsLink.defaultProps = defaultProps; -CommunicationsLink.displayName = 'CommunicationsLink'; - -export default withLocalize(CommunicationsLink); diff --git a/src/components/CommunicationsLink.tsx b/src/components/CommunicationsLink.tsx new file mode 100644 index 000000000000..646326e0a632 --- /dev/null +++ b/src/components/CommunicationsLink.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Clipboard from '@libs/Clipboard'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; +import ContextMenuItem from './ContextMenuItem'; +import * as Expensicons from './Icon/Expensicons'; + +type CommunicationsLinkProps = ChildrenProps & { + /** Styles to be assigned to Container */ + containerStyles?: StyleProp; + + /** Value to be copied or passed via tap. */ + value: string; +}; + +function CommunicationsLink({value, containerStyles, children}: CommunicationsLinkProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + return ( + + + {children} + Clipboard.setString(value)} + /> + + + ); +} + +CommunicationsLink.displayName = 'CommunicationsLink'; + +export default CommunicationsLink; diff --git a/src/components/CountrySelector.tsx b/src/components/CountrySelector.tsx index 589530cd7879..25dc99459064 100644 --- a/src/components/CountrySelector.tsx +++ b/src/components/CountrySelector.tsx @@ -3,6 +3,7 @@ import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import type {Country} from '@src/CONST'; import ROUTES from '@src/ROUTES'; @@ -11,7 +12,7 @@ import MenuItemWithTopDescription from './MenuItemWithTopDescription'; type CountrySelectorProps = { /** Form error text. e.g when no country is selected */ - errorText?: string; + errorText?: MaybePhraseKey; /** Callback called when the country changes. */ onInputChange: (value?: string) => void; @@ -47,7 +48,7 @@ function CountrySelector({errorText = '', value: countryCode, onInputChange}: Co description={translate('common.country')} onPress={() => { const activeRoute = Navigation.getActiveRouteWithoutParams(); - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); + Navigation.navigate(ROUTES.SETTINGS_ADDRESS_COUNTRY.getRoute(countryCode ?? '', activeRoute)); }} /> diff --git a/src/components/CustomStatusBarAndBackground/index.tsx b/src/components/CustomStatusBarAndBackground/index.tsx index f66a0204ac5e..42ea96fe41bb 100644 --- a/src/components/CustomStatusBarAndBackground/index.tsx +++ b/src/components/CustomStatusBarAndBackground/index.tsx @@ -10,7 +10,7 @@ import updateStatusBarAppearance from './updateStatusBarAppearance'; type CustomStatusBarAndBackgroundProps = { /** Whether the CustomStatusBar is nested within another CustomStatusBar. * A nested CustomStatusBar will disable the "root" CustomStatusBar. */ - isNested: boolean; + isNested?: boolean; }; function CustomStatusBarAndBackground({isNested = false}: CustomStatusBarAndBackgroundProps) { diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 86fa8f475664..91b8b0fc4483 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -53,6 +53,7 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit style={[textStyles, styles.pRelative]} numberOfLines={numberOfLines || undefined} ref={containerRef} + testID={DisplayNamesWithToolTip.displayName} > {shouldUseFullTitle ? ReportUtils.formatReportLastMessageText(fullTitle) diff --git a/src/components/DistanceRequest/index.js b/src/components/DistanceRequest/index.js index b63ce337a1d9..eafc36a57927 100644 --- a/src/components/DistanceRequest/index.js +++ b/src/components/DistanceRequest/index.js @@ -183,7 +183,7 @@ function DistanceRequest({transactionID, report, transaction, route, isEditingRe } if (_.size(validatedWaypoints) < 2) { - return {0: translate('iou.error.atLeastTwoDifferentWaypoints')}; + return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } }; diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index d2143f5b48da..3765d1e3b168 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -35,8 +35,8 @@ type DotIndicatorMessageProps = { }; /** Check if the error includes a receipt. */ -function isReceiptError(message: string | ReceiptError): message is ReceiptError { - if (typeof message === 'string') { +function isReceiptError(message: Localize.MaybePhraseKey | ReceiptError): message is ReceiptError { + if (typeof message === 'string' || Array.isArray(message)) { return false; } return (message?.error ?? '') === CONST.IOU.RECEIPT_ERROR; @@ -57,7 +57,7 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndica .map((key) => messages[key]); // Removing duplicates using Set and transforming the result into an array - const uniqueMessages = [...new Set(sortedMessages)].map((message) => Localize.translateIfPhraseKey(message)); + const uniqueMessages = [...new Set(sortedMessages)].map((message) => (isReceiptError(message) ? message : Localize.translateIfPhraseKey(message))); const isErrorMessage = type === 'error'; diff --git a/src/components/FormAlertWithSubmitButton.tsx b/src/components/FormAlertWithSubmitButton.tsx index 789f1cd2466d..9968bb0e0772 100644 --- a/src/components/FormAlertWithSubmitButton.tsx +++ b/src/components/FormAlertWithSubmitButton.tsx @@ -2,12 +2,13 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import Button from './Button'; import FormAlertWrapper from './FormAlertWrapper'; type FormAlertWithSubmitButtonProps = { /** Error message to display above button */ - message?: string | null; + message?: MaybePhraseKey; /** Whether the button is disabled */ isDisabled?: boolean; diff --git a/src/components/FormAlertWrapper.tsx b/src/components/FormAlertWrapper.tsx index bdd5622f7aeb..d8b379208a29 100644 --- a/src/components/FormAlertWrapper.tsx +++ b/src/components/FormAlertWrapper.tsx @@ -4,6 +4,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import type Network from '@src/types/onyx/Network'; import FormHelpMessage from './FormHelpMessage'; import {withNetwork} from './OnyxProvider'; @@ -28,7 +29,7 @@ type FormAlertWrapperProps = { isMessageHtml?: boolean; /** Error message to display above button */ - message?: string | null; + message?: MaybePhraseKey; /** Props to detect online status */ network: Network; @@ -68,7 +69,7 @@ function FormAlertWrapper({ {` ${translate('common.inTheFormBeforeContinuing')}.`} ); - } else if (isMessageHtml) { + } else if (isMessageHtml && typeof message === 'string') { content = ${message}`} />; } diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index 690f2fc6883a..bd4f72c63ec3 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -93,7 +93,6 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim & { + /** Key of the element */ + key?: string; +}; + +function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const styles = useThemeStyles(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; const {environmentURL} = useEnvironment(); // An auth token is needed to download Expensify chat attachments const isAttachment = Boolean(htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]); - const displayName = lodashGet(props.tnode, 'domNode.children[0].data', ''); - const parentStyle = lodashGet(props.tnode, 'parent.styles.nativeTextRet', {}); + const tNodeChild = tnode?.domNode?.children?.[0]; + const displayName = tNodeChild && 'data' in tNodeChild && typeof tNodeChild.data === 'string' ? tNodeChild.data : ''; + const parentStyle = tnode.parent?.styles?.nativeTextRet ?? {}; const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; const internalNewExpensifyPath = Link.getInternalNewExpensifyPath(attrHref); const internalExpensifyPath = Link.getInternalExpensifyPath(attrHref); - if (!HTMLEngineUtils.isChildOfComment(props.tnode)) { + if (!HTMLEngineUtils.isChildOfComment(tnode)) { // This is not a comment from a chat, the AnchorForCommentsOnly uses a Pressable to create a context menu on right click. // We don't have this behaviour in other links in NewDot // TODO: We should use TextLink, but I'm leaving it as Text for now because TextLink breaks the alignment in Android. @@ -34,7 +39,7 @@ function AnchorRenderer(props) { onPress={() => Link.openLink(attrHref, environmentURL, isAttachment)} suppressHighlighting > - + ); } @@ -58,18 +63,16 @@ function AnchorRenderer(props) { // eslint-disable-next-line react/jsx-props-no-multi-spaces target={htmlAttribs.target || '_blank'} rel={htmlAttribs.rel || 'noopener noreferrer'} - style={{...props.style, ...parentStyle, ...styles.textUnderlinePositionUnder, ...styles.textDecorationSkipInkNone}} - key={props.key} - displayName={displayName} + style={[parentStyle, styles.textUnderlinePositionUnder, styles.textDecorationSkipInkNone, style]} + key={key} // Only pass the press handler for internal links. For public links or whitelisted internal links fallback to default link handling onPress={internalNewExpensifyPath || internalExpensifyPath ? () => Link.openLink(attrHref, environmentURL, isAttachment) : undefined} > - + ); } -AnchorRenderer.propTypes = htmlRendererPropTypes; AnchorRenderer.displayName = 'AnchorRenderer'; export default AnchorRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx similarity index 65% rename from src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx index 1932eaaf8a4f..d1c11dc12ed5 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/CodeRenderer.tsx @@ -1,25 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; import {splitBoxModelStyle} from 'react-native-render-html'; -import _ from 'underscore'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; import InlineCodeBlock from '@components/InlineCodeBlock'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function CodeRenderer(props) { +type CodeRendererProps = CustomRendererProps & { + /** Key of the element */ + key?: string; +}; + +function CodeRenderer({TDefaultRenderer, key, style, ...defaultRendererProps}: CodeRendererProps) { const StyleUtils = useStyleUtils(); // We split wrapper and inner styles // "boxModelStyle" corresponds to border, margin, padding and backgroundColor - const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(props.style); + const {boxModelStyle, otherStyle: textStyle} = splitBoxModelStyle(style ?? {}); - // Get the correct fontFamily variant based in the fontStyle and fontWeight + /** Get the default fontFamily variant */ const font = StyleUtils.getFontFamilyMonospace({ - fontStyle: textStyle.fontStyle, - fontWeight: textStyle.fontWeight, + fontStyle: undefined, + fontWeight: undefined, }); // Determine the font size for the code based on whether it's inside an H1 element. - const isInsideH1 = HTMLEngineUtils.isChildOfH1(props.tnode); + const isInsideH1 = HTMLEngineUtils.isChildOfH1(defaultRendererProps.tnode); const fontSize = StyleUtils.getCodeFontSize(isInsideH1); @@ -34,20 +39,17 @@ function CodeRenderer(props) { fontStyle: undefined, }; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - return ( ); } -CodeRenderer.propTypes = htmlRendererPropTypes; CodeRenderer.displayName = 'CodeRenderer'; export default CodeRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx similarity index 61% rename from src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx index 9ff5fdecae13..03f7a5dbedf7 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EditedRenderer.tsx @@ -1,23 +1,17 @@ import React from 'react'; -import _ from 'underscore'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { - ...htmlRendererPropTypes, - ...withLocalizePropTypes, -}; - -function EditedRenderer(props) { +function EditedRenderer({tnode, TDefaultRenderer, style, ...defaultRendererProps}: CustomRendererProps) { const theme = useTheme(); const styles = useThemeStyles(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style', 'tnode']); - const isPendingDelete = Boolean(props.tnode.attributes.deleted !== undefined); + const {translate} = useLocalize(); + const isPendingDelete = Boolean(tnode.attributes.deleted !== undefined); return ( - {props.translate('reportActionCompose.edited')} + {translate('reportActionCompose.edited')} ); } -EditedRenderer.propTypes = propTypes; EditedRenderer.displayName = 'EditedRenderer'; -export default withLocalize(EditedRenderer); +export default EditedRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx similarity index 77% rename from src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx index f60377c842ea..3e6119ff279f 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ImageRenderer.tsx @@ -1,6 +1,7 @@ -import lodashGet from 'lodash/get'; import React, {memo} from 'react'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFocus from '@components/Pressable/PressableWithoutFocus'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; import ThumbnailImage from '@components/ThumbnailImage'; @@ -12,15 +13,22 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; +import type {User} from '@src/types/onyx'; -const propTypes = {...htmlRendererPropTypes}; +type ImageRendererWithOnyxProps = { + /** Current user */ + // Following line is disabled because the onyx prop is only being used on the memo HOC + // eslint-disable-next-line react/no-unused-prop-types + user: OnyxEntry; +}; -function ImageRenderer(props) { +type ImageRendererProps = ImageRendererWithOnyxProps & CustomRendererProps; + +function ImageRenderer({tnode}: ImageRendererProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const htmlAttribs = props.tnode.attributes; + const htmlAttribs = tnode.attributes; // There are two kinds of images that need to be displayed: // @@ -63,20 +71,10 @@ function ImageRenderer(props) { { - const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report.reportID, source); + const route = ROUTES.REPORT_ATTACHMENTS.getRoute(report?.reportID ?? '', source); Navigation.navigate(route); }} - onLongPress={(event) => - showContextMenuForReport( - // Imitate the web event for native renderers - {nativeEvent: {...(event.nativeEvent || {}), target: {tagName: 'IMG'}}}, - anchor, - report.reportID, - action, - checkIfContextMenuActive, - ReportUtils.isArchivedRoom(report), - ) - } + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} accessibilityRole={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} accessibilityLabel={translate('accessibilityHints.viewAttachment')} > @@ -93,18 +91,15 @@ function ImageRenderer(props) { ); } -ImageRenderer.propTypes = propTypes; ImageRenderer.displayName = 'ImageRenderer'; -export default withOnyx({ +export default withOnyx({ user: { key: ONYXKEYS.USER, }, })( memo( ImageRenderer, - (prevProps, nextProps) => - lodashGet(prevProps, 'tnode.attributes') === lodashGet(nextProps, 'tnode.attributes') && - lodashGet(prevProps, 'user.shouldUseStagingServer') === lodashGet(nextProps, 'user.shouldUseStagingServer'), + (prevProps, nextProps) => prevProps.tnode.attributes === nextProps.tnode.attributes && prevProps.user?.shouldUseStagingServer === nextProps.user?.shouldUseStagingServer, ), ); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx index 93ede229876d..09dc8cf9f641 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionHereRenderer.tsx @@ -1,26 +1,30 @@ import React from 'react'; +import type {TextStyle} from 'react-native'; +import {StyleSheet} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; import Text from '@components/Text'; import useStyleUtils from '@hooks/useStyleUtils'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -function MentionHereRenderer(props) { +function MentionHereRenderer({style, tnode}: CustomRendererProps) { const StyleUtils = useStyleUtils(); + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + return ( - + ); } -MentionHereRenderer.propTypes = htmlRendererPropTypes; MentionHereRenderer.displayName = 'HereMentionRenderer'; export default MentionHereRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js deleted file mode 100644 index eb4f5f763dbe..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.js +++ /dev/null @@ -1,120 +0,0 @@ -import {cloneDeep} from 'lodash'; -import lodashGet from 'lodash/get'; -import React from 'react'; -import {TNodeChildrenRenderer} from 'react-native-render-html'; -import _ from 'underscore'; -import {usePersonalDetails} from '@components/OnyxProvider'; -import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import Text from '@components/Text'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; -import Navigation from '@libs/Navigation/Navigation'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import CONST from '@src/CONST'; -import * as LoginUtils from '@src/libs/LoginUtils'; -import ROUTES from '@src/ROUTES'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; - -const propTypes = { - ...htmlRendererPropTypes, - - /** Current user personal details */ - currentUserPersonalDetails: personalDetailsPropType.isRequired, -}; - -function MentionUserRenderer(props) { - const styles = useThemeStyles(); - const StyleUtils = useStyleUtils(); - const {translate} = useLocalize(); - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'style']); - const htmlAttributeAccountID = lodashGet(props.tnode.attributes, 'accountid'); - const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; - - let accountID; - let displayNameOrLogin; - let navigationRoute; - const tnode = cloneDeep(props.tnode); - - const getMentionDisplayText = (displayText, userAccountID, userLogin = '') => { - // If the userAccountID does not exist, this is an email-based mention so the displayText must be an email. - // If the userAccountID exists but userLogin is different from displayText, this means the displayText is either user display name, Hidden, or phone number, in which case we should return it as is. - if (userAccountID && userLogin !== displayText) { - return displayText; - } - - // If the emails are not in the same private domain, we also return the displayText - if (!LoginUtils.areEmailsFromSamePrivateDomain(displayText, lodashGet(props.currentUserPersonalDetails, 'login', ''))) { - return displayText; - } - - // Otherwise, the emails must be of the same private domain, so we should remove the domain part - return displayText.split('@')[0]; - }; - - if (!_.isEmpty(htmlAttributeAccountID)) { - const user = lodashGet(personalDetails, htmlAttributeAccountID); - accountID = parseInt(htmlAttributeAccountID, 10); - displayNameOrLogin = lodashGet(user, 'displayName', '') || LocalePhoneNumber.formatPhoneNumber(lodashGet(user, 'login', '')) || translate('common.hidden'); - displayNameOrLogin = getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID, lodashGet(user, 'login', '')); - navigationRoute = ROUTES.PROFILE.getRoute(htmlAttributeAccountID); - } else if (!_.isEmpty(tnode.data)) { - // We need to remove the LTR unicode and leading @ from data as it is not part of the login - displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); - // We need to replace tnode.data here because we will pass it to TNodeChildrenRenderer below - tnode.data = tnode.data.replace(displayNameOrLogin, getMentionDisplayText(displayNameOrLogin, htmlAttributeAccountID)); - - accountID = _.first(PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])); - navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); - } else { - // If neither an account ID or email is provided, don't render anything - return null; - } - - const isOurMention = accountID === props.currentUserPersonalDetails.accountID; - - return ( - - {({anchor, report, action, checkIfContextMenuActive}) => ( - showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} - onPress={(event) => { - event.preventDefault(); - Navigation.navigate(navigationRoute); - }} - role={CONST.ROLE.LINK} - accessibilityLabel={`/${navigationRoute}`} - > - - - {!_.isEmpty(htmlAttributeAccountID) ? `@${displayNameOrLogin}` : } - - - - )} - - ); -} - -MentionUserRenderer.propTypes = propTypes; -MentionUserRenderer.displayName = 'MentionUserRenderer'; - -export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx new file mode 100644 index 000000000000..ad9cfb4e6384 --- /dev/null +++ b/src/components/HTMLEngineProvider/HTMLRenderers/MentionUserRenderer.tsx @@ -0,0 +1,98 @@ +import isEmpty from 'lodash/isEmpty'; +import React from 'react'; +import {StyleSheet} from 'react-native'; +import type {TextStyle} from 'react-native'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; +import {TNodeChildrenRenderer} from 'react-native-render-html'; +import {usePersonalDetails} from '@components/OnyxProvider'; +import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; +import Text from '@components/Text'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; +import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; +import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +type MentionUserRendererProps = WithCurrentUserPersonalDetailsProps & CustomRendererProps; + +function MentionUserRenderer({style, tnode, TDefaultRenderer, currentUserPersonalDetails, ...defaultRendererProps}: MentionUserRendererProps) { + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + const htmlAttribAccountID = tnode.attributes.accountid; + const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; + + let accountID: number; + let displayNameOrLogin: string; + let navigationRoute: Route; + + if (!isEmpty(htmlAttribAccountID)) { + const user = personalDetails.htmlAttribAccountID; + accountID = parseInt(htmlAttribAccountID, 10); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + displayNameOrLogin = LocalePhoneNumber.formatPhoneNumber(user?.login ?? '') || user?.displayName || translate('common.hidden'); + navigationRoute = ROUTES.PROFILE.getRoute(htmlAttribAccountID); + } else if ('data' in tnode && !isEmptyObject(tnode.data)) { + // We need to remove the LTR unicode and leading @ from data as it is not part of the login + displayNameOrLogin = tnode.data.replace(CONST.UNICODE.LTR, '').slice(1); + + accountID = PersonalDetailsUtils.getAccountIDsByLogins([displayNameOrLogin])?.[0]; + navigationRoute = ROUTES.DETAILS.getRoute(displayNameOrLogin); + } else { + // If neither an account ID or email is provided, don't render anything + return null; + } + + const isOurMention = accountID === currentUserPersonalDetails.accountID; + + const flattenStyle = StyleSheet.flatten(style as TextStyle); + const {color, ...styleWithoutColor} = flattenStyle; + + return ( + + {({anchor, report, action, checkIfContextMenuActive}) => ( + showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={(event) => { + event.preventDefault(); + Navigation.navigate(navigationRoute); + }} + role={CONST.ROLE.LINK} + accessibilityLabel={`/${navigationRoute}`} + > + + + {htmlAttribAccountID ? `@${displayNameOrLogin}` : } + + + + )} + + ); +} + +MentionUserRenderer.displayName = 'MentionUserRenderer'; + +export default withCurrentUserPersonalDetails(MentionUserRenderer); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx index 775bf75294eb..6124b59dfd49 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/NextStepEmailRenderer.tsx @@ -1,14 +1,9 @@ import React from 'react'; +import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; import Text from '@components/Text'; import useThemeStyles from '@hooks/useThemeStyles'; -type NextStepEmailRendererProps = { - tnode: { - data: string; - }; -}; - -function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { +function NextStepEmailRenderer({tnode}: CustomRendererProps) { const styles = useThemeStyles(); return ( @@ -16,7 +11,7 @@ function NextStepEmailRenderer({tnode}: NextStepEmailRendererProps) { nativeID="email-with-break-opportunities" style={[styles.breakWord, styles.textLabelSupporting, styles.textStrong]} > - {tnode.data} + {'data' in tnode ? tnode.data : ''} ); } diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx similarity index 53% rename from src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js rename to src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx index 27eff02d63ea..798ec8f64194 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer.tsx @@ -1,52 +1,47 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; -import _ from 'underscore'; +import type {GestureResponderEvent} from 'react-native'; +import type {CustomRendererProps, TBlock} from 'react-native-render-html'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import {ShowContextMenuContext, showContextMenuForReport} from '@components/ShowContextMenuContext'; -import withLocalize from '@components/withLocalize'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; -import htmlRendererPropTypes from './htmlRendererPropTypes'; -const propTypes = { +type PreRendererProps = CustomRendererProps & { /** Press in handler for the code block */ - onPressIn: PropTypes.func, + onPressIn?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** Press out handler for the code block */ - onPressOut: PropTypes.func, + onPressOut?: (event?: GestureResponderEvent | KeyboardEvent) => void; + + /** Long press handler for the code block */ + onLongPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** The position of this React element relative to the parent React element, starting at 0 */ - renderIndex: PropTypes.number.isRequired, + renderIndex: number; /** The total number of elements children of this React element parent */ - renderLength: PropTypes.number.isRequired, - - ...htmlRendererPropTypes, -}; - -const defaultProps = { - onPressIn: undefined, - onPressOut: undefined, + renderLength: number; }; -function PreRenderer(props) { +function PreRenderer({TDefaultRenderer, onPressIn, onPressOut, onLongPress, ...defaultRendererProps}: PreRendererProps) { const styles = useThemeStyles(); - const TDefaultRenderer = props.TDefaultRenderer; - const defaultRendererProps = _.omit(props, ['TDefaultRenderer', 'onPressIn', 'onPressOut', 'onLongPress']); - const isLast = props.renderIndex === props.renderLength - 1; + const {translate} = useLocalize(); + const isLast = defaultRendererProps.renderIndex === defaultRendererProps.renderLength - 1; return ( - + {({anchor, report, action, checkIfContextMenuActive}) => ( showContextMenuForReport(event, anchor, report.reportID, action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} + onPress={onPressIn ?? (() => {})} + onPressIn={onPressIn} + onPressOut={onPressOut} + onLongPress={(event) => showContextMenuForReport(event, anchor, report?.reportID ?? '', action, checkIfContextMenuActive, ReportUtils.isArchivedRoom(report))} role={CONST.ROLE.PRESENTATION} - accessibilityLabel={props.translate('accessibilityHints.prestyledText')} + accessibilityLabel={translate('accessibilityHints.prestyledText')} > {/* eslint-disable-next-line react/jsx-props-no-spreading */} @@ -60,7 +55,5 @@ function PreRenderer(props) { } PreRenderer.displayName = 'PreRenderer'; -PreRenderer.propTypes = propTypes; -PreRenderer.defaultProps = defaultProps; -export default withLocalize(PreRenderer); +export default PreRenderer; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js b/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js deleted file mode 100644 index f26806482e48..000000000000 --- a/src/components/HTMLEngineProvider/HTMLRenderers/htmlRendererPropTypes.js +++ /dev/null @@ -1,8 +0,0 @@ -import PropTypes from 'prop-types'; - -export default { - tnode: PropTypes.object, - TDefaultRenderer: PropTypes.oneOfType([PropTypes.object, PropTypes.func]), - key: PropTypes.string, - style: PropTypes.object, -}; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts similarity index 74% rename from src/components/HTMLEngineProvider/HTMLRenderers/index.js rename to src/components/HTMLEngineProvider/HTMLRenderers/index.ts index 9d0dab731792..f2c8cbe89a98 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/index.ts @@ -1,3 +1,4 @@ +import type {CustomTagRendererRecord} from 'react-native-render-html'; import AnchorRenderer from './AnchorRenderer'; import CodeRenderer from './CodeRenderer'; import EditedRenderer from './EditedRenderer'; @@ -10,7 +11,7 @@ import PreRenderer from './PreRenderer'; /** * This collection defines our custom renderers. It is a mapping from HTML tag type to the corresponding component. */ -export default { +const HTMLEngineProviderComponentList: CustomTagRendererRecord = { // Standard HTML tag renderers a: AnchorRenderer, code: CodeRenderer, @@ -20,7 +21,11 @@ export default { // Custom tag renderers edited: EditedRenderer, pre: PreRenderer, + /* eslint-disable @typescript-eslint/naming-convention */ 'mention-user': MentionUserRenderer, 'mention-here': MentionHereRenderer, 'next-step-email': NextStepEmailRenderer, + /* eslint-enable @typescript-eslint/naming-convention */ }; + +export default HTMLEngineProviderComponentList; diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts index 139b6789e8d1..252d13259aea 100644 --- a/src/components/Icon/Expensicons.ts +++ b/src/components/Icon/Expensicons.ts @@ -110,6 +110,7 @@ import QuestionMark from '@assets/images/question-mark-circle.svg'; import ReceiptSearch from '@assets/images/receipt-search.svg'; import Receipt from '@assets/images/receipt.svg'; import Rotate from '@assets/images/rotate-image.svg'; +import RotateLeft from '@assets/images/rotate-left.svg'; import Scan from '@assets/images/scan.svg'; import Send from '@assets/images/send.svg'; import Shield from '@assets/images/shield.svg'; @@ -252,6 +253,7 @@ export { Receipt, ReceiptSearch, Rotate, + RotateLeft, Scan, Send, Shield, diff --git a/src/components/InlineCodeBlock/index.native.tsx b/src/components/InlineCodeBlock/index.native.tsx index 3a70308fa0cc..85d02b7239ca 100644 --- a/src/components/InlineCodeBlock/index.native.tsx +++ b/src/components/InlineCodeBlock/index.native.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import type {TText} from 'react-native-render-html'; import useThemeStyles from '@hooks/useThemeStyles'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; import WrappedText from './WrappedText'; -function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) { +function InlineCodeBlock({TDefaultRenderer, defaultRendererProps, textStyle, boxModelStyle}: InlineCodeBlockProps) { const styles = useThemeStyles(); return ( @@ -16,7 +16,7 @@ function InlineCodeBlock({TDefaultRenderer, defaultRen textStyles={textStyle} wordStyles={[boxModelStyle, styles.codeWordStyle]} > - {defaultRendererProps.tnode.data} + {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data} ); diff --git a/src/components/InlineCodeBlock/index.tsx b/src/components/InlineCodeBlock/index.tsx index 0802d4752661..593a08aaad5e 100644 --- a/src/components/InlineCodeBlock/index.tsx +++ b/src/components/InlineCodeBlock/index.tsx @@ -1,10 +1,10 @@ import React from 'react'; import {StyleSheet} from 'react-native'; -import type {TText} from 'react-native-render-html'; import Text from '@components/Text'; import type InlineCodeBlockProps from './types'; +import type {TTextOrTPhrasing} from './types'; -function InlineCodeBlock({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps) { +function InlineCodeBlock({TDefaultRenderer, textStyle, defaultRendererProps, boxModelStyle}: InlineCodeBlockProps) { const flattenTextStyle = StyleSheet.flatten(textStyle); const {textDecorationLine, ...textStyles} = flattenTextStyle; @@ -13,7 +13,7 @@ function InlineCodeBlock({TDefaultRenderer, textStyle, // eslint-disable-next-line react/jsx-props-no-spreading {...defaultRendererProps} > - {defaultRendererProps.tnode.data} + {'data' in defaultRendererProps.tnode && defaultRendererProps.tnode.data} ); } diff --git a/src/components/InlineCodeBlock/types.ts b/src/components/InlineCodeBlock/types.ts index ae847b293a60..cc05f36a20cf 100644 --- a/src/components/InlineCodeBlock/types.ts +++ b/src/components/InlineCodeBlock/types.ts @@ -1,7 +1,9 @@ import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; -import type {TDefaultRenderer, TDefaultRendererProps, TText} from 'react-native-render-html'; +import type {TDefaultRenderer, TDefaultRendererProps, TPhrasing, TText} from 'react-native-render-html'; -type InlineCodeBlockProps = { +type TTextOrTPhrasing = TText | TPhrasing; + +type InlineCodeBlockProps = { TDefaultRenderer: TDefaultRenderer; textStyle: StyleProp; defaultRendererProps: TDefaultRendererProps; @@ -9,3 +11,4 @@ type InlineCodeBlockProps = { }; export default InlineCodeBlockProps; +export type {TTextOrTPhrasing}; diff --git a/src/components/MagicCodeInput.tsx b/src/components/MagicCodeInput.tsx index 1e2e57a0b3fb..46c96fd706a9 100644 --- a/src/components/MagicCodeInput.tsx +++ b/src/components/MagicCodeInput.tsx @@ -7,6 +7,7 @@ import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as Browser from '@libs/Browser'; +import type {MaybePhraseKey} from '@libs/Localize'; import * as ValidationUtils from '@libs/ValidationUtils'; import CONST from '@src/CONST'; import FormHelpMessage from './FormHelpMessage'; @@ -32,7 +33,7 @@ type MagicCodeInputProps = { shouldDelayFocus?: boolean; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Specifies autocomplete hints for the system, so it can provide autofill */ autoComplete: AutoCompleteVariant; diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 4d7089fb24bd..6163fa116561 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -14,6 +14,7 @@ import ControlSelection from '@libs/ControlSelection'; import convertToLTR from '@libs/convertToLTR'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import getButtonState from '@libs/getButtonState'; +import type {MaybePhraseKey} from '@libs/Localize'; import type {AvatarSource} from '@libs/UserUtils'; import variables from '@styles/variables'; import * as Session from '@userActions/Session'; @@ -136,7 +137,7 @@ type MenuItemProps = (IconProps | AvatarProps | NoIcon) & { error?: string; /** Error to display at the bottom of the component */ - errorText?: string; + errorText?: MaybePhraseKey; /** A boolean flag that gives the icon a green fill if true */ success?: boolean; @@ -565,7 +566,7 @@ function MenuItem( /> )} {/* Since subtitle can be of type number, we should allow 0 to be shown */} - {(subtitle ?? subtitle === 0) && ( + {(subtitle === 0 || subtitle) && ( {subtitle} diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3f5a3c50f6cc..a5b0d6707421 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -2,18 +2,15 @@ import React, {useMemo} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; -import GoogleMeetIcon from '@assets/images/google-meet.svg'; -import ZoomIcon from '@assets/images/zoom-icon.svg'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as IOU from '@userActions/IOU'; -import * as Link from '@userActions/Link'; -import * as Session from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -65,6 +62,8 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money isPolicyAdmin && (isApproved || isManager) : isPolicyAdmin || (ReportUtils.isMoneyRequestReport(moneyRequestReport) && isManager); const isDraft = ReportUtils.isDraftExpenseReport(moneyRequestReport); + const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const shouldShowPayButton = useMemo( () => isPayer && !isDraft && !isSettled && !moneyRequestReport.isWaitingOnBankAccount && reimbursableSpend !== 0 && !ReportUtils.isArchivedRoom(chatReport) && !isAutoReimbursable, [isPayer, isDraft, isSettled, moneyRequestReport, reimbursableSpend, chatReport, isAutoReimbursable], @@ -73,8 +72,11 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money if (!isPaidGroupPolicy) { return false; } + if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { + return false; + } return isManager && !isDraft && !isApproved && !isSettled; - }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled]); + }, [isPaidGroupPolicy, isManager, isDraft, isApproved, isSettled, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; const shouldShowSubmitButton = isDraft && reimbursableSpend !== 0; const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; @@ -91,22 +93,6 @@ function MoneyReportHeader({session, policy, chatReport, nextStep, report: money ); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(moneyRequestReport)]; - if (!ReportUtils.isArchivedRoom(chatReport)) { - threeDotsMenuItems.push({ - icon: ZoomIcon, - text: translate('videoChatButtonAndMenu.zoom'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); - }), - }); - threeDotsMenuItems.push({ - icon: GoogleMeetIcon, - text: translate('videoChatButtonAndMenu.googleMeet'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL); - }), - }); - } return ( diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index faa487887f22..92656a7ad225 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -567,7 +567,7 @@ function MoneyRequestConfirmationList(props) { )} {button} @@ -586,7 +586,6 @@ function MoneyRequestConfirmationList(props) { formError, styles.ph1, styles.mb2, - translate, ]); const {image: receiptImage, thumbnail: receiptThumbnail} = diff --git a/src/components/MoneyRequestHeader.js b/src/components/MoneyRequestHeader.tsx similarity index 63% rename from src/components/MoneyRequestHeader.js rename to src/components/MoneyRequestHeader.tsx index e907f798051b..97f967a32d29 100644 --- a/src/components/MoneyRequestHeader.js +++ b/src/components/MoneyRequestHeader.tsx @@ -1,97 +1,89 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; import React, {useCallback, useEffect, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import compose from '@libs/compose'; import * as HeaderUtils from '@libs/HeaderUtils'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import * as TransactionUtils from '@libs/TransactionUtils'; -import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; -import iouReportPropTypes from '@pages/iouReportPropTypes'; import * as IOU from '@userActions/IOU'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Policy, Report, ReportAction, ReportActions, Session, Transaction} from '@src/types/onyx'; +import type {OriginalMessageIOU} from '@src/types/onyx/OriginalMessage'; import ConfirmModal from './ConfirmModal'; import HeaderWithBackButton from './HeaderWithBackButton'; import * as Expensicons from './Icon/Expensicons'; import MoneyRequestHeaderStatusBar from './MoneyRequestHeaderStatusBar'; import {usePersonalDetails} from './OnyxProvider'; -import transactionPropTypes from './transactionPropTypes'; -const propTypes = { - /** The report currently being looked at */ - report: iouReportPropTypes.isRequired, - - /** The policy which the report is tied to */ - policy: PropTypes.shape({ - /** Name of the policy */ - name: PropTypes.string, - }), - - /* Onyx Props */ +type MoneyRequestHeaderOnyxProps = { /** Session info for the currently logged in user. */ - session: PropTypes.shape({ - /** Currently logged in user email */ - email: PropTypes.string, - }), + session: OnyxEntry; /** The expense report or iou report (only will have a value if this is a transaction thread) */ - parentReport: iouReportPropTypes, - - /** The report action the transaction is tied to from the parent report */ - parentReportAction: PropTypes.shape(reportActionPropTypes), + parentReport: OnyxEntry; /** All the data for the transaction */ - transaction: transactionPropTypes, + transaction: OnyxEntry; + + /** All report actions */ + // eslint-disable-next-line react/no-unused-prop-types + parentReportActions: OnyxEntry; }; -const defaultProps = { - session: { - email: null, - }, - parentReport: {}, - parentReportAction: {}, - transaction: {}, - policy: {}, +type MoneyRequestHeaderProps = MoneyRequestHeaderOnyxProps & { + /** The report currently being looked at */ + report: Report; + + /** The policy which the report is tied to */ + policy: Policy; + + /** The report action the transaction is tied to from the parent report */ + parentReportAction: ReportAction & OriginalMessageIOU; }; -function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}) { +function MoneyRequestHeader({session, parentReport, report, parentReportAction, transaction, policy}: MoneyRequestHeaderProps) { const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const moneyRequestReport = parentReport; - const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID); + const isSettled = ReportUtils.isSettled(moneyRequestReport?.reportID); const isApproved = ReportUtils.isReportApproved(moneyRequestReport); const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); // Only the requestor can take delete the request, admins can only edit it. - const isActionOwner = lodashGet(parentReportAction, 'actorAccountID') === lodashGet(session, 'accountID', null); + const isActionOwner = typeof parentReportAction?.actorAccountID === 'number' && typeof session?.accountID === 'number' && parentReportAction.actorAccountID === session?.accountID; const deleteTransaction = useCallback(() => { - IOU.deleteMoneyRequest(lodashGet(parentReportAction, 'originalMessage.IOUTransactionID'), parentReportAction, true); + IOU.deleteMoneyRequest(parentReportAction?.originalMessage?.IOUTransactionID ?? '', parentReportAction, true); setIsDeleteModalVisible(false); }, [parentReportAction, setIsDeleteModalVisible]); const isScanning = TransactionUtils.hasReceipt(transaction) && TransactionUtils.isReceiptBeingScanned(transaction); const isPending = TransactionUtils.isExpensifyCardTransaction(transaction) && TransactionUtils.isPending(transaction); - const canModifyRequest = isActionOwner && !isSettled && !isApproved && !ReportActionsUtils.isDeletedAction(parentReportAction); + let canDeleteRequest = canModifyRequest; + + if (ReportUtils.isPaidGroupPolicyExpenseReport(moneyRequestReport)) { + // If it's a paid policy expense report, only allow deleting the request if it's not submitted or the user is the policy admin + canDeleteRequest = canDeleteRequest && (ReportUtils.isDraftExpenseReport(moneyRequestReport) || PolicyUtils.isPolicyAdmin(policy)); + } useEffect(() => { - if (canModifyRequest) { + if (canDeleteRequest) { return; } setIsDeleteModalVisible(false); - }, [canModifyRequest]); + }, [canDeleteRequest]); const threeDotsMenuItems = [HeaderUtils.getPinMenuItem(report)]; if (canModifyRequest) { if (!TransactionUtils.hasReceipt(transaction)) { @@ -103,18 +95,20 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, ROUTES.MONEY_REQUEST_STEP_SCAN.getRoute( CONST.IOU.ACTION.EDIT, CONST.IOU.TYPE.REQUEST, - transaction.transactionID, + transaction?.transactionID ?? '', report.reportID, Navigation.getActiveRouteWithoutParams(), ), ), }); } - threeDotsMenuItems.push({ - icon: Expensicons.Trashcan, - text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), - onSelected: () => setIsDeleteModalVisible(true), - }); + if (canDeleteRequest) { + threeDotsMenuItems.push({ + icon: Expensicons.Trashcan, + text: translate('reportActionContextMenu.deleteAction', {action: parentReportAction}), + onSelected: () => setIsDeleteModalVisible(true), + }); + } } return ( @@ -129,7 +123,7 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, threeDotsAnchorPosition={styles.threeDotsPopoverOffsetNoCloseButton(windowWidth)} report={{ ...report, - ownerAccountID: lodashGet(parentReport, 'ownerAccountID', null), + ownerAccountID: parentReport?.ownerAccountID, }} policy={policy} personalDetails={personalDetails} @@ -166,29 +160,25 @@ function MoneyRequestHeader({session, parentReport, report, parentReportAction, } MoneyRequestHeader.displayName = 'MoneyRequestHeader'; -MoneyRequestHeader.propTypes = propTypes; -MoneyRequestHeader.defaultProps = defaultProps; -export default compose( - withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, - }, - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : '0'}`, - canEvict: false, +const MoneyRequestHeaderWithTransaction = withOnyx>({ + transaction: { + key: ({report, parentReportActions}) => { + const parentReportAction = (report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : {}) as ReportAction & OriginalMessageIOU; + return `${ONYXKEYS.COLLECTION.TRANSACTION}${parentReportAction.originalMessage.IOUTransactionID ?? 0}`; }, - }), - // eslint-disable-next-line rulesdir/no-multiple-onyx-in-file - withOnyx({ - transaction: { - key: ({report, parentReportActions}) => { - const parentReportAction = lodashGet(parentReportActions, [report.parentReportActionID]); - return `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(parentReportAction, 'originalMessage.IOUTransactionID', 0)}`; - }, - }, - }), -)(MoneyRequestHeader); + }, +})(MoneyRequestHeader); + +export default withOnyx, Omit>({ + session: { + key: ONYXKEYS.SESSION, + }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID}`, + }, + parentReportActions: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID ?? '0'}`, + canEvict: false, + }, +})(MoneyRequestHeaderWithTransaction); diff --git a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js index 8a61fe6daec5..a2f79d2696b8 100755 --- a/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js +++ b/src/components/MoneyTemporaryForRefactorRequestConfirmationList.js @@ -616,13 +616,13 @@ function MoneyTemporaryForRefactorRequestConfirmationList({ )} {button} ); - }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2, translate]); + }, [isReadOnly, iouType, selectedParticipants.length, confirm, bankAccountRoute, iouCurrencyCode, policyID, splitOrRequestOptions, formError, styles.ph1, styles.mb2]); const {image: receiptImage, thumbnail: receiptThumbnail} = receiptPath && receiptFilename ? ReceiptUtils.getThumbnailAndImageURIs(transaction, receiptPath, receiptFilename) : {}; return ( diff --git a/src/components/OfflineWithFeedback.tsx b/src/components/OfflineWithFeedback.tsx index 975d154b885b..2c41864564a3 100644 --- a/src/components/OfflineWithFeedback.tsx +++ b/src/components/OfflineWithFeedback.tsx @@ -1,9 +1,12 @@ +import {mapValues} from 'lodash'; import React, {useCallback} from 'react'; import type {ImageStyle, StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useNetwork from '@hooks/useNetwork'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import type {MaybePhraseKey} from '@libs/Localize'; import mapChildrenFlat from '@libs/mapChildrenFlat'; import shouldRenderOffscreen from '@libs/shouldRenderOffscreen'; import CONST from '@src/CONST'; @@ -59,6 +62,10 @@ type OfflineWithFeedbackProps = ChildrenProps & { type StrikethroughProps = Partial & {style: Array}; +function isMaybePhraseKeyType(message: unknown): message is MaybePhraseKey { + return typeof message === 'string' || Array.isArray(message); +} + function OfflineWithFeedback({ pendingAction, canDismissError = true, @@ -82,8 +89,8 @@ function OfflineWithFeedback({ // Some errors have a null message. This is used to apply opacity only and to avoid showing redundant messages. const errorEntries = Object.entries(errors ?? {}); - const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, string | ReceiptError] => errorEntry[1] !== null); - const errorMessages = Object.fromEntries(filteredErrorEntries); + const filteredErrorEntries = errorEntries.filter((errorEntry): errorEntry is [string, MaybePhraseKey | ReceiptError] => errorEntry[1] !== null); + const errorMessages = mapValues(Object.fromEntries(filteredErrorEntries), (error) => (isMaybePhraseKeyType(error) ? ErrorUtils.getErrorMessageWithTranslationData(error) : error)); const hasErrorMessages = !isEmptyObject(errorMessages); const isOfflinePendingAction = !!isOffline && !!pendingAction; diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index bb1732ceb2f8..cb6a2dcbe722 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -253,7 +253,7 @@ class BaseOptionsSelector extends Component { updateSearchValue(value) { this.setState({ paginationPage: 1, - errorMessage: value.length > this.props.maxLength ? this.props.translate('common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}) : '', + errorMessage: value.length > this.props.maxLength ? ['common.error.characterLimitExceedCounter', {length: value.length, limit: this.props.maxLength}] : '', value, }); diff --git a/src/components/PDFView/PDFPasswordForm.js b/src/components/PDFView/PDFPasswordForm.js index 42d2ebbb771e..10596bb9faf9 100644 --- a/src/components/PDFView/PDFPasswordForm.js +++ b/src/components/PDFView/PDFPasswordForm.js @@ -55,13 +55,13 @@ function PDFPasswordForm({isFocused, isPasswordInvalid, shouldShowLoadingIndicat const errorText = useMemo(() => { if (isPasswordInvalid) { - return translate('attachmentView.passwordIncorrect'); + return 'attachmentView.passwordIncorrect'; } if (!_.isEmpty(validationErrorText)) { - return translate(validationErrorText); + return validationErrorText; } return ''; - }, [isPasswordInvalid, translate, validationErrorText]); + }, [isPasswordInvalid, validationErrorText]); useEffect(() => { if (!isFocused) { diff --git a/src/components/Picker/types.ts b/src/components/Picker/types.ts index edf39a59c9d8..a12f4cbe683a 100644 --- a/src/components/Picker/types.ts +++ b/src/components/Picker/types.ts @@ -1,5 +1,6 @@ import type {ChangeEvent, Component, ReactElement} from 'react'; import type {MeasureLayoutOnSuccessCallback, NativeMethods, StyleProp, ViewStyle} from 'react-native'; +import type {MaybePhraseKey} from '@libs/Localize'; type MeasureLayoutOnFailCallback = () => void; @@ -58,7 +59,7 @@ type BasePickerProps = { placeholder?: PickerPlaceholder; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** Customize the BasePicker container */ containerStyles?: StyleProp; diff --git a/src/components/RadioButtonWithLabel.tsx b/src/components/RadioButtonWithLabel.tsx index f4bad4c082a7..52464a1453a1 100644 --- a/src/components/RadioButtonWithLabel.tsx +++ b/src/components/RadioButtonWithLabel.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import FormHelpMessage from './FormHelpMessage'; import * as Pressables from './Pressable'; import RadioButton from './RadioButton'; @@ -28,7 +29,7 @@ type RadioButtonWithLabelProps = { hasError?: boolean; /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; }; const PressableWithFeedback = Pressables.PressableWithFeedback; diff --git a/src/components/ReportActionItem/ReportActionItemImage.tsx b/src/components/ReportActionItem/ReportActionItemImage.tsx index 04a99e00c6bf..19727f4a5f5f 100644 --- a/src/components/ReportActionItem/ReportActionItemImage.tsx +++ b/src/components/ReportActionItem/ReportActionItemImage.tsx @@ -89,7 +89,7 @@ function ReportActionItemImage({thumbnail, image, enablePreviewModal = false, tr report={report} isReceiptAttachment canEditReceipt={canEditReceipt} - allowDownload + allowDownload={!isEReceipt} originalFileName={filename} > {({show}) => ( diff --git a/src/components/ReportActionItem/ReportPreview.tsx b/src/components/ReportActionItem/ReportPreview.tsx index f12c6d9bea31..311e63332f5c 100644 --- a/src/components/ReportActionItem/ReportPreview.tsx +++ b/src/components/ReportActionItem/ReportPreview.tsx @@ -19,6 +19,7 @@ import ControlSelection from '@libs/ControlSelection'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; +import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReceiptUtils from '@libs/ReceiptUtils'; import * as ReportActionUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -213,6 +214,8 @@ function ReportPreview({ ? // In a paid group policy, the admin approver can pay the report directly by skipping the approval step isPolicyAdmin && (isApproved || isCurrentUserManager) : isPolicyAdmin || (isMoneyRequestReport && isCurrentUserManager); + const isOnInstantSubmitPolicy = PolicyUtils.isInstantSubmitEnabled(policy); + const isOnSubmitAndClosePolicy = PolicyUtils.isSubmitAndClose(policy); const shouldShowPayButton = useMemo( () => isPayer && !isDraftExpenseReport && !iouSettled && !iouReport?.isWaitingOnBankAccount && reimbursableSpend !== 0 && !iouCanceled && !isAutoReimbursable, [isPayer, isDraftExpenseReport, iouSettled, reimbursableSpend, iouCanceled, isAutoReimbursable, iouReport], @@ -221,8 +224,11 @@ function ReportPreview({ if (!isPaidGroupPolicy) { return false; } + if (isOnInstantSubmitPolicy && isOnSubmitAndClosePolicy) { + return false; + } return isCurrentUserManager && !isDraftExpenseReport && !isApproved && !iouSettled; - }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, iouSettled]); + }, [isPaidGroupPolicy, isCurrentUserManager, isDraftExpenseReport, isApproved, isOnInstantSubmitPolicy, isOnSubmitAndClosePolicy, iouSettled]); const shouldShowSettlementButton = shouldShowPayButton || shouldShowApproveButton; /* diff --git a/src/components/RoomNameInput/roomNameInputPropTypes.js b/src/components/RoomNameInput/roomNameInputPropTypes.js index 60be8430b056..f634c6e0b3d6 100644 --- a/src/components/RoomNameInput/roomNameInputPropTypes.js +++ b/src/components/RoomNameInput/roomNameInputPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import refPropTypes from '@components/refPropTypes'; +import {translatableTextPropTypes} from '@libs/Localize'; const propTypes = { /** Callback to execute when the text input is modified correctly */ @@ -12,7 +13,7 @@ const propTypes = { disabled: PropTypes.bool, /** Error text to show */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), + errorText: translatableTextPropTypes, /** A ref forwarded to the TextInput */ forwardedRef: refPropTypes, diff --git a/src/components/ScreenWrapper.tsx b/src/components/ScreenWrapper.tsx index 2fc3efbb4f26..8b6a894cdd51 100644 --- a/src/components/ScreenWrapper.tsx +++ b/src/components/ScreenWrapper.tsx @@ -127,7 +127,7 @@ function ScreenWrapper( */ const navigationFallback = useNavigation>(); const navigation = navigationProp ?? navigationFallback; - const {windowHeight, isSmallScreenWidth} = useWindowDimensions(); + const {windowHeight, isSmallScreenWidth} = useWindowDimensions(shouldEnableMaxHeight); const {initialHeight} = useInitialDimensions(); const styles = useThemeStyles(); const keyboardState = useKeyboardState(); diff --git a/src/components/Section/index.tsx b/src/components/Section/index.tsx index 58e89d5bff76..7737927e5307 100644 --- a/src/components/Section/index.tsx +++ b/src/components/Section/index.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Lottie from '@components/Lottie'; @@ -42,8 +42,8 @@ type SectionProps = ChildrenProps & { /** Customize the Section container */ containerStyles?: StyleProp; - /** Customize the Section container */ - titleStyles?: StyleProp; + /** Customize the Section title */ + titleStyles?: StyleProp; /** Customize the Section container */ subtitleStyles?: StyleProp; @@ -114,9 +114,9 @@ function Section({ )} - + - {title} + {title} {cardLayout === CARD_LAYOUT.ICON_ON_RIGHT && ( {!!subtitle && ( - + {subtitle} )} diff --git a/src/components/SettlementButton.tsx b/src/components/SettlementButton.tsx index 058def7a34ad..80bedc84f069 100644 --- a/src/components/SettlementButton.tsx +++ b/src/components/SettlementButton.tsx @@ -147,7 +147,7 @@ function SettlementButton({ value: CONST.IOU.PAYMENT_TYPE.VBBA, }, [CONST.IOU.PAYMENT_TYPE.ELSEWHERE]: { - text: translate('iou.payElsewhere'), + text: translate('iou.payElsewhere', {formattedAmount}), icon: Expensicons.Cash, value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE, }, @@ -182,7 +182,7 @@ function SettlementButton({ // Put the preferred payment method to the front of the array, so it's shown as default if (paymentMethod) { - return buttonOptions.sort((method) => (method.value === paymentMethod ? 0 : 1)); + return buttonOptions.sort((method) => (method.value === paymentMethod ? -1 : 0)); } return buttonOptions; // We don't want to reorder the options when the preferred payment method changes while the button is still visible diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index a03e4f15fba0..b00111319b4a 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -6,13 +6,14 @@ import FormHelpMessage from '@components/FormHelpMessage'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import type {MaybePhraseKey} from '@libs/Localize'; import type {CountryData} from '@libs/searchCountryOptions'; import StateSelectorModal from './StateSelectorModal'; import type {State} from './StateSelectorModal'; type StatePickerProps = { /** Error text to display */ - errorText?: string; + errorText?: MaybePhraseKey; /** State to display */ value?: State; diff --git a/src/components/SubscriptAvatar.tsx b/src/components/SubscriptAvatar.tsx index c2de41d8b4db..3e0f5fb9785a 100644 --- a/src/components/SubscriptAvatar.tsx +++ b/src/components/SubscriptAvatar.tsx @@ -1,4 +1,5 @@ import React, {memo} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -46,9 +47,21 @@ type SubscriptAvatarProps = { /** Whether to show the tooltip */ showTooltip?: boolean; + + /** Additional style for container of subscription icon */ + subscriptionContainerAdditionalStyles?: StyleProp; }; -function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CONST.AVATAR_SIZE.DEFAULT, backgroundColor, noMargin = false, showTooltip = true}: SubscriptAvatarProps) { +function SubscriptAvatar({ + mainAvatar, + secondaryAvatar, + subscriptIcon, + size = CONST.AVATAR_SIZE.DEFAULT, + backgroundColor, + noMargin = false, + showTooltip = true, + subscriptionContainerAdditionalStyles = undefined, +}: SubscriptAvatarProps) { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -113,6 +126,7 @@ function SubscriptAvatar({mainAvatar, secondaryAvatar, subscriptIcon, size = CON styles.subscriptIcon, styles.dFlex, styles.justifyContentCenter, + subscriptionContainerAdditionalStyles, ]} // Hover on overflowed part of icon will not work on Electron if dragArea is true // https://stackoverflow.com/questions/56338939/hover-in-css-is-not-working-with-electron diff --git a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js index 78f06b4075e0..e6077bde71b3 100644 --- a/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js +++ b/src/components/TextInput/BaseTextInput/baseTextInputPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import sourcePropTypes from '@components/Image/sourcePropTypes'; +import {translatableTextPropTypes} from '@libs/Localize'; const propTypes = { /** Input label */ @@ -18,7 +19,7 @@ const propTypes = { placeholder: PropTypes.string, /** Error text to display */ - errorText: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]), + errorText: translatableTextPropTypes, /** Icon to display in right side of text input */ icon: sourcePropTypes, @@ -68,7 +69,7 @@ const propTypes = { maxLength: PropTypes.number, /** Hint text to display below the TextInput */ - hint: PropTypes.string, + hint: translatableTextPropTypes, /** Prefix character */ prefixCharacter: PropTypes.string, diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 01400adb0440..a637dc22d72e 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -67,7 +67,7 @@ type CustomBaseTextInputProps = { hideFocusedState?: boolean; /** Hint text to display below the TextInput */ - hint?: string; + hint?: MaybePhraseKey; /** Prefix character */ prefixCharacter?: string; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx index 72172365df13..a20dc353394e 100644 --- a/src/components/ThemeProvider.tsx +++ b/src/components/ThemeProvider.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/jsx-props-no-spreading */ -import PropTypes from 'prop-types'; import React, {useEffect, useMemo} from 'react'; import useThemePreferenceWithStaticOverride from '@hooks/useThemePreferenceWithStaticOverride'; import DomUtils from '@libs/DomUtils'; @@ -8,11 +6,6 @@ import themes from '@styles/theme'; import ThemeContext from '@styles/theme/context/ThemeContext'; import type {ThemePreferenceWithoutSystem} from '@styles/theme/types'; -const propTypes = { - /** Rendered child component */ - children: PropTypes.node.isRequired, -}; - type ThemeProviderProps = React.PropsWithChildren & { theme?: ThemePreferenceWithoutSystem; }; @@ -29,7 +22,6 @@ function ThemeProvider({children, theme: staticThemePreference}: ThemeProviderPr return {children}; } -ThemeProvider.propTypes = propTypes; ThemeProvider.displayName = 'ThemeProvider'; export default ThemeProvider; diff --git a/src/components/TimePicker/TimePicker.js b/src/components/TimePicker/TimePicker.js index 4664251ca765..f57f2540dfb3 100644 --- a/src/components/TimePicker/TimePicker.js +++ b/src/components/TimePicker/TimePicker.js @@ -536,7 +536,7 @@ function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) { {isError ? ( ) : ( diff --git a/src/components/ValuePicker/index.js b/src/components/ValuePicker/index.js index d90529114af4..28fa1ab26af2 100644 --- a/src/components/ValuePicker/index.js +++ b/src/components/ValuePicker/index.js @@ -7,12 +7,13 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import refPropTypes from '@components/refPropTypes'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; +import {translatableTextPropTypes} from '@libs/Localize'; import variables from '@styles/variables'; import ValueSelectorModal from './ValueSelectorModal'; const propTypes = { /** Form Error description */ - errorText: PropTypes.string, + errorText: translatableTextPropTypes, /** Item to display */ value: PropTypes.string, diff --git a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx b/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx deleted file mode 100755 index 9f615cef525d..000000000000 --- a/src/components/VideoChatButtonAndMenu/BaseVideoChatButtonAndMenu.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import React, {useCallback, useEffect, useRef, useState} from 'react'; -import {Dimensions, View} from 'react-native'; -import GoogleMeetIcon from '@assets/images/google-meet.svg'; -import ZoomIcon from '@assets/images/zoom-icon.svg'; -import Icon from '@components/Icon'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import Popover from '@components/Popover'; -import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; -import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; -import useLocalize from '@hooks/useLocalize'; -import useTheme from '@hooks/useTheme'; -import useThemeStyles from '@hooks/useThemeStyles'; -import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as Link from '@userActions/Link'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import type VideoChatButtonAndMenuProps from './types'; - -type BaseVideoChatButtonAndMenuProps = VideoChatButtonAndMenuProps & { - /** Link to open when user wants to create a new google meet meeting */ - googleMeetURL: string; -}; - -function BaseVideoChatButtonAndMenu({googleMeetURL, isConcierge = false, guideCalendarLink}: BaseVideoChatButtonAndMenuProps) { - const theme = useTheme(); - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {isSmallScreenWidth} = useWindowDimensions(); - const [isVideoChatMenuActive, setIsVideoChatMenuActive] = useState(false); - const [videoChatIconPosition, setVideoChatIconPosition] = useState({x: 0, y: 0}); - const videoChatIconWrapperRef = useRef(null); - const videoChatButtonRef = useRef(null); - - const menuItemData = [ - { - icon: ZoomIcon, - text: translate('videoChatButtonAndMenu.zoom'), - onPress: () => { - setIsVideoChatMenuActive(false); - Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); - }, - }, - { - icon: GoogleMeetIcon, - text: translate('videoChatButtonAndMenu.googleMeet'), - onPress: () => { - setIsVideoChatMenuActive(false); - Link.openExternalLink(googleMeetURL); - }, - }, - ]; - - /** - * This gets called onLayout to find the coordinates of the wrapper for the video chat button. - */ - const measureVideoChatIconPosition = useCallback(() => { - if (!videoChatIconWrapperRef.current) { - return; - } - - videoChatIconWrapperRef.current.measureInWindow((x, y) => { - setVideoChatIconPosition({x, y}); - }); - }, []); - - useEffect(() => { - const dimensionsEventListener = Dimensions.addEventListener('change', measureVideoChatIconPosition); - - return () => { - if (!dimensionsEventListener) { - return; - } - - dimensionsEventListener.remove(); - }; - }, [measureVideoChatIconPosition]); - - return ( - <> - - - { - // Drop focus to avoid blue focus ring. - videoChatButtonRef.current?.blur(); - - // If this is the Concierge chat, we'll open the modal for requesting a setup call instead - if (isConcierge && guideCalendarLink) { - Link.openExternalLink(guideCalendarLink); - return; - } - setIsVideoChatMenuActive((previousVal) => !previousVal); - })} - style={styles.touchableButtonImage} - accessibilityLabel={translate('videoChatButtonAndMenu.tooltip')} - role={CONST.ROLE.BUTTON} - > - - - - - - setIsVideoChatMenuActive(false)} - isVisible={isVideoChatMenuActive} - anchorPosition={{ - left: videoChatIconPosition.x - 150, - top: videoChatIconPosition.y + 40, - }} - withoutOverlay - anchorRef={videoChatButtonRef} - > - - {menuItemData.map(({icon, text, onPress}) => ( - - ))} - - - - ); -} - -BaseVideoChatButtonAndMenu.displayName = 'BaseVideoChatButtonAndMenu'; - -export default BaseVideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/index.android.tsx b/src/components/VideoChatButtonAndMenu/index.android.tsx deleted file mode 100644 index 838d296074fa..000000000000 --- a/src/components/VideoChatButtonAndMenu/index.android.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; -import CONST from '@src/CONST'; -import BaseVideoChatButtonAndMenu from './BaseVideoChatButtonAndMenu'; -import type VideoChatButtonAndMenuProps from './types'; - -// On Android creating a new google meet meeting requires the CALL_PHONE permission in some cases -// so we're just opening the google meet app instead, more details: -// https://github.com/Expensify/App/issues/8851#issuecomment-1120236904 -function VideoChatButtonAndMenu(props: VideoChatButtonAndMenuProps) { - return ( - - ); -} - -VideoChatButtonAndMenu.displayName = 'VideoChatButtonAndMenu'; - -export default VideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/index.tsx b/src/components/VideoChatButtonAndMenu/index.tsx deleted file mode 100644 index fa381c18d64a..000000000000 --- a/src/components/VideoChatButtonAndMenu/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import CONST from '@src/CONST'; -import BaseVideoChatButtonAndMenu from './BaseVideoChatButtonAndMenu'; -import type VideoChatButtonAndMenuProps from './types'; - -function VideoChatButtonAndMenu(props: VideoChatButtonAndMenuProps) { - return ( - - ); -} - -VideoChatButtonAndMenu.displayName = 'VideoChatButtonAndMenu'; - -export default VideoChatButtonAndMenu; diff --git a/src/components/VideoChatButtonAndMenu/types.ts b/src/components/VideoChatButtonAndMenu/types.ts deleted file mode 100644 index b8e263b48d01..000000000000 --- a/src/components/VideoChatButtonAndMenu/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -type VideoChatButtonAndMenuProps = { - /** If this is the Concierge chat, we'll open the modal for requesting a setup call instead of showing popover menu */ - isConcierge?: boolean; - - /** URL to the assigned guide's appointment booking calendar */ - guideCalendarLink?: string; -}; - -export default VideoChatButtonAndMenuProps; diff --git a/src/components/WorkspaceSwitcherButton.tsx b/src/components/WorkspaceSwitcherButton.tsx index b7485fbab7a8..963782bb50d5 100644 --- a/src/components/WorkspaceSwitcherButton.tsx +++ b/src/components/WorkspaceSwitcherButton.tsx @@ -13,6 +13,7 @@ import type {Policy} from '@src/types/onyx'; import * as Expensicons from './Icon/Expensicons'; import {PressableWithFeedback} from './Pressable'; import SubscriptAvatar from './SubscriptAvatar'; +import Tooltip from './Tooltip'; type WorkspaceSwitcherButtonOnyxProps = { policy: OnyxEntry; @@ -38,30 +39,33 @@ function WorkspaceSwitcherButton({activeWorkspaceID, policy}: WorkspaceSwitcherB }, [policy, activeWorkspaceID]); return ( - - interceptAnonymousUser(() => { - Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); - }) - } - > - {({hovered}) => ( - - )} - + + + interceptAnonymousUser(() => { + Navigation.navigate(ROUTES.WORKSPACE_SWITCHER); + }) + } + > + {({hovered}) => ( + + )} + + ); } diff --git a/src/components/transactionPropTypes.js b/src/components/transactionPropTypes.js index c70a2e524583..bdcf60bec5da 100644 --- a/src/components/transactionPropTypes.js +++ b/src/components/transactionPropTypes.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import _ from 'underscore'; +import {translatableTextPropTypes} from '@libs/Localize'; import CONST from '@src/CONST'; import sourcePropTypes from './Image/sourcePropTypes'; @@ -80,5 +81,5 @@ export default PropTypes.shape({ }), /** Server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), }); diff --git a/src/hooks/usePrivatePersonalDetails.ts b/src/hooks/usePrivatePersonalDetails.ts index 89d9951cef11..f17600e9878f 100644 --- a/src/hooks/usePrivatePersonalDetails.ts +++ b/src/hooks/usePrivatePersonalDetails.ts @@ -15,6 +15,6 @@ export default function usePrivatePersonalDetails() { return; } - PersonalDetails.openPersonalDetailsPage(); + PersonalDetails.openPersonalDetails(); }, [network?.isOffline]); } diff --git a/src/hooks/useWindowDimensions/index.ts b/src/hooks/useWindowDimensions/index.ts index b0a29e9f901b..4ba2c4ad9b41 100644 --- a/src/hooks/useWindowDimensions/index.ts +++ b/src/hooks/useWindowDimensions/index.ts @@ -1,13 +1,21 @@ +import {useEffect, useRef, useState} from 'react'; // eslint-disable-next-line no-restricted-imports import {Dimensions, useWindowDimensions} from 'react-native'; +import * as Browser from '@libs/Browser'; import variables from '@styles/variables'; import type WindowDimensions from './types'; +const initalViewportHeight = window.visualViewport?.height ?? window.innerHeight; +const tagNamesOpenKeyboard = ['INPUT', 'TEXTAREA']; + /** * A convenience wrapper around React Native's useWindowDimensions hook that also provides booleans for our breakpoints. */ -export default function (): WindowDimensions { +export default function (useCachedViewportHeight = false): WindowDimensions { + const isCachedViewportHeight = useCachedViewportHeight && Browser.isMobileSafari(); + const cachedViewportHeightWithKeyboardRef = useRef(initalViewportHeight); const {width: windowWidth, height: windowHeight} = useWindowDimensions(); + // When the soft keyboard opens on mWeb, the window height changes. Use static screen height instead to get real screenHeight. const screenHeight = Dimensions.get('screen').height; const isExtraSmallScreenHeight = screenHeight <= variables.extraSmallMobileResponsiveHeightBreakpoint; @@ -15,9 +23,61 @@ export default function (): WindowDimensions { const isMediumScreenWidth = windowWidth > variables.mobileResponsiveWidthBreakpoint && windowWidth <= variables.tabletResponsiveWidthBreakpoint; const isLargeScreenWidth = windowWidth > variables.tabletResponsiveWidthBreakpoint; + const [cachedViewportHeight, setCachedViewportHeight] = useState(windowHeight); + + const handleFocusIn = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(cachedViewportHeightWithKeyboardRef.current); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusin', handleFocusIn.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusin', handleFocusIn.current); + }; + }, [isCachedViewportHeight]); + + const handleFocusOut = useRef((event: FocusEvent) => { + const targetElement = event.target as HTMLElement; + if (tagNamesOpenKeyboard.includes(targetElement.tagName)) { + setCachedViewportHeight(initalViewportHeight); + } + }); + + useEffect(() => { + if (!isCachedViewportHeight) { + return; + } + window.addEventListener('focusout', handleFocusOut.current); + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + window.removeEventListener('focusout', handleFocusOut.current); + }; + }, [isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight && windowHeight >= cachedViewportHeightWithKeyboardRef.current) { + return; + } + setCachedViewportHeight(windowHeight); + }, [windowHeight, isCachedViewportHeight]); + + useEffect(() => { + if (!isCachedViewportHeight || !window.matchMedia('(orientation: portrait)').matches || windowHeight >= initalViewportHeight) { + return; + } + cachedViewportHeightWithKeyboardRef.current = windowHeight; + }, [isCachedViewportHeight, windowHeight]); + return { windowWidth, - windowHeight, + windowHeight: isCachedViewportHeight ? cachedViewportHeight : windowHeight, isExtraSmallScreenHeight, isSmallScreenWidth, isMediumScreenWidth, diff --git a/src/languages/en.ts b/src/languages/en.ts index aa9b9ca8c9a2..6b08eed4cb92 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -25,7 +25,6 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -382,8 +381,6 @@ export default { }, videoChatButtonAndMenu: { tooltip: 'Start a call', - zoom: 'Zoom', - googleMeet: 'Google Meet', }, hello: 'Hello', phoneCountryCode: '1', @@ -603,7 +600,7 @@ export default { settledExpensify: 'Paid', settledElsewhere: 'Paid elsewhere', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} with Expensify` : `Pay with Expensify`), - payElsewhere: 'Pay elsewhere', + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pay ${formattedAmount} elsewhere` : `Pay elsewhere`), nextStep: 'Next Steps', finished: 'Finished', requestAmount: ({amount}: RequestAmountParams) => `request ${amount}`, @@ -713,6 +710,14 @@ export default { offline: 'Offline', syncing: 'Syncing', profileAvatar: 'Profile avatar', + publicSection: { + title: 'Public', + subtitle: 'These details are displayed on your public profile, available for people to see.', + }, + privateSection: { + title: 'Private', + subtitle: 'These details are used for travel and payments. They are never shown on your public profile.', + }, }, loungeAccessPage: { loungeAccess: 'Lounge access', @@ -898,6 +903,9 @@ export default { sharedNoteMessage: 'Keep notes about this chat here. Expensify employees and other users on the team.expensify.com domain can view these notes.', composerLabel: 'Notes', myNote: 'My note', + error: { + genericFailureMessage: "Private notes couldn't be saved", + }, }, addDebitCardPage: { addADebitCard: 'Add a debit card', @@ -1167,7 +1175,7 @@ export default { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `Date should be before ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `Date should be after ${dateString}.`, hasInvalidCharacter: 'Name can only include Latin characters.', - incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, + incorrectZipFormat: (zipFormat?: string) => `Incorrect zip code format.${zipFormat ? ` Acceptable format: ${zipFormat}` : ''}`, }, }, resendValidationForm: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 39133892e684..99110ff07b63 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -24,7 +24,6 @@ import type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, @@ -372,8 +371,6 @@ export default { }, videoChatButtonAndMenu: { tooltip: 'Iniciar una llamada', - zoom: 'Zoom', - googleMeet: 'Google Meet', }, hello: 'Hola', phoneCountryCode: '34', @@ -595,7 +592,7 @@ export default { settledExpensify: 'Pagado', settledElsewhere: 'Pagado de otra forma', settleExpensify: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} con Expensify` : `Pagar con Expensify`), - payElsewhere: 'Pagar de otra forma', + payElsewhere: ({formattedAmount}: SettleExpensifyCardParams) => (formattedAmount ? `Pagar ${formattedAmount} de otra forma` : `Pagar de otra forma`), nextStep: 'Pasos Siguientes', finished: 'Finalizado', requestAmount: ({amount}: RequestAmountParams) => `solicitar ${amount}`, @@ -707,6 +704,14 @@ export default { offline: 'Desconectado', syncing: 'Sincronizando', profileAvatar: 'Perfil avatar', + publicSection: { + title: 'Público', + subtitle: 'Estos detalles se muestran en tu perfil público, a disposición de los demás.', + }, + privateSection: { + title: 'Privada', + subtitle: 'Estos detalles se utilizan para viajes y pagos. Nunca se mostrarán en tu perfil público.', + }, }, loungeAccessPage: { loungeAccess: 'Acceso a la sala vip', @@ -893,6 +898,9 @@ export default { sharedNoteMessage: 'Guarda notas sobre este chat aquí. Los empleados de Expensify y otros usuarios del dominio team.expensify.com pueden ver estas notas.', composerLabel: 'Notas', myNote: 'Mi nota', + error: { + genericFailureMessage: 'Las notas privadas no han podido ser guardadas', + }, }, addDebitCardPage: { addADebitCard: 'Añadir una tarjeta de débito', @@ -1164,7 +1172,7 @@ export default { error: { dateShouldBeBefore: ({dateString}: DateShouldBeBeforeParams) => `La fecha debe ser anterior a ${dateString}.`, dateShouldBeAfter: ({dateString}: DateShouldBeAfterParams) => `La fecha debe ser posterior a ${dateString}.`, - incorrectZipFormat: ({zipFormat}: IncorrectZipFormatParams) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, + incorrectZipFormat: (zipFormat?: string) => `Formato de código postal incorrecto.${zipFormat ? ` Formato aceptable: ${zipFormat}` : ''}`, hasInvalidCharacter: 'El nombre sólo puede incluir caracteres latinos.', }, }, diff --git a/src/languages/types.ts b/src/languages/types.ts index d4ec48eb3b41..c9442c6560a3 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -163,8 +163,6 @@ type DateShouldBeBeforeParams = {dateString: string}; type DateShouldBeAfterParams = {dateString: string}; -type IncorrectZipFormatParams = {zipFormat?: string}; - type WeSentYouMagicSignInLinkParams = {login: string; loginType: string}; type ToValidateLoginParams = {primaryLogin: string; secondaryLogin: string}; @@ -315,7 +313,6 @@ export type { FormattedMaxLengthParams, GoBackMessageParams, GoToRoomParams, - IncorrectZipFormatParams, InstantSummaryParams, LocalTimeParams, LoggedInAsParams, diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index a4ab3db9a7cd..4b383bacddaa 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -287,7 +287,7 @@ const READ_COMMANDS = { OPEN_WORKSPACE_VIEW: 'OpenWorkspaceView', GET_MAPBOX_ACCESS_TOKEN: 'GetMapboxAccessToken', OPEN_PAYMENTS_PAGE: 'OpenPaymentsPage', - OPEN_PERSONAL_DETAILS_PAGE: 'OpenPersonalDetailsPage', + OPEN_PERSONAL_DETAILS: 'OpenPersonalDetailsPage', OPEN_PUBLIC_PROFILE_PAGE: 'OpenPublicProfilePage', OPEN_PLAID_BANK_LOGIN: 'OpenPlaidBankLogin', OPEN_PLAID_BANK_ACCOUNT_SELECTOR: 'OpenPlaidBankAccountSelector', @@ -321,7 +321,7 @@ type ReadCommandParameters = { [READ_COMMANDS.OPEN_WORKSPACE_VIEW]: EmptyObject; [READ_COMMANDS.GET_MAPBOX_ACCESS_TOKEN]: EmptyObject; [READ_COMMANDS.OPEN_PAYMENTS_PAGE]: EmptyObject; - [READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE]: EmptyObject; + [READ_COMMANDS.OPEN_PERSONAL_DETAILS]: EmptyObject; [READ_COMMANDS.OPEN_PUBLIC_PROFILE_PAGE]: Parameters.OpenPublicProfilePageParams; [READ_COMMANDS.OPEN_PLAID_BANK_LOGIN]: Parameters.OpenPlaidBankLoginParams; [READ_COMMANDS.OPEN_PLAID_BANK_ACCOUNT_SELECTOR]: Parameters.OpenPlaidBankAccountSelectorParams; diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 6fbba1e750dc..8cfaa684917e 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -1,3 +1,4 @@ +import mapValues from 'lodash/mapValues'; import CONST from '@src/CONST'; import type {TranslationFlatObject, TranslationPaths} from '@src/languages/types'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; @@ -38,8 +39,8 @@ function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatO * Method used to get an error object with microsecond as the key. * @param error - error key or message to be saved */ -function getMicroSecondOnyxError(error: string | null): Errors { - return {[DateUtils.getMicroseconds()]: error}; +function getMicroSecondOnyxError(error: string | null, isTranslated = false): Errors { + return {[DateUtils.getMicroseconds()]: error && [error, {isTranslated}]}; } /** @@ -50,11 +51,16 @@ function getMicroSecondOnyxErrorObject(error: Errors): ErrorFields { return {[DateUtils.getMicroseconds()]: error}; } +// We can assume that if error is a string, it has already been translated because it is server error +function getErrorMessageWithTranslationData(error: Localize.MaybePhraseKey): Localize.MaybePhraseKey { + return typeof error === 'string' ? [error, {isTranslated: true}] : error; +} + type OnyxDataWithErrors = { errors?: Errors | null; }; -function getLatestErrorMessage(onyxData: TOnyxData): string | null { +function getLatestErrorMessage(onyxData: TOnyxData): Localize.MaybePhraseKey { const errors = onyxData.errors ?? {}; if (Object.keys(errors).length === 0) { @@ -62,8 +68,7 @@ function getLatestErrorMessage(onyxData: T } const key = Object.keys(errors).sort().reverse()[0]; - - return errors[key]; + return getErrorMessageWithTranslationData(errors[key]); } function getLatestErrorMessageField(onyxData: TOnyxData): Errors { @@ -90,8 +95,7 @@ function getLatestErrorField(onyxData } const key = Object.keys(errorsForField).sort().reverse()[0]; - - return {[key]: errorsForField[key]}; + return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Errors { @@ -102,18 +106,33 @@ function getEarliestErrorField(onyxDa } const key = Object.keys(errorsForField).sort()[0]; - - return {[key]: errorsForField[key]}; + return {[key]: getErrorMessageWithTranslationData(errorsForField[key])}; } -type ErrorsList = Record; +/** + * Method used to attach already translated message with isTranslated property + * @param errors - An object containing current errors in the form + * @returns Errors in the form of {timestamp: [message, {isTranslated}]} + */ +function getErrorsWithTranslationData(errors: Localize.MaybePhraseKey | Errors): Errors { + if (!errors || (Array.isArray(errors) && errors.length === 0)) { + return {}; + } + + if (typeof errors === 'string' || Array.isArray(errors)) { + // eslint-disable-next-line @typescript-eslint/naming-convention + return {'0': getErrorMessageWithTranslationData(errors)}; + } + + return mapValues(errors, getErrorMessageWithTranslationData); +} /** * Method used to generate error message for given inputID * @param errors - An object containing current errors in the form * @param message - Message to assign to the inputID errors */ -function addErrorMessage(errors: ErrorsList, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { +function addErrorMessage(errors: Errors, inputID?: string, message?: TKey | Localize.MaybePhraseKey) { if (!message || !inputID) { return; } @@ -138,6 +157,8 @@ export { getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, + getErrorMessageWithTranslationData, + getErrorsWithTranslationData, addErrorMessage, getLatestErrorMessageField, }; diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts index 0df9e25eff25..7c0cd437d5f9 100644 --- a/src/libs/Localize/index.ts +++ b/src/libs/Localize/index.ts @@ -1,3 +1,4 @@ +import PropTypes from 'prop-types'; import * as RNLocalize from 'react-native-localize'; import Onyx from 'react-native-onyx'; import Log from '@libs/Log'; @@ -98,7 +99,15 @@ function translateLocal(phrase: TKey, ...variable return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables); } -type MaybePhraseKey = string | null | [string, Record & {isTranslated?: true}] | []; +/** + * Traslatable text with phrase key and/or variables + * Use MaybePhraseKey for Typescript + * + * E.g. ['common.error.characterLimitExceedCounter', {length: 5, limit: 20}] + */ +const translatableTextPropTypes = PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]); + +type MaybePhraseKey = string | null | [string, Record & {isTranslated?: boolean}] | []; /** * Return translated string for given error. @@ -177,5 +186,5 @@ function getDevicePreferredLocale(): string { return RNLocalize.findBestAvailableLanguage([CONST.LOCALES.EN, CONST.LOCALES.ES])?.languageTag ?? CONST.LOCALES.DEFAULT; } -export {translate, translateLocal, translateIfPhraseKey, formatList, formatMessageElementList, getDevicePreferredLocale}; +export {translatableTextPropTypes, translate, translateLocal, translateIfPhraseKey, formatList, formatMessageElementList, getDevicePreferredLocale}; export type {PhraseParameters, Phrase, MaybePhraseKey}; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index b57ff5d8bf60..c7be135e8b57 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -208,11 +208,10 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Profile/DisplayNamePage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE]: () => require('../../../pages/settings/Profile/TimezoneInitialPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT]: () => require('../../../pages/settings/Profile/TimezoneSelectPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: () => require('../../../pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: () => require('../../../pages/settings/Profile/PersonalDetails/LegalNamePage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: () => require('../../../pages/settings/Profile/PersonalDetails/DateOfBirthPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default as React.ComponentType, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: () => require('../../../pages/settings/Profile/PersonalDetails/CountrySelectionPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHODS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.CONTACT_METHOD_DETAILS]: () => require('../../../pages/settings/Profile/Contacts/ContactMethodDetailsPage').default as React.ComponentType, [SCREENS.SETTINGS.PROFILE.NEW_CONTACT_METHOD]: () => require('../../../pages/settings/Profile/Contacts/NewContactMethodPage').default as React.ComponentType, diff --git a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx index b07c12d37b5a..e8a321984f1c 100644 --- a/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx +++ b/src/libs/Navigation/AppNavigator/createCustomFullScreenNavigator/CustomFullScreenRouter.tsx @@ -1,6 +1,6 @@ import type {ParamListBase, PartialState, RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {StackRouter} from '@react-navigation/native'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import SCREENS from '@src/SCREENS'; import type {FullScreenNavigatorRouterOptions} from './types'; @@ -9,11 +9,11 @@ type StackState = StackNavigationState | PartialState !!state.routes.find((route) => route.name === screenName); function adaptStateIfNecessary(state: StackState) { - const isSmallScreenWidth = getIsSmallScreenWidth(); + const isNarrowLayout = getIsNarrowLayout(); // If the screen is wide, there should be at least two screens inside: // - SETINGS.ROOT to cover left pane. // - SETTINGS_CENTRAL_PANE to cover central pane. - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { if (!isAtLeastOneInState(state, SCREENS.SETTINGS.ROOT)) { // @ts-expect-error Updating read only property // noinspection JSConstantReassignment diff --git a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts index a59150019142..504803026994 100644 --- a/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts +++ b/src/libs/Navigation/AppNavigator/createCustomStackNavigator/CustomRouter.ts @@ -1,7 +1,7 @@ import type {RouterConfigOptions, StackNavigationState} from '@react-navigation/native'; import {getPathFromState, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getTopmostBottomTabRoute from '@libs/Navigation/getTopmostBottomTabRoute'; import getTopmostCentralPaneRoute from '@libs/Navigation/getTopmostCentralPaneRoute'; import linkingConfig from '@libs/Navigation/linkingConfig'; @@ -38,10 +38,10 @@ function compareAndAdaptState(state: StackNavigationState) { // We need to be sure that the bottom tab state is defined. const topmostBottomTabRoute = getTopmostBottomTabRoute(state); - const isSmallScreenWidth = getIsSmallScreenWidth(); + const isNarrowLayout = getIsNarrowLayout(); // This solutions is heuristics and will work for our cases. We may need to improve it in the future if we will have more cases to handle. - if (topmostBottomTabRoute && !isSmallScreenWidth) { + if (topmostBottomTabRoute && !isNarrowLayout) { const fullScreenRoute = state.routes.find((route) => route.name === NAVIGATORS.FULL_SCREEN_NAVIGATOR); // If there is fullScreenRoute we don't need to add anything. diff --git a/src/libs/Navigation/linkTo.ts b/src/libs/Navigation/linkTo.ts index 49dcee71eda4..5a765d9a7d37 100644 --- a/src/libs/Navigation/linkTo.ts +++ b/src/libs/Navigation/linkTo.ts @@ -1,7 +1,7 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import type {Writable} from 'type-fest'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; @@ -212,7 +212,7 @@ export default function linkTo(navigation: NavigationContainerRef> = { SCREENS.SETTINGS.PROFILE.PRONOUNS, SCREENS.SETTINGS.PROFILE.TIMEZONE, SCREENS.SETTINGS.PROFILE.TIMEZONE_SELECT, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS, - SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY, + SCREENS.SETTINGS.PROFILE.LEGAL_NAME, + SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH, + SCREENS.SETTINGS.PROFILE.ADDRESS, + SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY, ], [SCREENS.SETTINGS.PREFERENCES.ROOT]: [SCREENS.SETTINGS.PREFERENCES.PRIORITY_MODE, SCREENS.SETTINGS.PREFERENCES.LANGUAGE, SCREENS.SETTINGS.PREFERENCES.THEME], [SCREENS.SETTINGS.WALLET.ROOT]: [ diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 0346dcfcf049..12577e360784 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -190,24 +190,20 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_NEW_CONTACT_METHOD.route, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.INITIAL]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS, + [SCREENS.SETTINGS.PROFILE.LEGAL_NAME]: { + path: ROUTES.SETTINGS_LEGAL_NAME, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.LEGAL_NAME]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME, + [SCREENS.SETTINGS.PROFILE.DATE_OF_BIRTH]: { + path: ROUTES.SETTINGS_DATE_OF_BIRTH, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.DATE_OF_BIRTH]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH, + [SCREENS.SETTINGS.PROFILE.ADDRESS]: { + path: ROUTES.SETTINGS_ADDRESS, exact: true, }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS, - exact: true, - }, - [SCREENS.SETTINGS.PROFILE.PERSONAL_DETAILS.ADDRESS_COUNTRY]: { - path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS_COUNTRY.route, + [SCREENS.SETTINGS.PROFILE.ADDRESS_COUNTRY]: { + path: ROUTES.SETTINGS_ADDRESS_COUNTRY.route, exact: true, }, [SCREENS.SETTINGS.TWO_FACTOR_AUTH]: { diff --git a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts index 06e58282da70..8e246d82ff72 100644 --- a/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/linkingConfig/getAdaptedStateFromPath.ts @@ -1,7 +1,7 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; import {getStateFromPath} from '@react-navigation/native'; import {isAnonymousUser} from '@libs/actions/Session'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import getTopmostNestedRHPRoute from '@libs/Navigation/getTopmostNestedRHPRoute'; import type {BottomTabName, CentralPaneName, FullScreenName, NavigationPartialRoute, RootStackParamList} from '@libs/Navigation/types'; import {extractPolicyIDFromPath, getPathWithoutPolicyID} from '@libs/PolicyUtils'; @@ -132,7 +132,7 @@ function getMatchingRootRouteForRHPRoute( } function getAdaptedState(state: PartialState>, policyID?: string): GetAdaptedStateReturnType { - const isSmallScreenWidth = getIsSmallScreenWidth(); + const isNarrowLayout = getIsNarrowLayout(); const metainfo = { isCentralPaneAndBottomTabMandatory: true, isFullScreenNavigatorMandatory: true, @@ -194,7 +194,7 @@ function getAdaptedState(state: PartialState policyID, ), ); - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { routes.push( createCentralPaneNavigator({ name: SCREENS.REPORT, @@ -226,7 +226,7 @@ function getAdaptedState(state: PartialState policyID, ), ); - if (!isSmallScreenWidth) { + if (!isNarrowLayout) { routes.push(createCentralPaneNavigator({name: SCREENS.REPORT})); } routes.push(fullScreenNavigator); @@ -254,7 +254,7 @@ function getAdaptedState(state: PartialState // Routes // - found bottom tab // - matching central pane on desktop layout - if (isSmallScreenWidth) { + if (isNarrowLayout) { return { adaptedState: state, metainfo, diff --git a/src/libs/Navigation/switchPolicyID.ts b/src/libs/Navigation/switchPolicyID.ts index 4b9259c76ad7..9afb325eee99 100644 --- a/src/libs/Navigation/switchPolicyID.ts +++ b/src/libs/Navigation/switchPolicyID.ts @@ -2,7 +2,7 @@ import {getActionFromState} from '@react-navigation/core'; import type {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; import {getPathFromState} from '@react-navigation/native'; import type {ValueOf, Writable} from 'type-fest'; -import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; +import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import type {Route} from '@src/ROUTES'; @@ -95,7 +95,7 @@ export default function switchPolicyID(navigation: NavigationContainerRef | EmptyObject): boolean { return policy?.type === CONST.POLICY.TYPE.TEAM || policy?.type === CONST.POLICY.TYPE.CORPORATE; } +/** + * Checks if policy's scheduled submit / auto reporting frequency is "instant". + * Note: Free policies have "instant" submit always enabled. + */ +function isInstantSubmitEnabled(policy: OnyxEntry): boolean { + return policy?.autoReportingFrequency === CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT || policy?.type === CONST.POLICY.TYPE.FREE; +} + +/** + * Checks if policy's approval mode is "optional", a.k.a. "Submit & Close" + */ +function isSubmitAndClose(policy: OnyxEntry): boolean { + return policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.OPTIONAL; +} + function extractPolicyIDFromPath(path: string) { return path.match(CONST.REGEX.POLICY_ID_FROM_PATH)?.[1]; } @@ -232,7 +247,9 @@ export { shouldShowPolicy, isExpensifyTeam, isExpensifyGuideTeam, + isInstantSubmitEnabled, isPolicyAdmin, + isSubmitAndClose, getMemberAccountIDsForWorkspace, getIneligibleInvitees, getTag, diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 50fcbac34c96..ba78d8a2cc38 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -172,8 +172,8 @@ type ReportRouteParams = { }; type ReportOfflinePendingActionAndErrors = { - addWorkspaceRoomOrChatPendingAction: PendingAction | undefined; - addWorkspaceRoomOrChatErrors: Errors | null | undefined; + reportPendingAction: PendingAction | undefined; + reportErrors: Errors | null | undefined; }; type OptimisticApprovedReportAction = Pick< @@ -1226,6 +1226,7 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: const report = getReport(reportID); const isActionOwner = reportAction?.actorAccountID === currentUserAccountID; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`] ?? null; if (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.IOU) { // For now, users cannot delete split actions @@ -1236,6 +1237,10 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: } if (isActionOwner) { + if (!isEmptyObject(report) && isPaidGroupPolicyExpenseReport(report)) { + // If it's a paid policy expense report, only allow deleting the request if it's not submitted or the user is the policy admin + return isDraftExpenseReport(report) || PolicyUtils.isPolicyAdmin(policy); + } return true; } } @@ -1249,7 +1254,6 @@ function canDeleteReportAction(reportAction: OnyxEntry, reportID: return false; } - const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report?.policyID}`]; const isAdmin = policy?.role === CONST.POLICY.ROLE.ADMIN && !isEmptyObject(report) && !isDM(report); return isActionOwner || isAdmin; @@ -4240,14 +4244,20 @@ function getOriginalReportID(reportID: string, reportAction: OnyxEntry): ReportOfflinePendingActionAndErrors { - // We are either adding a workspace room, or we're creating a chat, it isn't possible for both of these to be pending, or to have errors for the same report at the same time, so - // simply looking up the first truthy value for each case will get the relevant property if it's set. - const addWorkspaceRoomOrChatPendingAction = report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat; - const addWorkspaceRoomOrChatErrors = getAddWorkspaceRoomOrChatReportErrors(report); - return {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors}; + // It shouldn't be possible for all of these actions to be pending (or to have errors) for the same report at the same time, so just take the first that exists + const reportPendingAction = report?.pendingFields?.addWorkspaceRoom ?? report?.pendingFields?.createChat ?? report?.pendingFields?.reimbursed; + + const reportErrors = getAddWorkspaceRoomOrChatReportErrors(report); + return {reportPendingAction, reportErrors}; } function getPolicyExpenseChatReportIDByOwner(policyOwner: string): string | null { diff --git a/src/libs/actions/Card.ts b/src/libs/actions/Card.ts index 38a421409ade..2cc32616562d 100644 --- a/src/libs/actions/Card.ts +++ b/src/libs/actions/Card.ts @@ -3,7 +3,6 @@ import type {OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; import type {ActivatePhysicalExpensifyCardParams, ReportVirtualExpensifyCardFraudParams, RequestReplacementExpensifyCardParams, RevealExpensifyCardDetailsParams} from '@libs/API/parameters'; import {SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Response} from '@src/types/onyx'; @@ -167,12 +166,14 @@ function revealVirtualCardDetails(cardID: number): Promise { API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.REVEAL_EXPENSIFY_CARD_DETAILS, parameters) .then((response) => { if (response?.jsonCode !== CONST.JSON_CODE.SUCCESS) { - reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure')); + // eslint-disable-next-line prefer-promise-reject-errors + reject('cardPage.cardDetailsLoadingFailure'); return; } resolve(response); }) - .catch(() => reject(Localize.translateLocal('cardPage.cardDetailsLoadingFailure'))); + // eslint-disable-next-line prefer-promise-reject-errors + .catch(() => reject('cardPage.cardDetailsLoadingFailure')); }); } diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts index ca417f32cf90..e16f879b6913 100644 --- a/src/libs/actions/IOU.ts +++ b/src/libs/actions/IOU.ts @@ -3163,6 +3163,10 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT lastMessageHtml: optimisticIOUReportAction.message?.[0].html, hasOutstandingChildRequest: false, statusNum: CONST.REPORT.STATUS_NUM.REIMBURSED, + pendingFields: { + preview: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + reimbursed: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE, + }, }, }, { @@ -3182,6 +3186,16 @@ function getPayMoneyRequestParams(chatReport: OnyxTypes.Report, iouReport: OnyxT }, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, + value: { + pendingFields: { + preview: null, + reimbursed: null, + }, + }, + }, ]; const failureData: OnyxUpdate[] = [ diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index 26c8937de3aa..53491b386b8c 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -112,7 +112,7 @@ function updateLegalName(legalFirstName: string, legalLastName: string) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -133,7 +133,7 @@ function updateDateOfBirth({dob}: DateOfBirthForm) { ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } function updateAddress(street: string, street2: string, city: string, state: string, zip: string, country: string) { @@ -170,7 +170,7 @@ function updateAddress(street: string, street2: string, city: string, state: str ], }); - Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS); + Navigation.goBack(); } /** @@ -241,7 +241,7 @@ function updateSelectedTimezone(selectedTimezone: SelectedTimezone) { /** * Fetches additional personal data like legal name, date of birth, address */ -function openPersonalDetailsPage() { +function openPersonalDetails() { const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -272,7 +272,7 @@ function openPersonalDetailsPage() { }, ]; - API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS_PAGE, {}, {optimisticData, successData, failureData}); + API.read(READ_COMMANDS.OPEN_PERSONAL_DETAILS, {}, {optimisticData, successData, failureData}); } /** @@ -455,7 +455,7 @@ export { clearAvatarErrors, deleteAvatar, getPrivatePersonalDetails, - openPersonalDetailsPage, + openPersonalDetails, openPublicProfilePage, updateAddress, updateAutomaticTimezone, diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts index 5b4fb8160894..2d13624277f0 100644 --- a/src/libs/actions/Report.ts +++ b/src/libs/actions/Report.ts @@ -2529,7 +2529,7 @@ const updatePrivateNotes = (reportID: string, accountID: number, note: string) = value: { privateNotes: { [accountID]: { - errors: ErrorUtils.getMicroSecondOnyxError("Private notes couldn't be saved"), + errors: ErrorUtils.getMicroSecondOnyxError('privateNotes.error.genericFailureMessage'), }, }, }, diff --git a/src/libs/actions/Session/index.ts b/src/libs/actions/Session/index.ts index 4fbeba0abaa6..d000d5ebfbec 100644 --- a/src/libs/actions/Session/index.ts +++ b/src/libs/actions/Session/index.ts @@ -607,7 +607,7 @@ function clearAccountMessages() { } function setAccountError(error: string) { - Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error)}); + Onyx.merge(ONYXKEYS.ACCOUNT, {errors: ErrorUtils.getMicroSecondOnyxError(error, true)}); } // It's necessary to throttle requests to reauthenticate since calling this multiple times will cause Pusher to diff --git a/src/libs/checkForUpdates.ts b/src/libs/checkForUpdates.ts index 51ce12335e29..532253d7494f 100644 --- a/src/libs/checkForUpdates.ts +++ b/src/libs/checkForUpdates.ts @@ -1,9 +1,6 @@ -const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; +import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; -type PlatformSpecificUpdater = { - update: () => void; - init?: () => void; -}; +const UPDATE_INTERVAL = 1000 * 60 * 60 * 8; function checkForUpdates(platformSpecificUpdater: PlatformSpecificUpdater) { if (typeof platformSpecificUpdater.init === 'function') { @@ -16,4 +13,4 @@ function checkForUpdates(platformSpecificUpdater: PlatformSpecificUpdater) { }, UPDATE_INTERVAL); } -module.exports = checkForUpdates; +export default checkForUpdates; diff --git a/src/libs/getIsNarrowLayout/index.native.ts b/src/libs/getIsNarrowLayout/index.native.ts new file mode 100644 index 000000000000..c43130a63b2a --- /dev/null +++ b/src/libs/getIsNarrowLayout/index.native.ts @@ -0,0 +1,3 @@ +export default function getIsNarrowLayout() { + return true; +} diff --git a/src/libs/getIsNarrowLayout/index.ts b/src/libs/getIsNarrowLayout/index.ts new file mode 100644 index 000000000000..e901f04e7d26 --- /dev/null +++ b/src/libs/getIsNarrowLayout/index.ts @@ -0,0 +1,5 @@ +import getIsSmallScreenWidth from '@libs/getIsSmallScreenWidth'; + +export default function getIsNarrowLayout() { + return getIsSmallScreenWidth(); +} diff --git a/src/pages/EnablePayments/OnfidoPrivacy.js b/src/pages/EnablePayments/OnfidoPrivacy.js index 77b884fb2934..8de8bdb4bf07 100644 --- a/src/pages/EnablePayments/OnfidoPrivacy.js +++ b/src/pages/EnablePayments/OnfidoPrivacy.js @@ -45,9 +45,11 @@ function OnfidoPrivacy({walletOnfidoData, translate, form}) { BankAccounts.openOnfidoFlow(); }; - let onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) || ''; + const onfidoError = ErrorUtils.getLatestErrorMessage(walletOnfidoData) || ''; const onfidoFixableErrors = lodashGet(walletOnfidoData, 'fixableErrors', []); - onfidoError += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : ''; + if (_.isArray(onfidoError)) { + onfidoError[0] += !_.isEmpty(onfidoFixableErrors) ? `\n${onfidoFixableErrors.join('\n')}` : ''; + } return ( diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js deleted file mode 100644 index 47d2ad356dad..000000000000 --- a/src/pages/FlagCommentPage.js +++ /dev/null @@ -1,209 +0,0 @@ -import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; -import {ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; -import MenuItem from '@components/MenuItem'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; -import * as ReportUtils from '@libs/ReportUtils'; -import * as Report from '@userActions/Report'; -import * as Session from '@userActions/Session'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import reportActionPropTypes from './home/report/reportActionPropTypes'; -import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; -import reportPropTypes from './reportPropTypes'; - -const propTypes = { - /** Array of report actions for this report */ - reportActions: PropTypes.shape(reportActionPropTypes), - - /** The active report */ - report: reportPropTypes, - - /** Route params */ - route: PropTypes.shape({ - params: PropTypes.shape({ - /** Report ID passed via route r/:reportID/:reportActionID */ - reportID: PropTypes.string, - - /** ReportActionID passed via route r/:reportID/:reportActionID */ - reportActionID: PropTypes.string, - }), - }).isRequired, - - ...withLocalizePropTypes, - - /* Onyx Props */ - /** All the report actions from the parent report */ - parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)), -}; - -const defaultProps = { - reportActions: {}, - parentReportActions: {}, - report: {}, -}; - -/** - * Get the reportID for the associated chatReport - * - * @param {Object} route - * @param {Object} route.params - * @param {String} route.params.reportID - * @returns {String} - */ -function getReportID(route) { - return route.params.reportID.toString(); -} - -function FlagCommentPage(props) { - const styles = useThemeStyles(); - const severities = [ - { - severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, - name: props.translate('moderation.spam'), - icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.spamDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), - furtherDetailsIcon: Expensicons.FlagLevelOne, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, - name: props.translate('moderation.inconsiderate'), - icon: Expensicons.FlagLevelOne, - description: props.translate('moderation.inconsiderateDescription'), - furtherDetails: props.translate('moderation.levelOneResult'), - furtherDetailsIcon: Expensicons.FlagLevelOne, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, - name: props.translate('moderation.intimidation'), - icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.intimidationDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), - furtherDetailsIcon: Expensicons.FlagLevelTwo, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, - name: props.translate('moderation.bullying'), - icon: Expensicons.FlagLevelTwo, - description: props.translate('moderation.bullyingDescription'), - furtherDetails: props.translate('moderation.levelTwoResult'), - furtherDetailsIcon: Expensicons.FlagLevelTwo, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, - name: props.translate('moderation.harassment'), - icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.harassmentDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), - furtherDetailsIcon: Expensicons.FlagLevelThree, - }, - { - severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, - name: props.translate('moderation.assault'), - icon: Expensicons.FlagLevelThree, - description: props.translate('moderation.assaultDescription'), - furtherDetails: props.translate('moderation.levelThreeResult'), - furtherDetailsIcon: Expensicons.FlagLevelThree, - }, - ]; - - const getActionToFlag = useCallback(() => { - let reportAction = props.reportActions[`${props.route.params.reportActionID.toString()}`]; - - // Handle threads if needed - if (reportAction === undefined || reportAction.reportActionID === undefined) { - reportAction = props.parentReportActions[props.report.parentReportActionID] || {}; - } - - return reportAction; - }, [props.report, props.reportActions, props.route.params.reportActionID, props.parentReportActions]); - - const flagComment = (severity) => { - let reportID = getReportID(props.route); - const reportAction = getActionToFlag(); - const parentReportAction = props.parentReportActions[props.report.parentReportActionID] || {}; - - // Handle threads if needed - if (ReportUtils.isChatThread(props.report) && reportAction.reportActionID === parentReportAction.reportActionID) { - reportID = ReportUtils.getParentReport(props.report).reportID; - } - - if (ReportUtils.canFlagReportAction(reportAction, reportID)) { - Report.flagComment(reportID, reportAction, severity); - } - - Navigation.dismissModal(); - }; - - const severityMenuItems = _.map(severities, (item, index) => ( - flagComment(item.severity))} - style={[styles.pt2, styles.pb4, styles.ph5, styles.flexRow]} - furtherDetails={item.furtherDetails} - furtherDetailsIcon={item.furtherDetailsIcon} - /> - )); - - return ( - - {({safeAreaPaddingBottomStyle}) => ( - - { - Navigation.goBack(); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); - }} - /> - - - - {props.translate('moderation.flagDescription')} - - - {props.translate('moderation.chooseAReason')} - {severityMenuItems} - - - )} - - ); -} - -FlagCommentPage.propTypes = propTypes; -FlagCommentPage.defaultProps = defaultProps; -FlagCommentPage.displayName = 'FlagCommentPage'; - -export default compose( - withLocalize, - withReportAndReportActionOrNotFound, - withOnyx({ - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID || report.reportID}`, - canEvict: false, - }, - }), -)(FlagCommentPage); diff --git a/src/pages/FlagCommentPage.tsx b/src/pages/FlagCommentPage.tsx new file mode 100644 index 000000000000..00c38dabc4ec --- /dev/null +++ b/src/pages/FlagCommentPage.tsx @@ -0,0 +1,190 @@ +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {SvgProps} from 'react-native-svg'; +import type {ValueOf} from 'type-fest'; +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import type {FlagCommentNavigatorParamList} from '@libs/Navigation/types'; +import * as ReportUtils from '@libs/ReportUtils'; +import * as Report from '@userActions/Report'; +import * as Session from '@userActions/Session'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import type * as OnyxTypes from '@src/types/onyx'; +import withReportAndReportActionOrNotFound from './home/report/withReportAndReportActionOrNotFound'; +import type {WithReportAndReportActionOrNotFoundProps} from './home/report/withReportAndReportActionOrNotFound'; + +type FlagCommentPageWithOnyxProps = { + /** The report action from the parent report */ + parentReportAction: OnyxEntry; +}; + +type FlagCommentPageNavigationProps = StackScreenProps; + +type FlagCommentPageProps = FlagCommentPageNavigationProps & WithReportAndReportActionOrNotFoundProps & FlagCommentPageWithOnyxProps; + +type Severity = ValueOf; + +type SeverityItem = { + severity: Severity; + name: string; + icon: React.FC; + description: string; + furtherDetails: string; + furtherDetailsIcon: React.FC; +}; + +type SeverityItemList = SeverityItem[]; + +/** + * Get the reportID for the associated chatReport + */ +function getReportID(route: FlagCommentPageNavigationProps['route']) { + return route.params.reportID.toString(); +} + +function FlagCommentPage({parentReportAction, route, report, reportActions}: FlagCommentPageProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const severities: SeverityItemList = [ + { + severity: CONST.MODERATION.FLAG_SEVERITY_SPAM, + name: translate('moderation.spam'), + icon: Expensicons.FlagLevelOne, + description: translate('moderation.spamDescription'), + furtherDetails: translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INCONSIDERATE, + name: translate('moderation.inconsiderate'), + icon: Expensicons.FlagLevelOne, + description: translate('moderation.inconsiderateDescription'), + furtherDetails: translate('moderation.levelOneResult'), + furtherDetailsIcon: Expensicons.FlagLevelOne, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_INTIMIDATION, + name: translate('moderation.intimidation'), + icon: Expensicons.FlagLevelTwo, + description: translate('moderation.intimidationDescription'), + furtherDetails: translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_BULLYING, + name: translate('moderation.bullying'), + icon: Expensicons.FlagLevelTwo, + description: translate('moderation.bullyingDescription'), + furtherDetails: translate('moderation.levelTwoResult'), + furtherDetailsIcon: Expensicons.FlagLevelTwo, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_HARASSMENT, + name: translate('moderation.harassment'), + icon: Expensicons.FlagLevelThree, + description: translate('moderation.harassmentDescription'), + furtherDetails: translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + { + severity: CONST.MODERATION.FLAG_SEVERITY_ASSAULT, + name: translate('moderation.assault'), + icon: Expensicons.FlagLevelThree, + description: translate('moderation.assaultDescription'), + furtherDetails: translate('moderation.levelThreeResult'), + furtherDetailsIcon: Expensicons.FlagLevelThree, + }, + ]; + + const getActionToFlag = useCallback((): OnyxTypes.ReportAction | null => { + let reportAction = reportActions?.[`${route.params.reportActionID.toString()}`]; + + // Handle threads if needed + if (reportAction?.reportActionID === undefined && parentReportAction) { + reportAction = parentReportAction; + } + + if (!reportAction) { + return null; + } + + return reportAction; + }, [reportActions, route.params.reportActionID, parentReportAction]); + + const flagComment = (severity: Severity) => { + let reportID: string | undefined = getReportID(route); + const reportAction = getActionToFlag(); + + // Handle threads if needed + if (ReportUtils.isChatThread(report) && reportAction?.reportActionID === parentReportAction?.reportActionID) { + reportID = ReportUtils.getParentReport(report)?.reportID; + } + + if (reportAction && ReportUtils.canFlagReportAction(reportAction, reportID)) { + Report.flagComment(reportID ?? '', reportAction, severity); + } + + Navigation.dismissModal(); + }; + + const severityMenuItems = severities.map((item) => ( + flagComment(item.severity))} + style={[styles.pt2, styles.pb4, styles.ph5, styles.flexRow]} + furtherDetails={item.furtherDetails} + furtherDetailsIcon={item.furtherDetailsIcon} + /> + )); + + return ( + + {({safeAreaPaddingBottomStyle}) => ( + + { + Navigation.goBack(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report?.reportID ?? '')); + }} + /> + + + + {translate('moderation.flagDescription')} + + + {translate('moderation.chooseAReason')} + {severityMenuItems} + + + )} + + ); +} + +FlagCommentPage.displayName = 'FlagCommentPage'; + +export default withReportAndReportActionOrNotFound(FlagCommentPage); diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 71f8797f1a34..df95fc0a01b7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -272,7 +272,7 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i shouldShowReferralCTA={!dismissedReferralBanners[CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]} referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} - textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} + textInputAlert={isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} onConfirmSelection={createGroup} textInputLabel={translate('optionsSelector.nameEmailOrPhoneNumber')} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} diff --git a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx index acbc272c5ab4..66d25da28f9d 100644 --- a/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx +++ b/src/pages/OnboardEngagement/PurposeForUsingExpensifyPage.tsx @@ -76,12 +76,12 @@ function PurposeForUsingExpensifyModal() { const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); const theme = useTheme(); - const backgroundColorStyle = StyleUtils.getBackgroundColorStyle(theme.PAGE_THEMES[SCREENS.SETTINGS.WORKSPACES].backgroundColor); + const backgroundColorStyle = StyleUtils.getBackgroundColorStyle(theme.PAGE_THEMES[SCREENS.ONBOARD_ENGAGEMENT.ROOT].backgroundColor); const appBGColor = StyleUtils.getBackgroundColorStyle(theme.appBG); const navigateBack = useCallback(() => { Report.dismissEngagementModal(); - Navigation.goBack(ROUTES.HOME); + Navigation.goBack(); }, []); const completeEngagement = useCallback((message: string, choice: ValueOf) => { diff --git a/src/pages/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index d1d43f6a4108..d09b03d9007f 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -106,8 +106,8 @@ function AddressForm(props) { value={props.values.street} defaultValue={props.defaultValues.street} onInputChange={props.onFieldChange} - errorText={props.errors.street ? props.translate('bankAccount.error.addressStreet') : ''} - hint={props.translate('common.noPO')} + errorText={props.errors.street ? 'bankAccount.error.addressStreet' : ''} + hint="common.noPO" renamedInputKeys={props.inputKeys} maxInputLength={CONST.FORM_CHARACTER_LIMIT} isLimitedToUSA @@ -123,7 +123,7 @@ function AddressForm(props) { value={props.values.city} defaultValue={props.defaultValues.city} onChangeText={(value) => props.onFieldChange({city: value})} - errorText={props.errors.city ? props.translate('bankAccount.error.addressCity') : ''} + errorText={props.errors.city ? 'bankAccount.error.addressCity' : ''} containerStyles={[styles.mt4]} /> @@ -135,7 +135,7 @@ function AddressForm(props) { value={props.values.state} defaultValue={props.defaultValues.state || ''} onInputChange={(value) => props.onFieldChange({state: value})} - errorText={props.errors.state ? props.translate('bankAccount.error.addressState') : ''} + errorText={props.errors.state ? 'bankAccount.error.addressState' : ''} /> props.onFieldChange({zipCode: value})} - errorText={props.errors.zipCode ? props.translate('bankAccount.error.zipCode') : ''} + errorText={props.errors.zipCode ? 'bankAccount.error.zipCode' : ''} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} - hint={props.translate('common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples})} + hint={['common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}]} containerStyles={[styles.mt2]} /> diff --git a/src/pages/ReimbursementAccount/CompanyStep.js b/src/pages/ReimbursementAccount/CompanyStep.js index 47f81448a1a4..87537d05e3b5 100644 --- a/src/pages/ReimbursementAccount/CompanyStep.js +++ b/src/pages/ReimbursementAccount/CompanyStep.js @@ -220,7 +220,7 @@ function CompanyStep({reimbursementAccount, reimbursementAccountDraft, getDefaul containerStyles={[styles.mt4]} defaultValue={getDefaultStateForField('website', defaultWebsite)} shouldSaveDraft - hint={translate('common.websiteExample')} + hint="common.websiteExample" inputMode={CONST.INPUT_MODE.URL} /> @@ -161,7 +161,7 @@ function IdentityForm(props) { role={CONST.ROLE.PRESENTATION} value={props.values.lastName} defaultValue={props.defaultValues.lastName} - errorText={props.errors.lastName ? props.translate('bankAccount.error.lastName') : ''} + errorText={props.errors.lastName ? 'bankAccount.error.lastName' : ''} /> @@ -187,7 +187,7 @@ function IdentityForm(props) { containerStyles={[styles.mt4]} inputMode={CONST.INPUT_MODE.NUMERIC} defaultValue={props.defaultValues.ssnLast4} - errorText={props.errors.ssnLast4 ? props.translate('bankAccount.error.ssnLast4') : ''} + errorText={props.errors.ssnLast4 ? 'bankAccount.error.ssnLast4' : ''} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.SSN} /> {({didScreenTransitionEnd, safeAreaPaddingBottomStyle}) => ( <> - + !_.isEmpty(props.policy), [props.policy]); const canLeaveRoom = ReportUtils.canLeaveRoom(props.report, isPolicyMember); - const isArchivedRoom = ReportUtils.isArchivedRoom(props.report); const reportDescription = ReportUtils.getReportDescriptionText(props.report); const policyName = ReportUtils.getPolicyName(props.report); @@ -202,21 +198,6 @@ function HeaderView(props) { Link.openExternalLink(props.guideCalendarLink); }), }); - } else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) { - threeDotMenuItems.push({ - icon: ZoomIcon, - text: translate('videoChatButtonAndMenu.zoom'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); - }), - }); - threeDotMenuItems.push({ - icon: GoogleMeetIcon, - text: translate('videoChatButtonAndMenu.googleMeet'), - onSelected: Session.checkIfActionIsAllowed(() => { - Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL); - }), - }); } const shouldShowThreeDotsButton = !!threeDotMenuItems.length; diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js index bfe27910c943..8a6d7045dc5f 100644 --- a/src/pages/home/ReportScreen.js +++ b/src/pages/home/ReportScreen.js @@ -258,7 +258,7 @@ function ReportScreen({ } const reportID = getReportID(route); - const {addWorkspaceRoomOrChatPendingAction, addWorkspaceRoomOrChatErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); + const {reportPendingAction, reportErrors} = ReportUtils.getReportOfflinePendingActionAndErrors(report); const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}]; const isEmptyChat = useMemo(() => _.isEmpty(reportActions), [reportActions]); // There are no reportActions at all to display and we are still in the process of loading the next set of actions. @@ -521,8 +521,8 @@ function ReportScreen({ shouldShowLink={false} > @@ -572,7 +572,7 @@ function ReportScreen({ {isReportReadyForDisplay ? ( { const childReportNotificationPreference = ReportUtils.getChildReportNotificationPreference(reportAction); diff --git a/src/pages/home/report/ReportAttachmentsContext.js b/src/pages/home/report/ReportAttachmentsContext.tsx similarity index 52% rename from src/pages/home/report/ReportAttachmentsContext.js rename to src/pages/home/report/ReportAttachmentsContext.tsx index 5602612a6cd6..d118d4d8bee9 100644 --- a/src/pages/home/report/ReportAttachmentsContext.js +++ b/src/pages/home/report/ReportAttachmentsContext.tsx @@ -1,17 +1,20 @@ -import PropTypes from 'prop-types'; import React, {useEffect, useMemo, useRef} from 'react'; import useCurrentReportID from '@hooks/useCurrentReportID'; +import type ChildrenProps from '@src/types/utils/ChildrenProps'; -const ReportAttachmentsContext = React.createContext(); - -const propTypes = { - /** Rendered child component */ - children: PropTypes.node.isRequired, +type ReportAttachmentsContextValue = { + isAttachmentHidden: (reportActionID: string) => boolean; + updateHiddenAttachments: (reportActionID: string, isHidden: boolean) => void; }; -function ReportAttachmentsProvider(props) { +const ReportAttachmentsContext = React.createContext({ + isAttachmentHidden: () => false, + updateHiddenAttachments: () => {}, +}); + +function ReportAttachmentsProvider({children}: ChildrenProps) { const currentReportID = useCurrentReportID(); - const hiddenAttachments = useRef({}); + const hiddenAttachments = useRef>({}); useEffect(() => { // We only want to store the attachment visibility for the current report. @@ -21,8 +24,8 @@ function ReportAttachmentsProvider(props) { const contextValue = useMemo( () => ({ - isAttachmentHidden: (reportActionID) => hiddenAttachments.current[reportActionID], - updateHiddenAttachments: (reportActionID, value) => { + isAttachmentHidden: (reportActionID: string) => hiddenAttachments.current[reportActionID], + updateHiddenAttachments: (reportActionID: string, value: boolean) => { hiddenAttachments.current = { ...hiddenAttachments.current, [reportActionID]: value, @@ -32,10 +35,9 @@ function ReportAttachmentsProvider(props) { [], ); - return {props.children}; + return {children}; } -ReportAttachmentsProvider.propTypes = propTypes; ReportAttachmentsProvider.displayName = 'ReportAttachmentsProvider'; export default ReportAttachmentsContext; diff --git a/src/pages/home/report/withReportAndReportActionOrNotFound.tsx b/src/pages/home/report/withReportAndReportActionOrNotFound.tsx index ed686852158b..3fe43e96266a 100644 --- a/src/pages/home/report/withReportAndReportActionOrNotFound.tsx +++ b/src/pages/home/report/withReportAndReportActionOrNotFound.tsx @@ -1,19 +1,20 @@ /* eslint-disable rulesdir/no-negated-variables */ -import type {RouteProp} from '@react-navigation/native'; +import type {StackScreenProps} from '@react-navigation/stack'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {useCallback, useEffect} from 'react'; -import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import type {OnyxCollection, OnyxEntry, WithOnyxInstanceState} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import withWindowDimensions from '@components/withWindowDimensions'; import type {WindowDimensionsProps} from '@components/withWindowDimensions/types'; import compose from '@libs/compose'; import getComponentDisplayName from '@libs/getComponentDisplayName'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import type {FlagCommentNavigatorParamList} from '@libs/Navigation/types'; import * as ReportUtils from '@libs/ReportUtils'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Report from '@userActions/Report'; import ONYXKEYS from '@src/ONYXKEYS'; +import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -27,6 +28,9 @@ type OnyxProps = { /** Array of report actions for this report */ reportActions: OnyxEntry; + /** The report's parentReportAction */ + parentReportAction: OnyxEntry; + /** The policies which the user has access to */ policies: OnyxCollection; @@ -37,23 +41,22 @@ type OnyxProps = { isLoadingReportData: OnyxEntry; }; -type ComponentProps = OnyxProps & - WindowDimensionsProps & { - route: RouteProp<{params: {reportID: string; reportActionID: string}}>; - }; +type WithReportAndReportActionOrNotFoundProps = OnyxProps & WindowDimensionsProps & StackScreenProps; -export default function (WrappedComponent: ComponentType>): ComponentType> { +export default function ( + WrappedComponent: ComponentType>, +): ComponentType> { function WithReportOrNotFound(props: TProps, ref: ForwardedRef) { const getReportAction = useCallback(() => { let reportAction: OnyxTypes.ReportAction | Record | undefined = props.reportActions?.[`${props.route.params.reportActionID}`]; // Handle threads if needed if (!reportAction?.reportActionID) { - reportAction = ReportActionsUtils.getParentReportAction(props.report); + reportAction = props?.parentReportAction ?? {}; } return reportAction; - }, [props.report, props.reportActions, props.route.params.reportActionID]); + }, [props.reportActions, props.route.params.reportActionID, props.parentReportAction]); const reportAction = getReportAction(); @@ -78,7 +81,9 @@ export default function (WrappedComponent: } // Perform the access/not found checks - if (shouldHideReport || isEmptyObject(reportAction)) { + // Be sure to avoid showing the not-found page while the parent report actions are still being read from Onyx. The parentReportAction will be undefined while it's being read from Onyx + // and then reportAction will either be a valid parentReportAction or an empty object. In the case of an empty object, then it's OK to show the not-found page. + if (shouldHideReport || (props?.parentReportAction !== undefined && isEmptyObject(reportAction))) { return ; } @@ -114,7 +119,20 @@ export default function (WrappedComponent: key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${route.params.reportID}`, canEvict: false, }, + parentReportAction: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report ? report.parentReportID : 0}`, + selector: (parentReportActions: OnyxEntry, props: WithOnyxInstanceState): OnyxEntry => { + const parentReportActionID = props?.report?.parentReportActionID; + if (!parentReportActionID) { + return null; + } + return parentReportActions?.[parentReportActionID] ?? null; + }, + canEvict: false, + }, }), withWindowDimensions, )(React.forwardRef(WithReportOrNotFound)); } + +export type {WithReportAndReportActionOrNotFoundProps}; diff --git a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js index f4377500721c..d6c088c23e95 100644 --- a/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js +++ b/src/pages/iou/request/MoneyTemporaryForRefactorRequestParticipantsSelector.js @@ -90,7 +90,7 @@ function MoneyTemporaryForRefactorRequestParticipantsSelector({ const {isOffline} = useNetwork(); const personalDetails = usePersonalDetails(); - const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + const offlineMessage = isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''; const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.js b/src/pages/iou/request/step/IOURequestStepConfirmation.js index 207aa9ff47b1..98865539091a 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.js +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.js @@ -138,7 +138,9 @@ function IOURequestStepConfirmation({ return; } IOU.setMoneyRequestCategory_temporaryForRefactor(transactionID, defaultCategory); - }, [transactionID, transaction.category, requestType, defaultCategory]); + // Prevent resetting to default when unselect category + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [transactionID, requestType, defaultCategory]); const navigateBack = useCallback(() => { // If there is not a report attached to the IOU with a reportID, then the participants were manually selected and the user needs taken diff --git a/src/pages/iou/request/step/IOURequestStepDistance.js b/src/pages/iou/request/step/IOURequestStepDistance.js index 9549a93c8124..0c95ab1639c9 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.js +++ b/src/pages/iou/request/step/IOURequestStepDistance.js @@ -139,7 +139,7 @@ function IOURequestStepDistance({ } if (_.size(validatedWaypoints) < 2) { - return {0: translate('iou.error.atLeastTwoDifferentWaypoints')}; + return {0: 'iou.error.atLeastTwoDifferentWaypoints'}; } }; diff --git a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.native.js index 65c17d3cb7ab..049afe621167 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/NavigationAwareCamera/index.native.js @@ -18,6 +18,7 @@ const NavigationAwareCamera = React.forwardRef(({cameraTabIndex, ...props}, ref) // eslint-disable-next-line react/jsx-props-no-spreading {...props} isActive={isCameraActive} + photo={isCameraActive} /> ); }); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.js b/src/pages/iou/request/step/IOURequestStepScan/index.native.js index a1a3ed946967..8da496f342bc 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.js +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.js @@ -300,7 +300,6 @@ function IOURequestStepScan({ device={device} style={[styles.flex1]} zoom={device.neutralZoom} - photo cameraTabIndex={1} orientation="portrait" /> diff --git a/src/pages/iou/steps/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js index 8775562d4476..0722bf3f6d41 100644 --- a/src/pages/iou/steps/MoneyRequestAmountForm.js +++ b/src/pages/iou/steps/MoneyRequestAmountForm.js @@ -228,12 +228,12 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward */ const submitAndNavigateToNextPage = useCallback(() => { if (isAmountInvalid(currentAmount)) { - setFormError(translate('iou.error.invalidAmount')); + setFormError('iou.error.invalidAmount'); return; } if (isTaxAmountInvalid(currentAmount, taxAmount, isTaxAmountForm)) { - setFormError(translate('iou.error.invalidTaxAmount', {amount: formattedTaxAmount})); + setFormError(['iou.error.invalidTaxAmount', {amount: formattedTaxAmount}]); return; } @@ -243,7 +243,7 @@ function MoneyRequestAmountForm({amount, taxAmount, currency, isEditing, forward initializeAmount(backendAmount); onSubmitButtonPress({amount: currentAmount, currency}); - }, [onSubmitButtonPress, currentAmount, taxAmount, currency, isTaxAmountForm, formattedTaxAmount, translate, initializeAmount]); + }, [onSubmitButtonPress, currentAmount, taxAmount, currency, isTaxAmountForm, formattedTaxAmount, initializeAmount]); /** * Input handler to check for a forward-delete key (or keyboard shortcut) press. diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 7349b0c9fc84..7006c2703b13 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -97,7 +97,7 @@ function MoneyRequestParticipantsSelector({ const maxParticipantsReached = participants.length === CONST.REPORT.MAXIMUM_PARTICIPANTS; const setSearchTermAndSearchInServer = useSearchTermAndSearch(setSearchTerm, maxParticipantsReached); - const offlineMessage = isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''; + const offlineMessage = isOffline ? [`${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''; const newChatOptions = useMemo(() => { const chatOptions = OptionsListUtils.getFilteredOptions( diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 5b85359d5018..e0f414910d7b 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -25,6 +25,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import getTopmostSettingsCentralPaneName from '@libs/Navigation/getTopmostSettingsCentralPaneName'; import Navigation from '@libs/Navigation/Navigation'; import * as UserUtils from '@libs/UserUtils'; @@ -71,7 +72,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), }), ), @@ -327,7 +328,7 @@ function InitialSettingsPage(props) { <> diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index 7cafbe21ff6b..a9acf37ae556 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -21,6 +21,7 @@ import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeSt import compose from '@libs/compose'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import * as Session from '@userActions/Session'; import * as User from '@userActions/User'; @@ -44,7 +45,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), diff --git a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js index a4119e60d860..c85d123ad3fd 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodsPage.js @@ -16,6 +16,7 @@ import Text from '@components/Text'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -36,7 +37,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js index 8f6982e24b98..69fe8490f6aa 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.js +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.js @@ -15,6 +15,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as User from '@userActions/User'; @@ -37,7 +38,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index 453da6eb4c40..5c1fa30a88f1 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -18,6 +18,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as Session from '@userActions/Session'; import * as User from '@userActions/User'; @@ -45,7 +46,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), @@ -188,7 +189,7 @@ function BaseValidateCodeForm(props) { name="validateCode" value={validateCode} onChangeText={onTextInput} - errorText={formError.validateCode ? props.translate(formError.validateCode) : ErrorUtils.getLatestErrorMessage(props.account)} + errorText={formError.validateCode || ErrorUtils.getLatestErrorMessage(props.account)} hasError={!_.isEmpty(validateLoginError)} onFulfill={validateAndSubmitForm} autoFocus={false} diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js index 84ca74c2842f..61208447495d 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js +++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js @@ -54,21 +54,17 @@ function getSelectedStatusType(data) { } const useValidateCustomDate = (data) => { - const {translate} = useLocalize(); const [customDateError, setCustomDateError] = useState(''); const [customTimeError, setCustomTimeError] = useState(''); const validate = () => { const {dateValidationErrorKey, timeValidationErrorKey} = ValidationUtils.validateDateTimeIsAtLeastOneMinuteInFuture(data); - const dateError = dateValidationErrorKey ? translate(dateValidationErrorKey) : ''; - setCustomDateError(dateError); - - const timeError = timeValidationErrorKey ? translate(timeValidationErrorKey) : ''; - setCustomTimeError(timeError); + setCustomDateError(dateValidationErrorKey); + setCustomTimeError(timeValidationErrorKey); return { - dateError, - timeError, + dateValidationErrorKey, + timeValidationErrorKey, }; }; diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js index 6c883e7fa9d8..55d221085fcb 100644 --- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js +++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js @@ -12,7 +12,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import * as PersonalDetails from '@userActions/PersonalDetails'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /* Onyx Props */ @@ -122,7 +121,7 @@ function AddressPage({privatePersonalDetails, route}) { Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onBackButtonPress={() => Navigation.goBack()} /> {isLoadingPersonalDetails ? ( diff --git a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js index 9ca8daa69d04..42fa209e3a8c 100644 --- a/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js +++ b/src/pages/settings/Profile/PersonalDetails/DateOfBirthPage.js @@ -18,7 +18,6 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /* Onyx Props */ @@ -69,7 +68,7 @@ function DateOfBirthPage({translate, privatePersonalDetails}) { > Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onBackButtonPress={() => Navigation.goBack()} /> {isLoadingPersonalDetails ? ( diff --git a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js index 2943bcf19992..edb54534b76c 100644 --- a/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js +++ b/src/pages/settings/Profile/PersonalDetails/LegalNamePage.js @@ -20,7 +20,6 @@ import * as ValidationUtils from '@libs/ValidationUtils'; import * as PersonalDetails from '@userActions/PersonalDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; const propTypes = { /* Onyx Props */ @@ -90,7 +89,7 @@ function LegalNamePage(props) { > Navigation.goBack(ROUTES.SETTINGS_PERSONAL_DETAILS)} + onBackButtonPress={() => Navigation.goBack()} /> {isLoadingPersonalDetails ? ( diff --git a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js b/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js deleted file mode 100644 index 22b3af5be07c..000000000000 --- a/src/pages/settings/Profile/PersonalDetails/PersonalDetailsInitialPage.js +++ /dev/null @@ -1,121 +0,0 @@ -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {ScrollView, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import MenuItemGroup from '@components/MenuItemGroup'; -import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; -import {withNetwork} from '@components/OnyxProvider'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import Navigation from '@libs/Navigation/Navigation'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; - -const propTypes = { - /* Onyx Props */ - - /** User's private personal details */ - privatePersonalDetails: PropTypes.shape({ - legalFirstName: PropTypes.string, - legalLastName: PropTypes.string, - dob: PropTypes.string, - - /** User's home address */ - address: PropTypes.shape({ - street: PropTypes.string, - city: PropTypes.string, - state: PropTypes.string, - zip: PropTypes.string, - country: PropTypes.string, - }), - }), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - privatePersonalDetails: { - legalFirstName: '', - legalLastName: '', - dob: '', - address: { - street: '', - street2: '', - city: '', - state: '', - zip: '', - country: '', - }, - }, -}; - -function PersonalDetailsInitialPage(props) { - const styles = useThemeStyles(); - usePrivatePersonalDetails(); - const privateDetails = props.privatePersonalDetails || {}; - const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim(); - const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true); - - return ( - - Navigation.goBack()} - /> - {isLoadingPersonalDetails ? ( - - ) : ( - - - - {props.translate('privatePersonalDetails.privateDataMessage')} - - - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_LEGAL_NAME)} - /> - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_DATE_OF_BIRTH)} - titleStyle={[styles.flex1]} - /> - Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS)} - /> - - - - )} - - ); -} - -PersonalDetailsInitialPage.propTypes = propTypes; -PersonalDetailsInitialPage.defaultProps = defaultProps; -PersonalDetailsInitialPage.displayName = 'PersonalDetailsInitialPage'; - -export default compose( - withLocalize, - withOnyx({ - privatePersonalDetails: { - key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, - }, - }), - withNetwork(), -)(PersonalDetailsInitialPage); diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js index 204fe3452db0..966fef449da4 100755 --- a/src/pages/settings/Profile/ProfilePage.js +++ b/src/pages/settings/Profile/ProfilePage.js @@ -4,20 +4,25 @@ import React, {useEffect} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; -import MenuItem from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; +import Section from '@components/Section'; import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps, withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; +import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import compose from '@libs/compose'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserUtils from '@libs/UserUtils'; -import userPropTypes from '@pages/settings/userPropTypes'; import * as App from '@userActions/App'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -33,11 +38,25 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), }), ), - user: userPropTypes, + /** User's private personal details */ + privatePersonalDetails: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + dob: PropTypes.string, + + /** User's home address */ + address: PropTypes.shape({ + street: PropTypes.string, + city: PropTypes.string, + state: PropTypes.string, + zip: PropTypes.string, + country: PropTypes.string, + }), + }), ...withLocalizePropTypes, ...windowDimensionsPropTypes, @@ -46,12 +65,26 @@ const propTypes = { const defaultProps = { loginList: {}, - user: {}, ...withCurrentUserPersonalDetailsDefaultProps, + privatePersonalDetails: { + legalFirstName: '', + legalLastName: '', + dob: '', + address: { + street: '', + street2: '', + city: '', + state: '', + zip: '', + country: '', + }, + }, }; function ProfilePage(props) { + const theme = useTheme(); const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); const getPronouns = () => { let pronounsKey = lodashGet(props.currentUserPersonalDetails, 'pronouns', ''); if (pronounsKey.startsWith(CONST.PRONOUNS.PREFIX)) { @@ -66,8 +99,13 @@ function ProfilePage(props) { const currentUserDetails = props.currentUserPersonalDetails || {}; const contactMethodBrickRoadIndicator = UserUtils.getLoginListBrickRoadIndicator(props.loginList); const emojiCode = lodashGet(props, 'currentUserPersonalDetails.status.emojiCode', ''); + const {isSmallScreenWidth} = useWindowDimensions(); + usePrivatePersonalDetails(); + const privateDetails = props.privatePersonalDetails || {}; + const legalName = `${privateDetails.legalFirstName || ''} ${privateDetails.legalLastName || ''}`.trim(); + const isLoadingPersonalDetails = lodashGet(props.privatePersonalDetails, 'isLoading', true); - const profileSettingsOptions = [ + const publicOptions = [ { description: props.translate('displayNamePage.headerTitle'), title: lodashGet(currentUserDetails, 'displayName', ''), @@ -79,13 +117,11 @@ function ProfilePage(props) { pageRoute: ROUTES.SETTINGS_CONTACT_METHODS.route, brickRoadIndicator: contactMethodBrickRoadIndicator, }, - ...[ - { - description: props.translate('statusPage.status'), - title: emojiCode ? `${emojiCode} ${lodashGet(props, 'currentUserPersonalDetails.status.text', '')}` : '', - pageRoute: ROUTES.SETTINGS_STATUS, - }, - ], + { + description: props.translate('statusPage.status'), + title: emojiCode ? `${emojiCode} ${lodashGet(props, 'currentUserPersonalDetails.status.text', '')}` : '', + pageRoute: ROUTES.SETTINGS_STATUS, + }, { description: props.translate('pronounsPage.pronouns'), title: getPronouns(), @@ -102,6 +138,24 @@ function ProfilePage(props) { App.openProfile(props.currentUserPersonalDetails); }, [props.currentUserPersonalDetails]); + const privateOptions = [ + { + description: props.translate('privatePersonalDetails.legalName'), + title: legalName, + pageRoute: ROUTES.SETTINGS_LEGAL_NAME, + }, + { + description: props.translate('common.dob'), + title: privateDetails.dob || '', + pageRoute: ROUTES.SETTINGS_DATE_OF_BIRTH, + }, + { + description: props.translate('privatePersonalDetails.address'), + title: PersonalDetailsUtils.getFormattedAddress(props.privatePersonalDetails), + pageRoute: ROUTES.SETTINGS_ADDRESS, + }, + ]; + return ( - - {_.map(profileSettingsOptions, (detail, index) => ( - Navigation.navigate(detail.pageRoute)} - brickRoadIndicator={detail.brickRoadIndicator} - /> - ))} + +
+ {_.map(publicOptions, (detail, index) => ( + Navigation.navigate(detail.pageRoute)} + brickRoadIndicator={detail.brickRoadIndicator} + /> + ))} +
+
+ {isLoadingPersonalDetails ? ( + + ) : ( + <> + {_.map(privateOptions, (detail, index) => ( + Navigation.navigate(detail.pageRoute)} + /> + ))} + + )} +
- Navigation.navigate(ROUTES.SETTINGS_PERSONAL_DETAILS)} - shouldShowRightIcon - /> - {props.user.hasLoungeAccess && ( - Navigation.navigate(ROUTES.SETTINGS_LOUNGE_ACCESS)} - shouldShowRightIcon - /> - )}
); @@ -160,6 +233,9 @@ export default compose( loginList: { key: ONYXKEYS.LOGIN_LIST, }, + privatePersonalDetails: { + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + }, user: { key: ONYXKEYS.USER, }, diff --git a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js index 420d976dcd26..aafa144e769f 100644 --- a/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js +++ b/src/pages/settings/Security/TwoFactorAuth/Steps/CodesStep.js @@ -115,7 +115,7 @@ function CodesStep({account = defaultAccount, backTo}) { {!_.isEmpty(error) && ( )} diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js index 901c0aa1cffd..f65f7368de76 100644 --- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js +++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js @@ -93,7 +93,7 @@ function BaseTwoFactorAuthForm(props) { value={twoFactorAuthCode} onChangeText={onTextInput} onFulfill={validateAndSubmitForm} - errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ErrorUtils.getLatestErrorMessage(props.account)} + errorText={formError.twoFactorAuthCode || ErrorUtils.getLatestErrorMessage(props.account)} ref={inputRef} autoFocus={false} /> diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js index 649e42bfffbe..24156a7c74fc 100644 --- a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js +++ b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js @@ -119,12 +119,12 @@ function ActivatePhysicalCardPage({ activateCardCodeInputRef.current.blur(); if (lastFourDigits.replace(CONST.MAGIC_CODE_EMPTY_CHAR, '').length !== LAST_FOUR_DIGITS_LENGTH) { - setFormError(translate('activateCardPage.error.thatDidntMatch')); + setFormError('activateCardPage.error.thatDidntMatch'); return; } CardSettings.activatePhysicalExpensifyCard(lastFourDigits, cardID); - }, [lastFourDigits, cardID, translate]); + }, [lastFourDigits, cardID]); if (_.isEmpty(physicalCard)) { return ; diff --git a/src/pages/settings/Wallet/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js index 0c5cef489517..5cdbbd41904b 100644 --- a/src/pages/settings/Wallet/AddDebitCardPage.js +++ b/src/pages/settings/Wallet/AddDebitCardPage.js @@ -178,7 +178,7 @@ function DebitCardPage(props) { role={CONST.ROLE.PRESENTATION} inputMode={CONST.INPUT_MODE.NUMERIC} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} - hint={translate('common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples})} + hint={['common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}]} containerStyles={[styles.mt4]} /> diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js index ade598608f50..6688a0a69fa9 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js @@ -12,6 +12,7 @@ import * as Wallet from '@libs/actions/Wallet'; import * as CardUtils from '@libs/CardUtils'; import FormUtils from '@libs/FormUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import assignedCardPropTypes from '@pages/settings/Wallet/assignedCardPropTypes'; import CONST from '@src/CONST'; @@ -69,7 +70,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js index cacf35db22a6..755790dfec81 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.js +++ b/src/pages/settings/Wallet/ExpensifyCardPage.js @@ -18,6 +18,7 @@ import * as CardUtils from '@libs/CardUtils'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import FormUtils from '@libs/FormUtils'; import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; import * as Card from '@userActions/Card'; @@ -56,7 +57,7 @@ const propTypes = { validatedDate: PropTypes.string, /** Field-specific server side errors keyed by microtime */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Field-specific pending states for offline UI status */ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), @@ -192,7 +193,7 @@ function ExpensifyCardPage({ ) : null} diff --git a/src/pages/settings/Wallet/ReportCardLostPage.js b/src/pages/settings/Wallet/ReportCardLostPage.js index 49b69188c377..b78c0b6bdc21 100644 --- a/src/pages/settings/Wallet/ReportCardLostPage.js +++ b/src/pages/settings/Wallet/ReportCardLostPage.js @@ -194,7 +194,7 @@ function ReportCardLostPage({ @@ -212,7 +212,7 @@ function ReportCardLostPage({ diff --git a/src/pages/settings/Wallet/TransferBalancePage.js b/src/pages/settings/Wallet/TransferBalancePage.js index 8128395965b7..5c012fdac92e 100644 --- a/src/pages/settings/Wallet/TransferBalancePage.js +++ b/src/pages/settings/Wallet/TransferBalancePage.js @@ -18,6 +18,7 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; +import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import * as PaymentUtils from '@libs/PaymentUtils'; import userWalletPropTypes from '@pages/EnablePayments/userWalletPropTypes'; @@ -166,7 +167,7 @@ function TransferBalancePage(props) { const transferAmount = props.userWallet.currentBalance - calculatedFee; const isTransferable = transferAmount > 0; const isButtonDisabled = !isTransferable || !selectedAccount; - const errorMessage = !_.isEmpty(props.walletTransfer.errors) ? _.chain(props.walletTransfer.errors).values().first().value() : ''; + const errorMessage = ErrorUtils.getLatestErrorMessage(props.walletTransfer); const shouldShowTransferView = PaymentUtils.hasExpensifyPaymentMethod(paymentCardList, props.bankAccountList) && @@ -219,6 +220,7 @@ function TransferBalancePage(props) { title={selectedAccount.title} description={selectedAccount.description} shouldShowRightIcon + iconStyles={selectedAccount.iconStyles} iconWidth={selectedAccount.iconSize} iconHeight={selectedAccount.iconSize} icon={selectedAccount.icon} diff --git a/src/pages/signin/LoginForm/BaseLoginForm.js b/src/pages/signin/LoginForm/BaseLoginForm.js index 6cbef7da7f3f..a4221e8834de 100644 --- a/src/pages/signin/LoginForm/BaseLoginForm.js +++ b/src/pages/signin/LoginForm/BaseLoginForm.js @@ -259,9 +259,8 @@ function LoginForm(props) { }, })); - const formErrorText = useMemo(() => (formError ? translate(formError) : ''), [formError, translate]); const serverErrorText = useMemo(() => ErrorUtils.getLatestErrorMessage(props.account), [props.account]); - const shouldShowServerError = !_.isEmpty(serverErrorText) && _.isEmpty(formErrorText); + const shouldShowServerError = !_.isEmpty(serverErrorText) && _.isEmpty(formError); return ( <> @@ -302,18 +301,17 @@ function LoginForm(props) { autoCapitalize="none" autoCorrect={false} inputMode={CONST.INPUT_MODE.EMAIL} - errorText={formErrorText} + errorText={formError || ''} hasError={shouldShowServerError} maxLength={CONST.LOGIN_CHARACTER_LIMIT} /> {!_.isEmpty(props.account.success) && {props.account.success}} {!_.isEmpty(props.closeAccount.success || props.account.message) && ( - // DotIndicatorMessage mostly expects onyxData errors, so we need to mock an object so that the messages looks similar to prop.account.errors )} { diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js index 5465a3189818..c5fab7ccc8a9 100644 --- a/src/pages/signin/SignInPageLayout/index.js +++ b/src/pages/signin/SignInPageLayout/index.js @@ -98,6 +98,8 @@ function SignInPageLayout(props) { const scrollViewStyles = useMemo(() => scrollViewContentContainerStyles(styles), [styles]); + const backgroundImageHeight = Math.max(variables.signInContentMinHeight, containerHeight); + return ( {!props.shouldShowSmallScreen ? ( @@ -163,13 +165,15 @@ function SignInPageLayout(props) { keyboardShouldPersistTaps="handled" ref={scrollViewRef} > - - + + + + {props.translate('unlinkLoginForm.noLongerHaveAccess', {primaryLogin})} {!_.isEmpty(props.account.message) && ( - // DotIndicatorMessage mostly expects onyxData errors so we need to mock an object so that the messages looks similar to prop.account.errors )} {!_.isEmpty(props.account.errors) && ( )} diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index fd5e9b952612..4afba77b77b5 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -326,7 +326,7 @@ function BaseValidateCodeForm(props) { onChangeText={(text) => onTextInput(text, 'recoveryCode')} maxLength={CONST.RECOVERY_CODE_LENGTH} label={props.translate('recoveryCodeForm.recoveryCode')} - errorText={formError.recoveryCode ? props.translate(formError.recoveryCode) : ''} + errorText={formError.recoveryCode || ''} hasError={hasError} onSubmitEditing={validateAndSubmitForm} autoFocus @@ -342,7 +342,7 @@ function BaseValidateCodeForm(props) { onChangeText={(text) => onTextInput(text, 'twoFactorAuthCode')} onFulfill={validateAndSubmitForm} maxLength={CONST.TFA_CODE_LENGTH} - errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''} + errorText={formError.twoFactorAuthCode || ''} hasError={hasError} autoFocus key="twoFactorAuthCode" @@ -372,7 +372,7 @@ function BaseValidateCodeForm(props) { value={validateCode} onChangeText={(text) => onTextInput(text, 'validateCode')} onFulfill={validateAndSubmitForm} - errorText={formError.validateCode ? props.translate(formError.validateCode) : ''} + errorText={formError.validateCode || ''} hasError={hasError} autoFocus key="validateCode" diff --git a/src/pages/tasks/NewTaskPage.js b/src/pages/tasks/NewTaskPage.js index 1c4c3f58b0a1..bf54d02f778f 100644 --- a/src/pages/tasks/NewTaskPage.js +++ b/src/pages/tasks/NewTaskPage.js @@ -110,17 +110,17 @@ function NewTaskPage(props) { // the response function onSubmit() { if (!props.task.title && !props.task.shareDestination) { - setErrorMessage(props.translate('newTaskPage.confirmError')); + setErrorMessage('newTaskPage.confirmError'); return; } if (!props.task.title) { - setErrorMessage(props.translate('newTaskPage.pleaseEnterTaskName')); + setErrorMessage('newTaskPage.pleaseEnterTaskName'); return; } if (!props.task.shareDestination) { - setErrorMessage(props.translate('newTaskPage.pleaseEnterTaskDestination')); + setErrorMessage('newTaskPage.pleaseEnterTaskDestination'); return; } diff --git a/src/pages/tasks/TaskShareDestinationSelectorModal.js b/src/pages/tasks/TaskShareDestinationSelectorModal.js index 64fd5f50b61f..b8d9229e6158 100644 --- a/src/pages/tasks/TaskShareDestinationSelectorModal.js +++ b/src/pages/tasks/TaskShareDestinationSelectorModal.js @@ -145,7 +145,7 @@ function TaskShareDestinationSelectorModal(props) { showTitleTooltip shouldShowOptions={didScreenTransitionEnd} textInputLabel={props.translate('optionsSelector.nameEmailOrPhoneNumber')} - textInputAlert={isOffline ? `${props.translate('common.youAppearToBeOffline')} ${props.translate('search.resultsAreLimited')}` : ''} + textInputAlert={isOffline ? [`${props.translate('common.youAppearToBeOffline')} ${props.translate('search.resultsAreLimited')}`, {isTranslated: true}] : ''} safeAreaPaddingBottomStyle={safeAreaPaddingBottomStyle} autoFocus={false} ref={inputCallbackRef} diff --git a/src/pages/workspace/WorkspaceInitialPage.js b/src/pages/workspace/WorkspaceInitialPage.tsx similarity index 64% rename from src/pages/workspace/WorkspaceInitialPage.js rename to src/pages/workspace/WorkspaceInitialPage.tsx index 7cd4fcb536b6..ea243c56ac76 100644 --- a/src/pages/workspace/WorkspaceInitialPage.js +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,8 +1,9 @@ -import lodashGet from 'lodash/get'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import type {StackScreenProps} from '@react-navigation/stack'; +import React, {useCallback, useEffect, useState} from 'react'; import {ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import Breadcrumbs from '@components/Breadcrumbs'; import ConfirmModal from '@components/ConfirmModal'; @@ -16,152 +17,153 @@ import usePrevious from '@hooks/usePrevious'; import useSingleExecution from '@hooks/useSingleExecution'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; -import compose from '@libs/compose'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; -import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import type {BottomTabNavigatorParamList} from '@navigation/types'; import * as App from '@userActions/App'; import * as Policy from '@userActions/Policy'; import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import {policyDefaultProps, policyPropTypes} from './withPolicy'; +import type * as OnyxTypes from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import type IconAsset from '@src/types/utils/IconAsset'; +import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from './withPolicyAndFullscreenLoading'; -const propTypes = { - ...policyPropTypes, +type WorkspaceMenuItem = { + translationKey: TranslationPaths; + icon: IconAsset; + action: () => void; + brickRoadIndicator?: ValueOf; + routeName?: ValueOf; +}; +type WorkspaceInitialPageOnyxProps = { /** Bank account attached to free plan */ - reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes, + reimbursementAccount: OnyxEntry; }; -const defaultProps = { - ...policyDefaultProps, - reimbursementAccount: {}, -}; +type WorkspaceInitialPageProps = WithPolicyAndFullscreenLoadingProps & WorkspaceInitialPageOnyxProps & StackScreenProps; -/** - * @param {string} policyID - */ -function dismissError(policyID) { +function dismissError(policyID: string) { Navigation.goBack(ROUTES.SETTINGS_WORKSPACES); Policy.removeWorkspace(policyID); } -function WorkspaceInitialPage(props) { +function WorkspaceInitialPage({policyDraft, policy: policyProp, policyMembers, reimbursementAccount}: WorkspaceInitialPageProps) { const styles = useThemeStyles(); - const policy = props.policyDraft && props.policyDraft.id ? props.policyDraft : props.policy; + const policy = policyDraft?.id ? policyDraft : policyProp; const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false); - const hasPolicyCreationError = Boolean(policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.errors); + const hasPolicyCreationError = !!(policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && policy.errors); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); const activeRoute = useActiveRoute(); const {translate} = useLocalize(); - const policyID = useMemo(() => policy.id, [policy]); + const policyID = policy?.id ?? ''; + const policyName = policy?.name ?? ''; useEffect(() => { - const policyDraftId = lodashGet(props.policyDraft, 'id', null); + const policyDraftId = policyDraft?.id; + if (!policyDraftId) { return; } - App.savePolicyDraftByNewWorkspace(props.policyDraft.id, props.policyDraft.name, '', props.policyDraft.makeMeAdmin); + App.savePolicyDraftByNewWorkspace(policyDraft.id, policyDraft.name, '', policyDraft.makeMeAdmin); // We only care when the component renders the first time // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { - if (!isCurrencyModalOpen || policy.outputCurrency !== CONST.CURRENCY.USD) { + if (!isCurrencyModalOpen || policy?.outputCurrency !== CONST.CURRENCY.USD) { return; } setIsCurrencyModalOpen(false); - }, [policy.outputCurrency, isCurrencyModalOpen]); + }, [policy?.outputCurrency, isCurrencyModalOpen]); - /** - * Call update workspace currency and hide the modal - */ + /** Call update workspace currency and hide the modal */ const confirmCurrencyChangeAndHideModal = useCallback(() => { - Policy.updateGeneralSettings(policyID, policy.name, CONST.CURRENCY.USD); + Policy.updateGeneralSettings(policyID, policyName, CONST.CURRENCY.USD); setIsCurrencyModalOpen(false); ReimbursementAccount.navigateToBankAccountRoute(policyID); - }, [policyID, policy.name]); + }, [policyID, policyName]); - const policyName = lodashGet(policy, 'name', ''); - const hasMembersError = PolicyUtils.hasPolicyMemberError(props.policyMembers); - const hasGeneralSettingsError = !_.isEmpty(lodashGet(policy, 'errorFields.generalSettings', {})) || !_.isEmpty(lodashGet(policy, 'errorFields.avatar', {})); - const hasCustomUnitsError = PolicyUtils.hasCustomUnitsError(policy); + const hasMembersError = PolicyUtils.hasPolicyMemberError(policyMembers); + const hasGeneralSettingsError = !isEmptyObject(policy?.errorFields?.generalSettings ?? {}) || !isEmptyObject(policy?.errorFields?.avatar ?? {}); const shouldShowProtectedItems = PolicyUtils.isPolicyAdmin(policy); - const protectedMenuItems = [ + const protectedMenuItems: WorkspaceMenuItem[] = [ { translationKey: 'workspace.common.card', icon: Expensicons.ExpensifyCard, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policy.id)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_CARD.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.CARD, }, { translationKey: 'workspace.common.reimburse', icon: Expensicons.Receipt, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policy.id)))), - error: hasCustomUnitsError, + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_REIMBURSE.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.REIMBURSE, }, { translationKey: 'workspace.common.bills', icon: Expensicons.Bill, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policy.id)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_BILLS.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.BILLS, }, { translationKey: 'workspace.common.invoices', icon: Expensicons.Invoice, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policy.id)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.INVOICES, }, { translationKey: 'workspace.common.travel', icon: Expensicons.Luggage, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policy.id)))), + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_TRAVEL.getRoute(policyID)))), routeName: SCREENS.WORKSPACE.TRAVEL, }, { translationKey: 'workspace.common.members', icon: Expensicons.Users, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policy.id)))), - brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_MEMBERS.getRoute(policyID)))), + brickRoadIndicator: hasMembersError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, routeName: SCREENS.WORKSPACE.MEMBERS, }, { translationKey: 'workspace.common.bankAccount', icon: Expensicons.Bank, action: () => - policy.outputCurrency === CONST.CURRENCY.USD - ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policy.id, Navigation.getActiveRouteWithoutParams())))() + policy?.outputCurrency === CONST.CURRENCY.USD + ? singleExecution(waitForNavigate(() => ReimbursementAccount.navigateToBankAccountRoute(policyID, Navigation.getActiveRouteWithoutParams())))() : setIsCurrencyModalOpen(true), - brickRoadIndicator: !_.isEmpty(props.reimbursementAccount.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + brickRoadIndicator: !isEmptyObject(reimbursementAccount?.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, }, ]; - const menuItems = [ + const menuItems: WorkspaceMenuItem[] = [ { translationKey: 'workspace.common.overview', icon: Expensicons.Home, - action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policy.id)))), - brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : '', + action: singleExecution(waitForNavigate(() => Navigation.navigate(ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID)))), + brickRoadIndicator: hasGeneralSettingsError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined, routeName: SCREENS.WORKSPACE.OVERVIEW, }, - ].concat(shouldShowProtectedItems ? protectedMenuItems : []); + ...(shouldShowProtectedItems ? protectedMenuItems : []), + ]; const prevPolicy = usePrevious(policy); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = - _.isEmpty(policy) || + isEmptyObject(policy) || // We check isPendingDelete for both policy and prevPolicy to prevent the NotFound view from showing right after we delete the workspace (PolicyUtils.isPendingDeletePolicy(policy) && PolicyUtils.isPendingDeletePolicy(prevPolicy)); @@ -175,7 +177,7 @@ function WorkspaceInitialPage(props) { Navigation.goBack(ROUTES.SETTINGS_WORKSPACES)} shouldShow={shouldShowNotFoundPage} - subtitleKey={_.isEmpty(policy) ? undefined : 'workspace.common.notAuthorized'} + subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'} > dismissError(policy.id)} - errors={policy.errors} + pendingAction={policy?.pendingAction} + onClose={() => dismissError(policyID)} + errors={policy?.errors} errorRowStyles={[styles.ph5, styles.pv2]} > {/* - Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. - In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. - */} - {_.map(menuItems, (item) => ( + Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. + In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. + */} + {menuItems.map((item) => ( @@ -234,15 +236,12 @@ function WorkspaceInitialPage(props) { ); } -WorkspaceInitialPage.propTypes = propTypes; -WorkspaceInitialPage.defaultProps = defaultProps; WorkspaceInitialPage.displayName = 'WorkspaceInitialPage'; -export default compose( - withPolicyAndFullscreenLoading, - withOnyx({ +export default withPolicyAndFullscreenLoading( + withOnyx({ reimbursementAccount: { key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, }, - }), -)(WorkspaceInitialPage); + })(WorkspaceInitialPage), +); diff --git a/src/pages/workspace/WorkspaceInvitePage.js b/src/pages/workspace/WorkspaceInvitePage.js index 4bf2bf3a8472..8fcf425d8f34 100644 --- a/src/pages/workspace/WorkspaceInvitePage.js +++ b/src/pages/workspace/WorkspaceInvitePage.js @@ -325,7 +325,7 @@ function WorkspaceInvitePage(props) { isAlertVisible={shouldShowAlertPrompt} buttonText={translate('common.next')} onSubmit={inviteUser} - message={props.policy.alertMessage} + message={[props.policy.alertMessage, {isTranslated: true}]} containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.mb5]} enabledWhenOffline disablePressOnEnter diff --git a/src/pages/workspace/WorkspaceMembersPage.js b/src/pages/workspace/WorkspaceMembersPage.js index cdcf15f8b0e2..c24ea1af4a5e 100644 --- a/src/pages/workspace/WorkspaceMembersPage.js +++ b/src/pages/workspace/WorkspaceMembersPage.js @@ -408,7 +408,7 @@ function WorkspaceMembersPage(props) { return ( Policy.dismissAddedWithPrimaryLoginMessages(policyID)} /> diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js index 1c6981b9936a..b8676faf0510 100644 --- a/src/pages/workspace/WorkspaceNewRoomPage.js +++ b/src/pages/workspace/WorkspaceNewRoomPage.js @@ -23,6 +23,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import {translatableTextPropTypes} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportUtils from '@libs/ReportUtils'; @@ -69,7 +70,7 @@ const propTypes = { isLoading: PropTypes.bool, /** Field errors in the form */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), }), /** Session details for the user */ diff --git a/src/pages/workspace/WorkspaceOverviewPage.js b/src/pages/workspace/WorkspaceOverviewPage.js index dd3945136c47..4e18d09c9137 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.js +++ b/src/pages/workspace/WorkspaceOverviewPage.js @@ -92,7 +92,7 @@ function WorkspaceOverviewPage({policy, currencyList, route}) { )} type={CONST.ICON_TYPE_WORKSPACE} fallbackIcon={Expensicons.FallbackWorkspaceAvatar} - style={[styles.mb3, styles.mt5]} + style={[styles.mb3, styles.mt5, styles.mh5]} isUsingDefaultAvatar={!lodashGet(policy, 'avatar', null)} onImageSelected={(file) => Policy.updateWorkspaceAvatar(lodashGet(policy, 'id', ''), file)} onImageRemoved={() => Policy.deleteWorkspaceAvatar(lodashGet(policy, 'id', ''))} diff --git a/src/pages/workspace/reimburse/WorkspaceReimburseView.js b/src/pages/workspace/reimburse/WorkspaceReimburseView.js index 039d8e036ef8..8749ea53bfc5 100644 --- a/src/pages/workspace/reimburse/WorkspaceReimburseView.js +++ b/src/pages/workspace/reimburse/WorkspaceReimburseView.js @@ -151,7 +151,7 @@ function WorkspaceReimburseView(props) { description={translate('workspace.reimburse.trackDistanceRate')} shouldShowRightIcon onPress={() => Navigation.navigate(ROUTES.WORKSPACE_RATE_AND_UNIT.getRoute(props.policy.id))} - wrapperStyle={[styles.mt3, styles.ph8, styles.mhn8, styles.wAuto]} + wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3]} brickRoadIndicator={(lodashGet(distanceCustomUnit, 'errors') || lodashGet(distanceCustomRate, 'errors')) && CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR} /> diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index 8764412c87ad..d4397d3d5d1c 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -6,6 +6,7 @@ import React, {forwardRef} from 'react'; import type {OnyxEntry} from 'react-native-onyx'; import {withOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import {translatableTextPropTypes} from '@libs/Localize'; import type {BottomTabNavigatorParamList, CentralPaneNavigatorParamList, SettingsNavigatorParamList} from '@navigation/types'; import policyMemberPropType from '@pages/policyMemberPropType'; import * as Policy from '@userActions/Policy'; @@ -57,7 +58,7 @@ const policyPropTypes = { * } * } */ - errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), + errorFields: PropTypes.objectOf(PropTypes.objectOf(translatableTextPropTypes)), /** Whether or not the policy requires tags */ requiresTag: PropTypes.bool, diff --git a/src/setup/index.js b/src/setup/index.ts similarity index 100% rename from src/setup/index.js rename to src/setup/index.ts diff --git a/src/setup/platformSetup/index.desktop.js b/src/setup/platformSetup/index.desktop.ts similarity index 100% rename from src/setup/platformSetup/index.desktop.js rename to src/setup/platformSetup/index.desktop.ts diff --git a/src/setup/platformSetup/index.native.js b/src/setup/platformSetup/index.native.ts similarity index 100% rename from src/setup/platformSetup/index.native.js rename to src/setup/platformSetup/index.native.ts diff --git a/src/setup/platformSetup/index.ts b/src/setup/platformSetup/index.ts new file mode 100644 index 000000000000..2d1ec238274a --- /dev/null +++ b/src/setup/platformSetup/index.ts @@ -0,0 +1 @@ +export default () => {}; diff --git a/src/setup/platformSetup/index.website.js b/src/setup/platformSetup/index.website.ts similarity index 91% rename from src/setup/platformSetup/index.website.js rename to src/setup/platformSetup/index.website.ts index bdc64e769e09..0fef333e6508 100644 --- a/src/setup/platformSetup/index.website.js +++ b/src/setup/platformSetup/index.website.ts @@ -6,6 +6,7 @@ import DateUtils from '@libs/DateUtils'; import Visibility from '@libs/Visibility'; import Config from '@src/CONFIG'; import pkg from '../../../package.json'; +import type PlatformSpecificUpdater from './types'; /** * Download the latest app version from the server, and if it is different than the current one, @@ -21,23 +22,21 @@ function webUpdate() { if (!Visibility.isVisible()) { // Page is hidden, refresh immediately - window.location.reload(true); + window.location.reload(); return; } // Prompt user to refresh the page if (window.confirm('Refresh the page to get the latest updates!')) { - window.location.reload(true); + window.location.reload(); } }); } /** * Create an object whose shape reflects the callbacks used in checkForUpdates. - * - * @returns {Object} */ -const webUpdater = () => ({ +const webUpdater = (): PlatformSpecificUpdater => ({ init: () => { // We want to check for updates and refresh the page if necessary when the app is backgrounded. // That way, it will auto-update silently when they minimize the page, diff --git a/src/setup/platformSetup/types.ts b/src/setup/platformSetup/types.ts new file mode 100644 index 000000000000..1deb4c374153 --- /dev/null +++ b/src/setup/platformSetup/types.ts @@ -0,0 +1,6 @@ +type PlatformSpecificUpdater = { + update: () => void; + init?: () => void; +}; + +export default PlatformSpecificUpdater; diff --git a/src/stories/Form.stories.js b/src/stories/Form.stories.js index 6a4274c87eda..8a152d040a1f 100644 --- a/src/stories/Form.stories.js +++ b/src/stories/Form.stories.js @@ -68,7 +68,7 @@ function Template(args) { label="Street" inputID="street" containerStyles={[defaultStyles.mt4]} - hint="No PO box" + hint="common.noPO" /> color: theme.textSupporting, }, + accountSettingsSectionTitle: { + fontFamily: FontUtils.fontFamily.platform.EXP_NEUE_BOLD, + fontWeight: FontUtils.fontWeight.bold, + }, + sectionMenuItem: { borderRadius: 8, paddingHorizontal: 8, @@ -2603,6 +2608,12 @@ const styles = (theme: ThemeColors) => alignItems: 'center', }, + sectionMenuItemTopDescription: { + ...spacing.ph8, + ...spacing.mhn8, + width: 'auto', + }, + selectCircle: { width: variables.componentSizeSmall, height: variables.componentSizeSmall, @@ -3377,7 +3388,8 @@ const styles = (theme: ThemeColors) => }, cardSectionTitle: { - lineHeight: variables.lineHeightXXLarge, + fontSize: variables.fontSizeLarge, + lineHeight: variables.lineHeightXLarge, }, cardMenuItem: { diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index ea61a1b98ddf..8cb1566ec274 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -144,6 +144,10 @@ const darkTheme = { backgroundColor: colors.productDark200, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: { + backgroundColor: colors.pink800, + statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, + }, [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: { backgroundColor: colors.green600, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 7317dc836b2d..62c788abbfb1 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -144,6 +144,10 @@ const lightTheme = { backgroundColor: colors.productDark200, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, }, + [SCREENS.ONBOARD_ENGAGEMENT.ROOT]: { + backgroundColor: colors.pink800, + statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, + }, [SCREENS.ONBOARD_ENGAGEMENT.EXPENSIFY_CLASSIC]: { backgroundColor: colors.green600, statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT, diff --git a/src/styles/utils/addOutlineWidth/types.ts b/src/styles/utils/addOutlineWidth/types.ts index 45975b72dc8a..d3a2538cd1b2 100644 --- a/src/styles/utils/addOutlineWidth/types.ts +++ b/src/styles/utils/addOutlineWidth/types.ts @@ -1,6 +1,6 @@ -import type {TextStyle} from 'react-native'; +import type {TextStyle, ViewStyle} from 'react-native'; import type {ThemeColors} from '@styles/theme/types'; -type AddOutlineWidth = (theme: ThemeColors, obj: TextStyle, val?: number, hasError?: boolean) => TextStyle; +type AddOutlineWidth = (theme: ThemeColors, obj: TStyle, val?: number, hasError?: boolean) => TStyle; export default AddOutlineWidth; diff --git a/src/styles/utils/getSignInBgStyles/index.ios.ts b/src/styles/utils/getSignInBgStyles/index.ios.ts new file mode 100644 index 000000000000..84c50cfa42bd --- /dev/null +++ b/src/styles/utils/getSignInBgStyles/index.ios.ts @@ -0,0 +1,7 @@ +import type GetSignInBgStyles from './types'; + +const getSignInBgStyles: GetSignInBgStyles = (theme) => ({ + backgroundColor: theme.signInPage, +}); + +export default getSignInBgStyles; diff --git a/src/styles/utils/getSignInBgStyles/index.ts b/src/styles/utils/getSignInBgStyles/index.ts new file mode 100644 index 000000000000..10c2e5783207 --- /dev/null +++ b/src/styles/utils/getSignInBgStyles/index.ts @@ -0,0 +1,5 @@ +import type GetSignInBgStyles from './types'; + +const getSignInBgStyles: GetSignInBgStyles = () => ({}); + +export default getSignInBgStyles; diff --git a/src/styles/utils/getSignInBgStyles/types.ts b/src/styles/utils/getSignInBgStyles/types.ts new file mode 100644 index 000000000000..4d3ddbe6153d --- /dev/null +++ b/src/styles/utils/getSignInBgStyles/types.ts @@ -0,0 +1,6 @@ +import type {ViewStyle} from 'react-native'; +import type {ThemeColors} from '@styles/theme/types'; + +type GetSignInBgStyles = (theme: ThemeColors) => ViewStyle; + +export default GetSignInBgStyles; diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts index a45b7cdbcb34..d4d2b427cbe8 100644 --- a/src/styles/utils/index.ts +++ b/src/styles/utils/index.ts @@ -22,6 +22,7 @@ import createReportActionContextMenuStyleUtils from './generators/ReportActionCo import createTooltipStyleUtils from './generators/TooltipStyleUtils'; import getContextMenuItemStyles from './getContextMenuItemStyles'; import getNavigationModalCardStyle from './getNavigationModalCardStyles'; +import getSignInBgStyles from './getSignInBgStyles'; import {compactContentContainerStyles} from './optionRowStyles'; import positioning from './positioning'; import type { @@ -1084,6 +1085,7 @@ const staticStyleUtils = { getCardStyles, getOpacityStyle, getMultiGestureCanvasContainerStyle, + getSignInBgStyles, }; const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ @@ -1345,7 +1347,7 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({ /** * Returns style object for the user mention component based on whether the mention is ours or not. */ - getMentionStyle: (isOurMention: boolean): ViewStyle => { + getMentionStyle: (isOurMention: boolean): TextStyle => { const backgroundColor = isOurMention ? theme.ourMentionBG : theme.mentionBG; return { backgroundColor, diff --git a/src/styles/variables.ts b/src/styles/variables.ts index 48957acb3255..c6d2bebe1417 100644 --- a/src/styles/variables.ts +++ b/src/styles/variables.ts @@ -189,9 +189,9 @@ export default { bankCardHeight: 26, workspaceTypeIconWidth: 34, sectionMargin: 16, + workspaceSectionMaxWidth: 680, oldDotWireframeIconWidth: 263.38, oldDotWireframeIconHeight: 143.28, - workspaceSectionMaxWidth: 600, sectionIllustrationHeight: 240, photoUploadPopoverWidth: 335, diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 0a122f083c8d..03446e813949 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -27,9 +27,7 @@ declare module '*.lottie' { export default value; } -// Global methods for Onyx key management for debugging purposes // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Window { - enableMemoryOnlyKeys: () => void; - disableMemoryOnlyKeys: () => void; + setSupportToken: (token: string, email: string, accountID: number) => void; } diff --git a/src/types/modules/react-native-clipboard.d.ts b/src/types/modules/react-native-clipboard.d.ts new file mode 100644 index 000000000000..14f418a3f8b9 --- /dev/null +++ b/src/types/modules/react-native-clipboard.d.ts @@ -0,0 +1,16 @@ +declare module '@react-native-clipboard/clipboard/jest/clipboard-mock' { + const mockClipboard: { + getString: jest.MockedFunction<() => Promise>; + getImagePNG: jest.MockedFunction<() => void>; + getImageJPG: jest.MockedFunction<() => void>; + setImage: jest.MockedFunction<() => void>; + setString: jest.MockedFunction<() => void>; + hasString: jest.MockedFunction<() => Promise>; + hasImage: jest.MockedFunction<() => Promise>; + hasURL: jest.MockedFunction<() => Promise>; + addListener: jest.MockedFunction<() => void>; + removeAllListeners: jest.MockedFunction<() => void>; + useClipboard: jest.MockedFunction<() => [string, jest.MockedFunction<() => void>]>; + }; + export default mockClipboard; +} diff --git a/src/types/modules/react-native-onyx.d.ts b/src/types/modules/react-native-onyx.d.ts index 05302577910b..ba5774aa8539 100644 --- a/src/types/modules/react-native-onyx.d.ts +++ b/src/types/modules/react-native-onyx.d.ts @@ -1,3 +1,4 @@ +import type Onyx from 'react-native-onyx'; import type {OnyxCollectionKey, OnyxKey, OnyxValues} from '@src/ONYXKEYS'; declare module 'react-native-onyx' { @@ -8,3 +9,13 @@ declare module 'react-native-onyx' { values: OnyxValues; } } + +declare global { + // Global methods for Onyx key management for debugging purposes + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions + interface Window { + enableMemoryOnlyKeys: () => void; + disableMemoryOnlyKeys: () => void; + Onyx: typeof Onyx; + } +} diff --git a/src/types/onyx/OnyxCommon.ts b/src/types/onyx/OnyxCommon.ts index 2bb6eca8c3f0..599f0dde66c2 100644 --- a/src/types/onyx/OnyxCommon.ts +++ b/src/types/onyx/OnyxCommon.ts @@ -1,4 +1,5 @@ import type {ValueOf} from 'type-fest'; +import type {MaybePhraseKey} from '@libs/Localize'; import type {AvatarSource} from '@libs/UserUtils'; import type CONST from '@src/CONST'; @@ -8,7 +9,7 @@ type PendingFields = Record = Record; -type Errors = Record; +type Errors = Record; type AvatarType = typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_WORKSPACE; diff --git a/src/types/onyx/Session.ts b/src/types/onyx/Session.ts index e17bfaf1c2b9..88417a03d903 100644 --- a/src/types/onyx/Session.ts +++ b/src/types/onyx/Session.ts @@ -20,6 +20,9 @@ type Session = { /** Currently logged in user encrypted authToken */ encryptedAuthToken?: string; + /** Boolean that indicates whether it is loading or not */ + loading?: boolean; + /** Currently logged in user accountID */ accountID?: number; diff --git a/tests/README.md b/tests/README.md index 6170006cb9bb..08c750f75829 100644 --- a/tests/README.md +++ b/tests/README.md @@ -45,9 +45,9 @@ const policies = createCollection( ); ``` -## Mocking `node_modules`, user modules, and what belongs in `jest/setup.js` +## Mocking `node_modules`, user modules, and what belongs in `jest/setup.ts` -If you need to mock a library that exists in `node_modules` then add it to the `__mocks__` folder in the root of the project. More information about this [here](https://jestjs.io/docs/manual-mocks#mocking-node-modules). If you need to mock an individual library you should create a mock module in a `__mocks__` subdirectory adjacent to the library as explained [here](https://jestjs.io/docs/manual-mocks#mocking-user-modules). However, keep in mind that when you do this you also must manually require the mock by calling something like `jest.mock('../../src/libs/Log');` at the top of an individual test file. If every test in the app will need something to be mocked that's a good case for adding it to `jest/setup.js`, but we should generally avoid adding user mocks or `node_modules` mocks to this file. Please use the `__mocks__` subdirectories wherever appropriate. +If you need to mock a library that exists in `node_modules` then add it to the `__mocks__` folder in the root of the project. More information about this [here](https://jestjs.io/docs/manual-mocks#mocking-node-modules). If you need to mock an individual library you should create a mock module in a `__mocks__` subdirectory adjacent to the library as explained [here](https://jestjs.io/docs/manual-mocks#mocking-user-modules). However, keep in mind that when you do this you also must manually require the mock by calling something like `jest.mock('../../src/libs/Log');` at the top of an individual test file. If every test in the app will need something to be mocked that's a good case for adding it to `jest/setup.ts`, but we should generally avoid adding user mocks or `node_modules` mocks to this file. Please use the `__mocks__` subdirectories wherever appropriate. ## Assertions diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js index 92b39fc3ac50..cb31afbf8f8f 100644 --- a/tests/actions/IOUTest.js +++ b/tests/actions/IOUTest.js @@ -684,7 +684,7 @@ describe('actions/IOU', () => { Onyx.disconnect(connectionID); expect(transaction.pendingAction).toBeFalsy(); expect(transaction.errors).toBeTruthy(); - expect(_.values(transaction.errors)[0]).toBe('iou.error.genericCreateFailureMessage'); + expect(_.values(transaction.errors)[0]).toEqual(expect.arrayContaining(['iou.error.genericCreateFailureMessage', {isTranslated: false}])); resolve(); }, }); @@ -1629,7 +1629,7 @@ describe('actions/IOU', () => { Onyx.disconnect(connectionID); const updatedAction = _.find(allActions, (reportAction) => !_.isEmpty(reportAction)); expect(updatedAction.actionName).toEqual('MODIFIEDEXPENSE'); - expect(_.values(updatedAction.errors)).toEqual(expect.arrayContaining(['iou.error.genericEditFailureMessage'])); + expect(_.values(updatedAction.errors)).toEqual(expect.arrayContaining([['iou.error.genericEditFailureMessage', {isTranslated: false}]])); resolve(); }, }); @@ -1843,7 +1843,7 @@ describe('actions/IOU', () => { callback: (allActions) => { Onyx.disconnect(connectionID); const erroredAction = _.find(_.values(allActions), (action) => !_.isEmpty(action.errors)); - expect(_.values(erroredAction.errors)).toEqual(expect.arrayContaining(['iou.error.other'])); + expect(_.values(erroredAction.errors)).toEqual(expect.arrayContaining([['iou.error.other', {isTranslated: false}]])); resolve(); }, }); diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index 98faff32397d..63c88d207581 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -339,6 +339,16 @@ const runTests = async () => { mockNetwork: true, }); + const onError = (e) => { + testLog.done(); + if (i === 0) { + // If the error happened on the first test run, the test is broken + // and we should not continue running it + throw e; + } + console.error(e); + }; + // Wait for a test to finish by waiting on its done call to the http server try { await withFailTimeout( @@ -352,9 +362,7 @@ const runTests = async () => { progressText, ); } catch (e) { - // When we fail due to a timeout it's interesting to take a screenshot of the emulator to see whats going on - testLog.done(); - throw e; // Rethrow to abort execution + onError(e); } Logger.log('Killing main app'); @@ -378,9 +386,7 @@ const runTests = async () => { progressText, ); } catch (e) { - // When we fail due to a timeout it's interesting to take a screenshot of the emulator to see whats going on - testLog.done(); - throw e; // Rethrow to abort execution + onError(e); } } testLog.done();