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; 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', () => {