diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index d118b3fee252..cb5dc6d28b32 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -22,6 +22,13 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} + - name: Escape html characters in GH issue title + env: + GH_ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + escaped_title=$(echo "$GH_ISSUE_TITLE" | sed -e 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g; s/|/\|/g') + echo "GH_ISSUE_TITLE=$escaped_title" >> "$GITHUB_ENV" + - name: 'Post the issue in the #expensify-open-source slack room' if: ${{ success() }} uses: 8398a7/action-slack@v3 @@ -32,7 +39,7 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: "#DB4545", - text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>'.replace(/[&<>"'|]/g, function(m) { return {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '|': '|'}[m]; }), + text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ env.GH_ISSUE_TITLE }}>' }] } env: diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index 016fe89ccfce..92b4e52c159c 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -211,7 +211,9 @@ jobs: test_spec_file: tests/e2e/TestSpec.yml test_spec_type: APPIUM_NODE_TEST_SPEC remote_src: false - file_artifacts: Customer Artifacts.zip + file_artifacts: | + Customer Artifacts.zip + Test spec output.txt log_artifacts: debug.log cleanup: true timeout: 5400 @@ -220,6 +222,7 @@ jobs: if: failure() run: | echo ${{ steps.schedule-awsdf-main.outputs.data }} + cat "./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/Test spec output.txt" unzip "Customer Artifacts.zip" -d mainResults cat ./mainResults/Host_Machine_Files/\$WORKING_DIRECTORY/debug.log diff --git a/android/app/build.gradle b/android/app/build.gradle index 1d6ed247e1b9..301a76c353e2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,8 +91,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001040401 - versionName "1.4.4-1" + versionCode 1001040403 + versionName "1.4.4-3" } flavorDimensions "default" diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index d7a94c2a4337..32d3919efbe4 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -40,9 +40,22 @@ When creating RHP flows, you have to remember a couple things: An example of adding `Settings_Workspaces` page: -1. Add path to `ROUTES.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/ROUTES.js#L36 +1. Add path to `ROUTES.ts`: https://github.com/Expensify/App/blob/main/src/ROUTES.ts + +```ts +export const ROUTES = { + // static route + SETTINGS_WORKSPACES: 'settings/workspaces', + // dynamic route + SETTINGS_WORKSPACES: { + route: 'settings/:accountID', + getRoute: (accountID: number) => `settings/${accountID}` as const, + }, +}; + +``` -2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42 +2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.ts`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42 3. Add your page to proper navigator (it should be aligned with where you've put it in the previous step) https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/AppNavigator/ModalStackNavigators.js#L334-L338 diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index 669d960275e6..25ccdefad261 100644 --- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -24,30 +24,30 @@ After downloading the app, log into your new.expensify.com account (you’ll use ## How to send messages -Click **+** then **Send message** in New Expensify -Choose **Chat** -Search for any name, email or phone number -Select the individual to begin chatting +1. Click **+** then **Send message** in New Expensify +2. Choose **Chat** +3. Search for any name, email or phone number +4. Select the individual to begin chatting ## How to create a group -Click **+**, then **Send message** in New Expensify -Search for any name, email or phone number -Click **Add to group** -Group participants are listed with a green check -Repeat steps 1-3 to add more participants to the group -Click **Create chat** to start chatting +1. Click **+**, then **Send message** in New Expensify +2. Search for any name, email or phone number +3. Click **Add to group** +4. Group participants are listed with a green check +5. Repeat steps 1-3 to add more participants to the group +6. Click **Create chat** to start chatting ## How to create a room -Click **+**, then **Send message** in New Expensify -Click **Room** -Enter a room name that doesn’t already exist on the intended Workspace -Choose the Workspace you want to associate the room with. -Choose the room’s visibility setting: -Private: Only people explicitly invited can find the room* -Restricted: Workspace members can find the room* -Public: Anyone can find the room +1. Click **+**, then **Send message** in New Expensify +2. Click **Room** +3. Enter a room name that doesn’t already exist on the intended Workspace +4. Choose the Workspace you want to associate the room with. +5. Choose the room’s visibility setting: +6. Private: Only people explicitly invited can find the room* +7. Restricted: Workspace members can find the room* +8. Public: Anyone can find the room *Anyone, including non-Workspace Members, can be invited to a Private or Restricted room. @@ -56,26 +56,29 @@ Public: Anyone can find the room You can invite people to a Group or Room by @mentioning them or from the Members pane. ## Mentions: -Type **@** and start typing the person’s name or email address -Choose one or more contacts -Input message, if desired, then send + +1. Type **@** and start typing the person’s name or email address +2. Choose one or more contacts +3. Input message, if desired, then send ## Members pane invites: -Click the **Room** or **Group** header -Select **Members** -Click **Invite** -Find and select any contact/s you’d like to invite -Click **Next** -Write a custom invitation if you like -Click **Invite** + +1. Click the **Room** or **Group** header +2. Select **Members** +3. Click **Invite** +4. Find and select any contact/s you’d like to invite +5. Click **Next** +6. Write a custom invitation if you like +7. Click **Invite** ## Members pane removals: -Click the **Room** or **Group** header -Select **Members** -Find and select any contact/s you’d like to remove -Click **Remove** -Click **Remove members** + +1. Click the **Room** or **Group** header +2. Select **Members** +3. Find and select any contact/s you’d like to remove +4. Click **Remove** +5. Click **Remove members** ## How to format text diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 836abbcb500d..c8e8ab5a09f1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.4.1 + 1.4.4.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 1fee9bb4d417..fc779f5a711c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.4.4.1 + 1.4.4.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d94e36b0b3c9..bfd562963324 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -256,7 +256,7 @@ PODS: - Onfido (~> 28.3.0) - React - OpenSSL-Universal (1.1.1100) - - Plaid (4.1.0) + - Plaid (4.7.0) - PromisesObjC (2.2.0) - RCT-Folly (2021.07.22.00): - boost @@ -585,12 +585,12 @@ PODS: - React-Core - react-native-pager-view (6.2.0): - React-Core - - react-native-pdf (6.7.1): + - react-native-pdf (6.7.3): - React-Core - react-native-performance (5.1.0): - React-Core - - react-native-plaid-link-sdk (10.0.0): - - Plaid (~> 4.1.0) + - react-native-plaid-link-sdk (10.8.0): + - Plaid (~> 4.7.0) - React-Core - react-native-quick-sqlite (8.0.0-beta.2): - React @@ -1212,7 +1212,7 @@ SPEC CHECKSUMS: Onfido: c7d010d9793790d44a07799d9be25aa8e3814ee7 onfido-react-native-sdk: b346a620af5669f9fecb6dc3052314a35a94ad9f OpenSSL-Universal: ebc357f1e6bc71fa463ccb2fe676756aff50e88c - Plaid: 7d340abeadb46c7aa1a91f896c5b22395a31fcf2 + Plaid: 431ef9be5314a1345efb451bc5e6b067bfb3b4c6 PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef RCT-Folly: 424b8c9a7a0b9ab2886ffe9c3b041ef628fd4fb1 RCTRequired: c0569ecc035894e4a68baecb30fe6a7ea6e399f9 @@ -1241,9 +1241,9 @@ SPEC CHECKSUMS: react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df - react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae + react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e react-native-performance: cef2b618d47b277fb5c3280b81a3aad1e72f2886 - react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c + react-native-plaid-link-sdk: df1618a85a615d62ff34e34b76abb7a56497fbc1 react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 react-native-render-html: 96c979fe7452a0a41559685d2f83b12b93edac8c react-native-safe-area-context: 99b24a0c5acd0d5dcac2b1a7f18c49ea317be99a diff --git a/package-lock.json b/package-lock.json index 9e7820f866db..32271f8dc743 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.4-1", + "version": "1.4.4-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.4-1", + "version": "1.4.4-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -94,11 +94,11 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.1", + "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", - "react-native-plaid-link-sdk": "^10.0.0", + "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", "react-native-reanimated": "3.5.4", @@ -25886,10 +25886,9 @@ } }, "node_modules/crypto-js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", - "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==", - "license": "MIT" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/css-box-model": { "version": "1.2.1", @@ -44476,11 +44475,11 @@ } }, "node_modules/react-native-pdf": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz", - "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", + "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", "dependencies": { - "crypto-js": "^3.2.0", + "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" }, "peerDependencies": { @@ -44536,8 +44535,12 @@ } }, "node_modules/react-native-plaid-link-sdk": { - "version": "10.0.0", - "license": "MIT", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.8.0.tgz", + "integrity": "sha512-rhyI19SZdwKCsHtkJ0ZOCD/r0vNLS1vqUAS3HPPa97IIN6nS2ln9krLA7lFfMKtWxY5Z5d73SqTmqhd1qqdNuA==", + "dependencies": { + "react-native-plaid-link-sdk": "^10.4.0" + }, "peerDependencies": { "react": "*", "react-native": ">=0.66.0" @@ -71516,9 +71519,9 @@ } }, "crypto-js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", - "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "css-box-model": { "version": "1.2.1", @@ -84838,11 +84841,11 @@ "requires": {} }, "react-native-pdf": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz", - "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", + "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", "requires": { - "crypto-js": "^3.2.0", + "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" } }, @@ -84874,8 +84877,12 @@ } }, "react-native-plaid-link-sdk": { - "version": "10.0.0", - "requires": {} + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-10.8.0.tgz", + "integrity": "sha512-rhyI19SZdwKCsHtkJ0ZOCD/r0vNLS1vqUAS3HPPa97IIN6nS2ln9krLA7lFfMKtWxY5Z5d73SqTmqhd1qqdNuA==", + "requires": { + "react-native-plaid-link-sdk": "^10.4.0" + } }, "react-native-qrcode-svg": { "version": "6.2.0", diff --git a/package.json b/package.json index c1fbee4e8243..7da3658e67b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.4-1", + "version": "1.4.4-3", "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.", @@ -141,11 +141,11 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.1", + "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", - "react-native-plaid-link-sdk": "^10.0.0", + "react-native-plaid-link-sdk": "10.8.0", "react-native-qrcode-svg": "^6.2.0", "react-native-quick-sqlite": "^8.0.0-beta.2", "react-native-reanimated": "3.5.4", diff --git a/scripts/start-android.sh b/scripts/start-android.sh old mode 100644 new mode 100755 diff --git a/src/CONST.ts b/src/CONST.ts index 9b284752d074..dd6eafc7f0e6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -556,6 +556,7 @@ const CONST = { UPDATE_REIMBURSEMENT_CHOICE: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE', UPDATE_REPORT_FIELD: 'POLICYCHANGELOG_UPDATE_REPORT_FIELD', UPDATE_TAG: 'POLICYCHANGELOG_UPDATE_TAG', + UPDATE_TAG_ENABLED: 'POLICYCHANGELOG_UPDATE_TAG_ENABLED', UPDATE_TAG_LIST_NAME: 'POLICYCHANGELOG_UPDATE_TAG_LIST_NAME', UPDATE_TAG_NAME: 'POLICYCHANGELOG_UPDATE_TAG_NAME', UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 26589a3db0e0..a96b229ef4c1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,30 +1,28 @@ -import {ValueOf} from 'type-fest'; +import {IsEqual, ValueOf} from 'type-fest'; import CONST from './CONST'; -/** - * This is a file containing constants for all the routes we want to be able to go to - */ +// This is a file containing constants for all the routes we want to be able to go to /** * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs */ -function getUrlWithBackToParam(url: string, backTo?: string): string { - const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; - return url + backToParam; +function getUrlWithBackToParam(url: TUrl, backTo?: string): `${TUrl}` | `${TUrl}?backTo=${string}` | `${TUrl}&backTo=${string}` { + const backToParam = backTo ? (`${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` as const) : ''; + return `${url}${backToParam}` as const; } -export default { +const ROUTES = { HOME: '', /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ CONCIERGE: 'concierge', FLAG_COMMENT: { route: 'flag/:reportID/:reportActionID', - getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`, + getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}` as const, }, SEARCH: 'search', DETAILS: { route: 'details', - getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`, + getRoute: (login: string) => `details?login=${encodeURIComponent(login)}` as const, }, PROFILE: { route: 'a/:accountID', @@ -35,7 +33,7 @@ export default { VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: { route: 'get-assistance/:taskID', - getRoute: (taskID: string) => `get-assistance/${taskID}`, + getRoute: (taskID: string) => `get-assistance/${taskID}` as const, }, UNLINK_LOGIN: 'u/:accountID/:validateCode', APPLE_SIGN_IN: 'sign-in-with-apple', @@ -54,7 +52,7 @@ export default { BANK_ACCOUNT_PERSONAL: 'bank-account/personal', BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/:stepToOpen?', - getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), + getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, SETTINGS: 'settings', @@ -77,44 +75,44 @@ export default { SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { route: '/settings/wallet/card/:domain', - getRoute: (domain: string) => `/settings/wallet/card/${domain}`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const, }, SETTINGS_REPORT_FRAUD: { route: '/settings/wallet/card/:domain/report-virtual-fraud', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { route: '/settings/wallet/card/:domain/get-physical/name', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { route: '/settings/wallet/card/:domain/get-physical/phone', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { route: '/settings/wallet/card/:domain/get-physical/address', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const, }, SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { route: '/settings/wallet/card/:domain/get-physical/confirm', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const, }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', - getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address`, + getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const, }, SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', - getRoute: (domain: string) => `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', @@ -130,7 +128,7 @@ export default { }, SETTINGS_CONTACT_METHOD_DETAILS: { route: 'settings/profile/contact-methods/:contactMethod/details', - getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, + getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details` as const, }, SETTINGS_NEW_CONTACT_METHOD: 'settings/profile/contact-methods/new', SETTINGS_2FA: 'settings/security/two-factor-auth', @@ -146,157 +144,158 @@ export default { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string) => `r/${reportID}`, + getRoute: (reportID: string) => `r/${reportID}` as const, }, EDIT_REQUEST: { route: 'r/:threadReportID/edit/:field', - getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`, + getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}` as const, }, EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', - getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`, + getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, }, REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', - getRoute: (reportID: string) => `r/${reportID}/details/shareCode`, + getRoute: (reportID: string) => `r/${reportID}/details/shareCode` as const, }, REPORT_ATTACHMENTS: { route: 'r/:reportID/attachment', - getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`, + getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', - getRoute: (reportID: string) => `r/${reportID}/participants`, + getRoute: (reportID: string) => `r/${reportID}/participants` as const, }, REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', - getRoute: (reportID: string) => `r/${reportID}/details`, + getRoute: (reportID: string) => `r/${reportID}/details` as const, }, REPORT_SETTINGS: { route: 'r/:reportID/settings', - getRoute: (reportID: string) => `r/${reportID}/settings`, + getRoute: (reportID: string) => `r/${reportID}/settings` as const, }, REPORT_SETTINGS_ROOM_NAME: { route: 'r/:reportID/settings/room-name', - getRoute: (reportID: string) => `r/${reportID}/settings/room-name`, + getRoute: (reportID: string) => `r/${reportID}/settings/room-name` as const, }, REPORT_SETTINGS_NOTIFICATION_PREFERENCES: { route: 'r/:reportID/settings/notification-preferences', - getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`, + getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences` as const, }, REPORT_SETTINGS_WRITE_CAPABILITY: { route: 'r/:reportID/settings/who-can-post', - getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`, + getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const, }, REPORT_WELCOME_MESSAGE: { route: 'r/:reportID/welcomeMessage', - getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`, + getRoute: (reportID: string) => `r/${reportID}/welcomeMessage` as const, }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', - getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, + getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, }, EDIT_SPLIT_BILL: { route: `r/:reportID/split/:reportActionID/edit/:field`, - getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}`, + getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}` as const, }, EDIT_SPLIT_BILL_CURRENCY: { route: 'r/:reportID/split/:reportActionID/edit/currency', - getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}`, + getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => + `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, }, TASK_TITLE: { route: 'r/:reportID/title', - getRoute: (reportID: string) => `r/${reportID}/title`, + getRoute: (reportID: string) => `r/${reportID}/title` as const, }, TASK_DESCRIPTION: { route: 'r/:reportID/description', - getRoute: (reportID: string) => `r/${reportID}/description`, + getRoute: (reportID: string) => `r/${reportID}/description` as const, }, TASK_ASSIGNEE: { route: 'r/:reportID/assignee', - getRoute: (reportID: string) => `r/${reportID}/assignee`, + getRoute: (reportID: string) => `r/${reportID}/assignee` as const, }, PRIVATE_NOTES_VIEW: { route: 'r/:reportID/notes/:accountID', - getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`, + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}` as const, }, PRIVATE_NOTES_LIST: { route: 'r/:reportID/notes', - getRoute: (reportID: string) => `r/${reportID}/notes`, + getRoute: (reportID: string) => `r/${reportID}/notes` as const, }, PRIVATE_NOTES_EDIT: { route: 'r/:reportID/notes/:accountID/edit', - getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`, + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit` as const, }, ROOM_MEMBERS: { route: 'r/:reportID/members', - getRoute: (reportID: string) => `r/${reportID}/members`, + getRoute: (reportID: string) => `r/${reportID}/members` as const, }, ROOM_INVITE: { route: 'r/:reportID/invite', - getRoute: (reportID: string) => `r/${reportID}/invite`, + getRoute: (reportID: string) => `r/${reportID}/invite` as const, }, // To see the available iouType, please refer to CONST.IOU.TYPE MONEY_REQUEST: { route: ':iouType/new/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}` as const, }, MONEY_REQUEST_AMOUNT: { route: ':iouType/new/amount/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}` as const, }, MONEY_REQUEST_PARTICIPANTS: { route: ':iouType/new/participants/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const, }, MONEY_REQUEST_CONFIRMATION: { route: ':iouType/new/confirmation/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, }, MONEY_REQUEST_DATE: { route: ':iouType/new/date/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, }, MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', - getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`, + getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, MONEY_REQUEST_DESCRIPTION: { route: ':iouType/new/description/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}` as const, }, MONEY_REQUEST_CATEGORY: { route: ':iouType/new/category/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, }, MONEY_REQUEST_TAG: { route: ':iouType/new/tag/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}` as const, }, MONEY_REQUEST_MERCHANT: { route: ':iouType/new/merchant/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const, }, MONEY_REQUEST_WAYPOINT: { route: ':iouType/new/waypoint/:waypointIndex', - getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`, + getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}` as const, }, MONEY_REQUEST_RECEIPT: { route: ':iouType/new/receipt/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, MONEY_REQUEST_DISTANCE: { route: ':iouType/new/address/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const, }, MONEY_REQUEST_EDIT_WAYPOINT: { route: 'r/:threadReportID/edit/distance/:transactionID/waypoint/:waypointIndex', - getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}`, + getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}` as const, }, MONEY_REQUEST_DISTANCE_TAB: { route: ':iouType/new/:reportID?/distance', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance` as const, }, MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', @@ -321,63 +320,63 @@ export default { ERECEIPT: { route: 'eReceipt/:transactionID', - getRoute: (transactionID: string) => `eReceipt/${transactionID}`, + getRoute: (transactionID: string) => `eReceipt/${transactionID}` as const, }, WORKSPACE_NEW: 'workspace/new', WORKSPACE_NEW_ROOM: 'workspace/new-room', WORKSPACE_INITIAL: { route: 'workspace/:policyID', - getRoute: (policyID: string) => `workspace/${policyID}`, + getRoute: (policyID: string) => `workspace/${policyID}` as const, }, WORKSPACE_INVITE: { route: 'workspace/:policyID/invite', - getRoute: (policyID: string) => `workspace/${policyID}/invite`, + getRoute: (policyID: string) => `workspace/${policyID}/invite` as const, }, WORKSPACE_INVITE_MESSAGE: { route: 'workspace/:policyID/invite-message', - getRoute: (policyID: string) => `workspace/${policyID}/invite-message`, + getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, }, WORKSPACE_SETTINGS: { route: 'workspace/:policyID/settings', - getRoute: (policyID: string) => `workspace/${policyID}/settings`, + getRoute: (policyID: string) => `workspace/${policyID}/settings` as const, }, WORKSPACE_SETTINGS_CURRENCY: { route: 'workspace/:policyID/settings/currency', - getRoute: (policyID: string) => `workspace/${policyID}/settings/currency`, + getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', - getRoute: (policyID: string) => `workspace/${policyID}/card`, + getRoute: (policyID: string) => `workspace/${policyID}/card` as const, }, WORKSPACE_REIMBURSE: { route: 'workspace/:policyID/reimburse', - getRoute: (policyID: string) => `workspace/${policyID}/reimburse`, + getRoute: (policyID: string) => `workspace/${policyID}/reimburse` as const, }, WORKSPACE_RATE_AND_UNIT: { route: 'workspace/:policyID/rateandunit', - getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`, + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', - getRoute: (policyID: string) => `workspace/${policyID}/bills`, + getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, }, WORKSPACE_INVOICES: { route: 'workspace/:policyID/invoices', - getRoute: (policyID: string) => `workspace/${policyID}/invoices`, + getRoute: (policyID: string) => `workspace/${policyID}/invoices` as const, }, WORKSPACE_TRAVEL: { route: 'workspace/:policyID/travel', - getRoute: (policyID: string) => `workspace/${policyID}/travel`, + getRoute: (policyID: string) => `workspace/${policyID}/travel` as const, }, WORKSPACE_MEMBERS: { route: 'workspace/:policyID/members', - getRoute: (policyID: string) => `workspace/${policyID}/members`, + getRoute: (policyID: string) => `workspace/${policyID}/members` as const, }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', - getRoute: (contentType: string) => `referral/${contentType}`, + getRoute: (contentType: string) => `referral/${contentType}` as const, }, // These are some one-off routes that will be removed once they're no longer needed (see GH issues for details) @@ -385,3 +384,24 @@ export default { SBE: 'sbe', MONEY2020: 'money2020', } as const; + +export default ROUTES; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractRouteName = TRoute extends {getRoute: (...args: any[]) => infer TRouteName} ? TRouteName : TRoute; + +type AllRoutes = { + [K in keyof typeof ROUTES]: ExtractRouteName<(typeof ROUTES)[K]>; +}[keyof typeof ROUTES]; + +type RouteIsPlainString = IsEqual; + +/** + * Represents all routes in the app as a union of literal strings. + * + * If this type resolves to `never`, it implies that one or more routes defined within `ROUTES` have not correctly used + * `as const` in their `getRoute` function return value. + */ +type Route = RouteIsPlainString extends true ? never : AllRoutes; + +export type {Route}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f957a1dbb25e..f4cbcf4f2564 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -9,13 +9,13 @@ const PROTECTED_SCREENS = { REPORT_ATTACHMENTS: 'ReportAttachments', } as const; -export default { +const SCREENS = { ...PROTECTED_SCREENS, - LOADING: 'Loading', REPORT: 'Report', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', + UNLINK_LOGIN: 'UnlinkLogin', SETTINGS: { ROOT: 'Settings_Root', PREFERENCES: 'Settings_Preferences', @@ -40,4 +40,5 @@ export default { SAML_SIGN_IN: 'SAMLSignIn', } as const; +export default SCREENS; export {PROTECTED_SCREENS}; diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index ec4ddd623929..0b23704b5b26 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -209,7 +209,7 @@ function AddPlaidBankAccount({ // Handle Plaid login errors (will potentially reset plaid token and item depending on the error) if (event === 'ERROR') { Log.hmmm('[PlaidLink] Error: ', metadata); - if (bankAccountID && metadata.error_code) { + if (bankAccountID && metadata && metadata.error_code) { BankAccounts.handlePlaidError(bankAccountID, metadata.error_code, metadata.error_message, metadata.request_id); } } diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js index 19ab35f036c1..dfea017692ef 100644 --- a/src/components/AddressForm.js +++ b/src/components/AddressForm.js @@ -122,7 +122,6 @@ function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldS submitButtonText={submitButtonText} enabledWhenOffline > - ${displayName}`, - oldDisplayName: `${oldDisplayName}`, - policyName: `${policyName}`, - })} - shouldRenderHTML={shouldRenderHTML} - shouldShowIcon - /> - ); -} - -ArchivedReportFooter.propTypes = propTypes; -ArchivedReportFooter.defaultProps = defaultProps; -ArchivedReportFooter.displayName = 'ArchivedReportFooter'; - -export default compose( - withLocalize, - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - reportClosedAction: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - selector: ReportActionsUtils.getLastClosedReportAction, - }, - }), -)(ArchivedReportFooter); diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx new file mode 100644 index 000000000000..3187bf3604e8 --- /dev/null +++ b/src/components/ArchivedReportFooter.tsx @@ -0,0 +1,82 @@ +import lodashEscape from 'lodash/escape'; +import React from 'react'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import useThemeStyles from '@styles/useThemeStyles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx'; +import Banner from './Banner'; + +type ArchivedReportFooterOnyxProps = { + /** The reason this report was archived */ + reportClosedAction: OnyxEntry; + + /** Personal details of all users */ + personalDetails: OnyxEntry>; +}; + +type ArchivedReportFooterProps = ArchivedReportFooterOnyxProps & { + /** The archived report */ + report: Report; +}; + +function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}: ArchivedReportFooterProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null; + const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; + let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [report.ownerAccountID, 'displayName']); + + let oldDisplayName: string | undefined; + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { + const newAccountID = originalMessage?.newAccountID; + const oldAccountID = originalMessage?.oldAccountID; + displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [newAccountID, 'displayName']); + oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [oldAccountID, 'displayName']); + } + + const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT; + + let policyName = ReportUtils.getPolicyName(report); + + if (shouldRenderHTML) { + oldDisplayName = lodashEscape(oldDisplayName); + displayName = lodashEscape(displayName); + policyName = lodashEscape(policyName); + } + + const text = shouldRenderHTML + ? translate(`reportArchiveReasons.${archiveReason}`, { + displayName: `${displayName}`, + oldDisplayName: `${oldDisplayName}`, + policyName: `${policyName}`, + }) + : translate(`reportArchiveReasons.${archiveReason}`); + + return ( + + ); +} + +ArchivedReportFooter.displayName = 'ArchivedReportFooter'; + +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + reportClosedAction: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + canEvict: false, + selector: ReportActionsUtils.getLastClosedReportAction, + }, +})(ArchivedReportFooter); diff --git a/src/components/CustomDevMenu/index.js b/src/components/CustomDevMenu/index.js deleted file mode 100644 index b8944c185d13..000000000000 --- a/src/components/CustomDevMenu/index.js +++ /dev/null @@ -1,5 +0,0 @@ -const CustomDevMenu = () => {}; - -CustomDevMenu.displayName = 'CustomDevMenu'; - -export default CustomDevMenu; diff --git a/src/components/CustomDevMenu/index.native.js b/src/components/CustomDevMenu/index.native.js deleted file mode 100644 index c8d0e1e099d4..000000000000 --- a/src/components/CustomDevMenu/index.native.js +++ /dev/null @@ -1,15 +0,0 @@ -import {useEffect} from 'react'; -import DevMenu from 'react-native-dev-menu'; -import toggleTestToolsModal from '@userActions/TestTool'; - -function CustomDevMenu() { - useEffect(() => { - DevMenu.addItem('Open Test Preferences', toggleTestToolsModal); - }, []); - - return null; -} - -CustomDevMenu.displayName = 'CustomDevMenu'; - -export default CustomDevMenu; diff --git a/src/components/CustomDevMenu/index.native.tsx b/src/components/CustomDevMenu/index.native.tsx new file mode 100644 index 000000000000..d8a0ea987171 --- /dev/null +++ b/src/components/CustomDevMenu/index.native.tsx @@ -0,0 +1,18 @@ +import {useEffect} from 'react'; +import DevMenu from 'react-native-dev-menu'; +import toggleTestToolsModal from '@userActions/TestTool'; +import CustomDevMenuElement from './types'; + +const CustomDevMenu: CustomDevMenuElement = Object.assign( + () => { + useEffect(() => { + DevMenu.addItem('Open Test Preferences', toggleTestToolsModal); + }, []); + return <>; + }, + { + displayName: 'CustomDevMenu', + }, +); + +export default CustomDevMenu; diff --git a/src/components/CustomDevMenu/index.tsx b/src/components/CustomDevMenu/index.tsx new file mode 100644 index 000000000000..c8eae861b676 --- /dev/null +++ b/src/components/CustomDevMenu/index.tsx @@ -0,0 +1,6 @@ +import React from 'react'; +import CustomDevMenuElement from './types'; + +const CustomDevMenu: CustomDevMenuElement = Object.assign(() => <>, {displayName: 'CustomDevMenu'}); + +export default CustomDevMenu; diff --git a/src/components/CustomDevMenu/types.ts b/src/components/CustomDevMenu/types.ts new file mode 100644 index 000000000000..bdfc800a17f0 --- /dev/null +++ b/src/components/CustomDevMenu/types.ts @@ -0,0 +1,8 @@ +import {ReactElement} from 'react'; + +type CustomDevMenuElement = { + (): ReactElement; + displayName: string; +}; + +export default CustomDevMenuElement; diff --git a/src/components/CustomStatusBar/index.android.js b/src/components/CustomStatusBar/index.android.tsx similarity index 50% rename from src/components/CustomStatusBar/index.android.js rename to src/components/CustomStatusBar/index.android.tsx index a7bf509114e6..81b4f1d25f67 100644 --- a/src/components/CustomStatusBar/index.android.js +++ b/src/components/CustomStatusBar/index.android.tsx @@ -1,10 +1,15 @@ /** * On Android we setup the status bar in native code. */ +import type CustomStatusBarType from './types'; -export default function CustomStatusBar() { +// eslint-disable-next-line react/function-component-definition +const CustomStatusBar: CustomStatusBarType = () => // Prefer to not render the StatusBar component in Android as it can cause // issues with edge to edge display. We setup the status bar appearance in // MainActivity.java and styles.xml. - return null; -} + null; + +CustomStatusBar.displayName = 'CustomStatusBar'; + +export default CustomStatusBar; diff --git a/src/components/CustomStatusBar/index.js b/src/components/CustomStatusBar/index.tsx similarity index 87% rename from src/components/CustomStatusBar/index.js rename to src/components/CustomStatusBar/index.tsx index a724c71059ef..c5c013c2bef9 100644 --- a/src/components/CustomStatusBar/index.js +++ b/src/components/CustomStatusBar/index.tsx @@ -2,8 +2,10 @@ import React, {useEffect} from 'react'; import Navigation, {navigationRef} from '@libs/Navigation/Navigation'; import StatusBar from '@libs/StatusBar'; import useTheme from '@styles/themes/useTheme'; +import type CustomStatusBarType from './types'; -function CustomStatusBar() { +// eslint-disable-next-line react/function-component-definition +const CustomStatusBar: CustomStatusBarType = () => { const theme = useTheme(); useEffect(() => { Navigation.isNavigationReady().then(() => { @@ -20,7 +22,7 @@ function CustomStatusBar() { }); }, [theme.PAGE_BACKGROUND_COLORS, theme.appBG]); return ; -} +}; CustomStatusBar.displayName = 'CustomStatusBar'; diff --git a/src/components/CustomStatusBar/types.ts b/src/components/CustomStatusBar/types.ts new file mode 100644 index 000000000000..7fecd02beba0 --- /dev/null +++ b/src/components/CustomStatusBar/types.ts @@ -0,0 +1,6 @@ +type CustomStatusBar = { + (): React.ReactNode; + displayName: string; +}; + +export default CustomStatusBar; diff --git a/src/components/FixedFooter.tsx b/src/components/FixedFooter.tsx index 475de82fac35..34bce2133a89 100644 --- a/src/components/FixedFooter.tsx +++ b/src/components/FixedFooter.tsx @@ -7,12 +7,12 @@ type FixedFooterProps = { children: ReactNode; /** Styles to be assigned to Container */ - style?: StyleProp; + style: Array>; }; function FixedFooter({style = [], children}: FixedFooterProps) { const styles = useThemeStyles(); - return {children}; + return {children}; } FixedFooter.displayName = 'FixedFooter'; diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index 638b6e5f8d19..4f7346a94a2d 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -5,7 +5,6 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormSubmit from '@components/FormSubmit'; -import refPropTypes from '@components/refPropTypes'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -65,7 +64,7 @@ const propTypes = { errors: errorsPropType.isRequired, - inputRefs: PropTypes.objectOf(refPropTypes).isRequired, + inputRefs: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.func, PropTypes.object])).isRequired, }; const defaultProps = { diff --git a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js index 1ad1f0961e38..109e60adf672 100644 --- a/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js +++ b/src/components/HeaderWithBackButton/headerWithBackButtonPropTypes.js @@ -93,6 +93,9 @@ const propTypes = { /** Single execution function to prevent concurrent navigation actions */ singleExecution: PropTypes.func, + + /** Whether we should navigate to report page when the route have a topMostReport */ + shouldNavigateToTopMostReport: PropTypes.bool, }; export default propTypes; diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index edb3b8d26831..051e18ed675e 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -53,6 +53,7 @@ function HeaderWithBackButton({ children = null, shouldOverlay = false, singleExecution = (func) => func, + shouldNavigateToTopMostReport = false, }) { const styles = useThemeStyles(); const [isDownloadButtonActive, temporarilyDisableDownloadButton] = useThrottledButtonState(); @@ -74,7 +75,12 @@ function HeaderWithBackButton({ if (isKeyboardShown) { Keyboard.dismiss(); } - onBackButtonPress(); + const topmostReportId = Navigation.getTopmostReportId(); + if (shouldNavigateToTopMostReport && topmostReportId) { + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(topmostReportId)); + } else { + onBackButtonPress(); + } }} style={[styles.touchableButtonImage]} role="button" diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js index 677fe1c38827..1f3dd061a4bd 100644 --- a/src/components/MagicCodeInput.js +++ b/src/components/MagicCodeInput.js @@ -1,8 +1,10 @@ import PropTypes from 'prop-types'; import React, {forwardRef, useEffect, useImperativeHandle, useRef, useState} from 'react'; import {StyleSheet, View} from 'react-native'; +import {TapGestureHandler} from 'react-native-gesture-handler'; import _ from 'underscore'; import useNetwork from '@hooks/useNetwork'; +import * as Browser from '@libs/Browser'; import * as ValidationUtils from '@libs/ValidationUtils'; import * as StyleUtils from '@styles/StyleUtils'; import useThemeStyles from '@styles/useThemeStyles'; @@ -13,6 +15,8 @@ import {withNetwork} from './OnyxProvider'; import Text from './Text'; import TextInput from './TextInput'; +const TEXT_INPUT_EMPTY_STATE = ''; + const propTypes = { /** Information about the network */ network: networkPropTypes.isRequired, @@ -104,23 +108,53 @@ const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys()); function MagicCodeInput(props) { const styles = useThemeStyles(); - const inputRefs = useRef([]); - const [input, setInput] = useState(''); + const inputRefs = useRef(); + const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE); const [focusedIndex, setFocusedIndex] = useState(0); const [editIndex, setEditIndex] = useState(0); const [wasSubmitted, setWasSubmitted] = useState(false); + const shouldFocusLast = useRef(false); + const inputWidth = useRef(0); + const lastFocusedIndex = useRef(0); + const lastValue = useRef(TEXT_INPUT_EMPTY_STATE); + + useEffect(() => { + lastValue.current = input.length; + }, [input]); const blurMagicCodeInput = () => { - inputRefs.current[editIndex].blur(); + inputRefs.current.blur(); setFocusedIndex(undefined); }; + const focusMagicCodeInput = () => { + setFocusedIndex(0); + lastFocusedIndex.current = 0; + setEditIndex(0); + inputRefs.current.focus(); + }; + + const setInputAndIndex = (index) => { + setInput(TEXT_INPUT_EMPTY_STATE); + setFocusedIndex(index); + setEditIndex(index); + }; + useImperativeHandle(props.innerRef, () => ({ focus() { - inputRefs.current[0].focus(); + focusMagicCodeInput(); + }, + focusLastSelected() { + inputRefs.current.focus(); + }, + resetFocus() { + setInput(TEXT_INPUT_EMPTY_STATE); + focusMagicCodeInput(); }, clear() { - inputRefs.current[0].focus(); + lastFocusedIndex.current = 0; + setInputAndIndex(0); + inputRefs.current.focus(); props.onChangeText(''); }, blur() { @@ -140,6 +174,7 @@ function MagicCodeInput(props) { // on complete, it will call the onFulfill callback. blurMagicCodeInput(); props.onFulfill(props.value); + lastValue.current = ''; }; useNetwork({onReconnect: validateAndSubmit}); @@ -154,17 +189,34 @@ function MagicCodeInput(props) { }, [props.value, props.shouldSubmitOnComplete]); /** - * Callback for the onFocus event, updates the indexes - * of the currently focused input. + * Focuses on the input when it is pressed. * * @param {Object} event * @param {Number} index */ - const onFocus = (event, index) => { + const onFocus = (event) => { + if (shouldFocusLast.current) { + lastValue.current = TEXT_INPUT_EMPTY_STATE; + setInputAndIndex(lastFocusedIndex.current); + } event.preventDefault(); - setInput(''); - setFocusedIndex(index); - setEditIndex(index); + }; + + /** + * Callback for the onPress event, updates the indexes + * of the currently focused input. + * + * @param {Number} index + */ + const onPress = (index) => { + shouldFocusLast.current = false; + // TapGestureHandler works differently on mobile web and native app + // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually + if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) { + inputRefs.current.focus(); + } + setInputAndIndex(index); + lastFocusedIndex.current = index; }; /** @@ -181,9 +233,16 @@ function MagicCodeInput(props) { return; } + // Checks if one new character was added, or if the content was replaced + const hasToSlice = value.length - 1 === lastValue.current.length && value.slice(0, value.length - 1) === lastValue.current; + + // Gets the new value added by the user + const addedValue = hasToSlice ? value.slice(lastValue.current.length, value.length) : value; + + lastValue.current = value; // Updates the focused input taking into consideration the last input // edited and the number of digits added by the user. - const numbersArr = value + const numbersArr = addedValue .trim() .split('') .slice(0, props.maxLength - editIndex); @@ -192,7 +251,7 @@ function MagicCodeInput(props) { let numbers = decomposeString(props.value, props.maxLength); numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)]; - inputRefs.current[updatedFocusedIndex].focus(); + setInputAndIndex(updatedFocusedIndex); const finalInput = composeToString(numbers); props.onChangeText(finalInput); @@ -225,7 +284,7 @@ function MagicCodeInput(props) { // If the currently focused index already has a value, it will delete // that value but maintain the focus on the same input. if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) { - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)]; setEditIndex(focusedIndex); props.onChangeText(composeToString(numbers)); @@ -244,24 +303,31 @@ function MagicCodeInput(props) { } const newFocusedIndex = Math.max(0, focusedIndex - 1); + + // Saves the input string so that it can compare to the change text + // event that will be triggered, this is a workaround for mobile that + // triggers the change text on the event after the key press. + setInputAndIndex(newFocusedIndex); props.onChangeText(composeToString(numbers)); if (!_.isUndefined(newFocusedIndex)) { - inputRefs.current[newFocusedIndex].focus(); + inputRefs.current.focus(); } } if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.max(0, focusedIndex - 1); - inputRefs.current[newFocusedIndex].focus(); + setInputAndIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) { const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1); - inputRefs.current[newFocusedIndex].focus(); + setInputAndIndex(newFocusedIndex); + inputRefs.current.focus(); } else if (keyValue === 'Enter') { // We should prevent users from submitting when it's offline. if (props.network.isOffline) { return; } - setInput(''); + setInput(TEXT_INPUT_EMPTY_STATE); props.onFulfill(props.value); } }; @@ -290,6 +356,49 @@ function MagicCodeInput(props) { return ( <> + { + onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength))); + }} + > + {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */} + + { + inputWidth.current = e.nativeEvent.layout.width; + }} + ref={(ref) => (inputRefs.current = ref)} + autoFocus={props.autoFocus} + inputMode="numeric" + textContentType="oneTimeCode" + name={props.name} + maxLength={props.maxLength} + value={input} + hideFocusedState + autoComplete={input.length === 0 && props.autoComplete} + shouldDelayFocus={input.length === 0 && props.shouldDelayFocus} + keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} + onChangeText={(value) => { + onChangeText(value); + }} + onKeyPress={onKeyPress} + onFocus={onFocus} + onBlur={() => { + shouldFocusLast.current = true; + lastFocusedIndex.current = focusedIndex; + setFocusedIndex(undefined); + }} + selectionColor="transparent" + inputStyle={[styles.inputTransparent]} + role={CONST.ACCESSIBILITY_ROLE.TEXT} + style={[styles.inputTransparent]} + textInputContainerStyles={[styles.borderNone]} + /> + + {_.map(getInputPlaceholderSlots(props.maxLength), (index) => ( {decomposeString(props.value, props.maxLength)[index] || ''} - {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */} - - { - inputRefs.current[index] = ref; - // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome - if (ref && ref.setAttribute) { - ref.setAttribute('type', 'search'); - } - }} - disableKeyboard={props.isDisableKeyboard} - autoFocus={index === 0 && props.autoFocus} - shouldDelayFocus={index === 0 && props.shouldDelayFocus} - inputMode={props.isDisableKeyboard ? 'none' : 'numeric'} - textContentType="oneTimeCode" - name={props.name} - maxLength={props.maxLength} - value={input} - hideFocusedState - autoComplete={index === 0 ? props.autoComplete : 'off'} - onChangeText={(value) => { - // Do not run when the event comes from an input that is - // not currently being responsible for the input, this is - // necessary to avoid calls when the input changes due to - // deleted characters. Only happens in mobile. - if (index !== editIndex || _.isUndefined(focusedIndex)) { - return; - } - onChangeText(value); - }} - onKeyPress={onKeyPress} - onFocus={(event) => onFocus(event, index)} - // Manually set selectionColor to make caret transparent. - // We cannot use caretHidden as it breaks the pasting function on Android. - selectionColor="transparent" - textInputContainerStyles={[styles.borderNone]} - inputStyle={[styles.inputTransparent]} - role={CONST.ACCESSIBILITY_ROLE.TEXT} - /> - ))} diff --git a/src/components/NewDatePicker/CalendarPicker/index.js b/src/components/NewDatePicker/CalendarPicker/index.js index f2aa687c43e4..42904ba1a8c2 100644 --- a/src/components/NewDatePicker/CalendarPicker/index.js +++ b/src/components/NewDatePicker/CalendarPicker/index.js @@ -1,4 +1,4 @@ -import {addMonths, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, subMonths} from 'date-fns'; +import {addMonths, endOfDay, endOfMonth, format, getYear, isSameDay, parseISO, setDate, setYear, startOfDay, startOfMonth, subMonths} from 'date-fns'; import Str from 'expensify-common/lib/str'; import PropTypes from 'prop-types'; import React from 'react'; @@ -127,8 +127,8 @@ class CalendarPicker extends React.PureComponent { const currentMonthView = this.state.currentDateView.getMonth(); const currentYearView = this.state.currentDateView.getFullYear(); const calendarDaysMatrix = generateMonthMatrix(currentYearView, currentMonthView); - const hasAvailableDatesNextMonth = startOfDay(endOfMonth(new Date(this.props.maxDate))) > addMonths(new Date(this.state.currentDateView), 1); - const hasAvailableDatesPrevMonth = startOfDay(new Date(this.props.minDate)) < endOfMonth(subMonths(new Date(this.state.currentDateView), 1)); + const hasAvailableDatesNextMonth = startOfDay(new Date(this.props.maxDate)) > endOfMonth(new Date(this.state.currentDateView)); + const hasAvailableDatesPrevMonth = endOfDay(new Date(this.props.minDate)) < startOfMonth(new Date(this.state.currentDateView)); return ( @@ -219,7 +219,7 @@ class CalendarPicker extends React.PureComponent { const isBeforeMinDate = currentDate < startOfDay(new Date(this.props.minDate)); const isAfterMaxDate = currentDate > startOfDay(new Date(this.props.maxDate)); const isDisabled = !day || isBeforeMinDate || isAfterMaxDate; - const isSelected = isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day)); + const isSelected = !!day && isSameDay(parseISO(this.props.value), new Date(currentYearView, currentMonthView, day)); return ( { - Log.info('[PlaidLink] Handled Plaid Event: ', false, event); - props.onEvent(event.eventName, event.metadata); - }); - useEffect(() => { - props.onEvent(CONST.BANK_ACCOUNT.PLAID.EVENTS_NAME.OPEN, {}); - openLink({ - tokenConfig: { - token: props.token, - }, - onSuccess: ({publicToken, metadata}) => { - props.onSuccess({publicToken, metadata}); - }, - onExit: (exitError, metadata) => { - Log.info('[PlaidLink] Exit: ', false, {exitError, metadata}); - props.onExit(); - }, - }); - - return () => { - dismissLink(); - }; - - // We generally do not need to include the token as a dependency here as it is only provided once via props and should not change - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - return null; -} - -PlaidLink.propTypes = plaidLinkPropTypes; -PlaidLink.defaultProps = plaidLinkDefaultProps; -PlaidLink.displayName = 'PlaidLink'; - -export default PlaidLink; diff --git a/src/components/PlaidLink/index.native.tsx b/src/components/PlaidLink/index.native.tsx new file mode 100644 index 000000000000..c46a9df2076e --- /dev/null +++ b/src/components/PlaidLink/index.native.tsx @@ -0,0 +1,40 @@ +import {useEffect} from 'react'; +import {dismissLink, LinkEvent, openLink, usePlaidEmitter} from 'react-native-plaid-link-sdk'; +import Log from '@libs/Log'; +import CONST from '@src/CONST'; +import PlaidLinkProps from './types'; + +function PlaidLink({token, onSuccess = () => {}, onExit = () => {}, onEvent}: PlaidLinkProps) { + usePlaidEmitter((event: LinkEvent) => { + Log.info('[PlaidLink] Handled Plaid Event: ', false, {...event}); + onEvent(event.eventName, event.metadata); + }); + useEffect(() => { + onEvent(CONST.BANK_ACCOUNT.PLAID.EVENTS_NAME.OPEN); + openLink({ + tokenConfig: { + token, + noLoadingState: false, + }, + onSuccess: ({publicToken, metadata}) => { + onSuccess({publicToken, metadata}); + }, + onExit: ({error, metadata}) => { + Log.info('[PlaidLink] Exit: ', false, {error, metadata}); + onExit(); + }, + }); + + return () => { + dismissLink(); + }; + + // We generally do not need to include the token as a dependency here as it is only provided once via props and should not change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return null; +} + +PlaidLink.displayName = 'PlaidLink'; + +export default PlaidLink; diff --git a/src/components/PlaidLink/index.js b/src/components/PlaidLink/index.tsx similarity index 70% rename from src/components/PlaidLink/index.js rename to src/components/PlaidLink/index.tsx index 790206f34ce7..2109771473aa 100644 --- a/src/components/PlaidLink/index.js +++ b/src/components/PlaidLink/index.tsx @@ -1,35 +1,33 @@ import {useCallback, useEffect, useState} from 'react'; -import {usePlaidLink} from 'react-plaid-link'; +import {PlaidLinkOnSuccessMetadata, usePlaidLink} from 'react-plaid-link'; import Log from '@libs/Log'; -import {plaidLinkDefaultProps, plaidLinkPropTypes} from './plaidLinkPropTypes'; +import PlaidLinkProps from './types'; -function PlaidLink(props) { +function PlaidLink({token, onSuccess = () => {}, onError = () => {}, onExit = () => {}, onEvent, receivedRedirectURI}: PlaidLinkProps) { const [isPlaidLoaded, setIsPlaidLoaded] = useState(false); - const onSuccess = props.onSuccess; - const onError = props.onError; const successCallback = useCallback( - (publicToken, metadata) => { + (publicToken: string, metadata: PlaidLinkOnSuccessMetadata) => { onSuccess({publicToken, metadata}); }, [onSuccess], ); const {open, ready, error} = usePlaidLink({ - token: props.token, + token, onSuccess: successCallback, onExit: (exitError, metadata) => { Log.info('[PlaidLink] Exit: ', false, {exitError, metadata}); - props.onExit(); + onExit(); }, onEvent: (event, metadata) => { Log.info('[PlaidLink] Event: ', false, {event, metadata}); - props.onEvent(event, metadata); + onEvent(event, metadata); }, onLoad: () => setIsPlaidLoaded(true), // The redirect URI with an OAuth state ID. Needed to re-initialize the PlaidLink after directing the // user to their respective bank platform - receivedRedirectUri: props.receivedRedirectURI, + receivedRedirectUri: receivedRedirectURI, }); useEffect(() => { @@ -52,7 +50,5 @@ function PlaidLink(props) { return null; } -PlaidLink.propTypes = plaidLinkPropTypes; -PlaidLink.defaultProps = plaidLinkDefaultProps; PlaidLink.displayName = 'PlaidLink'; export default PlaidLink; diff --git a/src/components/PlaidLink/nativeModule/index.android.js b/src/components/PlaidLink/nativeModule/index.android.js deleted file mode 100644 index d4280feddb8e..000000000000 --- a/src/components/PlaidLink/nativeModule/index.android.js +++ /dev/null @@ -1,3 +0,0 @@ -import {NativeModules} from 'react-native'; - -export default NativeModules.PlaidAndroid; diff --git a/src/components/PlaidLink/nativeModule/index.ios.js b/src/components/PlaidLink/nativeModule/index.ios.js deleted file mode 100644 index 78d4315eac2d..000000000000 --- a/src/components/PlaidLink/nativeModule/index.ios.js +++ /dev/null @@ -1,3 +0,0 @@ -import {NativeModules} from 'react-native'; - -export default NativeModules.RNLinksdk; diff --git a/src/components/PlaidLink/plaidLinkPropTypes.js b/src/components/PlaidLink/types.ts similarity index 50% rename from src/components/PlaidLink/plaidLinkPropTypes.js rename to src/components/PlaidLink/types.ts index 6d647d26f17e..1034eb935f74 100644 --- a/src/components/PlaidLink/plaidLinkPropTypes.js +++ b/src/components/PlaidLink/types.ts @@ -1,31 +1,25 @@ -import PropTypes from 'prop-types'; +import {LinkEventMetadata, LinkSuccessMetadata} from 'react-native-plaid-link-sdk'; +import {PlaidLinkOnEventMetadata, PlaidLinkOnSuccessMetadata} from 'react-plaid-link'; -const plaidLinkPropTypes = { +type PlaidLinkProps = { // Plaid Link SDK public token used to initialize the Plaid SDK - token: PropTypes.string.isRequired, + token: string; // Callback to execute once the user taps continue after successfully entering their account information - onSuccess: PropTypes.func, + onSuccess?: (args: {publicToken: string; metadata: PlaidLinkOnSuccessMetadata | LinkSuccessMetadata}) => void; // Callback to execute when there is an error event emitted by the Plaid SDK - onError: PropTypes.func, + onError?: (error: ErrorEvent | null) => void; // Callback to execute when the user leaves the Plaid widget flow without entering any information - onExit: PropTypes.func, + onExit?: () => void; // Callback to execute whenever a Plaid event occurs - onEvent: PropTypes.func, + onEvent: (eventName: string, metadata?: PlaidLinkOnEventMetadata | LinkEventMetadata) => void; // The redirect URI with an OAuth state ID. Needed to re-initialize the PlaidLink after directing the // user to their respective bank platform - receivedRedirectURI: PropTypes.string, + receivedRedirectURI?: string; }; -const plaidLinkDefaultProps = { - onSuccess: () => {}, - onError: () => {}, - onExit: () => {}, - receivedRedirectURI: null, -}; - -export {plaidLinkPropTypes, plaidLinkDefaultProps}; +export default PlaidLinkProps; diff --git a/src/components/Pressable/GenericPressable/index.tsx b/src/components/Pressable/GenericPressable/index.tsx index e0436c26c8fe..e050e23c8e9a 100644 --- a/src/components/Pressable/GenericPressable/index.tsx +++ b/src/components/Pressable/GenericPressable/index.tsx @@ -13,7 +13,7 @@ function WebGenericPressable({focusable = true, ...props}: PressableProps, ref: ref={ref} // change native accessibility props to web accessibility props focusable={focusable} - tabIndex={!accessible || !focusable ? -1 : 0} + tabIndex={props.tabIndex ?? (!accessible || !focusable) ? -1 : 0} role={props.accessibilityRole as Role} id={props.nativeID} aria-label={props.accessibilityLabel} diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index b83710bd85bf..f8f9b345f855 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -1,4 +1,5 @@ import React, {useState} from 'react'; +import {View} from 'react-native'; import useThemeStyles from '@styles/useThemeStyles'; import RadioButtonWithLabel from './RadioButtonWithLabel'; @@ -20,7 +21,7 @@ function RadioButtons({items, onPress}: RadioButtonsProps) { const [checkedValue, setCheckedValue] = useState(''); return ( - <> + {items.map((item) => ( ))} - + ); } RadioButtons.displayName = 'RadioButtons'; -export type {Choice}; export default RadioButtons; diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 466a5a6eec51..652fb5a84e76 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -170,8 +170,7 @@ function MoneyRequestPreview(props) { const isDeleted = lodashGet(props.action, 'pendingAction', null) === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan - const shouldShowMerchant = - !_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; + const shouldShowMerchant = !_.isEmpty(requestMerchant) && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant && !isScanning; const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : []; @@ -322,7 +321,7 @@ function MoneyRequestPreview(props) { )} - {shouldShowMerchant && ( + {shouldShowMerchant && !props.isBillSplit && ( {hasPendingWaypoints ? requestMerchant.replace(CONST.REGEX.FIRST_SPACE, props.translate('common.tbd')) : requestMerchant} @@ -334,7 +333,9 @@ function MoneyRequestPreview(props) { {!isCurrentUserManager && props.shouldShowPendingConversionMessage && ( {props.translate('iou.pendingConversionMessage')} )} - {shouldShowDescription && {description}} + {(shouldShowDescription || (shouldShowMerchant && props.isBillSplit)) && ( + {shouldShowDescription ? description : requestMerchant} + )} {props.isBillSplit && !_.isEmpty(participantAccountIDs) && requestAmount > 0 && ( diff --git a/src/components/SingleChoiceQuestion.tsx b/src/components/SingleChoiceQuestion.tsx deleted file mode 100644 index 07d4dfe817dd..000000000000 --- a/src/components/SingleChoiceQuestion.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React, {ForwardedRef, forwardRef} from 'react'; -import {Text as RNText} from 'react-native'; -import useThemeStyles from '@styles/useThemeStyles'; -import FormHelpMessage from './FormHelpMessage'; -import RadioButtons, {Choice} from './RadioButtons'; -import Text from './Text'; - -type SingleChoiceQuestionProps = { - prompt: string; - errorText?: string | string[]; - possibleAnswers: Choice[]; - currentQuestionIndex: number; - onInputChange: (value: string) => void; -}; - -function SingleChoiceQuestion({prompt, errorText, possibleAnswers, currentQuestionIndex, onInputChange}: SingleChoiceQuestionProps, ref: ForwardedRef) { - const styles = useThemeStyles(); - - return ( - <> - - {prompt} - - - - - ); -} - -SingleChoiceQuestion.displayName = 'SingleChoiceQuestion'; - -export default forwardRef(SingleChoiceQuestion); diff --git a/src/components/withCurrentReportID.tsx b/src/components/withCurrentReportID.tsx index 22da02159073..3ce9eeae37b5 100644 --- a/src/components/withCurrentReportID.tsx +++ b/src/components/withCurrentReportID.tsx @@ -8,6 +8,7 @@ type CurrentReportIDContextValue = { updateCurrentReportID: (state: NavigationState) => void; currentReportID: string; }; + type CurrentReportIDContextProviderProps = { /** Actual content wrapped by this component */ children: React.ReactNode; diff --git a/src/components/withNavigation.tsx b/src/components/withNavigation.tsx index 0834eabc2adb..88788edafb79 100644 --- a/src/components/withNavigation.tsx +++ b/src/components/withNavigation.tsx @@ -1,9 +1,10 @@ import {NavigationProp, useNavigation} from '@react-navigation/native'; import React, {ComponentType, ForwardedRef, RefAttributes} from 'react'; import getComponentDisplayName from '@libs/getComponentDisplayName'; +import {RootStackParamList} from '@libs/Navigation/types'; type WithNavigationProps = { - navigation: NavigationProp; + navigation: NavigationProp; }; export default function withNavigation( diff --git a/src/hooks/useFlipper/index.js b/src/hooks/useFlipper/index.js deleted file mode 100644 index 2d1ec238274a..000000000000 --- a/src/hooks/useFlipper/index.js +++ /dev/null @@ -1 +0,0 @@ -export default () => {}; diff --git a/src/hooks/useFlipper/index.native.js b/src/hooks/useFlipper/index.native.js deleted file mode 100644 index 90779d5b8a14..000000000000 --- a/src/hooks/useFlipper/index.native.js +++ /dev/null @@ -1,3 +0,0 @@ -import {useFlipper} from '@react-navigation/devtools'; - -export default useFlipper; diff --git a/src/hooks/useFlipper/index.native.ts b/src/hooks/useFlipper/index.native.ts new file mode 100644 index 000000000000..df1aa3bf513b --- /dev/null +++ b/src/hooks/useFlipper/index.native.ts @@ -0,0 +1,6 @@ +import {useFlipper as useFlipperRN} from '@react-navigation/devtools'; +import UseFlipper from './types'; + +const useFlipper: UseFlipper = useFlipperRN; + +export default useFlipper; diff --git a/src/hooks/useFlipper/index.ts b/src/hooks/useFlipper/index.ts new file mode 100644 index 000000000000..26d4c9659ad8 --- /dev/null +++ b/src/hooks/useFlipper/index.ts @@ -0,0 +1,5 @@ +import UseFlipper from './types'; + +const useFlipper: UseFlipper = () => {}; + +export default useFlipper; diff --git a/src/hooks/useFlipper/types.ts b/src/hooks/useFlipper/types.ts new file mode 100644 index 000000000000..a995414e5dd1 --- /dev/null +++ b/src/hooks/useFlipper/types.ts @@ -0,0 +1,6 @@ +import {NavigationContainerRefWithCurrent} from '@react-navigation/core'; +import {RootStackParamList} from '@libs/Navigation/types'; + +type UseFlipper = (ref: NavigationContainerRefWithCurrent) => void; + +export default UseFlipper; diff --git a/src/languages/en.ts b/src/languages/en.ts index d661ee1ad97b..ea0444342ce8 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1191,7 +1191,7 @@ export default { noBankAccountAvailable: 'Sorry, no bank account is available', noBankAccountSelected: 'Please choose an account', taxID: 'Please enter a valid tax ID number', - website: 'Please enter a valid website', + website: 'Please enter a valid website. The website should be in lowercase.', zipCode: `Incorrect zip code format. Acceptable format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Please enter a valid phone number', companyName: 'Please enter a valid legal business name', @@ -1952,7 +1952,7 @@ export default { buttonText1: 'Request money, ', buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, header: `Request money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, - body1: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + body1: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, }, [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { buttonText1: 'Send money, ', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6ea01dc4bd14..a69edd7355ad 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1208,7 +1208,7 @@ export default { noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', noBankAccountSelected: 'Por favor, elige una cuenta bancaria', taxID: 'Por favor, introduce un número de identificación fiscal válido', - website: 'Por favor, introduce un sitio web válido', + website: 'Por favor, introduce un sitio web válido. El sitio web debe estar en minúsculas.', zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`, phoneNumber: 'Por favor, introduce un teléfono válido', companyName: 'Por favor, introduce un nombre comercial legal válido', diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts index 0dc483aff50e..b0d426c9774a 100644 --- a/src/libs/CardUtils.ts +++ b/src/libs/CardUtils.ts @@ -37,6 +37,14 @@ function isExpensifyCard(cardID: number) { return card.bank === CONST.EXPENSIFY_CARD.BANK; } +/** + * @param cardID + * @returns boolean if the cardID is in the cardList from ONYX. Includes Expensify Cards. + */ +function isCorporateCard(cardID: number) { + return !!allCards[cardID]; +} + /** * @param cardID * @returns string in format % - %. @@ -99,4 +107,4 @@ function findPhysicalCard(cards: Card[]) { return cards.find((card) => !card.isVirtual); } -export {isExpensifyCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard}; +export {isExpensifyCard, isCorporateCard, getDomainCards, getMonthFromExpirationDateString, getYearFromExpirationDateString, maskCard, getCardDescription, findPhysicalCard}; diff --git a/src/libs/Navigation/AppNavigator/PublicScreens.js b/src/libs/Navigation/AppNavigator/PublicScreens.js index b50769c7caed..09b5c0fd7734 100644 --- a/src/libs/Navigation/AppNavigator/PublicScreens.js +++ b/src/libs/Navigation/AppNavigator/PublicScreens.js @@ -26,27 +26,27 @@ function PublicScreens() { component={LogInWithShortLivedAuthTokenPage} /> diff --git a/src/libs/Navigation/FreezeWrapper.js b/src/libs/Navigation/FreezeWrapper.tsx similarity index 65% rename from src/libs/Navigation/FreezeWrapper.js rename to src/libs/Navigation/FreezeWrapper.tsx index 16a353ebddea..df3f117c9a2e 100644 --- a/src/libs/Navigation/FreezeWrapper.js +++ b/src/libs/Navigation/FreezeWrapper.tsx @@ -1,31 +1,24 @@ import {useIsFocused, useNavigation, useRoute} from '@react-navigation/native'; -import lodashFindIndex from 'lodash/findIndex'; -import PropTypes from 'prop-types'; import React, {useEffect, useRef, useState} from 'react'; import {Freeze} from 'react-freeze'; import {InteractionManager} from 'react-native'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; -const propTypes = { +type FreezeWrapperProps = ChildrenProps & { /** Prop to disable freeze */ - keepVisible: PropTypes.bool, - /** Children to wrap in FreezeWrapper. */ - children: PropTypes.node.isRequired, + keepVisible?: boolean; }; -const defaultProps = { - keepVisible: false, -}; - -function FreezeWrapper(props) { +function FreezeWrapper({keepVisible = false, children}: FreezeWrapperProps) { const [isScreenBlurred, setIsScreenBlurred] = useState(false); // we need to know the screen index to determine if the screen can be frozen - const screenIndexRef = useRef(null); + const screenIndexRef = useRef(null); const isFocused = useIsFocused(); const navigation = useNavigation(); const currentRoute = useRoute(); useEffect(() => { - const index = lodashFindIndex(navigation.getState().routes, (route) => route.key === currentRoute.key); + const index = navigation.getState().routes.findIndex((route) => route.key === currentRoute.key); screenIndexRef.current = index; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -35,7 +28,7 @@ function FreezeWrapper(props) { // if the screen is more than 1 screen away from the current screen, freeze it, // we don't want to freeze the screen if it's the previous screen because the freeze placeholder // would be visible at the beginning of the back animation then - if (navigation.getState().index - screenIndexRef.current > 1) { + if (navigation.getState().index - (screenIndexRef.current ?? 0) > 1) { InteractionManager.runAfterInteractions(() => setIsScreenBlurred(true)); } else { setIsScreenBlurred(false); @@ -44,11 +37,9 @@ function FreezeWrapper(props) { return () => unsubscribe(); }, [isFocused, isScreenBlurred, navigation]); - return {props.children}; + return {children}; } -FreezeWrapper.propTypes = propTypes; -FreezeWrapper.defaultProps = defaultProps; FreezeWrapper.displayName = 'FreezeWrapper'; export default FreezeWrapper; diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.ts similarity index 61% rename from src/libs/Navigation/Navigation.js rename to src/libs/Navigation/Navigation.ts index bfc0f509373e..c2dd3e76e7ad 100644 --- a/src/libs/Navigation/Navigation.js +++ b/src/libs/Navigation/Navigation.ts @@ -1,11 +1,10 @@ import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; -import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; -import _ from 'lodash'; -import lodashGet from 'lodash/get'; +import {CommonActions, EventMapCore, getPathFromState, NavigationState, PartialState, StackActions} from '@react-navigation/native'; +import findLastIndex from 'lodash/findLastIndex'; import Log from '@libs/Log'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {Route} from '@src/ROUTES'; import SCREENS, {PROTECTED_SCREENS} from '@src/SCREENS'; import getStateFromPath from './getStateFromPath'; import originalGetTopmostReportActionId from './getTopmostReportActionID'; @@ -13,13 +12,14 @@ import originalGetTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; import linkTo from './linkTo'; import navigationRef from './navigationRef'; +import {StackNavigationAction, StateOrRoute} from './types'; -let resolveNavigationIsReadyPromise; -const navigationIsReadyPromise = new Promise((resolve) => { +let resolveNavigationIsReadyPromise: () => void; +const navigationIsReadyPromise = new Promise((resolve) => { resolveNavigationIsReadyPromise = resolve; }); -let pendingRoute = null; +let pendingRoute: Route | null = null; let shouldPopAllStateOnUP = false; @@ -30,12 +30,7 @@ function setShouldPopAllStateOnUP() { shouldPopAllStateOnUP = true; } -/** - * @param {String} methodName - * @param {Object} params - * @returns {Boolean} - */ -function canNavigate(methodName, params = {}) { +function canNavigate(methodName: string, params: Record = {}): boolean { if (navigationRef.isReady()) { return true; } @@ -49,37 +44,32 @@ const getTopmostReportId = (state = navigationRef.getState()) => originalGetTopm // Re-exporting the getTopmostReportActionID here to fill in default value for state. The getTopmostReportActionID isn't defined in this file to avoid cyclic dependencies. const getTopmostReportActionId = (state = navigationRef.getState()) => originalGetTopmostReportActionId(state); -/** - * Method for finding on which index in stack we are. - * @param {Object} route - * @param {Number} index - * @returns {Number} - */ -const getActiveRouteIndex = function (route, index) { - if (route.routes) { - const childActiveRoute = route.routes[route.index || 0]; - return getActiveRouteIndex(childActiveRoute, route.index || 0); +/** Method for finding on which index in stack we are. */ +function getActiveRouteIndex(stateOrRoute: StateOrRoute, index?: number): number | undefined { + if ('routes' in stateOrRoute && stateOrRoute.routes) { + const childActiveRoute = stateOrRoute.routes[stateOrRoute.index ?? 0]; + return getActiveRouteIndex(childActiveRoute, stateOrRoute.index ?? 0); } - if (route.state && route.state.routes) { - const childActiveRoute = route.state.routes[route.state.index || 0]; - return getActiveRouteIndex(childActiveRoute, route.state.index || 0); + if ('state' in stateOrRoute && stateOrRoute.state?.routes) { + const childActiveRoute = stateOrRoute.state.routes[stateOrRoute.state.index ?? 0]; + return getActiveRouteIndex(childActiveRoute, stateOrRoute.state.index ?? 0); } - if (route.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + if ('name' in stateOrRoute && stateOrRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { return 0; } return index; -}; +} /** * Gets distance from the path in root navigator. In other words how much screen you have to pop to get to the route with this path. * The search is limited to 5 screens from the top for performance reasons. - * @param {String} path - Path that you are looking for. - * @return {Number} - Returns distance to path or -1 if the path is not found in root navigator. + * @param path - Path that you are looking for. + * @return - Returns distance to path or -1 if the path is not found in root navigator. */ -function getDistanceFromPathInRootNavigator(path) { +function getDistanceFromPathInRootNavigator(path: string): number { let currentState = navigationRef.getRootState(); for (let index = 0; index < 5; index++) { @@ -98,14 +88,10 @@ function getDistanceFromPathInRootNavigator(path) { return -1; } -/** - * Returns the current active route - * @returns {String} - */ -function getActiveRoute() { +/** Returns the current active route */ +function getActiveRoute(): string { const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute(); - const currentRouteHasName = lodashGet(currentRoute, 'name', false); - if (!currentRouteHasName) { + if (!currentRoute?.name) { return ''; } @@ -124,20 +110,19 @@ function getActiveRoute() { * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path * is undefined in the first navigation. * - * @param {String} routePath Path to check - * @return {Boolean} is active + * @param routePath Path to check + * @return is active */ -function isActiveRoute(routePath) { +function isActiveRoute(routePath: Route): boolean { // We remove First forward slash from the URL before matching return getActiveRoute().substring(1) === routePath; } /** * Main navigation method for redirecting to a route. - * @param {String} route - * @param {String} [type] - Type of action to perform. Currently UP is supported. + * @param [type] - Type of action to perform. Currently UP is supported. */ -function navigate(route = ROUTES.HOME, type) { +function navigate(route: Route = ROUTES.HOME, type?: string) { if (!canNavigate('navigate', {route})) { // Store intended route if the navigator is not yet available, // we will try again after the NavigationContainer is ready @@ -149,11 +134,11 @@ function navigate(route = ROUTES.HOME, type) { } /** - * @param {String} fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP - * @param {Boolean} shouldEnforceFallback - Enforces navigation to fallback route - * @param {Boolean} shouldPopToTop - Should we navigate to LHN on back press + * @param fallbackRoute - Fallback route if pop/goBack action should, but is not possible within RHP + * @param shouldEnforceFallback - Enforces navigation to fallback route + * @param shouldPopToTop - Should we navigate to LHN on back press */ -function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = false) { +function goBack(fallbackRoute: Route, shouldEnforceFallback = false, shouldPopToTop = false) { if (!canNavigate('goBack')) { return; } @@ -161,12 +146,12 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f if (shouldPopToTop) { if (shouldPopAllStateOnUP) { shouldPopAllStateOnUP = false; - navigationRef.current.dispatch(StackActions.popToTop()); + navigationRef.current?.dispatch(StackActions.popToTop()); return; } } - if (!navigationRef.current.canGoBack()) { + if (!navigationRef.current?.canGoBack()) { Log.hmmm('[Navigation] Unable to go back'); return; } @@ -174,9 +159,9 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f const isFirstRouteInNavigator = !getActiveRouteIndex(navigationRef.current.getState()); if (isFirstRouteInNavigator) { const rootState = navigationRef.getRootState(); - const lastRoute = _.last(rootState.routes); + const lastRoute = rootState.routes.at(-1); // If the user comes from a different flow (there is more than one route in RHP) we should go back to the previous flow on UP button press instead of using the fallbackRoute. - if (lastRoute.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && lastRoute.state.index > 0) { + if (lastRoute?.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && (lastRoute.state?.index ?? 0) > 0) { navigationRef.current.goBack(); return; } @@ -187,7 +172,7 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f return; } - const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState()).name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR; + const isCentralPaneFocused = findFocusedRoute(navigationRef.current.getState())?.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR; const distanceFromPathInRootNavigator = getDistanceFromPathInRootNavigator(fallbackRoute); // Allow CentralPane to use UP with fallback route if the path is not found in root navigator. @@ -196,7 +181,7 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f return; } - // Add posibility to go back more than one screen in root navigator if that screen is on the stack. + // Add possibility to go back more than one screen in root navigator if that screen is on the stack. if (isCentralPaneFocused && fallbackRoute && distanceFromPathInRootNavigator > 0) { navigationRef.current.dispatch(StackActions.pop(distanceFromPathInRootNavigator)); return; @@ -207,12 +192,9 @@ function goBack(fallbackRoute, shouldEnforceFallback = false, shouldPopToTop = f /** * Update route params for the specified route. - * - * @param {Object} params - * @param {String} routeKey */ -function setParams(params, routeKey) { - navigationRef.current.dispatch({ +function setParams(params: Record, routeKey: string) { + navigationRef.current?.dispatch({ ...CommonActions.setParams(params), source: routeKey, }); @@ -221,15 +203,15 @@ function setParams(params, routeKey) { /** * Dismisses the last modal stack if there is any * - * @param {String | undefined} targetReportID - The reportID to navigate to after dismissing the modal + * @param targetReportID - The reportID to navigate to after dismissing the modal */ -function dismissModal(targetReportID) { +function dismissModal(targetReportID?: string) { if (!canNavigate('dismissModal')) { return; } const rootState = navigationRef.getRootState(); - const lastRoute = _.last(rootState.routes); - switch (lastRoute.name) { + const lastRoute = rootState.routes.at(-1); + switch (lastRoute?.name) { case NAVIGATORS.RIGHT_MODAL_NAVIGATOR: case SCREENS.NOT_FOUND: case SCREENS.REPORT_ATTACHMENTS: @@ -237,16 +219,18 @@ function dismissModal(targetReportID) { if (targetReportID && targetReportID !== getTopmostReportId(rootState)) { const state = getStateFromPath(ROUTES.REPORT_WITH_ID.getRoute(targetReportID)); - const action = getActionFromState(state, linkingConfig.config); - action.type = 'REPLACE'; - navigationRef.current.dispatch(action); + const action: StackNavigationAction = getActionFromState(state, linkingConfig.config); + if (action) { + action.type = 'REPLACE'; + navigationRef.current?.dispatch(action); + } // If not-found page is in the route stack, we need to close it - } else if (targetReportID && _.some(rootState.routes, (route) => route.name === SCREENS.NOT_FOUND)) { + } else if (targetReportID && rootState.routes.some((route) => route.name === SCREENS.NOT_FOUND)) { const lastRouteIndex = rootState.routes.length - 1; - const centralRouteIndex = _.findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); - navigationRef.current.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key}); + const centralRouteIndex = findLastIndex(rootState.routes, (route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR); + navigationRef.current?.dispatch({...StackActions.pop(lastRouteIndex - centralRouteIndex), target: rootState.key}); } else { - navigationRef.current.dispatch({...StackActions.pop(), target: rootState.key}); + navigationRef.current?.dispatch({...StackActions.pop(), target: rootState.key}); } break; default: { @@ -257,21 +241,17 @@ function dismissModal(targetReportID) { /** * Returns the current active route without the URL params - * @returns {String} */ -function getActiveRouteWithoutParams() { +function getActiveRouteWithoutParams(): string { return getActiveRoute().replace(/\?.*/, ''); } -/** Returns the active route name from a state event from the navigationRef - * @param {Object} event - * @returns {String | undefined} - * */ -function getRouteNameFromStateEvent(event) { +/** Returns the active route name from a state event from the navigationRef */ +function getRouteNameFromStateEvent(event: EventMapCore['state']): string | undefined { if (!event.data.state) { return; } - const currentRouteName = event.data.state.routes.slice(-1).name; + const currentRouteName = event.data.state.routes.at(-1)?.name; // Check to make sure we have a route name if (currentRouteName) { @@ -292,10 +272,7 @@ function goToPendingRoute() { pendingRoute = null; } -/** - * @returns {Promise} - */ -function isNavigationReady() { +function isNavigationReady(): Promise { return navigationIsReadyPromise; } @@ -307,57 +284,50 @@ function setIsNavigationReady() { /** * Checks if the navigation state contains routes that are protected (over the auth wall). * - * @function - * @param {Object} state - react-navigation state object - * - * @returns {Boolean} + * @param state - react-navigation state object */ -function navContainsProtectedRoutes(state) { - if (!state || !state.routeNames || !_.isArray(state.routeNames)) { +function navContainsProtectedRoutes(state: NavigationState | PartialState | undefined): boolean { + if (!state?.routeNames || !Array.isArray(state.routeNames)) { return false; } - const protectedScreensName = _.values(PROTECTED_SCREENS); - const difference = _.difference(protectedScreensName, state.routeNames); - - return !difference.length; + const protectedScreensName = Object.values(PROTECTED_SCREENS); + return !protectedScreensName.some((screen) => !state.routeNames?.includes(screen)); } /** - * Waits for the navitgation state to contain protected routes specified in PROTECTED_SCREENS constant. - * If the navigation is in a state, where protected routes are avilable, the promise resolve immediately. + * Waits for the navigation state to contain protected routes specified in PROTECTED_SCREENS constant. + * If the navigation is in a state, where protected routes are available, the promise resolve immediately. * * @function - * @returns {Promise} A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree. + * @returns A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree. * * @example * waitForProtectedRoutes() * .then(()=> console.log('Protected routes are present!')) */ function waitForProtectedRoutes() { - return new Promise((resolve) => { + return new Promise((resolve) => { isNavigationReady().then(() => { - const currentState = navigationRef.current.getState(); + const currentState = navigationRef.current?.getState(); if (navContainsProtectedRoutes(currentState)) { resolve(); return; } - let unsubscribe; - const handleStateChange = ({data}) => { - const state = lodashGet(data, 'state'); + + const unsubscribe = navigationRef.current?.addListener('state', ({data}) => { + const state = data?.state; if (navContainsProtectedRoutes(state)) { - unsubscribe(); + unsubscribe?.(); resolve(); } - }; - unsubscribe = navigationRef.current.addListener('state', handleStateChange); + }); }); }); } export default { setShouldPopAllStateOnUP, - canNavigate, navigate, setParams, dismissModal, @@ -371,7 +341,6 @@ export default { getRouteNameFromStateEvent, getTopmostReportActionId, waitForProtectedRoutes, - navContainsProtectedRoutes, }; export {navigationRef}; diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.tsx similarity index 68% rename from src/libs/Navigation/NavigationRoot.js rename to src/libs/Navigation/NavigationRoot.tsx index 2373066cf4bd..b498bcdfdf4d 100644 --- a/src/libs/Navigation/NavigationRoot.js +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,7 +1,7 @@ -import {DefaultTheme, getPathFromState, NavigationContainer} from '@react-navigation/native'; -import PropTypes from 'prop-types'; +import {DefaultTheme, getPathFromState, NavigationContainer, NavigationState} from '@react-navigation/native'; import React, {useEffect, useRef} from 'react'; -import {Easing, interpolateColor, runOnJS, useAnimatedReaction, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; +import {ColorValue} from 'react-native'; +import {interpolateColor, runOnJS, useAnimatedReaction, useSharedValue, withDelay, withTiming} from 'react-native-reanimated'; import useCurrentReportID from '@hooks/useCurrentReportID'; import useFlipper from '@hooks/useFlipper'; import useWindowDimensions from '@hooks/useWindowDimensions'; @@ -21,19 +21,18 @@ const navigationTheme = { }, }; -const propTypes = { +type NavigationRootProps = { /** Whether the current user is logged in with an authToken */ - authenticated: PropTypes.bool.isRequired, + authenticated: boolean; /** Fired when react-navigation is ready */ - onReady: PropTypes.func.isRequired, + onReady: () => void; }; /** * Intercept navigation state changes and log it - * @param {NavigationState} state */ -function parseAndLogRoute(state) { +function parseAndLogRoute(state: NavigationState) { if (!state) { return; } @@ -50,11 +49,11 @@ function parseAndLogRoute(state) { Navigation.setIsNavigationReady(); } -function NavigationRoot(props) { +function NavigationRoot({authenticated, onReady}: NavigationRootProps) { useFlipper(navigationRef); const firstRenderRef = useRef(true); - const {updateCurrentReportID} = useCurrentReportID(); + const currentReportIDValue = useCurrentReportID(); const {isSmallScreenWidth} = useWindowDimensions(); useEffect(() => { @@ -72,24 +71,24 @@ function NavigationRoot(props) { }, [isSmallScreenWidth]); useEffect(() => { - if (!navigationRef.isReady() || !props.authenticated) { + if (!navigationRef.isReady() || !authenticated) { return; } // We need to force state rehydration so the CustomRouter can add the CentralPaneNavigator route if necessary. navigationRef.resetRoot(navigationRef.getRootState()); - }, [isSmallScreenWidth, props.authenticated]); + }, [isSmallScreenWidth, authenticated]); const prevStatusBarBackgroundColor = useRef(themeColors.appBG); const statusBarBackgroundColor = useRef(themeColors.appBG); const statusBarAnimation = useSharedValue(0); - const updateStatusBarBackgroundColor = (color) => StatusBar.setBackgroundColor(color); + const updateStatusBarBackgroundColor = (color: ColorValue) => StatusBar.setBackgroundColor(color); useAnimatedReaction( () => statusBarAnimation.value, (current, previous) => { // Do not run if either of the animated value is null // or previous animated value is greater than or equal to the current one - if ([current, previous].includes(null) || current <= previous) { + if (previous === null || current === null || current <= previous) { return; } const color = interpolateColor(statusBarAnimation.value, [0, 1], [prevStatusBarBackgroundColor.current, statusBarBackgroundColor.current]); @@ -99,7 +98,14 @@ function NavigationRoot(props) { const animateStatusBarBackgroundColor = () => { const currentRoute = navigationRef.getCurrentRoute(); - const currentScreenBackgroundColor = (currentRoute.params && currentRoute.params.backgroundColor) || themeColors.PAGE_BACKGROUND_COLORS[currentRoute.name] || themeColors.appBG; + + const backgroundColorFromRoute = + currentRoute?.params && 'backgroundColor' in currentRoute.params && typeof currentRoute.params.backgroundColor === 'string' && currentRoute.params.backgroundColor; + const backgroundColorFallback = themeColors.PAGE_BACKGROUND_COLORS[currentRoute?.name as keyof typeof themeColors.PAGE_BACKGROUND_COLORS] || themeColors.appBG; + + // It's possible for backgroundColorFromRoute to be empty string, so we must use "||" to fallback to backgroundColorFallback. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const currentScreenBackgroundColor = backgroundColorFromRoute || backgroundColorFallback; prevStatusBarBackgroundColor.current = statusBarBackgroundColor.current; statusBarBackgroundColor.current = currentScreenBackgroundColor; @@ -109,22 +115,17 @@ function NavigationRoot(props) { } statusBarAnimation.value = 0; - statusBarAnimation.value = withDelay( - 300, - withTiming(1, { - duration: 300, - easing: Easing.in, - }), - ); + statusBarAnimation.value = withDelay(300, withTiming(1)); }; - const handleStateChange = (state) => { + const handleStateChange = (state: NavigationState | undefined) => { if (!state) { return; } + // Performance optimization to avoid context consumers to delay first render setTimeout(() => { - updateCurrentReportID(state); + currentReportIDValue?.updateCurrentReportID(state); }, 0); parseAndLogRoute(state); animateStatusBarBackgroundColor(); @@ -133,7 +134,7 @@ function NavigationRoot(props) { return ( - + ); } NavigationRoot.displayName = 'NavigationRoot'; -NavigationRoot.propTypes = propTypes; + export default NavigationRoot; diff --git a/src/libs/Navigation/OnyxTabNavigator.js b/src/libs/Navigation/OnyxTabNavigator.tsx similarity index 54% rename from src/libs/Navigation/OnyxTabNavigator.js rename to src/libs/Navigation/OnyxTabNavigator.tsx index eeed3e0cd270..1ea57e773323 100644 --- a/src/libs/Navigation/OnyxTabNavigator.js +++ b/src/libs/Navigation/OnyxTabNavigator.tsx @@ -1,31 +1,33 @@ -import {createMaterialTopTabNavigator} from '@react-navigation/material-top-tabs'; -import PropTypes from 'prop-types'; +import {createMaterialTopTabNavigator, MaterialTopTabNavigationEventMap} from '@react-navigation/material-top-tabs'; +import {EventMapCore, NavigationState, ScreenListeners} from '@react-navigation/native'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; +import {OnyxEntry} from 'react-native-onyx/lib/types'; import Tab from '@userActions/Tab'; import ONYXKEYS from '@src/ONYXKEYS'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; -const propTypes = { - /** ID of the tab component to be saved in onyx */ - id: PropTypes.string.isRequired, +type OnyxTabNavigatorOnyxProps = { + selectedTab: OnyxEntry; +}; - /** Name of the selected tab */ - selectedTab: PropTypes.string, +type OnyxTabNavigatorProps = OnyxTabNavigatorOnyxProps & + ChildrenProps & { + /** ID of the tab component to be saved in onyx */ + id: string; - /** Children nodes */ - children: PropTypes.node.isRequired, -}; + /** Name of the selected tab */ + selectedTab?: string; -const defaultProps = { - selectedTab: '', -}; + screenListeners?: ScreenListeners; + }; // eslint-disable-next-line rulesdir/no-inline-named-export export const TopTab = createMaterialTopTabNavigator(); // This takes all the same props as MaterialTopTabsNavigator: https://reactnavigation.org/docs/material-top-tab-navigator/#props, // except ID is now required, and it gets a `selectedTab` from Onyx -function OnyxTabNavigator({id, selectedTab, children, ...rest}) { +function OnyxTabNavigator({id, selectedTab = '', children, screenListeners, ...rest}: OnyxTabNavigatorProps) { return ( { + state: (e) => { + const event = e as unknown as EventMapCore['state']; const state = event.data.state; const index = state.index; const routeNames = state.routeNames; Tab.setSelectedTab(id, routeNames[index]); }, - ...(rest.screenListeners || {}), + ...(screenListeners ?? {}), }} > {children} @@ -49,11 +52,9 @@ function OnyxTabNavigator({id, selectedTab, children, ...rest}) { ); } -OnyxTabNavigator.defaultProps = defaultProps; -OnyxTabNavigator.propTypes = propTypes; OnyxTabNavigator.displayName = 'OnyxTabNavigator'; -export default withOnyx({ +export default withOnyx({ selectedTab: { key: ({id}) => `${ONYXKEYS.COLLECTION.SELECTED_TAB}${id}`, }, diff --git a/src/libs/Navigation/getStateFromPath.js b/src/libs/Navigation/getStateFromPath.ts similarity index 57% rename from src/libs/Navigation/getStateFromPath.js rename to src/libs/Navigation/getStateFromPath.ts index f2564c9d2512..3a53b02fc3c7 100644 --- a/src/libs/Navigation/getStateFromPath.js +++ b/src/libs/Navigation/getStateFromPath.ts @@ -1,11 +1,12 @@ -import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {Route} from '@src/ROUTES'; import linkingConfig from './linkingConfig'; /** - * @param {String} path - The path to parse - * @returns {Object | undefined} - It's possible that there is no navigation action for the given path + * @param path - The path to parse + * @returns - It's possible that there is no navigation action for the given path */ -function getStateFromPath(path) { +function getStateFromPath(path: Route): PartialState { const normalizedPath = !path.startsWith('/') ? `/${path}` : path; const state = linkingConfig.getStateFromPath ? linkingConfig.getStateFromPath(normalizedPath, linkingConfig.config) : RNGetStateFromPath(normalizedPath, linkingConfig.config); @@ -13,6 +14,7 @@ function getStateFromPath(path) { if (!state) { throw new Error('Failed to parse the path to a navigation state.'); } + return state; } diff --git a/src/libs/Navigation/getTopmostReportActionID.js b/src/libs/Navigation/getTopmostReportActionID.js deleted file mode 100644 index a4480931cda0..000000000000 --- a/src/libs/Navigation/getTopmostReportActionID.js +++ /dev/null @@ -1,42 +0,0 @@ -import lodashFindLast from 'lodash/findLast'; -import lodashGet from 'lodash/get'; - -// This function is in a separate file than Navigation.js to avoid cyclic dependency. - -/** - * Find the last visited report screen in the navigation state and get the linked reportActionID of it. - * - * @param {Object} state - The react-navigation state - * @returns {String | undefined} - It's possible that there is no report screen - */ -function getTopmostReportActionID(state) { - if (!state) { - return; - } - const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator'); - - if (!topmostCentralPane) { - return; - } - - const directReportActionIDParam = lodashGet(topmostCentralPane, 'params.params.reportActionID'); - - if (!topmostCentralPane.state && !directReportActionIDParam) { - return; - } - - if (directReportActionIDParam) { - return directReportActionIDParam; - } - - const topmostReport = lodashFindLast(topmostCentralPane.state.routes, (route) => route.name === 'Report'); - if (!topmostReport) { - return; - } - - const topmostReportActionID = lodashGet(topmostReport, 'params.reportActionID'); - - return topmostReportActionID; -} - -export default getTopmostReportActionID; diff --git a/src/libs/Navigation/getTopmostReportActionID.ts b/src/libs/Navigation/getTopmostReportActionID.ts new file mode 100644 index 000000000000..15ab1efef704 --- /dev/null +++ b/src/libs/Navigation/getTopmostReportActionID.ts @@ -0,0 +1,48 @@ +import {NavigationState, PartialState} from '@react-navigation/native'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import {RootStackParamList} from './types'; + +// This function is in a separate file than Navigation.js to avoid cyclic dependency. + +/** + * Find the last visited report screen in the navigation state and get the linked reportActionID of it. + * + * @param state - The react-navigation state + * @returns - It's possible that there is no report screen + */ +function getTopmostReportActionID(state: NavigationState | NavigationState | PartialState): string | undefined { + if (!state) { + return; + } + + const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); + if (!topmostCentralPane) { + return; + } + + const directReportParams = topmostCentralPane.params && 'params' in topmostCentralPane.params && topmostCentralPane?.params?.params; + const directReportActionIDParam = directReportParams && 'reportActionID' in directReportParams && directReportParams?.reportActionID; + + if (!topmostCentralPane.state && !directReportActionIDParam) { + return; + } + + if (directReportActionIDParam) { + return directReportActionIDParam; + } + + const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1); + if (!topmostReport) { + return; + } + + const topmostReportActionID = topmostReport.params && 'reportActionID' in topmostReport.params && topmostReport.params?.reportActionID; + if (typeof topmostReportActionID !== 'string') { + return; + } + + return topmostReportActionID; +} + +export default getTopmostReportActionID; diff --git a/src/libs/Navigation/getTopmostReportId.js b/src/libs/Navigation/getTopmostReportId.js deleted file mode 100644 index 8ca9c39baf6a..000000000000 --- a/src/libs/Navigation/getTopmostReportId.js +++ /dev/null @@ -1,42 +0,0 @@ -import lodashFindLast from 'lodash/findLast'; -import lodashGet from 'lodash/get'; - -// This function is in a separate file than Navigation.js to avoid cyclic dependency. - -/** - * Find the last visited report screen in the navigation state and get the id of it. - * - * @param {Object} state - The react-navigation state - * @returns {String | undefined} - It's possible that there is no report screen - */ -function getTopmostReportId(state) { - if (!state) { - return; - } - const topmostCentralPane = lodashFindLast(state.routes, (route) => route.name === 'CentralPaneNavigator'); - - if (!topmostCentralPane) { - return; - } - - const directReportIdParam = lodashGet(topmostCentralPane, 'params.params.reportID'); - - if (!topmostCentralPane.state && !directReportIdParam) { - return; - } - - if (directReportIdParam) { - return directReportIdParam; - } - - const topmostReport = lodashFindLast(topmostCentralPane.state.routes, (route) => route.name === 'Report'); - if (!topmostReport) { - return; - } - - const topmostReportId = lodashGet(topmostReport, 'params.reportID'); - - return topmostReportId; -} - -export default getTopmostReportId; diff --git a/src/libs/Navigation/getTopmostReportId.ts b/src/libs/Navigation/getTopmostReportId.ts new file mode 100644 index 000000000000..3342761e7ccf --- /dev/null +++ b/src/libs/Navigation/getTopmostReportId.ts @@ -0,0 +1,48 @@ +import {NavigationState, PartialState} from '@react-navigation/native'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; +import {RootStackParamList} from './types'; + +// This function is in a separate file than Navigation.js to avoid cyclic dependency. + +/** + * Find the last visited report screen in the navigation state and get the id of it. + * + * @param state - The react-navigation state + * @returns - It's possible that there is no report screen + */ +function getTopmostReportId(state: NavigationState | NavigationState | PartialState): string | undefined { + if (!state) { + return; + } + + const topmostCentralPane = state.routes.filter((route) => route.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR).at(-1); + if (!topmostCentralPane) { + return; + } + + const directReportParams = topmostCentralPane.params && 'params' in topmostCentralPane.params && topmostCentralPane?.params?.params; + const directReportIdParam = directReportParams && 'reportID' in directReportParams && directReportParams?.reportID; + + if (!topmostCentralPane.state && !directReportIdParam) { + return; + } + + if (directReportIdParam) { + return directReportIdParam; + } + + const topmostReport = topmostCentralPane.state?.routes.filter((route) => route.name === SCREENS.REPORT).at(-1); + if (!topmostReport) { + return; + } + + const topmostReportId = topmostReport.params && 'reportID' in topmostReport.params && topmostReport.params?.reportID; + if (typeof topmostReportId !== 'string') { + return; + } + + return topmostReportId; +} + +export default getTopmostReportId; diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.ts similarity index 60% rename from src/libs/Navigation/linkTo.js rename to src/libs/Navigation/linkTo.ts index ca87a0d7b79c..1a4aa2d0cfb7 100644 --- a/src/libs/Navigation/linkTo.js +++ b/src/libs/Navigation/linkTo.ts @@ -1,39 +1,53 @@ import {getActionFromState} from '@react-navigation/core'; -import _ from 'lodash'; +import {NavigationAction, NavigationContainerRef, NavigationState, PartialState} from '@react-navigation/native'; +import {Writable} from 'type-fest'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; +import {Route} from '@src/ROUTES'; import getStateFromPath from './getStateFromPath'; import getTopmostReportId from './getTopmostReportId'; import linkingConfig from './linkingConfig'; +import {NavigationRoot, RootStackParamList, StackNavigationAction} from './types'; + +type ActionPayloadParams = { + screen?: string; + params?: unknown; + path?: string; +}; + +type ActionPayload = { + params?: ActionPayloadParams; +}; /** * Motivation for this function is described in NAVIGATION.md * - * @param {Object} action action generated by getActionFromState - * @param {Object} state The root state - * @returns {Object} minimalAction minimal action is the action that we should dispatch + * @param action action generated by getActionFromState + * @param state The root state + * @returns minimalAction minimal action is the action that we should dispatch */ -function getMinimalAction(action, state) { - let currentAction = action; - let currentState = state; - let currentTargetKey = null; +function getMinimalAction(action: NavigationAction, state: NavigationState): Writable { + let currentAction: NavigationAction = action; + let currentState: NavigationState | PartialState | undefined = state; + let currentTargetKey: string | undefined; - while (currentState.routes[currentState.index].name === currentAction.payload.name) { - if (!currentState.routes[currentState.index].state) { + while (currentAction.payload && 'name' in currentAction.payload && currentState?.routes[currentState.index ?? -1].name === currentAction.payload.name) { + if (!currentState?.routes[currentState.index ?? -1].state) { break; } - currentState = currentState.routes[currentState.index].state; + currentState = currentState?.routes[currentState.index ?? -1].state; + currentTargetKey = currentState?.key; - currentTargetKey = currentState.key; + const payload = currentAction.payload as ActionPayload; // Creating new smaller action currentAction = { type: currentAction.type, payload: { - name: currentAction.payload.params.screen, - params: currentAction.payload.params.params, - path: currentAction.payload.params.path, + name: payload?.params?.screen, + params: payload?.params?.params, + path: payload?.params?.path, }, target: currentTargetKey, }; @@ -41,13 +55,13 @@ function getMinimalAction(action, state) { return currentAction; } -export default function linkTo(navigation, path, type, isActiveRoute) { - if (navigation === undefined) { +export default function linkTo(navigation: NavigationContainerRef | null, path: Route, type?: string, isActiveRoute?: boolean) { + if (!navigation) { throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); } - let root = navigation; - let current; + let root: NavigationRoot = navigation; + let current: NavigationRoot | undefined; // Traverse up to get the root navigation // eslint-disable-next-line no-cond-assign @@ -55,18 +69,18 @@ export default function linkTo(navigation, path, type, isActiveRoute) { root = current; } + const rootState = root.getState(); const state = getStateFromPath(path); - - const action = getActionFromState(state, linkingConfig.config); + const action: StackNavigationAction = getActionFromState(state, linkingConfig.config); // If action type is different than NAVIGATE we can't change it to the PUSH safely - if (action.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { + if (action?.type === CONST.NAVIGATION.ACTION_TYPE.NAVIGATE) { // In case if type is 'FORCED_UP' we replace current screen with the provided. This means the current screen no longer exists in the stack if (type === CONST.NAVIGATION.TYPE.FORCED_UP) { action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; // If this action is navigating to the report screen and the top most navigator is different from the one we want to navigate - PUSH the new screen to the top of the stack - } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(root.getState()) !== getTopmostReportId(state)) { + } else if (action.payload.name === NAVIGATORS.CENTRAL_PANE_NAVIGATOR && getTopmostReportId(rootState) !== getTopmostReportId(state)) { action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; // If the type is UP, we deeplinked into one of the RHP flows and we want to replace the current screen with the previous one in the flow @@ -75,12 +89,12 @@ export default function linkTo(navigation, path, type, isActiveRoute) { action.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; // If this action is navigating to the RightModalNavigator and the last route on the root navigator is not RightModalNavigator then push - } else if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && _.last(root.getState().routes).name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + } else if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR && rootState.routes.at(-1)?.name !== NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { action.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; } } - if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { + if (action && 'payload' in action && action.payload && 'name' in action.payload && action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { const minimalAction = getMinimalAction(action, navigation.getRootState()); if (minimalAction) { // There are situations where a route already exists on the current navigation stack diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.ts similarity index 97% rename from src/libs/Navigation/linkingConfig.js rename to src/libs/Navigation/linkingConfig.ts index e0ac35c957a3..ae48d8e49201 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.ts @@ -1,21 +1,24 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {LinkingOptions} from '@react-navigation/native'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; +import {RootStackParamList} from './types'; -export default { +const linkingConfig: LinkingOptions = { prefixes: ['new-expensify://', 'https://www.expensify.cash', 'https://staging.expensify.cash', 'https://dev.new.expensify.com', CONST.NEW_EXPENSIFY_URL, CONST.STAGING_NEW_EXPENSIFY_URL], config: { initialRouteName: SCREENS.HOME, screens: { // Main Routes - ValidateLogin: ROUTES.VALIDATE_LOGIN, - UnlinkLogin: ROUTES.UNLINK_LOGIN, + [SCREENS.VALIDATE_LOGIN]: ROUTES.VALIDATE_LOGIN, + [SCREENS.UNLINK_LOGIN]: ROUTES.UNLINK_LOGIN, [SCREENS.TRANSITION_BETWEEN_APPS]: ROUTES.TRANSITION_BETWEEN_APPS, [SCREENS.CONCIERGE]: ROUTES.CONCIERGE, - AppleSignInDesktop: ROUTES.APPLE_SIGN_IN, - GoogleSignInDesktop: ROUTES.GOOGLE_SIGN_IN, - SAMLSignIn: ROUTES.SAML_SIGN_IN, + [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: ROUTES.APPLE_SIGN_IN, + [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: ROUTES.GOOGLE_SIGN_IN, + [SCREENS.SAML_SIGN_IN]: ROUTES.SAML_SIGN_IN, [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: ROUTES.DESKTOP_SIGN_IN_REDIRECT, [SCREENS.REPORT_ATTACHMENTS]: ROUTES.REPORT_ATTACHMENTS.route, @@ -448,3 +451,5 @@ export default { }, }, }; + +export default linkingConfig; diff --git a/src/libs/Navigation/navigationRef.js b/src/libs/Navigation/navigationRef.js deleted file mode 100644 index 00c98d178f7e..000000000000 --- a/src/libs/Navigation/navigationRef.js +++ /dev/null @@ -1,4 +0,0 @@ -import {createNavigationContainerRef} from '@react-navigation/native'; - -const navigationRef = createNavigationContainerRef(); -export default navigationRef; diff --git a/src/libs/Navigation/navigationRef.ts b/src/libs/Navigation/navigationRef.ts new file mode 100644 index 000000000000..032d9f9f3d9a --- /dev/null +++ b/src/libs/Navigation/navigationRef.ts @@ -0,0 +1,6 @@ +import {createNavigationContainerRef} from '@react-navigation/native'; +import {NavigationRef} from './types'; + +const navigationRef: NavigationRef = createNavigationContainerRef(); + +export default navigationRef; diff --git a/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js b/src/libs/Navigation/shouldPreventDeeplinkPrompt.ts similarity index 57% rename from src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js rename to src/libs/Navigation/shouldPreventDeeplinkPrompt.ts index 23f46cb9808f..2b19da1f5224 100644 --- a/src/libs/Navigation/shouldPreventDeeplinkPrompt/index.js +++ b/src/libs/Navigation/shouldPreventDeeplinkPrompt.ts @@ -2,12 +2,9 @@ import CONST from '@src/CONST'; /** * Determines if the deeplink prompt should be shown on the current screen - * @param {String} screenName - * @param {Boolean} isAuthenticated - * @returns {Boolean} */ -export default function shouldPreventDeeplinkPrompt(screenName) { +export default function shouldPreventDeeplinkPrompt(screenName: string): boolean { // We don't want to show the deeplink prompt on screens where a user is in the // authentication process, so we are blocking the prompt on the following screens (Denylist) - return CONST.DEEPLINK_PROMPT_DENYLIST.includes(screenName); + return CONST.DEEPLINK_PROMPT_DENYLIST.some((name) => name === screenName); } diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts new file mode 100644 index 000000000000..41df21d8e237 --- /dev/null +++ b/src/libs/Navigation/types.ts @@ -0,0 +1,401 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {CommonActions, NavigationContainerRefWithCurrent, NavigationHelpers, NavigationState, NavigatorScreenParams, PartialRoute, Route} from '@react-navigation/native'; +import {ValueOf} from 'type-fest'; +import CONST from '@src/CONST'; +import NAVIGATORS from '@src/NAVIGATORS'; +import SCREENS from '@src/SCREENS'; + +type NavigationRef = NavigationContainerRefWithCurrent; + +type NavigationRoot = NavigationHelpers; + +type GoBackAction = Extract; +type ResetAction = Extract; +type SetParamsAction = Extract; + +type ActionNavigate = { + type: ValueOf; + payload: { + name?: string; + key?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + params?: any; + path?: string; + merge?: boolean; + }; + source?: string; + target?: string; +}; + +type StackNavigationAction = GoBackAction | ResetAction | SetParamsAction | ActionNavigate | undefined; + +type NavigationStateRoute = NavigationState['routes'][number]; +type NavigationPartialRoute = PartialRoute>; +type StateOrRoute = NavigationState | NavigationStateRoute | NavigationPartialRoute; + +type CentralPaneNavigatorParamList = { + [SCREENS.REPORT]: { + reportActionID: string; + reportID: string; + }; +}; + +type SettingsNavigatorParamList = { + [SCREENS.SETTINGS.ROOT]: undefined; + Settings_Share_Code: undefined; + [SCREENS.SETTINGS.WORKSPACES]: undefined; + Settings_Profile: undefined; + Settings_Pronouns: undefined; + Settings_Display_Name: undefined; + Settings_Timezone: undefined; + Settings_Timezone_Select: undefined; + Settings_PersonalDetails_Initial: undefined; + Settings_PersonalDetails_LegalName: undefined; + Settings_PersonalDetails_DateOfBirth: undefined; + Settings_PersonalDetails_Address: undefined; + Settings_PersonalDetails_Address_Country: undefined; + Settings_ContactMethods: undefined; + Settings_ContactMethodDetails: undefined; + Settings_NewContactMethod: undefined; + [SCREENS.SETTINGS.PREFERENCES]: undefined; + Settings_Preferences_PriorityMode: undefined; + Settings_Preferences_Language: undefined; + Settings_Preferences_Theme: undefined; + Settings_Close: undefined; + [SCREENS.SETTINGS.SECURITY]: undefined; + Settings_About: undefined; + Settings_App_Download_Links: undefined; + Settings_Lounge_Access: undefined; + Settings_Wallet: undefined; + Settings_Wallet_Cards_Digital_Details_Update_Address: undefined; + Settings_Wallet_DomainCard: undefined; + Settings_Wallet_ReportVirtualCardFraud: undefined; + Settings_Wallet_Card_Activate: undefined; + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: undefined; + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: undefined; + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: undefined; + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: undefined; + Settings_Wallet_Transfer_Balance: undefined; + Settings_Wallet_Choose_Transfer_Account: undefined; + Settings_Wallet_EnablePayments: undefined; + Settings_Add_Debit_Card: undefined; + Settings_Add_Bank_Account: undefined; + [SCREENS.SETTINGS.STATUS]: undefined; + Settings_Status_Set: undefined; + Workspace_Initial: undefined; + Workspace_Settings: undefined; + Workspace_Settings_Currency: undefined; + Workspace_Card: { + policyID: string; + }; + Workspace_Reimburse: { + policyID: string; + }; + Workspace_RateAndUnit: undefined; + Workspace_Bills: { + policyID: string; + }; + Workspace_Invoices: { + policyID: string; + }; + Workspace_Travel: { + policyID: string; + }; + Workspace_Members: { + policyID: string; + }; + Workspace_Invite: { + policyID: string; + }; + Workspace_Invite_Message: { + policyID: string; + }; + ReimbursementAccount: { + stepToOpen: string; + policyID: string; + }; + GetAssistance: { + taskID: string; + }; + Settings_TwoFactorAuth: undefined; + Settings_ReportCardLostOrDamaged: undefined; + KeyboardShortcuts: undefined; +}; + +type NewChatNavigatorParamList = { + NewChat_Root: undefined; +}; + +type SearchNavigatorParamList = { + Search_Root: undefined; +}; + +type DetailsNavigatorParamList = { + Details_Root: { + login: string; + reportID: string; + }; +}; + +type ProfileNavigatorParamList = { + Profile_Root: { + accountID: string; + reportID: string; + }; +}; + +type ReportDetailsNavigatorParamList = { + Report_Details_Root: undefined; + Report_Details_Share_Code: { + reportID: string; + }; +}; + +type ReportSettingsNavigatorParamList = { + Report_Settings_Root: undefined; + Report_Settings_Room_Name: undefined; + Report_Settings_Notification_Preferences: undefined; + Report_Settings_Write_Capability: undefined; +}; + +type ReportWelcomeMessageNavigatorParamList = { + Report_WelcomeMessage_Root: {reportID: string}; +}; + +type ParticipantsNavigatorParamList = { + ReportParticipants_Root: {reportID: string}; +}; + +type RoomMembersNavigatorParamList = { + RoomMembers_Root: undefined; +}; + +type RoomInviteNavigatorParamList = { + RoomInvite_Root: undefined; +}; + +type MoneyRequestNavigatorParamList = { + Money_Request: undefined; + Money_Request_Amount: undefined; + Money_Request_Participants: { + iouType: string; + reportID: string; + }; + Money_Request_Confirmation: { + iouType: string; + reportID: string; + }; + Money_Request_Currency: { + iouType: string; + reportID: string; + currency: string; + backTo: string; + }; + Money_Request_Date: { + iouType: string; + reportID: string; + field: string; + threadReportID: string; + }; + Money_Request_Description: { + iouType: string; + reportID: string; + field: string; + threadReportID: string; + }; + Money_Request_Category: { + iouType: string; + reportID: string; + }; + Money_Request_Tag: { + iouType: string; + reportID: string; + }; + Money_Request_Merchant: { + iouType: string; + reportID: string; + field: string; + threadReportID: string; + }; + IOU_Send_Enable_Payments: undefined; + IOU_Send_Add_Bank_Account: undefined; + IOU_Send_Add_Debit_Card: undefined; + Money_Request_Waypoint: { + iouType: string; + transactionID: string; + waypointIndex: string; + threadReportID: number; + }; + Money_Request_Edit_Waypoint: { + iouType: string; + transactionID: string; + waypointIndex: string; + threadReportID: number; + }; + Money_Request_Distance: { + iouType: ValueOf; + reportID: string; + }; + Money_Request_Receipt: { + iouType: string; + reportID: string; + }; +}; + +type NewTaskNavigatorParamList = { + NewTask_Root: undefined; + NewTask_TaskAssigneeSelector: undefined; + NewTask_TaskShareDestinationSelector: undefined; + NewTask_Details: undefined; + NewTask_Title: undefined; + NewTask_Description: undefined; +}; + +type TeachersUniteNavigatorParamList = { + [SCREENS.SAVE_THE_WORLD.ROOT]: undefined; + I_Know_A_Teacher: undefined; + Intro_School_Principal: undefined; + I_Am_A_Teacher: undefined; +}; + +type TaskDetailsNavigatorParamList = { + Task_Title: undefined; + Task_Description: undefined; + Task_Assignee: { + reportID: string; + }; +}; + +type EnablePaymentsNavigatorParamList = { + EnablePayments_Root: undefined; +}; + +type SplitDetailsNavigatorParamList = { + SplitDetails_Root: { + reportActionID: string; + }; + SplitDetails_Edit_Request: undefined; + SplitDetails_Edit_Currency: undefined; +}; + +type AddPersonalBankAccountNavigatorParamList = { + AddPersonalBankAccount_Root: undefined; +}; + +type WalletStatementNavigatorParamList = { + WalletStatement_Root: undefined; +}; + +type FlagCommentNavigatorParamList = { + FlagComment_Root: { + reportID: string; + reportActionID: string; + }; +}; + +type EditRequestNavigatorParamList = { + EditRequest_Root: { + field: string; + threadReportID: string; + }; + EditRequest_Currency: undefined; +}; + +type SignInNavigatorParamList = { + SignIn_Root: undefined; +}; + +type ReferralDetailsNavigatorParamList = { + Referral_Details: undefined; +}; + +type PrivateNotesNavigatorParamList = { + PrivateNotes_View: { + reportID: string; + accountID: string; + }; + PrivateNotes_List: { + reportID: string; + accountID: string; + }; + PrivateNotes_Edit: { + reportID: string; + accountID: string; + }; +}; + +type RightModalNavigatorParamList = { + Settings: NavigatorScreenParams; + NewChat: NavigatorScreenParams; + Search: NavigatorScreenParams; + Details: NavigatorScreenParams; + Profile: NavigatorScreenParams; + Report_Details: NavigatorScreenParams; + Report_Settings: NavigatorScreenParams; + Report_WelcomeMessage: NavigatorScreenParams; + Participants: NavigatorScreenParams; + RoomMembers: NavigatorScreenParams; + RoomInvite: NavigatorScreenParams; + MoneyRequest: NavigatorScreenParams; + NewTask: NavigatorScreenParams; + TeachersUnite: NavigatorScreenParams; + Task_Details: NavigatorScreenParams; + EnablePayments: NavigatorScreenParams; + SplitDetails: NavigatorScreenParams; + AddPersonalBankAccount: NavigatorScreenParams; + Wallet_Statement: NavigatorScreenParams; + Flag_Comment: NavigatorScreenParams; + EditRequest: NavigatorScreenParams; + SignIn: NavigatorScreenParams; + Referral: NavigatorScreenParams; + Private_Notes: NavigatorScreenParams; +}; + +type PublicScreensParamList = { + [SCREENS.HOME]: undefined; + [SCREENS.TRANSITION_BETWEEN_APPS]: { + shouldForceLogin: string; + email: string; + shortLivedAuthToken: string; + exitTo: string; + }; + [SCREENS.VALIDATE_LOGIN]: { + accountID: string; + validateCode: string; + }; + [SCREENS.UNLINK_LOGIN]: { + accountID: string; + validateCode: string; + }; + [SCREENS.SIGN_IN_WITH_APPLE_DESKTOP]: undefined; + [SCREENS.SIGN_IN_WITH_GOOGLE_DESKTOP]: undefined; + [SCREENS.SAML_SIGN_IN]: undefined; +}; + +type AuthScreensParamList = { + [SCREENS.HOME]: undefined; + [NAVIGATORS.CENTRAL_PANE_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.VALIDATE_LOGIN]: { + accountID: string; + validateCode: string; + }; + [SCREENS.TRANSITION_BETWEEN_APPS]: { + shouldForceLogin: string; + email: string; + shortLivedAuthToken: string; + exitTo: string; + }; + [SCREENS.CONCIERGE]: undefined; + [SCREENS.REPORT_ATTACHMENTS]: { + reportID: string; + source: string; + }; + [SCREENS.NOT_FOUND]: undefined; + [NAVIGATORS.RIGHT_MODAL_NAVIGATOR]: NavigatorScreenParams; + [SCREENS.DESKTOP_SIGN_IN_REDIRECT]: undefined; + [CONST.DEMO_PAGES.MONEY2020]: undefined; +}; + +type RootStackParamList = PublicScreensParamList & AuthScreensParamList; + +export type {NavigationRef, StackNavigationAction, CentralPaneNavigatorParamList, RootStackParamList, StateOrRoute, NavigationStateRoute, NavigationRoot}; diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts index 763c8d19f800..d03235a637c7 100644 --- a/src/libs/TransactionUtils.ts +++ b/src/libs/TransactionUtils.ts @@ -5,7 +5,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import {RecentWaypoint, ReportAction, Transaction} from '@src/types/onyx'; import {Comment, Receipt, Waypoint, WaypointCollection} from '@src/types/onyx/Transaction'; import {EmptyObject} from '@src/types/utils/EmptyObject'; -import {isExpensifyCard} from './CardUtils'; +import {isCorporateCard, isExpensifyCard} from './CardUtils'; import DateUtils from './DateUtils'; import * as NumberUtils from './NumberUtils'; @@ -335,10 +335,11 @@ function isExpensifyCardTransaction(transaction: Transaction): boolean { } /** - * Determine whether a transaction is made with a card. + * Determine whether a transaction is made with a card (Expensify or Company Card). */ function isCardTransaction(transaction: Transaction): boolean { - return (transaction?.cardID ?? 0) > 0; + const cardID = transaction?.cardID ?? 0; + return isCorporateCard(cardID); } /** diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 7c49006c10a5..9246f760f7bd 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -200,7 +200,8 @@ function getAgeRequirementError(date: string, minimumAge: number, maximumAge: nu * http/https/ftp URL scheme required. */ function isValidWebsite(url: string): boolean { - return new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url); + const isLowerCase = url === url.toLowerCase(); + return new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url) && isLowerCase; } function validateIdentity(identity: Record): Record { diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts index accf84002bd3..4de8f1c1f171 100644 --- a/src/libs/actions/App.ts +++ b/src/libs/actions/App.ts @@ -13,7 +13,7 @@ import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as SessionUtils from '@libs/SessionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {Route} from '@src/ROUTES'; import * as OnyxTypes from '@src/types/onyx'; import {SelectedTimezone} from '@src/types/onyx/PersonalDetails'; import type {OnyxData} from '@src/types/onyx/Request'; @@ -392,7 +392,7 @@ function setUpPoliciesAndNavigate(session: OnyxTypes.Session) { const isLoggingInAsNewUser = !!session.email && SessionUtils.isLoggingInAsNewUser(currentUrl, session.email); const url = new URL(currentUrl); - const exitTo = url.searchParams.get('exitTo'); + const exitTo = url.searchParams.get('exitTo') as Route | null; // Approved Accountants and Guides can enter a flow where they make a workspace for other users, // and those are passed as a search parameter when using transition links diff --git a/src/libs/actions/Card.js b/src/libs/actions/Card.js index 9adcd3803766..68642bd8fdf1 100644 --- a/src/libs/actions/Card.js +++ b/src/libs/actions/Card.js @@ -93,7 +93,7 @@ function requestReplacementExpensifyCard(cardId, reason) { /** * Activates the physical Expensify card based on the last four digits of the card number * - * @param {Number} cardLastFourDigits + * @param {String} cardLastFourDigits * @param {Number} cardID */ function activatePhysicalExpensifyCard(cardLastFourDigits, cardID) { diff --git a/src/libs/migrations/PersonalDetailsByAccountID.js b/src/libs/migrations/PersonalDetailsByAccountID.js index 38c992a6a375..c08ec6fb2c43 100644 --- a/src/libs/migrations/PersonalDetailsByAccountID.js +++ b/src/libs/migrations/PersonalDetailsByAccountID.js @@ -257,6 +257,18 @@ export default function () { delete newReport.participants; } + if (lodashHas(newReport, ['ownerEmail'])) { + reportWasModified = true; + Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report ${newReport.reportID}`); + delete newReport.ownerEmail; + } + + if (lodashHas(newReport, ['managerEmail'])) { + reportWasModified = true; + Log.info(`[Migrate Onyx] PersonalDetailsByAccountID migration: removing managerEmail from report ${newReport.reportID}`); + delete newReport.managerEmail; + } + if (reportWasModified) { onyxData[onyxKey] = newReport; } diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index 43e1847e554a..a4d75a7c73a0 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -5,7 +5,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import AddPlaidBankAccount from '@components/AddPlaidBankAccount'; import ConfirmationPage from '@components/ConfirmationPage'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; @@ -113,7 +113,7 @@ function AddPersonalBankAccountPage({personalBankAccount, plaidData}) { onButtonPress={() => exitFlow(true)} /> ) : ( -
-
+ )} ); diff --git a/src/pages/EnablePayments/IdologyQuestions.js b/src/pages/EnablePayments/IdologyQuestions.js index 569a6f9aa109..97c0f55f27c6 100644 --- a/src/pages/EnablePayments/IdologyQuestions.js +++ b/src/pages/EnablePayments/IdologyQuestions.js @@ -1,14 +1,17 @@ import PropTypes from 'prop-types'; -import React, {useState} from 'react'; +import React, {useRef, useState} from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import SingleChoiceQuestion from '@components/SingleChoiceQuestion'; +import FixedFooter from '@components/FixedFooter'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FormScrollView from '@components/FormScrollView'; +import OfflineIndicator from '@components/OfflineIndicator'; +import RadioButtons from '@components/RadioButtons'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; import useLocalize from '@hooks/useLocalize'; +import * as ErrorUtils from '@libs/ErrorUtils'; import useThemeStyles from '@styles/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -48,13 +51,15 @@ const defaultProps = { walletAdditionalDetails: {}, }; -function IdologyQuestions({questions, idNumber}) { +function IdologyQuestions({questions, walletAdditionalDetails, idNumber}) { const styles = useThemeStyles(); + const formRef = useRef(); const {translate} = useLocalize(); const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [shouldHideSkipAnswer, setShouldHideSkipAnswer] = useState(false); const [userAnswers, setUserAnswers] = useState([]); + const [error, setError] = useState(''); const currentQuestion = questions[currentQuestionIndex] || {}; const possibleAnswers = _.filter( @@ -69,6 +74,7 @@ function IdologyQuestions({questions, idNumber}) { }; }), ); + const errorMessage = ErrorUtils.getLatestErrorMessage(walletAdditionalDetails) || error; /** * Put question answer in the state. @@ -80,6 +86,7 @@ function IdologyQuestions({questions, idNumber}) { tempAnswers[currentQuestionIndex] = {question: currentQuestion.type, answer}; setUserAnswers(tempAnswers); + setError(''); }; /** @@ -87,37 +94,30 @@ function IdologyQuestions({questions, idNumber}) { */ const submitAnswers = () => { if (!userAnswers[currentQuestionIndex]) { - return; - } - // Get the number of questions that were skipped by the user. - const skippedQuestionsCount = _.filter(userAnswers, (answer) => answer.answer === SKIP_QUESTION_TEXT).length; - - // We have enough answers, let's call expectID KBA to verify them - if (userAnswers.length - skippedQuestionsCount >= questions.length - MAX_SKIP) { - const tempAnswers = _.map(userAnswers, _.clone); - - // Auto skip any remaining questions - if (tempAnswers.length < questions.length) { - for (let i = tempAnswers.length; i < questions.length; i++) { - tempAnswers[i] = {question: questions[i].type, answer: SKIP_QUESTION_TEXT}; - } - } - - BankAccounts.answerQuestionsForWallet(tempAnswers, idNumber); - setUserAnswers(tempAnswers); + setError(translate('additionalDetailsStep.selectAnswer')); } else { - // Else, show next question - setCurrentQuestionIndex(currentQuestionIndex + 1); - setShouldHideSkipAnswer(skippedQuestionsCount >= MAX_SKIP); - } - }; + // Get the number of questions that were skipped by the user. + const skippedQuestionsCount = _.filter(userAnswers, (answer) => answer.answer === SKIP_QUESTION_TEXT).length; + + // We have enough answers, let's call expectID KBA to verify them + if (userAnswers.length - skippedQuestionsCount >= questions.length - MAX_SKIP) { + const tempAnswers = _.map(userAnswers, _.clone); + + // Auto skip any remaining questions + if (tempAnswers.length < questions.length) { + for (let i = tempAnswers.length; i < questions.length; i++) { + tempAnswers[i] = {question: questions[i].type, answer: SKIP_QUESTION_TEXT}; + } + } - const validate = (values) => { - const errors = {}; - if (!values.answer) { - errors.answer = translate('additionalDetailsStep.selectAnswer'); + BankAccounts.answerQuestionsForWallet(tempAnswers, idNumber); + setUserAnswers(tempAnswers); + } else { + // Else, show next question + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShouldHideSkipAnswer(skippedQuestionsCount >= MAX_SKIP); + } } - return errors; }; return ( @@ -131,23 +131,33 @@ function IdologyQuestions({questions, idNumber}) { {translate('additionalDetailsStep.helpLink')} - - + + {currentQuestion.prompt} + + + + + { + formRef.current.scrollTo({y: 0, animated: true}); + }} + message={errorMessage} + isLoading={walletAdditionalDetails.isLoading} + buttonText={translate('common.saveAndContinue')} + containerStyles={[styles.mh0, styles.mv0, styles.mb0]} /> - + + ); } diff --git a/src/pages/FlagCommentPage.js b/src/pages/FlagCommentPage.js index 721bc6742c4b..3695896ea473 100644 --- a/src/pages/FlagCommentPage.js +++ b/src/pages/FlagCommentPage.js @@ -19,6 +19,7 @@ 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'; @@ -161,7 +162,14 @@ function FlagCommentPage(props) { > {({safeAreaPaddingBottomStyle}) => ( - + { + Navigation.goBack(); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); + }} + /> { - const topMostReportID = Navigation.getTopmostReportId(); - if (topMostReportID) { - Navigation.goBack(ROUTES.HOME); - return; - } Navigation.goBack(); Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.report.reportID)); }} diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js index 7b315ff6c819..5b57419c8530 100644 --- a/src/pages/home/HeaderView.js +++ b/src/pages/home/HeaderView.js @@ -19,14 +19,12 @@ import TaskHeaderActionButton from '@components/TaskHeaderActionButton'; import Text from '@components/Text'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; -import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import compose from '@libs/compose'; +import useLocalize from '@hooks/useLocalize'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import {getGroupChatName} from '@libs/GroupChatUtils'; import * as HeaderUtils from '@libs/HeaderUtils'; import reportWithoutHasDraftSelector from '@libs/OnyxSelectors/reportWithoutHasDraftSelector'; import * as OptionsListUtils from '@libs/OptionsListUtils'; -import * as PolicyUtils from '@libs/PolicyUtils'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; @@ -60,8 +58,14 @@ const propTypes = { accountID: PropTypes.number, }), - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, + /** The current policy of the report */ + policy: PropTypes.shape({ + /** The policy name */ + name: PropTypes.string, + + /** The URL for the policy avatar */ + avatar: PropTypes.string, + }), }; const defaultProps = { @@ -72,9 +76,12 @@ const defaultProps = { session: { accountID: 0, }, + policy: {}, }; function HeaderView(props) { + const {isSmallScreenWidth, windowWidth} = useWindowDimensions(); + const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); const participants = lodashGet(props.report, 'participantAccountIDs', []); @@ -97,10 +104,9 @@ function HeaderView(props) { const lastVisibleMessage = ReportActionsUtils.getLastVisibleMessage(props.report.reportID); const isEmptyChat = !props.report.lastMessageText && !props.report.lastMessageTranslationKey && !lastVisibleMessage.lastMessageText && !lastVisibleMessage.lastMessageTranslationKey; const isUserCreatedPolicyRoom = ReportUtils.isUserCreatedPolicyRoom(props.report); - const policy = useMemo(() => props.policies[`${ONYXKEYS.COLLECTION.POLICY}${props.report.policyID}`], [props.policies, props.report.policyID]); - const canLeaveRoom = ReportUtils.canLeaveRoom(props.report, !_.isEmpty(policy)); + const isPolicyMember = useMemo(() => !_.isEmpty(props.policy), [props.policy]); + const canLeaveRoom = ReportUtils.canLeaveRoom(props.report, isPolicyMember); const isArchivedRoom = ReportUtils.isArchivedRoom(props.report); - const isPolicyMember = useMemo(() => PolicyUtils.isPolicyMember(props.report.policyID, props.policies), [props.report.policyID, props.policies]); // We hide the button when we are chatting with an automated Expensify account since it's not possible to contact // these users via alternative means. It is possible to request a call with Concierge so we leave the option for them. @@ -112,7 +118,7 @@ function HeaderView(props) { if (ReportUtils.isCompletedTaskReport(props.report) && canModifyTask) { threeDotMenuItems.push({ icon: Expensicons.Checkmark, - text: props.translate('task.markAsIncomplete'), + text: translate('task.markAsIncomplete'), onSelected: Session.checkIfActionIsAllowed(() => Task.reopenTask(props.report)), }); } @@ -121,7 +127,7 @@ function HeaderView(props) { if (props.report.stateNum !== CONST.REPORT.STATE_NUM.SUBMITTED && props.report.statusNum !== CONST.REPORT.STATUS.CLOSED && canModifyTask) { threeDotMenuItems.push({ icon: Expensicons.Trashcan, - text: props.translate('common.cancel'), + text: translate('common.cancel'), onSelected: Session.checkIfActionIsAllowed(() => Task.cancelTask(props.report.reportID, props.report.reportName, props.report.stateNum, props.report.statusNum)), }); } @@ -131,7 +137,7 @@ function HeaderView(props) { if (props.report.notificationPreference === CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN) { threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, - text: props.translate('common.join'), + text: translate('common.join'), onSelected: Session.checkIfActionIsAllowed(() => Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, false), ), @@ -140,7 +146,7 @@ function HeaderView(props) { const isWorkspaceMemberLeavingWorkspaceRoom = lodashGet(props.report, 'visibility', '') === CONST.REPORT.VISIBILITY.RESTRICTED && isPolicyMember; threeDotMenuItems.push({ icon: Expensicons.ChatBubbles, - text: props.translate('common.leave'), + text: translate('common.leave'), onSelected: Session.checkIfActionIsAllowed(() => Report.leaveRoom(props.report.reportID, isWorkspaceMemberLeavingWorkspaceRoom)), }); } @@ -151,7 +157,7 @@ function HeaderView(props) { if (isConcierge && props.guideCalendarLink) { threeDotMenuItems.push({ icon: Expensicons.Phone, - text: props.translate('videoChatButtonAndMenu.tooltip'), + text: translate('videoChatButtonAndMenu.tooltip'), onSelected: Session.checkIfActionIsAllowed(() => { Link.openExternalLink(props.guideCalendarLink); }), @@ -159,14 +165,14 @@ function HeaderView(props) { } else if (!isAutomatedExpensifyAccount && !isTaskReport && !isArchivedRoom) { threeDotMenuItems.push({ icon: ZoomIcon, - text: props.translate('videoChatButtonAndMenu.zoom'), + text: translate('videoChatButtonAndMenu.zoom'), onSelected: Session.checkIfActionIsAllowed(() => { Link.openExternalLink(CONST.NEW_ZOOM_MEETING_URL); }), }); threeDotMenuItems.push({ icon: GoogleMeetIcon, - text: props.translate('videoChatButtonAndMenu.googleMeet'), + text: translate('videoChatButtonAndMenu.googleMeet'), onSelected: Session.checkIfActionIsAllowed(() => { Link.openExternalLink(CONST.NEW_GOOGLE_MEET_MEETING_URL); }), @@ -179,7 +185,7 @@ function HeaderView(props) { const defaultSubscriptSize = ReportUtils.isExpenseRequest(props.report) ? CONST.AVATAR_SIZE.SMALL_NORMAL : CONST.AVATAR_SIZE.DEFAULT; const icons = ReportUtils.getIcons(reportHeaderData, props.personalDetails); const brickRoadIndicator = ReportUtils.hasReportNameError(props.report) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''; - const shouldShowBorderBottom = !isTaskReport || !props.isSmallScreenWidth; + const shouldShowBorderBottom = !isTaskReport || !isSmallScreenWidth; const shouldDisableDetailPage = ReportUtils.shouldDisableDetailPage(props.report); const isLoading = !props.report || !title; @@ -189,21 +195,21 @@ function HeaderView(props) { style={[styles.appContentHeader, shouldShowBorderBottom && styles.borderBottom]} dataSet={{dragArea: true}} > - + {isLoading ? ( ) : ( <> - {props.isSmallScreenWidth && ( + {isSmallScreenWidth && ( @@ -267,10 +273,10 @@ function HeaderView(props) { )} - {isTaskReport && !props.isSmallScreenWidth && ReportUtils.isOpenTaskReport(props.report) && } + {isTaskReport && !isSmallScreenWidth && ReportUtils.isOpenTaskReport(props.report) && } {shouldShowThreeDotsButton && ( @@ -288,25 +294,22 @@ HeaderView.displayName = 'HeaderView'; HeaderView.defaultProps = defaultProps; export default memo( - compose( - withWindowDimensions, - withLocalize, - withOnyx({ - guideCalendarLink: { - key: ONYXKEYS.ACCOUNT, - selector: (account) => (account && account.guideCalendarLink) || null, - initialValue: null, - }, - parentReport: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`, - selector: reportWithoutHasDraftSelector, - }, - session: { - key: ONYXKEYS.SESSION, - }, - policies: { - key: ONYXKEYS.COLLECTION.POLICY, - }, - }), - )(HeaderView), + withOnyx({ + guideCalendarLink: { + key: ONYXKEYS.ACCOUNT, + selector: (account) => (account && account.guideCalendarLink) || null, + initialValue: null, + }, + parentReport: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`, + selector: reportWithoutHasDraftSelector, + }, + session: { + key: ONYXKEYS.SESSION, + }, + policy: { + key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`, + selector: (policy) => _.pick(policy, ['name', 'avatar', 'pendingAction']), + }, + })(HeaderView), ); diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js index 1bd57bcab32b..61950e14337f 100755 --- a/src/pages/settings/InitialSettingsPage.js +++ b/src/pages/settings/InitialSettingsPage.js @@ -281,9 +281,9 @@ function InitialSettingsPage(props) { const getMenuItems = useMemo(() => { /** * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item - * @returns {Number} the user wallet balance + * @returns {String|undefined} the user's wallet balance */ - const getWalletBalance = (isPaymentItem) => isPaymentItem && CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance); + const getWalletBalance = (isPaymentItem) => (isPaymentItem ? CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance) : undefined); return ( <> diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index 5f91414368a0..3b8a1edf48c3 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -2,7 +2,7 @@ import Str from 'expensify-common/lib/str'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {Component} from 'react'; -import {Keyboard, ScrollView, View} from 'react-native'; +import {InteractionManager, Keyboard, ScrollView, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; @@ -264,6 +264,11 @@ class ContactMethodDetailsPage extends Component { title={this.props.translate('contacts.removeContactMethod')} onConfirm={this.confirmDeleteAndHideModal} onCancel={() => this.toggleDeleteModal(false)} + onModalHide={() => { + InteractionManager.runAfterInteractions(() => { + this.validateCodeFormRef.current.focusLastSelected(); + }); + }} prompt={this.props.translate('contacts.removeAreYouSure')} confirmText={this.props.translate('common.yesContinue')} cancelText={this.props.translate('common.cancel')} diff --git a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js index d5b87fbaf03f..3bb999261d44 100644 --- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js @@ -89,6 +89,15 @@ function BaseValidateCodeForm(props) { } inputValidateCodeRef.current.focus(); }, + focusLastSelected() { + if (!inputValidateCodeRef.current) { + return; + } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } + focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focusLastSelected, CONST.ANIMATED_TRANSITION); + }, })); useFocusEffect( @@ -96,6 +105,9 @@ function BaseValidateCodeForm(props) { if (!inputValidateCodeRef.current) { return; } + if (focusTimeoutRef.current) { + clearTimeout(focusTimeoutRef.current); + } focusTimeoutRef.current = setTimeout(inputValidateCodeRef.current.focus, CONST.ANIMATED_TRANSITION); return () => { if (!focusTimeoutRef.current) { diff --git a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js index e20721b5db4a..3534ef5c064c 100644 --- a/src/pages/settings/Wallet/ActivatePhysicalCardPage.js +++ b/src/pages/settings/Wallet/ActivatePhysicalCardPage.js @@ -123,7 +123,7 @@ function ActivatePhysicalCardPage({ return; } - CardSettings.activatePhysicalExpensifyCard(Number(lastFourDigits), cardID); + CardSettings.activatePhysicalExpensifyCard(lastFourDigits, cardID); }, [lastFourDigits, cardID, translate]); if (_.isEmpty(physicalCard)) { diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js index 030ca04b7074..bc37cf78d6be 100644 --- a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js +++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js @@ -207,7 +207,7 @@ function BaseGetPhysicalCard({ title={title} onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))} /> - {headline} + {headline} {renderContent(onSubmit, submitButtonText, children, onValidate)} ); diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js index e6a11e2ba1e1..10069bceb1c4 100644 --- a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js +++ b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js @@ -82,7 +82,7 @@ function GetPhysicalCardConfirm({ submitButtonText={translate('getPhysicalCard.shipCard')} title={translate('getPhysicalCard.header')} > - {translate('getPhysicalCard.estimatedDeliveryMessage')} + {translate('getPhysicalCard.estimatedDeliveryMessage')} diff --git a/src/pages/workspace/WorkspacesListPage.js b/src/pages/workspace/WorkspacesListPage.js index 1e51c64a711c..2749ccb52b96 100755 --- a/src/pages/workspace/WorkspacesListPage.js +++ b/src/pages/workspace/WorkspacesListPage.js @@ -114,7 +114,7 @@ function WorkspacesListPage({policies, allPolicyMembers, reimbursementAccount, u /** * @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item - * @returns {Number} the user wallet balance + * @returns {String|undefined} the user's wallet balance */ function getWalletBalance(isPaymentItem) { return isPaymentItem ? CurrencyUtils.convertToDisplayString(userWallet.currentBalance) : undefined; diff --git a/src/types/modules/react-navigation.d.ts b/src/types/modules/react-navigation.d.ts new file mode 100644 index 000000000000..1ac35c937116 --- /dev/null +++ b/src/types/modules/react-navigation.d.ts @@ -0,0 +1,8 @@ +import {RootStackParamList} from '@libs/Navigation/types'; + +declare global { + namespace ReactNavigation { + // eslint-disable-next-line + interface RootParamList extends RootStackParamList {} + } +} diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts index c5d9c27d34a1..0dc532ebeded 100644 --- a/src/types/onyx/OriginalMessage.ts +++ b/src/types/onyx/OriginalMessage.ts @@ -86,6 +86,8 @@ type Closed = { policyName: string; reason: ValueOf; lastModified?: string; + newAccountID?: number; + oldAccountID?: number; }; type OriginalMessageAddComment = { diff --git a/tests/e2e/TestSpec.yml b/tests/e2e/TestSpec.yml index c53010ec02fd..a234ef67b8a6 100644 --- a/tests/e2e/TestSpec.yml +++ b/tests/e2e/TestSpec.yml @@ -6,14 +6,16 @@ phases: # Install correct version of node - export NVM_DIR=$HOME/.nvm - . $NVM_DIR/nvm.sh - - nvm install 20.9.0 - - nvm use 20.9.0 + # Note: Node v16 is the latest supported version of node for AWS Device Farm + # using v20 will not work! + - nvm install 16 + - nvm use --delete-prefix 16 # Reverse ports using AWS magic - PORT=4723 - IP_ADDRESS=$(ip -4 addr show eth0 | grep -Po "(?<=inet\s)\d+(\.\d+){3}") - reverse_values="{\"ip_address\":\"$IP_ADDRESS\",\"local_port\":\"$PORT\",\"remote_port\":\"$PORT\"}" - - "curl -H \"Content-Type: application/json\" -X POST -d \"$reverse_values\" http://localhost:31007/reverse_forward_tcp" + - 'curl -H "Content-Type: application/json" -X POST -d "$reverse_values" http://localhost:31007/reverse_forward_tcp' - adb reverse tcp:$PORT tcp:$PORT test: @@ -23,4 +25,4 @@ phases: - node e2e/testRunner.js -- --skipInstallDeps --buildMode "skip" --skipCheckout --mainAppPath app-e2eRelease.apk --deltaAppPath app-e2edeltaRelease.apk artifacts: -- $WORKING_DIRECTORY + - $WORKING_DIRECTORY diff --git a/tests/e2e/compare/output/markdown.js b/tests/e2e/compare/output/markdown.js index 6dcf5c78eaa8..2015d8de6cc3 100644 --- a/tests/e2e/compare/output/markdown.js +++ b/tests/e2e/compare/output/markdown.js @@ -1,6 +1,6 @@ // From: https://raw.githubusercontent.com/callstack/reassure/main/packages/reassure-compare/src/output/markdown.ts -const fs = require('fs/promises'); +const fs = require('node:fs/promises'); const path = require('path'); const _ = require('underscore'); const markdownTable = require('./markdownTable'); diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index 1e9a9a89caf0..1cedbf1abd76 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -80,8 +80,8 @@ if (args.includes('--config')) { } // Important set app path after correct config file has been set -let mainAppPath = config.MAIN_APP_PATH; -let deltaAppPath = config.DELTA_APP_PATH; +let mainAppPath = args[args.indexOf('--mainAppPath') + 1] || config.MAIN_APP_PATH; +let deltaAppPath = args[args.indexOf('--deltaAppPath') + 1] || config.DELTA_APP_PATH; // Create some variables after the correct config file has been loaded const OUTPUT_FILE = `${config.OUTPUT_DIR}/${label}.json`; @@ -205,8 +205,8 @@ const runTests = async () => { let progressLog = Logger.progressInfo('Installing apps and reversing port'); - await installApp('android', config.MAIN_APP_PACKAGE, defaultConfig.MAIN_APP_PATH); - await installApp('android', config.DELTA_APP_PACKAGE, defaultConfig.DELTA_APP_PATH); + await installApp('android', config.MAIN_APP_PACKAGE, mainAppPath); + await installApp('android', config.DELTA_APP_PACKAGE, deltaAppPath); await reversePort(); progressLog.done(); diff --git a/tests/unit/CalendarPickerTest.js b/tests/unit/CalendarPickerTest.js index ae0888c6f0ed..92092acc4cd7 100644 --- a/tests/unit/CalendarPickerTest.js +++ b/tests/unit/CalendarPickerTest.js @@ -143,6 +143,19 @@ describe('CalendarPicker', () => { expect(getByTestId('next-month-arrow')).toBeDisabled(); }); + test('should allow navigating to the month of the max date when it has less days than the selected date', () => { + const maxDate = new Date('2003-11-27'); // This month has 30 days + const value = '2003-10-31'; + const {getByTestId} = render( + , + ); + + expect(getByTestId('next-month-arrow')).not.toBeDisabled(); + }); + test('should open the calendar on a month from max date if it is earlier than current month', () => { const onSelectedMock = jest.fn(); const maxDate = new Date('2011-03-01'); @@ -217,7 +230,7 @@ describe('CalendarPicker', () => { expect(getByLabelText('16')).not.toBeDisabled(); }); - test('should not allow to press max date', () => { + test('should allow to press max date', () => { const value = '2003-02-17'; const maxDate = new Date('2003-02-24'); const {getByLabelText} = render( diff --git a/tests/unit/MigrationTest.js b/tests/unit/MigrationTest.js index f212dd75447d..ebffc71e4e0e 100644 --- a/tests/unit/MigrationTest.js +++ b/tests/unit/MigrationTest.js @@ -452,6 +452,56 @@ describe('Migrations', () => { }, }); })); + + it('Should remove any instances of ownerEmail found in a report', () => + Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: { + reportID: 1, + ownerEmail: 'fake@test.com', + ownerAccountID: 5, + }, + }) + .then(PersonalDetailsByAccountID) + .then(() => { + expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing ownerEmail from report 1'); + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + const expectedReport = { + reportID: 1, + ownerAccountID: 5, + }; + expect(allReports[`${ONYXKEYS.COLLECTION.REPORT}1`]).toMatchObject(expectedReport); + }, + }); + })); + + it('Should remove any instances of managerEmail found in a report', () => + Onyx.multiSet({ + [`${ONYXKEYS.COLLECTION.REPORT}1`]: { + reportID: 1, + managerEmail: 'fake@test.com', + managerID: 5, + }, + }) + .then(PersonalDetailsByAccountID) + .then(() => { + expect(LogSpy).toHaveBeenCalledWith('[Migrate Onyx] PersonalDetailsByAccountID migration: removing managerEmail from report 1'); + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.REPORT, + waitForCollectionCallback: true, + callback: (allReports) => { + Onyx.disconnect(connectionID); + const expectedReport = { + reportID: 1, + managerID: 5, + }; + expect(allReports[`${ONYXKEYS.COLLECTION.REPORT}1`]).toMatchObject(expectedReport); + }, + }); + })); }); describe('CheckForPreviousReportActionID', () => {