diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index d4340e5a55f7..d940d99d9cde 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -139,12 +139,11 @@ In order to bundle actions with their dependencies into a single Node.js executa
- When calling your GitHub Action from one of our workflows, you must:
- First call `@actions/checkout`.
- - Use the absolute path of the action in GitHub, including the repo name, path, and branch ref, like so:
+ - Use the relative path of the action in GitHub from the root of this repo, like so:
```yaml
- name: Generate Version
- uses: Expensify/App/.github/actions/javascript/bumpVersion@main
+ uses: ./.github/actions/javascript/bumpVersion
```
- Do not try to use a relative path.
-- Confusingly, paths in action metadata files (`action.yml`) _must_ use relative paths.
+
- You can't use any dynamic values or environment variables in a `uses` statement
- In general, it is a best practice to minimize any side-effects of each action. Using atomic ("dumb") actions that have a clear and simple purpose will promote reuse and make it easier to understand the workflows that use them.
diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index 92480a94ba53..dd2c92e95568 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -27,7 +27,7 @@ jobs:
createNewVersion:
needs: validateActor
if: ${{ fromJSON(needs.validateActor.outputs.IS_DEPLOYER) }}
- uses: Expensify/App/.github/workflows/createNewVersion.yml@main
+ uses: ./.github/workflows/createNewVersion.yml
secrets: inherit
cherryPick:
@@ -42,7 +42,7 @@ jobs:
- name: Set up git for OSBotify
id: setupGitForOSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
@@ -50,7 +50,7 @@ jobs:
- name: Get previous app version
id: getPreviousVersion
- uses: Expensify/App/.github/actions/javascript/getPreviousVersion@main
+ uses: ./.github/actions/javascript/getPreviousVersion
with:
SEMVER_LEVEL: "PATCH"
@@ -67,7 +67,7 @@ jobs:
- name: Get merge commit for pull request to CP
id: getCPMergeCommit
- uses: Expensify/App/.github/actions/javascript/getPullRequestDetails@main
+ uses: ./.github/actions/javascript/getPullRequestDetails
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
USER: ${{ github.actor }}
diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml
index dde65f5a1503..9a1cac41ed69 100644
--- a/.github/workflows/createDeployChecklist.yml
+++ b/.github/workflows/createDeployChecklist.yml
@@ -14,15 +14,7 @@ jobs:
- name: Setup Node
uses: ./.github/actions/composite/setupNode
- - name: Set up git for OSBotify
- id: setupGitForOSBotify
- uses: ./.github/actions/composite/setupGitForOSBotifyApp
- with:
- GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
- OS_BOTIFY_PRIVATE_KEY: ${{ secrets.OS_BOTIFY_PRIVATE_KEY }}
-
- name: Create or update deploy checklist
uses: ./.github/actions/javascript/createOrUpdateStagingDeploy
with:
- GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/createNewVersion.yml b/.github/workflows/createNewVersion.yml
index 812ec200bd88..5f7f95e102e3 100644
--- a/.github/workflows/createNewVersion.yml
+++ b/.github/workflows/createNewVersion.yml
@@ -76,7 +76,7 @@ jobs:
token: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }}
- name: Setup git for OSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -85,7 +85,7 @@ jobs:
- name: Generate version
id: bumpVersion
- uses: Expensify/App/.github/actions/javascript/bumpVersion@main
+ uses: ./.github/actions/javascript/bumpVersion
with:
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
SEMVER_LEVEL: ${{ inputs.SEMVER_LEVEL }}
@@ -105,6 +105,6 @@ jobs:
- name: Announce failed workflow in Slack
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 6b32ac2e2616..f6deaae963e4 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -15,7 +15,7 @@ jobs:
ref: staging
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ - uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -38,7 +38,7 @@ jobs:
ref: production
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ - uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -50,7 +50,7 @@ jobs:
- name: Get Release Pull Request List
id: getReleasePRList
- uses: Expensify/App/.github/actions/javascript/getDeployPullRequestList@main
+ uses: ./.github/actions/javascript/getDeployPullRequestList
with:
TAG: ${{ env.PRODUCTION_VERSION }}
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
@@ -58,7 +58,7 @@ jobs:
- name: Generate Release Body
id: getReleaseBody
- uses: Expensify/App/.github/actions/javascript/getReleaseBody@main
+ uses: ./.github/actions/javascript/getReleaseBody
with:
PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }}
diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml
index b55354b95571..d118b3fee252 100644
--- a/.github/workflows/deployBlocker.yml
+++ b/.github/workflows/deployBlocker.yml
@@ -32,7 +32,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 }}|${{ toJSON(github.event.issue.title) }}>',
+ 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]; }),
}]
}
env:
diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index 7b9b7479f496..82cd62c5e832 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -32,7 +32,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup NodeJS
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Create docs routes file
run: ./.github/scripts/createDocsRoutes.sh
diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml
index 318198981097..016fe89ccfce 100644
--- a/.github/workflows/e2ePerformanceTests.yml
+++ b/.github/workflows/e2ePerformanceTests.yml
@@ -49,7 +49,7 @@ jobs:
- name: Checkout latest main commit (TODO temporary until new version is released)
run: git switch --detach main
- - uses: Expensify/App/.github/actions/composite/setupNode@main
+ - uses: ./.github/actions/composite/setupNode
- uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
with:
@@ -64,7 +64,7 @@ jobs:
- name: Build APK
run: npm run android-build-e2e
shell: bash
-
+
- name: Upload APK
uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05
with:
@@ -82,7 +82,7 @@ jobs:
- name: Get pull request details
id: getPullRequestDetails
- uses: Expensify/App/.github/actions/javascript/getPullRequestDetails@main
+ uses: ./.github/actions/javascript/getPullRequestDetails
with:
GITHUB_TOKEN: ${{ github.token }}
PULL_REQUEST_NUMBER: ${{ inputs.PR_NUMBER }}
@@ -136,13 +136,13 @@ jobs:
with:
ruby-version: "2.7"
bundler-cache: true
-
+
- uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef
-
+
- name: Build APK
run: npm run android-build-e2edelta
shell: bash
-
+
- name: Upload APK
uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05
with:
@@ -157,7 +157,7 @@ jobs:
- uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Make zip directory for everything to send to AWS Device Farm
run: mkdir zip
@@ -190,7 +190,7 @@ jobs:
run: zip -qr App.zip ./zip
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 6c3f3dfd7603..7fb5feaf6084 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -18,7 +18,7 @@ jobs:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ - uses: ./.github/actions/composite/setupGitForOSBotifyApp
id: setupGitForOSBotify
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
@@ -38,7 +38,7 @@ jobs:
- name: Reopen and comment on issue (not a team member)
if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }}
- uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
+ uses: ./.github/actions/javascript/reopenIssueWithComment
with:
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -49,14 +49,14 @@ jobs:
- name: Check for any deploy blockers
if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }}
id: checkDeployBlockers
- uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main
+ uses: ./.github/actions/javascript/checkDeployBlockers
with:
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Reopen and comment on issue (has blockers)
if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }}
- uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main
+ uses: ./.github/actions/javascript/reopenIssueWithComment
with:
GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
@@ -66,7 +66,7 @@ jobs:
- name: Announce failed workflow in Slack
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
@@ -84,7 +84,7 @@ jobs:
- name: Setup Git for OSBotify
id: setupGitForOSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
@@ -100,7 +100,7 @@ jobs:
- name: Announce failed workflow in Slack
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
@@ -108,7 +108,7 @@ jobs:
createNewPatchVersion:
needs: validate
if: ${{ fromJSON(needs.validate.outputs.isValid) }}
- uses: Expensify/App/.github/workflows/createNewVersion.yml@main
+ uses: ./.github/workflows/createNewVersion.yml
secrets: inherit
with:
SEMVER_LEVEL: PATCH
@@ -125,7 +125,7 @@ jobs:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- name: Setup Git for OSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
@@ -141,6 +141,6 @@ jobs:
- name: Announce failed workflow in Slack
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 22a60992e7c7..33c850823413 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -16,7 +16,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Lint JavaScript and Typescript with ESLint
run: npm run lint
diff --git a/.github/workflows/lockDeploys.yml b/.github/workflows/lockDeploys.yml
index 6a2812a4f92a..d73f982a47cb 100644
--- a/.github/workflows/lockDeploys.yml
+++ b/.github/workflows/lockDeploys.yml
@@ -16,7 +16,7 @@ jobs:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- name: Wait for staging deploys to finish
- uses: Expensify/App/.github/actions/javascript/awaitStagingDeploys@main
+ uses: ./.github/actions/javascript/awaitStagingDeploys
with:
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
@@ -30,6 +30,6 @@ jobs:
- name: Announce failed workflow
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
diff --git a/.github/workflows/platformDeploy.yml b/.github/workflows/platformDeploy.yml
index ce31eef342c5..291bd80816b9 100644
--- a/.github/workflows/platformDeploy.yml
+++ b/.github/workflows/platformDeploy.yml
@@ -39,6 +39,7 @@ jobs:
uses: ./.github/workflows/createDeployChecklist.yml
if: ${{ github.event_name != 'release' }}
needs: validateActor
+ secrets: inherit
android:
name: Build and deploy Android
@@ -53,7 +54,7 @@ jobs:
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
@@ -134,7 +135,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Decrypt Developer ID Certificate
run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg
@@ -176,7 +177,7 @@ jobs:
run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }}
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
@@ -285,13 +286,13 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Setup Cloudflare CLI
run: pip3 install cloudflare
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -341,7 +342,7 @@ jobs:
needs: [android, desktop, iOS, web]
steps:
- name: Post Slack message on failure
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
@@ -416,21 +417,21 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Set version
run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV"
- name: Get Release Pull Request List
id: getReleasePRList
- uses: Expensify/App/.github/actions/javascript/getDeployPullRequestList@main
+ uses: ./.github/actions/javascript/getDeployPullRequestList
with:
TAG: ${{ env.VERSION }}
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
- name: Comment on issues
- uses: Expensify/App/.github/actions/javascript/markPullRequestsAsDeployed@main
+ uses: ./.github/actions/javascript/markPullRequestsAsDeployed
with:
PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }}
IS_PRODUCTION_DEPLOY: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }}
diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml
index 54fd1a830b8b..8f9512062e9d 100644
--- a/.github/workflows/preDeploy.yml
+++ b/.github/workflows/preDeploy.yml
@@ -7,13 +7,13 @@ on:
jobs:
typecheck:
- uses: Expensify/App/.github/workflows/typecheck.yml@main
+ uses: ./.github/workflows/typecheck.yml
lint:
- uses: Expensify/App/.github/workflows/lint.yml@main
+ uses: ./.github/workflows/lint.yml
test:
- uses: Expensify/App/.github/workflows/test.yml@main
+ uses: ./.github/workflows/test.yml
confirmPassingBuild:
runs-on: ubuntu-latest
@@ -21,9 +21,11 @@ jobs:
if: ${{ always() }}
steps:
+ - uses: actions/checkout@v4
+
- name: Announce failed workflow in Slack
if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
@@ -39,6 +41,8 @@ jobs:
SHOULD_DEPLOY: ${{ fromJSON(steps.shouldDeploy.outputs.SHOULD_DEPLOY) }}
steps:
+ - uses: actions/checkout@v4
+
- name: Get merged pull request
id: getMergedPullRequest
uses: actions-ecosystem/action-get-merged-pull-request@59afe90821bb0b555082ce8ff1e36b03f91553d9
@@ -47,7 +51,7 @@ jobs:
- name: Check if StagingDeployCash is locked
id: isStagingDeployLocked
- uses: Expensify/App/.github/actions/javascript/isStagingDeployLocked@main
+ uses: ./.github/actions/javascript/isStagingDeployLocked
with:
GITHUB_TOKEN: ${{ github.token }}
@@ -71,7 +75,7 @@ jobs:
createNewVersion:
needs: chooseDeployActions
if: ${{ fromJSON(needs.chooseDeployActions.outputs.SHOULD_DEPLOY) }}
- uses: Expensify/App/.github/workflows/createNewVersion.yml@main
+ uses: ./.github/workflows/createNewVersion.yml
secrets: inherit
updateStaging:
@@ -92,7 +96,7 @@ jobs:
token: ${{ secrets.OS_BOTIFY_TOKEN }}
- name: Setup Git for OSBotify
- uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab
+ uses: ./.github/actions/composite/setupGitForOSBotifyApp
with:
GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }}
@@ -108,14 +112,14 @@ jobs:
- name: Announce failed workflow in Slack
if: ${{ failure() }}
- uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main
+ uses: ./.github/actions/composite/announceFailedWorkflowInSlack
with:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
e2ePerformanceTests:
needs: [chooseDeployActions]
if: ${{ needs.chooseDeployActions.outputs.SHOULD_DEPLOY }}
- uses: Expensify/App/.github/workflows/e2ePerformanceTests.yml@main
+ uses: ./.github/workflows/e2ePerformanceTests.yml
secrets: inherit
with:
PR_NUMBER: ${{ needs.chooseDeployActions.outputs.MERGED_PR }}
diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml
index 4aaa6fb2ce8c..a58745b742ad 100644
--- a/.github/workflows/reassurePerformanceTests.yml
+++ b/.github/workflows/reassurePerformanceTests.yml
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup NodeJS
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Run performance testing script
shell: bash
@@ -38,7 +38,7 @@ jobs:
- name: Validate output.json
id: validateReassureOutput
- uses: Expensify/App/.github/actions/javascript/validateReassureOutput@main
+ uses: ./.github/actions/javascript/validateReassureOutput
with:
DURATION_DEVIATION_PERCENTAGE: 20
COUNT_DEVIATION: 0
diff --git a/.github/workflows/reviewerChecklist.yml b/.github/workflows/reviewerChecklist.yml
index e86e08375269..19aeab8a1be7 100644
--- a/.github/workflows/reviewerChecklist.yml
+++ b/.github/workflows/reviewerChecklist.yml
@@ -9,7 +9,9 @@ jobs:
runs-on: ubuntu-latest
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
steps:
+ - uses: actions/checkout@v4
+
- name: reviewerChecklist.js
- uses: Expensify/App/.github/actions/javascript/reviewerChecklist@main
+ uses: ./.github/actions/javascript/reviewerChecklist
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 9c2e9486150b..6540a0fdd583 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Get number of CPU cores
id: cpu-cores
@@ -46,7 +46,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: Expensify/App/.github/actions/composite/setupNode@main
+ - uses: ./.github/actions/composite/setupNode
- name: Storybook run
run: npm run storybook -- --smoke-test --ci
@@ -60,7 +60,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Test CI git logic
run: tests/unit/CIGitLogicTest.sh
diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml
index 4725ca6c86ce..6f222398d04b 100644
--- a/.github/workflows/testBuild.yml
+++ b/.github/workflows/testBuild.yml
@@ -82,7 +82,7 @@ jobs:
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Setup Ruby
uses: ruby/setup-ruby@a05e47355e80e57b9a67566a813648fa67d92011
@@ -101,7 +101,7 @@ jobs:
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -149,7 +149,7 @@ jobs:
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Setup XCode
run: sudo xcode-select -switch /Applications/Xcode_14.2.app
@@ -191,7 +191,7 @@ jobs:
LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }}
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -230,7 +230,7 @@ jobs:
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Decrypt Developer ID Certificate
run: cd desktop && gpg --quiet --batch --yes --decrypt --passphrase="$DEVELOPER_ID_SECRET_PASSPHRASE" --output developer_id.p12 developer_id.p12.gpg
@@ -238,7 +238,7 @@ jobs:
DEVELOPER_ID_SECRET_PASSPHRASE: ${{ secrets.DEVELOPER_ID_SECRET_PASSPHRASE }}
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -273,10 +273,10 @@ jobs:
echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
- name: Configure AWS Credentials
- uses: Expensify/App/.github/actions/composite/configureAwsCredentials@main
+ uses: ./.github/actions/composite/configureAwsCredentials
with:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
@@ -343,7 +343,7 @@ jobs:
- name: Publish links to apps for download
if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }}
- uses: Expensify/App/.github/actions/javascript/postTestBuildComment@main
+ uses: ./.github/actions/javascript/postTestBuildComment
with:
PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }}
GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index c09db594e243..1f80908b02b5 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -14,7 +14,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: Expensify/App/.github/actions/composite/setupNode@main
+ - uses: ./.github/actions/composite/setupNode
- name: Type check with TypeScript
run: npm run typecheck
diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml
index 702c48fbc068..ceeca1ad39f1 100644
--- a/.github/workflows/validateDocsRoutes.yml
+++ b/.github/workflows/validateDocsRoutes.yml
@@ -13,7 +13,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- - uses: Expensify/App/.github/actions/composite/setupNode@main
+ - uses: ./.github/actions/composite/setupNode
# Verify that no new hubs were created without adding their metadata to _routes.yml
- name: Validate Docs Routes File
diff --git a/.github/workflows/validateGithubActions.yml b/.github/workflows/validateGithubActions.yml
index c493e26bc514..700f0b68100e 100644
--- a/.github/workflows/validateGithubActions.yml
+++ b/.github/workflows/validateGithubActions.yml
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v4
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
# Rebuild all the actions on this branch and check for a diff. Fail if there is one,
# because that would be a sign that the PR author did not rebuild the Github Actions
diff --git a/.github/workflows/verifyPodfile.yml b/.github/workflows/verifyPodfile.yml
index 08f9c3a5223b..04cd8d62461b 100644
--- a/.github/workflows/verifyPodfile.yml
+++ b/.github/workflows/verifyPodfile.yml
@@ -16,7 +16,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
+
- name: Setup Node
- uses: Expensify/App/.github/actions/composite/setupNode@main
+ uses: ./.github/actions/composite/setupNode
+
- name: Verify podfile
run: ./.github/scripts/verifyPodfile.sh
diff --git a/.github/workflows/verifySignedCommits.yml b/.github/workflows/verifySignedCommits.yml
index ee1b0c4c78da..9134dcd63a7a 100644
--- a/.github/workflows/verifySignedCommits.yml
+++ b/.github/workflows/verifySignedCommits.yml
@@ -9,7 +9,9 @@ jobs:
verifySignedCommits:
runs-on: ubuntu-latest
steps:
+ - uses: actions/checkout@v4
+
- name: Verify signed commits
- uses: Expensify/App/.github/actions/javascript/verifySignedCommits@main
+ uses: ./.github/actions/javascript/verifySignedCommits
with:
GITHUB_TOKEN: ${{ github.token }}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index d0874aefa721..cede00e256fa 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 1001040101
- versionName "1.4.1-1"
+ versionCode 1001040112
+ versionName "1.4.1-12"
}
flavorDimensions "default"
diff --git a/assets/images/empty-state__attach-receipt.svg b/assets/images/empty-state__attach-receipt.svg
index 6b50afbdbf0b..5ce3bfd593f5 100644
--- a/assets/images/empty-state__attach-receipt.svg
+++ b/assets/images/empty-state__attach-receipt.svg
@@ -1,16 +1 @@
-
+
\ No newline at end of file
diff --git a/assets/images/product-illustrations/payment-hands.svg b/assets/images/product-illustrations/payment-hands.svg
index 7d64d8572b30..bf76b528ee76 100644
--- a/assets/images/product-illustrations/payment-hands.svg
+++ b/assets/images/product-illustrations/payment-hands.svg
@@ -1,317 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md
index 8b6ea7de2642..372edd8f14ec 100644
--- a/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md
+++ b/docs/articles/expensify-classic/bank-accounts-and-credit-cards/company-cards/Direct-Bank-Connections.md
@@ -64,7 +64,13 @@ If you're using a connected accounting system such as NetSuite, Xero, Sage Intac
{:width="100%"}
-You can access the remaining company card settings by navigating to **Settings > Domains > _Domain Name_ > Company Cards > Settings.** More information on card settings can be found by searching **“How to configure company card settings”**.
+You can access the remaining company card settings by navigating to **Settings > Domains > _Domain Name_ > Company Cards > Settings.**
+
+## Connecting multiple card programs to the same domain
+
+If you need to connect a separate card program from the same bank (that's accessed via a different set of login credentials), when you try to import it by clicking **Import Card/Bank**, the connection to your previous card is disconnected.
+
+To fix this, you would need to contact your bank and request to combine all of your cards under a single set of login credentials. That way, you can connect all of your cards from that bank to Expensify using a single set of login credentials.
# FAQ
## How can I connect and manage my company’s cards centrally if I’m not a domain admin?
diff --git a/docs/assets/images/chat-bubble.svg b/docs/assets/images/chat-bubble.svg
index afa13dc39820..fbab26d72b44 100644
--- a/docs/assets/images/chat-bubble.svg
+++ b/docs/assets/images/chat-bubble.svg
@@ -1,20 +1 @@
-
-
-
+
\ No newline at end of file
diff --git a/docs/assets/images/playbook-impoort-employees.png b/docs/assets/images/playbook-impoort-employees.png
index b3d08c179850..e45e7d461145 100644
Binary files a/docs/assets/images/playbook-impoort-employees.png and b/docs/assets/images/playbook-impoort-employees.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 8adc1cebd3b9..271c2b3d5664 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.1.1
+ 1.4.1.12
ITSAppUsesNonExemptEncryption
LSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 4dcaa6482eff..a51f3908fa26 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -19,6 +19,6 @@
CFBundleSignature
????
CFBundleVersion
- 1.4.1.1
+ 1.4.1.12
diff --git a/package-lock.json b/package-lock.json
index 2de44d8fae8e..06c33f64c5e2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.1-1",
+ "version": "1.4.1-12",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.1-1",
+ "version": "1.4.1-12",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -53,6 +53,7 @@
"domhandler": "^4.3.0",
"expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#5e598b8dbc6fba1fa3f745a04ada065ed1465043",
"fbjs": "^3.0.2",
+ "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
@@ -30787,6 +30788,28 @@
"readable-stream": "^2.3.6"
}
},
+ "node_modules/focus-trap": {
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
+ "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
+ "dependencies": {
+ "tabbable": "^6.2.0"
+ }
+ },
+ "node_modules/focus-trap-react": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
+ "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
+ "dependencies": {
+ "focus-trap": "^7.5.2",
+ "tabbable": "^6.2.0"
+ },
+ "peerDependencies": {
+ "prop-types": "^15.8.1",
+ "react": ">=16.3.0",
+ "react-dom": ">=16.3.0"
+ }
+ },
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -49044,6 +49067,11 @@
"dev": true,
"license": "BSD-3-Clause"
},
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
"node_modules/table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
@@ -75075,6 +75103,23 @@
"readable-stream": "^2.3.6"
}
},
+ "focus-trap": {
+ "version": "7.5.2",
+ "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
+ "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
+ "requires": {
+ "tabbable": "^6.2.0"
+ }
+ },
+ "focus-trap-react": {
+ "version": "10.2.1",
+ "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
+ "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
+ "requires": {
+ "focus-trap": "^7.5.2",
+ "tabbable": "^6.2.0"
+ }
+ },
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -88065,6 +88110,11 @@
"version": "2.0.15",
"dev": true
},
+ "tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
+ },
"table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
diff --git a/package.json b/package.json
index 68b8722f8630..7853fd9404d7 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.1-1",
+ "version": "1.4.1-12",
"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.",
@@ -100,6 +100,7 @@
"domhandler": "^4.3.0",
"expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#5e598b8dbc6fba1fa3f745a04ada065ed1465043",
"fbjs": "^3.0.2",
+ "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
diff --git a/patches/react-pdf+6.2.2.patch b/patches/react-pdf+6.2.2.patch
new file mode 100644
index 000000000000..752155761250
--- /dev/null
+++ b/patches/react-pdf+6.2.2.patch
@@ -0,0 +1,28 @@
+diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js
+index 91db4d4..82cafec 100644
+--- a/node_modules/react-pdf/dist/esm/Document.js
++++ b/node_modules/react-pdf/dist/esm/Document.js
+@@ -78,7 +78,10 @@ var Document = /*#__PURE__*/function (_PureComponent) {
+ cancelRunningTask(_this.runningTask);
+
+ // If another loading is in progress, let's destroy it
+- if (_this.loadingTask) _this.loadingTask.destroy();
++ if (_this.loadingTask) {
++ _this.loadingTask._worker.destroy();
++ _this.loadingTask.destroy();
++ };
+ var cancellable = makeCancellable(_this.findDocumentSource());
+ _this.runningTask = cancellable;
+ cancellable.promise.then(function (source) {
+@@ -251,7 +254,10 @@ var Document = /*#__PURE__*/function (_PureComponent) {
+ cancelRunningTask(this.runningTask);
+
+ // If loading is in progress, let's destroy it
+- if (this.loadingTask) this.loadingTask.destroy();
++ if (this.loadingTask) {
++ this.loadingTask._worker.destroy();
++ this.loadingTask.destroy();
++ };
+ }
+ }, {
+ key: "childContext",
diff --git a/src/CONST.ts b/src/CONST.ts
index 8f51145b71a5..4024158d0805 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2879,9 +2879,6 @@ const CONST = {
*/
ADDITIONAL_ALLOWED_CHARACTERS: 20,
- /** types that will show a virtual keyboard in a mobile browser */
- INPUT_TYPES_WITH_KEYBOARD: ['text', 'search', 'tel', 'url', 'email', 'password'],
-
REFERRAL_PROGRAM: {
CONTENT_TYPES: {
MONEY_REQUEST: 'request',
@@ -2890,7 +2887,7 @@ const CONST = {
REFER_FRIEND: 'referralFriend',
},
REVENUE: 250,
- LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/getting-started/Referral-Program',
+ LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/billing-and-plan-types/Referral-Program',
LINK: 'https://join.my.expensify.com',
},
} as const;
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index a5a969adb833..75c284fb9546 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -261,6 +261,9 @@ const ONYXKEYS = {
REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_',
SECURITY_GROUP: 'securityGroup_',
TRANSACTION: 'transactions_',
+
+ // Holds temporary transactions used during the creation and edit flow
+ TRANSACTION_DRAFT: 'transactionsDraft_',
SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_',
PRIVATE_NOTES_DRAFT: 'privateNotesDraft_',
NEXT_STEP: 'reportNextStep_',
@@ -334,6 +337,8 @@ const ONYXKEYS = {
REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft',
REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm',
REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft',
+ GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm',
+ GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft',
},
} as const;
@@ -500,6 +505,8 @@ type OnyxValues = {
[ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined;
};
type OnyxKeyValue = OnyxEntry;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 57d4eb8187ec..26589a3db0e0 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -83,6 +83,22 @@ export default {
route: '/settings/wallet/card/:domain/report-virtual-fraud',
getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`,
},
+ SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: {
+ route: '/settings/wallet/card/:domain/get-physical/name',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`,
+ },
+ SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: {
+ route: '/settings/wallet/card/:domain/get-physical/phone',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`,
+ },
+ SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: {
+ route: '/settings/wallet/card/:domain/get-physical/address',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`,
+ },
+ SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: {
+ route: '/settings/wallet/card/:domain/get-physical/confirm',
+ getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`,
+ },
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',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index afc368858f55..f957a1dbb25e 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -23,7 +23,13 @@ export default {
SECURITY: 'Settings_Security',
STATUS: 'Settings_Status',
WALLET: 'Settings_Wallet',
- WALLET_DOMAIN_CARDS: 'Settings_Wallet_DomainCards',
+ WALLET_DOMAIN_CARD: 'Settings_Wallet_DomainCard',
+ WALLET_CARD_GET_PHYSICAL: {
+ NAME: 'Settings_Card_Get_Physical_Name',
+ PHONE: 'Settings_Card_Get_Physical_Phone',
+ ADDRESS: 'Settings_Card_Get_Physical_Address',
+ CONFIRM: 'Settings_Card_Get_Physical_Confirm',
+ },
},
SAVE_THE_WORLD: {
ROOT: 'SaveTheWorld_Root',
diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js
new file mode 100644
index 000000000000..19ab35f036c1
--- /dev/null
+++ b/src/components/AddressForm.js
@@ -0,0 +1,223 @@
+import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import React, {useCallback} from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import AddressSearch from './AddressSearch';
+import CountrySelector from './CountrySelector';
+import Form from './Form';
+import StatePicker from './StatePicker';
+import TextInput from './TextInput';
+
+const propTypes = {
+ /** Address city field */
+ city: PropTypes.string,
+
+ /** Address country field */
+ country: PropTypes.string,
+
+ /** Address state field */
+ state: PropTypes.string,
+
+ /** Address street line 1 field */
+ street1: PropTypes.string,
+
+ /** Address street line 2 field */
+ street2: PropTypes.string,
+
+ /** Address zip code field */
+ zip: PropTypes.string,
+
+ /** Callback which is executed when the user changes address, city or state */
+ onAddressChanged: PropTypes.func,
+
+ /** Callback which is executed when the user submits his address changes */
+ onSubmit: PropTypes.func.isRequired,
+
+ /** Whether or not should the form data should be saved as draft */
+ shouldSaveDraft: PropTypes.bool,
+
+ /** Text displayed on the bottom submit button */
+ submitButtonText: PropTypes.string,
+
+ /** A unique Onyx key identifying the form */
+ formID: PropTypes.string.isRequired,
+};
+
+const defaultProps = {
+ city: '',
+ country: '',
+ onAddressChanged: () => {},
+ shouldSaveDraft: false,
+ state: '',
+ street1: '',
+ street2: '',
+ submitButtonText: '',
+ zip: '',
+};
+
+function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) {
+ const {translate} = useLocalize();
+ const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], '');
+ const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
+ const isUSAForm = country === CONST.COUNTRY.US;
+
+ /**
+ * @param {Function} translate - translate function
+ * @param {Boolean} isUSAForm - selected country ISO code is US
+ * @param {Object} values - form input values
+ * @returns {Object} - An object containing the errors for each inputID
+ */
+ const validator = useCallback((values) => {
+ const errors = {};
+ const requiredFields = ['addressLine1', 'city', 'country', 'state'];
+
+ // Check "State" dropdown is a valid state if selected Country is USA
+ if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) {
+ errors.state = 'common.error.fieldRequired';
+ }
+
+ // Add "Field required" errors if any required field is empty
+ _.each(requiredFields, (fieldKey) => {
+ if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) {
+ return;
+ }
+ errors[fieldKey] = 'common.error.fieldRequired';
+ });
+
+ // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
+ const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {});
+
+ // The postal code system might not exist for a country, so no regex either for them.
+ const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex');
+ const countryZipFormat = lodashGet(countryRegexDetails, 'samples');
+
+ if (countrySpecificZipRegex) {
+ if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) {
+ if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) {
+ errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}];
+ } else {
+ errors.zipPostCode = 'common.error.fieldRequired';
+ }
+ }
+ } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) {
+ errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat';
+ }
+
+ return errors;
+ }, []);
+
+ return (
+
+ );
+}
+
+AddressForm.defaultProps = defaultProps;
+AddressForm.displayName = 'AddressForm';
+AddressForm.propTypes = propTypes;
+
+export default AddressForm;
diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js
index dc3a722c2331..3fc90433f13e 100644
--- a/src/components/Alert/index.js
+++ b/src/components/Alert/index.js
@@ -5,7 +5,7 @@ import _ from 'underscore';
*
* @param {String} title The title of the alert
* @param {String} description The description of the alert
- * @param {Object[]} options An array of objects with `style` and `onPress` properties
+ * @param {Object[]} [options] An array of objects with `style` and `onPress` properties
*/
export default (title, description, options) => {
const result = _.filter(window.confirm([title, description], Boolean)).join('\n');
diff --git a/src/components/AnimatedStep/AnimatedStepContext.js b/src/components/AnimatedStep/AnimatedStepContext.js
deleted file mode 100644
index 30377147fdb8..000000000000
--- a/src/components/AnimatedStep/AnimatedStepContext.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import {createContext} from 'react';
-
-const AnimatedStepContext = createContext();
-
-export default AnimatedStepContext;
diff --git a/src/components/AnimatedStep/AnimatedStepContext.ts b/src/components/AnimatedStep/AnimatedStepContext.ts
new file mode 100644
index 000000000000..3b4c5f79a34f
--- /dev/null
+++ b/src/components/AnimatedStep/AnimatedStepContext.ts
@@ -0,0 +1,15 @@
+import React, {createContext} from 'react';
+import {ValueOf} from 'type-fest';
+import CONST from '@src/CONST';
+
+type AnimationDirection = ValueOf;
+
+type StepContext = {
+ animationDirection: AnimationDirection;
+ setAnimationDirection: React.Dispatch>;
+};
+
+const AnimatedStepContext = createContext(null);
+
+export default AnimatedStepContext;
+export type {StepContext, AnimationDirection};
diff --git a/src/components/AnimatedStep/AnimatedStepProvider.js b/src/components/AnimatedStep/AnimatedStepProvider.tsx
similarity index 56%
rename from src/components/AnimatedStep/AnimatedStepProvider.js
rename to src/components/AnimatedStep/AnimatedStepProvider.tsx
index eb4797655344..53b3a0e0a53d 100644
--- a/src/components/AnimatedStep/AnimatedStepProvider.js
+++ b/src/components/AnimatedStep/AnimatedStepProvider.tsx
@@ -1,18 +1,14 @@
-import PropTypes from 'prop-types';
import React, {useMemo, useState} from 'react';
import CONST from '@src/CONST';
-import AnimatedStepContext from './AnimatedStepContext';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import AnimatedStepContext, {AnimationDirection} from './AnimatedStepContext';
-const propTypes = {
- children: PropTypes.node.isRequired,
-};
-
-function AnimatedStepProvider({children}) {
- const [animationDirection, setAnimationDirection] = useState(CONST.ANIMATION_DIRECTION.IN);
+function AnimatedStepProvider({children}: ChildrenProps): React.ReactNode {
+ const [animationDirection, setAnimationDirection] = useState(CONST.ANIMATION_DIRECTION.IN);
const contextValue = useMemo(() => ({animationDirection, setAnimationDirection}), [animationDirection, setAnimationDirection]);
return {children};
}
-AnimatedStepProvider.propTypes = propTypes;
+AnimatedStepProvider.displayName = 'AnimatedStepProvider';
export default AnimatedStepProvider;
diff --git a/src/components/AnimatedStep/index.js b/src/components/AnimatedStep/index.tsx
similarity index 54%
rename from src/components/AnimatedStep/index.js
rename to src/components/AnimatedStep/index.tsx
index e916cbe1b84c..607f4f0a4b11 100644
--- a/src/components/AnimatedStep/index.js
+++ b/src/components/AnimatedStep/index.tsx
@@ -1,62 +1,52 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import {StyleProp, ViewStyle} from 'react-native';
import * as Animatable from 'react-native-animatable';
import useNativeDriver from '@libs/useNativeDriver';
import styles from '@styles/styles';
import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import {AnimationDirection} from './AnimatedStepContext';
-const propTypes = {
- /** Children to wrap in AnimatedStep. */
- children: PropTypes.node.isRequired,
-
+type AnimatedStepProps = ChildrenProps & {
/** Styles to be assigned to Container */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
+ style: StyleProp;
/** Whether we're animating the step in or out */
- direction: PropTypes.oneOf(['in', 'out']),
+ direction: AnimationDirection;
/** Callback to fire when the animation ends */
- onAnimationEnd: PropTypes.func,
-};
-
-const defaultProps = {
- direction: 'in',
- style: [],
- onAnimationEnd: () => {},
+ onAnimationEnd: () => void;
};
-function getAnimationStyle(direction) {
+function getAnimationStyle(direction: AnimationDirection) {
let transitionValue;
if (direction === 'in') {
transitionValue = CONST.ANIMATED_TRANSITION_FROM_VALUE;
- } else if (direction === 'out') {
+ } else {
transitionValue = -CONST.ANIMATED_TRANSITION_FROM_VALUE;
}
return styles.makeSlideInTranslation('translateX', transitionValue);
}
-function AnimatedStep(props) {
+function AnimatedStep({onAnimationEnd, direction = CONST.ANIMATION_DIRECTION.IN, style = [], children}: AnimatedStepProps) {
return (
{
- if (!props.onAnimationEnd) {
+ if (!onAnimationEnd) {
return;
}
- props.onAnimationEnd();
+ onAnimationEnd();
}}
duration={CONST.ANIMATED_TRANSITION}
- animation={getAnimationStyle(props.direction)}
+ animation={getAnimationStyle(direction)}
useNativeDriver={useNativeDriver}
- style={props.style}
+ style={style}
>
- {props.children}
+ {children}
);
}
-AnimatedStep.propTypes = propTypes;
-AnimatedStep.defaultProps = defaultProps;
AnimatedStep.displayName = 'AnimatedStep';
export default AnimatedStep;
diff --git a/src/components/AnimatedStep/useAnimatedStepContext.js b/src/components/AnimatedStep/useAnimatedStepContext.ts
similarity index 69%
rename from src/components/AnimatedStep/useAnimatedStepContext.js
rename to src/components/AnimatedStep/useAnimatedStepContext.ts
index e2af9514e20e..3edc71e5289e 100644
--- a/src/components/AnimatedStep/useAnimatedStepContext.js
+++ b/src/components/AnimatedStep/useAnimatedStepContext.ts
@@ -1,7 +1,7 @@
import {useContext} from 'react';
-import AnimatedStepContext from './AnimatedStepContext';
+import AnimatedStepContext, {StepContext} from './AnimatedStepContext';
-function useAnimatedStepContext() {
+function useAnimatedStepContext(): StepContext {
const context = useContext(AnimatedStepContext);
if (!context) {
throw new Error('useAnimatedStepContext must be used within an AnimatedStepContextProvider');
diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js
index 27790121aab0..ec53507d4d8e 100644
--- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js
+++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js
@@ -1,6 +1,7 @@
-import React, {useEffect, useRef} from 'react';
-// We take FlatList from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another
-import {FlatList} from 'react-native-gesture-handler';
+import {FlashList} from '@shopify/flash-list';
+import React, {useCallback, useEffect, useRef} from 'react';
+// We take ScrollView from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another
+import {ScrollView} from 'react-native-gesture-handler';
import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import * as StyleUtils from '@styles/StyleUtils';
@@ -28,7 +29,16 @@ const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => {
return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT;
};
-function BaseAutoCompleteSuggestions(props) {
+function BaseAutoCompleteSuggestions({
+ highlightedSuggestionIndex,
+ onSelect,
+ renderSuggestionMenuItem,
+ suggestions,
+ accessibilityLabelExtractor,
+ keyExtractor,
+ isSuggestionPickerLarge,
+ forwardedRef,
+}) {
const styles = useThemeStyles();
const rowHeight = useSharedValue(0);
const scrollRef = useRef(null);
@@ -39,70 +49,56 @@ function BaseAutoCompleteSuggestions(props) {
* @param {Number} params.index
* @returns {JSX.Element}
*/
- const renderSuggestionMenuItem = ({item, index}) => (
- StyleUtils.getAutoCompleteSuggestionItemStyle(props.highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
- hoverDimmingValue={1}
- onMouseDown={(e) => e.preventDefault()}
- onPress={() => props.onSelect(index)}
- onLongPress={() => {}}
- accessibilityLabel={props.accessibilityLabelExtractor(item, index)}
- >
- {props.renderSuggestionMenuItem(item, index)}
-
+ const renderItem = useCallback(
+ ({item, index}) => (
+ StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
+ hoverDimmingValue={1}
+ onMouseDown={(e) => e.preventDefault()}
+ onPress={() => onSelect(index)}
+ onLongPress={() => {}}
+ accessibilityLabel={accessibilityLabelExtractor(item, index)}
+ >
+ {renderSuggestionMenuItem(item, index)}
+
+ ),
+ [highlightedSuggestionIndex, renderSuggestionMenuItem, onSelect, accessibilityLabelExtractor],
);
- /**
- * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
- * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
- * lists.
- *
- * Also, `scrollToIndex` should be used in conjunction with `getItemLayout`, otherwise there is no way to know the location of offscreen indices or handle failures.
- *
- * @param {Array} data - This is the same as the data we pass into the component
- * @param {Number} index the current item's index in the set of data
- *
- * @returns {Object}
- */
- const getItemLayout = (data, index) => ({
- length: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT,
- offset: index * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT,
- index,
- });
-
- const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * props.suggestions.length;
+ const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length;
const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value));
useEffect(() => {
- rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), {
+ rowHeight.value = withTiming(measureHeightOfSuggestionRows(suggestions.length, isSuggestionPickerLarge), {
duration: 100,
easing: Easing.inOut(Easing.ease),
});
- }, [props.suggestions.length, props.isSuggestionPickerLarge, rowHeight]);
+ }, [suggestions.length, isSuggestionPickerLarge, rowHeight]);
useEffect(() => {
if (!scrollRef.current) {
return;
}
- scrollRef.current.scrollToIndex({index: props.highlightedSuggestionIndex, animated: true});
- }, [props.highlightedSuggestionIndex]);
+ scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true});
+ }, [highlightedSuggestionIndex]);
return (
- rowHeight.value}
- style={{flex: 1}}
- getItemLayout={getItemLayout}
+ extraData={highlightedSuggestionIndex}
/>
);
diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js
index 893a02288e77..340fc9dfedbf 100644
--- a/src/components/AvatarWithImagePicker.js
+++ b/src/components/AvatarWithImagePicker.js
@@ -1,14 +1,15 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
+import React, {useEffect, useRef, useState} from 'react';
+import {StyleSheet, View} from 'react-native';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import * as Browser from '@libs/Browser';
-import compose from '@libs/compose';
import * as FileUtils from '@libs/fileDownload/FileUtils';
import getImageResolution from '@libs/fileDownload/getImageResolution';
-import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation';
import stylePropTypes from '@styles/stylePropTypes';
+import styles from '@styles/styles';
+import themeColors from '@styles/themes/default';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import AttachmentModal from './AttachmentModal';
@@ -21,11 +22,8 @@ import * as Expensicons from './Icon/Expensicons';
import OfflineWithFeedback from './OfflineWithFeedback';
import PopoverMenu from './PopoverMenu';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
-import Tooltip from './Tooltip/PopoverAnchorTooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import Tooltip from './Tooltip';
import withNavigationFocus from './withNavigationFocus';
-import withTheme, {withThemePropTypes} from './withTheme';
-import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles';
const propTypes = {
/** Avatar source to display */
@@ -54,9 +52,6 @@ const propTypes = {
left: PropTypes.number,
}).isRequired,
- /** Flag to see if image is being uploaded */
- isUploading: PropTypes.bool,
-
/** Size of Indicator */
size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]),
@@ -94,9 +89,11 @@ const propTypes = {
/** Whether navigation is focused */
isFocused: PropTypes.bool.isRequired,
- ...withLocalizePropTypes,
- ...withThemeStylesPropTypes,
- ...withThemePropTypes,
+ /** Where the popover should be positioned relative to the anchor points. */
+ anchorAlignment: PropTypes.shape({
+ horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)),
+ vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
+ }),
};
const defaultProps = {
@@ -106,7 +103,6 @@ const defaultProps = {
style: [],
DefaultAvatar: () => {},
isUsingDefaultAvatar: false,
- isUploading: false,
size: CONST.AVATAR_SIZE.DEFAULT,
fallbackIcon: Expensicons.FallbackAvatar,
type: CONST.ICON_TYPE_AVATAR,
@@ -118,58 +114,67 @@ const defaultProps = {
headerTitle: '',
previewSource: '',
originalFileName: '',
+ anchorAlignment: {
+ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
+ vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP,
+ },
};
-class AvatarWithImagePicker extends React.Component {
- constructor(props) {
- super(props);
- this.animation = new SpinningIndicatorAnimation();
- this.setError = this.setError.bind(this);
- this.isValidSize = this.isValidSize.bind(this);
- this.showAvatarCropModal = this.showAvatarCropModal.bind(this);
- this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this);
- this.state = {
- isMenuVisible: false,
- validationError: null,
- phraseParam: {},
- isAvatarCropModalOpen: false,
- imageName: '',
- imageUri: '',
- imageType: '',
- };
- this.anchorRef = React.createRef();
- }
-
- componentDidMount() {
- if (!this.props.isUploading) {
- return;
- }
-
- this.animation.start();
- }
-
- componentDidUpdate(prevProps) {
- if (!prevProps.isFocused && this.props.isFocused) {
- this.setError(null, {});
- }
- if (!prevProps.isUploading && this.props.isUploading) {
- this.animation.start();
- } else if (prevProps.isUploading && !this.props.isUploading) {
- this.animation.stop();
- }
- }
-
- componentWillUnmount() {
- this.animation.stop();
- }
+function AvatarWithImagePicker({
+ isFocused,
+ DefaultAvatar,
+ style,
+ pendingAction,
+ errors,
+ errorRowStyles,
+ onErrorClose,
+ source,
+ fallbackIcon,
+ size,
+ type,
+ headerTitle,
+ previewSource,
+ originalFileName,
+ isUsingDefaultAvatar,
+ onImageRemoved,
+ anchorPosition,
+ anchorAlignment,
+ onImageSelected,
+ editorMaskImage,
+}) {
+ const [isMenuVisible, setIsMenuVisible] = useState(false);
+ const [errorData, setErrorData] = useState({
+ validationError: null,
+ phraseParam: {},
+ });
+ const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false);
+ const [imageData, setImageData] = useState({
+ uri: '',
+ name: '',
+ type: '',
+ });
+ const anchorRef = useRef();
+ const {translate} = useLocalize();
/**
* @param {String} error
* @param {Object} phraseParam
*/
- setError(error, phraseParam) {
- this.setState({validationError: error, phraseParam});
- }
+ const setError = (error, phraseParam) => {
+ setErrorData({
+ validationError: error,
+ phraseParam,
+ });
+ };
+
+ useEffect(() => {
+ if (isFocused) {
+ return;
+ }
+
+ // Reset the error if the component is no longer focused.
+ setError(null, {});
+ }, [isFocused]);
/**
* Check if the attachment extension is allowed.
@@ -177,10 +182,10 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidExtension(image) {
+ const isValidExtension = (image) => {
const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', ''));
return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase());
- }
+ };
/**
* Check if the attachment size is less than allowed size.
@@ -188,9 +193,7 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Boolean}
*/
- isValidSize(image) {
- return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
- }
+ const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE;
/**
* Check if the attachment resolution matches constraints.
@@ -198,34 +201,29 @@ class AvatarWithImagePicker extends React.Component {
* @param {Object} image
* @returns {Promise}
*/
- isValidResolution(image) {
- return getImageResolution(image).then(
- (resolution) =>
- resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX &&
- resolution.width >= CONST.AVATAR_MIN_WIDTH_PX &&
- resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX &&
- resolution.width <= CONST.AVATAR_MAX_WIDTH_PX,
+ const isValidResolution = (image) =>
+ getImageResolution(image).then(
+ ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX,
);
- }
/**
* Validates if an image has a valid resolution and opens an avatar crop modal
*
* @param {Object} image
*/
- showAvatarCropModal(image) {
- if (!this.isValidExtension(image)) {
- this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
+ const showAvatarCropModal = (image) => {
+ if (!isValidExtension(image)) {
+ setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS});
return;
}
- if (!this.isValidSize(image)) {
- this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
+ if (!isValidSize(image)) {
+ setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)});
return;
}
- this.isValidResolution(image).then((isValidResolution) => {
- if (!isValidResolution) {
- this.setError('avatarWithImagePicker.resolutionConstraints', {
+ isValidResolution(image).then((isValid) => {
+ if (!isValid) {
+ setError('avatarWithImagePicker.resolutionConstraints', {
minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX,
minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX,
maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX,
@@ -234,158 +232,168 @@ class AvatarWithImagePicker extends React.Component {
return;
}
- this.setState({
- isAvatarCropModalOpen: true,
- validationError: null,
- phraseParam: {},
- isMenuVisible: false,
- imageUri: image.uri,
- imageName: image.name,
- imageType: image.type,
+ setIsAvatarCropModalOpen(true);
+ setError(null, {});
+ setIsMenuVisible(false);
+ setImageData({
+ uri: image.uri,
+ name: image.name,
+ type: image.type,
});
});
- }
-
- hideAvatarCropModal() {
- this.setState({isAvatarCropModalOpen: false});
- }
-
- render() {
- const DefaultAvatar = this.props.DefaultAvatar;
- const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style];
-
- return (
-
-
-
-
- this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))}
- role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
- accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')}
- disabled={this.state.isAvatarCropModalOpen}
- ref={this.anchorRef}
- >
-
- {this.props.source ? (
-
- ) : (
-
- )}
-
-
- {
+ setIsAvatarCropModalOpen(false);
+ };
+
+ /**
+ * Create menu items list for avatar menu
+ *
+ * @param {Function} openPicker
+ * @returns {Array}
+ */
+ const createMenuItems = (openPicker) => {
+ const menuItems = [
+ {
+ icon: Expensicons.Upload,
+ text: translate('avatarWithImagePicker.uploadPhoto'),
+ onSelected: () => {
+ if (Browser.isSafari()) {
+ return;
+ }
+ openPicker({
+ onPicked: showAvatarCropModal,
+ });
+ },
+ },
+ ];
+
+ // If current avatar isn't a default avatar, allow Remove Photo option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Trashcan,
+ text: translate('avatarWithImagePicker.removePhoto'),
+ onSelected: () => {
+ setError(null, {});
+ onImageRemoved();
+ },
+ });
+ }
+ return menuItems;
+ };
+
+ return (
+
+
+
+
+ setIsMenuVisible((prev) => !prev)}
+ role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON}
+ accessibilityLabel={translate('avatarWithImagePicker.editImage')}
+ disabled={isAvatarCropModalOpen}
+ ref={anchorRef}
+ >
+
+ {source ? (
+
-
-
-
-
-
- {({show}) => (
-
- {({openPicker}) => {
- const menuItems = [
- {
- icon: Expensicons.Upload,
- text: this.props.translate('avatarWithImagePicker.uploadPhoto'),
- onSelected: () => {
- if (Browser.isSafari()) {
- return;
- }
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {({show}) => (
+
+ {({openPicker}) => {
+ const menuItems = createMenuItems(openPicker);
+
+ // If the current avatar isn't a default avatar, allow the "View Photo" option
+ if (!isUsingDefaultAvatar) {
+ menuItems.push({
+ icon: Expensicons.Eye,
+ text: translate('avatarWithImagePicker.viewPhoto'),
+ onSelected: show,
+ });
+ }
+
+ return (
+ setIsMenuVisible(false)}
+ onItemSelected={(item, index) => {
+ setIsMenuVisible(false);
+ // In order for the file picker to open dynamically, the click
+ // function must be called from within an event handler that was initiated
+ // by the user on Safari.
+ if (index === 0 && Browser.isSafari()) {
openPicker({
- onPicked: this.showAvatarCropModal,
+ onPicked: showAvatarCropModal,
});
- },
- },
- ];
-
- // If current avatar isn't a default avatar, allow Remove Photo option
- if (!this.props.isUsingDefaultAvatar) {
- menuItems.push({
- icon: Expensicons.Trashcan,
- text: this.props.translate('avatarWithImagePicker.removePhoto'),
- onSelected: () => {
- this.setError(null, {});
- this.props.onImageRemoved();
- },
- });
-
- menuItems.push({
- icon: Expensicons.Eye,
- text: this.props.translate('avatarWithImagePicker.viewPhoto'),
- onSelected: () => show(),
- });
- }
- return (
- this.setState({isMenuVisible: false})}
- onItemSelected={(item, index) => {
- this.setState({isMenuVisible: false});
- // In order for the file picker to open dynamically, the click
- // function must be called from within a event handler that was initiated
- // by the user on Safari.
- if (index === 0 && Browser.isSafari()) {
- openPicker({
- onPicked: this.showAvatarCropModal,
- });
- }
- }}
- menuItems={menuItems}
- anchorPosition={this.props.anchorPosition}
- withoutOverlay
- anchorRef={this.anchorRef}
- anchorAlignment={this.props.anchorAlignment}
- />
- );
- }}
-
- )}
-
-
- {this.state.validationError && (
-
- )}
-
+ }
+ }}
+ menuItems={menuItems}
+ anchorPosition={anchorPosition}
+ withoutOverlay
+ anchorRef={anchorRef}
+ anchorAlignment={anchorAlignment}
+ />
+ );
+ }}
+
+ )}
+
- );
- }
+ {errorData.validationError && (
+
+ )}
+
+
+ );
}
AvatarWithImagePicker.propTypes = propTypes;
AvatarWithImagePicker.defaultProps = defaultProps;
+AvatarWithImagePicker.displayName = 'AvatarWithImagePicker';
-export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker);
+export default withNavigationFocus(AvatarWithImagePicker);
diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx
index 22c056dfdfc4..575646f7dd9c 100644
--- a/src/components/Badge.tsx
+++ b/src/components/Badge.tsx
@@ -29,7 +29,7 @@ type BadgeProps = {
textStyles?: StyleProp;
/** Callback to be called on onPress */
- onPress: (event?: GestureResponderEvent | KeyboardEvent) => void;
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
};
function Badge({success = false, error = false, pressable = false, text, environment = CONST.ENVIRONMENT.DEV, badgeStyles, textStyles, onPress = () => {}}: BadgeProps) {
diff --git a/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js b/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js
deleted file mode 100644
index 61a2d6feaa4b..000000000000
--- a/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js
+++ /dev/null
@@ -1,52 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import Checkbox from '@components/Checkbox';
-import withWindowDimensions from '@components/withWindowDimensions';
-import Growl from '@libs/Growl';
-import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes';
-
-class CheckboxWithTooltipForMobileWebAndNative extends React.Component {
- constructor(props) {
- super(props);
- this.showGrowlOrTriggerOnPress = this.showGrowlOrTriggerOnPress.bind(this);
- }
-
- componentDidUpdate(prevProps) {
- if (!this.props.toggleTooltip) {
- return;
- }
-
- if (prevProps.toggleTooltip !== this.props.toggleTooltip) {
- Growl.show(this.props.text, this.props.growlType, 3000);
- }
- }
-
- /**
- * Show warning modal on mobile devices since tooltips are not supported when checkbox is disabled.
- */
- showGrowlOrTriggerOnPress() {
- if (this.props.toggleTooltip) {
- Growl.show(this.props.text, this.props.growlType, 3000);
- return;
- }
- this.props.onPress();
- }
-
- render() {
- return (
-
-
-
- );
- }
-}
-
-CheckboxWithTooltipForMobileWebAndNative.propTypes = propTypes;
-CheckboxWithTooltipForMobileWebAndNative.defaultProps = defaultProps;
-
-export default withWindowDimensions(CheckboxWithTooltipForMobileWebAndNative);
diff --git a/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js b/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js
deleted file mode 100644
index 67588d00ef65..000000000000
--- a/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import PropTypes from 'prop-types';
-import {windowDimensionsPropTypes} from '@components/withWindowDimensions';
-import stylePropTypes from '@styles/stylePropTypes';
-import CONST from '@src/CONST';
-
-const propTypes = {
- /** Whether the checkbox is checked */
- isChecked: PropTypes.bool.isRequired,
-
- /** Called when the checkbox or label is pressed */
- onPress: PropTypes.func.isRequired,
-
- /** Flag to determine to toggle or not the tooltip */
- toggleTooltip: PropTypes.bool,
-
- /** The text to display in the tooltip. */
- text: PropTypes.string.isRequired,
-
- /** Type of the growl to be displayed in case of mobile devices */
- growlType: PropTypes.string,
-
- /** Container styles */
- style: stylePropTypes,
-
- /** Wheter the checkbox is disabled */
- disabled: PropTypes.bool,
-
- /** An accessibility label for the checkbox */
- accessibilityLabel: PropTypes.string,
-
- /** Props inherited from withWindowDimensions */
- ...windowDimensionsPropTypes,
-};
-
-const defaultProps = {
- style: [],
- disabled: false,
- toggleTooltip: true,
- growlType: CONST.GROWL.WARNING,
- accessibilityLabel: undefined,
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/CheckboxWithTooltip/index.js b/src/components/CheckboxWithTooltip/index.js
deleted file mode 100644
index 06e4e0412eba..000000000000
--- a/src/components/CheckboxWithTooltip/index.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import React from 'react';
-import {View} from 'react-native';
-import Checkbox from '@components/Checkbox';
-import Tooltip from '@components/Tooltip';
-import withWindowDimensions from '@components/withWindowDimensions';
-import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative';
-import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes';
-
-function CheckboxWithTooltip(props) {
- if (props.isSmallScreenWidth || props.isMediumScreenWidth) {
- return (
-
- );
- }
- const checkbox = (
-
- );
- return (
-
- {props.toggleTooltip ? (
-
- {checkbox}
-
- ) : (
- checkbox
- )}
-
- );
-}
-
-CheckboxWithTooltip.propTypes = propTypes;
-CheckboxWithTooltip.defaultProps = defaultProps;
-CheckboxWithTooltip.displayName = 'CheckboxWithTooltip';
-
-export default withWindowDimensions(CheckboxWithTooltip);
diff --git a/src/components/CheckboxWithTooltip/index.native.js b/src/components/CheckboxWithTooltip/index.native.js
deleted file mode 100644
index 46ce0bbd131e..000000000000
--- a/src/components/CheckboxWithTooltip/index.native.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import React from 'react';
-import withWindowDimensions from '@components/withWindowDimensions';
-import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative';
-import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes';
-
-function CheckboxWithTooltip(props) {
- return (
-
- );
-}
-
-CheckboxWithTooltip.propTypes = propTypes;
-CheckboxWithTooltip.defaultProps = defaultProps;
-CheckboxWithTooltip.displayName = 'CheckboxWithTooltip';
-
-export default withWindowDimensions(CheckboxWithTooltip);
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 3fe3838c8c81..7c720c4bd681 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -98,6 +98,7 @@ function ConfirmModal(props) {
shouldSetModalVisibility={props.shouldSetModalVisibility}
onModalHide={props.onModalHide}
type={props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
+ shouldEnableFocusTrap
>
(null);
+
+ return isEnabled ? (
+ (shouldEnableAutoFocus && ref.current) ?? false,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ fallbackFocus: () => ref.current!,
+ clickOutsideDeactivates: true,
+ }}
+ >
+
+
+ ) : (
+ props.children
+ );
+}
+
+FocusTrapView.displayName = 'FocusTrapView';
+
+export default FocusTrapView;
diff --git a/src/components/FocusTrapView/types.ts b/src/components/FocusTrapView/types.ts
new file mode 100644
index 000000000000..500b4b4315d9
--- /dev/null
+++ b/src/components/FocusTrapView/types.ts
@@ -0,0 +1,21 @@
+import {ViewProps} from 'react-native';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+
+type FocusTrapViewProps = ChildrenProps & {
+ /**
+ * Whether to enable the FocusTrap.
+ * If the FocusTrap is disabled, we just pass the children through.
+ */
+ isEnabled?: boolean;
+
+ /**
+ * Whether to disable auto focus
+ * It is used when the component inside the FocusTrap have their own auto focus logic
+ */
+ shouldEnableAutoFocus?: boolean;
+
+ /** Whether the FocusTrap is active (listening for events) */
+ isActive?: boolean;
+} & ViewProps;
+
+export default FocusTrapViewProps;
diff --git a/src/components/Form.js b/src/components/Form.js
index 28343691ea15..d5865dab44b8 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import compose from '@libs/compose';
import * as ErrorUtils from '@libs/ErrorUtils';
+import FormUtils from '@libs/FormUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
import Visibility from '@libs/Visibility';
import stylePropTypes from '@styles/stylePropTypes';
@@ -303,7 +304,8 @@ function Form(props) {
// We want to initialize the input value if it's undefined
if (_.isUndefined(inputValues[inputID])) {
- inputValues[inputID] = _.isBoolean(defaultValue) ? defaultValue : defaultValue || '';
+ // eslint-disable-next-line es/no-nullish-coalescing-operators
+ inputValues[inputID] = defaultValue ?? '';
}
// We force the form to set the input value from the defaultValue props if there is a saved valid value
@@ -543,7 +545,7 @@ export default compose(
key: (props) => props.formID,
},
draftValues: {
- key: (props) => `${props.formID}Draft`,
+ key: (props) => FormUtils.getDraftKey(props.formID),
},
}),
)(Form);
diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js
index 2d583881cab6..07ea1ea6f48d 100644
--- a/src/components/HeaderGap/index.desktop.js
+++ b/src/components/HeaderGap/index.desktop.js
@@ -1,7 +1,8 @@
import PropTypes from 'prop-types';
-import React, {PureComponent} from 'react';
+import React, {memo} from 'react';
import {View} from 'react-native';
import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles';
+import compose from '@libs/compose';
const propTypes = {
/** Styles to apply to the HeaderGap */
@@ -10,14 +11,15 @@ const propTypes = {
...withThemeStylesPropTypes,
};
-class HeaderGap extends PureComponent {
- render() {
- return ;
- }
+const defaultProps = {
+ styles: [],
+};
+
+function HeaderGap(props) {
+ return ;
}
+HeaderGap.displayName = 'HeaderGap';
HeaderGap.propTypes = propTypes;
-HeaderGap.defaultProps = {
- styles: [],
-};
-export default withThemeStyles(HeaderGap);
+HeaderGap.defaultProps = defaultProps;
+export default compose(memo, withThemeStyles)(HeaderGap);
diff --git a/src/components/HeaderGap/index.js b/src/components/HeaderGap/index.js
index ca81056d5f7a..35e6bf92fb5d 100644
--- a/src/components/HeaderGap/index.js
+++ b/src/components/HeaderGap/index.js
@@ -1,7 +1,6 @@
-import {PureComponent} from 'react';
-
-export default class HeaderGap extends PureComponent {
- render() {
- return null;
- }
+function HeaderGap() {
+ return null;
}
+
+HeaderGap.displayName = 'HeaderGap';
+export default HeaderGap;
diff --git a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js b/src/components/KeyboardSpacer/BaseKeyboardSpacer.js
deleted file mode 100644
index adab3e2ea66d..000000000000
--- a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import React, {useCallback, useEffect, useState} from 'react';
-import {Dimensions, Keyboard, View} from 'react-native';
-import * as StyleUtils from '@styles/StyleUtils';
-import {defaultProps, propTypes} from './BaseKeyboardSpacerPropTypes';
-
-function BaseKeyboardSpacer(props) {
- const [keyboardSpace, setKeyboardSpace] = useState(0);
-
- /**
- * Update the height of Keyboard View.
- *
- * @param {Object} [event] - A Keyboard Event.
- */
- const updateKeyboardSpace = useCallback(
- (event) => {
- if (!event.endCoordinates) {
- return;
- }
-
- const screenHeight = Dimensions.get('window').height;
- const space = screenHeight - event.endCoordinates.screenY + props.topSpacing;
- setKeyboardSpace(space);
- props.onToggle(true, space);
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [],
- );
-
- /**
- * Reset the height of Keyboard View.
- *
- * @param {Object} [event] - A Keyboard Event.
- */
- const resetKeyboardSpace = useCallback(() => {
- setKeyboardSpace(0);
- props.onToggle(false, 0);
- }, [setKeyboardSpace, props]);
-
- useEffect(() => {
- const updateListener = props.keyboardShowMethod;
- const resetListener = props.keyboardHideMethod;
- const keyboardListeners = [Keyboard.addListener(updateListener, updateKeyboardSpace), Keyboard.addListener(resetListener, resetKeyboardSpace)];
-
- return () => {
- keyboardListeners.forEach((listener) => listener.remove());
- };
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
-
- return ;
-}
-
-BaseKeyboardSpacer.defaultProps = defaultProps;
-BaseKeyboardSpacer.propTypes = propTypes;
-
-export default BaseKeyboardSpacer;
diff --git a/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js b/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js
deleted file mode 100644
index 23154da79e53..000000000000
--- a/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js
+++ /dev/null
@@ -1,24 +0,0 @@
-import PropTypes from 'prop-types';
-
-const propTypes = {
- /** Top Spacing is used when there is a requirement of additional height to view. */
- topSpacing: PropTypes.number,
-
- /** Callback to update the value of keyboard status along with keyboard height + top spacing. */
- onToggle: PropTypes.func,
-
- /** Platform specific keyboard event to show keyboard https://reactnative.dev/docs/keyboard#addlistener */
- /** Pass keyboardShow event name as a param, since iOS and android both have different event names show keyboard. */
- keyboardShowMethod: PropTypes.string.isRequired,
-
- /** Platform specific keyboard event to hide keyboard https://reactnative.dev/docs/keyboard#addlistener */
- /** Pass keyboardHide event name as a param, since iOS and android both have different event names show keyboard. */
- keyboardHideMethod: PropTypes.string.isRequired,
-};
-
-const defaultProps = {
- topSpacing: 0,
- onToggle: () => null,
-};
-
-export {propTypes, defaultProps};
diff --git a/src/components/KeyboardSpacer/index.android.js b/src/components/KeyboardSpacer/index.android.js
deleted file mode 100644
index d7c57f7d73c2..000000000000
--- a/src/components/KeyboardSpacer/index.android.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * On Android the keyboard covers the input fields on the bottom of the view. This component moves the
- * view up with the keyboard allowing the user to see what they are typing.
- */
-import React from 'react';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
-import StatusBar from '@libs/StatusBar';
-import BaseKeyboardSpacer from './BaseKeyboardSpacer';
-
-function KeyboardSpacer() {
- return (
-
- );
-}
-
-KeyboardSpacer.propTypes = windowDimensionsPropTypes;
-KeyboardSpacer.displayName = 'KeyboardSpacer';
-
-export default withWindowDimensions(KeyboardSpacer);
diff --git a/src/components/KeyboardSpacer/index.ios.js b/src/components/KeyboardSpacer/index.ios.js
deleted file mode 100644
index 612ef75c290f..000000000000
--- a/src/components/KeyboardSpacer/index.ios.js
+++ /dev/null
@@ -1,24 +0,0 @@
-/**
- * On iOS the keyboard covers the input fields on the bottom of the view. This component moves the view up with the
- * keyboard allowing the user to see what they are typing.
- */
-import React from 'react';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
-import * as StyleUtils from '@styles/StyleUtils';
-import CONST from '@src/CONST';
-import BaseKeyboardSpacer from './BaseKeyboardSpacer';
-
-function KeyboardSpacer(props) {
- return (
-
- );
-}
-
-KeyboardSpacer.propTypes = windowDimensionsPropTypes;
-KeyboardSpacer.displayName = 'KeyboardSpacer';
-
-export default withWindowDimensions(KeyboardSpacer);
diff --git a/src/components/KeyboardSpacer/index.js b/src/components/KeyboardSpacer/index.js
deleted file mode 100644
index 77e1cc978337..000000000000
--- a/src/components/KeyboardSpacer/index.js
+++ /dev/null
@@ -1,11 +0,0 @@
-/**
- * On non native platforms we do not need to implement a keyboard spacer, so we return a null component.
- *
- * @returns {null}
- * @constructor
- */
-function KeyboardSpacer() {
- return null;
-}
-
-export default KeyboardSpacer;
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index 0d300c5e2179..5e77947187e9 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,8 +1,7 @@
-import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
-import {View} from 'react-native';
+import {FlatList, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import participantPropTypes from '@components/participantPropTypes';
@@ -12,7 +11,6 @@ import compose from '@libs/compose';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
-import stylePropTypes from '@styles/stylePropTypes';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -21,10 +19,12 @@ import OptionRowLHNData from './OptionRowLHNData';
const propTypes = {
/** Wrapper style for the section list */
- style: stylePropTypes,
+ // eslint-disable-next-line react/forbid-prop-types
+ style: PropTypes.arrayOf(PropTypes.object),
/** Extra styles for the section list container */
- contentContainerStyles: stylePropTypes.isRequired,
+ // eslint-disable-next-line react/forbid-prop-types
+ contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired,
/** Sections for the section list */
data: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -80,7 +80,7 @@ const defaultProps = {
...withCurrentReportIDDefaultProps,
};
-const keyExtractor = (item) => `report_${item}`;
+const keyExtractor = (item) => item;
function LHNOptionsList({
style,
@@ -99,6 +99,28 @@ function LHNOptionsList({
currentReportID,
}) {
const styles = useThemeStyles();
+ /**
+ * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
+ * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
+ * lists.
+ *
+ * @param {Array} itemData - This is the same as the data we pass into the component
+ * @param {Number} index the current item's index in the set of data
+ *
+ * @returns {Object}
+ */
+ const getItemLayout = useCallback(
+ (itemData, index) => {
+ const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
+ return {
+ length: optionHeight,
+ offset: index * optionHeight,
+ index,
+ };
+ },
+ [optionMode],
+ );
+
/**
* Function which renders a row in the list
*
@@ -142,17 +164,20 @@ function LHNOptionsList({
return (
-
);
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index 103d063f9024..9883672976e8 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -78,6 +78,7 @@ const defaultProps = {
shouldGreyOutWhenDisabled: true,
error: '',
shouldRenderAsHTML: false,
+ rightLabel: '',
rightComponent: undefined,
shouldShowRightComponent: false,
titleWithTooltips: [],
@@ -364,6 +365,11 @@ const MenuItem = React.forwardRef((props, ref) => {
/>
)}
+ {Boolean(props.rightLabel) && (
+
+ {props.rightLabel}
+
+ )}
{Boolean(props.shouldShowRightIcon) && (
{
isVisibleRef.current = isVisible;
+ let removeOnCloseListener: () => void;
if (isVisible) {
Modal.willAlertModalBecomeVisible(true);
// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
- Modal.setCloseModal(onClose);
+ removeOnCloseListener = Modal.setCloseModal(onClose);
} else if (wasVisible && !isVisible) {
Modal.willAlertModalBecomeVisible(false);
- Modal.setCloseModal(null);
}
+
+ return () => {
+ if (!removeOnCloseListener) {
+ return;
+ }
+ removeOnCloseListener();
+ };
}, [isVisible, wasVisible, onClose]);
useEffect(
@@ -90,8 +97,6 @@ function BaseModal(
}
hideModal(true);
Modal.willAlertModalBecomeVisible(false);
- // To prevent closing any modal already unmounted when this modal still remains as visible state
- Modal.setCloseModal(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index f760d3c0244e..710ecd79b375 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,13 +1,16 @@
import React, {useState} from 'react';
+import FocusTrapView from '@components/FocusTrapView';
import withWindowDimensions from '@components/withWindowDimensions';
import StatusBar from '@libs/StatusBar';
import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldEnableFocusTrap = false, ...rest}: BaseModalProps) {
+ const styles = useThemeStyles();
const theme = useTheme();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -48,7 +51,13 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
fullscreen={fullscreen}
type={type}
>
- {children}
+
+ {children}
+
);
}
diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js
index 84e610b694e4..7465e28b28ad 100644
--- a/src/components/Modal/modalPropTypes.js
+++ b/src/components/Modal/modalPropTypes.js
@@ -66,6 +66,9 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,
+ /** Should the modal use custom focus trap logic */
+ shouldEnableFocusTrap: PropTypes.bool,
+
...windowDimensionsPropTypes,
};
@@ -84,6 +87,7 @@ const defaultProps = {
statusBarTranslucent: true,
avoidKeyboard: false,
hideModalContentWhileAnimating: false,
+ shouldEnableFocusTrap: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index 3fa60e6ac765..ddb51a68ba1b 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -61,6 +61,9 @@ type BaseModalProps = WindowDimensionsProps &
* See: https://github.com/react-native-modal/react-native-modal/pull/116
* */
hideModalContentWhileAnimating?: boolean;
+
+ /** Whether the modal should use focus trap */
+ shouldEnableFocusTrap?: boolean;
};
export default BaseModalProps;
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 605d6909ebbc..6cf1b7e6cef1 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -8,6 +8,7 @@ import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
+import usePermissions from '@hooks/usePermissions';
import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
@@ -210,6 +211,7 @@ function MoneyRequestConfirmationList(props) {
const {onSendMoney, onConfirm, onSelectParticipant} = props;
const {translate, toLocaleDigit} = useLocalize();
const transaction = props.isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction;
+ const {canUseViolations} = usePermissions();
const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST;
const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT;
@@ -223,7 +225,6 @@ function MoneyRequestConfirmationList(props) {
// A flag for showing the categories field
const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories)));
-
// A flag and a toggler for showing the rest of the form fields
const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false);
@@ -715,6 +716,7 @@ function MoneyRequestConfirmationList(props) {
titleStyle={styles.flex1}
disabled={didConfirm}
interactive={!props.isReadOnly}
+ rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''}
/>
)}
{shouldShowTags && (
@@ -727,6 +729,7 @@ function MoneyRequestConfirmationList(props) {
style={[styles.moneyRequestMenuItem]}
disabled={didConfirm}
interactive={!props.isReadOnly}
+ rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''}
/>
)}
diff --git a/src/components/ParentNavigationSubtitle.js b/src/components/ParentNavigationSubtitle.tsx
similarity index 65%
rename from src/components/ParentNavigationSubtitle.js
rename to src/components/ParentNavigationSubtitle.tsx
index 0ce6582fe86d..e65a8617a996 100644
--- a/src/components/ParentNavigationSubtitle.js
+++ b/src/components/ParentNavigationSubtitle.tsx
@@ -1,49 +1,38 @@
-import PropTypes from 'prop-types';
import React from 'react';
+import {StyleProp, ViewStyle} from 'react-native';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import {ParentNavigationSummaryParams} from '@src/languages/types';
import ROUTES from '@src/ROUTES';
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
import Text from './Text';
-const propTypes = {
- parentNavigationSubtitleData: PropTypes.shape({
- // Title of root report room
- rootReportName: PropTypes.string,
-
- // Name of workspace, if any
- workspaceName: PropTypes.string,
- }).isRequired,
+type ParentNavigationSubtitleProps = {
+ parentNavigationSubtitleData: ParentNavigationSummaryParams;
/** parent Report ID */
- parentReportID: PropTypes.string,
+ parentReportID?: string;
/** PressableWithoutFeedack additional styles */
- // eslint-disable-next-line react/forbid-prop-types
- pressableStyles: PropTypes.arrayOf(PropTypes.object),
-};
-
-const defaultProps = {
- parentReportID: '',
- pressableStyles: [],
+ pressableStyles?: StyleProp;
};
-function ParentNavigationSubtitle(props) {
+function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) {
const styles = useThemeStyles();
- const {workspaceName, rootReportName} = props.parentNavigationSubtitleData;
+ const {workspaceName, rootReportName} = parentNavigationSubtitleData;
const {translate} = useLocalize();
return (
{
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.parentReportID));
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID));
}}
accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})}
role={CONST.ACCESSIBILITY_ROLE.LINK}
- style={[...props.pressableStyles]}
+ style={pressableStyles}
>
{
+ let removeOnClose;
if (props.isVisible) {
props.onModalShow();
onOpen({
ref: props.withoutOverlayRef,
close: props.onClose,
anchorRef: props.anchorRef,
- onCloseCallback: () => Modal.setCloseModal(null),
- onOpenCallback: () => Modal.setCloseModal(() => props.onClose(props.anchorRef)),
});
+ removeOnClose = Modal.setCloseModal(() => props.onClose(props.anchorRef));
} else {
props.onModalHide();
close(props.anchorRef);
@@ -41,6 +41,12 @@ function Popover(props) {
}
Modal.willAlertModalBecomeVisible(props.isVisible);
+ return () => {
+ if (!removeOnClose) {
+ return;
+ }
+ removeOnClose();
+ };
// We want this effect to run strictly ONLY when isVisible prop changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.isVisible]);
diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js
index 1764b6a1171e..07aba132be0e 100644
--- a/src/components/ReportActionItem/MoneyRequestPreview.js
+++ b/src/components/ReportActionItem/MoneyRequestPreview.js
@@ -172,7 +172,7 @@ function MoneyRequestPreview(props) {
// 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 shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant;
+ const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant && !isScanning;
const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : [];
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 1da061fc741e..33ad99f32326 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -281,14 +281,13 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
/>
)}
-
{shouldShowBillable && (
{translate('common.billable')}
IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})}
+ onToggle={(value) => IOU.editMoneyRequest(transaction, report.reportID, {billable: value})}
/>
)}
diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.tsx
similarity index 68%
rename from src/components/ReportHeaderSkeletonView.js
rename to src/components/ReportHeaderSkeletonView.tsx
index e0ef3f4257e3..acc9261889bc 100644
--- a/src/components/ReportHeaderSkeletonView.js
+++ b/src/components/ReportHeaderSkeletonView.tsx
@@ -1,8 +1,8 @@
-import PropTypes from 'prop-types';
import React from 'react';
import {View} from 'react-native';
import {Circle, Rect} from 'react-native-svg';
-import compose from '@libs/compose';
+import useLocalize from '@hooks/useLocalize';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
@@ -11,37 +11,32 @@ import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
import SkeletonViewContentLoader from './SkeletonViewContentLoader';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions';
-const propTypes = {
- ...windowDimensionsPropTypes,
- ...withLocalizePropTypes,
- shouldAnimate: PropTypes.bool,
+type ReportHeaderSkeletonViewProps = {
+ shouldAnimate?: boolean;
};
-const defaultProps = {
- shouldAnimate: true,
-};
-
-function ReportHeaderSkeletonView(props) {
+function ReportHeaderSkeletonView({shouldAnimate = true}: ReportHeaderSkeletonViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const {isSmallScreenWidth} = useWindowDimensions();
+
return (
-
- {props.isSmallScreenWidth && (
+
+ {isSmallScreenWidth && (
{}}
style={[styles.LHNToggle]}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
- accessibilityLabel={props.translate('common.back')}
+ accessibilityLabel={translate('common.back')}
>
)}
{
@@ -48,6 +51,7 @@ const ScreenWrapper = React.forwardRef(
const {isDevelopment} = useEnvironment();
const {isOffline} = useNetwork();
const navigation = useNavigation();
+ const isFocused = useIsFocused();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
const minHeight = shouldEnableMinHeight ? initialHeight : undefined;
@@ -146,20 +150,27 @@ const ScreenWrapper = React.forwardRef(
style={styles.flex1}
enabled={shouldEnablePickerAvoiding}
>
-
- {isDevelopment && }
- {isDevelopment && }
- {
- // If props.children is a function, call it to provide the insets to the children.
- _.isFunction(children)
- ? children({
- insets,
- safeAreaPaddingBottomStyle,
- didScreenTransitionEnd,
- })
- : children
- }
- {isSmallScreenWidth && shouldShowOfflineIndicator && }
+
+
+ {isDevelopment && }
+ {isDevelopment && }
+ {
+ // If props.children is a function, call it to provide the insets to the children.
+ _.isFunction(children)
+ ? children({
+ insets,
+ safeAreaPaddingBottomStyle,
+ didScreenTransitionEnd,
+ })
+ : children
+ }
+ {isSmallScreenWidth && shouldShowOfflineIndicator && }
+
diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js
index c98968bb112b..8984c860a15f 100644
--- a/src/components/ScreenWrapper/propTypes.js
+++ b/src/components/ScreenWrapper/propTypes.js
@@ -48,6 +48,12 @@ const propTypes = {
/** Styles for the offline indicator */
offlineIndicatorStyle: stylePropTypes,
+
+ /** Whether to disable the focus trap */
+ shouldDisableFocusTrap: PropTypes.bool,
+
+ /** Whether to disable auto focus of the focus trap */
+ shouldEnableAutoFocus: PropTypes.bool,
};
const defaultProps = {
@@ -63,6 +69,8 @@ const defaultProps = {
shouldShowOfflineIndicator: true,
offlineIndicatorStyle: [],
headerGapStyles: [],
+ shouldDisableFocusTrap: false,
+ shouldEnableAutoFocus: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/SwipeableView/index.native.tsx b/src/components/SwipeableView/index.native.tsx
index 07f1d785d0a6..91d851101d4e 100644
--- a/src/components/SwipeableView/index.native.tsx
+++ b/src/components/SwipeableView/index.native.tsx
@@ -3,40 +3,30 @@ import {PanResponder, View} from 'react-native';
import CONST from '@src/CONST';
import SwipeableViewProps from './types';
-function SwipeableView({children, onSwipeDown, onSwipeUp}: SwipeableViewProps) {
+function SwipeableView({children, onSwipeDown}: SwipeableViewProps) {
const minimumPixelDistance = CONST.COMPOSER_MAX_HEIGHT;
const oldYRef = useRef(0);
- const directionRef = useRef<'UP' | 'DOWN' | null>(null);
-
const panResponder = useRef(
PanResponder.create({
- onMoveShouldSetPanResponderCapture: (event, gestureState) => {
+ // The PanResponder gets focus only when the y-axis movement is over minimumPixelDistance & swipe direction is downwards
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ onMoveShouldSetPanResponderCapture: (_event, gestureState) => {
if (gestureState.dy - oldYRef.current > 0 && gestureState.dy > minimumPixelDistance) {
- directionRef.current = 'DOWN';
- return true;
- }
-
- if (gestureState.dy - oldYRef.current < 0 && Math.abs(gestureState.dy) > minimumPixelDistance) {
- directionRef.current = 'UP';
return true;
}
oldYRef.current = gestureState.dy;
return false;
},
- onPanResponderRelease: () => {
- if (directionRef.current === 'DOWN' && onSwipeDown) {
- onSwipeDown();
- } else if (directionRef.current === 'UP' && onSwipeUp) {
- onSwipeUp();
- }
- directionRef.current = null; // Reset the direction after the gesture completes
- },
+ // Calls the callback when the swipe down is released; after the completion of the gesture
+ onPanResponderRelease: onSwipeDown,
}),
).current;
- // eslint-disable-next-line react/jsx-props-no-spreading
- return {children};
+ return (
+ // eslint-disable-next-line react/jsx-props-no-spreading
+ {children}
+ );
}
SwipeableView.displayName = 'SwipeableView';
diff --git a/src/components/SwipeableView/index.tsx b/src/components/SwipeableView/index.tsx
index 478935173841..335c3e7dcf03 100644
--- a/src/components/SwipeableView/index.tsx
+++ b/src/components/SwipeableView/index.tsx
@@ -1,77 +1,4 @@
-import React, {useEffect, useRef} from 'react';
-import {View} from 'react-native';
-import DomUtils from '@libs/DomUtils';
import SwipeableViewProps from './types';
-// Min delta y in px to trigger swipe
-const MIN_DELTA_Y = 25;
-
-function SwipeableView({onSwipeUp, onSwipeDown, style, children}: SwipeableViewProps) {
- const ref = useRef(null);
- const scrollableChildRef = useRef(null);
- const startY = useRef(0);
- const isScrolling = useRef(false);
-
- useEffect(() => {
- if (!ref.current) {
- return;
- }
-
- const element = ref.current as unknown as HTMLElement;
-
- const handleTouchStart = (event: TouchEvent) => {
- startY.current = event.touches[0].clientY;
- };
-
- const handleTouchEnd = (event: TouchEvent) => {
- const deltaY = event.changedTouches[0].clientY - startY.current;
- const isSelecting = DomUtils.isActiveTextSelection();
- let canSwipeDown = true;
- let canSwipeUp = true;
- if (scrollableChildRef.current) {
- canSwipeUp = scrollableChildRef.current.scrollHeight - scrollableChildRef.current.scrollTop === scrollableChildRef.current.clientHeight;
- canSwipeDown = scrollableChildRef.current.scrollTop === 0;
- }
-
- if (deltaY > MIN_DELTA_Y && onSwipeDown && !isSelecting && canSwipeDown && !isScrolling.current) {
- onSwipeDown();
- }
-
- if (deltaY < -MIN_DELTA_Y && onSwipeUp && !isSelecting && canSwipeUp && !isScrolling.current) {
- onSwipeUp();
- }
- isScrolling.current = false;
- };
-
- const handleScroll = (event: Event) => {
- isScrolling.current = true;
- if (!event.target || scrollableChildRef.current) {
- return;
- }
- scrollableChildRef.current = event.target as HTMLElement;
- };
-
- element.addEventListener('touchstart', handleTouchStart);
- element.addEventListener('touchend', handleTouchEnd);
- element.addEventListener('scroll', handleScroll, true);
-
- return () => {
- element.removeEventListener('touchstart', handleTouchStart);
- element.removeEventListener('touchend', handleTouchEnd);
- element.removeEventListener('scroll', handleScroll);
- };
- }, [onSwipeDown, onSwipeUp]);
-
- return (
-
- {children}
-
- );
-}
-
-SwipeableView.displayName = 'SwipeableView';
-
-export default SwipeableView;
+// Swipeable View is available just on Android/iOS for now.
+export default ({children}: SwipeableViewProps) => children;
diff --git a/src/components/SwipeableView/types.ts b/src/components/SwipeableView/types.ts
index 1f2fbcdc752c..560df7ef5a45 100644
--- a/src/components/SwipeableView/types.ts
+++ b/src/components/SwipeableView/types.ts
@@ -1,18 +1,11 @@
import {ReactNode} from 'react';
-import {StyleProp, ViewStyle} from 'react-native';
type SwipeableViewProps = {
/** The content to be rendered within the SwipeableView */
children: ReactNode;
/** Callback to fire when the user swipes down on the child content */
- onSwipeDown?: () => void;
-
- /** Callback to fire when the user swipes up on the child content */
- onSwipeUp?: () => void;
-
- /** Style for the wrapper View, applied only for the web version. Not used by the native version, as it brakes the layout. */
- style?: StyleProp;
+ onSwipeDown: () => void;
};
export default SwipeableViewProps;
diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js
index 3e2a835e0722..09ca427b8e56 100644
--- a/src/components/TaskHeaderActionButton.js
+++ b/src/components/TaskHeaderActionButton.js
@@ -6,6 +6,7 @@ import compose from '@libs/compose';
import * as ReportUtils from '@libs/ReportUtils';
import reportPropTypes from '@pages/reportPropTypes';
import useThemeStyles from '@styles/useThemeStyles';
+import * as Session from '@userActions/Session';
import * as Task from '@userActions/Task';
import ONYXKEYS from '@src/ONYXKEYS';
import Button from './Button';
@@ -38,7 +39,7 @@ function TaskHeaderActionButton(props) {
isDisabled={!Task.canModifyTask(props.report, props.session.accountID)}
medium
text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsComplete')}
- onPress={() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))}
+ onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report)))}
style={[styles.flex1]}
/>
diff --git a/src/components/TextInput/BaseTextInput/index.js b/src/components/TextInput/BaseTextInput/index.js
index bfd3a19659bb..a28365480c7a 100644
--- a/src/components/TextInput/BaseTextInput/index.js
+++ b/src/components/TextInput/BaseTextInput/index.js
@@ -250,7 +250,7 @@ function BaseTextInput(props) {
return (
<>
@@ -261,7 +261,6 @@ function BaseTextInput(props) {
style={[
props.autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, maxHeight),
!isMultiline && styles.componentHeightLarge,
- ...props.containerStyles,
]}
>
;
/** Whether the image requires an authToken */
- isAuthTokenRequired: PropTypes.bool.isRequired,
+ isAuthTokenRequired: boolean;
/** Width of the thumbnail image */
- imageWidth: PropTypes.number,
+ imageWidth?: number;
/** Height of the thumbnail image */
- imageHeight: PropTypes.number,
+ imageHeight?: number;
/** Should the image be resized on load or just fit container */
- shouldDynamicallyResize: PropTypes.bool,
+ shouldDynamicallyResize?: boolean;
};
-const defaultProps = {
- style: {},
- imageWidth: 200,
- imageHeight: 200,
- shouldDynamicallyResize: true,
+type UpdateImageSizeParams = {
+ width: number;
+ height: number;
+};
+
+type CalculateThumbnailImageSizeResult = {
+ thumbnailWidth?: number;
+ thumbnailHeight?: number;
};
/**
* Compute the thumbnails width and height given original image dimensions.
*
- * @param {Number} width - Width of the original image.
- * @param {Number} height - Height of the original image.
- * @param {Number} windowHeight - Height of the device/browser window.
- * @returns {Object} - Object containing thumbnails width and height.
+ * @param width - Width of the original image.
+ * @param height - Height of the original image.
+ * @param windowHeight - Height of the device/browser window.
+ * @returns - Object containing thumbnails width and height.
*/
-function calculateThumbnailImageSize(width, height, windowHeight) {
+function calculateThumbnailImageSize(width: number, height: number, windowHeight: number): CalculateThumbnailImageSizeResult {
if (!width || !height) {
return {};
}
@@ -69,44 +70,42 @@ function calculateThumbnailImageSize(width, height, windowHeight) {
return {thumbnailWidth: Math.max(40, thumbnailScreenWidth), thumbnailHeight: Math.max(40, thumbnailScreenHeight)};
}
-function ThumbnailImage(props) {
+function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidth = 200, imageHeight = 200, shouldDynamicallyResize = true}: ThumbnailImageProps) {
const styles = useThemeStyles();
const {windowHeight} = useWindowDimensions();
- const initialDimensions = calculateThumbnailImageSize(props.imageWidth, props.imageHeight, windowHeight);
- const [imageWidth, setImageWidth] = useState(initialDimensions.thumbnailWidth);
- const [imageHeight, setImageHeight] = useState(initialDimensions.thumbnailHeight);
+ const initialDimensions = calculateThumbnailImageSize(imageWidth, imageHeight, windowHeight);
+ const [currentImageWidth, setCurrentImageWidth] = useState(initialDimensions.thumbnailWidth);
+ const [currentImageHeight, setCurrentImageHeight] = useState(initialDimensions.thumbnailHeight);
/**
* Update the state with the computed thumbnail sizes.
- *
- * @param {{ width: number, height: number }} Params - width and height of the original image.
+ * @param Params - width and height of the original image.
*/
const updateImageSize = useCallback(
- ({width, height}) => {
+ ({width, height}: UpdateImageSizeParams) => {
const {thumbnailWidth, thumbnailHeight} = calculateThumbnailImageSize(width, height, windowHeight);
- setImageWidth(thumbnailWidth);
- setImageHeight(thumbnailHeight);
+
+ setCurrentImageWidth(thumbnailWidth);
+ setCurrentImageHeight(thumbnailHeight);
},
[windowHeight],
);
- const sizeStyles = props.shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(imageWidth, imageHeight)] : [styles.w100, styles.h100];
+ const sizeStyles = shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(currentImageWidth ?? 0, currentImageHeight)] : [styles.w100, styles.h100];
return (
-
+
);
}
-ThumbnailImage.propTypes = propTypes;
-ThumbnailImage.defaultProps = defaultProps;
ThumbnailImage.displayName = 'ThumbnailImage';
export default React.memo(ThumbnailImage);
diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js
index d4b12b9cf479..4d2de3275e23 100644
--- a/src/components/menuItemPropTypes.js
+++ b/src/components/menuItemPropTypes.js
@@ -148,6 +148,9 @@ const propTypes = {
/** Should render the content in HTML format */
shouldRenderAsHTML: PropTypes.bool,
+ /** Label to be displayed on the right */
+ rightLabel: PropTypes.string,
+
/** Component to be displayed on the right */
rightComponent: PropTypes.node,
diff --git a/src/hooks/useBlockViewportScroll/index.native.ts b/src/hooks/useBlockViewportScroll/index.native.ts
deleted file mode 100644
index 59ee34b1c9f6..000000000000
--- a/src/hooks/useBlockViewportScroll/index.native.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-/**
- * A hook that blocks viewport scroll when the keyboard is visible.
- * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event.
- * This scroll blocking is removed when the keyboard hides.
- * This hook is doing nothing on native platforms.
- *
- * @example
- * useBlockViewportScroll();
- */
-function useBlockViewportScroll() {
- // This hook is doing nothing on native platforms.
- // Check index.ts for web implementation.
-}
-
-export default useBlockViewportScroll;
diff --git a/src/hooks/useBlockViewportScroll/index.ts b/src/hooks/useBlockViewportScroll/index.ts
deleted file mode 100644
index 5766d59f2bdd..000000000000
--- a/src/hooks/useBlockViewportScroll/index.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import {useEffect, useRef} from 'react';
-import Keyboard from '@libs/NativeWebKeyboard';
-
-/**
- * A hook that blocks viewport scroll when the keyboard is visible.
- * It does this by capturing the current scrollY position when the keyboard is shown, then scrolls back to this position smoothly on 'touchend' event.
- * This scroll blocking is removed when the keyboard hides.
- * This hook is doing nothing on native platforms.
- *
- * @example
- * useBlockViewportScroll();
- */
-function useBlockViewportScroll() {
- const optimalScrollY = useRef(0);
- const keyboardShowListenerRef = useRef(() => {});
- const keyboardHideListenerRef = useRef(() => {});
-
- useEffect(() => {
- const handleTouchEnd = () => {
- window.scrollTo({top: optimalScrollY.current, behavior: 'smooth'});
- };
-
- const handleKeybShow = () => {
- optimalScrollY.current = window.scrollY;
- window.addEventListener('touchend', handleTouchEnd);
- };
-
- const handleKeybHide = () => {
- window.removeEventListener('touchend', handleTouchEnd);
- };
-
- keyboardShowListenerRef.current = Keyboard.addListener('keyboardDidShow', handleKeybShow);
- keyboardHideListenerRef.current = Keyboard.addListener('keyboardDidHide', handleKeybHide);
-
- return () => {
- keyboardShowListenerRef.current();
- keyboardHideListenerRef.current();
- window.removeEventListener('touchend', handleTouchEnd);
- };
- }, []);
-}
-
-export default useBlockViewportScroll;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 183f0638fbad..4c6ea25eb2c8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -267,6 +267,7 @@ export default {
tbd: 'TBD',
selectCurrency: 'Select a currency',
card: 'Card',
+ required: 'Required',
},
location: {
useCurrent: 'Use current location',
@@ -873,6 +874,7 @@ export default {
availableSpend: 'Remaining limit',
virtualCardNumber: 'Virtual card number',
physicalCardNumber: 'Physical card number',
+ getPhysicalCard: 'Get physical card',
reportFraud: 'Report virtual card fraud',
reviewTransaction: 'Review transaction',
suspiciousBannerTitle: 'Suspicious transaction',
@@ -903,6 +905,27 @@ export default {
thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.",
},
},
+ getPhysicalCard: {
+ header: 'Get physical card',
+ nameMessage: 'Enter your first and last name, as this will be shown on your card.',
+ legalName: 'Legal name',
+ legalFirstName: 'Legal first name',
+ legalLastName: 'Legal last name',
+ phoneMessage: 'Enter your phone number.',
+ phoneNumber: 'Phone number',
+ address: 'Address',
+ addressMessage: 'Enter your shipping address.',
+ streetAddress: 'Street Address',
+ city: 'City',
+ state: 'State',
+ zipPostcode: 'Zip/Postcode',
+ country: 'Country',
+ confirmMessage: 'Please confirm your details below.',
+ estimatedDeliveryMessage: 'Your physical card will arrive in 2-3 business days.',
+ next: 'Next',
+ getPhysicalCard: 'Get physical card',
+ shipCard: 'Ship card',
+ },
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`,
instant: 'Instant (Debit card)',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 12b0c95579e5..85eab5c3f14d 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -257,6 +257,7 @@ export default {
tbd: 'Por determinar',
selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
+ required: 'Obligatorio',
},
location: {
useCurrent: 'Usar ubicación actual',
@@ -868,6 +869,7 @@ export default {
availableSpend: 'Límite restante',
virtualCardNumber: 'Número de la tarjeta virtual',
physicalCardNumber: 'Número de la tarjeta física',
+ getPhysicalCard: 'Obtener tarjeta física',
reportFraud: 'Reportar fraude con la tarjeta virtual',
reviewTransaction: 'Revisar transacción',
suspiciousBannerTitle: 'Transacción sospechosa',
@@ -899,6 +901,28 @@ export default {
thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.',
},
},
+ // TODO: add translation
+ getPhysicalCard: {
+ header: 'Obtener tarjeta física',
+ nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.',
+ legalName: 'Nombre completo',
+ legalFirstName: 'Nombre legal',
+ legalLastName: 'Apellidos legales',
+ phoneMessage: 'Introduce tu número de teléfono.',
+ phoneNumber: 'Número de teléfono',
+ address: 'Dirección',
+ addressMessage: 'Introduce tu dirección de envío.',
+ streetAddress: 'Calle de dirección',
+ city: 'Ciudad',
+ state: 'Estado',
+ zipPostcode: 'Código postal',
+ country: 'País',
+ confirmMessage: 'Por favor confirma tus datos.',
+ estimatedDeliveryMessage: 'Tu tarjeta física llegará en 2-3 días laborales.',
+ next: 'Siguiente',
+ getPhysicalCard: 'Obtener tarjeta física',
+ shipCard: 'Enviar tarjeta',
+ },
transferAmountPage: {
transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`,
instant: 'Instante',
diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts
index 5a7da7ca08cf..32ebca9afee8 100644
--- a/src/libs/ComposerUtils/index.ts
+++ b/src/libs/ComposerUtils/index.ts
@@ -23,19 +23,4 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo
return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown;
}
-/**
- * Returns the length of the common suffix between two input strings.
- * The common suffix is the number of characters shared by both strings
- * at the end (suffix) until a mismatch is encountered.
- *
- * @returns The length of the common suffix between the strings.
- */
-function getCommonSuffixLength(str1: string, str2: string): number {
- let i = 0;
- while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) {
- i++;
- }
- return i;
-}
-
-export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, getCommonSuffixLength};
+export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys};
diff --git a/src/libs/DomUtils/index.native.ts b/src/libs/DomUtils/index.native.ts
index 8af83968e8d1..0864f1a16ac0 100644
--- a/src/libs/DomUtils/index.native.ts
+++ b/src/libs/DomUtils/index.native.ts
@@ -2,21 +2,6 @@ import GetActiveElement from './types';
const getActiveElement: GetActiveElement = () => null;
-/**
- * Checks if there is a text selection within the currently focused input or textarea element.
- *
- * This function determines whether the currently focused element is an input or textarea,
- * and if so, it checks whether there is a text selection (i.e., whether the start and end
- * of the selection are at different positions). It assumes that only inputs and textareas
- * can have text selections.
- * Works only on web. Throws an error on native.
- *
- * @returns True if there is a text selection within the focused element, false otherwise.
- */
-const isActiveTextSelection = () => {
- throw new Error('Not implemented in React Native. Use only for web.');
-};
-
const requestAnimationFrame = (callback: () => void) => {
if (!callback) {
return;
@@ -27,6 +12,5 @@ const requestAnimationFrame = (callback: () => void) => {
export default {
getActiveElement,
- isActiveTextSelection,
requestAnimationFrame,
};
diff --git a/src/libs/DomUtils/index.ts b/src/libs/DomUtils/index.ts
index 78c2cb37ccc8..6a2eed57fbe6 100644
--- a/src/libs/DomUtils/index.ts
+++ b/src/libs/DomUtils/index.ts
@@ -2,30 +2,7 @@ import GetActiveElement from './types';
const getActiveElement: GetActiveElement = () => document.activeElement;
-/**
- * Checks if there is a text selection within the currently focused input or textarea element.
- *
- * This function determines whether the currently focused element is an input or textarea,
- * and if so, it checks whether there is a text selection (i.e., whether the start and end
- * of the selection are at different positions). It assumes that only inputs and textareas
- * can have text selections.
- * Works only on web. Throws an error on native.
- *
- * @returns True if there is a text selection within the focused element, false otherwise.
- */
-const isActiveTextSelection = (): boolean => {
- const focused = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null;
- if (!focused) {
- return false;
- }
- if (typeof focused.selectionStart === 'number' && typeof focused.selectionEnd === 'number') {
- return focused.selectionStart !== focused.selectionEnd;
- }
- return false;
-};
-
export default {
getActiveElement,
- isActiveTextSelection,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
};
diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts
index 1308faa65d20..22e84921b1ee 100644
--- a/src/libs/EmojiUtils.ts
+++ b/src/libs/EmojiUtils.ts
@@ -13,7 +13,7 @@ import emojisTrie from './EmojiTrie';
type HeaderIndice = {code: string; index: number; icon: React.FC};
type EmojiSpacer = {code: string; spacer: boolean};
type EmojiPickerList = Array;
-type ReplacedEmoji = {text: string; emojis: Emoji[]};
+type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number};
type UserReactions = {
id: string;
skinTones: Record;
@@ -333,8 +333,11 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
if (!emojiData || emojiData.length === 0) {
return {text: newText, emojis};
}
- for (let i = 0; i < emojiData.length; i++) {
- const name = emojiData[i].slice(1, -1);
+
+ let cursorPosition;
+
+ for (const emoji of emojiData) {
+ const name = emoji.slice(1, -1);
let checkEmoji = trie.search(name);
// If the user has selected a language other than English, and the emoji doesn't exist in that language,
// we will check if the emoji exists in English.
@@ -346,35 +349,46 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI
}
}
if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) {
- let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
+ const emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone);
emojis.push({
name,
code: checkEmoji.metaData?.code,
types: checkEmoji.metaData.types,
});
- // If this is the last emoji in the message and it's the end of the message so far,
- // add a space after it so the user can keep typing easily.
- if (i === emojiData.length - 1) {
- emojiReplacement += ' ';
- }
+ // Set the cursor to the end of the last replaced Emoji. Note that we position after
+ // the extra space, if we added one.
+ cursorPosition = newText.indexOf(emoji) + emojiReplacement.length;
+
+ newText = newText.replace(emoji, emojiReplacement);
+ }
+ }
+
+ // cursorPosition, when not undefined, points to the end of the last emoji that was replaced.
+ // In that case we want to append a space at the cursor position, but only if the next character
+ // is not already a space (to avoid double spaces).
+ if (cursorPosition && cursorPosition > 0) {
+ const space = ' ';
- newText = newText.replace(emojiData[i], emojiReplacement);
+ if (newText.charAt(cursorPosition) !== space) {
+ newText = newText.slice(0, cursorPosition) + space + newText.slice(cursorPosition);
}
+ cursorPosition += space.length;
}
- return {text: newText, emojis};
+ return {text: newText, emojis, cursorPosition};
}
/**
* Find all emojis in a text and replace them with their code.
*/
function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji {
- const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang);
+ const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang);
return {
text: convertedText,
emojis: emojis.concat(extractEmojis(text)),
+ cursorPosition,
};
}
diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts
new file mode 100644
index 000000000000..facaf5bfddf4
--- /dev/null
+++ b/src/libs/FormUtils.ts
@@ -0,0 +1,10 @@
+import {OnyxFormKey} from '@src/ONYXKEYS';
+
+type ExcludeDraft = T extends `${string}Draft` ? never : T;
+type OnyxFormKeyWithoutDraft = ExcludeDraft;
+
+function getDraftKey(formID: OnyxFormKeyWithoutDraft): `${OnyxFormKeyWithoutDraft}Draft` {
+ return `${formID}Draft`;
+}
+
+export default {getDraftKey};
diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts
new file mode 100644
index 000000000000..57a9d773cc9d
--- /dev/null
+++ b/src/libs/GetPhysicalCardUtils.ts
@@ -0,0 +1,130 @@
+import CONST from '@src/CONST';
+import ROUTES from '@src/ROUTES';
+import {Login} from '@src/types/onyx';
+import Navigation from './Navigation/Navigation';
+import * as PersonalDetailsUtils from './PersonalDetailsUtils';
+import * as UserUtils from './UserUtils';
+
+type DraftValues = {
+ addressLine1: string;
+ addressLine2: string;
+ city: string;
+ country: string;
+ legalFirstName: string;
+ legalLastName: string;
+ phoneNumber: string;
+ state: string;
+ zipPostCode: string;
+};
+
+type PrivatePersonalDetails = {
+ address: {street: string; city: string; state: string; country: string; zip: string};
+ legalFirstName: string;
+ legalLastName: string;
+ phoneNumber: string;
+};
+
+type LoginList = Record;
+
+/**
+ *
+ * @param domain
+ * @param privatePersonalDetails
+ * @param loginList
+ * @returns
+ */
+function getCurrentRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) {
+ const {
+ address: {street, city, state, country, zip},
+ legalFirstName,
+ legalLastName,
+ phoneNumber,
+ } = privatePersonalDetails;
+
+ if (!legalFirstName && !legalLastName) {
+ return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain);
+ }
+ if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) {
+ return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain);
+ }
+ if (!(street && city && state && country && zip)) {
+ return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain);
+ }
+
+ return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain);
+}
+
+/**
+ *
+ * @param domain
+ * @param privatePersonalDetails
+ * @param loginList
+ * @returns
+ */
+function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) {
+ Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList));
+}
+
+/**
+ *
+ * @param currentRoute
+ * @param domain
+ * @param privatePersonalDetails
+ * @param loginList
+ * @returns
+ */
+function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) {
+ const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList);
+
+ // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step
+ if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) {
+ return;
+ }
+
+ // Redirect the user if he's not allowed to be on the current step
+ Navigation.navigate(expectedRoute, CONST.NAVIGATION.ACTION_TYPE.REPLACE);
+}
+
+/**
+ *
+ * @param draftValues
+ * @param privatePersonalDetails
+ * @returns
+ */
+function getUpdatedDraftValues(draftValues: DraftValues, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) {
+ const {
+ address: {city, country, state, street = '', zip},
+ legalFirstName,
+ legalLastName,
+ phoneNumber,
+ } = privatePersonalDetails;
+
+ return {
+ legalFirstName: draftValues.legalFirstName || legalFirstName,
+ legalLastName: draftValues.legalLastName || legalLastName,
+ addressLine1: draftValues.addressLine1 || street.split('\n')[0],
+ addressLine2: draftValues.addressLine2 || street.split('\n')[1] || '',
+ city: draftValues.city || city,
+ country: draftValues.country || country,
+ phoneNumber: draftValues.phoneNumber || (phoneNumber ?? UserUtils.getSecondaryPhoneLogin(loginList) ?? ''),
+ state: draftValues.state || state,
+ zipPostCode: draftValues.zipPostCode || zip,
+ };
+}
+
+/**
+ *
+ * @param draftValues
+ * @returns
+ */
+function getUpdatedPrivatePersonalDetails(draftValues: DraftValues) {
+ const {addressLine1, addressLine2, city, country, legalFirstName, legalLastName, phoneNumber, state, zipPostCode} = draftValues;
+ return {
+ legalFirstName,
+ legalLastName,
+ phoneNumber,
+ address: {street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2), city, country, state, zip: zipPostCode},
+ };
+}
+
+export {getUpdatedDraftValues, getUpdatedPrivatePersonalDetails, goToNextPhysicalCardRoute, setCurrentRoute};
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.ts
similarity index 65%
rename from src/libs/HttpUtils.js
rename to src/libs/HttpUtils.ts
index 2df7421ea91c..859c8624833c 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.ts
@@ -1,13 +1,16 @@
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {ValueOf} from 'type-fest';
import alert from '@components/Alert';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import {RequestType} from '@src/types/onyx/Request';
+import type Response from '@src/types/onyx/Response';
import * as ApiUtils from './ApiUtils';
import HttpsError from './Errors/HttpsError';
let shouldFailAllRequests = false;
let shouldForceOffline = false;
+
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
@@ -25,14 +28,8 @@ let cancellationController = new AbortController();
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
- *
- * @param {String} url
- * @param {String} [method]
- * @param {Object} [body]
- * @param {Boolean} [canCancel]
- * @returns {Promise}
*/
-function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
+function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, canCancel = true): Promise {
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: canCancel ? cancellationController.signal : undefined,
@@ -49,40 +46,41 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (!response.ok) {
// Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred
- const serviceInterruptedStatuses = [
+ const serviceInterruptedStatuses: Array> = [
CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR,
CONST.HTTP_STATUS.BAD_GATEWAY,
CONST.HTTP_STATUS.GATEWAY_TIMEOUT,
CONST.HTTP_STATUS.UNKNOWN_ERROR,
];
- if (_.contains(serviceInterruptedStatuses, response.status)) {
+ if (serviceInterruptedStatuses.indexOf(response.status as ValueOf) > -1) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: response.status,
+ status: response.status.toString(),
title: 'Issue connecting to Expensify site',
});
- } else if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
+ }
+ if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
throw new HttpsError({
message: CONST.ERROR.THROTTLED,
- status: response.status,
+ status: response.status.toString(),
title: 'API request throttled',
});
}
throw new HttpsError({
message: response.statusText,
- status: response.status,
+ status: response.status.toString(),
});
}
- return response.json();
+ return response.json() as Promise;
})
.then((response) => {
// Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists
if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) {
throw new HttpsError({
message: CONST.ERROR.DUPLICATE_RECORD,
- status: CONST.JSON_CODE.BAD_REQUEST,
+ status: CONST.JSON_CODE.BAD_REQUEST.toString(),
title: CONST.ERROR_TITLE.DUPLICATE_RECORD,
});
}
@@ -91,43 +89,42 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: CONST.JSON_CODE.EXP_ERROR,
+ status: CONST.JSON_CODE.EXP_ERROR.toString(),
title: CONST.ERROR_TITLE.SOCKET,
});
}
if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) {
- const {phpCommandName, authWriteCommands} = response.data;
- // eslint-disable-next-line max-len
- const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
- ', ',
- )}. Check the APIWriteCommands class in Web-Expensify`;
- alert('Too many auth writes', message);
+ if (response.data) {
+ const {phpCommandName, authWriteCommands} = response.data;
+ // eslint-disable-next-line max-len
+ const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
+ ', ',
+ )}. Check the APIWriteCommands class in Web-Expensify`;
+ alert('Too many auth writes', message);
+ }
}
- return response;
+ return response as Promise;
});
}
/**
* Makes XHR request
- * @param {String} command the name of the API command
- * @param {Object} data parameters for the API command
- * @param {String} type HTTP request type (get/post)
- * @param {Boolean} shouldUseSecure should we use the secure server
- * @returns {Promise}
+ * @param command the name of the API command
+ * @param data parameters for the API command
+ * @param type HTTP request type (get/post)
+ * @param shouldUseSecure should we use the secure server
*/
-function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
+function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
const formData = new FormData();
- _.each(data, (val, key) => {
- // Do not send undefined request parameters to our API. They will be processed as strings of 'undefined'.
- if (_.isUndefined(val)) {
+ Object.keys(data).forEach((key) => {
+ if (typeof data[key] === 'undefined') {
return;
}
-
- formData.append(key, val);
+ formData.append(key, data[key] as string | Blob);
});
const url = ApiUtils.getCommandURL({shouldUseSecure, command});
- return processHTTPRequest(url, type, formData, data.canCancel);
+ return processHTTPRequest(url, type, formData, Boolean(data.canCancel));
}
function cancelPendingRequests() {
diff --git a/src/libs/NativeWebKeyboard/index.native.ts b/src/libs/NativeWebKeyboard/index.native.ts
deleted file mode 100644
index 404bd58075d4..000000000000
--- a/src/libs/NativeWebKeyboard/index.native.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-import {Keyboard} from 'react-native';
-
-export default Keyboard;
diff --git a/src/libs/NativeWebKeyboard/index.ts b/src/libs/NativeWebKeyboard/index.ts
deleted file mode 100644
index 45223d4d5b42..000000000000
--- a/src/libs/NativeWebKeyboard/index.ts
+++ /dev/null
@@ -1,136 +0,0 @@
-import {Keyboard} from 'react-native';
-import CONST from '@src/CONST';
-
-type InputType = (typeof CONST.INPUT_TYPES_WITH_KEYBOARD)[number];
-type TCallbackFn = () => void;
-
-const isInputKeyboardType = (element: Element | null): boolean => {
- if (element && ((element.tagName === 'INPUT' && CONST.INPUT_TYPES_WITH_KEYBOARD.includes((element as HTMLInputElement).type as InputType)) || element.tagName === 'TEXTAREA')) {
- return true;
- }
- return false;
-};
-
-const isVisible = (): boolean => {
- const focused = document.activeElement;
- return isInputKeyboardType(focused);
-};
-
-const nullFn: () => null = () => null;
-
-let isKeyboardListenerRunning = false;
-let currentVisibleElement: Element | null = null;
-const showListeners: TCallbackFn[] = [];
-const hideListeners: TCallbackFn[] = [];
-const visualViewport = window.visualViewport ?? {
- height: window.innerHeight,
- width: window.innerWidth,
- addEventListener: window.addEventListener.bind(window),
- removeEventListener: window.removeEventListener.bind(window),
-};
-let previousVPHeight = visualViewport.height;
-
-const handleViewportResize = (): void => {
- if (visualViewport.height < previousVPHeight) {
- if (isInputKeyboardType(document.activeElement) && document.activeElement !== currentVisibleElement) {
- showListeners.forEach((fn) => fn());
- }
- }
-
- if (visualViewport.height > previousVPHeight) {
- if (!isVisible()) {
- hideListeners.forEach((fn) => fn());
- }
- }
-
- previousVPHeight = visualViewport.height;
- currentVisibleElement = document.activeElement;
-};
-
-const startKeboardListeningService = (): void => {
- isKeyboardListenerRunning = true;
- visualViewport.addEventListener('resize', handleViewportResize);
-};
-
-const addListener = (eventName: 'keyboardDidShow' | 'keyboardDidHide', callbackFn: TCallbackFn): (() => void) => {
- if ((eventName !== 'keyboardDidShow' && eventName !== 'keyboardDidHide') || !callbackFn) {
- throw new Error('Invalid eventName passed to addListener()');
- }
-
- if (eventName === 'keyboardDidShow') {
- showListeners.push(callbackFn);
- }
-
- if (eventName === 'keyboardDidHide') {
- hideListeners.push(callbackFn);
- }
-
- if (!isKeyboardListenerRunning) {
- startKeboardListeningService();
- }
-
- return () => {
- if (eventName === 'keyboardDidShow') {
- showListeners.filter((fn) => fn !== callbackFn);
- }
-
- if (eventName === 'keyboardDidHide') {
- hideListeners.filter((fn) => fn !== callbackFn);
- }
-
- if (isKeyboardListenerRunning && !showListeners.length && !hideListeners.length) {
- visualViewport.removeEventListener('resize', handleViewportResize);
- isKeyboardListenerRunning = false;
- }
- };
-};
-
-export default {
- /**
- * Whether the keyboard is last known to be visible.
- */
- isVisible,
- /**
- * Dismisses the active keyboard and removes focus.
- */
- dismiss: Keyboard.dismiss,
- /**
- * The `addListener` function connects a JavaScript function to an identified native
- * keyboard notification event.
- *
- * This function then returns the reference to the listener.
- *
- * {string} eventName The `nativeEvent` is the string that identifies the event you're listening for. This
- * can be any of the following:
- *
- * - `keyboardWillShow`
- * - `keyboardDidShow`
- * - `keyboardWillHide`
- * - `keyboardDidHide`
- * - `keyboardWillChangeFrame`
- * - `keyboardDidChangeFrame`
- *
- * Note that if you set `android:windowSoftInputMode` to `adjustResize` or `adjustNothing`,
- * only `keyboardDidShow` and `keyboardDidHide` events will be available on Android.
- * `keyboardWillShow` as well as `keyboardWillHide` are generally not available on Android
- * since there is no native corresponding event.
- *
- * On Web only two events are available:
- *
- * - `keyboardDidShow`
- * - `keyboardDidHide`
- *
- * {function} callback function to be called when the event fires.
- */
- addListener,
- /**
- * Useful for syncing TextInput (or other keyboard accessory view) size of
- * position changes with keyboard movements.
- * Not working on web.
- */
- scheduleLayoutAnimation: nullFn,
- /**
- * Return the metrics of the soft-keyboard if visible. Currently not working on web.
- */
- metrics: nullFn,
-};
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index a2f9bdd7a903..01573cb434b4 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -159,9 +159,13 @@ const SettingsModalStackNavigator = createModalStackNavigator({
Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default,
Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default,
Settings_Wallet_Cards_Digital_Details_Update_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default,
- Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default,
+ Settings_Wallet_DomainCard: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default,
Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default,
Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default,
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardName').default,
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardPhone').default,
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardAddress').default,
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default,
Settings_Wallet_Transfer_Balance: () => require('../../../pages/settings/Wallet/TransferBalancePage').default,
Settings_Wallet_Choose_Transfer_Account: () => require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default,
Settings_Wallet_EnablePayments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default,
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 7a2c61ea7b53..2629d36999bf 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -111,7 +111,6 @@ function navigate(route = ROUTES.HOME, type) {
pendingRoute = route;
return;
}
-
linkTo(navigationRef.current, route, type);
}
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js
index 286074914cf7..55bd4b31dbdf 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.js
@@ -83,6 +83,17 @@ export default function linkTo(navigation, path, type) {
if (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
+ // But we want to push the same route instead of going back in the stack
+ // Which would break the user navigation history
+ if (type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
+ minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
+ }
+ // There are situations when the user is trying to access a route which he has no access to
+ // So we want to redirect him to the right one and replace the one he tried to access
+ if (type === CONST.NAVIGATION.ACTION_TYPE.REPLACE) {
+ minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE;
+ }
root.dispatch(minimalAction);
return;
}
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 44473998ac62..e0ac35c957a3 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -73,7 +73,7 @@ export default {
path: ROUTES.SETTINGS_WALLET,
exact: true,
},
- Settings_Wallet_DomainCards: {
+ Settings_Wallet_DomainCard: {
path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route,
exact: true,
},
@@ -81,6 +81,22 @@ export default {
path: ROUTES.SETTINGS_REPORT_FRAUD.route,
exact: true,
},
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route,
+ exact: true,
+ },
+ [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: {
+ path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route,
+ exact: true,
+ },
Settings_Wallet_EnablePayments: {
path: ROUTES.SETTINGS_ENABLE_PAYMENTS,
exact: true,
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index d4aee4a221e5..5da032baaf45 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -160,7 +160,7 @@ NetworkStore.onReconnection(flush);
function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save([request]);
+ PersistedRequests.save(request);
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts
index 6ff54f94bc88..3fadeea7447c 100644
--- a/src/libs/Network/enhanceParameters.ts
+++ b/src/libs/Network/enhanceParameters.ts
@@ -37,5 +37,8 @@ export default function enhanceParameters(command: string, parameters: Record}
*/
function getTagsOptions(tags) {
- return _.map(tags, (tag) => ({
- text: tag.name,
- keyForList: tag.name,
- searchText: tag.name,
- tooltipText: tag.name,
- isDisabled: !tag.enabled,
- }));
+ return _.map(tags, (tag) => {
+ // This is to remove unnecessary escaping backslash in tag name sent from backend.
+ const tagName = tag.name && tag.name.replace(/\\{1,2}:/g, ':');
+
+ return {
+ text: tagName,
+ keyForList: tagName,
+ searchText: tagName,
+ tooltipText: tagName,
+ isDisabled: !tag.enabled,
+ };
+ });
}
/**
diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js
index c99adc32a56a..560480dcec9d 100644
--- a/src/libs/PersonalDetailsUtils.js
+++ b/src/libs/PersonalDetailsUtils.js
@@ -162,6 +162,26 @@ function formatPiece(piece) {
return piece ? `${piece}, ` : '';
}
+/**
+ *
+ * @param {String} street1 - street line 1
+ * @param {String} street2 - street line 2
+ * @returns {String} formatted street
+ */
+function getFormattedStreet(street1 = '', street2 = '') {
+ return `${street1}\n${street2}`;
+}
+
+/**
+ *
+ * @param {*} street - formatted address
+ * @returns {[string, string]} [street1, street2]
+ */
+function getStreetLines(street = '') {
+ const streets = street.split('\n');
+ return [streets[0], streets[1]];
+}
+
/**
* Formats an address object into an easily readable string
*
@@ -170,11 +190,20 @@ function formatPiece(piece) {
*/
function getFormattedAddress(privatePersonalDetails) {
const {address} = privatePersonalDetails;
- const [street1, street2] = (address.street || '').split('\n');
+ const [street1, street2] = getStreetLines(address.street);
const formattedAddress = formatPiece(street1) + formatPiece(street2) + formatPiece(address.city) + formatPiece(address.state) + formatPiece(address.zip) + formatPiece(address.country);
// Remove the last comma of the address
return formattedAddress.trim().replace(/,$/, '');
}
-export {getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData, getFormattedAddress};
+export {
+ getDisplayNameOrDefault,
+ getPersonalDetailsByIDs,
+ getAccountIDsByLogins,
+ getLoginsByAccountIDs,
+ getNewPersonalDetailsOnyxData,
+ getFormattedAddress,
+ getFormattedStreet,
+ getStreetLines,
+};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index 4615cac245ea..4af2d0c8a3c2 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -415,9 +415,12 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions =
};
}
- const messageText = message?.text ?? '';
+ let messageText = message?.text ?? '';
+ if (messageText) {
+ messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
+ }
return {
- lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
+ lastMessageText: messageText,
};
}
@@ -469,7 +472,7 @@ function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEnt
* 4. We will get the second last action from filtered actions because the last
* action is always the created action
*/
-function getFirstVisibleReportActionID(sortedReportActions: ReportAction[], isOffline: boolean): string {
+function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], isOffline = false): string {
if (!Array.isArray(sortedReportActions)) {
return '';
}
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index 673cb09232de..2d0f7fcce106 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -839,8 +839,15 @@ function isOneOnOneChat(report) {
* @returns {Object}
*/
function getReport(reportID) {
- // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check
- return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {};
+ /**
+ * Using typical string concatenation here due to performance issues
+ * with template literals.
+ */
+ if (!allReports) {
+ return {};
+ }
+
+ return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {};
}
/**
@@ -1561,14 +1568,25 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
* @returns {String}
*/
function getPolicyExpenseChatName(report, policy = undefined) {
- const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName;
+ const ownerAccountID = report.ownerAccountID;
+ const personalDetails = allPersonalDetails[ownerAccountID];
+ const login = personalDetails ? personalDetails.login : null;
+ const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName;
// If the policy expense chat is owned by this user, use the name of the policy as the report name.
if (report.isOwnPolicyExpenseChat) {
return getPolicyName(report, false, policy);
}
- const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user';
+ let policyExpenseChatRole = 'user';
+ /**
+ * Using typical string concatenation here due to performance issues
+ * with template literals.
+ */
+ const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID];
+ if (policyItem) {
+ policyExpenseChatRole = policyItem.role || 'user';
+ }
// If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat
// of the account which was merged into the current user's account. Use the name of the policy as the name of the report.
@@ -2941,12 +2959,13 @@ function buildOptimisticChatReport(
welcomeMessage = '',
) {
const currentTime = DateUtils.getDBTime();
+ const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat;
return {
type: CONST.REPORT.TYPE.CHAT,
chatType,
hasOutstandingIOU: false,
isOwnPolicyExpenseChat,
- isPinned: reportName === CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS,
+ isPinned: reportName === CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS || isNewlyCreatedWorkspaceChat,
lastActorAccountID: 0,
lastMessageTranslationKey: '',
lastMessageHtml: '',
@@ -3965,7 +3984,7 @@ function getWorkspaceChats(policyID, accountIDs) {
* @returns {Boolean}
*/
function shouldDisableRename(report, policy) {
- if (isDefaultRoom(report) || isArchivedRoom(report) || isChatThread(report) || isMoneyRequestReport(report) || isPolicyExpenseChat(report)) {
+ if (isDefaultRoom(report) || isArchivedRoom(report) || isThread(report) || isMoneyRequestReport(report) || isPolicyExpenseChat(report)) {
return true;
}
@@ -3980,6 +3999,15 @@ function shouldDisableRename(report, policy) {
return !_.keys(loginList).includes(policy.owner) && policy.role !== CONST.POLICY.ROLE.ADMIN;
}
+/**
+ * @param {Object|null} report
+ * @param {Object|null} policy - the workspace the report is on, null if the user isn't a member of the workspace
+ * @returns {Boolean}
+ */
+function canEditWriteCapability(report, policy) {
+ return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report);
+}
+
/**
* Returns the onyx data needed for the task assignee chat
* @param {Number} accountID
@@ -4437,4 +4465,5 @@ export {
getChannelLogMemberMessage,
getRoom,
shouldDisableWelcomeMessage,
+ canEditWriteCapability,
};
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 335731763ec9..18fadca467ad 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -16,7 +16,7 @@ function makeXHR(request: Request): Promise {
return new Promise((resolve) => resolve());
}
- return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise;
+ return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
});
}
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 58c4a124335d..763a0000ba35 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -158,18 +158,6 @@ function getOrderedReportIDs(
}
}
- // There are a few properties that need to be calculated for the report which are used when sorting reports.
- reportsToDisplay.forEach((report) => {
- // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
- // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
- // the reportDisplayName property to the report object directly.
- // eslint-disable-next-line no-param-reassign
- report.displayName = ReportUtils.getReportName(report);
-
- // eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
- });
-
// The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
// 1. Pinned/GBR - Always sorted by reportDisplayName
// 2. Drafts - Always sorted by reportDisplayName
@@ -183,7 +171,18 @@ function getOrderedReportIDs(
const draftReports: Report[] = [];
const nonArchivedReports: Report[] = [];
const archivedReports: Report[] = [];
+
+ // There are a few properties that need to be calculated for the report which are used when sorting reports.
reportsToDisplay.forEach((report) => {
+ // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
+ // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
+ // the reportDisplayName property to the report object directly.
+ // eslint-disable-next-line no-param-reassign
+ report.displayName = ReportUtils.getReportName(report);
+
+ // eslint-disable-next-line no-param-reassign
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
+
const isPinned = report.isPinned ?? false;
if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) {
pinnedAndGBRReports.push(report);
diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js
index 9af74f8313c3..bfa0cd911177 100644
--- a/src/libs/UnreadIndicatorUpdater/index.js
+++ b/src/libs/UnreadIndicatorUpdater/index.js
@@ -1,3 +1,4 @@
+import {InteractionManager} from 'react-native';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import * as ReportUtils from '@libs/ReportUtils';
@@ -5,11 +6,33 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import updateUnread from './updateUnread/index';
+let previousUnreadCount = 0;
+
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (reportsFromOnyx) => {
- const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
- updateUnread(_.size(unreadReports));
+ if (!reportsFromOnyx) {
+ return;
+ }
+
+ /**
+ * We need to wait until after interactions have finished to update the unread count because otherwise
+ * the unread count will be updated while the interactions/animations are in progress and we don't want
+ * to put more work on the main thread.
+ *
+ * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions
+ * have finished.
+ *
+ * More info: https://reactnative.dev/docs/interactionmanager
+ */
+ InteractionManager.runAfterInteractions(() => {
+ const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+ const unreadReportsCount = _.size(unreadReports);
+ if (previousUnreadCount !== unreadReportsCount) {
+ previousUnreadCount = unreadReportsCount;
+ updateUnread(unreadReportsCount);
+ }
+ });
},
});
diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts
index 1a5ced6c9f85..b6d061432585 100644
--- a/src/libs/UserUtils.ts
+++ b/src/libs/UserUtils.ts
@@ -1,3 +1,4 @@
+import Str from 'expensify-common/lib/str';
import {SvgProps} from 'react-native-svg';
import {ValueOf} from 'type-fest';
import * as defaultAvatars from '@components/Icon/DefaultAvatars';
@@ -190,6 +191,14 @@ function generateAccountID(searchValue: string): number {
return hashText(searchValue, 2 ** 32);
}
+/**
+ * Gets the secondary phone login number
+ */
+function getSecondaryPhoneLogin(loginList: Record): string | undefined {
+ const parsedLoginList = Object.keys(loginList).map((login) => Str.removeSMSDomain(login));
+ return parsedLoginList.find((login) => Str.isValidPhone(login));
+}
+
export {
hashText,
hasLoginListError,
@@ -203,5 +212,6 @@ export {
getSmallSizeAvatar,
getFullSizeAvatar,
generateAccountID,
+ getSecondaryPhoneLogin,
};
export type {AvatarSource};
diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts
index ecaf38dc44f2..29d9ecda9f73 100644
--- a/src/libs/actions/FormActions.ts
+++ b/src/libs/actions/FormActions.ts
@@ -1,5 +1,6 @@
import Onyx from 'react-native-onyx';
import {KeyValueMapping, NullishDeep} from 'react-native-onyx/lib/types';
+import FormUtils from '@libs/FormUtils';
import {OnyxFormKey} from '@src/ONYXKEYS';
import {Form} from '@src/types/onyx';
import * as OnyxCommon from '@src/types/onyx/OnyxCommon';
@@ -19,8 +20,15 @@ function setErrorFields(formID: OnyxFormKey, errorFields: OnyxCommon.ErrorFields
Onyx.merge(formID, {errorFields} satisfies Form);
}
-function setDraftValues(formID: T, draftValues: NullishDeep) {
- Onyx.merge(`${formID}Draft`, draftValues);
+function setDraftValues(formID: OnyxFormKeyWithoutDraft, draftValues: NullishDeep) {
+ Onyx.merge(FormUtils.getDraftKey(formID), draftValues);
}
-export {setDraftValues, setErrorFields, setErrors, setIsLoading};
+/**
+ * @param formID
+ */
+function clearDraftValues(formID: OnyxFormKeyWithoutDraft) {
+ Onyx.merge(FormUtils.getDraftKey(formID), undefined);
+}
+
+export {setDraftValues, setErrorFields, setErrors, setIsLoading, clearDraftValues};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 67c2a51015a7..939a11dad511 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -687,7 +687,7 @@ function createDistanceRequest(report, participant, comment, created, transactio
* @param {Object} [transactionChanges.waypoints]
*
*/
-function updateDistanceRequest(transactionID, transactionThreadReportID, transactionChanges) {
+function editDistanceMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) {
const optimisticData = [];
const successData = [];
const failureData = [];
@@ -794,10 +794,10 @@ function updateDistanceRequest(transactionID, transactionThreadReportID, transac
});
if (_.has(transactionChanges, 'waypoints')) {
- // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors
+ // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors
successData.push({
onyxMethod: Onyx.METHOD.SET,
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`,
value: null,
});
}
@@ -1771,7 +1771,7 @@ function setDraftSplitTransaction(transactionID, transactionChanges = {}) {
* @param {Number} transactionThreadReportID
* @param {Object} transactionChanges
*/
-function editMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) {
+function editRegularMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) {
// STEP 1: Get all collections we're updating
const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`];
const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
@@ -1985,6 +1985,19 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
);
}
+/**
+ * @param {object} transaction
+ * @param {Number} transactionThreadReportID
+ * @param {Object} transactionChanges
+ */
+function editMoneyRequest(transaction, transactionThreadReportID, transactionChanges) {
+ if (TransactionUtils.isDistanceRequest(transaction)) {
+ editDistanceMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges);
+ } else {
+ editRegularMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges);
+ }
+}
+
/**
* @param {String} transactionID
* @param {Object} reportAction - the money request reportAction we are deleting
@@ -2432,7 +2445,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType
function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) {
const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction(
CONST.IOU.REPORT_ACTION_TYPE.PAY,
- iouReport.total,
+ -iouReport.total,
iouReport.currency,
'',
[recipient],
@@ -2962,7 +2975,6 @@ function getIOUReportID(iou, route) {
export {
createDistanceRequest,
- editMoneyRequest,
deleteMoneyRequest,
splitBill,
splitBillAndOpenReport,
@@ -2992,8 +3004,8 @@ export {
setMoneyRequestReceipt,
setUpDistanceTransaction,
navigateToNextPage,
- updateDistanceRequest,
replaceReceipt,
detachReceipt,
getIOUReportID,
+ editMoneyRequest,
};
diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts
index 39016b241585..e1e73d425281 100644
--- a/src/libs/actions/Modal.ts
+++ b/src/libs/actions/Modal.ts
@@ -1,30 +1,38 @@
import Onyx from 'react-native-onyx';
import ONYXKEYS from '@src/ONYXKEYS';
-let closeModal: ((isNavigating: boolean) => void) | null;
+const closeModals: Array<(isNavigating: boolean) => void> = [];
+
let onModalClose: null | (() => void);
/**
* Allows other parts of the app to call modal close function
*/
-function setCloseModal(onClose: (() => void) | null) {
- closeModal = onClose;
+function setCloseModal(onClose: () => void) {
+ if (!closeModals.includes(onClose)) {
+ closeModals.push(onClose);
+ }
+ return () => {
+ const index = closeModals.indexOf(onClose);
+ if (index === -1) {
+ return;
+ }
+ closeModals.splice(index, 1);
+ };
}
/**
* Close modal in other parts of the app
*/
function close(onModalCloseCallback: () => void, isNavigating = true) {
- if (!closeModal) {
- // If modal is already closed, no need to wait for modal close. So immediately call callback.
- if (onModalCloseCallback) {
- onModalCloseCallback();
- }
- onModalClose = null;
+ if (closeModals.length === 0) {
+ onModalCloseCallback();
return;
}
onModalClose = onModalCloseCallback;
- closeModal(isNavigating);
+ [...closeModals].reverse().forEach((onClose) => {
+ onClose(isNavigating);
+ });
}
function onModalDidClose() {
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index c35de9ee94c4..c788d69de70e 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -17,15 +17,17 @@ function clear() {
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, []);
}
-function save(requestsToPersist: Request[]) {
- let requests: Request[] = [];
- if (persistedRequests.length) {
- requests = persistedRequests.concat(requestsToPersist);
+function save(requestToPersist: Request) {
+ // Check for a request w/ matching idempotencyKey in the queue
+ const existingRequestIndex = persistedRequests.findIndex((request) => request.data?.idempotencyKey && request.data?.idempotencyKey === requestToPersist.data?.idempotencyKey);
+ if (existingRequestIndex > -1) {
+ // Merge the new request into the existing one, keeping its place in the queue
+ persistedRequests.splice(existingRequestIndex, 1, requestToPersist);
} else {
- requests = requestsToPersist;
+ // If not, push the new request to the end of the queue
+ persistedRequests.push(requestToPersist);
}
- persistedRequests = requests;
- Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
+ Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
}
function remove(requestToRemove: Request) {
diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts
index 2d51fbb9e8d2..e26cee71dc67 100644
--- a/src/libs/actions/PersonalDetails.ts
+++ b/src/libs/actions/PersonalDetails.ts
@@ -1,9 +1,10 @@
import Str from 'expensify-common/lib/str';
import Onyx, {OnyxEntry, OnyxUpdate} from 'react-native-onyx';
import * as API from '@libs/API';
-import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types';
+import {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types';
import * as LocalePhoneNumber from '@libs/LocalePhoneNumber';
import Navigation from '@libs/Navigation/Navigation';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as UserUtils from '@libs/UserUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -267,7 +268,7 @@ function updateAddress(street: string, street2: string, city: string, state: str
key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
value: {
address: {
- street: `${street}\n${street2}`,
+ street: PersonalDetailsUtils.getFormattedStreet(street, street2),
city,
state,
zip,
@@ -444,7 +445,7 @@ function openPublicProfilePage(accountID: number) {
/**
* Updates the user's avatar image
*/
-function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) {
+function updateAvatar(file: File | CustomRNImageManipulatorResult) {
if (!currentUserAccountID) {
return;
}
@@ -500,7 +501,7 @@ function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) {
];
type UpdateUserAvatarParams = {
- file: FileWithUri | CustomRNImageManipulatorResult;
+ file: File | CustomRNImageManipulatorResult;
};
const parameters: UpdateUserAvatarParams = {file};
diff --git a/src/libs/actions/PushNotification.js b/src/libs/actions/PushNotification.ts
similarity index 84%
rename from src/libs/actions/PushNotification.js
rename to src/libs/actions/PushNotification.ts
index 7abbd7b94ba0..888892fdc188 100644
--- a/src/libs/actions/PushNotification.js
+++ b/src/libs/actions/PushNotification.ts
@@ -6,15 +6,18 @@ import * as Device from './Device';
let isUserOptedInToPushNotifications = false;
Onyx.connect({
key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED,
- callback: (val) => (isUserOptedInToPushNotifications = val),
+ callback: (value) => {
+ if (value === null) {
+ return;
+ }
+ isUserOptedInToPushNotifications = value;
+ },
});
/**
* Record that user opted-in or opted-out of push notifications on the current device.
- *
- * @param {Boolean} isOptingIn
*/
-function setPushNotificationOptInStatus(isOptingIn) {
+function setPushNotificationOptInStatus(isOptingIn: boolean) {
Device.getDeviceID().then((deviceID) => {
const commandName = isOptingIn ? 'OptInToPushNotifications' : 'OptOutOfPushNotifications';
const optimisticData = [
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index c225bdf5b65d..ac45a1e3f3be 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -470,6 +470,9 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (!reportID) {
return;
}
+
+ const commandName = 'OpenReport';
+
const optimisticReportData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -535,6 +538,7 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
emailList: participantLoginList ? participantLoginList.join(',') : '',
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
parentReportActionID,
+ idempotencyKey: `${commandName}_${reportID}`,
};
if (isFromDeepLink) {
@@ -612,6 +616,7 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
// Add the createdReportActionID parameter to the API call
params.createdReportActionID = optimisticCreatedAction.reportActionID;
+ params.idempotencyKey = `${params.idempotencyKey}_NewReport_${optimisticCreatedAction.reportActionID}`;
// If we are creating a thread, ensure the report action has childReportID property added
if (newReportObject.parentReportID && parentReportActionID) {
@@ -632,12 +637,12 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (isFromDeepLink) {
// eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects('OpenReport', params, onyxData).finally(() => {
+ API.makeRequestWithSideEffects(commandName, params, onyxData).finally(() => {
Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false);
});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.write('OpenReport', params, onyxData);
+ API.write(commandName, params, onyxData);
}
}
@@ -1542,14 +1547,11 @@ function navigateToConciergeChat(ignoreConciergeReportID = false) {
* @param {String} policyID
* @param {String} reportName
* @param {String} visibility
- * @param {Array} policyMembersAccountIDs
* @param {String} writeCapability
* @param {String} welcomeMessage
*/
-function addPolicyReport(policyID, reportName, visibility, policyMembersAccountIDs, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL, welcomeMessage = '') {
- // The participants include the current user (admin), and for restricted rooms, the policy members. Participants must not be empty.
- const members = visibility === CONST.REPORT.VISIBILITY.RESTRICTED ? policyMembersAccountIDs : [];
- const participants = _.unique([currentUserAccountID, ...members]);
+function addPolicyReport(policyID, reportName, visibility, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL, welcomeMessage = '') {
+ const participants = [currentUserAccountID];
const parsedWelcomeMessage = ReportUtils.getParsedComment(welcomeMessage);
const policyReport = ReportUtils.buildOptimisticChatReport(
participants,
diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts
index d7ff96fc6c2e..49d2432277a0 100644
--- a/src/libs/actions/ReportActions.ts
+++ b/src/libs/actions/ReportActions.ts
@@ -27,7 +27,7 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction) {
// Delete the failed task report too
const taskReportID = reportAction.message?.[0]?.taskReportID;
- if (taskReportID) {
+ if (taskReportID && ReportActionUtils.isCreatedTaskReportAction(reportAction)) {
Report.deleteReport(taskReportID);
}
return;
diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js
index 78a271f0f8cd..2cb79ac387bd 100644
--- a/src/libs/actions/TransactionEdit.js
+++ b/src/libs/actions/TransactionEdit.js
@@ -11,7 +11,7 @@ function createBackupTransaction(transaction) {
...transaction,
};
// Use set so that it will always fully overwrite any backup transaction that could have existed before
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction);
}
/**
@@ -19,12 +19,12 @@ function createBackupTransaction(transaction) {
* @param {String} transactionID
*/
function removeBackupTransaction(transactionID) {
- Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null);
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null);
}
function restoreOriginalTransactionFromBackup(transactionID) {
const connectionID = Onyx.connect({
- key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`,
callback: (backupTransaction) => {
Onyx.disconnect(connectionID);
diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js
index bfc2a7306434..fad529c4b1f5 100644
--- a/src/libs/actions/Wallet.js
+++ b/src/libs/actions/Wallet.js
@@ -330,6 +330,45 @@ function answerQuestionsForWallet(answers, idNumber) {
);
}
+function requestPhysicalExpensifyCard(cardID, authToken, privatePersonalDetails) {
+ const {
+ legalFirstName,
+ legalLastName,
+ phoneNumber,
+ address: {city, country, state, street, zip},
+ } = privatePersonalDetails;
+ const params = {
+ authToken,
+ legalFirstName,
+ legalLastName,
+ phoneNumber,
+ addressCity: city,
+ addressCountry: country,
+ addressState: state,
+ addressStreet: street,
+ addressZip: zip,
+ };
+ const onyxData = {
+ optimisticData: [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.CARD_LIST,
+ value: {
+ [cardID]: {
+ state: 4, // NOT_ACTIVATED
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ value: privatePersonalDetails,
+ },
+ ],
+ };
+ API.write('RequestPhysicalExpensifyCard', params, onyxData);
+}
+
export {
openOnfidoFlow,
openInitialSettingsPage,
@@ -343,4 +382,5 @@ export {
verifyIdentity,
acceptWalletTerms,
setKYCWallSource,
+ requestPhysicalExpensifyCard,
};
diff --git a/src/libs/cropOrRotateImage/index.ts b/src/libs/cropOrRotateImage/index.ts
index 6b222c9759b5..a66ddbb40b00 100644
--- a/src/libs/cropOrRotateImage/index.ts
+++ b/src/libs/cropOrRotateImage/index.ts
@@ -1,4 +1,4 @@
-import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions, FileWithUri} from './types';
+import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions} from './types';
type SizeFromAngle = {
width: number;
@@ -71,13 +71,13 @@ function cropCanvas(canvas: HTMLCanvasElement, options: CropOptions) {
return result;
}
-function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise {
+function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise {
return new Promise((resolve) => {
canvas.toBlob((blob) => {
if (!blob) {
return;
}
- const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}) as FileWithUri;
+ const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'});
file.uri = URL.createObjectURL(file);
resolve(file);
});
diff --git a/src/libs/cropOrRotateImage/types.ts b/src/libs/cropOrRotateImage/types.ts
index 09f441bd9324..188d557a1258 100644
--- a/src/libs/cropOrRotateImage/types.ts
+++ b/src/libs/cropOrRotateImage/types.ts
@@ -18,12 +18,8 @@ type Action = {
rotate?: number;
};
-type FileWithUri = File & {
- uri: string;
-};
-
type CustomRNImageManipulatorResult = RNImageManipulatorResult & {size: number; type: string; name: string};
-type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise;
+type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise;
-export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions, CustomRNImageManipulatorResult};
+export type {CropOrRotateImage, CropOptions, Action, CropOrRotateImageOptions, CustomRNImageManipulatorResult};
diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.ts
similarity index 71%
rename from src/libs/fileDownload/FileUtils.js
rename to src/libs/fileDownload/FileUtils.ts
index b838a81ea550..5bac47fb63ec 100644
--- a/src/libs/fileDownload/FileUtils.js
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -2,6 +2,7 @@ import {Alert, Linking, Platform} from 'react-native';
import DateUtils from '@libs/DateUtils';
import * as Localize from '@libs/Localize';
import CONST from '@src/CONST';
+import type {ReadFileAsync, SplitExtensionFromFileName} from './types';
/**
* Show alert on successful attachment download
@@ -43,7 +44,9 @@ function showPermissionErrorAlert() {
},
{
text: Localize.translateLocal('common.settings'),
- onPress: () => Linking.openSettings(),
+ onPress: () => {
+ Linking.openSettings();
+ },
},
]);
}
@@ -62,7 +65,9 @@ function showCameraPermissionsAlert() {
},
{
text: Localize.translateLocal('common.settings'),
- onPress: () => Linking.openSettings(),
+ onPress: () => {
+ Linking.openSettings();
+ },
},
],
{cancelable: false},
@@ -71,42 +76,36 @@ function showCameraPermissionsAlert() {
/**
* Generate a random file name with timestamp and file extension
- * @param {String} url
- * @returns {String}
*/
-function getAttachmentName(url) {
+function getAttachmentName(url: string): string {
if (!url) {
return '';
}
- return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop().trim()}`;
+ return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop()?.trim()}`;
}
-/**
- * @param {String} fileName
- * @returns {Boolean}
- */
-function isImage(fileName) {
+function isImage(fileName: string): boolean {
return CONST.FILE_TYPE_REGEX.IMAGE.test(fileName);
}
-/**
- * @param {String} fileName
- * @returns {Boolean}
- */
-function isVideo(fileName) {
+function isVideo(fileName: string): boolean {
return CONST.FILE_TYPE_REGEX.VIDEO.test(fileName);
}
/**
* Returns file type based on the uri
- * @param {String} fileUrl
- * @returns {String}
*/
-function getFileType(fileUrl) {
+function getFileType(fileUrl: string): string | undefined {
if (!fileUrl) {
return;
}
- const fileName = fileUrl.split('/').pop().split('?')[0].split('#')[0];
+
+ const fileName = fileUrl.split('/').pop()?.split('?')[0].split('#')[0];
+
+ if (!fileName) {
+ return;
+ }
+
if (isImage(fileName)) {
return CONST.ATTACHMENT_FILE_TYPE.IMAGE;
}
@@ -118,32 +117,22 @@ function getFileType(fileUrl) {
/**
* Returns the filename split into fileName and fileExtension
- *
- * @param {String} fullFileName
- * @returns {Object}
*/
-function splitExtensionFromFileName(fullFileName) {
+const splitExtensionFromFileName: SplitExtensionFromFileName = (fullFileName) => {
const fileName = fullFileName.trim();
const splitFileName = fileName.split('.');
const fileExtension = splitFileName.length > 1 ? splitFileName.pop() : '';
- return {fileName: splitFileName.join('.'), fileExtension};
-}
+ return {fileName: splitFileName.join('.'), fileExtension: fileExtension ?? ''};
+};
/**
* Returns the filename replacing special characters with underscore
- *
- * @param {String} fileName
- * @returns {String}
*/
-function cleanFileName(fileName) {
+function cleanFileName(fileName: string): string {
return fileName.replace(/[^a-zA-Z0-9\-._]/g, '_');
}
-/**
- * @param {String} fileName
- * @returns {String}
- */
-function appendTimeToFileName(fileName) {
+function appendTimeToFileName(fileName: string): string {
const file = splitExtensionFromFileName(fileName);
let newFileName = `${file.fileName}-${DateUtils.getDBTime()}`;
// Replace illegal characters before trying to download the attachment.
@@ -156,21 +145,17 @@ function appendTimeToFileName(fileName) {
/**
* Reads a locally uploaded file
- *
- * @param {String} path - the blob url of the locally uplodaded file
- * @param {String} fileName
- * @param {Function} onSuccess
- * @param {Function} onFailure
- *
- * @returns {Promise}
+ * @param path - the blob url of the locally uploaded file
+ * @param fileName - name of the file to read
*/
-const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) =>
+const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = () => {}) =>
new Promise((resolve) => {
if (!path) {
resolve();
+ onFailure('[FileUtils] Path not specified');
+ return;
}
-
- return fetch(path)
+ fetch(path)
.then((res) => {
// For some reason, fetch is "Unable to read uploaded file"
// on Android even though the blob is returned, so we'll ignore
@@ -178,19 +163,26 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) =>
if (!res.ok && Platform.OS !== 'android') {
throw Error(res.statusText);
}
- return res.blob();
- })
- .then((blob) => {
- const file = new File([blob], cleanFileName(fileName), {type: blob.type});
- file.source = path;
- // For some reason, the File object on iOS does not have a uri property
- // so images aren't uploaded correctly to the backend
- file.uri = path;
- onSuccess(file);
+ res.blob()
+ .then((blob) => {
+ const file = new File([blob], cleanFileName(fileName));
+ file.source = path;
+ // For some reason, the File object on iOS does not have a uri property
+ // so images aren't uploaded correctly to the backend
+ file.uri = path;
+ onSuccess(file);
+ resolve(file);
+ })
+ .catch((e) => {
+ console.debug('[FileUtils] Could not read uploaded file', e);
+ onFailure(e);
+ resolve();
+ });
})
.catch((e) => {
console.debug('[FileUtils] Could not read uploaded file', e);
onFailure(e);
+ resolve();
});
});
@@ -198,16 +190,16 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) =>
* Converts a base64 encoded image string to a File instance.
* Adds a `uri` property to the File instance for accessing the blob as a URI.
*
- * @param {string} base64 - The base64 encoded image string.
- * @param {string} filename - Desired filename for the File instance.
- * @returns {File} The File instance created from the base64 string with an additional `uri` property.
+ * @param base64 - The base64 encoded image string.
+ * @param filename - Desired filename for the File instance.
+ * @returns The File instance created from the base64 string with an additional `uri` property.
*
* @example
* const base64Image = "data:image/png;base64,..."; // your base64 encoded image
* const imageFile = base64ToFile(base64Image, "example.png");
* console.log(imageFile.uri); // Blob URI
*/
-function base64ToFile(base64, filename) {
+function base64ToFile(base64: string, filename: string): File {
// Decode the base64 string
const byteString = atob(base64.split(',')[1]);
diff --git a/src/libs/fileDownload/getAttachmentDetails.js b/src/libs/fileDownload/getAttachmentDetails.ts
similarity index 81%
rename from src/libs/fileDownload/getAttachmentDetails.js
rename to src/libs/fileDownload/getAttachmentDetails.ts
index 28b678ffb651..5787979a3795 100644
--- a/src/libs/fileDownload/getAttachmentDetails.js
+++ b/src/libs/fileDownload/getAttachmentDetails.ts
@@ -1,12 +1,11 @@
import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
import CONST from '@src/CONST';
+import type {GetAttachmentDetails} from './types';
/**
* Extract the thumbnail URL, source URL and the original filename from the HTML.
- * @param {String} html
- * @returns {Object}
*/
-export default function getAttachmentDetails(html) {
+const getAttachmentDetails: GetAttachmentDetails = (html) => {
// Files can be rendered either as anchor tag or as an image so based on that we have to form regex.
const IS_IMAGE_TAG = /
/i.test(html);
const PREVIEW_SOURCE_REGEX = new RegExp(`${CONST.ATTACHMENT_PREVIEW_ATTRIBUTE}*=*"(.+?)"`, 'i');
@@ -21,10 +20,10 @@ export default function getAttachmentDetails(html) {
}
// Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified
- const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)[1]);
- const imageURL = IS_IMAGE_TAG && tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)[1]);
+ const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)?.[1] ?? '');
+ const imageURL = IS_IMAGE_TAG ? tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)?.[1] ?? '') : null;
const previewSourceURL = IS_IMAGE_TAG ? imageURL : sourceURL;
- const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)[1];
+ const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)?.[1] ?? null;
// Update the image URL so the images can be accessed depending on the config environment
return {
@@ -32,4 +31,6 @@ export default function getAttachmentDetails(html) {
sourceURL,
originalFileName,
};
-}
+};
+
+export default getAttachmentDetails;
diff --git a/src/libs/fileDownload/getImageResolution.native.js b/src/libs/fileDownload/getImageResolution.native.ts
similarity index 61%
rename from src/libs/fileDownload/getImageResolution.native.js
rename to src/libs/fileDownload/getImageResolution.native.ts
index f291886f4665..3bdff78a93ed 100644
--- a/src/libs/fileDownload/getImageResolution.native.js
+++ b/src/libs/fileDownload/getImageResolution.native.ts
@@ -1,14 +1,13 @@
+import {Asset} from 'react-native-image-picker';
+import type {GetImageResolution} from './types';
+
/**
* Get image resolution
* Image object is returned as a result of a user selecting image using the react-native-image-picker
* Image already has width and height properties coming from library so we just need to return them on native
* Opposite to web where we need to create a new Image object and get dimensions from it
*
- * @param {*} file Picked file blob
- * @returns {Promise}
*/
-function getImageResolution(file) {
- return Promise.resolve({width: file.width, height: file.height});
-}
+const getImageResolution: GetImageResolution = (file: Asset) => Promise.resolve({width: file.width ?? 0, height: file.height ?? 0});
export default getImageResolution;
diff --git a/src/libs/fileDownload/getImageResolution.js b/src/libs/fileDownload/getImageResolution.ts
similarity index 80%
rename from src/libs/fileDownload/getImageResolution.js
rename to src/libs/fileDownload/getImageResolution.ts
index 2f9a6d4fbdb4..74dc7401d801 100644
--- a/src/libs/fileDownload/getImageResolution.js
+++ b/src/libs/fileDownload/getImageResolution.ts
@@ -1,3 +1,5 @@
+import type {GetImageResolution} from './types';
+
/**
* Get image resolution
* File object is returned as a result of a user selecting image using the
@@ -7,10 +9,8 @@
* new Image() is used specifically for performance reasons, opposed to using FileReader (5ms vs +100ms)
* because FileReader is slow and causes a noticeable delay in the UI when selecting an image.
*
- * @param {*} file Picked file blob
- * @returns {Promise}
*/
-function getImageResolution(file) {
+const getImageResolution: GetImageResolution = (file) => {
if (!(file instanceof File)) {
return Promise.reject(new Error('Object is not an instance of File'));
}
@@ -20,14 +20,14 @@ function getImageResolution(file) {
const objectUrl = URL.createObjectURL(file);
image.onload = function () {
resolve({
- width: this.naturalWidth,
- height: this.naturalHeight,
+ width: (this as HTMLImageElement).naturalWidth,
+ height: (this as HTMLImageElement).naturalHeight,
});
URL.revokeObjectURL(objectUrl);
};
image.onerror = reject;
image.src = objectUrl;
});
-}
+};
export default getImageResolution;
diff --git a/src/libs/fileDownload/index.android.js b/src/libs/fileDownload/index.android.ts
similarity index 82%
rename from src/libs/fileDownload/index.android.js
rename to src/libs/fileDownload/index.android.ts
index c3528b579f67..41c7cb29550a 100644
--- a/src/libs/fileDownload/index.android.js
+++ b/src/libs/fileDownload/index.android.ts
@@ -1,15 +1,15 @@
import {PermissionsAndroid, Platform} from 'react-native';
-import RNFetchBlob from 'react-native-blob-util';
+import RNFetchBlob, {FetchBlobResponse} from 'react-native-blob-util';
import * as FileUtils from './FileUtils';
+import type {FileDownload} from './types';
/**
* Android permission check to store images
- * @returns {Promise}
*/
-function hasAndroidPermission() {
+function hasAndroidPermission(): Promise {
// On Android API Level 33 and above, these permissions do nothing and always return 'never_ask_again'
// More info here: https://stackoverflow.com/a/74296799
- if (Platform.Version >= 33) {
+ if (Number(Platform.Version) >= 33) {
return Promise.resolve(true);
}
@@ -31,11 +31,8 @@ function hasAndroidPermission() {
/**
* Handling the download
- * @param {String} url
- * @param {String} fileName
- * @returns {Promise}
*/
-function handleDownload(url, fileName) {
+function handleDownload(url: string, fileName: string): Promise {
return new Promise((resolve) => {
const dirs = RNFetchBlob.fs.dirs;
@@ -46,7 +43,7 @@ function handleDownload(url, fileName) {
const isLocalFile = url.startsWith('file://');
let attachmentPath = isLocalFile ? url : undefined;
- let fetchedAttachment = Promise.resolve();
+ let fetchedAttachment: Promise = Promise.resolve();
if (!isLocalFile) {
// Fetching the attachment
@@ -69,7 +66,7 @@ function handleDownload(url, fileName) {
}
if (!isLocalFile) {
- attachmentPath = attachment.path();
+ attachmentPath = (attachment as FetchBlobResponse).path();
}
return RNFetchBlob.MediaCollection.copyToMediaStore(
@@ -79,11 +76,13 @@ function handleDownload(url, fileName) {
mimeType: null,
},
'Download',
- attachmentPath,
+ attachmentPath ?? '',
);
})
.then(() => {
- RNFetchBlob.fs.unlink(attachmentPath);
+ if (attachmentPath) {
+ RNFetchBlob.fs.unlink(attachmentPath);
+ }
FileUtils.showSuccessAlert();
})
.catch(() => {
@@ -95,12 +94,9 @@ function handleDownload(url, fileName) {
/**
* Checks permission and downloads the file for Android
- * @param {String} url
- * @param {String} fileName
- * @returns {Promise}
*/
-export default function fileDownload(url, fileName) {
- return new Promise((resolve) => {
+const fileDownload: FileDownload = (url, fileName) =>
+ new Promise((resolve) => {
hasAndroidPermission()
.then((hasPermission) => {
if (hasPermission) {
@@ -113,4 +109,5 @@ export default function fileDownload(url, fileName) {
})
.finally(() => resolve());
});
-}
+
+export default fileDownload;
diff --git a/src/libs/fileDownload/index.ios.js b/src/libs/fileDownload/index.ios.ts
similarity index 72%
rename from src/libs/fileDownload/index.ios.js
rename to src/libs/fileDownload/index.ios.ts
index 1599e919d28a..fdc4a78e0b9b 100644
--- a/src/libs/fileDownload/index.ios.js
+++ b/src/libs/fileDownload/index.ios.ts
@@ -1,23 +1,20 @@
import {CameraRoll} from '@react-native-camera-roll/camera-roll';
-import lodashGet from 'lodash/get';
import RNFetchBlob from 'react-native-blob-util';
import CONST from '@src/CONST';
import * as FileUtils from './FileUtils';
+import type {FileDownload} from './types';
/**
* Downloads the file to Documents section in iOS
- * @param {String} fileUrl
- * @param {String} fileName
- * @returns {Promise}
*/
-function downloadFile(fileUrl, fileName) {
+function downloadFile(fileUrl: string, fileName: string) {
const dirs = RNFetchBlob.fs.dirs;
// The iOS files will download to documents directory
const path = dirs.DocumentDir;
// Fetching the attachment
- const fetchedAttachment = RNFetchBlob.config({
+ return RNFetchBlob.config({
fileCache: true,
path: `${path}/${fileName}`,
addAndroidDownloads: {
@@ -26,60 +23,61 @@ function downloadFile(fileUrl, fileName) {
path: `${path}/Expensify/${fileName}`,
},
}).fetch('GET', fileUrl);
- return fetchedAttachment;
}
/**
* Download the image to photo lib in iOS
- * @param {String} fileUrl
- * @param {String} fileName
- * @returns {String} URI
*/
-function downloadImage(fileUrl) {
+function downloadImage(fileUrl: string) {
return CameraRoll.save(fileUrl);
}
/**
* Download the video to photo lib in iOS
- * @param {String} fileUrl
- * @param {String} fileName
- * @returns {String} URI
*/
-function downloadVideo(fileUrl, fileName) {
+function downloadVideo(fileUrl: string, fileName: string): Promise {
return new Promise((resolve, reject) => {
- let documentPathUri = null;
- let cameraRollUri = null;
+ let documentPathUri: string | null = null;
+ let cameraRollUri: string | null = null;
// Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download as documents, then copy to photo lib and unlink the original file.
downloadFile(fileUrl, fileName)
.then((attachment) => {
- documentPathUri = lodashGet(attachment, 'data');
+ documentPathUri = attachment.data;
+ if (!documentPathUri) {
+ throw new Error('Error downloading video');
+ }
return CameraRoll.save(documentPathUri);
})
.then((attachment) => {
cameraRollUri = attachment;
+ if (!documentPathUri) {
+ throw new Error('Error downloading video');
+ }
return RNFetchBlob.fs.unlink(documentPathUri);
})
- .then(() => resolve(cameraRollUri))
+ .then(() => {
+ if (!cameraRollUri) {
+ throw new Error('Error downloading video');
+ }
+ resolve(cameraRollUri);
+ })
.catch((err) => reject(err));
});
}
/**
* Download the file based on type(image, video, other file types)for iOS
- * @param {String} fileUrl
- * @param {String} fileName
- * @returns {Promise}
*/
-export default function fileDownload(fileUrl, fileName) {
- return new Promise((resolve) => {
- let fileDownloadPromise = null;
+const fileDownload: FileDownload = (fileUrl, fileName) =>
+ new Promise((resolve) => {
+ let fileDownloadPromise;
const fileType = FileUtils.getFileType(fileUrl);
const attachmentName = FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(fileUrl);
switch (fileType) {
case CONST.ATTACHMENT_FILE_TYPE.IMAGE:
- fileDownloadPromise = downloadImage(fileUrl, attachmentName);
+ fileDownloadPromise = downloadImage(fileUrl);
break;
case CONST.ATTACHMENT_FILE_TYPE.VIDEO:
fileDownloadPromise = downloadVideo(fileUrl, attachmentName);
@@ -108,4 +106,5 @@ export default function fileDownload(fileUrl, fileName) {
})
.finally(() => resolve());
});
-}
+
+export default fileDownload;
diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js
deleted file mode 100644
index 002594244def..000000000000
--- a/src/libs/fileDownload/index.js
+++ /dev/null
@@ -1,54 +0,0 @@
-import _ from 'lodash';
-import * as ApiUtils from '@libs/ApiUtils';
-import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
-import * as Link from '@userActions/Link';
-import CONST from '@src/CONST';
-import * as FileUtils from './FileUtils';
-
-/**
- * Downloading attachment in web, desktop
- * @param {String} url
- * @param {String} fileName
- * @returns {Promise}
- */
-export default function fileDownload(url, fileName) {
- const resolvedUrl = tryResolveUrlFromApiRoot(url);
- if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !_.some(CONST.ATTACHMENT_LOCAL_URL_PREFIX, (prefix) => resolvedUrl.startsWith(prefix))) {
- // Different origin URLs might pose a CORS issue during direct downloads.
- // Opening in a new tab avoids this limitation, letting the browser handle the download.
- Link.openExternalLink(url);
- return Promise.resolve();
- }
-
- return (
- fetch(url)
- .then((response) => response.blob())
- .then((blob) => {
- // Create blob link to download
- const href = URL.createObjectURL(new Blob([blob]));
-
- // creating anchor tag to initiate download
- const link = document.createElement('a');
-
- // adding href to anchor
- link.href = href;
- link.style.display = 'none';
- link.setAttribute(
- 'download',
- FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name
- );
-
- // Append to html link element page
- document.body.appendChild(link);
-
- // Start download
- link.click();
-
- // Clean up and remove the link
- URL.revokeObjectURL(link.href);
- link.parentNode.removeChild(link);
- })
- // file could not be downloaded, open sourceURL in new tab
- .catch(() => Link.openExternalLink(url))
- );
-}
diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts
new file mode 100644
index 000000000000..ef36647e549d
--- /dev/null
+++ b/src/libs/fileDownload/index.ts
@@ -0,0 +1,53 @@
+import * as ApiUtils from '@libs/ApiUtils';
+import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot';
+import * as Link from '@userActions/Link';
+import CONST from '@src/CONST';
+import * as FileUtils from './FileUtils';
+import type {FileDownload} from './types';
+
+/**
+ * The function downloads an attachment on web/desktop platforms.
+ */
+const fileDownload: FileDownload = (url, fileName) => {
+ const resolvedUrl = tryResolveUrlFromApiRoot(url);
+ if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix))) {
+ // Different origin URLs might pose a CORS issue during direct downloads.
+ // Opening in a new tab avoids this limitation, letting the browser handle the download.
+ Link.openExternalLink(url);
+ return Promise.resolve();
+ }
+
+ return fetch(url)
+ .then((response) => response.blob())
+ .then((blob) => {
+ // Create blob link to download
+ const href = URL.createObjectURL(new Blob([blob]));
+
+ // creating anchor tag to initiate download
+ const link = document.createElement('a');
+
+ // adding href to anchor
+ link.href = href;
+ link.style.display = 'none';
+ link.setAttribute(
+ 'download',
+ FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name
+ );
+
+ // Append to html link element page
+ document.body.appendChild(link);
+
+ // Start download
+ link.click();
+
+ // Clean up and remove the link
+ URL.revokeObjectURL(link.href);
+ link.parentNode?.removeChild(link);
+ })
+ .catch(() => {
+ // file could not be downloaded, open sourceURL in new tab
+ Link.openExternalLink(url);
+ });
+};
+
+export default fileDownload;
diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts
new file mode 100644
index 000000000000..c7388f2e52a2
--- /dev/null
+++ b/src/libs/fileDownload/types.ts
@@ -0,0 +1,20 @@
+import {Asset} from 'react-native-image-picker';
+
+type FileDownload = (url: string, fileName: string) => Promise;
+
+type ImageResolution = {width: number; height: number};
+type GetImageResolution = (url: File | Asset) => Promise;
+
+type ExtensionAndFileName = {fileName: string; fileExtension: string};
+type SplitExtensionFromFileName = (fileName: string) => ExtensionAndFileName;
+
+type ReadFileAsync = (path: string, fileName: string, onSuccess: (file: File) => void, onFailure: (error?: unknown) => void) => Promise;
+
+type AttachmentDetails = {
+ previewSourceURL: null | string;
+ sourceURL: null | string;
+ originalFileName: null | string;
+};
+type GetAttachmentDetails = (html: string) => AttachmentDetails;
+
+export type {SplitExtensionFromFileName, GetAttachmentDetails, ReadFileAsync, FileDownload, GetImageResolution};
diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js
index b65670819418..5daba3686208 100644
--- a/src/libs/migrateOnyx.js
+++ b/src/libs/migrateOnyx.js
@@ -3,6 +3,7 @@ import Log from './Log';
import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID';
import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID';
import RenameReceiptFilename from './migrations/RenameReceiptFilename';
+import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection';
export default function () {
const startTime = Date.now();
@@ -10,7 +11,7 @@ export default function () {
return new Promise((resolve) => {
// Add all migrations to an array so they are executed in order
- const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID];
+ const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection];
// Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the
// previous promise to finish before moving onto the next one.
diff --git a/src/libs/migrations/TransactionBackupsToCollection.ts b/src/libs/migrations/TransactionBackupsToCollection.ts
new file mode 100644
index 000000000000..ddaa691b8d47
--- /dev/null
+++ b/src/libs/migrations/TransactionBackupsToCollection.ts
@@ -0,0 +1,58 @@
+import Onyx, {OnyxCollection} from 'react-native-onyx';
+import Log from '@libs/Log';
+import ONYXKEYS from '@src/ONYXKEYS';
+import {Transaction} from '@src/types/onyx';
+
+/**
+ * This migration moves all the transaction backups stored in the transaction collection, ONYXKEYS.COLLECTION.TRANSACTION, to a reserved collection that only
+ * stores draft transactions, ONYXKEYS.COLLECTION.TRANSACTION_DRAFT. The purpose of the migration is that there is a possibility that transaction backups are
+ * not filtered by most functions, e.g, getAllReportTransactions (src/libs/TransactionUtils.ts). One problem that arose from storing transaction backups with
+ * the other transactions is that for every distance request which have their waypoints updated offline, we expect the ReportPreview component to display the
+ * default image of a pending map. However, due to the presence of the transaction backup, the previous map image will be displayed alongside the pending map.
+ * The problem was further discussed in this PR. https://github.com/Expensify/App/pull/30232#issuecomment-178110172
+ */
+export default function (): Promise {
+ return new Promise((resolve) => {
+ const connectionID = Onyx.connect({
+ key: ONYXKEYS.COLLECTION.TRANSACTION,
+ waitForCollectionCallback: true,
+ callback: (transactions: OnyxCollection) => {
+ Onyx.disconnect(connectionID);
+
+ // Determine whether any transactions were stored
+ if (!transactions || Object.keys(transactions).length === 0) {
+ Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transactions');
+ return resolve();
+ }
+
+ const onyxData: OnyxCollection = {};
+
+ // Find all the transaction backups available
+ Object.keys(transactions).forEach((transactionOnyxKey: string) => {
+ const transaction: Transaction | null = transactions[transactionOnyxKey];
+
+ // Determine whether or not the transaction is a backup
+ if (transactionOnyxKey.endsWith('-backup') && transaction) {
+ // Create the transaction backup in the draft transaction collection
+ onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`] = transaction;
+
+ // Delete the transaction backup stored in the transaction collection
+ onyxData[transactionOnyxKey] = null;
+ }
+ });
+
+ // Determine whether any transaction backups are found
+ if (Object.keys(onyxData).length === 0) {
+ Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transaction backups');
+ return resolve();
+ }
+
+ // Move the transaction backups to the draft transaction collection
+ Onyx.multiSet(onyxData as Partial<{string: [Transaction | null]}>).then(() => {
+ Log.info('[Migrate Onyx] TransactionBackupsToCollection migration: Successfully moved all the transaction backups to the draft transaction collection');
+ resolve();
+ });
+ },
+ });
+ });
+}
diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js
index 0fca8aee4be9..48b80890dc49 100644
--- a/src/pages/EditRequestDistancePage.js
+++ b/src/pages/EditRequestDistancePage.js
@@ -102,7 +102,7 @@ function EditRequestDistancePage({report, route, transaction, transactionBackup}
}
transactionWasSaved.current = true;
- IOU.updateDistanceRequest(transaction.transactionID, report.reportID, {waypoints});
+ IOU.editMoneyRequest(transaction, report.reportID, {waypoints});
// If the client is offline, then the modal can be closed as well (because there are no errors or other feedback to show them
// until they come online again and sync with the server).
@@ -140,6 +140,6 @@ export default withOnyx({
key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
},
transactionBackup: {
- key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}-backup`,
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`,
},
})(EditRequestDistancePage);
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 194cd2855dbd..95313bea142d 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -119,11 +119,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT
// Update the transaction object and close the modal
function editMoneyRequest(transactionChanges) {
- if (TransactionUtils.isDistanceRequest(transaction)) {
- IOU.updateDistanceRequest(transaction.transactionID, report.reportID, transactionChanges);
- } else {
- IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges);
- }
+ IOU.editMoneyRequest(transaction, report.reportID, transactionChanges);
Navigation.dismissModal(report.reportID);
}
diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js
index 9ada6b820e8e..aac2e6d613f9 100644
--- a/src/pages/ErrorPage/NotFoundPage.js
+++ b/src/pages/ErrorPage/NotFoundPage.js
@@ -1,16 +1,31 @@
+import PropTypes from 'prop-types';
import React from 'react';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import ScreenWrapper from '@components/ScreenWrapper';
+const propTypes = {
+ /** Method to trigger when pressing back button of the header */
+ onBackButtonPress: PropTypes.func,
+};
+
+const defaultProps = {
+ onBackButtonPress: undefined,
+};
+
// eslint-disable-next-line rulesdir/no-negated-variables
-function NotFoundPage() {
+function NotFoundPage({onBackButtonPress}) {
return (
-
+
);
}
NotFoundPage.displayName = 'NotFoundPage';
+NotFoundPage.propTypes = propTypes;
+NotFoundPage.defaultProps = defaultProps;
export default NotFoundPage;
diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js
index 8fd0ec144a1f..df38c28e561a 100644
--- a/src/pages/LogOutPreviousUserPage.js
+++ b/src/pages/LogOutPreviousUserPage.js
@@ -5,10 +5,17 @@ import {Linking} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import * as SessionUtils from '@libs/SessionUtils';
+import Navigation from '@navigation/Navigation';
import * as Session from '@userActions/Session';
import ONYXKEYS from '@src/ONYXKEYS';
const propTypes = {
+ /** The details about the account that the user is signing in with */
+ account: PropTypes.shape({
+ /** Whether the account data is loading */
+ isLoading: PropTypes.bool,
+ }),
+
/** The data about the current session which will be set once the user is authenticated and we return to this component as an AuthScreen */
session: PropTypes.shape({
/** The user's email for the current session */
@@ -17,37 +24,43 @@ const propTypes = {
};
const defaultProps = {
+ account: {
+ isLoading: false,
+ },
session: {
email: null,
},
};
function LogOutPreviousUserPage(props) {
- useEffect(
- () => {
- Linking.getInitialURL().then((transitionURL) => {
- const sessionEmail = props.session.email;
- const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail);
-
- if (isLoggingInAsNewUser) {
- Session.signOutAndRedirectToSignIn();
- }
-
- // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot
- // and their authToken stored in Onyx becomes invalid.
- // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot
- // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken
- const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true';
- if (shouldForceLogin) {
- const email = lodashGet(props, 'route.params.email', '');
- const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', '');
- Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken);
- }
- });
- },
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [],
- );
+ useEffect(() => {
+ Linking.getInitialURL().then((transitionURL) => {
+ const sessionEmail = props.session.email;
+ const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail);
+
+ if (isLoggingInAsNewUser) {
+ Session.signOutAndRedirectToSignIn();
+ }
+
+ // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot
+ // and their authToken stored in Onyx becomes invalid.
+ // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot
+ // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken
+ const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true';
+ if (shouldForceLogin) {
+ const email = lodashGet(props, 'route.params.email', '');
+ const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', '');
+ Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken);
+ }
+
+ const exitTo = lodashGet(props, 'route.params.exitTo', '');
+ if (exitTo && !props.account.isLoading && !isLoggingInAsNewUser) {
+ Navigation.isNavigationReady().then(() => {
+ Navigation.navigate(exitTo);
+ });
+ }
+ });
+ }, [props]);
return ;
}
@@ -57,6 +70,9 @@ LogOutPreviousUserPage.defaultProps = defaultProps;
LogOutPreviousUserPage.displayName = 'LogOutPreviousUserPage';
export default withOnyx({
+ account: {
+ key: ONYXKEYS.ACCOUNT,
+ },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 4b3c927ef317..17ea63ca1003 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -148,7 +148,10 @@ function ProfilePage(props) {
}, [accountID, hasMinimumDetails]);
return (
-
+
Navigation.goBack(navigateBackTo)}
diff --git a/src/pages/ReferralDetailsPage.js b/src/pages/ReferralDetailsPage.js
index 282e85fe0237..36fc74836d58 100644
--- a/src/pages/ReferralDetailsPage.js
+++ b/src/pages/ReferralDetailsPage.js
@@ -17,6 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
import styles from '@styles/styles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
const propTypes = {
/** Navigation route context info provided by react navigation */
@@ -52,6 +53,17 @@ function ReferralDetailsPage({route, account}) {
return `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(email)}`;
}
+ function getFallbackRoute() {
+ const fallbackRoutes = {
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.REQUEST),
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SEND),
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: ROUTES.NEW_CHAT,
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: ROUTES.SEARCH,
+ };
+
+ return fallbackRoutes[contentType];
+ }
+
return (
Navigation.goBack()}
+ onBackButtonPress={() => Navigation.goBack(getFallbackRoute())}
/>
Navigation.goBack()}
+ onPress={() => Navigation.goBack(getFallbackRoute())}
pressOnEnter
enterKeyEventListenerPriority={1}
/>
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index 88d5ddec0c54..abfe625f1508 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -163,7 +163,10 @@ function ReportDetailsPage(props) {
) : null;
return (
-
+
.map((accountID, index) => {
const userPersonalDetail = lodashGet(personalDetails, accountID, {displayName: personalDetails.displayName || translate('common.hidden'), avatar: ''});
const userLogin = LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login || '') || translate('common.hidden');
+ const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail, 'displayName');
return {
alternateText: userLogin,
- displayName: userPersonalDetail.displayName,
+ displayName,
accountID: userPersonalDetail.accountID,
icons: [
{
@@ -74,9 +76,9 @@ const getAllParticipants = (report, personalDetails, translate) =>
],
keyForList: `${index}-${userLogin}`,
login: userLogin,
- text: userPersonalDetail.displayName,
+ text: displayName,
tooltipText: userLogin,
- participantsList: [{accountID, displayName: userPersonalDetail.displayName}],
+ participantsList: [{accountID, displayName}],
};
})
.sortBy((participant) => participant.displayName.toLowerCase())
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 3353f791745f..312f64ea13f3 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -15,7 +15,6 @@ import ScreenWrapper from '@components/ScreenWrapper';
import TaskHeaderActionButton from '@components/TaskHeaderActionButton';
import withCurrentReportID, {withCurrentReportIDDefaultProps, withCurrentReportIDPropTypes} from '@components/withCurrentReportID';
import withViewportOffsetTop from '@components/withViewportOffsetTop';
-import useBlockViewportScroll from '@hooks/useBlockViewportScroll';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import useWindowDimensions from '@hooks/useWindowDimensions';
@@ -127,8 +126,12 @@ const defaultProps = {
* @returns {String}
*/
function getReportID(route) {
- // // The reportID is used inside a collection key and should not be empty, as an empty reportID will result in the entire collection being returned.
- return String(lodashGet(route, 'params.reportID', null));
+ // The report ID is used in an onyx key. If it's an empty string, onyx will return
+ // a collection instead of an individual report.
+ // We can't use the default value functionality of `lodash.get()` because it only
+ // provides a default value on `undefined`, and will return an empty string.
+ // Placing the default value outside of `lodash.get()` is intentional.
+ return String(lodashGet(route, 'params.reportID') || 0);
}
function ReportScreen({
@@ -151,7 +154,6 @@ function ReportScreen({
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isSmallScreenWidth} = useWindowDimensions();
- useBlockViewportScroll();
const firstRenderRef = useRef(true);
const flatListRef = useRef();
@@ -376,6 +378,7 @@ function ReportScreen({
style={screenWrapperStyle}
shouldEnableKeyboardAvoidingView={isTopMostReportId}
testID={ReportScreen.displayName}
+ shouldDisableFocusTrap
>
{
raiseIsScrollLikelyLayoutTriggered();
- const {text: newComment, emojis} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
+ const {text: newComment, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(commentValue, preferredSkinTone, preferredLocale);
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
if (!_.isEmpty(newEmojis)) {
@@ -230,14 +230,20 @@ function ComposerWithSuggestions({
}
}
const newCommentConverted = convertToLTRForComposer(newComment);
+ const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/);
+ const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/);
+
+ /** Only update isCommentEmpty state if it's different from previous one */
+ if (isNewCommentEmpty !== isPrevCommentEmpty) {
+ setIsCommentEmpty(isNewCommentEmpty);
+ }
emojisPresentBefore.current = emojis;
- setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/));
setValue(newCommentConverted);
if (commentValue !== newComment) {
- const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
+ const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
setSelection({
- start: newComment.length - remainder,
- end: newComment.length - remainder,
+ start: position,
+ end: position,
});
}
@@ -270,6 +276,7 @@ function ComposerWithSuggestions({
suggestionsRef,
raiseIsScrollLikelyLayoutTriggered,
debouncedSaveReportComment,
+ selection.end,
],
);
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
index dc84f77b6311..88f0d0a68c67 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
@@ -201,6 +201,10 @@ function SuggestionEmoji({
const getSuggestions = useCallback(() => suggestionValues.suggestedEmojis, [suggestionValues]);
+ const resetEmojiSuggestions = useCallback(() => {
+ setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}));
+ }, []);
+
useImperativeHandle(
forwardedRef,
() => ({
@@ -220,11 +224,11 @@ function SuggestionEmoji({
return (
setSuggestionValues((prevState) => ({...prevState, suggestedEmojis: []}))}
+ onClose={resetEmojiSuggestions}
highlightedEmojiIndex={highlightedEmojiIndex}
emojis={suggestionValues.suggestedEmojis}
comment={value}
- updateComment={(newComment) => setValue(newComment)}
+ updateComment={setValue}
colonIndex={suggestionValues.colonIndex}
prefix={value.slice(suggestionValues.colonIndex + 1, selection.start)}
onSelect={insertSelectedEmoji}
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionMention.js b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
index 0a9ed2c11293..b442ca961939 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionMention.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionMention.js
@@ -284,7 +284,7 @@ function SuggestionMention({
highlightedMentionIndex={highlightedMentionIndex}
mentions={suggestionValues.suggestedMentions}
comment={value}
- updateComment={(newComment) => setValue(newComment)}
+ updateComment={setValue}
colonIndex={suggestionValues.colonIndex}
prefix={suggestionValues.mentionPrefix}
onSelect={insertSelectedMention}
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index b723ddd93582..c8ea0d5e3514 100644
--- a/src/pages/home/report/ReportActionItemMessageEdit.js
+++ b/src/pages/home/report/ReportActionItemMessageEdit.js
@@ -122,6 +122,7 @@ function ReportActionItemMessageEdit(props) {
const textInputRef = useRef(null);
const isFocusedRef = useRef(false);
const insertedEmojis = useRef([]);
+ const draftRef = useRef(draft);
useEffect(() => {
if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) {
@@ -241,7 +242,7 @@ function ReportActionItemMessageEdit(props) {
*/
const updateDraft = useCallback(
(newDraftInput) => {
- const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale);
+ const {text: newDraft, emojis, cursorPosition} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale);
if (!_.isEmpty(emojis)) {
const newEmojis = EmojiUtils.getAddedEmojis(emojis, emojisPresentBefore.current);
@@ -255,13 +256,15 @@ function ReportActionItemMessageEdit(props) {
setDraft(newDraft);
if (newDraftInput !== newDraft) {
- const remainder = ComposerUtils.getCommonSuffixLength(newDraftInput, newDraft);
+ const position = Math.max(selection.end + (newDraft.length - draftRef.current.length), cursorPosition || 0);
setSelection({
- start: newDraft.length - remainder,
- end: newDraft.length - remainder,
+ start: position,
+ end: position,
});
}
+ draftRef.current = newDraft;
+
// This component is rendered only when draft is set to a non-empty string. In order to prevent component
// unmount when user deletes content of textarea, we set previous message instead of empty string.
if (newDraft.trim().length > 0) {
@@ -271,7 +274,7 @@ function ReportActionItemMessageEdit(props) {
debouncedSaveDraft(props.action.message[0].html);
}
},
- [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale],
+ [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale, selection.end],
);
useEffect(() => {
diff --git a/src/pages/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index dd537959c91f..e1230d7219db 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -213,7 +213,7 @@ function ReportActionsList({
if (!userActiveSince.current || report.reportID !== prevReportID) {
return;
}
- if (!messageManuallyMarkedUnread && lastReadTimeRef.current && lastReadTimeRef.current < report.lastReadTime) {
+ if (!messageManuallyMarkedUnread && (lastReadTimeRef.current || '') < report.lastReadTime) {
cacheUnreadMarkers.delete(report.reportID);
}
lastReadTimeRef.current = report.lastReadTime;
diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js
index 9f973674d6a7..e5dd5da19ad5 100644
--- a/src/pages/home/report/ReportFooter.js
+++ b/src/pages/home/report/ReportFooter.js
@@ -92,10 +92,7 @@ function ReportFooter(props) {
)}
{!hideComposer && (props.shouldShowComposeInput || !props.isSmallScreenWidth) && (
-
+
-
-
- {isLoading && (
-
-
-
- )}
-
+
+
+ {isLoading && }
);
}
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index dc7d00566bc0..088eb5c0092a 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -199,23 +199,28 @@ export default compose(
chatReports: {
key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
+ initialValue: {},
},
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
priorityMode: {
key: ONYXKEYS.NVP_PRIORITY_MODE,
+ initialValue: CONST.PRIORITY_MODE.DEFAULT,
},
betas: {
key: ONYXKEYS.BETAS,
+ initialValue: [],
},
allReportActions: {
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
selector: reportActionsSelector,
+ initialValue: {},
},
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
selector: policySelector,
+ initialValue: {},
},
}),
)(SidebarLinksData);
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index 5b7a126a4655..efb5e839f618 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -30,6 +30,7 @@ function BaseSidebarScreen(props) {
shouldEnableKeyboardAvoidingView={false}
style={[styles.sidebar, Browser.isMobile() ? styles.userSelectNone : {}]}
testID={BaseSidebarScreen.displayName}
+ shouldDisableFocusTrap
>
{({insets}) => (
<>
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index d1fe21d8cf4e..2ebe96d60ed8 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -109,7 +109,10 @@ function SplitBillDetailsPage(props) {
);
return (
-
+
diff --git a/src/pages/settings/Profile/PersonalDetails/AddressPage.js b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
index 22907aa6e5b0..fa22a3b22f9e 100644
--- a/src/pages/settings/Profile/PersonalDetails/AddressPage.js
+++ b/src/pages/settings/Profile/PersonalDetails/AddressPage.js
@@ -1,25 +1,16 @@
-import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
-import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import _ from 'underscore';
-import AddressSearch from '@components/AddressSearch';
-import CountrySelector from '@components/CountrySelector';
-import Form from '@components/Form';
+import AddressForm from '@components/AddressForm';
import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
-import StatePicker from '@components/StatePicker';
-import TextInput from '@components/TextInput';
import useLocalize from '@hooks/useLocalize';
import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
import Navigation from '@libs/Navigation/Navigation';
-import * as ValidationUtils from '@libs/ValidationUtils';
import useThemeStyles from '@styles/useThemeStyles';
import * as PersonalDetails from '@userActions/PersonalDetails';
-import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
@@ -75,9 +66,6 @@ function AddressPage({privatePersonalDetails, route}) {
const address = useMemo(() => lodashGet(privatePersonalDetails, 'address') || {}, [privatePersonalDetails]);
const countryFromUrl = lodashGet(route, 'params.country');
const [currentCountry, setCurrentCountry] = useState(address.country);
- const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [currentCountry, 'samples'], '');
- const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat});
- const isUSAForm = currentCountry === CONST.COUNTRY.US;
const isLoadingPersonalDetails = lodashGet(privatePersonalDetails, 'isLoading', true);
const [street1, street2] = (address.street || '').split('\n');
const [state, setState] = useState(address.state);
@@ -94,51 +82,6 @@ function AddressPage({privatePersonalDetails, route}) {
setZipcode(address.zip);
}, [address]);
- /**
- * @param {Function} translate - translate function
- * @param {Boolean} isUSAForm - selected country ISO code is US
- * @param {Object} values - form input values
- * @returns {Object} - An object containing the errors for each inputID
- */
- const validate = useCallback((values) => {
- const errors = {};
- const requiredFields = ['addressLine1', 'city', 'country', 'state'];
-
- // Check "State" dropdown is a valid state if selected Country is USA
- if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) {
- errors.state = 'common.error.fieldRequired';
- }
-
- // Add "Field required" errors if any required field is empty
- _.each(requiredFields, (fieldKey) => {
- if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) {
- return;
- }
- errors[fieldKey] = 'common.error.fieldRequired';
- });
-
- // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object
- const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {});
-
- // The postal code system might not exist for a country, so no regex either for them.
- const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex');
- const countryZipFormat = lodashGet(countryRegexDetails, 'samples');
-
- if (countrySpecificZipRegex) {
- if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) {
- if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) {
- errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}];
- } else {
- errors.zipPostCode = 'common.error.fieldRequired';
- }
- }
- } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) {
- errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat';
- }
-
- return errors;
- }, []);
-
const handleAddressChange = useCallback((value, key) => {
if (key !== 'country' && key !== 'state' && key !== 'city' && key !== 'zipPostCode') {
return;
@@ -184,93 +127,18 @@ function AddressPage({privatePersonalDetails, route}) {
{isLoadingPersonalDetails ? (
) : (
-
+ city={city}
+ country={currentCountry}
+ onAddressChanged={handleAddressChange}
+ state={state}
+ street1={street1}
+ street2={street2}
+ zip={zipcode}
+ />
)}
);
diff --git a/src/pages/settings/Report/ReportSettingsPage.js b/src/pages/settings/Report/ReportSettingsPage.js
index f327d728e4d2..0676f0875932 100644
--- a/src/pages/settings/Report/ReportSettingsPage.js
+++ b/src/pages/settings/Report/ReportSettingsPage.js
@@ -75,7 +75,7 @@ function ReportSettingsPage(props) {
const writeCapability = ReportUtils.isAdminRoom(report) ? CONST.REPORT.WRITE_CAPABILITIES.ADMINS : report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL;
const writeCapabilityText = translate(`writeCapabilityPage.writeCapability.${writeCapability}`);
- const shouldAllowWriteCapabilityEditing = lodashGet(linkedWorkspace, 'role', '') === CONST.POLICY.ROLE.ADMIN && !ReportUtils.isAdminRoom(report) && !isMoneyRequestReport;
+ const shouldAllowWriteCapabilityEditing = useMemo(() => ReportUtils.canEditWriteCapability(report, linkedWorkspace), [report, linkedWorkspace]);
const shouldShowNotificationPref = !isMoneyRequestReport && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN;
const roomNameLabel = translate(isMoneyRequestReport ? 'workspace.editor.nameInputLabel' : 'newRoomPage.roomName');
diff --git a/src/pages/settings/Report/WriteCapabilityPage.js b/src/pages/settings/Report/WriteCapabilityPage.js
index c1b417bc28bd..fc587b028f7d 100644
--- a/src/pages/settings/Report/WriteCapabilityPage.js
+++ b/src/pages/settings/Report/WriteCapabilityPage.js
@@ -8,7 +8,6 @@ import SelectionList from '@components/SelectionList';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
-import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import withReportOrNotFound from '@pages/home/report/withReportOrNotFound';
import reportPropTypes from '@pages/reportPropTypes';
@@ -38,7 +37,7 @@ function WriteCapabilityPage(props) {
isSelected: value === (props.report.writeCapability || CONST.REPORT.WRITE_CAPABILITIES.ALL),
}));
- const isAbleToEdit = !ReportUtils.isAdminRoom(props.report) && PolicyUtils.isPolicyAdmin(props.policy) && !ReportUtils.isArchivedRoom(props.report);
+ const isAbleToEdit = ReportUtils.canEditWriteCapability(props.report, props.policy);
return (
() => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
-
- useEffect(() => {
+ const currentStep = useMemo(() => {
if (account.twoFactorAuthStep) {
- setCurrentStep(account.twoFactorAuthStep);
- return;
- }
-
- if (account.requiresTwoFactorAuth) {
- setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
- } else {
- setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+ return account.twoFactorAuthStep;
}
+ return account.requiresTwoFactorAuth ? CONST.TWO_FACTOR_AUTH_STEPS.ENABLED : CONST.TWO_FACTOR_AUTH_STEPS.CODES;
}, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]);
+ const {setAnimationDirection} = useAnimatedStepContext();
+
+ useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
const handleSetStep = useCallback(
(step, animationDirection = CONST.ANIMATION_DIRECTION.IN) => {
setAnimationDirection(animationDirection);
TwoFactorAuthActions.setTwoFactorAuthStep(step);
- setCurrentStep(step);
},
[setAnimationDirection],
);
diff --git a/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js
new file mode 100644
index 000000000000..030ca04b7074
--- /dev/null
+++ b/src/pages/settings/Wallet/Card/BaseGetPhysicalCard.js
@@ -0,0 +1,236 @@
+import PropTypes from 'prop-types';
+import React, {useCallback, useEffect, useRef} from 'react';
+import {Text} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import Form from '@components/Form';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import * as FormActions from '@libs/actions/FormActions';
+import * as Wallet from '@libs/actions/Wallet';
+import * as CardUtils from '@libs/CardUtils';
+import FormUtils from '@libs/FormUtils';
+import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import assignedCardPropTypes from '@pages/settings/Wallet/assignedCardPropTypes';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+
+const propTypes = {
+ /* Onyx Props */
+ /** List of available assigned cards */
+ cardList: PropTypes.objectOf(assignedCardPropTypes),
+
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ phoneNumber: PropTypes.string,
+ /** User's home address */
+ address: PropTypes.shape({
+ street: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ zip: PropTypes.string,
+ country: PropTypes.string,
+ }),
+ }),
+
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ addressLine1: PropTypes.string,
+ addressLine2: PropTypes.string,
+ city: PropTypes.string,
+ country: PropTypes.string,
+ phoneNumber: PropTypes.string,
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ state: PropTypes.string,
+ zipPostCode: PropTypes.string,
+ }),
+
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user authToken */
+ authToken: PropTypes.string,
+ }),
+
+ /** List of available login methods */
+ loginList: PropTypes.shape({
+ /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */
+ partnerName: PropTypes.string,
+
+ /** Phone/Email associated with user */
+ partnerUserID: PropTypes.string,
+
+ /** The date when the login was validated, used to show the brickroad status */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+
+ /** Field-specific pending states for offline UI status */
+ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+
+ /* Base Props */
+ /** Text displayed below page title */
+ headline: PropTypes.string.isRequired,
+
+ /** Children components that will be rendered by renderContent */
+ children: PropTypes.node,
+
+ /** Current route from ROUTES */
+ currentRoute: PropTypes.string.isRequired,
+
+ /** Expensify card domain */
+ domain: PropTypes.string,
+
+ /** Whether or not the current step of the get physical card flow is the confirmation page */
+ isConfirmation: PropTypes.bool,
+
+ /** Render prop, used to render form content */
+ renderContent: PropTypes.func,
+
+ /** Text displayed on bottom submit button */
+ submitButtonText: PropTypes.string.isRequired,
+
+ /** Title displayed on top of the page */
+ title: PropTypes.string.isRequired,
+
+ /** Callback executed when validating get physical card form data */
+ onValidate: PropTypes.func,
+};
+
+const defaultProps = {
+ cardList: {},
+ children: null,
+ domain: '',
+ draftValues: null,
+ privatePersonalDetails: null,
+ session: {},
+ loginList: {},
+ isConfirmation: false,
+ renderContent: (onSubmit, submitButtonText, children = () => {}, onValidate = () => ({})) => (
+
+ ),
+ onValidate: () => ({}),
+};
+
+function BaseGetPhysicalCard({
+ cardList,
+ children,
+ currentRoute,
+ domain,
+ draftValues,
+ privatePersonalDetails,
+ headline,
+ isConfirmation,
+ loginList,
+ renderContent,
+ session: {authToken},
+ submitButtonText,
+ title,
+ onValidate,
+}) {
+ const isRouteSet = useRef(false);
+
+ useEffect(() => {
+ if (isRouteSet.current || !privatePersonalDetails || !cardList) {
+ return;
+ }
+
+ const domainCards = CardUtils.getDomainCards(cardList)[domain] || [];
+ const physicalCard = _.find(domainCards, (card) => !card.isVirtual);
+
+ // When there are no cards for the specified domain, user is redirected to the wallet page
+ if (domainCards.length === 0) {
+ Navigation.goBack(ROUTES.SETTINGS_WALLET);
+ return;
+ }
+
+ // When there's no physical card or it exists but it doesn't have the required state for this flow,
+ // redirect user to the espensify card page
+ if (!physicalCard || physicalCard.state !== CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED) {
+ Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
+ return;
+ }
+
+ if (!draftValues) {
+ const updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues({}, privatePersonalDetails, loginList);
+ // Form draft data needs to be initialized with the private personal details
+ // If no draft data exists
+ FormActions.setDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM, updatedDraftValues);
+ return;
+ }
+
+ // Redirect user to previous steps of the flow if he hasn't finished them yet
+ const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues);
+ GetPhysicalCardUtils.setCurrentRoute(currentRoute, domain, updatedPrivatePersonalDetails, loginList);
+ isRouteSet.current = true;
+ }, [cardList, currentRoute, domain, draftValues, loginList, privatePersonalDetails]);
+
+ const onSubmit = useCallback(() => {
+ const updatedPrivatePersonalDetails = GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(draftValues);
+ // If the current step of the get physical card flow is the confirmation page
+ if (isConfirmation) {
+ const domainCards = CardUtils.getDomainCards(cardList)[domain];
+ const virtualCard = _.find(domainCards, (card) => card.isVirtual) || {};
+ const cardID = virtualCard.cardID;
+ Wallet.requestPhysicalExpensifyCard(cardID, authToken, updatedPrivatePersonalDetails);
+ // Form draft data needs to be erased when the flow is complete,
+ // so that no stale data is left on Onyx
+ FormActions.clearDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM);
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain));
+ return;
+ }
+ GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, updatedPrivatePersonalDetails, loginList);
+ }, [authToken, cardList, domain, draftValues, isConfirmation, loginList]);
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_WALLET_DOMAINCARD.getRoute(domain))}
+ />
+ {headline}
+ {renderContent(onSubmit, submitButtonText, children, onValidate)}
+
+ );
+}
+
+BaseGetPhysicalCard.defaultProps = defaultProps;
+BaseGetPhysicalCard.displayName = 'BaseGetPhysicalCard';
+BaseGetPhysicalCard.propTypes = propTypes;
+
+export default withOnyx({
+ cardList: {
+ key: ONYXKEYS.CARD_LIST,
+ },
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
+})(BaseGetPhysicalCard);
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js b/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js
new file mode 100644
index 000000000000..21ba85b6c5dd
--- /dev/null
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardAddress.js
@@ -0,0 +1,101 @@
+import PropTypes from 'prop-types';
+import React, {useCallback, useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import AddressForm from '@components/AddressForm';
+import useLocalize from '@hooks/useLocalize';
+import * as FormActions from '@libs/actions/FormActions';
+import FormUtils from '@libs/FormUtils';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import BaseGetPhysicalCard from './BaseGetPhysicalCard';
+
+const propTypes = {
+ /* Onyx Props */
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ // User home address
+ addressLine1: PropTypes.string,
+ addressLine2: PropTypes.string,
+ city: PropTypes.string,
+ country: PropTypes.string,
+ state: PropTypes.string,
+ zipPostCode: PropTypes.string,
+ }),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** Currently selected country */
+ country: PropTypes.string,
+ /** domain passed via route /settings/wallet/card/:domain */
+ domain: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ draftValues: {
+ addressLine1: '',
+ addressLine2: '',
+ city: '',
+ country: '',
+ state: '',
+ zipPostCode: '',
+ },
+};
+
+function GetPhysicalCardAddress({
+ draftValues: {addressLine1, addressLine2, city, state, zipPostCode, country},
+ route: {
+ params: {country: countryFromUrl, domain},
+ },
+}) {
+ const {translate} = useLocalize();
+
+ useEffect(() => {
+ if (!countryFromUrl) {
+ return;
+ }
+ FormActions.setDraftValues(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM, {country: countryFromUrl});
+ }, [countryFromUrl]);
+
+ const renderContent = useCallback(
+ (onSubmit, submitButtonText) => (
+
+ ),
+ [addressLine1, addressLine2, city, country, state, zipPostCode],
+ );
+
+ return (
+
+ );
+}
+
+GetPhysicalCardAddress.defaultProps = defaultProps;
+GetPhysicalCardAddress.displayName = 'GetPhysicalCardAddress';
+GetPhysicalCardAddress.propTypes = propTypes;
+
+export default withOnyx({
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
+})(GetPhysicalCardAddress);
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js
new file mode 100644
index 000000000000..e6a11e2ba1e1
--- /dev/null
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardConfirm.js
@@ -0,0 +1,127 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import * as Expensicons from '@components/Icon/Expensicons';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import FormUtils from '@libs/FormUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import BaseGetPhysicalCard from './BaseGetPhysicalCard';
+
+const goToGetPhysicalCardName = (domain) => {
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH);
+};
+
+const goToGetPhysicalCardPhone = (domain) => {
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH);
+};
+
+const goToGetPhysicalCardAddress = (domain) => {
+ Navigation.navigate(ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain), CONST.NAVIGATION.ACTION_TYPE.PUSH);
+};
+
+const propTypes = {
+ /* Onyx Props */
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ addressLine1: PropTypes.string,
+ addressLine2: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ country: PropTypes.string,
+ zipPostCode: PropTypes.string,
+ phoneNumber: PropTypes.string,
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ }),
+
+ /* Navigation Props */
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** domain passed via route /settings/wallet/card/:domain */
+ domain: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ draftValues: {
+ addressLine1: '',
+ addressLine2: '',
+ city: '',
+ state: '',
+ country: '',
+ zipPostCode: '',
+ phoneNumber: '',
+ legalFirstName: '',
+ legalLastName: '',
+ },
+};
+
+function GetPhysicalCardConfirm({
+ draftValues: {addressLine1, addressLine2, city, state, country, zipPostCode, legalFirstName, legalLastName, phoneNumber},
+ route: {
+ params: {domain},
+ },
+}) {
+ const {translate} = useLocalize();
+
+ return (
+
+ {translate('getPhysicalCard.estimatedDeliveryMessage')}
+ goToGetPhysicalCardName(domain)}
+ shouldShowRightIcon
+ title={`${legalFirstName} ${legalLastName}`}
+ />
+ goToGetPhysicalCardPhone(domain)}
+ shouldShowRightIcon
+ title={phoneNumber}
+ />
+ goToGetPhysicalCardAddress(domain)}
+ shouldShowRightIcon
+ title={PersonalDetailsUtils.getFormattedAddress({
+ address: {
+ street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2),
+ city,
+ state,
+ zip: zipPostCode,
+ country,
+ },
+ })}
+ />
+
+ );
+}
+
+GetPhysicalCardConfirm.defaultProps = defaultProps;
+GetPhysicalCardConfirm.displayName = 'GetPhysicalCardConfirm';
+GetPhysicalCardConfirm.propTypes = propTypes;
+
+export default withOnyx({
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
+})(GetPhysicalCardConfirm);
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardName.js b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js
new file mode 100644
index 000000000000..3a5399adad3a
--- /dev/null
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardName.js
@@ -0,0 +1,108 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import FormUtils from '@libs/FormUtils';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import BaseGetPhysicalCard from './BaseGetPhysicalCard';
+
+const propTypes = {
+ /* Onyx Props */
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ }),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** domain passed via route /settings/wallet/card/:domain */
+ domain: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ draftValues: {
+ legalFirstName: '',
+ legalLastName: '',
+ },
+};
+
+function GetPhysicalCardName({
+ draftValues: {legalFirstName, legalLastName},
+ route: {
+ params: {domain},
+ },
+}) {
+ const {translate} = useLocalize();
+ const onValidate = (values) => {
+ const errors = {};
+
+ if (!ValidationUtils.isValidLegalName(values.legalFirstName)) {
+ errors.legalFirstName = 'privatePersonalDetails.error.hasInvalidCharacter';
+ } else if (_.isEmpty(values.legalFirstName)) {
+ errors.legalFirstName = 'common.error.fieldRequired';
+ }
+
+ if (!ValidationUtils.isValidLegalName(values.legalLastName)) {
+ errors.legalLastName = 'privatePersonalDetails.error.hasInvalidCharacter';
+ } else if (_.isEmpty(values.legalLastName)) {
+ errors.legalLastName = 'common.error.fieldRequired';
+ }
+
+ return errors;
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+GetPhysicalCardName.defaultProps = defaultProps;
+GetPhysicalCardName.displayName = 'GetPhysicalCardName';
+GetPhysicalCardName.propTypes = propTypes;
+
+export default withOnyx({
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
+})(GetPhysicalCardName);
diff --git a/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
new file mode 100644
index 000000000000..9d9ae607438e
--- /dev/null
+++ b/src/pages/settings/Wallet/Card/GetPhysicalCardPhone.js
@@ -0,0 +1,90 @@
+import {parsePhoneNumber} from 'awesome-phonenumber';
+import Str from 'expensify-common/lib/str';
+import PropTypes from 'prop-types';
+import React from 'react';
+import {withOnyx} from 'react-native-onyx';
+import _ from 'underscore';
+import TextInput from '@components/TextInput';
+import useLocalize from '@hooks/useLocalize';
+import FormUtils from '@libs/FormUtils';
+import styles from '@styles/styles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import BaseGetPhysicalCard from './BaseGetPhysicalCard';
+
+const propTypes = {
+ /* Onyx Props */
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ phoneNumber: PropTypes.string,
+ }),
+
+ /** Route from navigation */
+ route: PropTypes.shape({
+ /** Params from the route */
+ params: PropTypes.shape({
+ /** domain passed via route /settings/wallet/card/:domain */
+ domain: PropTypes.string,
+ }),
+ }).isRequired,
+};
+
+const defaultProps = {
+ draftValues: {
+ phoneNumber: '',
+ },
+};
+
+function GetPhysicalCardPhone({
+ draftValues: {phoneNumber},
+ route: {
+ params: {domain},
+ },
+}) {
+ const {translate} = useLocalize();
+
+ const onValidate = (values) => {
+ const errors = {};
+
+ if (!(parsePhoneNumber(values.phoneNumber).possible && Str.isValidPhone(values.phoneNumber))) {
+ errors.phoneNumber = 'common.error.phoneNumber';
+ } else if (_.isEmpty(values.phoneNumber)) {
+ errors.phoneNumber = 'common.error.fieldRequired';
+ }
+
+ return errors;
+ };
+
+ return (
+
+
+
+ );
+}
+
+GetPhysicalCardPhone.defaultProps = defaultProps;
+GetPhysicalCardPhone.displayName = 'GetPhysicalCardPhone';
+GetPhysicalCardPhone.propTypes = propTypes;
+
+export default withOnyx({
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
+})(GetPhysicalCardPhone);
diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.js b/src/pages/settings/Wallet/ExpensifyCardPage.js
index 8f1be0425622..e92fca171817 100644
--- a/src/pages/settings/Wallet/ExpensifyCardPage.js
+++ b/src/pages/settings/Wallet/ExpensifyCardPage.js
@@ -15,6 +15,8 @@ import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import * as CardUtils from '@libs/CardUtils';
import * as CurrencyUtils from '@libs/CurrencyUtils';
+import FormUtils from '@libs/FormUtils';
+import * as GetPhysicalCardUtils from '@libs/GetPhysicalCardUtils';
import Navigation from '@libs/Navigation/Navigation';
import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import useTheme from '@styles/themes/useTheme';
@@ -32,6 +34,48 @@ const propTypes = {
/* Onyx Props */
/** The details about the Expensify cards */
cardList: PropTypes.objectOf(assignedCardPropTypes),
+ /** Draft values used by the get physical card form */
+ draftValues: PropTypes.shape({
+ addressLine1: PropTypes.string,
+ addressLine2: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ country: PropTypes.string,
+ zipPostCode: PropTypes.string,
+ phoneNumber: PropTypes.string,
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ }),
+ loginList: PropTypes.shape({
+ /** The partner creating the account. It depends on the source: website, mobile, integrations, ... */
+ partnerName: PropTypes.string,
+
+ /** Phone/Email associated with user */
+ partnerUserID: PropTypes.string,
+
+ /** The date when the login was validated, used to show the brickroad status */
+ validatedDate: PropTypes.string,
+
+ /** Field-specific server side errors keyed by microtime */
+ errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+
+ /** Field-specific pending states for offline UI status */
+ pendingFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
+ }),
+ /** User's private personal details */
+ privatePersonalDetails: PropTypes.shape({
+ legalFirstName: PropTypes.string,
+ legalLastName: PropTypes.string,
+ phoneNumber: PropTypes.string,
+ /** User's home address */
+ address: PropTypes.shape({
+ street: PropTypes.string,
+ city: PropTypes.string,
+ state: PropTypes.string,
+ zip: PropTypes.string,
+ country: PropTypes.string,
+ }),
+ }),
/** Navigation route context info provided by react navigation */
route: PropTypes.shape({
@@ -44,10 +88,37 @@ const propTypes = {
const defaultProps = {
cardList: {},
+ draftValues: {
+ addressLine1: '',
+ addressLine2: '',
+ city: '',
+ state: '',
+ country: '',
+ zipPostCode: '',
+ phoneNumber: '',
+ legalFirstName: '',
+ legalLastName: '',
+ },
+ loginList: {},
+ privatePersonalDetails: {
+ legalFirstName: '',
+ legalLastName: '',
+ phoneNumber: null,
+ address: {
+ street: '',
+ city: '',
+ state: '',
+ zip: '',
+ country: '',
+ },
+ },
};
function ExpensifyCardPage({
cardList,
+ draftValues,
+ loginList,
+ privatePersonalDetails,
route: {
params: {domain},
},
@@ -65,7 +136,7 @@ function ExpensifyCardPage({
const [cardDetailsError, setCardDetailsError] = useState('');
if (_.isEmpty(virtualCard) && _.isEmpty(physicalCard)) {
- return ;
+ return Navigation.goBack(ROUTES.SETTINGS_WALLET)} />;
}
const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(physicalCard.availableSpend || virtualCard.availableSpend || 0);
@@ -85,6 +156,12 @@ function ExpensifyCardPage({
.finally(() => setIsLoading(false));
};
+ const goToGetPhysicalCardFlow = () => {
+ const updatedDraftValues = GetPhysicalCardUtils.getUpdatedDraftValues(draftValues, privatePersonalDetails, loginList);
+
+ GetPhysicalCardUtils.goToNextPhysicalCardRoute(domain, GetPhysicalCardUtils.getUpdatedPrivatePersonalDetails(updatedDraftValues), loginList);
+ };
+
const hasDetectedDomainFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN);
const hasDetectedIndividualFraud = _.some(domainCards, (card) => card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL);
const cardDetailsErrorObject = cardDetailsError ? {error: cardDetailsError} : {};
@@ -211,6 +288,15 @@ function ExpensifyCardPage({
text={translate('activateCardPage.activatePhysicalCard')}
/>
)}
+ {physicalCard.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED && (
+
+ )}
>
)}
@@ -225,4 +311,13 @@ export default withOnyx({
cardList: {
key: ONYXKEYS.CARD_LIST,
},
+ loginList: {
+ key: ONYXKEYS.LOGIN_LIST,
+ },
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ draftValues: {
+ key: FormUtils.getDraftKey(ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM),
+ },
})(ExpensifyCardPage);
diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.js b/src/pages/settings/Wallet/WalletPage/WalletPage.js
index 23d4112eea21..a5d62b0ead07 100644
--- a/src/pages/settings/Wallet/WalletPage/WalletPage.js
+++ b/src/pages/settings/Wallet/WalletPage/WalletPage.js
@@ -449,6 +449,7 @@ function WalletPage({bankAccountList, betas, cardList, fundList, isLoadingPaymen
shouldShowAddPaymentMethodButton={false}
shouldShowAssignedCards
shouldShowEmptyListMessage={false}
+ shouldEnableScroll={false}
onPress={paymentMethodPressed}
style={styles.mt5}
isAddPaymentMenuActive={shouldShowAddPaymentMenu}
diff --git a/src/pages/signin/SignInHeroImage.js b/src/pages/signin/SignInHeroImage.js
index bfd076336af7..302e09a7afbc 100644
--- a/src/pages/signin/SignInHeroImage.js
+++ b/src/pages/signin/SignInHeroImage.js
@@ -1,3 +1,4 @@
+import PropTypes from 'prop-types';
import React from 'react';
import Lottie from '@components/Lottie';
import LottieAnimations from '@components/LottieAnimations';
@@ -7,12 +8,18 @@ import variables from '@styles/variables';
const propTypes = {
...windowDimensionsPropTypes,
+
+ shouldShowSmallScreen: PropTypes.bool,
+};
+
+const defaultProps = {
+ shouldShowSmallScreen: false,
};
function SignInHeroImage(props) {
const styles = useThemeStyles();
let imageSize;
- if (props.isSmallScreenWidth) {
+ if (props.isSmallScreenWidth || props.shouldShowSmallScreen) {
imageSize = {
height: variables.signInHeroImageMobileHeight,
width: variables.signInHeroImageMobileWidth,
@@ -42,5 +49,6 @@ function SignInHeroImage(props) {
SignInHeroImage.displayName = 'SignInHeroImage';
SignInHeroImage.propTypes = propTypes;
+SignInHeroImage.defaultProps = defaultProps;
export default withWindowDimensions(SignInHeroImage);
diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js
index 8d3b10ce606d..406625dbb8e9 100644
--- a/src/pages/signin/SignInPage.js
+++ b/src/pages/signin/SignInPage.js
@@ -234,14 +234,14 @@ function SignInPage({credentials, account, isInModal, activeClients, preferredLo
return (
// Bottom SafeAreaView is removed so that login screen svg displays correctly on mobile.
// The SVG should flow under the Home Indicator on iOS.
-
+
{/* LoginForm must use the isVisible prop. This keeps it mounted, but visually hidden
so that password managers can access the values. Conditionally rendering this component will break this feature. */}
diff --git a/src/pages/signin/SignInPageLayout/SignInPageContent.js b/src/pages/signin/SignInPageLayout/SignInPageContent.js
index a85e193a8356..d09cee9aa1c2 100755
--- a/src/pages/signin/SignInPageLayout/SignInPageContent.js
+++ b/src/pages/signin/SignInPageLayout/SignInPageContent.js
@@ -7,7 +7,7 @@ import OfflineIndicator from '@components/OfflineIndicator';
import SignInPageForm from '@components/SignInPageForm';
import Text from '@components/Text';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import compose from '@libs/compose';
import SignInHeroImage from '@pages/signin/SignInHeroImage';
import * as StyleUtils from '@styles/StyleUtils';
@@ -32,20 +32,24 @@ const propTypes = {
/** Whether to show welcome header on a particular page */
shouldShowWelcomeHeader: PropTypes.bool.isRequired,
+ /** Whether to show signIn hero image on a particular page */
+ shouldShowSmallScreen: PropTypes.bool.isRequired,
+
...withLocalizePropTypes,
- ...windowDimensionsPropTypes,
};
function SignInPageContent(props) {
+ const {isSmallScreenWidth} = useWindowDimensions();
const styles = useThemeStyles();
+
return (
{/* This empty view creates margin on the top of the sign in form which will shrink and grow depending on if the keyboard is open or not */}
-
+
-
+
@@ -56,7 +60,7 @@ function SignInPageContent(props) {
StyleUtils.getLineHeightStyle(variables.lineHeightSignInHeroXSmall),
StyleUtils.getFontSizeStyle(variables.fontSizeSignInHeroXSmall),
!props.welcomeText ? styles.mb5 : {},
- !props.isSmallScreenWidth ? styles.textAlignLeft : {},
+ !isSmallScreenWidth ? styles.textAlignLeft : {},
styles.mb5,
]}
>
@@ -64,7 +68,7 @@ function SignInPageContent(props) {
) : null}
{props.shouldShowWelcomeText && props.welcomeText ? (
- {props.welcomeText}
+ {props.welcomeText}
) : null}
{props.children}
@@ -72,9 +76,9 @@ function SignInPageContent(props) {
- {props.isSmallScreenWidth ? (
+ {props.shouldShowSmallScreen ? (
-
+
) : null}
@@ -86,4 +90,4 @@ function SignInPageContent(props) {
SignInPageContent.propTypes = propTypes;
SignInPageContent.displayName = 'SignInPageContent';
-export default compose(withWindowDimensions, withLocalize, withSafeAreaInsets)(SignInPageContent);
+export default compose(withLocalize, withSafeAreaInsets)(SignInPageContent);
diff --git a/src/pages/signin/SignInPageLayout/index.js b/src/pages/signin/SignInPageLayout/index.js
index a9b48acbd90b..1b5ac55c9da2 100644
--- a/src/pages/signin/SignInPageLayout/index.js
+++ b/src/pages/signin/SignInPageLayout/index.js
@@ -4,8 +4,8 @@ import {ScrollView, View} from 'react-native';
import {withSafeAreaInsets} from 'react-native-safe-area-context';
import SignInGradient from '@assets/images/home-fade-gradient.svg';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
import usePrevious from '@hooks/usePrevious';
+import useWindowDimensions from '@hooks/useWindowDimensions';
import compose from '@libs/compose';
import SignInPageHero from '@pages/signin/SignInPageHero';
import * as StyleUtils from '@styles/StyleUtils';
@@ -39,7 +39,7 @@ const propTypes = {
innerRef: PropTypes.oneOfType([PropTypes.func, PropTypes.object]),
/** Whether or not the sign in page is being rendered in the RHP modal */
- isInModal: PropTypes.bool,
+ shouldShowSmallScreen: PropTypes.bool,
/** Override the green headline copy */
customHeadline: PropTypes.string,
@@ -47,13 +47,12 @@ const propTypes = {
/** Override the smaller hero body copy below the headline */
customHeroBody: PropTypes.string,
- ...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
const defaultProps = {
innerRef: () => {},
- isInModal: false,
+ shouldShowSmallScreen: false,
customHeadline: '',
customHeroBody: '',
};
@@ -65,12 +64,12 @@ function SignInPageLayout(props) {
const prevPreferredLocale = usePrevious(props.preferredLocale);
let containerStyles = [styles.flex1, styles.signInPageInner];
let contentContainerStyles = [styles.flex1, styles.flexRow];
- const shouldShowSmallScreen = props.isSmallScreenWidth || props.isInModal;
+ const {windowHeight} = useWindowDimensions();
// To scroll on both mobile and web, we need to set the container height manually
- const containerHeight = props.windowHeight - props.insets.top - props.insets.bottom;
+ const containerHeight = windowHeight - props.insets.top - props.insets.bottom;
- if (shouldShowSmallScreen) {
+ if (props.shouldShowSmallScreen) {
containerStyles = [styles.flex1];
contentContainerStyles = [styles.flex1, styles.flexColumn];
}
@@ -96,7 +95,7 @@ function SignInPageLayout(props) {
return (
- {!shouldShowSmallScreen ? (
+ {!props.shouldShowSmallScreen ? (
{props.children}
@@ -167,6 +167,7 @@ function SignInPageLayout(props) {
welcomeText={props.welcomeText}
shouldShowWelcomeText={props.shouldShowWelcomeText}
shouldShowWelcomeHeader={props.shouldShowWelcomeHeader}
+ shouldShowSmallScreen={props.shouldShowSmallScreen}
>
{props.children}
@@ -197,4 +198,4 @@ const SignInPageLayoutWithRef = forwardRef((props, ref) => (
SignInPageLayoutWithRef.displayName = 'SignInPageLayoutWithRef';
-export default compose(withWindowDimensions, withSafeAreaInsets, withLocalize)(SignInPageLayoutWithRef);
+export default compose(withSafeAreaInsets, withLocalize)(SignInPageLayoutWithRef);
diff --git a/src/pages/workspace/WorkspaceNewRoomPage.js b/src/pages/workspace/WorkspaceNewRoomPage.js
index 2174c23bdf92..df78539bf665 100644
--- a/src/pages/workspace/WorkspaceNewRoomPage.js
+++ b/src/pages/workspace/WorkspaceNewRoomPage.js
@@ -23,7 +23,6 @@ import Permissions from '@libs/Permissions';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
-import policyMemberPropType from '@pages/policyMemberPropType';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import * as App from '@userActions/App';
@@ -61,9 +60,6 @@ const propTypes = {
}),
),
- /** A collection of objects for all policies which key policy member objects by accountIDs */
- allPolicyMembers: PropTypes.objectOf(PropTypes.objectOf(policyMemberPropType)),
-
/** Whether navigation is focused */
isFocused: PropTypes.bool.isRequired,
};
@@ -71,7 +67,6 @@ const defaultProps = {
betas: [],
reports: {},
policies: {},
- allPolicyMembers: {},
};
function WorkspaceNewRoomPage(props) {
@@ -95,8 +90,7 @@ function WorkspaceNewRoomPage(props) {
* @param {Object} values - form input values passed by the Form component
*/
const submit = (values) => {
- const policyMembers = _.map(_.keys(props.allPolicyMembers[`${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${values.policyID}`]), (accountID) => Number(accountID));
- Report.addPolicyReport(policyID, values.roomName, visibility, policyMembers, writeCapability, values.welcomeMessage);
+ Report.addPolicyReport(policyID, values.roomName, visibility, writeCapability, values.welcomeMessage);
};
useEffect(() => {
@@ -271,8 +265,5 @@ export default compose(
reports: {
key: ONYXKEYS.COLLECTION.REPORT,
},
- allPolicyMembers: {
- key: ONYXKEYS.COLLECTION.POLICY_MEMBERS,
- },
}),
)(WorkspaceNewRoomPage);
diff --git a/src/pages/workspace/WorkspaceSettingsPage.js b/src/pages/workspace/WorkspaceSettingsPage.js
index cc9505a4378f..b51146cde7f3 100644
--- a/src/pages/workspace/WorkspaceSettingsPage.js
+++ b/src/pages/workspace/WorkspaceSettingsPage.js
@@ -111,7 +111,6 @@ function WorkspaceSettingsPage({policy, currencyList, windowWidth, route}) {
enabledWhenOffline
>
(
diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts
index e93d88c3eb53..4b998f940244 100644
--- a/src/styles/StyleUtils.ts
+++ b/src/styles/StyleUtils.ts
@@ -1041,6 +1041,7 @@ function getAutoCompleteSuggestionContainerStyle(itemsHeight: number): ViewStyle
overflow: 'hidden',
top: -(height + CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTER_PADDING + borderWidth),
height,
+ minHeight: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT,
};
}
diff --git a/src/styles/animation/SpinningIndicatorAnimation.js b/src/styles/animation/SpinningIndicatorAnimation.js
deleted file mode 100644
index 1ae4b1518325..000000000000
--- a/src/styles/animation/SpinningIndicatorAnimation.js
+++ /dev/null
@@ -1,89 +0,0 @@
-import {Animated, Easing} from 'react-native';
-import useNativeDriver from '@libs/useNativeDriver';
-
-class SpinningIndicatorAnimation {
- constructor() {
- this.rotate = new Animated.Value(0);
- this.scale = new Animated.Value(1);
- this.startRotation = this.startRotation.bind(this);
- this.start = this.start.bind(this);
- this.stop = this.stop.bind(this);
- this.getSyncingStyles = this.getSyncingStyles.bind(this);
- }
-
- /**
- * Rotation animation for indicator in a loop
- *
- * @memberof AvatarWithImagePicker
- */
- startRotation() {
- this.rotate.setValue(0);
- Animated.loop(
- Animated.timing(this.rotate, {
- toValue: 1,
- duration: 2000,
- easing: Easing.linear,
- isInteraction: false,
-
- // Animated.loop does not work with `useNativeDriver: true` on Web
- useNativeDriver,
- }),
- ).start();
- }
-
- /**
- * Start Animation for Indicator
- *
- * @memberof AvatarWithImagePicker
- */
- start() {
- this.startRotation();
- Animated.spring(this.scale, {
- toValue: 1.666,
- tension: 1,
- isInteraction: false,
- useNativeDriver,
- }).start();
- }
-
- /**
- * Stop Animation for Indicator
- *
- * @memberof AvatarWithImagePicker
- */
- stop() {
- Animated.spring(this.scale, {
- toValue: 1,
- tension: 1,
- isInteraction: false,
- useNativeDriver,
- }).start(() => {
- this.rotate.resetAnimation();
- this.scale.resetAnimation();
- this.rotate.setValue(0);
- });
- }
-
- /**
- * Get Indicator Styles while animating
- *
- * @returns {Object}
- */
- getSyncingStyles() {
- return {
- transform: [
- {
- rotate: this.rotate.interpolate({
- inputRange: [0, 1],
- outputRange: ['0deg', '-360deg'],
- }),
- },
- {
- scale: this.scale,
- },
- ],
- };
- }
-}
-
-export default SpinningIndicatorAnimation;
diff --git a/src/styles/styles.ts b/src/styles/styles.ts
index efa346b55eb5..e597f0ec874e 100644
--- a/src/styles/styles.ts
+++ b/src/styles/styles.ts
@@ -1367,6 +1367,7 @@ const styles = (theme: ThemeColors) =>
},
sidebarListContainer: {
+ scrollbarWidth: 'none',
paddingBottom: 4,
},
@@ -1441,6 +1442,11 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
},
+ rightLabelMenuItem: {
+ fontSize: variables.fontSizeLabel,
+ color: theme.textSupporting,
+ },
+
popoverMenuText: {
fontSize: variables.fontSizeNormal,
color: theme.heading,
diff --git a/src/styles/utilities/sizing.ts b/src/styles/utilities/sizing.ts
index e68500beaf99..c8ec7352d463 100644
--- a/src/styles/utilities/sizing.ts
+++ b/src/styles/utilities/sizing.ts
@@ -38,6 +38,10 @@ export default {
minWidth: 120,
},
+ w40: {
+ width: '40%',
+ },
+
w50: {
width: '50%',
},
diff --git a/src/types/modules/pusher.d.ts b/src/types/modules/pusher.d.ts
index b54a0508c309..fb7bbaa97f79 100644
--- a/src/types/modules/pusher.d.ts
+++ b/src/types/modules/pusher.d.ts
@@ -5,4 +5,11 @@ declare global {
interface Window {
getPusherInstance: () => Pusher | null;
}
+
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
+ interface File {
+ source?: string;
+
+ uri?: string;
+ }
}
diff --git a/src/types/onyx/Request.ts b/src/types/onyx/Request.ts
index 836138ca99ba..746e7f75b3d5 100644
--- a/src/types/onyx/Request.ts
+++ b/src/types/onyx/Request.ts
@@ -7,14 +7,17 @@ type OnyxData = {
optimisticData?: OnyxUpdate[];
};
+type RequestType = 'get' | 'post';
+
type RequestData = {
command: string;
commandName?: string;
data?: Record;
- type?: string;
+ type?: RequestType;
shouldUseSecure?: boolean;
successData?: OnyxUpdate[];
failureData?: OnyxUpdate[];
+ idempotencyKey?: string;
resolve?: (value: Response) => void;
reject?: (value?: unknown) => void;
@@ -23,4 +26,4 @@ type RequestData = {
type Request = RequestData & OnyxData;
export default Request;
-export type {OnyxData};
+export type {OnyxData, RequestType};
diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts
index d36a875ea6de..66d5dcbdfd5b 100644
--- a/src/types/onyx/Response.ts
+++ b/src/types/onyx/Response.ts
@@ -1,5 +1,10 @@
import {OnyxUpdate} from 'react-native-onyx';
+type Data = {
+ phpCommandName: string;
+ authWriteCommands: string[];
+};
+
type Response = {
previousUpdateID?: number | string;
lastUpdateID?: number | string;
@@ -10,6 +15,9 @@ type Response = {
authToken?: string;
encryptedAuthToken?: string;
message?: string;
+ title?: string;
+ data?: Data;
+ type?: string;
shortLivedAuthToken?: string;
auth?: string;
// eslint-disable-next-line @typescript-eslint/naming-convention
diff --git a/src/types/utils/viewRef.ts b/src/types/utils/viewRef.ts
new file mode 100644
index 000000000000..015d88cc5a8b
--- /dev/null
+++ b/src/types/utils/viewRef.ts
@@ -0,0 +1,5 @@
+import {View} from 'react-native';
+
+const viewRef = (ref: React.RefObject) => ref as React.RefObject;
+
+export default viewRef;
diff --git a/tests/actions/IOUTest.js b/tests/actions/IOUTest.js
index 794c57ed31c3..944ec944648a 100644
--- a/tests/actions/IOUTest.js
+++ b/tests/actions/IOUTest.js
@@ -1478,7 +1478,7 @@ describe('actions/IOU', () => {
return waitForBatchedUpdates();
})
.then(() => {
- IOU.editMoneyRequest(transaction.transactionID, thread.reportID, {amount: 20000, comment: 'Double the amount!'});
+ IOU.editMoneyRequest(transaction, thread.reportID, {amount: 20000, comment: 'Double the amount!'});
return waitForBatchedUpdates();
})
.then(
@@ -1613,7 +1613,7 @@ describe('actions/IOU', () => {
})
.then(() => {
fetch.fail();
- IOU.editMoneyRequest(transaction.transactionID, thread.reportID, {amount: 20000, comment: 'Double the amount!'});
+ IOU.editMoneyRequest(transaction, thread.reportID, {amount: 20000, comment: 'Double the amount!'});
return waitForBatchedUpdates();
})
.then(
@@ -1757,7 +1757,7 @@ describe('actions/IOU', () => {
}),
]),
originalMessage: expect.objectContaining({
- amount,
+ amount: -amount,
paymentType: CONST.IOU.PAYMENT_TYPE.VBBA,
type: 'pay',
}),
diff --git a/tests/perf-test/ReportScreen.perf-test.js b/tests/perf-test/ReportScreen.perf-test.js
index d52659ccce11..21537a7a5db7 100644
--- a/tests/perf-test/ReportScreen.perf-test.js
+++ b/tests/perf-test/ReportScreen.perf-test.js
@@ -2,18 +2,18 @@ import {fireEvent, screen} from '@testing-library/react-native';
import React from 'react';
import Onyx from 'react-native-onyx';
import {measurePerformance} from 'reassure';
-import ComposeProviders from '@components/ComposeProviders';
-import DragAndDropProvider from '@components/DragAndDrop/Provider';
-import {LocaleContextProvider} from '@components/LocaleContextProvider';
-import OnyxProvider from '@components/OnyxProvider';
-import {CurrentReportIDContextProvider} from '@components/withCurrentReportID';
-import {KeyboardStateProvider} from '@components/withKeyboardState';
-import {WindowDimensionsProvider} from '@components/withWindowDimensions';
-import * as Localize from '@libs/Localize';
-import {ReportAttachmentsProvider} from '@pages/home/report/ReportAttachmentsContext';
-import ReportScreen from '@pages/home/ReportScreen';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
+import ComposeProviders from '../../src/components/ComposeProviders';
+import DragAndDropProvider from '../../src/components/DragAndDrop/Provider';
+import {LocaleContextProvider} from '../../src/components/LocaleContextProvider';
+import OnyxProvider from '../../src/components/OnyxProvider';
+import {CurrentReportIDContextProvider} from '../../src/components/withCurrentReportID';
+import {KeyboardStateProvider} from '../../src/components/withKeyboardState';
+import {WindowDimensionsProvider} from '../../src/components/withWindowDimensions';
+import CONST from '../../src/CONST';
+import * as Localize from '../../src/libs/Localize';
+import ONYXKEYS from '../../src/ONYXKEYS';
+import {ReportAttachmentsProvider} from '../../src/pages/home/report/ReportAttachmentsContext';
+import ReportScreen from '../../src/pages/home/ReportScreen';
import * as LHNTestUtils from '../utils/LHNTestUtils';
import PusherHelper from '../utils/PusherHelper';
import * as ReportTestUtils from '../utils/ReportTestUtils';
diff --git a/tests/perf-test/SidebarLinks.perf-test.js b/tests/perf-test/SidebarLinks.perf-test.js
index 5601c588bb93..f6819d40a48f 100644
--- a/tests/perf-test/SidebarLinks.perf-test.js
+++ b/tests/perf-test/SidebarLinks.perf-test.js
@@ -105,9 +105,9 @@ test('should scroll and click some of the items', () => {
expect(lhnOptionsList).toBeDefined();
fireEvent.scroll(lhnOptionsList, eventData);
- // find elements that are currently visible in the viewport
- const button1 = await screen.findByTestId('7');
- const button2 = await screen.findByTestId('8');
+
+ const button1 = await screen.findByTestId('1');
+ const button2 = await screen.findByTestId('2');
fireEvent.press(button1);
fireEvent.press(button2);
};
diff --git a/tests/unit/EmojiTest.js b/tests/unit/EmojiTest.js
index 4da29027de86..40474d0331fe 100644
--- a/tests/unit/EmojiTest.js
+++ b/tests/unit/EmojiTest.js
@@ -106,11 +106,41 @@ describe('EmojiTest', () => {
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄 ');
});
+ it('will add a space after the last emoji', () => {
+ const text = 'Hi :smile::wave:';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 ');
+ });
+
it('will add a space after the last emoji if there is text after it', () => {
const text = 'Hi :smile::wave:space after last emoji';
expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
});
+ it('will add a space after the last emoji if there is invalid emoji after it', () => {
+ const text = 'Hi :smile::wave:space when :invalidemoji: present';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space when :invalidemoji: present');
+ });
+
+ it('will not add a space after the last emoji if there if last emoji is immediately followed by a space', () => {
+ const text = 'Hi :smile::wave: space after last emoji';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'text')).toBe('Hi 😄👋 space after last emoji');
+ });
+
+ it('will return correct cursor position', () => {
+ const text = 'Hi :smile: there :wave:!';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(15);
+ });
+
+ it('will return correct cursor position when space is not added by space follows last emoji', () => {
+ const text = 'Hi :smile: there!';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(6);
+ });
+
+ it('will return undefined cursor position when no emoji is replaced', () => {
+ const text = 'Hi there!';
+ expect(lodashGet(EmojiUtils.replaceEmojis(text), 'cursorPosition')).toBe(undefined);
+ });
+
it('suggests emojis when typing emojis prefix after colon', () => {
const text = 'Hi :coffin';
expect(EmojiUtils.suggestEmojis(text, 'en')).toEqual([{code: '⚰️', name: 'coffin'}]);
diff --git a/tests/unit/PersistedRequestsTest.ts b/tests/unit/PersistedRequestsTest.ts
new file mode 100644
index 000000000000..79153efdc806
--- /dev/null
+++ b/tests/unit/PersistedRequestsTest.ts
@@ -0,0 +1,67 @@
+import * as PersistedRequests from '../../src/libs/actions/PersistedRequests';
+import Request from '../../src/types/onyx/Request';
+
+const request: Request = {
+ command: 'OpenReport',
+ data: {
+ idempotencyKey: 'OpenReport_1',
+ },
+ successData: [{key: 'reportMetadata_1', onyxMethod: 'merge', value: {}}],
+ failureData: [{key: 'reportMetadata_2', onyxMethod: 'merge', value: {}}],
+};
+
+beforeEach(() => {
+ PersistedRequests.clear();
+ PersistedRequests.save(request);
+});
+
+afterEach(() => {
+ PersistedRequests.clear();
+});
+
+describe('PersistedRequests', () => {
+ it('save a new request with an idempotency key which currently exists in the PersistedRequests array', () => {
+ PersistedRequests.save(request);
+ expect(PersistedRequests.getAll().length).toBe(1);
+ });
+
+ it('save a new request with a new idempotency key', () => {
+ const newRequest = {
+ command: 'OpenReport',
+ data: {
+ idempotencyKey: 'OpenReport_2',
+ },
+ };
+ PersistedRequests.save(newRequest);
+ expect(PersistedRequests.getAll().length).toBe(2);
+ });
+
+ it('replace a request existing in the PersistedRequests array with a new one', () => {
+ const newRequest: Request = {
+ command: 'OpenReport',
+ data: {
+ idempotencyKey: 'OpenReport_1',
+ },
+ successData: [{key: 'reportMetadata_3', onyxMethod: 'merge', value: {}}],
+ failureData: [{key: 'reportMetadata_4', onyxMethod: 'merge', value: {}}],
+ };
+
+ PersistedRequests.save(newRequest);
+
+ const persistedRequests = PersistedRequests.getAll();
+
+ expect(persistedRequests.length).toBe(1);
+
+ const mergedRequest = persistedRequests[0];
+
+ expect(mergedRequest.successData?.length).toBe(1);
+ expect(mergedRequest.failureData?.length).toBe(1);
+ expect(mergedRequest.successData?.[0]?.key).toBe('reportMetadata_3');
+ expect(mergedRequest.failureData?.[0]?.key).toBe('reportMetadata_4');
+ });
+
+ it('remove a request from the PersistedRequests array', () => {
+ PersistedRequests.remove(request);
+ expect(PersistedRequests.getAll().length).toBe(0);
+ });
+});