diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml b/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml index 348c5fe89d3d..b228c694566c 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml @@ -4,9 +4,7 @@ inputs: GITHUB_TOKEN: description: Auth token for New Expensify Github required: true - NPM_VERSION: - description: The new NPM version of the StagingDeployCash issue. - required: false + runs: using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js index f81a181cb8d3..f0e45257bbef 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const format = require('date-fns/format'); const _ = require('underscore'); const core = require('@actions/core'); @@ -6,8 +7,8 @@ const GithubUtils = require('../../../libs/GithubUtils'); const GitUtils = require('../../../libs/GitUtils'); async function run() { - const newVersion = core.getInput('NPM_VERSION'); - console.log('New version found from action input:', newVersion); + // Note: require('package.json').version does not work because ncc will resolve that to a plain string at compile time + const newVersionTag = JSON.parse(fs.readFileSync('package.json')).version; try { // Start by fetching the list of recent StagingDeployCash issues, along with the list of open deploy blockers @@ -35,14 +36,12 @@ async function run() { const currentChecklistData = shouldCreateNewDeployChecklist ? {} : GithubUtils.getStagingDeployCashData(mostRecentChecklist); // Find the list of PRs merged between the current checklist and the previous checklist - // Note that any time we're creating a new checklist we MUST have `NPM_VERSION` passed in as an input - const newTag = newVersion || _.get(currentChecklistData, 'tag'); - const mergedPRs = await GitUtils.getPullRequestsMergedBetween(previousChecklistData.tag, newTag); + const mergedPRs = await GitUtils.getPullRequestsMergedBetween(previousChecklistData.tag, newVersionTag); // Next, we generate the checklist body let checklistBody = ''; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -94,9 +93,9 @@ async function run() { }); } - const didVersionChange = newVersion ? newVersion !== currentChecklistData.tag : false; + const didVersionChange = newVersionTag !== currentChecklistData.tag; checklistBody = await GithubUtils.generateStagingDeployCashBody( - newTag, + newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), _.pluck(deployBlockers, 'url'), diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index da8db977d114..bf8214759c12 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -7,6 +7,7 @@ /***/ 3926: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { +const fs = __nccwpck_require__(7147); const format = __nccwpck_require__(2168); const _ = __nccwpck_require__(5067); const core = __nccwpck_require__(2186); @@ -15,8 +16,8 @@ const GithubUtils = __nccwpck_require__(7999); const GitUtils = __nccwpck_require__(669); async function run() { - const newVersion = core.getInput('NPM_VERSION'); - console.log('New version found from action input:', newVersion); + // Note: require('package.json').version does not work because ncc will resolve that to a plain string at compile time + const newVersionTag = JSON.parse(fs.readFileSync('package.json')).version; try { // Start by fetching the list of recent StagingDeployCash issues, along with the list of open deploy blockers @@ -44,14 +45,12 @@ async function run() { const currentChecklistData = shouldCreateNewDeployChecklist ? {} : GithubUtils.getStagingDeployCashData(mostRecentChecklist); // Find the list of PRs merged between the current checklist and the previous checklist - // Note that any time we're creating a new checklist we MUST have `NPM_VERSION` passed in as an input - const newTag = newVersion || _.get(currentChecklistData, 'tag'); - const mergedPRs = await GitUtils.getPullRequestsMergedBetween(previousChecklistData.tag, newTag); + const mergedPRs = await GitUtils.getPullRequestsMergedBetween(previousChecklistData.tag, newVersionTag); // Next, we generate the checklist body let checklistBody = ''; if (shouldCreateNewDeployChecklist) { - checklistBody = await GithubUtils.generateStagingDeployCashBody(newTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); + checklistBody = await GithubUtils.generateStagingDeployCashBody(newVersionTag, _.map(mergedPRs, GithubUtils.getPullRequestURLFromNumber)); } else { // Generate the updated PR list, preserving the previous state of `isVerified` for existing PRs const PRList = _.reduce( @@ -103,9 +102,9 @@ async function run() { }); } - const didVersionChange = newVersion ? newVersion !== currentChecklistData.tag : false; + const didVersionChange = newVersionTag !== currentChecklistData.tag; checklistBody = await GithubUtils.generateStagingDeployCashBody( - newTag, + newVersionTag, _.pluck(PRList, 'url'), _.pluck(_.where(PRList, {isVerified: true}), 'url'), _.pluck(deployBlockers, 'url'), @@ -191,9 +190,9 @@ const {getPreviousVersion, SEMANTIC_VERSION_LEVELS} = __nccwpck_require__(8007); /** * @param {String} tag + * @param {String} [shallowExcludeTag] when fetching the given tag, exclude all history reachable by the shallowExcludeTag (used to make fetch much faster) */ -function fetchTag(tag) { - const previousPatchVersion = getPreviousVersion(tag, SEMANTIC_VERSION_LEVELS.PATCH); +function fetchTag(tag, shallowExcludeTag = '') { let shouldRetry = true; let needsRepack = false; while (shouldRetry) { @@ -209,11 +208,9 @@ function fetchTag(tag) { command = `git fetch origin tag ${tag} --no-tags`; - // Exclude commits reachable from the previous patch version (i.e: previous checklist), - // so that we don't have to fetch the full history - // Note that this condition would only ever _not_ be true in the 1.0.0-0 edge case - if (previousPatchVersion !== tag) { - command += ` --shallow-exclude=${previousPatchVersion}`; + // Note that this condition is only ever NOT true in the 1.0.0-0 edge case + if (shallowExcludeTag && shallowExcludeTag !== tag) { + command += ` --shallow-exclude=${shallowExcludeTag}`; } console.log(`Running command: ${command}`); @@ -240,8 +237,10 @@ function fetchTag(tag) { * @returns {Promise>>} */ function getCommitHistoryAsJSON(fromTag, toTag) { - fetchTag(fromTag); - fetchTag(toTag); + // Fetch tags, exclude commits reachable from the previous patch version (i.e: previous checklist), so that we don't have to fetch the full history + const previousPatchVersion = getPreviousVersion(fromTag, SEMANTIC_VERSION_LEVELS.PATCH); + fetchTag(fromTag, previousPatchVersion); + fetchTag(toTag, previousPatchVersion); console.log('Getting pull requests merged between the following tags:', fromTag, toTag); return new Promise((resolve, reject) => { diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index af691cfb6d1d..974824ac4628 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -133,9 +133,9 @@ const {getPreviousVersion, SEMANTIC_VERSION_LEVELS} = __nccwpck_require__(8007); /** * @param {String} tag + * @param {String} [shallowExcludeTag] when fetching the given tag, exclude all history reachable by the shallowExcludeTag (used to make fetch much faster) */ -function fetchTag(tag) { - const previousPatchVersion = getPreviousVersion(tag, SEMANTIC_VERSION_LEVELS.PATCH); +function fetchTag(tag, shallowExcludeTag = '') { let shouldRetry = true; let needsRepack = false; while (shouldRetry) { @@ -151,11 +151,9 @@ function fetchTag(tag) { command = `git fetch origin tag ${tag} --no-tags`; - // Exclude commits reachable from the previous patch version (i.e: previous checklist), - // so that we don't have to fetch the full history - // Note that this condition would only ever _not_ be true in the 1.0.0-0 edge case - if (previousPatchVersion !== tag) { - command += ` --shallow-exclude=${previousPatchVersion}`; + // Note that this condition is only ever NOT true in the 1.0.0-0 edge case + if (shallowExcludeTag && shallowExcludeTag !== tag) { + command += ` --shallow-exclude=${shallowExcludeTag}`; } console.log(`Running command: ${command}`); @@ -182,8 +180,10 @@ function fetchTag(tag) { * @returns {Promise>>} */ function getCommitHistoryAsJSON(fromTag, toTag) { - fetchTag(fromTag); - fetchTag(toTag); + // Fetch tags, exclude commits reachable from the previous patch version (i.e: previous checklist), so that we don't have to fetch the full history + const previousPatchVersion = getPreviousVersion(fromTag, SEMANTIC_VERSION_LEVELS.PATCH); + fetchTag(fromTag, previousPatchVersion); + fetchTag(toTag, previousPatchVersion); console.log('Getting pull requests merged between the following tags:', fromTag, toTag); return new Promise((resolve, reject) => { diff --git a/.github/libs/GitUtils.js b/.github/libs/GitUtils.js index fa2cf430b277..2076763fbb55 100644 --- a/.github/libs/GitUtils.js +++ b/.github/libs/GitUtils.js @@ -6,9 +6,9 @@ const {getPreviousVersion, SEMANTIC_VERSION_LEVELS} = require('../libs/versionUp /** * @param {String} tag + * @param {String} [shallowExcludeTag] when fetching the given tag, exclude all history reachable by the shallowExcludeTag (used to make fetch much faster) */ -function fetchTag(tag) { - const previousPatchVersion = getPreviousVersion(tag, SEMANTIC_VERSION_LEVELS.PATCH); +function fetchTag(tag, shallowExcludeTag = '') { let shouldRetry = true; let needsRepack = false; while (shouldRetry) { @@ -24,11 +24,9 @@ function fetchTag(tag) { command = `git fetch origin tag ${tag} --no-tags`; - // Exclude commits reachable from the previous patch version (i.e: previous checklist), - // so that we don't have to fetch the full history - // Note that this condition would only ever _not_ be true in the 1.0.0-0 edge case - if (previousPatchVersion !== tag) { - command += ` --shallow-exclude=${previousPatchVersion}`; + // Note that this condition is only ever NOT true in the 1.0.0-0 edge case + if (shallowExcludeTag && shallowExcludeTag !== tag) { + command += ` --shallow-exclude=${shallowExcludeTag}`; } console.log(`Running command: ${command}`); @@ -55,8 +53,10 @@ function fetchTag(tag) { * @returns {Promise>>} */ function getCommitHistoryAsJSON(fromTag, toTag) { - fetchTag(fromTag); - fetchTag(toTag); + // Fetch tags, exclude commits reachable from the previous patch version (i.e: previous checklist), so that we don't have to fetch the full history + const previousPatchVersion = getPreviousVersion(fromTag, SEMANTIC_VERSION_LEVELS.PATCH); + fetchTag(fromTag, previousPatchVersion); + fetchTag(toTag, previousPatchVersion); console.log('Getting pull requests merged between the following tags:', fromTag, toTag); return new Promise((resolve, reject) => { 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/authorChecklist.yml b/.github/workflows/authorChecklist.yml index ecb0b87a6416..907b1e7be6ca 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -13,7 +13,8 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - name: authorChecklist.js uses: ./.github/actions/javascript/authorChecklist 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 new file mode 100644 index 000000000000..9a1cac41ed69 --- /dev/null +++ b/.github/workflows/createDeployChecklist.yml @@ -0,0 +1,20 @@ +name: Create or update deploy checklist + +on: + workflow_call: + workflow_dispatch: + +jobs: + createChecklist: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Create or update deploy checklist + uses: ./.github/actions/javascript/createOrUpdateStagingDeploy + with: + 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..63148f9e4eb5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -14,8 +14,9 @@ jobs: with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + + - name: Setup git for OSBotify + uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -38,7 +39,8 @@ jobs: ref: production token: ${{ secrets.OS_BOTIFY_TOKEN }} - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + - name: Setup git for OSBotify + uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -50,7 +52,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 +60,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 355d001622f7..cb5dc6d28b32 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -6,30 +6,29 @@ on: - labeled jobs: + updateChecklist: + if: github.event.label.name == 'DeployBlockerCash' + uses: ./.github/workflows/createDeployChecklist.yml + deployBlocker: + if: github.event.label.name == 'DeployBlockerCash' runs-on: ubuntu-latest - if: ${{ github.event.label.name == 'DeployBlockerCash' }} - steps: - name: Checkout uses: actions/checkout@v4 - - uses: ./.github/actions/composite/setupGitForOSBotifyApp - id: setupGitForOSBotify - 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: Update StagingDeployCash with new deploy blocker - uses: ./.github/actions/javascript/createOrUpdateStagingDeploy - with: - GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} - - - run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' + - name: Give the issue/PR the Hourly, Engineering labels + run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' env: GITHUB_TOKEN: ${{ github.token }} + - name: Escape html characters in GH issue title + env: + GH_ISSUE_TITLE: ${{ github.event.issue.title }} + run: | + escaped_title=$(echo "$GH_ISSUE_TITLE" | sed -e 's/&/\&/g; s//\>/g; s/"/\"/g; s/'"'"'/\'/g; s/|/\|/g') + echo "GH_ISSUE_TITLE=$escaped_title" >> "$GITHUB_ENV" + - name: 'Post the issue in the #expensify-open-source slack room' if: ${{ success() }} uses: 8398a7/action-slack@v3 @@ -40,7 +39,7 @@ jobs: channel: '#expensify-open-source', attachments: [{ color: "#DB4545", - text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ 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 }}|${{ env.GH_ISSUE_TITLE }}>' }] } env: @@ -51,9 +50,11 @@ jobs: run: | gh issue comment ${{ github.event.issue.number }} --body "$(cat <<'EOF' :wave: Friendly reminder that deploy blockers are time-sensitive ⏱ issues! [Check out the open \`StagingDeployCash\` deploy checklist](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3AStagingDeployCash) to see the list of PRs included in this release, then work quickly to do one of the following: + 1. Identify the pull request that introduced this issue and revert it. 2. Find someone who can quickly fix the issue. 3. Fix the issue yourself. + EOF )" 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..2285eec56065 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -18,7 +18,8 @@ jobs: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + - name: Setup git for OSBotify + uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -38,7 +39,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 +50,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 +67,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 }} @@ -82,9 +83,9 @@ jobs: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - - name: Setup Git for OSBotify + - 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 +101,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 +109,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 @@ -124,8 +125,8 @@ jobs: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} - - name: Setup Git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + - name: Setup git for OSBotify + uses: ./.github/actions/composite/setupGitForOSBotifyApp with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} @@ -141,6 +142,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 19c5cf9c90ef..291bd80816b9 100644 --- a/.github/workflows/platformDeploy.yml +++ b/.github/workflows/platformDeploy.yml @@ -36,24 +36,10 @@ jobs: # Note: we're updating the checklist before running the deploys and assuming that it will succeed on at least one platform deployChecklist: name: Create or update deploy checklist - runs-on: ubuntu-latest + uses: ./.github/workflows/createDeployChecklist.yml if: ${{ github.event_name != 'release' }} needs: validateActor - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: Setup Node - uses: Expensify/App/.github/actions/composite/setupNode@main - - - name: Set version - id: getVersion - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_OUTPUT" - - - name: Create or update staging deploy - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} - NPM_VERSION: ${{ steps.getVersion.outputs.VERSION }} + secrets: inherit android: name: Build and deploy Android @@ -68,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 @@ -149,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 @@ -191,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 @@ -300,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 }} @@ -356,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 }} @@ -431,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/testGithubActionsWorkflows.yml b/.github/workflows/testGithubActionsWorkflows.yml new file mode 100644 index 000000000000..58de3ba2d9f3 --- /dev/null +++ b/.github/workflows/testGithubActionsWorkflows.yml @@ -0,0 +1,35 @@ +name: Test GitHub Actions workflows + +on: + workflow_dispatch: + workflow_call: + pull_request: + types: [opened, reopened, edited, synchronize] + branches-ignore: [staging, production] + paths: ['.github/**'] + +jobs: + testGHWorkflows: + if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' || github.event_name == 'workflow_call' }} + runs-on: ubuntu-latest + env: + CI: true + name: test GitHub Workflows + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup Node + uses: Expensify/App/.github/actions/composite/setupNode@main + + - name: Setup Homebrew + uses: Homebrew/actions/setup-homebrew@master + + - name: Install Act + run: brew install act + + - name: Set ACT_BINARY + run: echo "ACT_BINARY=$(which act)" >> "$GITHUB_ENV" + + - name: Run tests + run: npm run workflow-test diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index c09db594e243..0951b194430b 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 @@ -24,8 +24,16 @@ jobs: - name: Check for new JavaScript files run: | git fetch origin main --no-tags --depth=1 - count_new_js=$(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js' | wc -l) + + # Explanation: + # - comm is used to get the intersection between two bash arrays + # - git diff is used to see the files that were added on this branch + # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main + # - wc counts the words in the result of the intersection + count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l) if [ "$count_new_js" -gt "0" ]; then echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead." exit 1 fi + env: + GITHUB_TOKEN: ${{ github.token }} 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/.gitignore b/.gitignore index 335efdc5586a..3ed0ef54523a 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,7 @@ tsconfig.tsbuildinfo # Workflow test logs /workflow_tests/logs/ +/workflow_tests/repo/ # Yalc .yalc diff --git a/Gemfile.lock b/Gemfile.lock index 079b5a5b742b..93dab195ebdd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -18,21 +18,21 @@ GEM rubyzip (~> 2.0) artifactory (3.0.15) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.824.0) - aws-sdk-core (3.181.1) + aws-eventstream (1.3.0) + aws-partitions (1.857.0) + aws-sdk-core (3.188.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.71.0) - aws-sdk-core (~> 3, >= 3.177.0) + aws-sdk-kms (1.73.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.134.0) - aws-sdk-core (~> 3, >= 3.181.0) + aws-sdk-s3 (1.140.0) + aws-sdk-core (~> 3, >= 3.188.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.0) + aws-sigv4 (1.7.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) claide (1.1.0) @@ -81,14 +81,13 @@ GEM declarative (0.0.20) digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20231109) dotenv (2.8.1) emoji_regex (3.2.3) escape (0.0.4) ethon (0.16.0) ffi (>= 1.15.0) - excon (0.103.0) + excon (0.104.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -118,7 +117,7 @@ GEM faraday_middleware (1.2.0) faraday (~> 1.0) fastimage (2.2.7) - fastlane (2.215.1) + fastlane (2.217.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -166,9 +165,9 @@ GEM fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.49.0) + google-apis-androidpublisher_v3 (0.53.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.1) + google-apis-core (0.11.2) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -181,23 +180,23 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) + google-apis-storage_v1 (0.29.0) + google-apis-core (>= 0.11.0, < 2.a) google-cloud-core (1.6.0) google-cloud-env (~> 1.0) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-storage (1.45.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-storage_v1 (~> 0.29.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.8.0) + googleauth (1.8.1) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) multi_json (~> 1.11) @@ -229,7 +228,7 @@ GEM os (1.1.4) plist (3.7.0) public_suffix (4.0.7) - rake (13.0.6) + rake (13.1.0) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) @@ -262,13 +261,10 @@ GEM tzinfo (2.0.6) concurrent-ruby (~> 1.0) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (2.4.2) + unicode-display_width (2.5.0) webrick (1.8.1) word_wrap (1.0.0) - xcodeproj (1.22.0) + xcodeproj (1.23.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) diff --git a/android/app/build.gradle b/android/app/build.gradle index 274a0d55eb37..301a76c353e2 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -91,8 +91,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001040003 - versionName "1.4.0-3" + versionCode 1001040403 + versionName "1.4.4-3" } 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/empty-state_background-fade-dark.png b/assets/images/empty-state_background-fade-dark.png new file mode 100644 index 000000000000..1caf5630bee3 Binary files /dev/null and b/assets/images/empty-state_background-fade-dark.png differ diff --git a/assets/images/empty-state_background-fade-light.png b/assets/images/empty-state_background-fade-light.png new file mode 100644 index 000000000000..98456609b502 Binary files /dev/null and b/assets/images/empty-state_background-fade-light.png differ diff --git a/assets/images/empty-state_background-fade.png b/assets/images/empty-state_background-fade.png deleted file mode 100644 index 816ff7343310..000000000000 Binary files a/assets/images/empty-state_background-fade.png and /dev/null differ 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/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md index d7a94c2a4337..32d3919efbe4 100644 --- a/contributingGuides/NAVIGATION.md +++ b/contributingGuides/NAVIGATION.md @@ -40,9 +40,22 @@ When creating RHP flows, you have to remember a couple things: An example of adding `Settings_Workspaces` page: -1. Add path to `ROUTES.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/ROUTES.js#L36 +1. Add path to `ROUTES.ts`: https://github.com/Expensify/App/blob/main/src/ROUTES.ts + +```ts +export const ROUTES = { + // static route + SETTINGS_WORKSPACES: 'settings/workspaces', + // dynamic route + SETTINGS_WORKSPACES: { + route: 'settings/:accountID', + getRoute: (accountID: number) => `settings/${accountID}` as const, + }, +}; + +``` -2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.js`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42 +2. Add `Settings_Workspaces` page to proper RHP flow in `linkingConfig.ts`: https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/linkingConfig.js#L40-L42 3. Add your page to proper navigator (it should be aligned with where you've put it in the previous step) https://github.com/Expensify/App/blob/3531af22dcadaa94ed11eccf370517dca0b8c305/src/libs/Navigation/AppNavigator/ModalStackNavigators.js#L334-L338 diff --git a/docs/articles/expensify-classic/account-settings/Close-Account.md b/docs/articles/expensify-classic/account-settings/Close-Account.md index 5e18490fc357..c25c22de9704 100644 --- a/docs/articles/expensify-classic/account-settings/Close-Account.md +++ b/docs/articles/expensify-classic/account-settings/Close-Account.md @@ -1,5 +1,123 @@ --- -title: Close Account +title: Close Account description: Close Account ---- -## Resource Coming Soon! +--- +# Overview + +Here is a walk through of how to close an Expensify account through the website or mobile app. + +# How to close your account +On the Web: + +1. Go to **Settings** in the left hand menu. Click **Account**. +2. Click “Close Account” +3. Follow the prompts to verify your email or phone number. +4. Your account will be closed, and all data will be deleted.* + +![Close Account via Website]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Desktop.png){:width="100%"} + +On the Mobile App: + +Open the app and tap the three horizontal lines in the upper left corner. +Select Settings from the menu. +Look for the "Close Account" option in the "Others" section. (If you don’t see this option, you have a Domain Controlled account and will need to ask your Domain Admin to delete your account.) +Complete the verification process using your email or phone number. +Your account will be closed, and all data will be deleted.* + +![Close Account on Mobile Application]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Mobile.png){:width="100%"} + +These instructions may vary depending on the specific device you are using, so be sure to follow the steps as they appear on your screen. + +*Note: Transactions shared with other accounts will still be visible to those accounts. (Example: A report submitted to your company and reimbursed will not be deleted from your company’s account.) Additionally, we are required to retain certain records of transactions in compliance with laws in various jurisdictions. + +# How to reopen your account + +If your Expensify account is closed and not associated with a verified domain, you can reopen it with the following steps: + +1. Visit [expensify.com](https://www.expensify.com/). +2. Attempt to sign in using your email address or phone number associated with the closed account. +3. After entering your user name, you will see a prompt to reopen your account. +4. Click on **Reopen Account**. +5. A magic link will be sent to your email address. +6. Access your email and click on the magic link. This link will take you to Expensify and reopen your account. +7. Follow the prompts to create a new password associated with your account. +8. Your account is now reopened. Any previously approved expense data will still be visible in the account. + +Note: Reports submitted and closed on an Individual workspace will not be retained since they have not been approved or shared with anyone else in Expensify. + +That's it! Your account is successfully reopened, and you can access your historical data that was shared with other accounts. Remember to recreate any workspaces and adjust settings if needed. + +# How to Reopen a Domain-controlled account +Once an account has been **Closed** by a Domain Admin, it can be reopened by any Domain Admin on that domain. + +The Domain Admin will simply need to invite the previously closed account in the same manner that new accounts are invited to the Domain. The user will receive a magic link to their email account which they can use to Reopen the account. + +# How to retain a free account to keep historical expenses +If you no longer need a group workspace or have a more advanced workspace than necessary in Expensify, and you want to downgrade while retaining your historical data, here's what you should do: + +1. If you're part of a group workspace, request the Workspace Admin to remove you, or if you own the workspace, delete it to downgrade to a free version. +2. Once you've removed or been removed from a workspace, start using your free Expensify account. Your submitted expenses will still be saved, allowing you to access the historical data. +3. Domain Admins in the company will still retain access to approved and reimbursed expenses. +4. To keep your data, avoid closing your account. Account closures are irreversible and will result in the deletion of all your unapproved data. + +# Deep Dive + +## I’m unable to close my account + +If you're encountering an error message while trying to close your Expensify account, it's important to pinpoint the specific error. Encountering an error when trying to close your account is typically only experienced if the account has been an admin on a company’s Expensify workspace. (Especially if the account was involved in responsibilities like reimbursing employees or exporting expense reports.) + +In order to avoid users accidentally creating issues for their company, Expensify prevents account closure if you still have any individual responsibilities related to a Workspace within the platform. To successfully close your account, you need to ensure that your workspace no longer relies on your account to operate. + +Here are the reasons why you might encounter an error when trying to close your Expensify account, along with the actions required to address each of these issues: + +- **Account Under a Validated Domain**: If your account is associated with a validated domain, only a Domain Admin can close it. You can find this option in your Domain Admin's settings under Settings > Domains > Domain Members. Afterward, if you have a secondary personal login, you can delete it by following the instructions mentioned above. +- **Sole Domain Admin for Your Company**: If you are the only Domain Admin for your company's domain, you must appoint another Domain Admin before you can close your account. This is to avoid accidentally prohibiting your entire company from using Expensify. You can do this by going to Settings > Domains > [Domain Name] > Domain Admins and making the necessary changes, or you can reset the entire domain. +- **Workspace Billing Owner with an Annual Subscription**: If you are the Workspace Billing Owner with an Annual Subscription, you need to downgrade from the Annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces. +- **Ownership of a Company Workspace or Outstanding Balance**: If you own a company workspace or there is an outstanding balance owed to Expensify, you must take one of the following actions before closing your account: + + - Make the payment for the outstanding balance. + - Have another user take over billing for the workspace. + - Request a refund for your initial bill. + - Delete the workspace. + +- **Preferred Exporter for Your Workspace Integration**: If you are the "Preferred Exporter" for a workspace Integration, you must update the Preferred Exporter before closing your account. You can do this by navigating to **Settings** > **Workspaces** > **Group** > [Workspace name] > **Connections** > **Configure** and selecting any Workspace Admin from the dropdown menu as the Preferred Exporter. +- **Verified Business Account with Outstanding Balance or Locked Status**: If you have a Verified Business Account with an outstanding balance or if the account is locked, you should wait for all payments to settle or unlock the account. To settle the amount owed, go to **Settings** > **Account** > **Payments** > **Bank Accounts** and take the necessary steps. + +## Validate the account to close it + +Sometimes, you may find yourself with an Expensify account that you don't need. This could be due to various reasons like a company inviting you for reimbursement, a vendor inviting you to pay, or a mistaken sign-up. + +In such cases, you have two options: + +**Option 1**: Retain the Account for Future Use +You can keep the Expensify account just in case you need it later. + +**Option 2**: Close the Account + +Start by verifying your email or phone number + +Before closing the account, you need to verify that you have access to the email or phone number associated with it. This ensures that only you can close the account. + +Here's how to do it: + +1. Go to [www.expensify.com](http://www.expensify.com/). +2. Enter your email address or phone number (whichever is associated with the unwanted account). +3. Click the **Resend Link** button. +4. Check your Home Page for the most recent email with the subject line "Please validate your Expensify login." Click the link provided in the email to validate your email address. + - If it's an account linked to a phone number, tap the link sent to your phone. +5. After clicking the validation link, you'll be directed to an Expensify Home Page. +6. Navigate to **Settings** > **Account** > **Account Details** > **Close Account**. +7. Click the **Close My Account** button. + - Re-enter the email address or phone number of the account when prompted. + - Check the box that says, "I understand all of my unsubmitted expense data will be deleted." + - Click the **Close My Account** button. + +By following these steps, you can easily verify your email or phone number and close an unwanted Expensify account. + +# FAQ + +## What should I do if I'm not directed to my account when clicking the validate option from my phone or email? +It's possible your browser has blocked this, either because of some existing cache or extension. In this case, you should follow the Reset Password flow to reset the password and manually gain access with the new password, along with your email address. + +## Why don't I see the Close Account option? +It's possible your account is on a managed company domain. In this case, only the admins from that company can close it. 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/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md index 3ee1c8656b4b..9d17160d3a36 100644 --- a/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md +++ b/docs/articles/expensify-classic/integrations/accounting-integrations/QuickBooks-Online.md @@ -1,5 +1,308 @@ --- -title: Coming Soon -description: Coming Soon +title: QuickBooks Online +description: Everything you need to know about using Expensify's direct integration with QuickBooks Online. --- -## Resource Coming Soon! +# Overview + +The Expensify integration with QuickBooks Online brings in your expense accounts and other data and even exports reports directly to QuickBooks for easy reconciliation. Plus, with advanced features in QuickBooks Online, you can fine-tune coding settings in Expensify for automated data export to optimize your accounting workflow. + +## Before connecting + +It's crucial to understand the requirements based on your specific QuickBooks subscription: + +- While all the features are available in Expensify, their accessibility may vary depending on your QuickBooks Online subscription. +- An error will occur if you try to export to QuickBooks with a feature enabled that isn't part of your subscription. +- Please be aware that Expensify does not support the Self-Employed subscription in QuickBooks Online. + +# How to connect to QuickBooks Online + +## Step 1: Setup employees in QuickBooks Online + +Employees must be set up as either Vendors or Employees in QuickBooks Online. Make sure to include the submitter's email in their record. + +If you use vendor records, you can export as Vendor Bills, Checks, or Journal Entries. If you use employee records, you can export as Checks or Journal Entries (if exporting against a liability account). + +Additional Options for Streamlined Setup: + +- Automatic Vendor Creation: Enable “Automatically Create Entities” in your connection settings to automatically generate Vendor or Employee records upon export for submitters that don't already exist in QBO. +- Employee Setup Considerations: If setting up submitters as Employees, ensure you activate QuickBooks Online Payroll. This will grant access to the Employee Profile tab to input employee email addresses. + +## Step 2: Connect Expensify and QuickBooks Online + +- Navigate to Settings > Workspaces > Group > [Workspace Name] > Connections > QuickBooks Online. Click Connect to QuickBooks. +- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace). Then click Authorize. +- Enter your QuickBooks Online Administrator’s login information and choose the QuickBooks Online Company File you want to connect to Expensify (you can connect one Company File per Workspace): + +## Exporting historical Reports to QuickBooks Online: + +After connecting QuickBooks Online to Expensify, you may receive a prompt to export all historical reports from Expensify. To export multiple reports at once, follow these steps: + +- Go to the Reports page on the web. +- Tick the checkbox next to the reports you want to export. +- Click 'Export To' and select 'QuickBooks Online' from the drop-down list. + +If you don't want to export specific reports, click “Mark as manually entered” on the report. + +# How to configure export settings for QuickBooks Online + +Our QuickBooks Online integration offers a range of features. This section will focus on Export Settings and how to set them up. + +## Preferred Exporter + +Any Workspace admin can export to your accounting integration, but the Preferred Exporter can be chosen to automate specific steps. You can set this role from Settings > Workspaces > Group > [Workspace Name] > Connections > Configure > Export > Preferred Exporter. + +The Preferred Exporter: + +- Is the user whose Concierge performs all automated exports on behalf of. +- Is the only user who will see reports awaiting export in their **Home.** +- Must be a **Domain Admin** if you have set individual GL accounts for Company Card export. +- Must be a **Domain Admin** if this is the Preferred Workspace for any Expensify Card domain using Automatic Reconciliation. + +## Date + +When exporting reports to QuickBooks Online, you can choose the report's **submitted date**, the report's **exported date**, or the **date of the last expense on the report.** + +Most export options (Check, Journal Entry, and Vendor Bill) will create a single itemized entry with one date. +Please note that if you choose a Credit Card or Debit Card for non-reimbursable expenses, we'll use the transaction date on each expense during export. + +# Reimbursable expenses + +Reimbursable expenses export to QuickBooks Online as: + +- Vendor Bills +- Checks +- Journal Entries + +## Vendor bill (recommended) + +This is a single itemized vendor bill for each Expensify report. If the accounting period is closed, we will post the vendor bill on the first day of the next open period. If you export as Vendor Bills, you can also choose to Sync reimbursed reports (set on the Advanced tab). **An A/P account is required to export to a vendor bill.** + +The submitter will be listed as the vendor in the vendor bill. + +## Check + +This is a single itemized check for each Expensify report. You can mark a check to be printed later in QuickBooks Online. + +## Journal entry + +This is a single itemized journal entry for each Expensify report. + +# Non-reimbursable expenses + +Non-reimbursable expenses export to QuickBooks Online as: + +- Credit Card expenses +- Debit Card Expenses +- Vendor Bills + +## Credit/debit card + +Using Credit/Debit Card Transactions: + +- Each expense will be exported as a bank transaction with its transaction date. +- If you split an expense in Expensify, we'll consolidate it into a single credit card transaction in QuickBooks with multiple line items posted to the corresponding General Ledger accounts. + +Pro-Tip: To ensure the payee field in QuickBooks Online reflects the merchant name for Credit Card expenses, ensure there's a matching Vendor in QuickBooks Online. Expensify checks for an exact match during export. If none are found, the payee will be mapped to a vendor we create and labeled as Credit Card Misc. or Debit Card Misc. + +If you centrally manage your company cards through Domains, you can export expenses from each card to a specific account in QuickBooks. + +## Vendor Bill + +- A single detailed vendor bill is generated for each Expensify report. If the accounting period is closed, the vendor bill will be posted on the first day of the next open period. If you choose to export non-reimbursable expenses as Vendor Bills, you can assign a default vendor to the bill. +- The export will use your default vendor if you have Default Vendor enabled. If the Default Vendor is disabled, the report's submitter will be set as the Vendor in QuickBooks. + +## Billable Expenses + +- In Expensify, you can designate expenses as billable. These will be exported to QuickBooks Online with the billable flag. This feature applies only to expenses exported as Vendor Bills or Checks. To maximize this functionality, ensure that any billable expense is associated with a Customer/Job. + +## Export Invoices + +If you are creating Invoices in Expensify and exporting these to QuickBooks Online, this is the account the invoice will appear against. + +# Configure coding for QuickBooks Online + +The coding tab is where your information is configured for Expensify; this will allow employees to code expenses and reports accurately. + +- Categories +- Classes and/or Customers/Projects +- Locations +- Items +- Tax + +## Categories + +QuickBooks Online expense accounts will be automatically imported into Expensify as Categories. + +## Account Import + +Equity type accounts will also be imported as categories. + +Important notes: + +- Other Current Liabilities can only be exported as Journal Entries if the submitter is set up as an Employee in QuickBooks. +- Exchange Gain or Loss detail type does not import. + +Recommended steps to take after importing the expense accounts from QuickBooks to Expensify: + +- Go to Settings > Workspaces > Groups > [Workspace Name] > Categories to see the accounts imported from QuickBooks Online. +- Use the enable/disable button to choose which Categories to make available to your employees, and set Category specific rules via the blue settings cog. +- If necessary, edit the names of imported Categories to make expense coding easier for your employees. (Please Note: If you make any changes to these accounts in QuickBooks Online, the category names on Expensify's side will revert to match the name of the account in QuickBooks Online the next time you sync). +- If you use Items in QuickBooks Online, you can import them into Expensify as Categories. + +Please note that each expense has to have a category selected to export to QuickBooks Online. The chosen category has to be imported from QuickBooks Online and cannot be manually created within the Workspace settings. + +## Classes and Customers/Projects + +If you use Classes or Customers/Projects in QuickBooks Online, you can import those into Expensify as Tags or Report Fields: + +- Tags let you apply a Class and/or Customer/Project to each expense. +- Report Fields enables you to apply a Class and/or Customer/Project to all expenses on a report. + +Note: Although Projects can be imported into Expensify and coded to expenses, due to the limitations of the QuickBooks API, expenses cannot be created within the Projects module in QuickBooks. + +## Locations + +Locations can be imported into Expensify as a Report Field or, if you export reimbursable expenses as Journal Entries and non-reimbursable expenses as Credit/Debit Card, you can import Locations as Tags. + +## Items + +If you use Items in QuickBooks Online, you can import Items defined with Purchasing Information (with or without Sales Information) into Expensify as Categories. + +## Tax + +- Using our tax tracking feature, you can assign a tax rate and amount to each expense. +- To activate tax tracking, go to connection configuration and enable it. This will automatically import purchasing taxes from QuickBooks Online into Expensify. +- After the connection is set, navigate to Settings > Workspaces > Groups > [Workspace Name] > Tax. Here, you can view the taxes imported from QuickBooks Online. +- Use the enable/disable button to choose which taxes are accessible to your employees. +- Set a default tax for the Company Workspace, which will automatically apply to all new expenses. +- Please note that, at present, tax cannot be exported to Journal Entries in QuickBooks Online. +- Expensify performs a daily sync to ensure your information is up-to-date. This minimizes errors from outdated QuickBooks Online data and saves you time on syncing. + +# How to configure advanced settings for QuickBooks Online + +The advanced settings are where functionality for automating and customizing the QuickBooks Online integration can be enabled. +Navigate to this section of your Workspace by following Settings > Workspaces > Group > [Workspace Name] > Connections > Configure button > Advanced tab. + +## Auto Sync +With QuickBooks Online auto-sync, once a non-reimbursable report is final approved in Expensify, it's automatically queued for export to QuickBooks Online. For expenses eligible for reimbursement with a linked business bank account, they'll sync when marked as reimbursed. + +## Newly Imported Categories + +This setting determines the default status of newly imported categories from QuickBooks Online to Expensify, either enabled or disabled. + +## Invite Employees + +Enabling this automatically invites all Employees from QuickBooks Online to the connected Expensify Company Workspace. If not, you can manually invite or import them using a CSV file. + +## Automatically Create Entities + +When exporting reimbursable expenses as Vendor Bills or Journal Entries, Expensify will automatically create a vendor in QuickBooks if one doesn't exist. It will also generate a customer when exporting Invoices. + +## Sync Reimbursed Reports + +Enabling this marks the Vendor Bill as paid in QuickBooks Online when you reimburse a report via ACH direct deposit in Expensify. If reimbursing outside Expensify, marking the Vendor Bill as paid will automatically in QuickBooks Online update the report as reimbursed in Expensify. Note: After enabling this feature, select your QuickBooks Account in the drop-down, indicating the bank account for reimbursements. + +## Collection Account + +If you are exporting Invoices from Expensify to Quickbooks Online, this is the account the Invoice will appear against once marked as Paid. + +# Deep Dive + +## Preventing Duplicate Transactions in QuickBooks + +When importing a banking feed directly into QuickBooks Online while also importing transactions from Expensify, it's possible to encounter duplicate entries in QuickBooks. To prevent this, follow these steps: + +Step 1: Complete the Approval Process in Expensify + +- Before exporting any expenses to QuickBooks Online, ensure they are added to a report and the report receives approval. Depending on your Workspace setup, reports may require approval from one or more individuals. The approval process concludes when the last user who views the report selects "Final Approve." + +Step 2: Exporting Reports to QuickBooks Online + +- To ensure expenses exported from Expensify match seamlessly in the QuickBooks Banking platform, make sure these expenses are marked as non-reimbursable within Expensify and that “Credit Card” is selected as the non-reimbursable export option for your expenses. + +Step 3: Importing Your Credit Card Transactions into QuickBooks Online + +- After completing Steps 1 and 2, you can import your credit card transactions into QuickBooks Online. These imported banking transactions will align with the ones brought in from Expensify. QuickBooks Online will guide you through the process of matching these transactions, similar to the example below: + +## Tax in QuickBooks Online + +If your country applies taxes on sales (like GST, HST, or VAT), you can utilize Expensify's Tax Tracking along with your QuickBooks Online tax rates. Please note: Tax Tracking is not available for Workspaces linked to the US version of QuickBooks Online. If you need assistance applying taxes after reports are exported, contact QuickBooks. + +To get started: + +- Go to Settings > Workspaces > Group > [Workspace Name] > Connections, and click Configure. +- Navigate to the Coding tab. +- Turn on **Tax**. +- Click Save. This imports the Tax Name and rate from QuickBooks Online. +- Visit Settings > Workspaces > Group > [Workspace Name] > Tax to view the imported taxes. +- Use the enable/disable button in the Tax tab to choose which taxes your employees can use. + +Remember, you can also set a default tax rate for the entire Workspace. This will be automatically applied to all new expenses. The user can still choose a different tax rate for each expense. + +Tax information can't be sent to Journal Entries in QuickBooks Online. Also, when dealing with multiple tax rates, where one receipt has different tax rates (like in the EU, UK, and Canada), users should split the expense into the respective parts and set the appropriate tax rate for each part. + +## Multi-currency + +When working with QuickBooks Online Multi-Currency, there are some things to remember when exporting Vendor Bills and Check! Make sure the vendor's currency and the Accounts Payable (A/P) bank account match. + +In QuickBooks Online, the currency conversion rates are not applied when exporting. All transactions will be exported with a 1:1 conversion rate, so for example, if a vendor's currency is CAD (Canadian Dollar) and the home currency is USD (US Dollar), the export will show these currencies without applying conversion rates. + +To correct this, you must manually update the conversion rate after the report has been exported to QuickBooks Online. + +Specifically for Vendor Bills: + +If multi-currency is enabled and the Vendor's currency is different from the Workspace currency, OR if QuickBooks Online home currency is foreign from the Workspace currency, then: + +- We create the Vendor Bill in the Vendor's currency (this is a QuickBooks Online requirement - we don't have a choice) +- We set the exchange rate between the home currency and the Vendor's currency +- We convert line item amounts to the vendor's currency + +Let's consider this example: + +- QuickBooks Online home currency is USD +- Vendor's currency is VND +- Workspace (report) currency is JPY + +Upon export, we: + +1. Specified the bill is in VND +2. Set the exchange rate between VND and USD (home currency), computed at the time of export. +3. Converted line items from JPY (currency in Expensify) to VND +4. QuickBooks Online automatically computed the USD amount (home currency) based on the exchange rate we specified +5. Journal Entries, Credit Card, and Debit Card: + +Multi-currency exports will fail as the account currency must match both the vendor and home currencies. + +## Report Fields + +Report fields are a handy way to collect specific information for a report tailored to your organization's needs. They can specify a project, business trip, client, location, and more! + +When integrating Expensify with Your Accounting Software, you can create your report fields in your accounting software so the next time you sync your Workspace, these fields will be imported into Expensify. + +To select how a specific field imports to Expensify, head to Settings > Workspaces > Group > +[Workspace Name] > Connections > Accounting Integrations > QuickBooks Online > Configure > Coding. + +Here are the QuickBooks Online fields that can be mapped as a report field within Expensify: + +- Classes +- Customers/Projects +- Locations + +# FAQ + +## What happens if the report can't be exported to QuickBooks Online automatically? + +If a report encounters an issue during automatic export to QuickBooks Online, you'll receive an email with details about the problem, including any specific error messages. These messages will also be recorded in the report's history section. + +The report will be placed in your Home for your attention. You can address the issues there. If you need further assistance, refer to our QuickBooks Online Export Errors page or export the report manually. + +## How can I ensure that I final approve reports before they're exported to QuickBooks Online? + +To ensure reports are reviewed before export, set up your Workspaces with the appropriate workflow in Expensify. Additionally, consider changing your Workspace settings to enforce expense Workspace workflows strictly. This guarantees that your Workspace's workflow is consistently followed. + +## What happens to existing approved and reimbursed reports if I enable Auto Sync? + +- If Auto Sync was disabled when your Workspace was linked to QuickBooks Online, enabling it won't impact existing reports that haven't been exported. +- If a report has been exported and reimbursed via ACH, it will be automatically marked as paid in QuickBooks Online during the next sync. +- If a report has been exported and marked as paid in QuickBooks Online, it will be automatically marked as reimbursed in Expensify during the next sync. +- Reports that have yet to be exported to QuickBooks Online won't be automatically exported. diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md index 3ee1c8656b4b..32ce41d3cbf3 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md @@ -1,5 +1,189 @@ --- -title: Coming Soon -description: Coming Soon +title: How To Manage Employees and Reports > Approving Reports +description: This page will help you understand the lifecycle of a report and how to approve reports that are submitted to you. --- -## Resource Coming Soon! +# About +This article provides a comprehensive guide on report management within our platform. From viewing, editing, and submitting your employees' Open reports to handling rejections and unapproving, as well as harnessing the power of our "Guided Review" feature. Additionally, we'll delve into best practices for Concierge, offering insights on how to streamline and automate your report approval processes for maximum efficiency. +Let's dive in! + + +# How-to manage reports +This section covers the most essential information a user needs to operate a feature i.e. what to click on. We’ll go over any action the user might take when configuring or using the feature, starting from configuration and moving to usage. + + +What options does a user have when configuring this feature? + + +What options does a user have then interacting with this feature? + + +What elements of this feature are pay-walled vs. free? + + +As a Workspace admin, you have the ability to view, edit, and submit your employees' Open reports. + + +We recommend beginning this process from the web version of Expensify because it offers more functionality compared to the mobile app. Here's how to get started: +Click on the "Reports" tab. +Select the "All Submitters" and "Open" filters. +This will display all employee reports on your Workspaces that have not yet been submitted. +​ +## Viewing Employee Reports +Viewing employee reports can vary depending on whether you're using the web or mobile versions of Expensify. We generally recommend using the web version for this purpose, as it offers the following advantages: + + +You will only receive reports directly submitted to you when using the mobile app. + + +The option to filter reports via the Reports page is exclusively available in the web version, making it more convenient when reviewing multiple reports during a session. +​ +## Viewing employee reports on the mobile app +When using the mobile app to view reports, please note the following: + + +Tapping on the Reports list will only display your own reports; you won't see reports from other Workspace members. + + +To view another Workspace member's report in the Expensify app, it must be submitted directly to you, and you must access it through a link from an email or via Home. + + +When you access a report in this manner, you will have the option to approve/reject it or go through the review process if there are expenses that require your attention. + + +Once you've approved or rejected the report, it won't be accessible in the app anymore. To view it again, please visit the website and follow the steps mentioned above. +​ +## Editing employee reports +If a report has been submitted directly to you, follow these steps to edit the expense details. Please note that you cannot change the expense amount; to make changes affecting the report total, you must reject it and return it to the employee. + + +Here's what to do: +- Click on any expense within the report to edit the expense details. +- Remember that you cannot modify the expense amount directly. To make changes affecting the report total, reject the report, and it will be sent back to the employee for revisions. +- If you're a Workspace admin and need to edit a report that wasn't submitted directly to you, use the "Take Control" button at the top of the report. Keep in mind that taking control of a report will disrupt the approval workflow. +​ +Additionally, here are some other editing options for Admins: +- Undelete deleted company card expenses via the Reconciliation Dashboard (requires Domain Admin privileges). +- Add unreported company card expenses to an existing Open (unsubmitted) report or create a new report via the Reconciliation Dashboard (requires Domain Admin privileges). +- Add or modify expense coding, including Category, Tag(s), and Attendees. +- Attach a receipt to an expense that doesn't have one. +- Move card expenses between two Open (unsubmitted) reports. +- Merge duplicate expenses (only applicable if they are not card transactions). +- Change the Workspace associated with a report.​ + + +## Submitting Employee Reports +As a Workspace Admin, you have the option to submit any of your employee's Open reports. If an employee is unable to do it themselves, a Workspace admin can submit an expense report on their behalf to initiate the approval process. Follow these steps: +Click the "Submit" button located at the top of the report. + + +## Report History and Comments +Please keep in mind that any changes made by the admin are tracked under "Report History and Comments." If you change the reimbursable status of an expense (e.g., from Reimbursable to Non-Reimbursable), an email notification will be sent to the employee to notify them of this change. + + +## Rejecting or Unapproving a Report +If you need to reject a report that has been submitted to you or Unapprove a report that has already been approved. + + +To reject the report, click Reject rather than beginning the Review process. If there are multiple approvers involved, you can choose how far back to reject the report. + + +Rejecting a report will return the report back to the submitter in an Open status or, in the case of a multi-level approval workflow, back to the previous approver in a Processing status (awaiting their approval). You may need to do this if the submitter is not ready to submit the report, or perhaps the report as a whole needs to be rejected based on the configuration of your organization's expense Workspace. + + +## Unapprove a Report +You can click the red Unapprove button at the top of the report to undo approving a report. Keep in mind that you'll only see the Unapprove button if you're a report approver on an admin that has taken control of the report.​ + + +## Marking a Report as Reimbursed Outside of Expensify +If you are reimbursing reports via paper check, through payroll or any other method that takes place outside of Expensify, you'll want to keep track of which reports have been taken care of by marking reports as reimbursed. + + +1. Log into your Expensify account using your preferred web browser, (ie: Chrome or Safari) +2. Head to your Reports page and locate the report +3. Click the report name to open it +4. Click on Reimburse +5. Choose "I'll do it manually - just mark it as reimbursed". This will change the report status to Reimbursed +6. The submitter can then go into the report and confirm that they received the reimbursement by clicking the button at the top of the report. +7. This will change the report status to Reimbursed: CONFIRMED + + +# How to Use Guided Review to Approve Reports +Guided Review helps alert you to what might be out-of-Workspace for an Expense Report. You'll be guided through all report violations and warnings and given the option to Reject or Edit items that need review prior to approving a report. + + +Guided Review helps approvers quickly identify reports that need more attention so they can pass over reports that can be quickly approved. Both Submitters and Approvers have actionable notifications for the following: violations, warnings, and notices. These notifications are important since they will be included in “review mode” for the approver to make clear approve or reject decisions. + + +Via the Website:​ +1. Simply click Review at the top left of the report and the system will begin to walk you through the entire report. +2. Choose to Reject, View, or skip over an item needing review. If you wish to stop the process at any time, click the X in the progress bar in the top right corner. +Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected. +View: This will allow you to open the expense so you can view and fix any incorrect data. +Next: This will allow you to skip over the current item and move forward to review the rest of the report. +Finish: Click this to finish reviewing the report! +3. Click the Finish button if you are done reviewing, or reject/edit the last item to finish the review process. +4. Approve the report! Approve and Forward the report if there is another person who needs to review the report in your approval workflow, or you can Final Approve if you are the final approver. Note: When in Guided Review, you'll automatically Approve the report adhering to your Company's Approval Workflow once you Approve the final expense on the report. You'll then be immediately taken to the next report requiring your attention - making Approving multiple expenses a quick and painless process! + + + + +Via the Mobile App:​ +1. From Home, under Reports that need your attention, click Begin Review, and the system will bring you to the first expense on the oldest report in Home. +2. Edit the expense: Make any necessary edits to the expense by tapping the corresponding field. Be sure to address any Violations and Notes on the expense! Notes are indicated at the top of the expense with a yellow exclamation point, while violations appear on the expense with a red exclamation point: +3. Choose Reject or Accept at the top of the expense. + + +Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected, and a comment will be added to the report it was rejected from. If this is the only expense on the report, the entire report will be rejected (and the expense will remain on the report). + + +If Scheduled Submit is being used, rejected expenses will auto-report to the next Open report on the Workspace (as if it were a new expense). If an open report doesn't exist, Concierge will create a new one. +​ + + +If Scheduled Submit is not being used, any rejected expenses will be Unreported in the submitter's account and need to be manually applied to a new report. +​ +Accept: This will move to the next expense on the report, leaving behind any outstanding violations or notes. If this is the last expense on the report, you'll be all done! +Once you've made it through all of the expenses on the report, you'll be all set! + + +# Deep Dive +## Concierge Report Management + + +Concierge report approval removes the need for you to manually click "Approve" on endless reports! Instead, you can set up your group Workspace to capture all the requirements you have for your team's expenses. As long as all the rules have been followed and Concierge's additional audit is passed (more below), we will automatically approve such reports on behalf of the approver after submission. +​ +Before you start: +Ensure are a Workspace admin on a group Workspace +Set your workflow to Submit-and-Approve or Advanced Approval workflow​ + + +## Then follow these steps: +Set up your group Workspace so that all of your expense requirements are defined. Setting automatic categories for employees and category rules (e.g., maximum amounts, receipt requirements, etc.) are great examples! + + +Navigate to Settings > Workspaces > Group > [Workspace Name] > Members.​ + + +Scroll down to Approval Mode and select either Submit-and-Approve or Advanced Approval. + + +Under Expense Approvals, select a Manual Approval Threshold greater than $0.​ + + +With this setup, manual approval will only be required: +- For reports that fail audit (i.e. there is at least one Workspace violation on the report) +- For reports that contain at least one expense over the Manual Approval Threshold +- For any percentage of reports that you'd like to spot-check (this is set at 5% or 1 in 20 by default). +- If the report meets all of the requirements you specify in your Workspace settings and all expenses are under the Manual Approval Threshold, then Concierge will automatically move your report through each step of your designated approval workflow (unless it's routed for a spot-check).​ + + + + +## Concierge Receipt Audit +Concierge Receipt Audit is a real-time audit and compliance of receipts submitted by employees and Workspace users. Concierge checks every receipt for accuracy and compliance, flagging any expenses that seem fishy before expense reports are even submitted for approval. All risky expenses are highlighted for manual review, leaving you with more control over and visibility into employee expenses. + + +1. Concierge will SmartScan every receipt to verify the data input by the user matches the currency, date, and amount on the physical receipt. +2. After the report is submitted for approval, Concierge highlights any differences between the SmartScanned values and the employee's chosen input. +3. Each receipt that has been verified will show the "Verified" logo. + diff --git a/docs/articles/expensify-classic/send-payments/Pay-Bills.md b/docs/articles/expensify-classic/send-payments/Pay-Bills.md index 41c0146126ba..8a5c7c5c7f88 100644 --- a/docs/articles/expensify-classic/send-payments/Pay-Bills.md +++ b/docs/articles/expensify-classic/send-payments/Pay-Bills.md @@ -1,5 +1,110 @@ --- title: Pay Bills -description: Pay Bills +description: How to receive and pay company bills in Expensify --- -## Resource Coming Soon! + + +# Overview +Simplify your back office by receiving bills from vendors and suppliers in Expensify. Anyone with or without an Expensify account can send you a bill, and Expensify will file it as a Bill and help you issue the payment. + +# How to Receive Vendor or Supplier Bills in Expensify + +There are three ways to get a vendor or supplier bill into Expensify: + +**Option 1:** Have vendors send bills to Expensify directly: Ask your vendors to email all bills to your Expensify billing intake email. + +**Option 2:** Forward bills to Expensify: If your bills are emailed to you, you can forward those bills to your Expensify billing intake email yourself. + +**Option 3:** Manually upload bills to Expensify: If you receive physical bills, you can manually create a Bill in Expensify on the web from the Reports page: +1. Click **New Report** and choose **Bill** +2. Add the expense details and vendor's email address to the pop-up window +3. Upload a pdf/image of the bill +4. Click **Submit** + +# How to Pay Bills + +There are multiple ways to pay Bills in Expensify. Let’s go over each method below: + +## ACH bank-to-bank transfer + +To use this payment method, you must have a business bank account connected to your Expensify account. + +To pay with an ACH bank-to-bank transfer: + +1. Sign in to your Expensify account on the web at www.expensify.com. +2. Go to the Inbox or Reports page and locate the Bill that needs to be paid. +3. Click the **Pay** button to be redirected to the Bill. +4. Choose the ACH option from the drop-down list. +5. Follow the prompts to connect your business bank account to Expensify. + +**Fees:** None + +## Pay using a credit or debit card + +This option is available to all US and International customers receiving an bill from a US vendor with a US business bank account. + +To pay with a credit or debit card: +1. Sign-in to your Expensify account on the web app at www.expensify.com. +2, Click on the Bill you’d like to pay to see the details. +3, Click the **Pay** button. +4. You’ll be prompted to enter your credit card or debit card details. + +**Fees:** Includes 2.9% credit card payment fee + +## Venmo + +If both you and the vendor have Venmo setup in their Expensify account, you can opt to pay the bill through Venmo. + +**Fees:** Venmo charges a 3% sender’s fee + +## Pay Outside of Expensify + +If you are not able to pay using one of the above methods, then you can mark the Bill as paid manually in Expensify to update its status and indicate that you have made payment outside Expensify. + +To mark a Bill as paid outside of Expensify: + +1. Sign-in to your Expensify account on the web app at www.expensify.com. +2. Click on the Bill you’d like to pay to see the details. +3. Click on the **Reimburse** button. +4. Choose **I’ll do it manually** + +**Fees:** None + +# FAQ + +## What is my company's billing intake email? +Your billing intake email is [yourdomain.com]@expensify.cash. Example, if your domain is `company.io` your billing email is `company.io@expensify.cash`. + +## When a vendor or supplier bill is sent to Expensify, who receives it? + +Bills are received by the Primary Contact for the domain. This is the email address listed at **Settings > Domains > Domain Admins**. + +## Who can view a Bill in Expensify? + +Only the primary contact of the domain can view a Bill. + +## Who can pay a Bill? + +Only the primary domain contact (owner of the bill) will be able to pay the Mill. + +## How can you share access to Bills? + +To give others the ability to view a Bill, the primary contact can manually “share” the Bill under the Details section of the report via the Sharing Options button. +To give someone else the ability to pay Bills, the primary domain contact will need to grant those individuals Copilot access to the primary domain contact's account. + +## Is Bill Pay supported internationally? + +Payments are currently only supported for users paying in United States Dollars (USD). + +## What’s the difference between a Bill and an Invoice in Expensify? + +A Bill is a payable which represents an amount owed to a payee (usually a vendor or supplier), and is usually created from a vendor invoice. An Invoice is a receivable, and indicates an amount owed to you by someone else. + +# Deep Dive: How company bills and vendor invoices are processed in Expensify + +Here is how a vendor or supplier bill goes from received to paid in Expensify: + +1. When a vendor or supplier bill is received in Expensify via, the document is SmartScanned automatically and a Bill is created. The Bill is owned by the primary domain contact, who will see the Bill on the Reports page on their default group policy. +2. When the Bill is ready for processing, it is submitted and follows the primary domain contact’s approval workflow. Each time the Bill is approved, it is visible in the next approver's Inbox. +3. The final approver pays the Bill from their Expensify account on the web via one of the methods. +4. The Bill is coded with the relevant imported GL codes from a connected accounting software. After it has finished going through the approval workflow the Bill can be exported back to the accounting package connected to the policy. diff --git a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index 669d960275e6..25ccdefad261 100644 --- a/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -24,30 +24,30 @@ After downloading the app, log into your new.expensify.com account (you’ll use ## How to send messages -Click **+** then **Send message** in New Expensify -Choose **Chat** -Search for any name, email or phone number -Select the individual to begin chatting +1. Click **+** then **Send message** in New Expensify +2. Choose **Chat** +3. Search for any name, email or phone number +4. Select the individual to begin chatting ## How to create a group -Click **+**, then **Send message** in New Expensify -Search for any name, email or phone number -Click **Add to group** -Group participants are listed with a green check -Repeat steps 1-3 to add more participants to the group -Click **Create chat** to start chatting +1. Click **+**, then **Send message** in New Expensify +2. Search for any name, email or phone number +3. Click **Add to group** +4. Group participants are listed with a green check +5. Repeat steps 1-3 to add more participants to the group +6. Click **Create chat** to start chatting ## How to create a room -Click **+**, then **Send message** in New Expensify -Click **Room** -Enter a room name that doesn’t already exist on the intended Workspace -Choose the Workspace you want to associate the room with. -Choose the room’s visibility setting: -Private: Only people explicitly invited can find the room* -Restricted: Workspace members can find the room* -Public: Anyone can find the room +1. Click **+**, then **Send message** in New Expensify +2. Click **Room** +3. Enter a room name that doesn’t already exist on the intended Workspace +4. Choose the Workspace you want to associate the room with. +5. Choose the room’s visibility setting: +6. Private: Only people explicitly invited can find the room* +7. Restricted: Workspace members can find the room* +8. Public: Anyone can find the room *Anyone, including non-Workspace Members, can be invited to a Private or Restricted room. @@ -56,26 +56,29 @@ Public: Anyone can find the room You can invite people to a Group or Room by @mentioning them or from the Members pane. ## Mentions: -Type **@** and start typing the person’s name or email address -Choose one or more contacts -Input message, if desired, then send + +1. Type **@** and start typing the person’s name or email address +2. Choose one or more contacts +3. Input message, if desired, then send ## Members pane invites: -Click the **Room** or **Group** header -Select **Members** -Click **Invite** -Find and select any contact/s you’d like to invite -Click **Next** -Write a custom invitation if you like -Click **Invite** + +1. Click the **Room** or **Group** header +2. Select **Members** +3. Click **Invite** +4. Find and select any contact/s you’d like to invite +5. Click **Next** +6. Write a custom invitation if you like +7. Click **Invite** ## Members pane removals: -Click the **Room** or **Group** header -Select **Members** -Find and select any contact/s you’d like to remove -Click **Remove** -Click **Remove members** + +1. Click the **Room** or **Group** header +2. Select **Members** +3. Find and select any contact/s you’d like to remove +4. Click **Remove** +5. Click **Remove members** ## How to format text diff --git a/docs/assets/images/Cancel Reimbursement.png b/docs/assets/images/Cancel Reimbursement.png new file mode 100644 index 000000000000..a1322202ded3 Binary files /dev/null and b/docs/assets/images/Cancel Reimbursement.png differ diff --git a/docs/assets/images/CompanyCards_Assign.png b/docs/assets/images/CompanyCards_Assign.png new file mode 100644 index 000000000000..53effeb56b88 Binary files /dev/null and b/docs/assets/images/CompanyCards_Assign.png differ diff --git a/docs/assets/images/CompanyCards_EmailAssign.png b/docs/assets/images/CompanyCards_EmailAssign.png new file mode 100644 index 000000000000..a3d9683518a7 Binary files /dev/null and b/docs/assets/images/CompanyCards_EmailAssign.png differ diff --git a/docs/assets/images/CompanyCards_Unassign.png b/docs/assets/images/CompanyCards_Unassign.png new file mode 100644 index 000000000000..14a2fdc205a7 Binary files /dev/null and b/docs/assets/images/CompanyCards_Unassign.png differ diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png new file mode 100644 index 000000000000..8a102375d1c8 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png differ diff --git a/docs/assets/images/Reimbursing Default.png b/docs/assets/images/Reimbursing Default.png new file mode 100644 index 000000000000..23ffd557ca14 Binary files /dev/null and b/docs/assets/images/Reimbursing Default.png differ diff --git a/docs/assets/images/Reimbursing Manual Warning.png b/docs/assets/images/Reimbursing Manual Warning.png new file mode 100644 index 000000000000..2579e21fe2e3 Binary files /dev/null and b/docs/assets/images/Reimbursing Manual Warning.png differ diff --git a/docs/assets/images/Reimbursing Manual.png b/docs/assets/images/Reimbursing Manual.png new file mode 100644 index 000000000000..3b9eb27bfa0b Binary files /dev/null and b/docs/assets/images/Reimbursing Manual.png differ diff --git a/docs/assets/images/Reimbursing Reports Dropdown.png b/docs/assets/images/Reimbursing Reports Dropdown.png new file mode 100644 index 000000000000..2e9c6329ae19 Binary files /dev/null and b/docs/assets/images/Reimbursing Reports Dropdown.png differ 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 9f7e4a190690..c8e8ab5a09f1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.4.4 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.0.3 + 1.4.4.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index c5801e5606a8..fc779f5a711c 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.0 + 1.4.4 CFBundleSignature ???? CFBundleVersion - 1.4.0.3 + 1.4.4.3 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index d94e36b0b3c9..7f1ca6b3290c 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -585,7 +585,7 @@ PODS: - React-Core - react-native-pager-view (6.2.0): - React-Core - - react-native-pdf (6.7.1): + - react-native-pdf (6.7.3): - React-Core - react-native-performance (5.1.0): - React-Core @@ -1241,7 +1241,7 @@ SPEC CHECKSUMS: react-native-key-command: 5af6ee30ff4932f78da6a2109017549042932aa5 react-native-netinfo: ccbe1085dffd16592791d550189772e13bf479e2 react-native-pager-view: 0ccb8bf60e2ebd38b1f3669fa3650ecce81db2df - react-native-pdf: 7c0e91ada997bac8bac3bb5bea5b6b81f5a3caae + react-native-pdf: b4ca3d37a9a86d9165287741c8b2ef4d8940c00e react-native-performance: cef2b618d47b277fb5c3280b81a3aad1e72f2886 react-native-plaid-link-sdk: 9eb0f71dad94b3bdde649c7a384cba93024af46c react-native-quick-sqlite: bcc7a7a250a40222f18913a97cd356bf82d0a6c4 diff --git a/jest.config.js b/jest.config.js index efd72d20694f..611a0248b491 100644 --- a/jest.config.js +++ b/jest.config.js @@ -24,7 +24,7 @@ module.exports = { }, testEnvironment: 'jsdom', setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'], - setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js'], + setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'], cacheDirectory: '/.jest-cache', moduleNameMapper: { '\\.(lottie)$': '/__mocks__/fileMock.js', diff --git a/package-lock.json b/package-lock.json index 4a489b415ab1..d532c0d3e9b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.0-3", + "version": "1.4.4-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.0-3", + "version": "1.4.4-3", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -51,7 +51,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#886f90cbd5e83218fdfd7784d8356c308ef05791", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -94,7 +94,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.1", + "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", @@ -25886,10 +25886,9 @@ } }, "node_modules/crypto-js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", - "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==", - "license": "MIT" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/css-box-model": { "version": "1.2.1", @@ -29905,8 +29904,8 @@ }, "node_modules/expensify-common": { "version": "1.0.0", - "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#886f90cbd5e83218fdfd7784d8356c308ef05791", - "integrity": "sha512-f+HGB8GZ9NTHT1oI6fhiGfIM3Cd411Qg45Nl0Yd3Te2CU34FhwMNLEkcDa6/zAlYkCeoJUk5XUVmTimjEgS0ig==", + "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", + "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==", "license": "MIT", "dependencies": { "classnames": "2.3.1", @@ -44476,11 +44475,11 @@ } }, "node_modules/react-native-pdf": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz", - "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", + "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", "dependencies": { - "crypto-js": "^3.2.0", + "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" }, "peerDependencies": { @@ -71516,9 +71515,9 @@ } }, "crypto-js": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-3.3.0.tgz", - "integrity": "sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "css-box-model": { "version": "1.2.1", @@ -74417,9 +74416,9 @@ } }, "expensify-common": { - "version": "git+ssh://git@github.com/Expensify/expensify-common.git#886f90cbd5e83218fdfd7784d8356c308ef05791", - "integrity": "sha512-f+HGB8GZ9NTHT1oI6fhiGfIM3Cd411Qg45Nl0Yd3Te2CU34FhwMNLEkcDa6/zAlYkCeoJUk5XUVmTimjEgS0ig==", - "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#886f90cbd5e83218fdfd7784d8356c308ef05791", + "version": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", + "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==", + "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "requires": { "classnames": "2.3.1", "clipboard": "2.0.4", @@ -84838,11 +84837,11 @@ "requires": {} }, "react-native-pdf": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.1.tgz", - "integrity": "sha512-zszQygtNBYoUfEtP/fV7zhzGeohDlUksh2p3OzshLrxdY9mw7Tm5VXAxYq4d8HsomRJUbFlJ7rHaTU9AQL800g==", + "version": "6.7.3", + "resolved": "https://registry.npmjs.org/react-native-pdf/-/react-native-pdf-6.7.3.tgz", + "integrity": "sha512-bK1fVkj18kBA5YlRFNJ3/vJ1bEX3FDHyAPY6ArtIdVs+vv0HzcK5WH9LSd2bxUsEMIyY9CSjP4j8BcxNXTiQkQ==", "requires": { - "crypto-js": "^3.2.0", + "crypto-js": "4.2.0", "deprecated-react-native-prop-types": "^2.3.0" } }, diff --git a/package.json b/package.json index bdd346db4251..204784c14db1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.0-3", + "version": "1.4.4-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -98,7 +98,7 @@ "date-fns-tz": "^2.0.0", "dom-serializer": "^0.2.2", "domhandler": "^4.3.0", - "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#886f90cbd5e83218fdfd7784d8356c308ef05791", + "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2", "fbjs": "^3.0.2", "htmlparser2": "^7.2.0", "idb-keyval": "^6.2.1", @@ -141,7 +141,7 @@ "react-native-modal": "^13.0.0", "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", - "react-native-pdf": "^6.7.1", + "react-native-pdf": "^6.7.3", "react-native-performance": "^5.1.0", "react-native-permissions": "^3.9.3", "react-native-picker-select": "git+https://github.com/Expensify/react-native-picker-select.git#eae05855286dc699954415cc1d629bfd8e8e47e2", 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/scripts/start-android.sh b/scripts/start-android.sh old mode 100644 new mode 100755 diff --git a/src/App.js b/src/App.js index 698dfe4437b2..ac34ece5c6c7 100644 --- a/src/App.js +++ b/src/App.js @@ -24,6 +24,7 @@ import OnyxUpdateManager from './libs/actions/OnyxUpdateManager'; import * as Session from './libs/actions/Session'; import * as Environment from './libs/Environment/Environment'; import {ReportAttachmentsProvider} from './pages/home/report/ReportAttachmentsContext'; +import ThemeIllustrationsProvider from './styles/illustrations/ThemeIllustrationsProvider'; import ThemeProvider from './styles/themes/ThemeProvider'; import ThemeStylesProvider from './styles/ThemeStylesProvider'; @@ -64,6 +65,7 @@ function App() { EnvironmentProvider, ThemeProvider, ThemeStylesProvider, + ThemeIllustrationsProvider, ]} > diff --git a/src/CONST.ts b/src/CONST.ts index 709e9d3bafe2..dd6eafc7f0e6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -218,9 +218,8 @@ const CONST = { REGEX: { US_ACCOUNT_NUMBER: /^[0-9]{4,17}$/, - // If the account number length is from 4 to 13 digits, we show the last 4 digits and hide the rest with X - // If the length is longer than 13 digits, we show the first 6 and last 4 digits, hiding the rest with X - MASKED_US_ACCOUNT_NUMBER: /^[X]{0,9}[0-9]{4}$|^[0-9]{6}[X]{4,7}[0-9]{4}$/, + // The back-end is always returning account number with 4 last digits and mask the rest with X + MASKED_US_ACCOUNT_NUMBER: /^[X]{0,13}[0-9]{4}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, }, VERIFICATION_MAX_ATTEMPTS: 7, @@ -249,88 +248,9 @@ const CONST = { BETAS: { ALL: 'all', CHRONOS_IN_CASH: 'chronosInCash', - PAY_WITH_EXPENSIFY: 'payWithExpensify', - FREE_PLAN: 'freePlan', DEFAULT_ROOMS: 'defaultRooms', - BETA_EXPENSIFY_WALLET: 'expensifyWallet', BETA_COMMENT_LINKING: 'commentLinking', - INTERNATIONALIZATION: 'internationalization', POLICY_ROOMS: 'policyRooms', - PASSWORDLESS: 'passwordless', - TASKS: 'tasks', - THREADS: 'threads', - CUSTOM_STATUS: 'customStatus', - NEW_DOT_SAML: 'newDotSAML', - PDF_META_STORE: 'pdfMetaStore', - REPORT_ACTION_CONTEXT_MENU: 'reportActionContextMenu', - SUBMIT_POLICY: 'submitPolicy', - ATTENDEES: 'attendees', - AUTO_EXPORT: 'autoExport', - AUTO_EXPORT_INTACCT: 'autoExportIntacct', - AUTO_EXPORT_QBO: 'autoExportQbo', - AUTO_EXPORT_XERO: 'autoExportXero', - AUTO_JOIN_POLICY: 'autoJoinPolicy', - AUTOMATED_TAX_EXEMPTION: 'automatedTaxExemption', - BILL_PAY: 'billPay', - CATEGORY_DEFAULT_TAX: 'categoryDefaultTax', - COLLECTABLE_DEPOSIT_ACCOUNTS: 'collectableDepositAccounts', - CONCIERGE_TRAVEL: 'conciergeTravel', - CONNECTED_CARDS: 'connectedCards', - DISCREPANCY: 'discrepancy', - DOMAIN_CONTACT_BILLING: 'domainContactBilling', - DOMAIN_TWO_FACTOR_AUTH: 'domainTwoFactorAuth', - DUPLICATE_DETECTION: 'duplicateDetection', - EMAIL_SUPPRESSION_BETA: 'emailSuppressionBeta', - EXPENSES_V2: 'expensesV2', - EXPENSIFY_CARD: 'expensifyCard', - EXPENSIFY_CARD_INTACCT_RECONCILIATION: 'expensifyCardIntacctReconciliation', - EXPENSIFY_CARD_NETSUITE_RECONCILIATION: 'expensifyCardNetSuiteReconciliation', - EXPENSIFY_CARD_QBO_RECONCILIATION: 'expensifyCardQBOReconciliation', - EXPENSIFY_CARD_RAPID_INCREASE_FRAUD: 'expensifyCardRapidIncreaseFraud', - EXPENSIFY_CARD_XERO_RECONCILIATION: 'expensifyCardXeroReconciliation', - EXPENSIFY_ORG: 'expensifyOrg', - FIX_VIOLATION_PUSH_NOTIFICATION: 'fixViolationPushNotification', - FREE_PLAN_FULL_LAUNCH: 'freePlanFullLaunch', - FREE_PLAN_SOFT_LAUNCH: 'freePlanSoftLaunch', - GUSTO: 'gusto', - INBOX_CACHE: 'inboxCache', - INBOX_HIDDEN_TASKS: 'inboxHiddenTasks', - INDIRECT_INTEGRATION_SETUP: 'indirectIntegrationSetup', - IOU: 'IOU', - JOIN_POLICY: 'joinPolicy', - LOAD_POLICY_ASYNC: 'loadPolicyAsync', - MAP_RECEIPT: 'mapReceipt', - MERGE_API: 'mergeAPI', - MOBILE_REALTIME_REPORT_COMMENTS: 'mobileRealtimeReportComments', - MOBILE_SECURE_RECEIPTS: 'mobileSecureReceipts', - MONTHLY_SETTLEMENT: 'monthlySettlement', - NAMES_AND_AVATARS: 'namesAndAvatars', - NATIVE_CHAT: 'nativeChat', - NEW_PRICING: 'newPricing', - NEWSLETTER_THREE: 'newsletterThree', - NEXT_STEPS: 'nextSteps', - OPEN_FACE_HAMBURGER: 'openFaceHamburger', - PER_DIEM: 'perDiem', - PER_DIEM_INTERNATIONAL: 'perDiemInternational', - PRICING_COPY_CHANGES: 'pricingCopyChanges', - QBO_INVOICES: 'qboInvoices', - QUICKBOOKS_DESKTOP_V2: 'quickbooksDesktopV2', - REALTIME_REPORT_COMMENTS: 'realtimeReportComments', - S2W_ANNOUNCEMENT: 's2wAnnouncement', - SCHEDULED_AUTO_REPORTING: 'scheduledAutoReporting', - SECURE_RECEIPTS: 'secureReceipts', - SECURE_RECEIPTS_REPORTS: 'secureReceiptsReports', - SELF_SERVICE_HARD_LAUNCH: 'selfServiceHardLaunch', - SEND_MONEY: 'sendMoney', - SMART_SCAN_USER_DISPUTES: 'smartScanUserDisputes', - SMS_SIGN_UP: 'smsSignUp', - STRIPE_CONNECT: 'stripeConnect', - SUMMARY_EMAIL: 'summaryEmail', - SWIPE_TO_WIN: 'swipeToWin', - TAX_FOR_MILEAGE: 'taxForMileage', - TWO_FACTOR_AUTH: 'twoFactorAuth', - VENMO_INTEGRATION: 'venmoIntegration', - ZENEFITS_INTEGRATION: 'zenefitsIntegration', VIOLATIONS: 'violations', }, BUTTON_STATES: { @@ -575,6 +495,7 @@ const CONST = { CREATED: 'CREATED', IOU: 'IOU', MODIFIEDEXPENSE: 'MODIFIEDEXPENSE', + MOVED: 'MOVED', REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED', RENAMED: 'RENAMED', REPORTPREVIEW: 'REPORTPREVIEW', @@ -635,6 +556,7 @@ const CONST = { UPDATE_REIMBURSEMENT_CHOICE: 'POLICYCHANGELOG_UPDATE_REIMBURSEMENT_CHOICE', UPDATE_REPORT_FIELD: 'POLICYCHANGELOG_UPDATE_REPORT_FIELD', UPDATE_TAG: 'POLICYCHANGELOG_UPDATE_TAG', + UPDATE_TAG_ENABLED: 'POLICYCHANGELOG_UPDATE_TAG_ENABLED', UPDATE_TAG_LIST_NAME: 'POLICYCHANGELOG_UPDATE_TAG_LIST_NAME', UPDATE_TAG_NAME: 'POLICYCHANGELOG_UPDATE_TAG_NAME', UPDATE_TIME_ENABLED: 'POLICYCHANGELOG_UPDATE_TIME_ENABLED', @@ -1189,7 +1111,8 @@ const CONST = { PAYMENT_METHODS: { DEBIT_CARD: 'debitCard', - BANK_ACCOUNT: 'bankAccount', + PERSONAL_BANK_ACCOUNT: 'bankAccount', + BUSINESS_BANK_ACCOUNT: 'businessBankAccount', }, PAYMENT_METHOD_ID_KEYS: { @@ -1232,6 +1155,7 @@ const CONST = { DOCX: 'docx', SVG: 'svg', }, + RECEIPT_ERROR: 'receiptError', }, GROWL: { @@ -1278,7 +1202,11 @@ const CONST = { TYPE: { FREE: 'free', PERSONAL: 'personal', + + // Often referred to as "control" workspaces CORPORATE: 'corporate', + + // Often referred to as "collect" workspaces TEAM: 'team', }, ROLE: { @@ -2880,28 +2808,49 @@ 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'], - /** - * native IDs for close buttons in Overlay component - */ - OVERLAY: { - TOP_BUTTON_NATIVE_ID: 'overLayTopButton', - BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton', - }, - - BACK_BUTTON_NATIVE_ID: 'backButton', REFERRAL_PROGRAM: { CONTENT_TYPES: { MONEY_REQUEST: 'request', START_CHAT: 'startChat', SEND_MONEY: 'sendMoney', REFER_FRIEND: 'referralFriend', + SHARE_CODE: 'shareCode', }, 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', }, + + /** + * native IDs for close buttons in Overlay component + */ + OVERLAY: { + TOP_BUTTON_NATIVE_ID: 'overLayTopButton', + BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton', + }, + + BACK_BUTTON_NATIVE_ID: 'backButton', + + /** + * The maximum count of items per page for OptionsSelector. + * When paginate, it multiplies by page number. + */ + MAX_OPTIONS_SELECTOR_PAGE_LENGTH: 500, + + /** + * Performance test setup - run the same test multiple times to get a more accurate result + */ + PERFORMANCE_TESTS: { + RUNS: 20, + }, + + /** + * Constants for maxToRenderPerBatch parameter that is used for FlatList or SectionList. This controls the amount of items rendered per batch, which is the next chunk of items rendered on every scroll. + */ + MAX_TO_RENDER_PER_BATCH: { + DEFAULT: 5, + CAROUSEL: 3, + }, } as const; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a5a969adb833..5576eb64736d 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -84,6 +84,9 @@ const ONYXKEYS = { /** Contains all the users settings for the Settings page and sub pages */ USER: 'user', + /** Contains latitude and longitude of user's last known location */ + USER_LOCATION: 'userLocation', + /** Contains metadata (partner, login, validation date) for all of the user's logins */ LOGIN_LIST: 'loginList', @@ -246,6 +249,7 @@ const ONYXKEYS = { POLICY_TAGS: 'policyTags_', POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_', WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_', + WORKSPACE_INVITE_MESSAGE_DRAFT: 'workspaceInviteMessageDraft_', REPORT: 'report_', // REPORT_METADATA is a perf optimization used to hold loading states (isLoadingInitialReportActions, isLoadingOlderReportActions, isLoadingNewerReportActions). // A lot of components are connected to the Report entity and do not care about the actions. Setting the loading state @@ -261,6 +265,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 +341,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; @@ -366,6 +375,7 @@ type OnyxValues = { [ONYXKEYS.COUNTRY_CODE]: number; [ONYXKEYS.COUNTRY]: string; [ONYXKEYS.USER]: OnyxTypes.User; + [ONYXKEYS.USER_LOCATION]: OnyxTypes.UserLocation; [ONYXKEYS.LOGIN_LIST]: Record; [ONYXKEYS.SESSION]: OnyxTypes.Session; [ONYXKEYS.BETAS]: OnyxTypes.Beta[]; @@ -500,6 +510,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..a96b229ef4c1 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1,30 +1,28 @@ -import {ValueOf} from 'type-fest'; +import {IsEqual, ValueOf} from 'type-fest'; import CONST from './CONST'; -/** - * This is a file containing constants for all the routes we want to be able to go to - */ +// This is a file containing constants for all the routes we want to be able to go to /** * Builds a URL with an encoded URI component for the `backTo` param which can be added to the end of URLs */ -function getUrlWithBackToParam(url: string, backTo?: string): string { - const backToParam = backTo ? `${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` : ''; - return url + backToParam; +function getUrlWithBackToParam(url: TUrl, backTo?: string): `${TUrl}` | `${TUrl}?backTo=${string}` | `${TUrl}&backTo=${string}` { + const backToParam = backTo ? (`${url.includes('?') ? '&' : '?'}backTo=${encodeURIComponent(backTo)}` as const) : ''; + return `${url}${backToParam}` as const; } -export default { +const ROUTES = { HOME: '', /** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */ CONCIERGE: 'concierge', FLAG_COMMENT: { route: 'flag/:reportID/:reportActionID', - getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`, + getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}` as const, }, SEARCH: 'search', DETAILS: { route: 'details', - getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`, + getRoute: (login: string) => `details?login=${encodeURIComponent(login)}` as const, }, PROFILE: { route: 'a/:accountID', @@ -35,7 +33,7 @@ export default { VALIDATE_LOGIN: 'v/:accountID/:validateCode', GET_ASSISTANCE: { route: 'get-assistance/:taskID', - getRoute: (taskID: string) => `get-assistance/${taskID}`, + getRoute: (taskID: string) => `get-assistance/${taskID}` as const, }, UNLINK_LOGIN: 'u/:accountID/:validateCode', APPLE_SIGN_IN: 'sign-in-with-apple', @@ -54,7 +52,7 @@ export default { BANK_ACCOUNT_PERSONAL: 'bank-account/personal', BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/:stepToOpen?', - getRoute: (stepToOpen = '', policyID = '', backTo?: string): string => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), + getRoute: (stepToOpen = '', policyID = '', backTo?: string) => getUrlWithBackToParam(`bank-account/${stepToOpen}?policyID=${policyID}`, backTo), }, SETTINGS: 'settings', @@ -77,28 +75,44 @@ export default { SETTINGS_WALLET: 'settings/wallet', SETTINGS_WALLET_DOMAINCARD: { route: '/settings/wallet/card/:domain', - getRoute: (domain: string) => `/settings/wallet/card/${domain}`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}` as const, }, SETTINGS_REPORT_FRAUD: { route: '/settings/wallet/card/:domain/report-virtual-fraud', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud` as const, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { + route: '/settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name` as const, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { + route: '/settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone` as const, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { + route: '/settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address` as const, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { + route: '/settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm` as const, }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', - getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address`, + getRoute: (domain: string) => `settings/wallet/card/${domain}/digital-details/update-address` as const, }, SETTINGS_WALLET_TRANSFER_BALANCE: 'settings/wallet/transfer-balance', SETTINGS_WALLET_CHOOSE_TRANSFER_ACCOUNT: 'settings/wallet/choose-transfer-account', SETTINGS_WALLET_REPORT_CARD_LOST_OR_DAMAGED: { route: '/settings/wallet/card/:domain/report-card-lost-or-damaged', - getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged`, + getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-card-lost-or-damaged` as const, }, SETTINGS_WALLET_CARD_ACTIVATE: { route: 'settings/wallet/card/:domain/activate', - getRoute: (domain: string) => `settings/wallet/card/${domain}/activate`, + getRoute: (domain: string) => `settings/wallet/card/${domain}/activate` as const, }, SETTINGS_PERSONAL_DETAILS: 'settings/profile/personal-details', SETTINGS_PERSONAL_DETAILS_LEGAL_NAME: 'settings/profile/personal-details/legal-name', @@ -114,7 +128,7 @@ export default { }, SETTINGS_CONTACT_METHOD_DETAILS: { route: 'settings/profile/contact-methods/:contactMethod/details', - getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details`, + getRoute: (contactMethod: string) => `settings/profile/contact-methods/${encodeURIComponent(contactMethod)}/details` as const, }, SETTINGS_NEW_CONTACT_METHOD: 'settings/profile/contact-methods/new', SETTINGS_2FA: 'settings/security/two-factor-auth', @@ -130,157 +144,158 @@ export default { REPORT: 'r', REPORT_WITH_ID: { route: 'r/:reportID?/:reportActionID?', - getRoute: (reportID: string) => `r/${reportID}`, + getRoute: (reportID: string) => `r/${reportID}` as const, }, EDIT_REQUEST: { route: 'r/:threadReportID/edit/:field', - getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`, + getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}` as const, }, EDIT_CURRENCY_REQUEST: { route: 'r/:threadReportID/edit/currency', - getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}`, + getRoute: (threadReportID: string, currency: string, backTo: string) => `r/${threadReportID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, }, REPORT_WITH_ID_DETAILS_SHARE_CODE: { route: 'r/:reportID/details/shareCode', - getRoute: (reportID: string) => `r/${reportID}/details/shareCode`, + getRoute: (reportID: string) => `r/${reportID}/details/shareCode` as const, }, REPORT_ATTACHMENTS: { route: 'r/:reportID/attachment', - getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`, + getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}` as const, }, REPORT_PARTICIPANTS: { route: 'r/:reportID/participants', - getRoute: (reportID: string) => `r/${reportID}/participants`, + getRoute: (reportID: string) => `r/${reportID}/participants` as const, }, REPORT_WITH_ID_DETAILS: { route: 'r/:reportID/details', - getRoute: (reportID: string) => `r/${reportID}/details`, + getRoute: (reportID: string) => `r/${reportID}/details` as const, }, REPORT_SETTINGS: { route: 'r/:reportID/settings', - getRoute: (reportID: string) => `r/${reportID}/settings`, + getRoute: (reportID: string) => `r/${reportID}/settings` as const, }, REPORT_SETTINGS_ROOM_NAME: { route: 'r/:reportID/settings/room-name', - getRoute: (reportID: string) => `r/${reportID}/settings/room-name`, + getRoute: (reportID: string) => `r/${reportID}/settings/room-name` as const, }, REPORT_SETTINGS_NOTIFICATION_PREFERENCES: { route: 'r/:reportID/settings/notification-preferences', - getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`, + getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences` as const, }, REPORT_SETTINGS_WRITE_CAPABILITY: { route: 'r/:reportID/settings/who-can-post', - getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`, + getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post` as const, }, REPORT_WELCOME_MESSAGE: { route: 'r/:reportID/welcomeMessage', - getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`, + getRoute: (reportID: string) => `r/${reportID}/welcomeMessage` as const, }, SPLIT_BILL_DETAILS: { route: 'r/:reportID/split/:reportActionID', - getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`, + getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}` as const, }, EDIT_SPLIT_BILL: { route: `r/:reportID/split/:reportActionID/edit/:field`, - getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}`, + getRoute: (reportID: string, reportActionID: string, field: ValueOf) => `r/${reportID}/split/${reportActionID}/edit/${field}` as const, }, EDIT_SPLIT_BILL_CURRENCY: { route: 'r/:reportID/split/:reportActionID/edit/currency', - getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}`, + getRoute: (reportID: string, reportActionID: string, currency: string, backTo: string) => + `r/${reportID}/split/${reportActionID}/edit/currency?currency=${currency}&backTo=${backTo}` as const, }, TASK_TITLE: { route: 'r/:reportID/title', - getRoute: (reportID: string) => `r/${reportID}/title`, + getRoute: (reportID: string) => `r/${reportID}/title` as const, }, TASK_DESCRIPTION: { route: 'r/:reportID/description', - getRoute: (reportID: string) => `r/${reportID}/description`, + getRoute: (reportID: string) => `r/${reportID}/description` as const, }, TASK_ASSIGNEE: { route: 'r/:reportID/assignee', - getRoute: (reportID: string) => `r/${reportID}/assignee`, + getRoute: (reportID: string) => `r/${reportID}/assignee` as const, }, PRIVATE_NOTES_VIEW: { route: 'r/:reportID/notes/:accountID', - getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`, + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}` as const, }, PRIVATE_NOTES_LIST: { route: 'r/:reportID/notes', - getRoute: (reportID: string) => `r/${reportID}/notes`, + getRoute: (reportID: string) => `r/${reportID}/notes` as const, }, PRIVATE_NOTES_EDIT: { route: 'r/:reportID/notes/:accountID/edit', - getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`, + getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit` as const, }, ROOM_MEMBERS: { route: 'r/:reportID/members', - getRoute: (reportID: string) => `r/${reportID}/members`, + getRoute: (reportID: string) => `r/${reportID}/members` as const, }, ROOM_INVITE: { route: 'r/:reportID/invite', - getRoute: (reportID: string) => `r/${reportID}/invite`, + getRoute: (reportID: string) => `r/${reportID}/invite` as const, }, // To see the available iouType, please refer to CONST.IOU.TYPE MONEY_REQUEST: { route: ':iouType/new/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}` as const, }, MONEY_REQUEST_AMOUNT: { route: ':iouType/new/amount/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}` as const, }, MONEY_REQUEST_PARTICIPANTS: { route: ':iouType/new/participants/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}` as const, }, MONEY_REQUEST_CONFIRMATION: { route: ':iouType/new/confirmation/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}` as const, }, MONEY_REQUEST_DATE: { route: ':iouType/new/date/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}` as const, }, MONEY_REQUEST_CURRENCY: { route: ':iouType/new/currency/:reportID?', - getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}`, + getRoute: (iouType: string, reportID: string, currency: string, backTo: string) => `${iouType}/new/currency/${reportID}?currency=${currency}&backTo=${backTo}` as const, }, MONEY_REQUEST_DESCRIPTION: { route: ':iouType/new/description/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}` as const, }, MONEY_REQUEST_CATEGORY: { route: ':iouType/new/category/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}` as const, }, MONEY_REQUEST_TAG: { route: ':iouType/new/tag/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}` as const, }, MONEY_REQUEST_MERCHANT: { route: ':iouType/new/merchant/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}` as const, }, MONEY_REQUEST_WAYPOINT: { route: ':iouType/new/waypoint/:waypointIndex', - getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`, + getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}` as const, }, MONEY_REQUEST_RECEIPT: { route: ':iouType/new/receipt/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}` as const, }, MONEY_REQUEST_DISTANCE: { route: ':iouType/new/address/:reportID?', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}` as const, }, MONEY_REQUEST_EDIT_WAYPOINT: { route: 'r/:threadReportID/edit/distance/:transactionID/waypoint/:waypointIndex', - getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}`, + getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}` as const, }, MONEY_REQUEST_DISTANCE_TAB: { route: ':iouType/new/:reportID?/distance', - getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`, + getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance` as const, }, MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual', MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan', @@ -305,63 +320,63 @@ export default { ERECEIPT: { route: 'eReceipt/:transactionID', - getRoute: (transactionID: string) => `eReceipt/${transactionID}`, + getRoute: (transactionID: string) => `eReceipt/${transactionID}` as const, }, WORKSPACE_NEW: 'workspace/new', WORKSPACE_NEW_ROOM: 'workspace/new-room', WORKSPACE_INITIAL: { route: 'workspace/:policyID', - getRoute: (policyID: string) => `workspace/${policyID}`, + getRoute: (policyID: string) => `workspace/${policyID}` as const, }, WORKSPACE_INVITE: { route: 'workspace/:policyID/invite', - getRoute: (policyID: string) => `workspace/${policyID}/invite`, + getRoute: (policyID: string) => `workspace/${policyID}/invite` as const, }, WORKSPACE_INVITE_MESSAGE: { route: 'workspace/:policyID/invite-message', - getRoute: (policyID: string) => `workspace/${policyID}/invite-message`, + getRoute: (policyID: string) => `workspace/${policyID}/invite-message` as const, }, WORKSPACE_SETTINGS: { route: 'workspace/:policyID/settings', - getRoute: (policyID: string) => `workspace/${policyID}/settings`, + getRoute: (policyID: string) => `workspace/${policyID}/settings` as const, }, WORKSPACE_SETTINGS_CURRENCY: { route: 'workspace/:policyID/settings/currency', - getRoute: (policyID: string) => `workspace/${policyID}/settings/currency`, + getRoute: (policyID: string) => `workspace/${policyID}/settings/currency` as const, }, WORKSPACE_CARD: { route: 'workspace/:policyID/card', - getRoute: (policyID: string) => `workspace/${policyID}/card`, + getRoute: (policyID: string) => `workspace/${policyID}/card` as const, }, WORKSPACE_REIMBURSE: { route: 'workspace/:policyID/reimburse', - getRoute: (policyID: string) => `workspace/${policyID}/reimburse`, + getRoute: (policyID: string) => `workspace/${policyID}/reimburse` as const, }, WORKSPACE_RATE_AND_UNIT: { route: 'workspace/:policyID/rateandunit', - getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`, + getRoute: (policyID: string) => `workspace/${policyID}/rateandunit` as const, }, WORKSPACE_BILLS: { route: 'workspace/:policyID/bills', - getRoute: (policyID: string) => `workspace/${policyID}/bills`, + getRoute: (policyID: string) => `workspace/${policyID}/bills` as const, }, WORKSPACE_INVOICES: { route: 'workspace/:policyID/invoices', - getRoute: (policyID: string) => `workspace/${policyID}/invoices`, + getRoute: (policyID: string) => `workspace/${policyID}/invoices` as const, }, WORKSPACE_TRAVEL: { route: 'workspace/:policyID/travel', - getRoute: (policyID: string) => `workspace/${policyID}/travel`, + getRoute: (policyID: string) => `workspace/${policyID}/travel` as const, }, WORKSPACE_MEMBERS: { route: 'workspace/:policyID/members', - getRoute: (policyID: string) => `workspace/${policyID}/members`, + getRoute: (policyID: string) => `workspace/${policyID}/members` as const, }, // Referral program promotion REFERRAL_DETAILS_MODAL: { route: 'referral/:contentType', - getRoute: (contentType: string) => `referral/${contentType}`, + getRoute: (contentType: string) => `referral/${contentType}` as const, }, // These are some one-off routes that will be removed once they're no longer needed (see GH issues for details) @@ -369,3 +384,24 @@ export default { SBE: 'sbe', MONEY2020: 'money2020', } as const; + +export default ROUTES; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type ExtractRouteName = TRoute extends {getRoute: (...args: any[]) => infer TRouteName} ? TRouteName : TRoute; + +type AllRoutes = { + [K in keyof typeof ROUTES]: ExtractRouteName<(typeof ROUTES)[K]>; +}[keyof typeof ROUTES]; + +type RouteIsPlainString = IsEqual; + +/** + * Represents all routes in the app as a union of literal strings. + * + * If this type resolves to `never`, it implies that one or more routes defined within `ROUTES` have not correctly used + * `as const` in their `getRoute` function return value. + */ +type Route = RouteIsPlainString extends true ? never : AllRoutes; + +export type {Route}; diff --git a/src/SCREENS.ts b/src/SCREENS.ts index afc368858f55..f4cbcf4f2564 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -9,13 +9,13 @@ const PROTECTED_SCREENS = { REPORT_ATTACHMENTS: 'ReportAttachments', } as const; -export default { +const SCREENS = { ...PROTECTED_SCREENS, - LOADING: 'Loading', REPORT: 'Report', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', + UNLINK_LOGIN: 'UnlinkLogin', SETTINGS: { ROOT: 'Settings_Root', PREFERENCES: 'Settings_Preferences', @@ -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', @@ -34,4 +40,5 @@ export default { SAML_SIGN_IN: 'SAMLSignIn', } as const; +export default SCREENS; export {PROTECTED_SCREENS}; diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js index 252c8380b062..4d01fa108e2a 100644 --- a/src/components/AddPaymentMethodMenu.js +++ b/src/components/AddPaymentMethodMenu.js @@ -1,15 +1,18 @@ +import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React from 'react'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; import compose from '@libs/compose'; -import Permissions from '@libs/Permissions'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import iouReportPropTypes from '@pages/iouReportPropTypes'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import * as Expensicons from './Icon/Expensicons'; import PopoverMenu from './PopoverMenu'; import refPropTypes from './refPropTypes'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; import withWindowDimensions from './withWindowDimensions'; const propTypes = { @@ -19,6 +22,12 @@ const propTypes = { /** Callback to execute when the component closes. */ onClose: PropTypes.func.isRequired, + /** Callback to execute when the payment method is selected. */ + onItemSelected: PropTypes.func.isRequired, + + /** The IOU/Expense report we are paying */ + iouReport: iouReportPropTypes, + /** Anchor position for the AddPaymentMenu. */ anchorPosition: PropTypes.shape({ horizontal: PropTypes.number, @@ -31,51 +40,72 @@ const propTypes = { vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), }), - /** List of betas available to current user */ - betas: PropTypes.arrayOf(PropTypes.string), - /** Popover anchor ref */ anchorRef: refPropTypes, - ...withLocalizePropTypes, + /** Session info for the currently logged in user. */ + session: PropTypes.shape({ + /** Currently logged in user accountID */ + accountID: PropTypes.number, + }), }; const defaultProps = { + iouReport: {}, anchorPosition: {}, anchorAlignment: { horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM, }, - betas: [], anchorRef: () => {}, + session: {}, }; -function AddPaymentMethodMenu(props) { +function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session}) { + const {translate} = useLocalize(); + + // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report + // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee. + const canUseBusinessBankAccount = + ReportUtils.isExpenseReport(iouReport) || + (ReportUtils.isIOUReport(iouReport) && !ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0))); + return ( { - props.onItemSelected(CONST.PAYMENT_METHODS.BANK_ACCOUNT); - }, - }, - ...(Permissions.canUseWallet(props.betas) + ...(ReportUtils.isIOUReport(iouReport) ? [ { - text: props.translate('common.debitCard'), - icon: Expensicons.CreditCard, - onSelected: () => props.onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD), + text: translate('common.personalBankAccount'), + icon: Expensicons.Bank, + onSelected: () => { + onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); + }, }, ] : []), + ...(canUseBusinessBankAccount + ? [ + { + text: translate('common.businessBankAccount'), + icon: Expensicons.Building, + onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT), + }, + ] + : []), + ...[ + { + text: translate('common.debitCard'), + icon: Expensicons.CreditCard, + onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD), + }, + ], ]} withoutOverlay /> @@ -88,10 +118,9 @@ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu'; export default compose( withWindowDimensions, - withLocalize, withOnyx({ - betas: { - key: ONYXKEYS.BETAS, + session: { + key: ONYXKEYS.SESSION, }, }), )(AddPaymentMethodMenu); diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index ec4ddd623929..0b23704b5b26 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -209,7 +209,7 @@ function AddPlaidBankAccount({ // Handle Plaid login errors (will potentially reset plaid token and item depending on the error) if (event === 'ERROR') { Log.hmmm('[PlaidLink] Error: ', metadata); - if (bankAccountID && metadata.error_code) { + if (bankAccountID && metadata && metadata.error_code) { BankAccounts.handlePlaidError(bankAccountID, metadata.error_code, metadata.error_message, metadata.request_id); } } diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js new file mode 100644 index 000000000000..dfea017692ef --- /dev/null +++ b/src/components/AddressForm.js @@ -0,0 +1,222 @@ +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/AddressSearch/index.js b/src/components/AddressSearch/index.js index 73472beeb48d..a401300e920d 100644 --- a/src/components/AddressSearch/index.js +++ b/src/components/AddressSearch/index.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {ActivityIndicator, Keyboard, LogBox, ScrollView, Text, View} from 'react-native'; import {GooglePlacesAutocomplete} from 'react-native-google-places-autocomplete'; import _ from 'underscore'; @@ -140,29 +140,48 @@ const defaultProps = { resultTypes: 'address', }; -// Do not convert to class component! It's been tried before and presents more challenges than it's worth. -// Relevant thread: https://expensify.slack.com/archives/C03TQ48KC/p1634088400387400 -// Reference: https://github.com/FaridSafi/react-native-google-places-autocomplete/issues/609#issuecomment-886133839 -function AddressSearch(props) { +function AddressSearch({ + canUseCurrentLocation, + containerStyles, + defaultValue, + errorText, + hint, + innerRef, + inputID, + isLimitedToUSA, + label, + maxInputLength, + network, + onBlur, + onInputChange, + onPress, + predefinedPlaces, + preferredLocale, + renamedInputKeys, + resultTypes, + shouldSaveDraft, + translate, + value, +}) { const theme = useTheme(); const styles = useThemeStyles(); const [displayListViewBorder, setDisplayListViewBorder] = useState(false); const [isTyping, setIsTyping] = useState(false); const [isFocused, setIsFocused] = useState(false); - const [searchValue, setSearchValue] = useState(props.value || props.defaultValue || ''); + const [searchValue, setSearchValue] = useState(value || defaultValue || ''); const [locationErrorCode, setLocationErrorCode] = useState(null); const [isFetchingCurrentLocation, setIsFetchingCurrentLocation] = useState(false); const shouldTriggerGeolocationCallbacks = useRef(true); const containerRef = useRef(); const query = useMemo( () => ({ - language: props.preferredLocale, - types: props.resultTypes, - components: props.isLimitedToUSA ? 'country:us' : undefined, + language: preferredLocale, + types: resultTypes, + components: isLimitedToUSA ? 'country:us' : undefined, }), - [props.preferredLocale, props.resultTypes, props.isLimitedToUSA], + [preferredLocale, resultTypes, isLimitedToUSA], ); - const shouldShowCurrentLocationButton = props.canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; + const shouldShowCurrentLocationButton = canUseCurrentLocation && searchValue.trim().length === 0 && isFocused; const saveLocationDetails = (autocompleteData, details) => { const addressComponents = details.address_components; @@ -171,7 +190,7 @@ function AddressSearch(props) { // to this component which don't match the usual properties coming from auto-complete. In that case, only a limited // amount of data massaging needs to happen for what the parent expects to get from this function. if (_.size(details)) { - props.onPress({ + onPress({ address: lodashGet(details, 'description'), lat: lodashGet(details, 'geometry.location.lat', 0), lng: lodashGet(details, 'geometry.location.lng', 0), @@ -269,7 +288,7 @@ function AddressSearch(props) { // Not all pages define the Address Line 2 field, so in that case we append any additional address details // (e.g. Apt #) to Address Line 1 - if (subpremise && typeof props.renamedInputKeys.street2 === 'undefined') { + if (subpremise && typeof renamedInputKeys.street2 === 'undefined') { values.street += `, ${subpremise}`; } @@ -278,19 +297,19 @@ function AddressSearch(props) { values.country = country; } - if (props.inputID) { - _.each(values, (value, key) => { - const inputKey = lodashGet(props.renamedInputKeys, key, key); + if (inputID) { + _.each(values, (inputValue, key) => { + const inputKey = lodashGet(renamedInputKeys, key, key); if (!inputKey) { return; } - props.onInputChange(value, inputKey); + onInputChange(inputValue, inputKey); }); } else { - props.onInputChange(values); + onInputChange(values); } - props.onPress(values); + onPress(values); }; /** Gets the user's current location and registers success/error callbacks */ @@ -320,7 +339,7 @@ function AddressSearch(props) { lng: successData.coords.longitude, address: CONST.YOUR_LOCATION_TEXT, }; - props.onPress(location); + onPress(location); }, (errorData) => { if (!shouldTriggerGeolocationCallbacks.current) { @@ -338,16 +357,16 @@ function AddressSearch(props) { }; const renderHeaderComponent = () => - props.predefinedPlaces.length > 0 && ( + predefinedPlaces.length > 0 && ( <> {/* This will show current location button in list if there are some recent destinations */} {shouldShowCurrentLocationButton && ( )} - {!props.value && {props.translate('common.recentDestinations')}} + {!value && {translate('common.recentDestinations')}} ); @@ -359,6 +378,26 @@ function AddressSearch(props) { }; }, []); + const listEmptyComponent = useCallback( + () => + network.isOffline || !isTyping ? null : ( + {translate('common.noResultsFound')} + ), + [network.isOffline, isTyping, styles, translate], + ); + + const listLoader = useCallback( + () => ( + + + + ), + [styles.pv4, theme.spinner], + ); + return ( /* * The GooglePlacesAutocomplete component uses a VirtualizedList internally, @@ -385,20 +424,10 @@ function AddressSearch(props) { fetchDetails suppressDefaultStyles enablePoweredByContainer={false} - predefinedPlaces={props.predefinedPlaces} - listEmptyComponent={ - props.network.isOffline || !isTyping ? null : ( - {props.translate('common.noResultsFound')} - ) - } - listLoaderComponent={ - - - - } + predefinedPlaces={predefinedPlaces} + listEmptyComponent={listEmptyComponent} + listLoaderComponent={listLoader} + renderHeaderComponent={renderHeaderComponent} renderRow={(data) => { const title = data.isPredefinedPlace ? data.name : data.structured_formatting.main_text; const subtitle = data.isPredefinedPlace ? data.description : data.structured_formatting.secondary_text; @@ -409,7 +438,6 @@ function AddressSearch(props) { ); }} - renderHeaderComponent={renderHeaderComponent} onPress={(data, details) => { saveLocationDetails(data, details); setIsTyping(false); @@ -424,34 +452,31 @@ function AddressSearch(props) { query={query} requestUrl={{ useOnPlatform: 'all', - url: props.network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), + url: network.isOffline ? null : ApiUtils.getCommandURL({command: 'Proxy_GooglePlaces&proxyUrl='}), }} textInputProps={{ InputComp: TextInput, ref: (node) => { - if (!props.innerRef) { + if (!innerRef) { return; } - if (_.isFunction(props.innerRef)) { - props.innerRef(node); + if (_.isFunction(innerRef)) { + innerRef(node); return; } // eslint-disable-next-line no-param-reassign - props.innerRef.current = node; + innerRef.current = node; }, - label: props.label, - containerStyles: props.containerStyles, - errorText: props.errorText, - hint: - displayListViewBorder || (props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (props.canUseCurrentLocation && isTyping) - ? undefined - : props.hint, - value: props.value, - defaultValue: props.defaultValue, - inputID: props.inputID, - shouldSaveDraft: props.shouldSaveDraft, + label, + containerStyles, + errorText, + hint: displayListViewBorder || (predefinedPlaces.length === 0 && shouldShowCurrentLocationButton) || (canUseCurrentLocation && isTyping) ? undefined : hint, + value, + defaultValue, + inputID, + shouldSaveDraft, onFocus: () => { setIsFocused(true); }, @@ -461,24 +486,24 @@ function AddressSearch(props) { setIsFocused(false); setIsTyping(false); } - props.onBlur(); + onBlur(); }, autoComplete: 'off', onInputChange: (text) => { setSearchValue(text); setIsTyping(true); - if (props.inputID) { - props.onInputChange(text); + if (inputID) { + onInputChange(text); } else { - props.onInputChange({street: text}); + onInputChange({street: text}); } // If the text is empty and we have no predefined places, we set displayListViewBorder to false to prevent UI flickering - if (_.isEmpty(text) && _.isEmpty(props.predefinedPlaces)) { + if (_.isEmpty(text) && _.isEmpty(predefinedPlaces)) { setDisplayListViewBorder(false); } }, - maxLength: props.maxInputLength, + maxLength: maxInputLength, spellCheck: false, selectTextOnFocus: true, }} @@ -500,17 +525,18 @@ function AddressSearch(props) { }} inbetweenCompo={ // We want to show the current location button even if there are no recent destinations - props.predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( + predefinedPlaces.length === 0 && shouldShowCurrentLocationButton ? ( ) : ( <> ) } + placeholder="" /> setLocationErrorCode(null)} 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/ArchivedReportFooter.js b/src/components/ArchivedReportFooter.js deleted file mode 100644 index b1fac827d273..000000000000 --- a/src/components/ArchivedReportFooter.js +++ /dev/null @@ -1,105 +0,0 @@ -import _ from 'lodash'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {withOnyx} from 'react-native-onyx'; -import compose from '@libs/compose'; -import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; -import * as ReportActionsUtils from '@libs/ReportActionsUtils'; -import * as ReportUtils from '@libs/ReportUtils'; -import personalDetailsPropType from '@pages/personalDetailsPropType'; -import reportPropTypes from '@pages/reportPropTypes'; -import useThemeStyles from '@styles/useThemeStyles'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import Banner from './Banner'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; - -const propTypes = { - /** The reason this report was archived */ - reportClosedAction: PropTypes.shape({ - /** Message attached to the report closed action */ - originalMessage: PropTypes.shape({ - /** The reason the report was closed */ - reason: PropTypes.string.isRequired, - - /** (For accountMerged reason only), the accountID of the previous owner of this report. */ - oldAccountID: PropTypes.number, - - /** (For accountMerged reason only), the accountID of the account the previous owner was merged into */ - newAccountID: PropTypes.number, - }).isRequired, - }), - - /** The archived report */ - report: reportPropTypes.isRequired, - - /** Personal details of all users */ - personalDetails: PropTypes.objectOf(personalDetailsPropType), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - reportClosedAction: { - originalMessage: { - reason: CONST.REPORT.ARCHIVE_REASON.DEFAULT, - }, - }, - personalDetails: {}, -}; - -function ArchivedReportFooter(props) { - const styles = useThemeStyles(); - const archiveReason = lodashGet(props.reportClosedAction, 'originalMessage.reason', CONST.REPORT.ARCHIVE_REASON.DEFAULT); - let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [props.report.ownerAccountID, 'displayName']); - - let oldDisplayName; - if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { - const newAccountID = props.reportClosedAction.originalMessage.newAccountID; - const oldAccountID = props.reportClosedAction.originalMessage.oldAccountID; - displayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [newAccountID, 'displayName']); - oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(props.personalDetails, [oldAccountID, 'displayName']); - } - - const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT; - - let policyName = ReportUtils.getPolicyName(props.report); - - if (shouldRenderHTML) { - oldDisplayName = _.escape(oldDisplayName); - displayName = _.escape(displayName); - policyName = _.escape(policyName); - } - - return ( - ${displayName}`, - oldDisplayName: `${oldDisplayName}`, - policyName: `${policyName}`, - })} - shouldRenderHTML={shouldRenderHTML} - shouldShowIcon - /> - ); -} - -ArchivedReportFooter.propTypes = propTypes; -ArchivedReportFooter.defaultProps = defaultProps; -ArchivedReportFooter.displayName = 'ArchivedReportFooter'; - -export default compose( - withLocalize, - withOnyx({ - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - reportClosedAction: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - selector: ReportActionsUtils.getLastClosedReportAction, - }, - }), -)(ArchivedReportFooter); diff --git a/src/components/ArchivedReportFooter.tsx b/src/components/ArchivedReportFooter.tsx new file mode 100644 index 000000000000..3187bf3604e8 --- /dev/null +++ b/src/components/ArchivedReportFooter.tsx @@ -0,0 +1,82 @@ +import lodashEscape from 'lodash/escape'; +import React from 'react'; +import {OnyxEntry, withOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; +import * as ReportUtils from '@libs/ReportUtils'; +import useThemeStyles from '@styles/useThemeStyles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx'; +import Banner from './Banner'; + +type ArchivedReportFooterOnyxProps = { + /** The reason this report was archived */ + reportClosedAction: OnyxEntry; + + /** Personal details of all users */ + personalDetails: OnyxEntry>; +}; + +type ArchivedReportFooterProps = ArchivedReportFooterOnyxProps & { + /** The archived report */ + report: Report; +}; + +function ArchivedReportFooter({report, reportClosedAction, personalDetails = {}}: ArchivedReportFooterProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + + const originalMessage = reportClosedAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CLOSED ? reportClosedAction.originalMessage : null; + const archiveReason = originalMessage?.reason ?? CONST.REPORT.ARCHIVE_REASON.DEFAULT; + let displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [report.ownerAccountID, 'displayName']); + + let oldDisplayName: string | undefined; + if (archiveReason === CONST.REPORT.ARCHIVE_REASON.ACCOUNT_MERGED) { + const newAccountID = originalMessage?.newAccountID; + const oldAccountID = originalMessage?.oldAccountID; + displayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [newAccountID, 'displayName']); + oldDisplayName = PersonalDetailsUtils.getDisplayNameOrDefault(personalDetails, [oldAccountID, 'displayName']); + } + + const shouldRenderHTML = archiveReason !== CONST.REPORT.ARCHIVE_REASON.DEFAULT; + + let policyName = ReportUtils.getPolicyName(report); + + if (shouldRenderHTML) { + oldDisplayName = lodashEscape(oldDisplayName); + displayName = lodashEscape(displayName); + policyName = lodashEscape(policyName); + } + + const text = shouldRenderHTML + ? translate(`reportArchiveReasons.${archiveReason}`, { + displayName: `${displayName}`, + oldDisplayName: `${oldDisplayName}`, + policyName: `${policyName}`, + }) + : translate(`reportArchiveReasons.${archiveReason}`); + + return ( + + ); +} + +ArchivedReportFooter.displayName = 'ArchivedReportFooter'; + +export default withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + reportClosedAction: { + key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, + canEvict: false, + selector: ReportActionsUtils.getLastClosedReportAction, + }, +})(ArchivedReportFooter); diff --git a/src/components/AttachmentModal.js b/src/components/AttachmentModal.js index 4ab81ae462c9..d346f271b36d 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -128,6 +128,8 @@ function AttachmentModal(props) { const [isDownloadButtonReadyToBeShown, setIsDownloadButtonReadyToBeShown] = React.useState(true); const {windowWidth} = useWindowDimensions(); + const isOverlayModalVisible = (isAttachmentReceipt && isDeleteReceiptConfirmModalVisible) || (!isAttachmentReceipt && isAttachmentInvalid); + const [file, setFile] = useState( props.originalFileName ? { @@ -406,7 +408,7 @@ function AttachmentModal(props) { { diff --git a/src/components/Attachments/AttachmentCarousel/index.js b/src/components/Attachments/AttachmentCarousel/index.js index fa4ff50512d0..141e619e489e 100644 --- a/src/components/Attachments/AttachmentCarousel/index.js +++ b/src/components/Attachments/AttachmentCarousel/index.js @@ -12,6 +12,7 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import Navigation from '@libs/Navigation/Navigation'; import useThemeStyles from '@styles/useThemeStyles'; import variables from '@styles/variables'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import AttachmentCarouselCellRenderer from './AttachmentCarouselCellRenderer'; import {defaultProps, propTypes} from './attachmentCarouselPropTypes'; @@ -203,7 +204,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, initialScrollIndex={page} initialNumToRender={3} windowSize={5} - maxToRenderPerBatch={3} + maxToRenderPerBatch={CONST.MAX_TO_RENDER_PER_BATCH.CAROUSEL} data={attachments} CellRendererComponent={AttachmentCarouselCellRenderer} renderItem={renderItem} diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js deleted file mode 100644 index 27790121aab0..000000000000 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ /dev/null @@ -1,124 +0,0 @@ -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 Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; -import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import * as StyleUtils from '@styles/StyleUtils'; -import useThemeStyles from '@styles/useThemeStyles'; -import CONST from '@src/CONST'; -import {propTypes} from './autoCompleteSuggestionsPropTypes'; - -/** - * @param {Number} numRows - * @param {Boolean} isSuggestionPickerLarge - * @returns {Number} - */ -const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => { - if (isSuggestionPickerLarge) { - if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { - // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available - return CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - if (numRows > 2) { - // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible - return CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; - } - return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; -}; - -function BaseAutoCompleteSuggestions(props) { - const styles = useThemeStyles(); - const rowHeight = useSharedValue(0); - const scrollRef = useRef(null); - /** - * Render a suggestion menu item component. - * @param {Object} params - * @param {Object} params.item - * @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)} - - ); - - /** - * 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 animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); - - useEffect(() => { - rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { - duration: 100, - easing: Easing.inOut(Easing.ease), - }); - }, [props.suggestions.length, props.isSuggestionPickerLarge, rowHeight]); - - useEffect(() => { - if (!scrollRef.current) { - return; - } - scrollRef.current.scrollToIndex({index: props.highlightedSuggestionIndex, animated: true}); - }, [props.highlightedSuggestionIndex]); - - return ( - - rowHeight.value} - style={{flex: 1}} - getItemLayout={getItemLayout} - /> - - ); -} - -BaseAutoCompleteSuggestions.propTypes = propTypes; -BaseAutoCompleteSuggestions.displayName = 'BaseAutoCompleteSuggestions'; - -const BaseAutoCompleteSuggestionsWithRef = React.forwardRef((props, ref) => ( - -)); - -BaseAutoCompleteSuggestionsWithRef.displayName = 'BaseAutoCompleteSuggestionsWithRef'; - -export default BaseAutoCompleteSuggestionsWithRef; diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx new file mode 100644 index 000000000000..3e5e7b4fdd9a --- /dev/null +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx @@ -0,0 +1,104 @@ +import {FlashList} from '@shopify/flash-list'; +import React, {ForwardedRef, forwardRef, ReactElement, useCallback, useEffect, useRef} from 'react'; +import {View} from 'react-native'; +// 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'; +import useThemeStyles from '@styles/useThemeStyles'; +import CONST from '@src/CONST'; +import viewForwardedRef from '@src/types/utils/viewForwardedRef'; +import type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps} from './types'; + +const measureHeightOfSuggestionRows = (numRows: number, isSuggestionPickerLarge: boolean): number => { + if (isSuggestionPickerLarge) { + if (numRows > CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER) { + // On large screens, if there are more than 5 suggestions, we display a scrollable window with a height of 5 items, indicating that there are more items available + return CONST.AUTO_COMPLETE_SUGGESTER.MAX_AMOUNT_OF_VISIBLE_SUGGESTIONS_IN_CONTAINER * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + if (numRows > 2) { + // On small screens, we display a scrollable window with a height of 2.5 items, indicating that there are more items available beyond what is currently visible + return CONST.AUTO_COMPLETE_SUGGESTER.SMALL_CONTAINER_HEIGHT_FACTOR * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; + } + return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; +}; + +function BaseAutoCompleteSuggestions( + { + highlightedSuggestionIndex, + onSelect, + accessibilityLabelExtractor, + renderSuggestionMenuItem, + suggestions, + isSuggestionPickerLarge, + keyExtractor, + }: AutoCompleteSuggestionsProps, + ref: ForwardedRef, +) { + const styles = useThemeStyles(); + const rowHeight = useSharedValue(0); + const scrollRef = useRef>(null); + /** + * Render a suggestion menu item component. + */ + const renderItem = useCallback( + ({item, index}: RenderSuggestionMenuItemProps): ReactElement => ( + 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], + ); + + const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; + const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); + + useEffect(() => { + rowHeight.value = withTiming(measureHeightOfSuggestionRows(suggestions.length, isSuggestionPickerLarge), { + duration: 100, + easing: Easing.inOut(Easing.ease), + }); + }, [suggestions.length, isSuggestionPickerLarge, rowHeight]); + + useEffect(() => { + if (!scrollRef.current) { + return; + } + scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true}); + }, [highlightedSuggestionIndex]); + + return ( + + rowHeight.value} + extraData={highlightedSuggestionIndex} + /> + + ); +} + +BaseAutoCompleteSuggestions.displayName = 'BaseAutoCompleteSuggestions'; + +export default forwardRef(BaseAutoCompleteSuggestions); diff --git a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js b/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js deleted file mode 100644 index 8c6dca1902c5..000000000000 --- a/src/components/AutoCompleteSuggestions/autoCompleteSuggestionsPropTypes.js +++ /dev/null @@ -1,36 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Array of suggestions */ - // eslint-disable-next-line react/forbid-prop-types - suggestions: PropTypes.arrayOf(PropTypes.object).isRequired, - - /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */ - renderSuggestionMenuItem: PropTypes.func.isRequired, - - /** Create unique keys for each suggestion item */ - keyExtractor: PropTypes.func.isRequired, - - /** The index of the highlighted suggestion */ - highlightedSuggestionIndex: PropTypes.number.isRequired, - - /** Fired when the user selects a suggestion */ - onSelect: PropTypes.func.isRequired, - - /** Show that we can use large auto-complete suggestion picker. - * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. - * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ - isSuggestionPickerLarge: PropTypes.bool.isRequired, - - /** create accessibility label for each item */ - accessibilityLabelExtractor: PropTypes.func.isRequired, - - /** Meaures the parent container's position and dimensions. */ - measureParentContainer: PropTypes.func, -}; - -const defaultProps = { - measureParentContainer: () => {}, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/AutoCompleteSuggestions/index.native.js b/src/components/AutoCompleteSuggestions/index.native.tsx similarity index 61% rename from src/components/AutoCompleteSuggestions/index.native.js rename to src/components/AutoCompleteSuggestions/index.native.tsx index 439fa45eae78..fbfa7d953581 100644 --- a/src/components/AutoCompleteSuggestions/index.native.js +++ b/src/components/AutoCompleteSuggestions/index.native.tsx @@ -1,18 +1,17 @@ import {Portal} from '@gorhom/portal'; import React from 'react'; -import {propTypes} from './autoCompleteSuggestionsPropTypes'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; +import type {AutoCompleteSuggestionsProps} from './types'; -function AutoCompleteSuggestions({measureParentContainer, ...props}) { +function AutoCompleteSuggestions({measureParentContainer, ...props}: AutoCompleteSuggestionsProps) { return ( {/* eslint-disable-next-line react/jsx-props-no-spreading */} - + {...props} /> ); } -AutoCompleteSuggestions.propTypes = propTypes; AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions'; export default AutoCompleteSuggestions; diff --git a/src/components/AutoCompleteSuggestions/index.js b/src/components/AutoCompleteSuggestions/index.tsx similarity index 76% rename from src/components/AutoCompleteSuggestions/index.js rename to src/components/AutoCompleteSuggestions/index.tsx index 30654caf5708..24b846c265a9 100644 --- a/src/components/AutoCompleteSuggestions/index.js +++ b/src/components/AutoCompleteSuggestions/index.tsx @@ -4,8 +4,8 @@ import {View} from 'react-native'; import useWindowDimensions from '@hooks/useWindowDimensions'; import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import * as StyleUtils from '@styles/StyleUtils'; -import {propTypes} from './autoCompleteSuggestionsPropTypes'; import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; +import type {AutoCompleteSuggestionsProps} from './types'; /** * On the mobile-web platform, when long-pressing on auto-complete suggestions, @@ -14,8 +14,8 @@ import BaseAutoCompleteSuggestions from './BaseAutoCompleteSuggestions'; * On the native platform, tapping on auto-complete suggestions will not blur the main input. */ -function AutoCompleteSuggestions({measureParentContainer, ...props}) { - const containerRef = React.useRef(null); +function AutoCompleteSuggestions({measureParentContainer = () => {}, ...props}: AutoCompleteSuggestionsProps) { + const containerRef = React.useRef(null); const {windowHeight, windowWidth} = useWindowDimensions(); const [{width, left, bottom}, setContainerState] = React.useState({ width: 0, @@ -25,7 +25,7 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) { React.useEffect(() => { const container = containerRef.current; if (!container) { - return; + return () => {}; } container.onpointerdown = (e) => { if (DeviceCapabilities.hasHoverSupport()) { @@ -44,20 +44,20 @@ function AutoCompleteSuggestions({measureParentContainer, ...props}) { }, [measureParentContainer, windowHeight, windowWidth]); const componentToRender = ( - // eslint-disable-next-line react/jsx-props-no-spreading {...props} ref={containerRef} /> ); + const bodyElement = document.querySelector('body'); + return ( - Boolean(width) && - ReactDOM.createPortal({componentToRender}, document.querySelector('body')) + !!width && bodyElement && ReactDOM.createPortal({componentToRender}, bodyElement) ); } -AutoCompleteSuggestions.propTypes = propTypes; AutoCompleteSuggestions.displayName = 'AutoCompleteSuggestions'; export default AutoCompleteSuggestions; diff --git a/src/components/AutoCompleteSuggestions/types.ts b/src/components/AutoCompleteSuggestions/types.ts new file mode 100644 index 000000000000..9130f5139d71 --- /dev/null +++ b/src/components/AutoCompleteSuggestions/types.ts @@ -0,0 +1,38 @@ +import {ReactElement} from 'react'; + +type MeasureParentContainerCallback = (x: number, y: number, width: number) => void; + +type RenderSuggestionMenuItemProps = { + item: TSuggestion; + index: number; +}; + +type AutoCompleteSuggestionsProps = { + /** Array of suggestions */ + suggestions: TSuggestion[]; + + /** Function used to render each suggestion, returned JSX will be enclosed inside a Pressable component */ + renderSuggestionMenuItem: (item: TSuggestion, index: number) => ReactElement; + + /** Create unique keys for each suggestion item */ + keyExtractor: (item: TSuggestion, index: number) => string; + + /** The index of the highlighted suggestion */ + highlightedSuggestionIndex: number; + + /** Fired when the user selects a suggestion */ + onSelect: (index: number) => void; + + /** Show that we can use large auto-complete suggestion picker. + * Depending on available space and whether the input is expanded, we can have a small or large mention suggester. + * When this value is false, the suggester will have a height of 2.5 items. When this value is true, the height can be up to 5 items. */ + isSuggestionPickerLarge: boolean; + + /** create accessibility label for each item */ + accessibilityLabelExtractor: (item: TSuggestion, index: number) => string; + + /** Meaures the parent container's position and dimensions. */ + measureParentContainer?: (callback: MeasureParentContainerCallback) => void; +}; + +export type {AutoCompleteSuggestionsProps, RenderSuggestionMenuItemProps}; 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/Banner.js b/src/components/Banner.tsx similarity index 61% rename from src/components/Banner.js rename to src/components/Banner.tsx index 2fcb866334e0..1c92208a7aa2 100644 --- a/src/components/Banner.js +++ b/src/components/Banner.tsx @@ -1,7 +1,6 @@ -import PropTypes from 'prop-types'; import React, {memo} from 'react'; -import {View} from 'react-native'; -import compose from '@libs/compose'; +import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; import getButtonState from '@libs/getButtonState'; import * as StyleUtils from '@styles/StyleUtils'; import useThemeStyles from '@styles/useThemeStyles'; @@ -13,54 +12,41 @@ import PressableWithFeedback from './Pressable/PressableWithFeedback'; import RenderHTML from './RenderHTML'; import Text from './Text'; import Tooltip from './Tooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -const propTypes = { +type BannerProps = { /** Text to display in the banner. */ - text: PropTypes.string.isRequired, + text: string; /** Should this component render the left-aligned exclamation icon? */ - shouldShowIcon: PropTypes.bool, + shouldShowIcon?: boolean; /** Should this component render a close button? */ - shouldShowCloseButton: PropTypes.bool, + shouldShowCloseButton?: boolean; /** Should this component render the text as HTML? */ - shouldRenderHTML: PropTypes.bool, + shouldRenderHTML?: boolean; /** Callback called when the close button is pressed */ - onClose: PropTypes.func, + onClose?: () => void; /** Callback called when the message is pressed */ - onPress: PropTypes.func, + onPress?: () => void; /** Styles to be assigned to the Banner container */ - // eslint-disable-next-line react/forbid-prop-types - containerStyles: PropTypes.arrayOf(PropTypes.object), + containerStyles?: StyleProp; /** Styles to be assigned to the Banner text */ - // eslint-disable-next-line react/forbid-prop-types - textStyles: PropTypes.arrayOf(PropTypes.object), - - ...withLocalizePropTypes, -}; - -const defaultProps = { - shouldRenderHTML: false, - shouldShowIcon: false, - shouldShowCloseButton: false, - onClose: undefined, - onPress: undefined, - containerStyles: [], - textStyles: [], + textStyles?: StyleProp; }; -function Banner(props) { +function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRenderHTML = false, shouldShowIcon = false, shouldShowCloseButton = false}: BannerProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + return ( {(isHovered) => { - const isClickable = props.onClose || props.onPress; + const isClickable = onClose ?? onPress; const shouldHighlight = isClickable && isHovered; return ( - {props.shouldShowIcon && ( + {shouldShowIcon && ( )} - {props.shouldRenderHTML ? ( - + {shouldRenderHTML ? ( + ) : ( - {props.text} + {text} )} - {props.shouldShowCloseButton && ( - + {shouldShowCloseButton && !!onClose && ( + @@ -113,8 +99,6 @@ function Banner(props) { ); } -Banner.propTypes = propTypes; -Banner.defaultProps = defaultProps; Banner.displayName = 'Banner'; -export default compose(withLocalize, memo)(Banner); +export default memo(Banner); diff --git a/src/components/Button/index.js b/src/components/Button/index.tsx similarity index 64% rename from src/components/Button/index.js rename to src/components/Button/index.tsx index b9aaf8868924..71bce9777174 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.tsx @@ -1,212 +1,167 @@ import {useIsFocused} from '@react-navigation/native'; -import PropTypes from 'prop-types'; -import React, {useCallback} from 'react'; -import {ActivityIndicator, View} from 'react-native'; +import React, {ForwardedRef, useCallback} from 'react'; +import {ActivityIndicator, GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native'; +import {SvgProps} from 'react-native-svg'; import Icon from '@components/Icon'; import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; -import refPropTypes from '@components/refPropTypes'; import Text from '@components/Text'; import withNavigationFallback from '@components/withNavigationFallback'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import HapticFeedback from '@libs/HapticFeedback'; -import * as StyleUtils from '@styles/StyleUtils'; +import themeColors from '@styles/themes/default'; import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; import validateSubmitShortcut from './validateSubmitShortcut'; -const propTypes = { - /** Should the press event bubble across multiple instances when Enter key triggers it. */ - allowBubble: PropTypes.bool, - +type ButtonWithText = { /** The text for the button label */ - text: PropTypes.string, + text: string; /** Boolean whether to display the right icon */ - shouldShowRightIcon: PropTypes.bool, + shouldShowRightIcon?: boolean; /** The icon asset to display to the left of the text */ - icon: PropTypes.func, + icon?: React.FC | null; +}; + +type ButtonProps = (ButtonWithText | ChildrenProps) & { + /** Should the press event bubble across multiple instances when Enter key triggers it. */ + allowBubble?: boolean; /** The icon asset to display to the right of the text */ - iconRight: PropTypes.func, + iconRight?: React.FC; /** The fill color to pass into the icon. */ - iconFill: PropTypes.string, + iconFill?: string; /** Any additional styles to pass to the left icon container. */ - // eslint-disable-next-line react/forbid-prop-types - iconStyles: PropTypes.arrayOf(PropTypes.object), + iconStyles?: StyleProp; /** Any additional styles to pass to the right icon container. */ - // eslint-disable-next-line react/forbid-prop-types - iconRightStyles: PropTypes.arrayOf(PropTypes.object), + iconRightStyles?: StyleProp; /** Small sized button */ - small: PropTypes.bool, + small?: boolean; /** Large sized button */ - large: PropTypes.bool, + large?: boolean; - /** medium sized button */ - medium: PropTypes.bool, + /** Medium sized button */ + medium?: boolean; /** Indicates whether the button should be disabled and in the loading state */ - isLoading: PropTypes.bool, + isLoading?: boolean; /** Indicates whether the button should be disabled */ - isDisabled: PropTypes.bool, + isDisabled?: boolean; /** A function that is called when the button is clicked on */ - onPress: PropTypes.func, + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; /** A function that is called when the button is long pressed */ - onLongPress: PropTypes.func, + onLongPress?: (event?: GestureResponderEvent) => void; /** A function that is called when the button is pressed */ - onPressIn: PropTypes.func, + onPressIn?: () => void; /** A function that is called when the button is released */ - onPressOut: PropTypes.func, + onPressOut?: () => void; /** Callback that is called when mousedown is triggered. */ - onMouseDown: PropTypes.func, + onMouseDown?: () => void; /** Call the onPress function when Enter key is pressed */ - pressOnEnter: PropTypes.bool, + pressOnEnter?: boolean; /** The priority to assign the enter key event listener. 0 is the highest priority. */ - enterKeyEventListenerPriority: PropTypes.number, + enterKeyEventListenerPriority?: number; /** Additional styles to add after local styles. Applied to Pressable portion of button */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + style?: StyleProp; - /** Additional button styles. Specific to the OpacityView of button */ - // eslint-disable-next-line react/forbid-prop-types - innerStyles: PropTypes.arrayOf(PropTypes.object), + /** Additional button styles. Specific to the OpacityView of the button */ + innerStyles?: StyleProp; /** Additional text styles */ - // eslint-disable-next-line react/forbid-prop-types - textStyles: PropTypes.arrayOf(PropTypes.object), + textStyles?: StyleProp; /** Whether we should use the default hover style */ - shouldUseDefaultHover: PropTypes.bool, + shouldUseDefaultHover?: boolean; /** Whether we should use the success theme color */ - success: PropTypes.bool, + success?: boolean; /** Whether we should use the danger theme color */ - danger: PropTypes.bool, - - /** Children to replace all inner contents of button */ - children: PropTypes.node, + danger?: boolean; /** Should we remove the right border radius top + bottom? */ - shouldRemoveRightBorderRadius: PropTypes.bool, + shouldRemoveRightBorderRadius?: boolean; /** Should we remove the left border radius top + bottom? */ - shouldRemoveLeftBorderRadius: PropTypes.bool, + shouldRemoveLeftBorderRadius?: boolean; /** Should enable the haptic feedback? */ - shouldEnableHapticFeedback: PropTypes.bool, + shouldEnableHapticFeedback?: boolean; /** Id to use for this button */ - id: PropTypes.string, + id?: string; /** Accessibility label for the component */ - accessibilityLabel: PropTypes.string, - - /** A ref to forward the button */ - forwardedRef: refPropTypes, -}; - -const defaultProps = { - allowBubble: false, - text: '', - shouldShowRightIcon: false, - icon: null, - iconRight: Expensicons.ArrowRight, - iconFill: undefined, - iconStyles: [], - iconRightStyles: [], - isLoading: false, - isDisabled: false, - small: false, - large: false, - medium: false, - onPress: () => {}, - onLongPress: () => {}, - onPressIn: () => {}, - onPressOut: () => {}, - onMouseDown: undefined, - pressOnEnter: false, - enterKeyEventListenerPriority: 0, - style: [], - innerStyles: [], - textStyles: [], - shouldUseDefaultHover: true, - success: false, - danger: false, - children: null, - shouldRemoveRightBorderRadius: false, - shouldRemoveLeftBorderRadius: false, - shouldEnableHapticFeedback: false, - id: '', - accessibilityLabel: '', - forwardedRef: undefined, + accessibilityLabel?: string; }; -function Button({ - allowBubble, - text, - shouldShowRightIcon, - - icon, - iconRight, - iconFill, - iconStyles, - iconRightStyles, - - small, - large, - medium, - - isLoading, - isDisabled, - - onPress, - onLongPress, - onPressIn, - onPressOut, - onMouseDown, - - pressOnEnter, - enterKeyEventListenerPriority, - - style, - innerStyles, - textStyles, - - shouldUseDefaultHover, - success, - danger, - children, - - shouldRemoveRightBorderRadius, - shouldRemoveLeftBorderRadius, - shouldEnableHapticFeedback, - - id, - accessibilityLabel, - forwardedRef, -}) { +function Button( + { + allowBubble = false, + + iconRight = Expensicons.ArrowRight, + iconFill = themeColors.textLight, + iconStyles = [], + iconRightStyles = [], + + small = false, + large = false, + medium = false, + + isLoading = false, + isDisabled = false, + + onPress = () => {}, + onLongPress = () => {}, + onPressIn = () => {}, + onPressOut = () => {}, + onMouseDown = undefined, + + pressOnEnter = false, + enterKeyEventListenerPriority = 0, + + style = [], + innerStyles = [], + textStyles = [], + + shouldUseDefaultHover = true, + success = false, + danger = false, + + shouldRemoveRightBorderRadius = false, + shouldRemoveLeftBorderRadius = false, + shouldEnableHapticFeedback = false, + + id = '', + accessibilityLabel = '', + ...rest + }: ButtonProps, + ref: ForwardedRef, +) { const theme = useTheme(); const styles = useThemeStyles(); const isFocused = useIsFocused(); const keyboardShortcutCallback = useCallback( - (event) => { + (event?: GestureResponderEvent | KeyboardEvent) => { if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) { return; } @@ -223,10 +178,12 @@ function Button({ }); const renderContent = () => { - if (children) { - return children; + if ('children' in rest) { + return rest.children; } + const {text = '', icon = null, shouldShowRightIcon = false} = rest; + const textComponent = ( @@ -248,12 +205,13 @@ function Button({ ); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing if (icon || shouldShowRightIcon) { return ( {icon && ( - + {shouldShowRightIcon && ( - + { - if (event && event.type === 'click') { - event.currentTarget.blur(); + if (event?.type === 'click') { + const currentTarget = event?.currentTarget as HTMLElement; + currentTarget?.blur(); } if (shouldEnableHapticFeedback) { @@ -307,7 +266,7 @@ function Button({ styles.buttonContainer, shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - ...StyleUtils.parseStyleAsArray(style), + style, ]} style={[ styles.button, @@ -320,8 +279,9 @@ function Button({ isDisabled && !danger && !success ? styles.buttonDisabled : undefined, shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined, shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined, - icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined, - ...innerStyles, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined, + innerStyles, ]} hoverStyle={[ shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined, @@ -344,18 +304,6 @@ function Button({ ); } -Button.propTypes = propTypes; -Button.defaultProps = defaultProps; Button.displayName = 'Button'; -const ButtonWithRef = React.forwardRef((props, ref) => ( -