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 ![Expensify domain cards settings](https://help.expensify.com/assets/images/ExpensifyHelp_UnassignCard-1.png){: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 ( +
+ + + { + onAddressChanged(data, key); + // This enforces the country selector to use the country from address instead of the country from URL + Navigation.setParams({country: undefined}); + }} + defaultValue={street1 || ''} + renamedInputKeys={{ + street: 'addressLine1', + street2: 'addressLine2', + city: 'city', + state: 'state', + zipCode: 'zipPostCode', + country: 'country', + }} + maxInputLength={CONST.FORM_CHARACTER_LIMIT} + shouldSaveDraft={shouldSaveDraft} + /> + + + + + + + + + {isUSAForm ? ( + + + + ) : ( + + )} + + + + + + ); +} + +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 ? ( ) : ( -
- - - - - - - - - - - {isUSAForm ? ( - - - - ) : ( - - )} - - - - - + 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 = () => ({})) => ( +
+ {children} +
+ ), + 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 && ( +