diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c106a25c8ab1..4f78ee6b69bf 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,2 @@ # Every PR gets a review from an internal Expensify engineer * @Expensify/pullerbear - -# Every PR that touches redirects gets reviewed by ring0 -docs/redirects.csv @Expensify/infra \ No newline at end of file diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml index 52fb097d254e..7a90cc45257d 100644 --- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -29,11 +29,11 @@ runs: shell: bash run: | if [[ -f .github/workflows/OSBotify-private-key.asc.gpg ]]; then - echo "::set-output name=key_exists::true" + echo "key_exists=true" >> "$GITHUB_OUTPUT" fi - + - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 if: steps.key_check.outputs.key_exists != 'true' with: sparse-checkout: | diff --git a/.github/actions/composite/setupNode/action.yml b/.github/actions/composite/setupNode/action.yml index 57c7e4d91379..7e1b5fbbae90 100644 --- a/.github/actions/composite/setupNode/action.yml +++ b/.github/actions/composite/setupNode/action.yml @@ -4,7 +4,7 @@ description: Set up Node runs: using: composite steps: - - uses: actions/setup-node@8c91899e586c5b171469028077307d293428b516 + - uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: npm diff --git a/.github/actions/javascript/awaitStagingDeploys/action.yml b/.github/actions/javascript/awaitStagingDeploys/action.yml index fdd0b940abaa..3499b4050de0 100644 --- a/.github/actions/javascript/awaitStagingDeploys/action.yml +++ b/.github/actions/javascript/awaitStagingDeploys/action.yml @@ -8,5 +8,5 @@ inputs: description: If provided, this action will only wait for a deploy matching this tag. required: false runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/bumpVersion/action.yml b/.github/actions/javascript/bumpVersion/action.yml index d092821d96ac..dc4a75d6eb71 100644 --- a/.github/actions/javascript/bumpVersion/action.yml +++ b/.github/actions/javascript/bumpVersion/action.yml @@ -11,5 +11,5 @@ outputs: NEW_VERSION: description: The new semver version of the application, updated in the JS and native layers. runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/checkDeployBlockers/action.yml b/.github/actions/javascript/checkDeployBlockers/action.yml index ce0d19f2def1..c6c7b3954c89 100644 --- a/.github/actions/javascript/checkDeployBlockers/action.yml +++ b/.github/actions/javascript/checkDeployBlockers/action.yml @@ -11,5 +11,5 @@ outputs: HAS_DEPLOY_BLOCKERS: description: A true/false indicating whether or not a deploy blocker was found. runs: - using: 'node16' + using: 'node20' main: 'index.js' diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml b/.github/actions/javascript/createOrUpdateStagingDeploy/action.yml index 870cab318d09..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: 'node16' + 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 abf0712103a5..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,23 +190,42 @@ 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); - try { - let command = `git fetch origin tag ${tag} --no-tags`; +function fetchTag(tag, shallowExcludeTag = '') { + let shouldRetry = true; + let needsRepack = false; + while (shouldRetry) { + try { + let command = ''; + if (needsRepack) { + // We have seen some scenarios where this fixes the git fetch. + // Why? Who knows... https://github.com/Expensify/App/pull/31459 + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); + } - // 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}`; - } + command = `git fetch origin tag ${tag} --no-tags`; - console.log(`Running command: ${command}`); - execSync(command); - } catch (e) { - console.error(e); + // 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}`); + execSync(command); + shouldRetry = false; + } catch (e) { + console.error(e); + if (!needsRepack) { + console.log('Attempting to repack and retry...'); + needsRepack = true; + } else { + console.error("Repack didn't help, giving up..."); + shouldRetry = false; + } + } } } @@ -219,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) => { @@ -297,16 +317,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/actions/javascript/getDeployPullRequestList/action.yml b/.github/actions/javascript/getDeployPullRequestList/action.yml index 4cbf7041a7eb..1362f207ba4a 100644 --- a/.github/actions/javascript/getDeployPullRequestList/action.yml +++ b/.github/actions/javascript/getDeployPullRequestList/action.yml @@ -14,5 +14,5 @@ outputs: PR_LIST: description: Array of pull request numbers runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index dbe109d99e32..974824ac4628 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -133,23 +133,42 @@ 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); - try { - let command = `git fetch origin tag ${tag} --no-tags`; +function fetchTag(tag, shallowExcludeTag = '') { + let shouldRetry = true; + let needsRepack = false; + while (shouldRetry) { + try { + let command = ''; + if (needsRepack) { + // We have seen some scenarios where this fixes the git fetch. + // Why? Who knows... https://github.com/Expensify/App/pull/31459 + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); + } - // 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}`; - } + command = `git fetch origin tag ${tag} --no-tags`; - console.log(`Running command: ${command}`); - execSync(command); - } catch (e) { - console.error(e); + // 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}`); + execSync(command); + shouldRetry = false; + } catch (e) { + console.error(e); + if (!needsRepack) { + console.log('Attempting to repack and retry...'); + needsRepack = true; + } else { + console.error("Repack didn't help, giving up..."); + shouldRetry = false; + } + } } } @@ -161,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) => { @@ -239,16 +260,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/actions/javascript/getPreviousVersion/action.yml b/.github/actions/javascript/getPreviousVersion/action.yml index 6b2221af7c40..ec81bd99e4f8 100644 --- a/.github/actions/javascript/getPreviousVersion/action.yml +++ b/.github/actions/javascript/getPreviousVersion/action.yml @@ -8,5 +8,5 @@ outputs: PREVIOUS_VERSION: description: The previous semver version of the application, according to the SEMVER_LEVEL provided runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getPullRequestDetails/action.yml b/.github/actions/javascript/getPullRequestDetails/action.yml index ed2c60f018a1..d931d101b5da 100644 --- a/.github/actions/javascript/getPullRequestDetails/action.yml +++ b/.github/actions/javascript/getPullRequestDetails/action.yml @@ -22,5 +22,5 @@ outputs: FORKED_REPO_URL: description: 'Output forked repo URL if PR includes changes from a fork' runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/getReleaseBody/action.yml b/.github/actions/javascript/getReleaseBody/action.yml index c221acbdaae2..e4a451ccda8d 100644 --- a/.github/actions/javascript/getReleaseBody/action.yml +++ b/.github/actions/javascript/getReleaseBody/action.yml @@ -8,5 +8,5 @@ outputs: RELEASE_BODY: description: String body of a production release. runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/isStagingDeployLocked/action.yml b/.github/actions/javascript/isStagingDeployLocked/action.yml index 9e5e50b26452..395a081a7620 100644 --- a/.github/actions/javascript/isStagingDeployLocked/action.yml +++ b/.github/actions/javascript/isStagingDeployLocked/action.yml @@ -10,5 +10,5 @@ outputs: NUMBER: description: StagingDeployCash issue number runs: - using: 'node16' + using: 'node20' main: 'index.js' diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/action.yml b/.github/actions/javascript/markPullRequestsAsDeployed/action.yml index 7015293d2bb8..f0ca77bdbf00 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/action.yml +++ b/.github/actions/javascript/markPullRequestsAsDeployed/action.yml @@ -28,5 +28,5 @@ inputs: description: "Web job result ('success', 'failure', 'cancelled', or 'skipped')" required: true runs: - using: "node16" + using: "node20" main: "./index.js" diff --git a/.github/actions/javascript/postTestBuildComment/action.yml b/.github/actions/javascript/postTestBuildComment/action.yml index 07829dfab8cd..00c826badf9f 100644 --- a/.github/actions/javascript/postTestBuildComment/action.yml +++ b/.github/actions/javascript/postTestBuildComment/action.yml @@ -32,5 +32,5 @@ inputs: description: "Link for the web build" required: false runs: - using: "node16" + using: "node20" main: "./index.js" diff --git a/.github/actions/javascript/reopenIssueWithComment/action.yml b/.github/actions/javascript/reopenIssueWithComment/action.yml index 0a163e6651f0..3dfcba9b0c35 100644 --- a/.github/actions/javascript/reopenIssueWithComment/action.yml +++ b/.github/actions/javascript/reopenIssueWithComment/action.yml @@ -11,5 +11,5 @@ inputs: description: The comment string we want to leave on the issue after we reopen it. required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/reviewerChecklist/action.yml b/.github/actions/javascript/reviewerChecklist/action.yml index 24fe815dcc6a..d8c1e6620b77 100644 --- a/.github/actions/javascript/reviewerChecklist/action.yml +++ b/.github/actions/javascript/reviewerChecklist/action.yml @@ -5,5 +5,5 @@ inputs: description: Auth token for New Expensify Github required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/validateReassureOutput/action.yml b/.github/actions/javascript/validateReassureOutput/action.yml index 1b4488757e9c..4fd53e838fb5 100644 --- a/.github/actions/javascript/validateReassureOutput/action.yml +++ b/.github/actions/javascript/validateReassureOutput/action.yml @@ -11,5 +11,5 @@ inputs: description: Refers to the results obtained from regression tests `.reassure/output.json`. required: true runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/actions/javascript/verifySignedCommits/action.yml b/.github/actions/javascript/verifySignedCommits/action.yml index 1a641cddb391..a724220eba32 100644 --- a/.github/actions/javascript/verifySignedCommits/action.yml +++ b/.github/actions/javascript/verifySignedCommits/action.yml @@ -9,5 +9,5 @@ inputs: required: false runs: - using: 'node16' + using: 'node20' main: './index.js' diff --git a/.github/libs/GitUtils.js b/.github/libs/GitUtils.js index 7bc600470dd1..2076763fbb55 100644 --- a/.github/libs/GitUtils.js +++ b/.github/libs/GitUtils.js @@ -6,23 +6,42 @@ 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); - try { - let 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}`; - } +function fetchTag(tag, shallowExcludeTag = '') { + let shouldRetry = true; + let needsRepack = false; + while (shouldRetry) { + try { + let command = ''; + if (needsRepack) { + // We have seen some scenarios where this fixes the git fetch. + // Why? Who knows... https://github.com/Expensify/App/pull/31459 + command = 'git repack -d'; + console.log(`Running command: ${command}`); + execSync(command); + } + + command = `git fetch origin tag ${tag} --no-tags`; + + // 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}`); - execSync(command); - } catch (e) { - console.error(e); + console.log(`Running command: ${command}`); + execSync(command); + shouldRetry = false; + } catch (e) { + console.error(e); + if (!needsRepack) { + console.log('Attempting to repack and retry...'); + needsRepack = true; + } else { + console.error("Repack didn't help, giving up..."); + shouldRetry = false; + } + } } } @@ -34,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) => { @@ -112,16 +133,15 @@ function getValidMergedPRs(commits) { * @param {String} toTag * @returns {Promise>} – Pull request numbers */ -function getPullRequestsMergedBetween(fromTag, toTag) { +async function getPullRequestsMergedBetween(fromTag, toTag) { console.log(`Looking for commits made between ${fromTag} and ${toTag}...`); - return getCommitHistoryAsJSON(fromTag, toTag).then((commitList) => { - console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); + const commitList = await getCommitHistoryAsJSON(fromTag, toTag); + console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList); - // Find which commit messages correspond to merged PR's - const pullRequestNumbers = getValidMergedPRs(commitList); - console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); - return _.map(pullRequestNumbers, (prNum) => Number.parseInt(prNum, 10)); - }); + // Find which commit messages correspond to merged PR's + const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b); + console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers); + return pullRequestNumbers; } module.exports = { diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 68b98ab625be..d940d99d9cde 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -42,12 +42,12 @@ Due to the large, ever-growing history of this repo, do not do any full-fetches ```yaml # Bad -- uses: actions/checkout@v3 +- uses: actions/checkout@v4 with: fetch-depth: 0 # Good -- uses: actions/checkout@v3 +- uses: actions/checkout@v4 ``` ```sh @@ -63,7 +63,7 @@ git fetch origin tag 1.0.1-0 --no-tags --shallow-exclude=1.0.0-0 # This will fet ## Security Rules 🔐 1. Do **not** use `pull_request_target` trigger unless an external fork needs access to secrets, or a _write_ `GITHUB_TOKEN`. -1. Do **not ever** write a `pull_request_target` trigger with an explicit PR checkout, e.g. using `actions/checkout@v2`. This is [discussed further here](https://securitylab.github.com/research/github-actions-preventing-pwn-requests) +1. Do **not ever** write a `pull_request_target` trigger with an explicit PR checkout, e.g. using `actions/checkout@v4`. This is [discussed further here](https://securitylab.github.com/research/github-actions-preventing-pwn-requests) 1. **Do use** the `pull_request` trigger as it does not send internal secrets and only grants a _read_ `GITHUB_TOKEN`. 1. If an untrusted (i.e: not maintained by GitHub) external action needs access to any secret (`GITHUB_TOKEN` or internal secret), use the commit hash of the workflow to prevent a modification of underlying source code at that version. For example: 1. **Bad:** `hmarr/auto-approve-action@v2.0.0` Relies on the tag @@ -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 740e7b3a5e69..ecb0b87a6416 100644 --- a/.github/workflows/authorChecklist.yml +++ b/.github/workflows/authorChecklist.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' steps: - - uses: actions/checkout@v3 + - 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 43f3c64554bc..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: @@ -35,14 +35,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout staging branch - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - 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 c9c97d5355fb..5f7f95e102e3 100644 --- a/.github/workflows/createNewVersion.yml +++ b/.github/workflows/createNewVersion.yml @@ -68,7 +68,7 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Check out - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main # The OS_BOTIFY_COMMIT_TOKEN is a personal access token tied to osbotify @@ -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 78040f237689..f6deaae963e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,12 +10,12 @@ jobs: if: github.ref == 'refs/heads/staging' steps: - name: Checkout staging branch - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + + - uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -32,13 +32,13 @@ jobs: runs-on: ubuntu-latest if: github.ref == 'refs/heads/production' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 name: Checkout with: ref: production token: ${{ secrets.OS_BOTIFY_TOKEN }} - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + - uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -50,7 +50,7 @@ jobs: - name: Get Release Pull Request List id: getReleasePRList - uses: Expensify/App/.github/actions/javascript/getDeployPullRequestList@main + uses: ./.github/actions/javascript/getDeployPullRequestList with: TAG: ${{ env.PRODUCTION_VERSION }} GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} @@ -58,7 +58,7 @@ jobs: - name: Generate Release Body id: getReleaseBody - uses: Expensify/App/.github/actions/javascript/getReleaseBody@main + uses: ./.github/actions/javascript/getReleaseBody with: PR_LIST: ${{ steps.getReleasePRList.outputs.PR_LIST }} diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml index f42d19ca8241..d118b3fee252 100644 --- a/.github/workflows/deployBlocker.yml +++ b/.github/workflows/deployBlocker.yml @@ -6,35 +6,21 @@ 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@v3 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Get URL, title, & number of new deploy blocker (issue) - if: ${{ github.event_name == 'issues' }} - env: - TITLE: ${{ github.event.issue.title }} - run: | - { echo "DEPLOY_BLOCKER_URL=${{ github.event.issue.html_url }}"; - echo "DEPLOY_BLOCKER_NUMBER=${{ github.event.issue.number }}"; - echo "DEPLOY_BLOCKER_TITLE=$(sed -e "s/'/'\\\\''/g; s/\`/\\\\\`/g; 1s/^/'/; \$s/\$/'/" <<< "$TITLE")";} >> "$GITHUB_ENV" - - - name: Update StagingDeployCash with new deploy blocker - uses: Expensify/App/.github/actions/javascript/createOrUpdateStagingDeploy@main - with: - GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} + uses: actions/checkout@v4 - name: Give the issue/PR the Hourly, Engineering labels - uses: andymckay/labeler@978f846c4ca6299fd136f465b42c5e87aca28cac - with: - add-labels: 'Hourly, Engineering' - remove-labels: 'Daily, Weekly, Monthly' + run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly' + env: + GITHUB_TOKEN: ${{ github.token }} - name: 'Post the issue in the #expensify-open-source slack room' if: ${{ success() }} @@ -46,26 +32,29 @@ 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: <${{ env.DEPLOY_BLOCKER_URL }}|'+ `${{ env.DEPLOY_BLOCKER_TITLE }}`.replace(/(^'|'$)/gi, '').replace(/'\''/gi,'\'') + '>', + text: '💥 We have found a New Expensify Deploy Blocker, if you have any idea which PR could be causing this, please comment in the issue: <${{ github.event.issue.html_url }}|${{ github.event.issue.title }}>'.replace(/[&<>"'|]/g, function(m) { return {'&': '&', '<': '<', '>': '>', '"': '"', "'": ''', '|': '|'}[m]; }), }] } env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - - name: Comment on deferred PR - uses: actions-ecosystem/action-create-comment@cd098164398331c50e7dfdd0dfa1b564a1873fac - with: - github_token: ${{ secrets.OS_BOTIFY_TOKEN }} - number: ${{ env.DEPLOY_BLOCKER_NUMBER }} - body: | - :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. + - name: Comment on deploy blocker + 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: + GITHUB_TOKEN: ${{ github.token }} - 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/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml index 4a53e75354c6..82cd62c5e832 100644 --- a/.github/workflows/deployExpensifyHelp.yml +++ b/.github/workflows/deployExpensifyHelp.yml @@ -29,10 +29,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + 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 9d94bf900615..016fe89ccfce 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -22,7 +22,7 @@ jobs: outputs: VERSION: ${{ steps.getMostRecentRelease.outputs.VERSION }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Get most recent release version id: getMostRecentRelease @@ -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: @@ -78,11 +78,11 @@ jobs: outputs: DELTA_REF: ${{ steps.getDeltaRef.outputs.DELTA_REF }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - 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: @@ -154,10 +154,10 @@ jobs: needs: [buildBaseline, buildDelta] name: Run E2E tests in AWS device farm steps: - - uses: actions/checkout@v3 + - 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 f8b68786aaab..7fb5feaf6084 100644 --- a/.github/workflows/finishReleaseCycle.yml +++ b/.github/workflows/finishReleaseCycle.yml @@ -13,12 +13,12 @@ jobs: isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} - - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + - uses: ./.github/actions/composite/setupGitForOSBotifyApp id: setupGitForOSBotify with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} @@ -38,7 +38,7 @@ jobs: - name: Reopen and comment on issue (not a team member) if: ${{ !fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }} - uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main + uses: ./.github/actions/javascript/reopenIssueWithComment with: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} @@ -49,14 +49,14 @@ jobs: - name: Check for any deploy blockers if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) }} id: checkDeployBlockers - uses: Expensify/App/.github/actions/javascript/checkDeployBlockers@main + uses: ./.github/actions/javascript/checkDeployBlockers with: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} - name: Reopen and comment on issue (has blockers) if: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS || 'false') }} - uses: Expensify/App/.github/actions/javascript/reopenIssueWithComment@main + uses: ./.github/actions/javascript/reopenIssueWithComment with: GITHUB_TOKEN: ${{ steps.setupGitForOSBotify.outputs.OS_BOTIFY_API_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} @@ -66,7 +66,7 @@ jobs: - name: Announce failed workflow in Slack if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main + uses: ./.github/actions/composite/announceFailedWorkflowInSlack with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} @@ -77,14 +77,14 @@ jobs: if: ${{ fromJSON(needs.validate.outputs.isValid) }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: staging token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Git for OSBotify id: setupGitForOSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + uses: ./.github/actions/composite/setupGitForOSBotifyApp with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} @@ -100,7 +100,7 @@ jobs: - name: Announce failed workflow in Slack if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main + uses: ./.github/actions/composite/announceFailedWorkflowInSlack with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} @@ -108,7 +108,7 @@ jobs: createNewPatchVersion: needs: validate if: ${{ fromJSON(needs.validate.outputs.isValid) }} - uses: Expensify/App/.github/workflows/createNewVersion.yml@main + uses: ./.github/workflows/createNewVersion.yml secrets: inherit with: SEMVER_LEVEL: PATCH @@ -119,13 +119,13 @@ jobs: needs: [updateProduction, createNewPatchVersion] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main token: ${{ secrets.OS_BOTIFY_TOKEN }} - name: Setup Git for OSBotify - uses: Expensify/App/.github/actions/composite/setupGitForOSBotifyApp@8c19d6da4a3d7ce3b15c9cd89a802187d208ecab + uses: ./.github/actions/composite/setupGitForOSBotifyApp with: GPG_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} OS_BOTIFY_APP_ID: ${{ secrets.OS_BOTIFY_APP_ID }} @@ -141,6 +141,6 @@ jobs: - name: Announce failed workflow in Slack if: ${{ failure() }} - uses: Expensify/App/.github/actions/composite/announceFailedWorkflowInSlack@main + uses: ./.github/actions/composite/announceFailedWorkflowInSlack with: SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3072b3354a84..33c850823413 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,10 +13,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + 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 6ca025bb2a25..d73f982a47cb 100644 --- a/.github/workflows/lockDeploys.yml +++ b/.github/workflows/lockDeploys.yml @@ -10,13 +10,13 @@ jobs: runs-on: macos-12 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main 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 d494ea0d008b..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@v3 - - 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 @@ -62,13 +48,13 @@ jobs: runs-on: ubuntu-latest-xl steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure MapBox SDK 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 @@ -146,10 +132,10 @@ jobs: runs-on: macos-12-xl steps: - name: Checkout - uses: actions/checkout@v3 + 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 @@ -185,13 +171,13 @@ jobs: runs-on: macos-13-xlarge steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Configure MapBox SDK 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 @@ -297,16 +283,16 @@ jobs: runs-on: ubuntu-latest-xl steps: - name: Checkout - uses: actions/checkout@v3 + 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 }} @@ -367,7 +353,7 @@ jobs: needs: [android, desktop, iOS, web] steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set version run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" @@ -428,24 +414,24 @@ jobs: needs: [android, desktop, iOS, web] steps: - name: Checkout - uses: actions/checkout@v3 + 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 bae843e74709..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: @@ -86,13 +90,13 @@ jobs: GITHUB_TOKEN: ${{ github.token }} - name: Checkout main - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: main 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 b259ff9052b6..a58745b742ad 100644 --- a/.github/workflows/reassurePerformanceTests.yml +++ b/.github/workflows/reassurePerformanceTests.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + 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/shellCheck.yml b/.github/workflows/shellCheck.yml index 609541e9a660..366caa8a0d19 100644 --- a/.github/workflows/shellCheck.yml +++ b/.github/workflows/shellCheck.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Lint shell scripts with ShellCheck run: npm run shellcheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fa47a2f61d4a..6540a0fdd583 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,10 +20,10 @@ jobs: name: test (job ${{ fromJSON(matrix.chunk) }}) steps: - name: Checkout - uses: actions/checkout@v3 + 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 @@ -44,9 +44,9 @@ jobs: runs-on: ubuntu-latest name: Storybook tests steps: - - uses: actions/checkout@v3 + - 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 @@ -57,10 +57,10 @@ jobs: name: Shell tests steps: - name: Checkout - uses: actions/checkout@v3 + 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 b79b687e638e..6f222398d04b 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -50,7 +50,7 @@ jobs: steps: - name: Checkout if: ${{ github.event_name == 'workflow_dispatch' }} - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 - name: Check if pull request number is correct if: ${{ github.event_name == 'workflow_dispatch' }} @@ -70,9 +70,8 @@ jobs: env: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - # This action checks-out the repository, so the workflow can access it. - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -83,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 @@ -102,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 }} @@ -135,9 +134,8 @@ jobs: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} runs-on: macos-13-xlarge steps: - # This action checks-out the repository, so the workflow can access it. - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -151,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 @@ -193,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 }} @@ -221,7 +219,7 @@ jobs: runs-on: macos-12-xl steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -232,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 @@ -240,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 }} @@ -264,7 +262,7 @@ jobs: runs-on: ubuntu-latest-xl steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -275,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 }} @@ -302,7 +300,7 @@ jobs: PULL_REQUEST_NUMBER: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} @@ -345,7 +343,7 @@ jobs: - name: Publish links to apps for download if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - uses: Expensify/App/.github/actions/javascript/postTestBuildComment@main + uses: ./.github/actions/javascript/postTestBuildComment with: PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_TOKEN }} diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index cdb95bd66779..0951b194430b 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -12,9 +12,9 @@ jobs: if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - 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/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml index 531b8a3812fd..af24d5f17db4 100644 --- a/.github/workflows/updateHelpDotRedirects.yml +++ b/.github/workflows/updateHelpDotRedirects.yml @@ -22,7 +22,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 + uses: actions/checkout@v4 - name: Create help dot redirect env: diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml index 14c08e087565..ceeca1ad39f1 100644 --- a/.github/workflows/validateDocsRoutes.yml +++ b/.github/workflows/validateDocsRoutes.yml @@ -11,9 +11,9 @@ jobs: if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - 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 5cfc4670620f..700f0b68100e 100644 --- a/.github/workflows/validateGithubActions.yml +++ b/.github/workflows/validateGithubActions.yml @@ -12,10 +12,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v3 + 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 d98780e3e829..04cd8d62461b 100644 --- a/.github/workflows/verifyPodfile.yml +++ b/.github/workflows/verifyPodfile.yml @@ -15,8 +15,10 @@ jobs: runs-on: macos-latest steps: - name: Checkout - uses: actions/checkout@v3 + 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/.github/workflows/welcome.yml b/.github/workflows/welcome.yml index 43e0e1650381..1ea81129fc15 100644 --- a/.github/workflows/welcome.yml +++ b/.github/workflows/welcome.yml @@ -10,7 +10,7 @@ jobs: if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Get merged pull request id: getMergedPullRequest 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/.nvmrc b/.nvmrc index d9289897d305..43bff1f8cf98 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16.15.1 +20.9.0 \ No newline at end of file diff --git a/__mocks__/fs.js b/__mocks__/fs.js new file mode 100644 index 000000000000..cca0aa9520ec --- /dev/null +++ b/__mocks__/fs.js @@ -0,0 +1,3 @@ +const {fs} = require('memfs'); + +module.exports = fs; diff --git a/__mocks__/fs/promises.js b/__mocks__/fs/promises.js new file mode 100644 index 000000000000..1a58f0f013ac --- /dev/null +++ b/__mocks__/fs/promises.js @@ -0,0 +1,3 @@ +const {fs} = require('memfs'); + +module.exports = fs.promises; diff --git a/android/app/build.gradle b/android/app/build.gradle index 6827448c5053..025fa63b2a05 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 1001040000 - versionName "1.4.0-0" + versionCode 1001040203 + versionName "1.4.2-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/product-illustrations/payment-hands.svg b/assets/images/product-illustrations/payment-hands.svg new file mode 100644 index 000000000000..bf76b528ee76 --- /dev/null +++ b/assets/images/product-illustrations/payment-hands.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/docs/_data/_routes.yml b/docs/_data/_routes.yml index 84735e95e0e9..efdcf41b63d5 100644 --- a/docs/_data/_routes.yml +++ b/docs/_data/_routes.yml @@ -88,18 +88,18 @@ platforms: image: /assets/images/settings-new-dot.svg hubs: - - href: getting-started - title: Getting Started - icon: /assets/images/accounting.svg - description: From setting up your account to ensuring you get the most out of Expensify’s suite of features, click here to get started on streamlining your expense management journey. - + - href: chat + title: Chat + icon: /assets/images/chat-bubble.svg + description: Enhance your financial experience using Expensify's chat feature, offering quick and secure communication for personalized support and payment transfers. + - href: account-settings title: Account Settings icon: /assets/images/gears.svg description: Discover how to personalize your profile, add secondary logins, and grant delegated access to employees with our comprehensive guide on Account Settings. - - href: bank-accounts-and-credit-cards - title: Bank Accounts & Credit Cards + - href: bank-accounts + title: Bank Accounts icon: /assets/images/bank-card.svg description: Find out how to connect Expensify to your financial institutions, track credit card transactions, and best practices for reconciling company cards. @@ -108,11 +108,6 @@ platforms: icon: /assets/images/money-wings.svg description: Here is where you can review Expensify's billing and subscription options, plan types, and payment methods. - - href: expense-and-report-features - title: Expense & Report Features - icon: /assets/images/money-receipt.svg - description: From enabling automatic expense auditing to tracking attendees, here is where you can review tips and tutorials to streamline expense management. - - href: expensify-card title: Expensify Card icon: /assets/images/hand-card.svg @@ -128,21 +123,6 @@ platforms: icon: /assets/images/money-into-wallet.svg description: Whether you submit an expense report or an invoice, find out here how to ensure a smooth and timely payback process every time. - - href: insights-and-custom-reporting - title: Insights & Custom Reporting - icon: /assets/images/monitor.svg - description: From exporting reports to creating custom templates, here is where you can learn more about Expensify's versatile export options. - - - href: integrations - title: Integrations - icon: /assets/images/workflow.svg - description: Enhance Expensify’s capabilities by integrating it with your accounting or HR software. Here is where you can learn more about creating a synchronized financial management ecosystem. - - - href: manage-employees-and-report-approvals - title: Manage Employees & Report Approvals - icon: /assets/images/envelope-receipt.svg - description: Master the art of overseeing employees and reports by utilizing Expensify’s automation features and approval workflows. - - href: send-payments title: Send Payments icon: /assets/images/money-wings.svg diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss index 46434787d6df..7a0804b0f962 100644 --- a/docs/_sass/_main.scss +++ b/docs/_sass/_main.scss @@ -657,6 +657,7 @@ button { p.description { padding: 0; + color: $color-text-supporting; &.with-min-height { min-height: 68px; 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/billing-and-subscriptions/Free-Trial.md b/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md index e08aaa3d6094..4f660588d432 100644 --- a/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md +++ b/docs/articles/expensify-classic/billing-and-subscriptions/Free-Trial.md @@ -1,5 +1,44 @@ --- title: Free Trial -description: Free Trial +description: Learn more about your free trial with Expensify. --- -## Resource Coming Soon! + +# Overview +New customers can take advantage of a seven-day Free Trial on a group Workspace. This trial period allows you to fully explore Expensify's features and capabilities before deciding on a subscription. +During the trial, your organization will have complete access to all the features and functionality offered by the Collect or Control workspace plan. This post provides a step-by-step guide on how to begin, oversee, and successfully conclude your organization's Expensify Free Trial. + +# How to start a Free Trial +1. Sign up for a new Expensify account at expensify.com. +2. After you've signed up for a new Expensify account, you will see a task on your Home page asking if you are using Expensify for your business or as an individual. + a. **Note**: If you select “Individual”, Expensify is free for individuals for up to 25 SmartScans per month. Selecting Individual will **not** start a Free Trial. More details on individual subscriptions can be found [here](https://help.expensify.com/articles/expensify-classic/billing-and-subscriptions/Individual-Subscription). +3. Select the Business option. +4. Select which Expensify features you'd like to set up for your organization. +5. Congratulations, your seven-day Free Trial has started! + +Once you've made these selections, we'll automatically enroll you in a Free Trial and create a Group Workspace, which will trigger new tasks on your Home page to walk you through how to configure Expensify for your organization. If you have any questions about a specific task or need assistance setting up your company, you can speak with your designated Setup Specialist by clicking “Support” on the left-hand navigation menu and selecting their name. This will allow you to message your Setup Specialist, and request a call if you need. + +# How to unlock additional Free Trial weeks +When you begin a Free Trial, you'll have an initial seven-day period before you need to provide your billing information to continue using Expensify. Luckily, Expensify offers the option to extend your Free Trial by an additional five weeks! + +To access these extra free weeks, all you need to do is complete the tasks on your Home page marked with the "Free Week!" banner. Each task completed in this category will automatically add seven more days to your trial. You can easily keep track of the remaining days of your Free Trial by checking the top right-hand corner of your Expensify Home page. + +# How to make the most of your Free Trial +- Complete all of the "Free Week!" tasks right away. These tasks are crucial for establishing your organization's Workspace, and finishing them will give you a clear idea of how much time you have left in your Free Trial. + +- Every Free Trial has dedicated access to a Setup Specialist who can help you set up your account to your preferences. We highly recommend booking a call with your dedicated Setup Specialist as soon as you start your Free Trial. If you ever need assistance with a setup task, your tasks also include demo videos. + +- Invite a few employees to join Expensify as early as possible during the Free Trial. Bringing employees on board and having them submit expenses will allow you to fully experience how all of the features and functionalities of Expensify work together to save time. We provide excellent resources to help employees get started with Expensify. + +- Establish a connection between Expensify and your accounting system from the outset. By doing this early, you can start testing Expensify comprehensively from end to end. + +# FAQ +## What happens when my Free Trial ends? +If you’ve already added a billing card to Expensify, you will automatically start your organization’s Expensify subscription after your Free Trial ends. At the beginning of the following month, we'll bill the card you have on file for your subscription, adjusting the charge to exclude the Free Trial period. +If your Free Trial concludes without a billing card on file, you will see a notification on your Home page saying, 'Your Free Trial has expired.' +If you still have outstanding 'Free Week!' tasks, completing them will extend your Free Trial by additional days. +If you continue without adding a billing card, you will be granted a five-day grace period after the following billing cycle before all Group Workspace functionality is disabled. To continue using Expensify's Group Workspace features, you will need to input your billing card information and initiate a subscription. + +## How can I downgrade my account after my Free Trial ends? +If you’d like to downgrade to an individual account after your Free Trial has ended, you will need to delete any Group Workspace that you have created. This action will remove the Workspaces, subscription, and any amount owed. You can do this in one of two ways from the Expensify web app: +- Select the “Downgrade” option on the billing card task on your Home page. +- Go to **Settings > Workspaces > [Workspace name]**, then click the gear button next to the Workspace and select Delete. diff --git a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md index 5f5ecca13b2f..bc8a8d8bf184 100644 --- a/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md +++ b/docs/articles/expensify-classic/expensify-card/Auto-Reconciliation.md @@ -69,9 +69,13 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### Example - We have card transactions for the day totaling $100, so we create the following journal entry upon sync: +![QBO Journal Entry](https://help.expensify.com/assets/images/Auto-Reconciliation QBO 1.png){:width="100%"} - The current balance of the Expensify Clearing Account is now $100: +![QBO Clearing Account](https://help.expensify.com/assets/images/Auto-reconciliation QBO 2.png){:width="100%"} - After transactions are posted in Expensify and the report is approved and exported, a second journal entry is generated: +![QBO Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation QBO 3.png){:width="100%"} - We reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account: +![QBO Clearing Account 2](https://help.expensify.com/assets/images/Auto-reconciliation QBO 4.png){:width="100%"} - Now, you'll have a debit on your credit card account (increasing the total spent) and a credit on the bank account (reducing the available amount). The Clearing Account balance is $0. - Each expense will also create a credit card expense, similar to how we do it today, exported upon final approval. This action debits the expense account (category) and includes any other line item data. - This process occurs daily during the QuickBooks Online Auto-Sync to ensure your card remains reconciled. @@ -89,6 +93,7 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### How This Works 1. During the first overnight Auto Sync after enabling Continuous Reconciliation, Expensify will create a Liability Account (Bank Account) on your Xero Dashboard. If you've opted for Daily Settlement, an additional Clearing Account will be created in your General Ledger. Two Contacts —Expensify and Expensify Card— will also be generated: +![Xero Contacts](https://help.expensify.com/assets/images/Auto-reconciliation Xero 1.png){:width="100%"} 2. The bank account for Expensify Card transactions is tied to the Liability Account Expensify created. Note that this doesn't apply to other cards or non-reimbursable expenses, which follow your workspace settings. ### Daily Settlement Reconciliation @@ -129,7 +134,9 @@ Once Auto-Reconciliation is enabled, there are a few things that happen. Let’s ### Example - Let's say you have card transactions totaling $100 for the day. - We create a journal entry: +![NetSuite Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 1.png){:width="100%"} - After transactions are posted in Expensify, we create the second Journal Entry(ies): +![NetSuite Second Journal Entry](https://help.expensify.com/assets/images/Auto-reconciliation NS 2.png){:width="100%"} - We then reconcile the matching amounts automatically, clearing the balance of the Expensify Clearing Account. - Now, you'll have a debit on your Credit Card account (increasing the total spent) and a credit on the bank account (reducing the amount available). The clearing account has a $0 balance. - Each expense will also create a Journal Entry, just as we do today, exported upon final approval. This entry will debit the expense account (category) and contain any other line item data. diff --git a/docs/articles/new-expensify/account-settings/Coming-Soon.md b/docs/articles/new-expensify/account-settings/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/account-settings/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/account-settings/Profile.md b/docs/articles/new-expensify/account-settings/Profile.md new file mode 100644 index 000000000000..908cf39c7ac6 --- /dev/null +++ b/docs/articles/new-expensify/account-settings/Profile.md @@ -0,0 +1,92 @@ +--- +title: Profile +description: How to manage your Expensify Profile +--- +# Overview +Your Profile in Expensify allows you to: +- Set your public profile photo +- Set a display name +- Manage your contact methods +- Communicate your current status +- Set your pronouns +- Configure your timezone +- Store your personal details (for travel and payment purposes) + +# How to set your public profile photo + +To set or update your profile photo: +1. Go to **Settings > Profile** +2. Tap on the default or your existing profile photo, +3. You can either either upload (to set a new profile photo), remove or view your profile photo + +Your profile photo is visible to all Expensify users. + +# How to set a display name + +To set or update your display name: +1. Go to **Settings > Profile** +2. Tap on **Display name** +3. Set a first name and a last name, then **Save** + +Your display name is public to all Expensify users. + +# How to add or remove contact methods (email address and phone number) + +Your contact methods allow people to contact you (using your email address or phone number), and allow you to forward receipts to receipts@expensify.com from multiple email addresses. + +To manage your contact methods: +1. Go to **Settings > Profile** +2. Tap on **Contact method** +3. Tap **New contact method** to add a new email or phone number + +Your default contact method (email address or phone number) will be visible to "known" users, with whom you have interacted or are part of your team. + +To change the email address or phone number that's displayed on your Expensify account, add a new contact method, then tap on that email address and tap **Set as default**. + +# How to communicate your current status + +You can use your status emoji to communicate your mood, focus or current activity. You can optionally add a status message too! + +To set your status emoji and status message: +1. Go to **Settings > Profile** +2. Tap on **Status** then **Status** +3. Choose a status emoji, and optionally set a status message +4. Tap on **Save** + +Your status emoji will be visible next to your name in Expensify, and your status emoji and status message will appear in your profile (which is public to all Expensify users). On a computer, your status message will also be visible by hovering your mouse over your name. + +You can also remove your current status: +1. Go to **Settings > Profile** +2. Tap on **Status** +3. Tap on **Clear status** + +# How to set your pronouns + +To set your pronouns: +1. Go to **Settings > Profile** +2. Tap on **Pronouns** +3. Search for your preferred pronouns, then tap on your choice + +Your pronouns will be visible to "known" users, with whom you have interacted or are part of your team. + +# How to configure your timezone + +Your timezone is automatically set using an estimation based on your IP address. + +To set your timezone manually: +1. Go to **Settings > Profile** +2. Tap on **Timezone** +3. Disable **Automatically determine your location** +4. Tap on **Timezone** +5. Search for your preferred timezone, then tap on your choice + +Your timezone will be visible to "known" users, with whom you have interacted or are part of your team. + +# How to store your personal details (for travel and payment purposes) + +Your personal details can be used in Expensify for travel and payment purposes. These will not be shared with any other Expensify user. + +To set your timezone manually: +1. Go to **Settings > Profile** +2. Tap on **Personal details** +3. Tap on **Legal name**, **Date of birth**, and **Address** to set your personal details diff --git a/docs/articles/new-expensify/getting-started/Security.md b/docs/articles/new-expensify/account-settings/Security.md similarity index 100% rename from docs/articles/new-expensify/getting-started/Security.md rename to docs/articles/new-expensify/account-settings/Security.md diff --git a/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md b/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/bank-accounts-and-credit-cards/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md b/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md new file mode 100644 index 000000000000..de66315f2d79 --- /dev/null +++ b/docs/articles/new-expensify/bank-accounts/Connect-a-Bank-Account.md @@ -0,0 +1,142 @@ +--- +title: Connect a Business Bank Account - US +description: How to connect a business bank account to Expensify (US) +--- +# Overview +Adding a verified business bank account unlocks a myriad of features and automation in Expensify. +Once you connect your business bank account, you can: +- Reimburse expenses via direct bank transfer +- Pay bills +- Collect invoice payments +- Issue the Expensify Card + +# How to add a verified business bank account +To connect a business bank account to Expensify, follow the below steps: +1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account > Connect bank account** +2. Click **Connect online with Plaid** +3. Click **Continue** +4. When you reach the **Plaid** screen, you'll be shown a list of compatible banks that offer direct online login access +5. Login to the business bank account: +- If the bank is not listed, click the X to go back to the connection type +- Here you’ll see the option to **Connect Manually** +- Enter your account and routing numbers +6. Enter your bank login credentials: +- If your bank requires additional security measures, you will be directed to obtain and enter a security code +- If you have more than one account available to choose from, you will be directed to choose the desired account + +Next, to verify the bank account, you’ll enter some details about the business as well as some personal information. + +## Enter company information +This is where you’ll add the legal business name as well as several other company details. + +- **Company address**: The company address must be located in the US and a physical location (If you input a maildrop address, PO box, or UPS Store, the address will be flagged for review, and adding the bank account to Expensify will be delayed) +- **Tax Identification Number**: This is the identification number that was assigned to the business by the IRS +- **Company website**: A company website is required to use most of Expensify’s payment features. When adding the website of the business, format it as, https://www.domain.com +- **Industry Classification Code**: You can locate a list of Industry Classification Codes [here]([url](https://www.census.gov/naics/?input=software&year=2022)) + +## Enter personal information +Whoever is connecting the bank account to Expensify, must enter their details under the Requestor Information section: +- The address must be a physical address +- The address must be located in the US +- The SSN must be US-issued + +This does not need to be a signor on the bank account. If someone other than the Expensify account holder enters their personal information in this section, the details will be flagged for review, and adding the bank account to Expensify will be delayed. + +## Upload ID +After entering your personal details, you’ll be prompted to click a link or scan a QR code so that you can do the following: +1. Upload a photo of the front and back of your ID (this cannot be a photo of an existing image) +2. Use your device to take a selfie and record a short video of yourself + +**Your ID must be:** +- Issued in the US +- Current (ie: the expiration date must be in the future) + +## Additional Information +Check the appropriate box under **Additional Information**, accept the agreement terms, and verify that all of the information is true and accurate: +- A Beneficial Owner refers to an **individual** who owns 25% or more of the business. +- If you or another **individual** owns 25% or more of the business, please check the appropriate box +- If someone else owns 25% or more of the business, you will be prompted to provide their personal information + +If no individual owns more than 25% of the company you do not need to list any beneficial owners. In that case, be sure to leave both boxes unchecked under the Beneficial Owner Additional Information section. + +# How to validate the bank account + +The account you set up can be found under **Settings > Workspaces > _Workspace Name_ > Bank account** in either the **Verifying** or **Pending** state. + +If it is **Verifying**, then this means we sent you a message and need more information from you. Please review the automated message sent by Concierge. This should include a message with specific details about what's required to move forward. + +If it is **Pending**, then in 1-2 business days Expensify will administer 3 test transactions to your bank account. If after two business days you do not see these test transactions, reach out to Concierge for assistance. + +After these transactions (2 withdrawals and 1 deposit) have been processed to your account, head to the **Bank accounts** section of your workspace settings. Here you'll see a prompt to input the transaction amounts. + +Once you've finished these steps, your business bank account is ready to use in Expensify! + +# How to delete a verified bank account +If you need to delete a bank account from Expensify, run through the following steps: +1. Go to **Settings > Workspaces > _Workspace Name_ > Bank account** +2. Click the red **Delete** button under the corresponding bank account + +# Deep Dive + +## Verified bank account requirements + +To add a business bank account to issue reimbursements via ACH (US), to pay invoices (US), or to issue Expensify Cards: +- You must enter a physical address for yourself, any Beneficial Owner (if one exists), and the business associated with the bank account. We **cannot** accept a PO Box or MailDrop location. +- If you are adding the bank account to Expensify, you must add it from **your** Expensify account settings. +- If you are adding a bank account to Expensify, we are required by law to verify your identity. Part of this process requires you to verify a US-issued photo ID. For using features related to US ACH, your ID must be issued by the United States. You and any Beneficial Owner (if one exists), must also have a US address +- You must have a valid website for your business to utilize the Expensify Card, or to pay invoices with Expensify. + +## Locked bank account +When you reimburse a report, you authorize Expensify to withdraw the funds from your account. If your bank rejects Expensify’s withdrawal request, your verified bank account is locked until the issue is resolved. + +Withdrawal requests can be rejected due to insufficient funds, or if the bank account has not been enabled for direct debit. +If you need to enable direct debits from your verified bank account, your bank will require the following details: +- The ACH CompanyIDs (1270239450, 4270239450 and 2270239450) +- The ACH Originator Name (Expensify) + +To request to unlock the bank account, go to **Settings > Workspaces > _Workspace Name_ > Bank account** and click **Fix.** This sends a request to our support team to review why the bank account was locked, who will send you a message to confirm that. + +Unlocking a bank account can take 4-5 business days to process, to allow for ACH processing time and clawback periods. + +## Error adding an ID to Onfido + +Expensify is required by both our sponsor bank and federal law to verify the identity of the individual who is initiating the movement of money. We use Onfido to confirm that the person adding a payment method is genuine and not impersonating someone else. + +If you get a generic error message that indicates, "Something's gone wrong", please go through the following steps: + +1. Ensure you are using either Safari (on iPhone) or Chrome (on Android) as your web browser. +2. Check your browser's permissions to make sure that the camera and microphone settings are set to "Allow" +3. Clear your web cache for Safari (on iPhone) or Chrome (on Android). +4. If using a corporate Wi-Fi network, confirm that your corporate firewall isn't blocking the website. +5. Make sure no other apps are overlapping your screen, such as the Facebook Messenger bubble, while recording the video. +6. On iPhone, if using iOS version 15 or later, disable the Hide IP address feature in Safari. +7. If possible, try these steps on another device +8. If you have another phone available, try to follow these steps on that device +If the issue persists, please contact your Account Manager or Concierge for further troubleshooting assistance. + +# FAQ +## What is a Beneficial Owner? + +A Beneficial Owner refers to an **individual** who owns 25% or more of the business. If no individual owns 25% or more of the business, the company does not have a Beneficial Owner. + +## What do I do if the Beneficial Owner section only asks for personal details, but my organization is owned by another company? + +Please only indicate you have a Beneficial Owner, if it is an individual that owns 25% or more of the business. + +## Why can’t I input my address or upload my ID? + +Are you entering a US address? When adding a verified business bank account in Expensify, the individual adding the account, and any beneficial owner (if one exists) are required to have a US address, US photo ID, and a US SSN. If you do not meet these requirements, you’ll need to have another admin add the bank account, and then share access with you once verified. + +## Why am I asked for documents when adding my bank account? + +When a bank account is added to Expensify, we complete a series of checks to verify the information provided to us. We conduct these checks to comply with both our sponsor bank's requirements and federal government regulations, specifically the Bank Secrecy Act / Anti-Money Laundering (BSA / AML) laws. Expensify also has anti-fraud measures in place. +If automatic verification fails, we may request manual verification, which could involve documents such as address verification for your business, a letter from your bank confirming bank account ownership, etc. + +If you have any questions regarding the documentation request you received, please contact Concierge and they will be happy to assist. + +## I don’t see all three microtransactions I need to validate my bank account. What should I do? + +It's a good idea to wait till the end of that second business day. If you still don’t see them, please reach out to your bank and ask them to whitelist our ACH IDs **1270239450**, **4270239450**, and **2270239450**. Expensify’s ACH Originator Name is "Expensify". + +Make sure to reach out to your Account Manager or Concierge once that's all set, and our team will be able to re-trigger those three test transactions! + diff --git a/docs/articles/new-expensify/getting-started/Referral-Program.md b/docs/articles/new-expensify/billing-and-plan-types/Referral-Program.md similarity index 100% rename from docs/articles/new-expensify/getting-started/Referral-Program.md rename to docs/articles/new-expensify/billing-and-plan-types/Referral-Program.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md similarity index 97% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md index 17c7a60b8e5a..5128484adc9d 100644 --- a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Admins.md +++ b/docs/articles/new-expensify/chat/Expensify-Chat-For-Admins.md @@ -1,7 +1,6 @@ --- title: Expensify Chat for Admins description: Best Practices for Admins settings up Expensify Chat -redirect_from: articles/other/Expensify-Chat-For-Admins/ --- # Overview diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Attendees.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Attendees.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Attendees.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md b/docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Speakers.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-For-Conference-Speakers.md rename to docs/articles/new-expensify/chat/Expensify-Chat-For-Conference-Speakers.md diff --git a/docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md b/docs/articles/new-expensify/chat/Expensify-Chat-Playbook-For-Conferences.md similarity index 100% rename from docs/articles/new-expensify/getting-started/chat/Expensify-Chat-Playbook-For-Conferences.md rename to docs/articles/new-expensify/chat/Expensify-Chat-Playbook-For-Conferences.md diff --git a/docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md similarity index 99% rename from docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md rename to docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md index da78061027fa..669d960275e6 100644 --- a/docs/articles/new-expensify/getting-started/chat/Get-to-know-Expensify-Chat.md +++ b/docs/articles/new-expensify/chat/Introducing-Expensify-Chat.md @@ -1,5 +1,5 @@ --- -title: Get to know Expensify Chat +title: Introducing Expensify Chat description: Everything you need to know about Expensify Chat! redirect_from: articles/other/Everything-About-Chat/ --- diff --git a/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md b/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/expense-and-report-features/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md b/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/insights-and-custom-reporting/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- diff --git a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md b/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md deleted file mode 100644 index 7a0717eeb5d1..000000000000 --- a/docs/articles/new-expensify/integrations/accounting-integrations/QuickBooks-Online.md +++ /dev/null @@ -1,320 +0,0 @@ ---- -title: The QuickBooks Online Integration -description: Expensify's integration with QuickBooks Online streamlines your expense management. - ---- -# 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: - -a. Go to the Reports page on the web. - -b. Tick the checkbox next to the reports you want to export. - -c. 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. Here is a screenshot of how your expenses map in QuickBooks.** - -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 > Worspaces > 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 > Workpaces > Group > [Workspace Name] > Connections, and click Configure. -- Navigate to the Coding tab. -- Turn on 'T.''. -- 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/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md b/docs/articles/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md deleted file mode 100644 index 6b85bb0364b5..000000000000 --- a/docs/articles/new-expensify/manage-employees-and-report-approvals/Coming-Soon.md +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- 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/chat-bubble.svg b/docs/assets/images/chat-bubble.svg new file mode 100644 index 000000000000..fbab26d72b44 --- /dev/null +++ b/docs/assets/images/chat-bubble.svg @@ -0,0 +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 1cb8a11b95fc..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/docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html b/docs/new-expensify/hubs/bank-accounts/index.html similarity index 100% rename from docs/new-expensify/hubs/bank-accounts-and-credit-cards/index.html rename to docs/new-expensify/hubs/bank-accounts/index.html diff --git a/docs/new-expensify/hubs/chat/index.html b/docs/new-expensify/hubs/chat/index.html new file mode 100644 index 000000000000..9fa1f1547c0f --- /dev/null +++ b/docs/new-expensify/hubs/chat/index.html @@ -0,0 +1,6 @@ +--- +layout: default +title: Chat +--- + +{% include hub.html %} diff --git a/docs/new-expensify/hubs/expense-and-report-features/index.html b/docs/new-expensify/hubs/expense-and-report-features/index.html deleted file mode 100644 index 0057ae0fa46c..000000000000 --- a/docs/new-expensify/hubs/expense-and-report-features/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Expense and Report Features ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/getting-started/chat.html b/docs/new-expensify/hubs/getting-started/chat.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/getting-started/chat.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/docs/new-expensify/hubs/getting-started/index.html b/docs/new-expensify/hubs/getting-started/index.html deleted file mode 100644 index 14ca13d0c2e8..000000000000 --- a/docs/new-expensify/hubs/getting-started/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Getting Started ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/insights-and-custom-reporting/index.html b/docs/new-expensify/hubs/insights-and-custom-reporting/index.html deleted file mode 100644 index 16c96cb51d01..000000000000 --- a/docs/new-expensify/hubs/insights-and-custom-reporting/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Exports ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/integrations/accounting-integrations.html b/docs/new-expensify/hubs/integrations/accounting-integrations.html deleted file mode 100644 index 86641ee60b7d..000000000000 --- a/docs/new-expensify/hubs/integrations/accounting-integrations.html +++ /dev/null @@ -1,5 +0,0 @@ ---- -layout: default ---- - -{% include section.html %} diff --git a/docs/new-expensify/hubs/integrations/index.html b/docs/new-expensify/hubs/integrations/index.html deleted file mode 100644 index d1f173534c8a..000000000000 --- a/docs/new-expensify/hubs/integrations/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Integrations ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html b/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html deleted file mode 100644 index 31e992f32d5d..000000000000 --- a/docs/new-expensify/hubs/manage-employees-and-report-approvals/index.html +++ /dev/null @@ -1,6 +0,0 @@ ---- -layout: default -title: Manage Employees & Report Approvals ---- - -{% include hub.html %} \ No newline at end of file diff --git a/docs/redirects.csv b/docs/redirects.csv index 3bcfb594bedb..9d9470919e41 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -17,4 +17,11 @@ https://community.expensify.com/discussion/5802/deep-dive-understanding-math-and https://community.expensify.com/discussion/5796/deep-dive-user-level-formula,https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates https://community.expensify.com/discussion/4750/how-to-create-a-custom-export,https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates https://community.expensify.com/discussion/4642/how-to-export-reports-to-a-custom-template,https://help.expensify.com/articles/expensify-classic/insights-and-custom-reporting/Custom-Templates - +https://community.expensify.com/discussion/5648/deep-dive-policy-users-and-roles,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles +https://community.expensify.com/discussion/5740/deep-dive-what-expense-information-is-available-based-on-role,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles +https://community.expensify.com/discussion/4472/how-to-set-or-edit-a-user-role,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles +https://community.expensify.com/discussion/5655/deep-dive-what-is-a-vacation-delegate,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate +https://community.expensify.com/discussion/5194/how-to-assign-a-vacation-delegate-for-an-employee-through-domains,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate +https://community.expensify.com/discussion/5190/how-to-individually-assign-a-vacation-delegate-from-account-settings,https://help.expensify.com/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate +https://community.expensify.com/discussion/5274/how-to-set-up-an-adp-indirect-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/integrations/HR-integrations/ADP +https://community.expensify.com/discussion/5776/how-to-create-mileage-expenses-in-expensify,https://help.expensify.com/articles/expensify-classic/get-paid-back/Distance-Tracking#gsc.tab=0 diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 0de3f7cb2671..fb8390ed33da 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.4.0 + 1.4.2 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.4.0.0 + 1.4.2.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index cd8b9bd630c8..555d20bf7323 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.4.0 + 1.4.2 CFBundleSignature ???? CFBundleVersion - 1.4.0.0 + 1.4.2.3 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 fae3dbb10ed7..18f74f2422cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.4.0-0", + "version": "1.4.2-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.4.0-0", + "version": "1.4.2-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", @@ -92,7 +92,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.115", + "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -165,7 +165,6 @@ "@types/js-yaml": "^4.0.5", "@types/lodash": "^4.14.195", "@types/mapbox-gl": "^2.7.13", - "@types/mock-fs": "^4.13.1", "@types/pusher-js": "^5.1.0", "@types/react": "^18.2.12", "@types/react-beautiful-dnd": "^13.1.4", @@ -213,8 +212,8 @@ "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-transformer-svg": "^2.0.1", + "memfs": "^4.6.0", "metro-react-native-babel-preset": "0.76.8", - "mock-fs": "^4.13.0", "onchange": "^7.1.0", "portfinder": "^1.0.28", "prettier": "^2.8.8", @@ -241,8 +240,8 @@ "yaml": "^2.2.1" }, "engines": { - "node": "16.15.1", - "npm": "8.11.0" + "node": "20.9.0", + "npm": "10.1.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -12823,6 +12822,18 @@ "semver": "bin/semver.js" } }, + "node_modules/@storybook/builder-webpack5/node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@storybook/builder-webpack5/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -16801,6 +16812,18 @@ "semver": "bin/semver.js" } }, + "node_modules/@storybook/manager-webpack5/node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@storybook/manager-webpack5/node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -19413,15 +19436,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mock-fs": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", - "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -29891,8 +29905,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", @@ -30232,9 +30246,10 @@ "license": "MIT" }, "node_modules/fast-diff": { - "version": "1.2.0", - "dev": true, - "license": "Apache-2.0" + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true }, "node_modules/fast-equals": { "version": "4.0.3", @@ -30976,6 +30991,18 @@ "dev": true, "license": "MIT" }, + "node_modules/fork-ts-checker-webpack-plugin/node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/fork-ts-checker-webpack-plugin/node_modules/schema-utils": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", @@ -31121,9 +31148,10 @@ } }, "node_modules/fs-monkey": { - "version": "1.0.3", - "dev": true, - "license": "Unlicense" + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", + "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", + "dev": true }, "node_modules/fs-write-stream-atomic": { "version": "1.0.10", @@ -32759,6 +32787,15 @@ "which": "bin/which" } }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, + "engines": { + "node": ">=10.18" + } + }, "node_modules/hyphenate-style-name": { "version": "1.0.4", "license": "BSD-3-Clause" @@ -37359,6 +37396,13 @@ "integrity": "sha512-hFuH8TY+Yji7Eja3mGiuAxBqLagejScbG8GbG0j6o9vzn0YL14My+ktnqtZgFTosKymC9/44wP6s7xyuLfnClw==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "peer": true + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -38587,14 +38631,70 @@ } }, "node_modules/memfs": { - "version": "3.4.7", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.6.0.tgz", + "integrity": "sha512-I6mhA1//KEZfKRQT9LujyW6lRbX7RkC24xKododIDO3AGShcaFAMKElv1yFGWX8fD4UaSiwasr3NeQ5TdtHY1A==", "dev": true, - "license": "Unlicense", "dependencies": { - "fs-monkey": "^1.0.3" + "json-joy": "^9.2.0", + "thingies": "^1.11.1" }, "engines": { "node": ">= 4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/memfs/node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "node_modules/memfs/node_modules/json-joy": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/json-joy/-/json-joy-9.9.1.tgz", + "integrity": "sha512-/d7th2nbQRBQ/nqTkBe6KjjvDciSwn9UICmndwk3Ed/Bk9AqkTRm4PnLVfXG4DKbT0rEY0nKnwE7NqZlqKE6kg==", + "dev": true, + "dependencies": { + "arg": "^5.0.2", + "hyperdyperid": "^1.2.0" + }, + "bin": { + "jj": "bin/jj.js", + "json-pack": "bin/json-pack.js", + "json-pack-test": "bin/json-pack-test.js", + "json-patch": "bin/json-patch.js", + "json-patch-test": "bin/json-patch-test.js", + "json-pointer": "bin/json-pointer.js", + "json-pointer-test": "bin/json-pointer-test.js", + "json-unpack": "bin/json-unpack.js" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "quill-delta": "^5", + "rxjs": "7", + "tslib": "2" + } + }, + "node_modules/memfs/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "peer": true, + "dependencies": { + "tslib": "^2.1.0" } }, "node_modules/memoize-one": { @@ -40887,13 +40987,6 @@ "node": ">=10" } }, - "node_modules/mock-fs": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-4.14.0.tgz", - "integrity": "sha512-qYvlv/exQ4+svI3UOvPUpLDF0OMX5euvUH0Ny4N5QyRyhNdgAgUrVH3iUINSzEPLvx0kbo/Bp28GJKIqvE7URw==", - "dev": true, - "license": "MIT" - }, "node_modules/move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -43651,6 +43744,21 @@ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "dev": true, + "peer": true, + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -44323,17 +44431,17 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.115", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.115.tgz", - "integrity": "sha512-uPrJcw3Ta/EFL3Mh3iUggZ7EeEwLTSSSc5iUkKAA+a9Y8kBo8+6MWup9VCM/4wgysZbf3VHUGJCWQ8H3vWKgUg==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", "underscore": "^1.13.6" }, "engines": { - "node": ">=16.15.1 <=18.17.1", - "npm": ">=8.11.0 <=9.6.7" + "node": ">=16.15.1 <=20.9.0", + "npm": ">=8.11.0 <=10.1.0" }, "peerDependencies": { "idb-keyval": "^6.2.1", @@ -49296,6 +49404,18 @@ "dev": true, "license": "MIT" }, + "node_modules/thingies": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.12.0.tgz", + "integrity": "sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA==", + "dev": true, + "engines": { + "node": ">=10.18" + }, + "peerDependencies": { + "tslib": "^2" + } + }, "node_modules/throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", @@ -51690,6 +51810,18 @@ "node": ">= 10" } }, + "node_modules/webpack-dev-server/node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/webpack-dev-server/node_modules/schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", @@ -61813,6 +61945,15 @@ } } }, + "memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.4" + } + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -64738,6 +64879,15 @@ } } }, + "memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.4" + } + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -66601,15 +66751,6 @@ "version": "3.0.5", "dev": true }, - "@types/mock-fs": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.1.tgz", - "integrity": "sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==", - "dev": true, - "requires": { - "@types/node": "*" - } - }, "@types/ms": { "version": "0.7.31", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-0.7.31.tgz", @@ -74276,9 +74417,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", @@ -74528,7 +74669,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-diff": { - "version": "1.2.0", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "dev": true }, "fast-equals": { @@ -75059,6 +75202,15 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, + "memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.4" + } + }, "schema-utils": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-2.7.0.tgz", @@ -75158,7 +75310,9 @@ } }, "fs-monkey": { - "version": "1.0.3", + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.5.tgz", + "integrity": "sha512-8uMbBjrhzW76TYgEV27Y5E//W2f/lTFmx78P2w19FZSxarhI/798APGQyuGCwmkNxgwGRhrLfvWyLBvNtuOmew==", "dev": true }, "fs-write-stream-atomic": { @@ -76329,6 +76483,12 @@ } } }, + "hyperdyperid": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true + }, "hyphenate-style-name": { "version": "1.0.4" }, @@ -79519,6 +79679,13 @@ "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", "integrity": "sha512-hFuH8TY+Yji7Eja3mGiuAxBqLagejScbG8GbG0j6o9vzn0YL14My+ktnqtZgFTosKymC9/44wP6s7xyuLfnClw==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "peer": true + }, "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -80428,10 +80595,41 @@ } }, "memfs": { - "version": "3.4.7", + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.6.0.tgz", + "integrity": "sha512-I6mhA1//KEZfKRQT9LujyW6lRbX7RkC24xKododIDO3AGShcaFAMKElv1yFGWX8fD4UaSiwasr3NeQ5TdtHY1A==", "dev": true, "requires": { - "fs-monkey": "^1.0.3" + "json-joy": "^9.2.0", + "thingies": "^1.11.1" + }, + "dependencies": { + "arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true + }, + "json-joy": { + "version": "9.9.1", + "resolved": "https://registry.npmjs.org/json-joy/-/json-joy-9.9.1.tgz", + "integrity": "sha512-/d7th2nbQRBQ/nqTkBe6KjjvDciSwn9UICmndwk3Ed/Bk9AqkTRm4PnLVfXG4DKbT0rEY0nKnwE7NqZlqKE6kg==", + "dev": true, + "requires": { + "arg": "^5.0.2", + "hyperdyperid": "^1.2.0" + } + }, + "rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "peer": true, + "requires": { + "tslib": "^2.1.0" + } + } } }, "memoize-one": { @@ -82086,12 +82284,6 @@ "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==" }, - "mock-fs": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-4.14.0.tgz", - "integrity": "sha512-qYvlv/exQ4+svI3UOvPUpLDF0OMX5euvUH0Ny4N5QyRyhNdgAgUrVH3iUINSzEPLvx0kbo/Bp28GJKIqvE7URw==", - "dev": true - }, "move-concurrently": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/move-concurrently/-/move-concurrently-1.0.1.tgz", @@ -84053,6 +84245,18 @@ "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==" }, + "quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "dev": true, + "peer": true, + "requires": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + } + }, "raf-schd": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", @@ -84618,9 +84822,9 @@ } }, "react-native-onyx": { - "version": "1.0.115", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.115.tgz", - "integrity": "sha512-uPrJcw3Ta/EFL3Mh3iUggZ7EeEwLTSSSc5iUkKAA+a9Y8kBo8+6MWup9VCM/4wgysZbf3VHUGJCWQ8H3vWKgUg==", + "version": "1.0.118", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.118.tgz", + "integrity": "sha512-w54jO+Bpu1ElHsrxZXIIpcBqNkrUvuVCQmwWdfOW5LvO4UwsPSwmMxzExbUZ4ip+7CROmm10IgXFaAoyfeYSVQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -88122,6 +88326,13 @@ "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, + "thingies": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-1.12.0.tgz", + "integrity": "sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA==", + "dev": true, + "requires": {} + }, "throat": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", @@ -89919,6 +90130,15 @@ "integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==", "dev": true }, + "memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "requires": { + "fs-monkey": "^1.0.4" + } + }, "schema-utils": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", diff --git a/package.json b/package.json index b7e2f5ad8515..88c2ab282bd5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.4.0-0", + "version": "1.4.2-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", @@ -139,7 +139,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.115", + "react-native-onyx": "1.0.118", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -212,7 +212,6 @@ "@types/js-yaml": "^4.0.5", "@types/lodash": "^4.14.195", "@types/mapbox-gl": "^2.7.13", - "@types/mock-fs": "^4.13.1", "@types/pusher-js": "^5.1.0", "@types/react": "^18.2.12", "@types/react-beautiful-dnd": "^13.1.4", @@ -260,8 +259,8 @@ "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-transformer-svg": "^2.0.1", + "memfs": "^4.6.0", "metro-react-native-babel-preset": "0.76.8", - "mock-fs": "^4.13.0", "onchange": "^7.1.0", "portfinder": "^1.0.28", "prettier": "^2.8.8", @@ -302,7 +301,7 @@ ] }, "engines": { - "node": "16.15.1", - "npm": "8.11.0" + "node": "20.9.0", + "npm": "10.1.0" } } 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/build-desktop.sh b/scripts/build-desktop.sh index c67c37a527a2..88ab17e7a2bd 100755 --- a/scripts/build-desktop.sh +++ b/scripts/build-desktop.sh @@ -14,16 +14,15 @@ else fi SCRIPTS_DIR=$(dirname "${BASH_SOURCE[0]}") -LOCAL_PACKAGES=$(npm bin) source "$SCRIPTS_DIR/shellUtils.sh"; title "Bundling Desktop js Bundle Using Webpack" info " • ELECTRON_ENV: $ELECTRON_ENV" info " • ENV file: $ENV_FILE" info "" -"$LOCAL_PACKAGES/webpack" --config config/webpack/webpack.desktop.js --env envFile=$ENV_FILE +npx webpack --config config/webpack/webpack.desktop.js --env envFile=$ENV_FILE title "Building Desktop App Archive Using Electron" info "" shift 1 -"$LOCAL_PACKAGES/electron-builder" --config config/electronBuilder.config.js "$@" +npx electron-builder --config config/electronBuilder.config.js "$@" diff --git a/src/CONST.ts b/src/CONST.ts index 69ca8256cc6f..436ac4ebbc31 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, @@ -538,7 +537,6 @@ const CONST = { ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', - // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', @@ -576,6 +574,7 @@ const CONST = { CREATED: 'CREATED', IOU: 'IOU', MODIFIEDEXPENSE: 'MODIFIEDEXPENSE', + MOVED: 'MOVED', REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED', RENAMED: 'RENAMED', REPORTPREVIEW: 'REPORTPREVIEW', @@ -961,6 +960,7 @@ const CONST = { ATTACHMENT_SOURCE_ATTRIBUTE: 'data-expensify-source', ATTACHMENT_PREVIEW_ATTRIBUTE: 'src', ATTACHMENT_ORIGINAL_FILENAME_ATTRIBUTE: 'data-name', + ATTACHMENT_LOCAL_URL_PREFIX: ['blob:', 'file:'], ATTACHMENT_PICKER_TYPE: { FILE: 'file', @@ -1189,7 +1189,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 +1233,7 @@ const CONST = { DOCX: 'docx', SVG: 'svg', }, + RECEIPT_ERROR: 'receiptError', }, GROWL: { @@ -1278,7 +1280,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: { @@ -2879,6 +2885,35 @@ const CONST = { * The count of characters we'll allow the user to type after reaching SEARCH_MAX_LENGTH in an input. */ ADDITIONAL_ALLOWED_CHARACTERS: 20, + + REFERRAL_PROGRAM: { + CONTENT_TYPES: { + MONEY_REQUEST: 'request', + START_CHAT: 'startChat', + SEND_MONEY: 'sendMoney', + REFER_FRIEND: 'referralFriend', + }, + REVENUE: 250, + 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', + + /** + * Performance test setup - run the same test multiple times to get a more accurate result + */ + PERFORMANCE_TESTS: { + RUNS: 20, + }, } as const; export default CONST; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index a5a969adb833..75c284fb9546 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -261,6 +261,9 @@ const ONYXKEYS = { REPORT_USER_IS_LEAVING_ROOM: 'reportUserIsLeavingRoom_', SECURITY_GROUP: 'securityGroup_', TRANSACTION: 'transactions_', + + // Holds temporary transactions used during the creation and edit flow + TRANSACTION_DRAFT: 'transactionsDraft_', SPLIT_TRANSACTION_DRAFT: 'splitTransactionDraft_', PRIVATE_NOTES_DRAFT: 'privateNotesDraft_', NEXT_STEP: 'reportNextStep_', @@ -334,6 +337,8 @@ const ONYXKEYS = { REPORT_PHYSICAL_CARD_FORM_DRAFT: 'requestPhysicalCardFormDraft', REPORT_VIRTUAL_CARD_FRAUD: 'reportVirtualCardFraudForm', REPORT_VIRTUAL_CARD_FRAUD_DRAFT: 'reportVirtualCardFraudFormDraft', + GET_PHYSICAL_CARD_FORM: 'getPhysicalCardForm', + GET_PHYSICAL_CARD_FORM_DRAFT: 'getPhysicalCardFormDraft', }, } as const; @@ -500,6 +505,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_VIRTUAL_CARD_FRAUD_DRAFT]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM]: OnyxTypes.Form; [ONYXKEYS.FORMS.REPORT_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM]: OnyxTypes.Form; + [ONYXKEYS.FORMS.GET_PHYSICAL_CARD_FORM_DRAFT]: OnyxTypes.Form | undefined; }; type OnyxKeyValue = OnyxEntry; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index ed9cc6ae987c..26589a3db0e0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -83,6 +83,22 @@ export default { route: '/settings/wallet/card/:domain/report-virtual-fraud', getRoute: (domain: string) => `/settings/wallet/card/${domain}/report-virtual-fraud`, }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME: { + route: '/settings/wallet/card/:domain/get-physical/name', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/name`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE: { + route: '/settings/wallet/card/:domain/get-physical/phone', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/phone`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS: { + route: '/settings/wallet/card/:domain/get-physical/address', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/address`, + }, + SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM: { + route: '/settings/wallet/card/:domain/get-physical/confirm', + getRoute: (domain: string) => `/settings/wallet/card/${domain}/get-physical/confirm`, + }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', @@ -358,6 +374,11 @@ export default { route: 'workspace/:policyID/members', getRoute: (policyID: string) => `workspace/${policyID}/members`, }, + // Referral program promotion + REFERRAL_DETAILS_MODAL: { + route: 'referral/:contentType', + getRoute: (contentType: string) => `referral/${contentType}`, + }, // These are some one-off routes that will be removed once they're no longer needed (see GH issues for details) SAASTR: 'saastr', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index f7de8cfab4b6..f957a1dbb25e 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -2,15 +2,20 @@ * This is a file containing constants for all of the screen names. In most cases, we should use the routes for * navigation. But there are situations where we may need to access screen names directly. */ -export default { + +const PROTECTED_SCREENS = { HOME: 'Home', + CONCIERGE: 'Concierge', + REPORT_ATTACHMENTS: 'ReportAttachments', +} as const; + +export default { + ...PROTECTED_SCREENS, LOADING: 'Loading', REPORT: 'Report', - REPORT_ATTACHMENTS: 'ReportAttachments', NOT_FOUND: 'not-found', TRANSITION_BETWEEN_APPS: 'TransitionBetweenApps', VALIDATE_LOGIN: 'ValidateLogin', - CONCIERGE: 'Concierge', SETTINGS: { ROOT: 'Settings_Root', PREFERENCES: 'Settings_Preferences', @@ -18,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', @@ -28,3 +39,5 @@ export default { DESKTOP_SIGN_IN_REDIRECT: 'DesktopSignInRedirect', SAML_SIGN_IN: 'SAMLSignIn', } as const; + +export {PROTECTED_SCREENS}; diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js index 252c8380b062..4f1500132106 100644 --- a/src/components/AddPaymentMethodMenu.js +++ b/src/components/AddPaymentMethodMenu.js @@ -1,15 +1,19 @@ +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 +23,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, @@ -37,10 +47,15 @@ const propTypes = { /** 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, @@ -48,31 +63,47 @@ const defaultProps = { }, betas: [], anchorRef: () => {}, + session: {}, }; -function AddPaymentMethodMenu(props) { +function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session, betas}) { + const {translate} = useLocalize(); + return ( { - props.onItemSelected(CONST.PAYMENT_METHODS.BANK_ACCOUNT); - }, - }, - ...(Permissions.canUseWallet(props.betas) + ...(ReportUtils.isIOUReport(iouReport) ? [ { - text: props.translate('common.debitCard'), + text: translate('common.personalBankAccount'), + icon: Expensicons.Bank, + onSelected: () => { + onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); + }, + }, + ] + : []), + ...(!ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0)) + ? [ + { + text: translate('common.businessBankAccount'), + icon: Expensicons.Building, + onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT), + }, + ] + : []), + ...(Permissions.canUseWallet(betas) + ? [ + { + text: translate('common.debitCard'), icon: Expensicons.CreditCard, - onSelected: () => props.onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD), + onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD), }, ] : []), @@ -88,10 +119,12 @@ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu'; export default compose( withWindowDimensions, - withLocalize, withOnyx({ betas: { key: ONYXKEYS.BETAS, }, + session: { + key: ONYXKEYS.SESSION, + }, }), )(AddPaymentMethodMenu); diff --git a/src/components/AddressForm.js b/src/components/AddressForm.js new file mode 100644 index 000000000000..19ab35f036c1 --- /dev/null +++ b/src/components/AddressForm.js @@ -0,0 +1,223 @@ +import {CONST as COMMON_CONST} from 'expensify-common/lib/CONST'; +import lodashGet from 'lodash/get'; +import PropTypes from 'prop-types'; +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import * as ValidationUtils from '@libs/ValidationUtils'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import AddressSearch from './AddressSearch'; +import CountrySelector from './CountrySelector'; +import Form from './Form'; +import StatePicker from './StatePicker'; +import TextInput from './TextInput'; + +const propTypes = { + /** Address city field */ + city: PropTypes.string, + + /** Address country field */ + country: PropTypes.string, + + /** Address state field */ + state: PropTypes.string, + + /** Address street line 1 field */ + street1: PropTypes.string, + + /** Address street line 2 field */ + street2: PropTypes.string, + + /** Address zip code field */ + zip: PropTypes.string, + + /** Callback which is executed when the user changes address, city or state */ + onAddressChanged: PropTypes.func, + + /** Callback which is executed when the user submits his address changes */ + onSubmit: PropTypes.func.isRequired, + + /** Whether or not should the form data should be saved as draft */ + shouldSaveDraft: PropTypes.bool, + + /** Text displayed on the bottom submit button */ + submitButtonText: PropTypes.string, + + /** A unique Onyx key identifying the form */ + formID: PropTypes.string.isRequired, +}; + +const defaultProps = { + city: '', + country: '', + onAddressChanged: () => {}, + shouldSaveDraft: false, + state: '', + street1: '', + street2: '', + submitButtonText: '', + zip: '', +}; + +function AddressForm({city, country, formID, onAddressChanged, onSubmit, shouldSaveDraft, state, street1, street2, submitButtonText, zip}) { + const {translate} = useLocalize(); + const zipSampleFormat = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, [country, 'samples'], ''); + const zipFormat = translate('common.zipCodeExampleFormat', {zipSampleFormat}); + const isUSAForm = country === CONST.COUNTRY.US; + + /** + * @param {Function} translate - translate function + * @param {Boolean} isUSAForm - selected country ISO code is US + * @param {Object} values - form input values + * @returns {Object} - An object containing the errors for each inputID + */ + const validator = useCallback((values) => { + const errors = {}; + const requiredFields = ['addressLine1', 'city', 'country', 'state']; + + // Check "State" dropdown is a valid state if selected Country is USA + if (values.country === CONST.COUNTRY.US && !COMMON_CONST.STATES[values.state]) { + errors.state = 'common.error.fieldRequired'; + } + + // Add "Field required" errors if any required field is empty + _.each(requiredFields, (fieldKey) => { + if (ValidationUtils.isRequiredFulfilled(values[fieldKey])) { + return; + } + errors[fieldKey] = 'common.error.fieldRequired'; + }); + + // If no country is selected, default value is an empty string and there's no related regex data so we default to an empty object + const countryRegexDetails = lodashGet(CONST.COUNTRY_ZIP_REGEX_DATA, values.country, {}); + + // The postal code system might not exist for a country, so no regex either for them. + const countrySpecificZipRegex = lodashGet(countryRegexDetails, 'regex'); + const countryZipFormat = lodashGet(countryRegexDetails, 'samples'); + + if (countrySpecificZipRegex) { + if (!countrySpecificZipRegex.test(values.zipPostCode.trim().toUpperCase())) { + if (ValidationUtils.isRequiredFulfilled(values.zipPostCode.trim())) { + errors.zipPostCode = ['privatePersonalDetails.error.incorrectZipFormat', {zipFormat: countryZipFormat}]; + } else { + errors.zipPostCode = 'common.error.fieldRequired'; + } + } + } else if (!CONST.GENERIC_ZIP_CODE_REGEX.test(values.zipPostCode.trim().toUpperCase())) { + errors.zipPostCode = 'privatePersonalDetails.error.incorrectZipFormat'; + } + + return errors; + }, []); + + return ( +
+ + + { + onAddressChanged(data, key); + // This enforces the country selector to use the country from address instead of the country from URL + Navigation.setParams({country: undefined}); + }} + defaultValue={street1 || ''} + renamedInputKeys={{ + street: 'addressLine1', + street2: 'addressLine2', + city: 'city', + state: 'state', + zipCode: 'zipPostCode', + country: 'country', + }} + maxInputLength={CONST.FORM_CHARACTER_LIMIT} + shouldSaveDraft={shouldSaveDraft} + /> + + + + + + + + + {isUSAForm ? ( + + + + ) : ( + + )} + + + + + + ); +} + +AddressForm.defaultProps = defaultProps; +AddressForm.displayName = 'AddressForm'; +AddressForm.propTypes = propTypes; + +export default AddressForm; diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js index dc3a722c2331..3fc90433f13e 100644 --- a/src/components/Alert/index.js +++ b/src/components/Alert/index.js @@ -5,7 +5,7 @@ import _ from 'underscore'; * * @param {String} title The title of the alert * @param {String} description The description of the alert - * @param {Object[]} options An array of objects with `style` and `onPress` properties + * @param {Object[]} [options] An array of objects with `style` and `onPress` properties */ export default (title, description, options) => { const result = _.filter(window.confirm([title, description], Boolean)).join('\n'); diff --git a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js index fc9cd1d7a043..763ca4615c6e 100644 --- a/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js +++ b/src/components/AnchorForCommentsOnly/BaseAnchorForCommentsOnly.js @@ -56,6 +56,7 @@ function BaseAnchorForCommentsOnly({onPressIn, onPressOut, href = '', rel = '', return ( { ReportActionContextMenu.showContextMenu( 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/AttachmentModal.js b/src/components/AttachmentModal.js index a541950d063d..4ab81ae462c9 100755 --- a/src/components/AttachmentModal.js +++ b/src/components/AttachmentModal.js @@ -23,7 +23,6 @@ import * as StyleUtils from '@styles/StyleUtils'; import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import * as IOU from '@userActions/IOU'; -import * as Policy from '@userActions/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -361,12 +360,8 @@ function AttachmentModal(props) { } const menuItems = []; const parentReportAction = props.parentReportActions[props.report.parentReportActionID]; - const isDeleted = ReportActionsUtils.isDeletedAction(parentReportAction); - const isSettled = ReportUtils.isSettled(props.parentReport.reportID); - const isAdmin = Policy.isAdminOfFreePolicy([props.policy]) && ReportUtils.isExpenseReport(props.parentReport); - const isRequestor = ReportUtils.isMoneyRequestReport(props.parentReport) && lodashGet(props.session, 'accountID', null) === parentReportAction.actorAccountID; - const canEdit = !isSettled && !isDeleted && (isAdmin || isRequestor); + const canEdit = ReportUtils.canEditFieldOfMoneyRequest(parentReportAction, props.parentReport.reportID, CONST.EDIT_REQUEST_FIELD.RECEIPT); if (canEdit) { menuItems.push({ icon: Expensicons.Camera, diff --git a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js index 28af6b511641..0f1fa15c99ca 100644 --- a/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js +++ b/src/components/Attachments/AttachmentCarousel/extractAttachmentsFromReport.js @@ -54,7 +54,7 @@ function extractAttachmentsFromReport(parentReportAction, reportActions, transac if (TransactionUtils.hasReceipt(transaction)) { const {image} = ReceiptUtils.getThumbnailAndImageURIs(transaction); - const isLocalFile = typeof image === 'string' && (image.startsWith('blob:') || image.startsWith('file:')); + const isLocalFile = typeof image === 'string' && _.some(CONST.ATTACHMENT_LOCAL_URL_PREFIX, (prefix) => image.startsWith(prefix)); attachments.unshift({ source: tryResolveUrlFromApiRoot(image), isAuthTokenRequired: !isLocalFile, diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js index 27790121aab0..ec53507d4d8e 100644 --- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js +++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.js @@ -1,6 +1,7 @@ -import React, {useEffect, useRef} from 'react'; -// We take FlatList from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another -import {FlatList} from 'react-native-gesture-handler'; +import {FlashList} from '@shopify/flash-list'; +import React, {useCallback, useEffect, useRef} from 'react'; +// We take ScrollView from this package to properly handle the scrolling of AutoCompleteSuggestions in chats since one scroll is nested inside another +import {ScrollView} from 'react-native-gesture-handler'; import Animated, {Easing, FadeOutDown, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import * as StyleUtils from '@styles/StyleUtils'; @@ -28,7 +29,16 @@ const measureHeightOfSuggestionRows = (numRows, isSuggestionPickerLarge) => { return numRows * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT; }; -function BaseAutoCompleteSuggestions(props) { +function BaseAutoCompleteSuggestions({ + highlightedSuggestionIndex, + onSelect, + renderSuggestionMenuItem, + suggestions, + accessibilityLabelExtractor, + keyExtractor, + isSuggestionPickerLarge, + forwardedRef, +}) { const styles = useThemeStyles(); const rowHeight = useSharedValue(0); const scrollRef = useRef(null); @@ -39,70 +49,56 @@ function BaseAutoCompleteSuggestions(props) { * @param {Number} params.index * @returns {JSX.Element} */ - const renderSuggestionMenuItem = ({item, index}) => ( - StyleUtils.getAutoCompleteSuggestionItemStyle(props.highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} - hoverDimmingValue={1} - onMouseDown={(e) => e.preventDefault()} - onPress={() => props.onSelect(index)} - onLongPress={() => {}} - accessibilityLabel={props.accessibilityLabelExtractor(item, index)} - > - {props.renderSuggestionMenuItem(item, index)} - + const renderItem = useCallback( + ({item, index}) => ( + StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)} + hoverDimmingValue={1} + onMouseDown={(e) => e.preventDefault()} + onPress={() => onSelect(index)} + onLongPress={() => {}} + accessibilityLabel={accessibilityLabelExtractor(item, index)} + > + {renderSuggestionMenuItem(item, index)} + + ), + [highlightedSuggestionIndex, renderSuggestionMenuItem, onSelect, accessibilityLabelExtractor], ); - /** - * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization - * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large - * lists. - * - * Also, `scrollToIndex` should be used in conjunction with `getItemLayout`, otherwise there is no way to know the location of offscreen indices or handle failures. - * - * @param {Array} data - This is the same as the data we pass into the component - * @param {Number} index the current item's index in the set of data - * - * @returns {Object} - */ - const getItemLayout = (data, index) => ({ - length: CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, - offset: index * CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, - index, - }); - - const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * props.suggestions.length; + const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length; const animatedStyles = useAnimatedStyle(() => StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.value)); useEffect(() => { - rowHeight.value = withTiming(measureHeightOfSuggestionRows(props.suggestions.length, props.isSuggestionPickerLarge), { + rowHeight.value = withTiming(measureHeightOfSuggestionRows(suggestions.length, isSuggestionPickerLarge), { duration: 100, easing: Easing.inOut(Easing.ease), }); - }, [props.suggestions.length, props.isSuggestionPickerLarge, rowHeight]); + }, [suggestions.length, isSuggestionPickerLarge, rowHeight]); useEffect(() => { if (!scrollRef.current) { return; } - scrollRef.current.scrollToIndex({index: props.highlightedSuggestionIndex, animated: true}); - }, [props.highlightedSuggestionIndex]); + scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true}); + }, [highlightedSuggestionIndex]); return ( - rowHeight.value} - style={{flex: 1}} - getItemLayout={getItemLayout} + extraData={highlightedSuggestionIndex} /> ); diff --git a/src/components/AvatarWithImagePicker.js b/src/components/AvatarWithImagePicker.js index 893a02288e77..340fc9dfedbf 100644 --- a/src/components/AvatarWithImagePicker.js +++ b/src/components/AvatarWithImagePicker.js @@ -1,14 +1,15 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; +import React, {useEffect, useRef, useState} from 'react'; +import {StyleSheet, View} from 'react-native'; import _ from 'underscore'; +import useLocalize from '@hooks/useLocalize'; import * as Browser from '@libs/Browser'; -import compose from '@libs/compose'; import * as FileUtils from '@libs/fileDownload/FileUtils'; import getImageResolution from '@libs/fileDownload/getImageResolution'; -import SpinningIndicatorAnimation from '@styles/animation/SpinningIndicatorAnimation'; import stylePropTypes from '@styles/stylePropTypes'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import AttachmentModal from './AttachmentModal'; @@ -21,11 +22,8 @@ import * as Expensicons from './Icon/Expensicons'; import OfflineWithFeedback from './OfflineWithFeedback'; import PopoverMenu from './PopoverMenu'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; -import Tooltip from './Tooltip/PopoverAnchorTooltip'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; +import Tooltip from './Tooltip'; import withNavigationFocus from './withNavigationFocus'; -import withTheme, {withThemePropTypes} from './withTheme'; -import withThemeStyles, {withThemeStylesPropTypes} from './withThemeStyles'; const propTypes = { /** Avatar source to display */ @@ -54,9 +52,6 @@ const propTypes = { left: PropTypes.number, }).isRequired, - /** Flag to see if image is being uploaded */ - isUploading: PropTypes.bool, - /** Size of Indicator */ size: PropTypes.oneOf([CONST.AVATAR_SIZE.LARGE, CONST.AVATAR_SIZE.DEFAULT]), @@ -94,9 +89,11 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, - ...withLocalizePropTypes, - ...withThemeStylesPropTypes, - ...withThemePropTypes, + /** Where the popover should be positioned relative to the anchor points. */ + anchorAlignment: PropTypes.shape({ + horizontal: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL)), + vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)), + }), }; const defaultProps = { @@ -106,7 +103,6 @@ const defaultProps = { style: [], DefaultAvatar: () => {}, isUsingDefaultAvatar: false, - isUploading: false, size: CONST.AVATAR_SIZE.DEFAULT, fallbackIcon: Expensicons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, @@ -118,58 +114,67 @@ const defaultProps = { headerTitle: '', previewSource: '', originalFileName: '', + anchorAlignment: { + horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, + vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.TOP, + }, }; -class AvatarWithImagePicker extends React.Component { - constructor(props) { - super(props); - this.animation = new SpinningIndicatorAnimation(); - this.setError = this.setError.bind(this); - this.isValidSize = this.isValidSize.bind(this); - this.showAvatarCropModal = this.showAvatarCropModal.bind(this); - this.hideAvatarCropModal = this.hideAvatarCropModal.bind(this); - this.state = { - isMenuVisible: false, - validationError: null, - phraseParam: {}, - isAvatarCropModalOpen: false, - imageName: '', - imageUri: '', - imageType: '', - }; - this.anchorRef = React.createRef(); - } - - componentDidMount() { - if (!this.props.isUploading) { - return; - } - - this.animation.start(); - } - - componentDidUpdate(prevProps) { - if (!prevProps.isFocused && this.props.isFocused) { - this.setError(null, {}); - } - if (!prevProps.isUploading && this.props.isUploading) { - this.animation.start(); - } else if (prevProps.isUploading && !this.props.isUploading) { - this.animation.stop(); - } - } - - componentWillUnmount() { - this.animation.stop(); - } +function AvatarWithImagePicker({ + isFocused, + DefaultAvatar, + style, + pendingAction, + errors, + errorRowStyles, + onErrorClose, + source, + fallbackIcon, + size, + type, + headerTitle, + previewSource, + originalFileName, + isUsingDefaultAvatar, + onImageRemoved, + anchorPosition, + anchorAlignment, + onImageSelected, + editorMaskImage, +}) { + const [isMenuVisible, setIsMenuVisible] = useState(false); + const [errorData, setErrorData] = useState({ + validationError: null, + phraseParam: {}, + }); + const [isAvatarCropModalOpen, setIsAvatarCropModalOpen] = useState(false); + const [imageData, setImageData] = useState({ + uri: '', + name: '', + type: '', + }); + const anchorRef = useRef(); + const {translate} = useLocalize(); /** * @param {String} error * @param {Object} phraseParam */ - setError(error, phraseParam) { - this.setState({validationError: error, phraseParam}); - } + const setError = (error, phraseParam) => { + setErrorData({ + validationError: error, + phraseParam, + }); + }; + + useEffect(() => { + if (isFocused) { + return; + } + + // Reset the error if the component is no longer focused. + setError(null, {}); + }, [isFocused]); /** * Check if the attachment extension is allowed. @@ -177,10 +182,10 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidExtension(image) { + const isValidExtension = (image) => { const {fileExtension} = FileUtils.splitExtensionFromFileName(lodashGet(image, 'name', '')); return _.contains(CONST.AVATAR_ALLOWED_EXTENSIONS, fileExtension.toLowerCase()); - } + }; /** * Check if the attachment size is less than allowed size. @@ -188,9 +193,7 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Boolean} */ - isValidSize(image) { - return image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; - } + const isValidSize = (image) => image && lodashGet(image, 'size', 0) < CONST.AVATAR_MAX_ATTACHMENT_SIZE; /** * Check if the attachment resolution matches constraints. @@ -198,34 +201,29 @@ class AvatarWithImagePicker extends React.Component { * @param {Object} image * @returns {Promise} */ - isValidResolution(image) { - return getImageResolution(image).then( - (resolution) => - resolution.height >= CONST.AVATAR_MIN_HEIGHT_PX && - resolution.width >= CONST.AVATAR_MIN_WIDTH_PX && - resolution.height <= CONST.AVATAR_MAX_HEIGHT_PX && - resolution.width <= CONST.AVATAR_MAX_WIDTH_PX, + const isValidResolution = (image) => + getImageResolution(image).then( + ({height, width}) => height >= CONST.AVATAR_MIN_HEIGHT_PX && width >= CONST.AVATAR_MIN_WIDTH_PX && height <= CONST.AVATAR_MAX_HEIGHT_PX && width <= CONST.AVATAR_MAX_WIDTH_PX, ); - } /** * Validates if an image has a valid resolution and opens an avatar crop modal * * @param {Object} image */ - showAvatarCropModal(image) { - if (!this.isValidExtension(image)) { - this.setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); + const showAvatarCropModal = (image) => { + if (!isValidExtension(image)) { + setError('avatarWithImagePicker.notAllowedExtension', {allowedExtensions: CONST.AVATAR_ALLOWED_EXTENSIONS}); return; } - if (!this.isValidSize(image)) { - this.setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); + if (!isValidSize(image)) { + setError('avatarWithImagePicker.sizeExceeded', {maxUploadSizeInMB: CONST.AVATAR_MAX_ATTACHMENT_SIZE / (1024 * 1024)}); return; } - this.isValidResolution(image).then((isValidResolution) => { - if (!isValidResolution) { - this.setError('avatarWithImagePicker.resolutionConstraints', { + isValidResolution(image).then((isValid) => { + if (!isValid) { + setError('avatarWithImagePicker.resolutionConstraints', { minHeightInPx: CONST.AVATAR_MIN_HEIGHT_PX, minWidthInPx: CONST.AVATAR_MIN_WIDTH_PX, maxHeightInPx: CONST.AVATAR_MAX_HEIGHT_PX, @@ -234,158 +232,168 @@ class AvatarWithImagePicker extends React.Component { return; } - this.setState({ - isAvatarCropModalOpen: true, - validationError: null, - phraseParam: {}, - isMenuVisible: false, - imageUri: image.uri, - imageName: image.name, - imageType: image.type, + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri, + name: image.name, + type: image.type, }); }); - } - - hideAvatarCropModal() { - this.setState({isAvatarCropModalOpen: false}); - } - - render() { - const DefaultAvatar = this.props.DefaultAvatar; - const additionalStyles = _.isArray(this.props.style) ? this.props.style : [this.props.style]; - - return ( - - - - - this.setState((prev) => ({isMenuVisible: !prev.isMenuVisible}))} - role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} - accessibilityLabel={this.props.translate('avatarWithImagePicker.editImage')} - disabled={this.state.isAvatarCropModalOpen} - ref={this.anchorRef} - > - - {this.props.source ? ( - - ) : ( - - )} - - - { + setIsAvatarCropModalOpen(false); + }; + + /** + * Create menu items list for avatar menu + * + * @param {Function} openPicker + * @returns {Array} + */ + const createMenuItems = (openPicker) => { + const menuItems = [ + { + icon: Expensicons.Upload, + text: translate('avatarWithImagePicker.uploadPhoto'), + onSelected: () => { + if (Browser.isSafari()) { + return; + } + openPicker({ + onPicked: showAvatarCropModal, + }); + }, + }, + ]; + + // If current avatar isn't a default avatar, allow Remove Photo option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Trashcan, + text: translate('avatarWithImagePicker.removePhoto'), + onSelected: () => { + setError(null, {}); + onImageRemoved(); + }, + }); + } + return menuItems; + }; + + return ( + + + + + setIsMenuVisible((prev) => !prev)} + role={CONST.ACCESSIBILITY_ROLE.IMAGEBUTTON} + accessibilityLabel={translate('avatarWithImagePicker.editImage')} + disabled={isAvatarCropModalOpen} + ref={anchorRef} + > + + {source ? ( + - - - - - - {({show}) => ( - - {({openPicker}) => { - const menuItems = [ - { - icon: Expensicons.Upload, - text: this.props.translate('avatarWithImagePicker.uploadPhoto'), - onSelected: () => { - if (Browser.isSafari()) { - return; - } + ) : ( + + )} + + + + + + + + + {({show}) => ( + + {({openPicker}) => { + const menuItems = createMenuItems(openPicker); + + // If the current avatar isn't a default avatar, allow the "View Photo" option + if (!isUsingDefaultAvatar) { + menuItems.push({ + icon: Expensicons.Eye, + text: translate('avatarWithImagePicker.viewPhoto'), + onSelected: show, + }); + } + + return ( + setIsMenuVisible(false)} + onItemSelected={(item, index) => { + setIsMenuVisible(false); + // In order for the file picker to open dynamically, the click + // function must be called from within an event handler that was initiated + // by the user on Safari. + if (index === 0 && Browser.isSafari()) { openPicker({ - onPicked: this.showAvatarCropModal, + onPicked: showAvatarCropModal, }); - }, - }, - ]; - - // If current avatar isn't a default avatar, allow Remove Photo option - if (!this.props.isUsingDefaultAvatar) { - menuItems.push({ - icon: Expensicons.Trashcan, - text: this.props.translate('avatarWithImagePicker.removePhoto'), - onSelected: () => { - this.setError(null, {}); - this.props.onImageRemoved(); - }, - }); - - menuItems.push({ - icon: Expensicons.Eye, - text: this.props.translate('avatarWithImagePicker.viewPhoto'), - onSelected: () => show(), - }); - } - return ( - this.setState({isMenuVisible: false})} - onItemSelected={(item, index) => { - this.setState({isMenuVisible: false}); - // In order for the file picker to open dynamically, the click - // function must be called from within a event handler that was initiated - // by the user on Safari. - if (index === 0 && Browser.isSafari()) { - openPicker({ - onPicked: this.showAvatarCropModal, - }); - } - }} - menuItems={menuItems} - anchorPosition={this.props.anchorPosition} - withoutOverlay - anchorRef={this.anchorRef} - anchorAlignment={this.props.anchorAlignment} - /> - ); - }} - - )} - - - {this.state.validationError && ( - - )} - + } + }} + menuItems={menuItems} + anchorPosition={anchorPosition} + withoutOverlay + anchorRef={anchorRef} + anchorAlignment={anchorAlignment} + /> + ); + }} + + )} + - ); - } + {errorData.validationError && ( + + )} + + + ); } AvatarWithImagePicker.propTypes = propTypes; AvatarWithImagePicker.defaultProps = defaultProps; +AvatarWithImagePicker.displayName = 'AvatarWithImagePicker'; -export default compose(withLocalize, withNavigationFocus, withThemeStyles, withTheme)(AvatarWithImagePicker); +export default withNavigationFocus(AvatarWithImagePicker); diff --git a/src/components/Badge.tsx b/src/components/Badge.tsx index 22c056dfdfc4..575646f7dd9c 100644 --- a/src/components/Badge.tsx +++ b/src/components/Badge.tsx @@ -29,7 +29,7 @@ type BadgeProps = { textStyles?: StyleProp; /** Callback to be called on onPress */ - onPress: (event?: GestureResponderEvent | KeyboardEvent) => void; + onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void; }; function Badge({success = false, error = false, pressable = false, text, environment = CONST.ENVIRONMENT.DEV, badgeStyles, textStyles, onPress = () => {}}: BadgeProps) { diff --git a/src/components/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/CategoryPicker/index.js b/src/components/CategoryPicker/index.js index ff7087df91dd..36cf9b1deadc 100644 --- a/src/components/CategoryPicker/index.js +++ b/src/components/CategoryPicker/index.js @@ -52,20 +52,9 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC return categoryOptions; }, [policyCategories, policyRecentlyUsedCategories, searchValue, selectedOptions]); - const initialFocusedIndex = useMemo(() => { - let categoryInitialFocusedIndex = 0; - - if (!_.isEmpty(searchValue) || isCategoriesCountBelowThreshold) { - const index = _.findIndex(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory); - - categoryInitialFocusedIndex = index === -1 ? 0 : index; - } - - return categoryInitialFocusedIndex; - }, [selectedCategory, searchValue, isCategoriesCountBelowThreshold, sections]); - const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue); const shouldShowTextInput = !isCategoriesCountBelowThreshold; + const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory)[0], 'keyForList'); return ( { - if (event.code !== 'Space') { - return; - } - - props.onPress(); - }; - - const firePressHandlerOnClick = (event) => { - // Pressable can be triggered with Enter key and by a click. As this is a checkbox, - // We do not want to toggle it, when Enter key is pressed. - if (event.type && event.type !== 'click') { - return; - } - - props.onPress(); - }; - - return ( - - {props.children ? ( - props.children - ) : ( - - {props.isChecked && ( - - )} - - )} - - ); -} - -Checkbox.propTypes = propTypes; -Checkbox.defaultProps = defaultProps; -Checkbox.displayName = 'Checkbox'; - -export default Checkbox; diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx new file mode 100644 index 000000000000..6ee5ed1c558f --- /dev/null +++ b/src/components/Checkbox.tsx @@ -0,0 +1,126 @@ +import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react'; +import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native'; +import * as StyleUtils from '@styles/StyleUtils'; +import useTheme from '@styles/themes/useTheme'; +import useThemeStyles from '@styles/useThemeStyles'; +import CONST from '@src/CONST'; +import ChildrenProps from '@src/types/utils/ChildrenProps'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; + +type CheckboxProps = ChildrenProps & { + /** Whether checkbox is checked */ + isChecked?: boolean; + + /** A function that is called when the box/label is pressed */ + onPress: () => void; + + /** Should the input be styled for errors */ + hasError?: boolean; + + /** Should the input be disabled */ + disabled?: boolean; + + /** Additional styles to add to checkbox button */ + style?: StyleProp; + + /** Additional styles to add to checkbox container */ + containerStyle?: StyleProp; + + /** Callback that is called when mousedown is triggered. */ + onMouseDown?: () => void; + + /** The size of the checkbox container */ + containerSize?: number; + + /** The border radius of the checkbox container */ + containerBorderRadius?: number; + + /** The size of the caret (checkmark) */ + caretSize?: number; + + /** An accessibility label for the checkbox */ + accessibilityLabel: string; +}; + +function Checkbox( + { + isChecked = false, + hasError = false, + disabled = false, + style, + containerStyle, + children = null, + onMouseDown, + containerSize = 20, + containerBorderRadius = 4, + caretSize = 14, + onPress, + accessibilityLabel, + }: CheckboxProps, + ref: ForwardedRef, +) { + const theme = useTheme(); + const styles = useThemeStyles(); + + const handleSpaceKey = (event?: ReactKeyboardEvent) => { + if (event?.code !== 'Space') { + return; + } + + onPress(); + }; + + const firePressHandlerOnClick = (event?: GestureResponderEvent | KeyboardEvent) => { + // Pressable can be triggered with Enter key and by a click. As this is a checkbox, + // We do not want to toggle it, when Enter key is pressed. + if (event?.type && event.type !== 'click') { + return; + } + + onPress(); + }; + + return ( + + {children ?? ( + + {isChecked && ( + + )} + + )} + + ); +} + +Checkbox.displayName = 'Checkbox'; + +export default forwardRef(Checkbox); diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js index 0a90a9be46e2..146e37ceb730 100644 --- a/src/components/CheckboxWithLabel.js +++ b/src/components/CheckboxWithLabel.js @@ -7,6 +7,7 @@ import variables from '@styles/variables'; import Checkbox from './Checkbox'; import FormHelpMessage from './FormHelpMessage'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import refPropTypes from './refPropTypes'; import Text from './Text'; /** @@ -54,7 +55,7 @@ const propTypes = { defaultValue: PropTypes.bool, /** React ref being forwarded to the Checkbox input */ - forwardedRef: PropTypes.func, + forwardedRef: refPropTypes, /** The ID used to uniquely identify the input in a Form */ /* eslint-disable-next-line react/no-unused-prop-types */ diff --git a/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js b/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js deleted file mode 100644 index 61a2d6feaa4b..000000000000 --- a/src/components/CheckboxWithTooltip/CheckboxWithTooltipForMobileWebAndNative.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import withWindowDimensions from '@components/withWindowDimensions'; -import Growl from '@libs/Growl'; -import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes'; - -class CheckboxWithTooltipForMobileWebAndNative extends React.Component { - constructor(props) { - super(props); - this.showGrowlOrTriggerOnPress = this.showGrowlOrTriggerOnPress.bind(this); - } - - componentDidUpdate(prevProps) { - if (!this.props.toggleTooltip) { - return; - } - - if (prevProps.toggleTooltip !== this.props.toggleTooltip) { - Growl.show(this.props.text, this.props.growlType, 3000); - } - } - - /** - * Show warning modal on mobile devices since tooltips are not supported when checkbox is disabled. - */ - showGrowlOrTriggerOnPress() { - if (this.props.toggleTooltip) { - Growl.show(this.props.text, this.props.growlType, 3000); - return; - } - this.props.onPress(); - } - - render() { - return ( - - - - ); - } -} - -CheckboxWithTooltipForMobileWebAndNative.propTypes = propTypes; -CheckboxWithTooltipForMobileWebAndNative.defaultProps = defaultProps; - -export default withWindowDimensions(CheckboxWithTooltipForMobileWebAndNative); diff --git a/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js b/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js deleted file mode 100644 index 67588d00ef65..000000000000 --- a/src/components/CheckboxWithTooltip/checkboxWithTooltipPropTypes.js +++ /dev/null @@ -1,43 +0,0 @@ -import PropTypes from 'prop-types'; -import {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import stylePropTypes from '@styles/stylePropTypes'; -import CONST from '@src/CONST'; - -const propTypes = { - /** Whether the checkbox is checked */ - isChecked: PropTypes.bool.isRequired, - - /** Called when the checkbox or label is pressed */ - onPress: PropTypes.func.isRequired, - - /** Flag to determine to toggle or not the tooltip */ - toggleTooltip: PropTypes.bool, - - /** The text to display in the tooltip. */ - text: PropTypes.string.isRequired, - - /** Type of the growl to be displayed in case of mobile devices */ - growlType: PropTypes.string, - - /** Container styles */ - style: stylePropTypes, - - /** Wheter the checkbox is disabled */ - disabled: PropTypes.bool, - - /** An accessibility label for the checkbox */ - accessibilityLabel: PropTypes.string, - - /** Props inherited from withWindowDimensions */ - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - style: [], - disabled: false, - toggleTooltip: true, - growlType: CONST.GROWL.WARNING, - accessibilityLabel: undefined, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/CheckboxWithTooltip/index.js b/src/components/CheckboxWithTooltip/index.js deleted file mode 100644 index 06e4e0412eba..000000000000 --- a/src/components/CheckboxWithTooltip/index.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import {View} from 'react-native'; -import Checkbox from '@components/Checkbox'; -import Tooltip from '@components/Tooltip'; -import withWindowDimensions from '@components/withWindowDimensions'; -import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative'; -import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes'; - -function CheckboxWithTooltip(props) { - if (props.isSmallScreenWidth || props.isMediumScreenWidth) { - return ( - - ); - } - const checkbox = ( - - ); - return ( - - {props.toggleTooltip ? ( - - {checkbox} - - ) : ( - checkbox - )} - - ); -} - -CheckboxWithTooltip.propTypes = propTypes; -CheckboxWithTooltip.defaultProps = defaultProps; -CheckboxWithTooltip.displayName = 'CheckboxWithTooltip'; - -export default withWindowDimensions(CheckboxWithTooltip); diff --git a/src/components/CheckboxWithTooltip/index.native.js b/src/components/CheckboxWithTooltip/index.native.js deleted file mode 100644 index 46ce0bbd131e..000000000000 --- a/src/components/CheckboxWithTooltip/index.native.js +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import withWindowDimensions from '@components/withWindowDimensions'; -import CheckboxWithTooltipForMobileWebAndNative from './CheckboxWithTooltipForMobileWebAndNative'; -import {defaultProps, propTypes} from './checkboxWithTooltipPropTypes'; - -function CheckboxWithTooltip(props) { - return ( - - ); -} - -CheckboxWithTooltip.propTypes = propTypes; -CheckboxWithTooltip.defaultProps = defaultProps; -CheckboxWithTooltip.displayName = 'CheckboxWithTooltip'; - -export default withWindowDimensions(CheckboxWithTooltip); diff --git a/src/components/Composer/index.js b/src/components/Composer/index.js index 4c61a5b5bba5..cbf8a6e40abd 100755 --- a/src/components/Composer/index.js +++ b/src/components/Composer/index.js @@ -13,7 +13,6 @@ import * as Browser from '@libs/Browser'; import compose from '@libs/compose'; import * as ComposerUtils from '@libs/ComposerUtils'; import updateIsFullComposerAvailable from '@libs/ComposerUtils/updateIsFullComposerAvailable'; -import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import isEnterWhileComposition from '@libs/KeyboardShortcut/isEnterWhileComposition'; import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager'; import * as StyleUtils from '@styles/StyleUtils'; @@ -139,8 +138,6 @@ const getNextChars = (str, cursorPos) => { return substr.substring(0, spaceIndex); }; -const supportsPassive = DeviceCapabilities.hasPassiveEventListenerSupport(); - // Enable Markdown parsing. // On web we like to have the Text Input field always focused so the user can easily type a new chat function Composer({ @@ -333,19 +330,6 @@ function Composer({ [onPasteFile, handlePastedHTML, checkComposerVisibility, handlePastePlainText], ); - /** - * Manually scrolls the text input, then prevents the event from being passed up to the parent. - * @param {Object} event native Event - */ - const handleWheel = useCallback((event) => { - if (event.target !== document.activeElement) { - return; - } - - textInput.current.scrollTop += event.deltaY; - event.stopPropagation(); - }, []); - /** * Check the current scrollHeight of the textarea (minus any padding) and * divide by line height to get the total number of rows for the textarea. @@ -387,7 +371,6 @@ function Composer({ if (textInput.current) { document.addEventListener('paste', handlePaste); - textInput.current.addEventListener('wheel', handleWheel, supportsPassive ? {passive: true} : false); } return () => { @@ -397,11 +380,6 @@ function Composer({ unsubscribeFocus(); unsubscribeBlur(); document.removeEventListener('paste', handlePaste); - // eslint-disable-next-line es/no-optional-chaining - if (!textInput.current) { - return; - } - textInput.current.removeEventListener('wheel', handleWheel); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/components/CopyTextToClipboard.js b/src/components/CopyTextToClipboard.js index ac396c6cedf4..acd3f08f2b22 100644 --- a/src/components/CopyTextToClipboard.js +++ b/src/components/CopyTextToClipboard.js @@ -12,18 +12,19 @@ const propTypes = { /** Styles to apply to the text */ // eslint-disable-next-line react/forbid-prop-types textStyles: PropTypes.arrayOf(PropTypes.object), - + urlToCopy: PropTypes.string, ...withLocalizePropTypes, }; const defaultProps = { textStyles: [], + urlToCopy: null, }; function CopyTextToClipboard(props) { const copyToClipboard = useCallback(() => { - Clipboard.setString(props.text); - }, [props.text]); + Clipboard.setString(props.urlToCopy || props.text); + }, [props.text, props.urlToCopy]); return ( { + if (_.isString(message)) { + return false; + } + return _.get(message, 'error', '') === CONST.IOU.RECEIPT_ERROR; +}; + function DotIndicatorMessage(props) { const theme = useTheme(); const styles = useThemeStyles(); @@ -71,14 +89,33 @@ function DotIndicatorMessage(props) { /> - {_.map(sortedMessages, (message, i) => ( - - {message} - - ))} + {_.map(sortedMessages, (message, i) => + isReceiptError(message) ? ( + { + fileDownload(message.source, message.filename); + }} + > + + {Localize.translateLocal('iou.error.receiptFailureMessage')} + {Localize.translateLocal('iou.error.saveFileMessage')} + {Localize.translateLocal('iou.error.loseFileMessage')} + + + ) : ( + + {message} + + ), + )} ); diff --git a/src/components/EmojiPicker/EmojiPicker.js b/src/components/EmojiPicker/EmojiPicker.js index 9f2744a058d1..9fc1224f96c0 100644 --- a/src/components/EmojiPicker/EmojiPicker.js +++ b/src/components/EmojiPicker/EmojiPicker.js @@ -135,6 +135,9 @@ const EmojiPicker = forwardRef((props, ref) => { }); }); return () => { + if (!emojiPopoverDimensionListener) { + return; + } emojiPopoverDimensionListener.remove(); }; }, [isEmojiPickerVisible, isSmallScreenWidth, emojiPopoverAnchorOrigin]); diff --git a/src/components/EmojiPicker/EmojiPickerButton.js b/src/components/EmojiPicker/EmojiPickerButton.js index 440f5e3beff1..db1834296a52 100644 --- a/src/components/EmojiPicker/EmojiPickerButton.js +++ b/src/components/EmojiPicker/EmojiPickerButton.js @@ -5,8 +5,6 @@ import * as Expensicons from '@components/Icon/Expensicons'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import Tooltip from '@components/Tooltip/PopoverAnchorTooltip'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import withNavigationFocus from '@components/withNavigationFocus'; -import compose from '@libs/compose'; import getButtonState from '@libs/getButtonState'; import * as StyleUtils from '@styles/StyleUtils'; import useThemeStyles from '@styles/useThemeStyles'; @@ -44,9 +42,6 @@ function EmojiPickerButton(props) { style={({hovered, pressed}) => [styles.chatItemEmojiButton, StyleUtils.getButtonBackgroundColorStyle(getButtonState(hovered, pressed))]} disabled={props.isDisabled} onPress={() => { - if (!props.isFocused) { - return; - } if (!EmojiPickerAction.emojiPickerRef.current.isEmojiPickerVisible) { EmojiPickerAction.showEmojiPicker(props.onModalHide, props.onEmojiSelected, emojiPopoverAnchor.current, undefined, () => {}, props.emojiPickerID); } else { @@ -70,4 +65,4 @@ function EmojiPickerButton(props) { EmojiPickerButton.propTypes = propTypes; EmojiPickerButton.defaultProps = defaultProps; EmojiPickerButton.displayName = 'EmojiPickerButton'; -export default compose(withLocalize, withNavigationFocus)(EmojiPickerButton); +export default withLocalize(EmojiPickerButton); diff --git a/src/components/EnvironmentBadge.js b/src/components/EnvironmentBadge.tsx similarity index 94% rename from src/components/EnvironmentBadge.js rename to src/components/EnvironmentBadge.tsx index f32946f8bc25..c31677a8f5c3 100644 --- a/src/components/EnvironmentBadge.js +++ b/src/components/EnvironmentBadge.tsx @@ -18,7 +18,7 @@ function EnvironmentBadge() { const {environment} = useEnvironment(); // If we are on production, don't show any badge - if (environment === CONST.ENVIRONMENT.PRODUCTION) { + if (environment === CONST.ENVIRONMENT.PRODUCTION || environment === undefined) { return null; } diff --git a/src/components/ExpensifyWordmark.js b/src/components/ExpensifyWordmark.tsx similarity index 55% rename from src/components/ExpensifyWordmark.js rename to src/components/ExpensifyWordmark.tsx index efb3b20dbe87..45c0c9bcef1e 100644 --- a/src/components/ExpensifyWordmark.js +++ b/src/components/ExpensifyWordmark.tsx @@ -1,7 +1,5 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; +import {StyleProp, View, ViewStyle} from 'react-native'; import AdHocLogo from '@assets/images/expensify-logo--adhoc.svg'; import DevLogo from '@assets/images/expensify-logo--dev.svg'; import StagingLogo from '@assets/images/expensify-logo--staging.svg'; @@ -12,40 +10,36 @@ import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; +import withWindowDimensions from './withWindowDimensions'; +import type {WindowDimensionsProps} from './withWindowDimensions/types'; -const propTypes = { +type ExpensifyWordmarkProps = WindowDimensionsProps & { /** Additional styles to add to the component */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), - - ...windowDimensionsPropTypes, -}; - -const defaultProps = { - style: {}, + style?: StyleProp; }; const logoComponents = { [CONST.ENVIRONMENT.DEV]: DevLogo, [CONST.ENVIRONMENT.STAGING]: StagingLogo, [CONST.ENVIRONMENT.PRODUCTION]: ProductionLogo, + [CONST.ENVIRONMENT.ADHOC]: AdHocLogo, }; -function ExpensifyWordmark(props) { +function ExpensifyWordmark({isSmallScreenWidth, style}: ExpensifyWordmarkProps) { const theme = useTheme(); const styles = useThemeStyles(); const {environment} = useEnvironment(); // PascalCase is required for React components, so capitalize the const here + const LogoComponent = environment ? logoComponents[environment] : AdHocLogo; - const LogoComponent = logoComponents[environment] || AdHocLogo; return ( <> @@ -55,6 +49,5 @@ function ExpensifyWordmark(props) { } ExpensifyWordmark.displayName = 'ExpensifyWordmark'; -ExpensifyWordmark.defaultProps = defaultProps; -ExpensifyWordmark.propTypes = propTypes; + export default withWindowDimensions(ExpensifyWordmark); diff --git a/src/components/Form.js b/src/components/Form.js index 28343691ea15..ad5fcf611e9b 100644 --- a/src/components/Form.js +++ b/src/components/Form.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import compose from '@libs/compose'; import * as ErrorUtils from '@libs/ErrorUtils'; +import FormUtils from '@libs/FormUtils'; import * as ValidationUtils from '@libs/ValidationUtils'; import Visibility from '@libs/Visibility'; import stylePropTypes from '@styles/stylePropTypes'; @@ -303,7 +304,8 @@ function Form(props) { // We want to initialize the input value if it's undefined if (_.isUndefined(inputValues[inputID])) { - inputValues[inputID] = _.isBoolean(defaultValue) ? defaultValue : defaultValue || ''; + // eslint-disable-next-line es/no-nullish-coalescing-operators + inputValues[inputID] = defaultValue ?? ''; } // We force the form to set the input value from the defaultValue props if there is a saved valid value @@ -348,10 +350,18 @@ function Form(props) { onBlur: (event) => { // Only run validation when user proactively blurs the input. if (Visibility.isVisible() && Visibility.hasFocus()) { + const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); // We delay the validation in order to prevent Checkbox loss of focus when // the user are focusing a TextInput and proceeds to toggle a CheckBox in // web and mobile web platforms. + setTimeout(() => { + if ( + relatedTargetId && + _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId) + ) { + return; + } setTouchedInput(inputID); if (props.shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); @@ -543,7 +553,7 @@ export default compose( key: (props) => props.formID, }, draftValues: { - key: (props) => `${props.formID}Draft`, + key: (props) => FormUtils.getDraftKey(props.formID), }, }), )(Form); diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index fa0cc3ebd723..776aaae688ed 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -47,6 +47,9 @@ const propTypes = { errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)), }), + /** Contains draft values for each input in the form */ + draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])), + /** Should the button be enabled when offline */ enabledWhenOffline: PropTypes.bool, @@ -77,6 +80,7 @@ const defaultProps = { formState: { isLoading: false, }, + draftValues: {}, enabledWhenOffline: false, isSubmitActionDangerous: false, scrollContextEnabled: false, @@ -100,7 +104,7 @@ function getInitialValueByType(valueType) { } } -function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) { +function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}) { const inputRefs = useRef({}); const touchedInputs = useRef({}); const [inputValues, setInputValues] = useState({}); @@ -208,7 +212,9 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC if (!_.isUndefined(propsToParse.value)) { inputValues[inputID] = propsToParse.value; - } else if (propsToParse.shouldUseDefaultValue) { + } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) { + inputValues[inputID] = draftValues[inputID]; + } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) { // We force the form to set the input value from the defaultValue props if there is a saved valid value inputValues[inputID] = propsToParse.defaultValue; } else if (_.isUndefined(inputValues[inputID])) { @@ -263,10 +269,15 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC onBlur: (event) => { // Only run validation when user proactively blurs the input. if (Visibility.isVisible() && Visibility.hasFocus()) { + const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id'); // We delay the validation in order to prevent Checkbox loss of focus when // the user is focusing a TextInput and proceeds to toggle a CheckBox in // web and mobile web platforms. + setTimeout(() => { + if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) { + return; + } setTouchedInput(inputID); if (shouldValidateOnBlur) { onValidate(inputValues, !hasServerError); @@ -293,7 +304,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC }); if (propsToParse.shouldSaveDraft) { - FormActions.setDraftValues(propsToParse.formID, {[inputKey]: value}); + FormActions.setDraftValues(formID, {[inputKey]: value}); } if (_.isFunction(propsToParse.onValueChange)) { @@ -302,7 +313,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC }, }; }, - [errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], + [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange], ); const value = useMemo(() => ({registerInput}), [registerInput]); @@ -317,7 +328,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC errors={errors} enabledWhenOffline={enabledWhenOffline} > - {children} + {_.isFunction(children) ? children({inputValues}) : children} ); @@ -333,5 +344,8 @@ export default compose( formState: { key: (props) => props.formID, }, + draftValues: { + key: (props) => `${props.formID}Draft`, + }, }), )(FormProvider); diff --git a/src/components/FormElement.js b/src/components/FormElement.js deleted file mode 100644 index d929ddb5f2e4..000000000000 --- a/src/components/FormElement.js +++ /dev/null @@ -1,16 +0,0 @@ -import React, {forwardRef} from 'react'; -import {View} from 'react-native'; -import * as ComponentUtils from '@libs/ComponentUtils'; - -const FormElement = forwardRef((props, ref) => ( - -)); - -FormElement.displayName = 'BaseForm'; -export default FormElement; diff --git a/src/components/FormElement.tsx b/src/components/FormElement.tsx new file mode 100644 index 000000000000..c61a09b9d1ec --- /dev/null +++ b/src/components/FormElement.tsx @@ -0,0 +1,18 @@ +import React, {ForwardedRef, forwardRef} from 'react'; +import {View, ViewProps} from 'react-native'; +import * as ComponentUtils from '@libs/ComponentUtils'; + +function FormElement(props: ViewProps, ref: ForwardedRef) { + return ( + + ); +} + +FormElement.displayName = 'FormElement'; + +export default forwardRef(FormElement); diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js index ed6d275201ec..3beb52e6ee81 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js +++ b/src/components/HTMLEngineProvider/HTMLRenderers/PreRenderer/index.js @@ -33,7 +33,6 @@ function PreRenderer(props) { const horizontalOverflow = node.scrollWidth > node.offsetWidth; if (event.currentTarget === node && horizontalOverflow && !debouncedIsScrollingVertically(event)) { node.scrollLeft += event.deltaX; - event.stopPropagation(); } }, []); diff --git a/src/components/HeaderGap/index.desktop.js b/src/components/HeaderGap/index.desktop.js index 2d583881cab6..07ea1ea6f48d 100644 --- a/src/components/HeaderGap/index.desktop.js +++ b/src/components/HeaderGap/index.desktop.js @@ -1,7 +1,8 @@ import PropTypes from 'prop-types'; -import React, {PureComponent} from 'react'; +import React, {memo} from 'react'; import {View} from 'react-native'; import withThemeStyles, {withThemeStylesPropTypes} from '@components/withThemeStyles'; +import compose from '@libs/compose'; const propTypes = { /** Styles to apply to the HeaderGap */ @@ -10,14 +11,15 @@ const propTypes = { ...withThemeStylesPropTypes, }; -class HeaderGap extends PureComponent { - render() { - return ; - } +const defaultProps = { + styles: [], +}; + +function HeaderGap(props) { + return ; } +HeaderGap.displayName = 'HeaderGap'; HeaderGap.propTypes = propTypes; -HeaderGap.defaultProps = { - styles: [], -}; -export default withThemeStyles(HeaderGap); +HeaderGap.defaultProps = defaultProps; +export default compose(memo, withThemeStyles)(HeaderGap); diff --git a/src/components/HeaderGap/index.js b/src/components/HeaderGap/index.js index ca81056d5f7a..35e6bf92fb5d 100644 --- a/src/components/HeaderGap/index.js +++ b/src/components/HeaderGap/index.js @@ -1,7 +1,6 @@ -import {PureComponent} from 'react'; - -export default class HeaderGap extends PureComponent { - render() { - return null; - } +function HeaderGap() { + return null; } + +HeaderGap.displayName = 'HeaderGap'; +export default HeaderGap; diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js index 1371e6a36b97..edb3b8d26831 100755 --- a/src/components/HeaderWithBackButton/index.js +++ b/src/components/HeaderWithBackButton/index.js @@ -79,6 +79,7 @@ function HeaderWithBackButton({ style={[styles.touchableButtonImage]} role="button" accessibilityLabel={translate('common.back')} + nativeID={CONST.BACK_BUTTON_NATIVE_ID} > this.setState({shouldShowAddPaymentMenu: false})} anchorRef={this.anchorRef} anchorPosition={{ diff --git a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js b/src/components/KeyboardSpacer/BaseKeyboardSpacer.js deleted file mode 100644 index adab3e2ea66d..000000000000 --- a/src/components/KeyboardSpacer/BaseKeyboardSpacer.js +++ /dev/null @@ -1,56 +0,0 @@ -import React, {useCallback, useEffect, useState} from 'react'; -import {Dimensions, Keyboard, View} from 'react-native'; -import * as StyleUtils from '@styles/StyleUtils'; -import {defaultProps, propTypes} from './BaseKeyboardSpacerPropTypes'; - -function BaseKeyboardSpacer(props) { - const [keyboardSpace, setKeyboardSpace] = useState(0); - - /** - * Update the height of Keyboard View. - * - * @param {Object} [event] - A Keyboard Event. - */ - const updateKeyboardSpace = useCallback( - (event) => { - if (!event.endCoordinates) { - return; - } - - const screenHeight = Dimensions.get('window').height; - const space = screenHeight - event.endCoordinates.screenY + props.topSpacing; - setKeyboardSpace(space); - props.onToggle(true, space); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); - - /** - * Reset the height of Keyboard View. - * - * @param {Object} [event] - A Keyboard Event. - */ - const resetKeyboardSpace = useCallback(() => { - setKeyboardSpace(0); - props.onToggle(false, 0); - }, [setKeyboardSpace, props]); - - useEffect(() => { - const updateListener = props.keyboardShowMethod; - const resetListener = props.keyboardHideMethod; - const keyboardListeners = [Keyboard.addListener(updateListener, updateKeyboardSpace), Keyboard.addListener(resetListener, resetKeyboardSpace)]; - - return () => { - keyboardListeners.forEach((listener) => listener.remove()); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - return ; -} - -BaseKeyboardSpacer.defaultProps = defaultProps; -BaseKeyboardSpacer.propTypes = propTypes; - -export default BaseKeyboardSpacer; diff --git a/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js b/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js deleted file mode 100644 index 23154da79e53..000000000000 --- a/src/components/KeyboardSpacer/BaseKeyboardSpacerPropTypes.js +++ /dev/null @@ -1,24 +0,0 @@ -import PropTypes from 'prop-types'; - -const propTypes = { - /** Top Spacing is used when there is a requirement of additional height to view. */ - topSpacing: PropTypes.number, - - /** Callback to update the value of keyboard status along with keyboard height + top spacing. */ - onToggle: PropTypes.func, - - /** Platform specific keyboard event to show keyboard https://reactnative.dev/docs/keyboard#addlistener */ - /** Pass keyboardShow event name as a param, since iOS and android both have different event names show keyboard. */ - keyboardShowMethod: PropTypes.string.isRequired, - - /** Platform specific keyboard event to hide keyboard https://reactnative.dev/docs/keyboard#addlistener */ - /** Pass keyboardHide event name as a param, since iOS and android both have different event names show keyboard. */ - keyboardHideMethod: PropTypes.string.isRequired, -}; - -const defaultProps = { - topSpacing: 0, - onToggle: () => null, -}; - -export {propTypes, defaultProps}; diff --git a/src/components/KeyboardSpacer/index.android.js b/src/components/KeyboardSpacer/index.android.js deleted file mode 100644 index d7c57f7d73c2..000000000000 --- a/src/components/KeyboardSpacer/index.android.js +++ /dev/null @@ -1,23 +0,0 @@ -/** - * On Android the keyboard covers the input fields on the bottom of the view. This component moves the - * view up with the keyboard allowing the user to see what they are typing. - */ -import React from 'react'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import StatusBar from '@libs/StatusBar'; -import BaseKeyboardSpacer from './BaseKeyboardSpacer'; - -function KeyboardSpacer() { - return ( - - ); -} - -KeyboardSpacer.propTypes = windowDimensionsPropTypes; -KeyboardSpacer.displayName = 'KeyboardSpacer'; - -export default withWindowDimensions(KeyboardSpacer); diff --git a/src/components/KeyboardSpacer/index.ios.js b/src/components/KeyboardSpacer/index.ios.js deleted file mode 100644 index 612ef75c290f..000000000000 --- a/src/components/KeyboardSpacer/index.ios.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * On iOS the keyboard covers the input fields on the bottom of the view. This component moves the view up with the - * keyboard allowing the user to see what they are typing. - */ -import React from 'react'; -import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions'; -import * as StyleUtils from '@styles/StyleUtils'; -import CONST from '@src/CONST'; -import BaseKeyboardSpacer from './BaseKeyboardSpacer'; - -function KeyboardSpacer(props) { - return ( - - ); -} - -KeyboardSpacer.propTypes = windowDimensionsPropTypes; -KeyboardSpacer.displayName = 'KeyboardSpacer'; - -export default withWindowDimensions(KeyboardSpacer); diff --git a/src/components/KeyboardSpacer/index.js b/src/components/KeyboardSpacer/index.js deleted file mode 100644 index 77e1cc978337..000000000000 --- a/src/components/KeyboardSpacer/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * On non native platforms we do not need to implement a keyboard spacer, so we return a null component. - * - * @returns {null} - * @constructor - */ -function KeyboardSpacer() { - return null; -} - -export default KeyboardSpacer; diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js index 5e77947187e9..0d300c5e2179 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.js +++ b/src/components/LHNOptionsList/LHNOptionsList.js @@ -1,7 +1,8 @@ +import {FlashList} from '@shopify/flash-list'; import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; import React, {useCallback} from 'react'; -import {FlatList, View} from 'react-native'; +import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import participantPropTypes from '@components/participantPropTypes'; @@ -11,6 +12,7 @@ import compose from '@libs/compose'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import reportActionPropTypes from '@pages/home/report/reportActionPropTypes'; import reportPropTypes from '@pages/reportPropTypes'; +import stylePropTypes from '@styles/stylePropTypes'; import useThemeStyles from '@styles/useThemeStyles'; import variables from '@styles/variables'; import CONST from '@src/CONST'; @@ -19,12 +21,10 @@ import OptionRowLHNData from './OptionRowLHNData'; const propTypes = { /** Wrapper style for the section list */ - // eslint-disable-next-line react/forbid-prop-types - style: PropTypes.arrayOf(PropTypes.object), + style: stylePropTypes, /** Extra styles for the section list container */ - // eslint-disable-next-line react/forbid-prop-types - contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired, + contentContainerStyles: stylePropTypes.isRequired, /** Sections for the section list */ data: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -80,7 +80,7 @@ const defaultProps = { ...withCurrentReportIDDefaultProps, }; -const keyExtractor = (item) => item; +const keyExtractor = (item) => `report_${item}`; function LHNOptionsList({ style, @@ -99,28 +99,6 @@ function LHNOptionsList({ currentReportID, }) { const styles = useThemeStyles(); - /** - * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization - * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large - * lists. - * - * @param {Array} itemData - This is the same as the data we pass into the component - * @param {Number} index the current item's index in the set of data - * - * @returns {Object} - */ - const getItemLayout = useCallback( - (itemData, index) => { - const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight; - return { - length: optionHeight, - offset: index * optionHeight, - index, - }; - }, - [optionMode], - ); - /** * Function which renders a row in the list * @@ -164,20 +142,17 @@ function LHNOptionsList({ return ( - ); diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 103d063f9024..9883672976e8 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -78,6 +78,7 @@ const defaultProps = { shouldGreyOutWhenDisabled: true, error: '', shouldRenderAsHTML: false, + rightLabel: '', rightComponent: undefined, shouldShowRightComponent: false, titleWithTooltips: [], @@ -364,6 +365,11 @@ const MenuItem = React.forwardRef((props, ref) => { /> )} + {Boolean(props.rightLabel) && ( + + {props.rightLabel} + + )} {Boolean(props.shouldShowRightIcon) && ( { isVisibleRef.current = isVisible; + let removeOnCloseListener: () => void; if (isVisible) { Modal.willAlertModalBecomeVisible(true); // To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu - Modal.setCloseModal(onClose); + removeOnCloseListener = Modal.setCloseModal(onClose); } else if (wasVisible && !isVisible) { Modal.willAlertModalBecomeVisible(false); - Modal.setCloseModal(null); } + + return () => { + if (!removeOnCloseListener) { + return; + } + removeOnCloseListener(); + }; }, [isVisible, wasVisible, onClose]); useEffect( @@ -90,8 +97,6 @@ function BaseModal( } hideModal(true); Modal.willAlertModalBecomeVisible(false); - // To prevent closing any modal already unmounted when this modal still remains as visible state - Modal.setCloseModal(null); }, // eslint-disable-next-line react-hooks/exhaustive-deps [], diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js index df41abea30a3..bf8bc7719316 100644 --- a/src/components/MoneyReportHeader.js +++ b/src/components/MoneyReportHeader.js @@ -119,7 +119,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - shouldShowPaymentOptions style={[styles.pv2]} formattedAmount={formattedAmount} /> @@ -164,7 +163,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - shouldShowPaymentOptions formattedAmount={formattedAmount} /> diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js index 605d6909ebbc..efa9c5a49cec 100755 --- a/src/components/MoneyRequestConfirmationList.js +++ b/src/components/MoneyRequestConfirmationList.js @@ -8,6 +8,7 @@ import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import compose from '@libs/compose'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -210,6 +211,7 @@ function MoneyRequestConfirmationList(props) { const {onSendMoney, onConfirm, onSelectParticipant} = props; const {translate, toLocaleDigit} = useLocalize(); const transaction = props.isEditingSplitBill ? props.draftTransaction || props.transaction : props.transaction; + const {canUseViolations} = usePermissions(); const isTypeRequest = props.iouType === CONST.IOU.TYPE.REQUEST; const isSplitBill = props.iouType === CONST.IOU.TYPE.SPLIT; @@ -223,7 +225,6 @@ function MoneyRequestConfirmationList(props) { // A flag for showing the categories field const shouldShowCategories = props.isPolicyExpenseChat && (props.iouCategory || OptionsListUtils.hasEnabledOptions(_.values(props.policyCategories))); - // A flag and a toggler for showing the rest of the form fields const [shouldExpandFields, toggleShouldExpandFields] = useReducer((state) => !state, false); @@ -508,7 +509,6 @@ function MoneyRequestConfirmationList(props) { addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD} currency={props.iouCurrencyCode} policyID={props.policyID} - shouldShowPaymentOptions buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE} kycWallAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, @@ -715,6 +715,7 @@ function MoneyRequestConfirmationList(props) { titleStyle={styles.flex1} disabled={didConfirm} interactive={!props.isReadOnly} + rightLabel={canUseViolations && Boolean(props.policy.requiresCategory) ? translate('common.required') : ''} /> )} {shouldShowTags && ( @@ -727,6 +728,7 @@ function MoneyRequestConfirmationList(props) { style={[styles.moneyRequestMenuItem]} disabled={didConfirm} interactive={!props.isReadOnly} + rightLabel={canUseViolations && Boolean(props.policy.requiresTag) ? translate('common.required') : ''} /> )} diff --git a/src/components/MultipleAvatars.tsx b/src/components/MultipleAvatars.tsx index e867de7ddb97..febe18f30c7d 100644 --- a/src/components/MultipleAvatars.tsx +++ b/src/components/MultipleAvatars.tsx @@ -119,8 +119,8 @@ function MultipleAvatars({ const rowSize = Math.min(Math.ceil(icons.length / 2), maxAvatarsInRow); // Slice the icons array into two rows - const firstRow = icons.slice(rowSize); - const secondRow = icons.slice(0, rowSize); + const firstRow = icons.slice(0, rowSize); + const secondRow = icons.slice(rowSize); // Update the state with the two rows as an array return [firstRow, secondRow]; diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js index e1c554dc1d37..8afda6c375bb 100644 --- a/src/components/OptionRow.js +++ b/src/components/OptionRow.js @@ -190,7 +190,6 @@ function OptionRow(props) { props.optionIsFocused ? styles.sidebarLinkActive : null, props.shouldHaveOptionSeparator && styles.borderTop, !props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null, - props.isSelected && props.highlightSelected && styles.optionRowSelected, ]} accessibilityLabel={props.option.text} role={CONST.ACCESSIBILITY_ROLE.BUTTON} diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js index 8c480c27f20f..1702f66605f7 100755 --- a/src/components/OptionsSelector/BaseOptionsSelector.js +++ b/src/components/OptionsSelector/BaseOptionsSelector.js @@ -7,16 +7,23 @@ import ArrowKeyFocusManager from '@components/ArrowKeyFocusManager'; import Button from '@components/Button'; import FixedFooter from '@components/FixedFooter'; import FormHelpMessage from '@components/FormHelpMessage'; +import Icon from '@components/Icon'; +import {Info} from '@components/Icon/Expensicons'; import OptionsList from '@components/OptionsList'; +import {PressableWithoutFeedback} from '@components/Pressable'; +import Text from '@components/Text'; import TextInput from '@components/TextInput'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; import withNavigationFocus from '@components/withNavigationFocus'; import compose from '@libs/compose'; import getPlatform from '@libs/getPlatform'; import KeyboardShortcut from '@libs/KeyboardShortcut'; +import Navigation from '@libs/Navigation/Navigation'; import setSelection from '@libs/setSelection'; +import colors from '@styles/colors'; import styles from '@styles/styles'; import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; import {defaultProps as optionsSelectorDefaultProps, propTypes as optionsSelectorPropTypes} from './optionsSelectorPropTypes'; const propTypes = { @@ -35,12 +42,20 @@ const propTypes = { /** Whether navigation is focused */ isFocused: PropTypes.bool.isRequired, + /** Whether referral CTA should be displayed */ + shouldShowReferralCTA: PropTypes.bool, + + /** Referral content type */ + referralContentType: PropTypes.string, + ...optionsSelectorPropTypes, ...withLocalizePropTypes, }; const defaultProps = { shouldDelayFocus: false, + shouldShowReferralCTA: false, + referralContentType: CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND, safeAreaPaddingBottomStyle: {}, contentContainerStyles: [], listContainerStyles: [styles.flex1], @@ -55,6 +70,7 @@ class BaseOptionsSelector extends Component { this.updateFocusedIndex = this.updateFocusedIndex.bind(this); this.scrollToIndex = this.scrollToIndex.bind(this); this.selectRow = this.selectRow.bind(this); + this.handleReferralModal = this.handleReferralModal.bind(this); this.selectFocusedOption = this.selectFocusedOption.bind(this); this.addToSelection = this.addToSelection.bind(this); this.updateSearchValue = this.updateSearchValue.bind(this); @@ -67,6 +83,7 @@ class BaseOptionsSelector extends Component { allOptions, focusedIndex, shouldDisableRowSelection: false, + shouldShowReferralModal: false, errorMessage: '', }; } @@ -120,7 +137,7 @@ class BaseOptionsSelector extends Component { this.setState( { allOptions: newOptions, - focusedIndex: _.isNumber(this.props.initialFocusedIndex) ? this.props.initialFocusedIndex : newFocusedIndex, + focusedIndex: _.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex, }, () => { // If we just toggled an option on a multi-selection page or cleared the search input, scroll to top @@ -151,14 +168,14 @@ class BaseOptionsSelector extends Component { * @returns {Number} */ getInitiallyFocusedIndex(allOptions) { - if (_.isNumber(this.props.initialFocusedIndex)) { - return this.props.initialFocusedIndex; + let defaultIndex; + if (this.props.shouldTextInputAppearBelowOptions) { + defaultIndex = allOptions.length; + } else if (this.props.focusedIndex >= 0) { + defaultIndex = this.props.focusedIndex; + } else { + defaultIndex = this.props.selectedOptions.length; } - - if (this.props.selectedOptions.length > 0) { - return this.props.selectedOptions.length; - } - const defaultIndex = this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0; if (_.isUndefined(this.props.initiallyFocusedOptionKey)) { return defaultIndex; } @@ -180,6 +197,10 @@ class BaseOptionsSelector extends Component { this.props.onChangeText(value); } + handleReferralModal() { + this.setState((prevState) => ({shouldShowReferralModal: !prevState.shouldShowReferralModal})); + } + subscribeToKeyboardShortcut() { const enterConfig = CONST.KEYBOARD_SHORTCUTS.ENTER; this.unsubscribeEnter = KeyboardShortcut.subscribe( @@ -495,6 +516,34 @@ class BaseOptionsSelector extends Component { )} + {this.props.shouldShowReferralCTA && ( + + { + Navigation.navigate(ROUTES.REFERRAL_DETAILS_MODAL.getRoute(this.props.referralContentType)); + }} + style={[styles.p5, styles.w100, styles.br2, styles.highlightBG, styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter, {gap: 10}]} + accessibilityLabel="referral" + role={CONST.ACCESSIBILITY_ROLE.BUTTON} + > + + {this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText1`)} + + {this.props.translate(`referralProgram.${this.props.referralContentType}.buttonText2`)} + + + + + + )} + {shouldShowFooter && ( {shouldShowDefaultConfirmButton && ( diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js index 94aab8fac5f6..ba4f5beb55cd 100644 --- a/src/components/OptionsSelector/optionsSelectorPropTypes.js +++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js @@ -127,8 +127,8 @@ const propTypes = { /** Whether to wrap large text up to 2 lines */ isRowMultilineSupported: PropTypes.bool, - /** Initial focused index value */ - initialFocusedIndex: PropTypes.number, + /** Index for option to focus on */ + focusedIndex: PropTypes.number, /** Whether the text input should intercept swipes or not */ shouldTextInputInterceptSwipe: PropTypes.bool, @@ -174,7 +174,7 @@ const defaultProps = { onChangeText: () => {}, shouldUseStyleForChildren: true, isRowMultilineSupported: false, - initialFocusedIndex: undefined, + focusedIndex: undefined, shouldTextInputInterceptSwipe: false, shouldAllowScrollingChildren: false, nestedScrollEnabled: true, diff --git a/src/components/ParentNavigationSubtitle.js b/src/components/ParentNavigationSubtitle.tsx similarity index 65% rename from src/components/ParentNavigationSubtitle.js rename to src/components/ParentNavigationSubtitle.tsx index 0ce6582fe86d..e65a8617a996 100644 --- a/src/components/ParentNavigationSubtitle.js +++ b/src/components/ParentNavigationSubtitle.tsx @@ -1,49 +1,38 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import {StyleProp, ViewStyle} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import Navigation from '@libs/Navigation/Navigation'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; +import {ParentNavigationSummaryParams} from '@src/languages/types'; import ROUTES from '@src/ROUTES'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; import Text from './Text'; -const propTypes = { - parentNavigationSubtitleData: PropTypes.shape({ - // Title of root report room - rootReportName: PropTypes.string, - - // Name of workspace, if any - workspaceName: PropTypes.string, - }).isRequired, +type ParentNavigationSubtitleProps = { + parentNavigationSubtitleData: ParentNavigationSummaryParams; /** parent Report ID */ - parentReportID: PropTypes.string, + parentReportID?: string; /** PressableWithoutFeedack additional styles */ - // eslint-disable-next-line react/forbid-prop-types - pressableStyles: PropTypes.arrayOf(PropTypes.object), -}; - -const defaultProps = { - parentReportID: '', - pressableStyles: [], + pressableStyles?: StyleProp; }; -function ParentNavigationSubtitle(props) { +function ParentNavigationSubtitle({parentNavigationSubtitleData, parentReportID = '', pressableStyles}: ParentNavigationSubtitleProps) { const styles = useThemeStyles(); - const {workspaceName, rootReportName} = props.parentNavigationSubtitleData; + const {workspaceName, rootReportName} = parentNavigationSubtitleData; const {translate} = useLocalize(); return ( { - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(props.parentReportID)); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(parentReportID)); }} accessibilityLabel={translate('threads.parentNavigationSummary', {rootReportName, workspaceName})} role={CONST.ACCESSIBILITY_ROLE.LINK} - style={[...props.pressableStyles]} + style={pressableStyles} > { + let removeOnClose; if (props.isVisible) { props.onModalShow(); onOpen({ ref: props.withoutOverlayRef, close: props.onClose, anchorRef: props.anchorRef, - onCloseCallback: () => Modal.setCloseModal(null), - onOpenCallback: () => Modal.setCloseModal(() => props.onClose(props.anchorRef)), }); + removeOnClose = Modal.setCloseModal(() => props.onClose(props.anchorRef)); } else { props.onModalHide(); close(props.anchorRef); @@ -41,6 +41,12 @@ function Popover(props) { } Modal.willAlertModalBecomeVisible(props.isVisible); + return () => { + if (!removeOnClose) { + return; + } + removeOnClose(); + }; // We want this effect to run strictly ONLY when isVisible prop changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.isVisible]); diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js index 8e2fb5141091..935b8ece5933 100644 --- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js +++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js @@ -51,6 +51,9 @@ const propTypes = { /** Whether the view needs to be rendered offscreen (for Android only) */ needsOffscreenAlphaCompositing: PropTypes.bool, + + /** Whether the text has a gray highlights on press down (for IOS only) */ + suppressHighlighting: PropTypes.bool, }; const defaultProps = { @@ -63,6 +66,7 @@ const defaultProps = { activeOpacity: 1, enableLongPressWithHover: false, needsOffscreenAlphaCompositing: false, + suppressHighlighting: false, }; export {propTypes, defaultProps}; diff --git a/src/components/RadioButtonWithLabel.js b/src/components/RadioButtonWithLabel.tsx similarity index 51% rename from src/components/RadioButtonWithLabel.js rename to src/components/RadioButtonWithLabel.tsx index 178d5ebdd953..7d8df23bae49 100644 --- a/src/components/RadioButtonWithLabel.js +++ b/src/components/RadioButtonWithLabel.tsx @@ -1,85 +1,71 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; +import React, {ComponentType} from 'react'; +import {StyleProp, View, ViewStyle} from 'react-native'; import useThemeStyles from '@styles/useThemeStyles'; import FormHelpMessage from './FormHelpMessage'; import * as Pressables from './Pressable'; import RadioButton from './RadioButton'; import Text from './Text'; -const propTypes = { +type RadioButtonWithLabelProps = { /** Whether the radioButton is checked */ - isChecked: PropTypes.bool.isRequired, + isChecked: boolean; /** Called when the radioButton or label is pressed */ - onPress: PropTypes.func.isRequired, + onPress: () => void; /** Container styles */ - style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]), + style?: StyleProp; /** Text that appears next to check box */ - label: PropTypes.string, + label?: string; /** Component to display for label */ - LabelComponent: PropTypes.func, + LabelComponent?: ComponentType; /** Should the input be styled for errors */ - hasError: PropTypes.bool, + hasError?: boolean; /** Error text to display */ - errorText: PropTypes.string, -}; - -const defaultProps = { - style: [], - label: undefined, - LabelComponent: undefined, - hasError: false, - errorText: '', + errorText?: string; }; const PressableWithFeedback = Pressables.PressableWithFeedback; -function RadioButtonWithLabel(props) { +function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = false, errorText = '', isChecked, onPress}: RadioButtonWithLabelProps) { const styles = useThemeStyles(); - const LabelComponent = props.LabelComponent; const defaultStyles = [styles.flexRow, styles.alignItemsCenter]; - const wrapperStyles = _.isArray(props.style) ? [...defaultStyles, ...props.style] : [...defaultStyles, props.style]; - if (!props.label && !LabelComponent) { + if (!label && !LabelComponent) { throw new Error('Must provide at least label or LabelComponent prop'); } return ( <> - + props.onPress()} + onPress={onPress} style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]} wrapperStyle={[styles.ml3, styles.pr2, styles.w100]} // disable hover style when disabled hoverDimmingValue={0.8} pressDimmingValue={0.5} > - {Boolean(props.label) && {props.label}} - {Boolean(LabelComponent) && } + {Boolean(label) && {label}} + {!!LabelComponent && } - + ); } -RadioButtonWithLabel.propTypes = propTypes; -RadioButtonWithLabel.defaultProps = defaultProps; RadioButtonWithLabel.displayName = 'RadioButtonWithLabel'; export default RadioButtonWithLabel; diff --git a/src/components/ReportActionItem/MoneyRequestPreview.js b/src/components/ReportActionItem/MoneyRequestPreview.js index 1764b6a1171e..07aba132be0e 100644 --- a/src/components/ReportActionItem/MoneyRequestPreview.js +++ b/src/components/ReportActionItem/MoneyRequestPreview.js @@ -172,7 +172,7 @@ function MoneyRequestPreview(props) { // Show the merchant for IOUs and expenses only if they are custom or not related to scanning smartscan const shouldShowMerchant = !_.isEmpty(requestMerchant) && !props.isBillSplit && requestMerchant !== CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT && requestMerchant !== CONST.TRANSACTION.DEFAULT_MERCHANT; - const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant; + const shouldShowDescription = !_.isEmpty(description) && !shouldShowMerchant && !isScanning; const receiptImages = hasReceipt ? [ReceiptUtils.getThumbnailAndImageURIs(props.transaction)] : []; diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 1da061fc741e..33ad99f32326 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -281,14 +281,13 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor /> )} - {shouldShowBillable && ( {translate('common.billable')} IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})} + onToggle={(value) => IOU.editMoneyRequest(transaction, report.reportID, {billable: value})} /> )} diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js index 9f291e1318f5..f04029182d45 100644 --- a/src/components/ReportActionItem/ReportPreview.js +++ b/src/components/ReportActionItem/ReportPreview.js @@ -254,7 +254,6 @@ function ReportPreview(props) { onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)} enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS} addBankAccountRoute={bankAccountRoute} - shouldShowPaymentOptions style={[styles.mt3]} kycWallAnchorAlignment={{ horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT, diff --git a/src/components/ReportHeaderSkeletonView.js b/src/components/ReportHeaderSkeletonView.tsx similarity index 68% rename from src/components/ReportHeaderSkeletonView.js rename to src/components/ReportHeaderSkeletonView.tsx index e0ef3f4257e3..acc9261889bc 100644 --- a/src/components/ReportHeaderSkeletonView.js +++ b/src/components/ReportHeaderSkeletonView.tsx @@ -1,8 +1,8 @@ -import PropTypes from 'prop-types'; import React from 'react'; import {View} from 'react-native'; import {Circle, Rect} from 'react-native-svg'; -import compose from '@libs/compose'; +import useLocalize from '@hooks/useLocalize'; +import useWindowDimensions from '@hooks/useWindowDimensions'; import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import variables from '@styles/variables'; @@ -11,37 +11,32 @@ import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; import PressableWithFeedback from './Pressable/PressableWithFeedback'; import SkeletonViewContentLoader from './SkeletonViewContentLoader'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions'; -const propTypes = { - ...windowDimensionsPropTypes, - ...withLocalizePropTypes, - shouldAnimate: PropTypes.bool, +type ReportHeaderSkeletonViewProps = { + shouldAnimate?: boolean; }; -const defaultProps = { - shouldAnimate: true, -}; - -function ReportHeaderSkeletonView(props) { +function ReportHeaderSkeletonView({shouldAnimate = true}: ReportHeaderSkeletonViewProps) { const theme = useTheme(); const styles = useThemeStyles(); + const {translate} = useLocalize(); + const {isSmallScreenWidth} = useWindowDimensions(); + return ( - - {props.isSmallScreenWidth && ( + + {isSmallScreenWidth && ( {}} style={[styles.LHNToggle]} role={CONST.ACCESSIBILITY_ROLE.BUTTON} - accessibilityLabel={props.translate('common.back')} + accessibilityLabel={translate('common.back')} > )} setSelection(event.nativeEvent.selection)} errorText={errorText} autoCapitalize="none" - onBlur={() => isFocused && onBlur()} + onBlur={(event) => isFocused && onBlur(event)} shouldDelayFocus={shouldDelayFocus} autoFocus={isFocused && autoFocus} maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH} diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js index 828affe33d07..a2c09996ad34 100644 --- a/src/components/RoomNameInput/index.native.js +++ b/src/components/RoomNameInput/index.native.js @@ -41,7 +41,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef, errorText={errorText} maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH} keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449 - onBlur={() => isFocused && onBlur()} + onBlur={(event) => isFocused && onBlur(event)} autoFocus={isFocused && autoFocus} autoCapitalize="none" shouldDelayFocus={shouldDelayFocus} diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js index d2030eac8d7d..27ba3d08a16f 100644 --- a/src/components/SettlementButton.js +++ b/src/components/SettlementButton.js @@ -40,9 +40,6 @@ const propTypes = { /** The route to redirect if user does not have a payment method setup */ enablePaymentsRoute: PropTypes.string.isRequired, - /** Should we show the payment options? */ - shouldShowPaymentOptions: PropTypes.bool, - /** The last payment method used per policy */ nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string), @@ -97,7 +94,6 @@ const defaultProps = { betas: CONST.EMPTY_ARRAY, iouReport: CONST.EMPTY_OBJECT, nvp_lastPaymentMethod: CONST.EMPTY_OBJECT, - shouldShowPaymentOptions: false, style: [], policyID: '', formattedAmount: '', @@ -130,7 +126,6 @@ function SettlementButton({ onPress, pressOnEnter, policyID, - shouldShowPaymentOptions, style, }) { const {translate} = useLocalize(); @@ -164,34 +159,11 @@ function SettlementButton({ // To achieve the one tap pay experience we need to choose the correct payment type as default, // if user already paid for some request or expense, let's use the last payment method or use default. - let paymentMethod = nvp_lastPaymentMethod[policyID] || ''; - if (!shouldShowPaymentOptions) { - if (!paymentMethod) { - // In case the user hasn't paid a request yet, let's default to VBBA payment type in case of expense reports - if (isExpenseReport) { - paymentMethod = CONST.IOU.PAYMENT_TYPE.VBBA; - } else if (canUseWallet) { - // If they have Wallet set up, use that payment method as default - paymentMethod = CONST.IOU.PAYMENT_TYPE.EXPENSIFY; - } else { - paymentMethod = CONST.IOU.PAYMENT_TYPE.ELSEWHERE; - } - } - - // In case of the settlement button in the report preview component, we do not show payment options and the label for Wallet and ACH type is simply "Pay". - return [ - { - ...paymentMethods[paymentMethod], - text: paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE ? translate('iou.payElsewhere') : translate('iou.pay'), - }, - ]; - } + const paymentMethod = nvp_lastPaymentMethod[policyID] || ''; if (canUseWallet) { buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]); } - if (isExpenseReport) { - buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]); - } + buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.VBBA]); buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.ELSEWHERE]); // Put the preferred payment method to the front of the array so its shown as default @@ -199,7 +171,7 @@ function SettlementButton({ return _.sortBy(buttonOptions, (method) => (method.value === paymentMethod ? 0 : 1)); } return buttonOptions; - }, [betas, currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, shouldShowPaymentOptions, translate]); + }, [betas, currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, translate]); const selectPaymentType = (event, iouPaymentType, triggerKYCFlow) => { if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) { diff --git a/src/components/SignInPageForm/index.tsx b/src/components/SignInPageForm/index.tsx index 1cdc31b33fd9..20b93e6db1b5 100644 --- a/src/components/SignInPageForm/index.tsx +++ b/src/components/SignInPageForm/index.tsx @@ -1,4 +1,5 @@ import React, {useEffect, useRef} from 'react'; +import {View} from 'react-native'; import FormElement from '@components/FormElement'; import SignInPageFormProps from './types'; @@ -9,7 +10,7 @@ const preventFormDefault = (event: SubmitEvent) => { }; function SignInPageForm(props: SignInPageFormProps) { - const form = useRef(null); + const form = useRef(null); useEffect(() => { const formCurrent = form.current; diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js index 37e52d3aca0c..f9071aa5267d 100644 --- a/src/components/TagPicker/index.js +++ b/src/components/TagPicker/index.js @@ -11,7 +11,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import {defaultProps, propTypes} from './tagPickerPropTypes'; -function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit}) { +function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubmit, shouldShowDisabledAndSelectedOption}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [searchValue, setSearchValue] = useState(''); @@ -37,25 +37,24 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm ]; }, [selectedTag]); - const initialFocusedIndex = useMemo(() => { - if (isTagsCountBelowThreshold && selectedOptions.length > 0) { - return _.chain(policyTagList) - .values() - .findIndex((policyTag) => policyTag.name === selectedOptions[0].name, true) - .value(); + const enabledTags = useMemo(() => { + if (!shouldShowDisabledAndSelectedOption) { + return policyTagList; } - - return 0; - }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]); + const selectedNames = _.map(selectedOptions, (s) => s.name); + const tags = [...selectedOptions, ..._.filter(policyTagList, (policyTag) => policyTag.enabled && !selectedNames.includes(policyTag.name))]; + return tags; + }, [selectedOptions, policyTagList, shouldShowDisabledAndSelectedOption]); const sections = useMemo( - () => - OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], true, policyTagList, policyRecentlyUsedTagsList, false).tagOptions, - [searchValue, selectedOptions, policyTagList, policyRecentlyUsedTagsList], + () => OptionsListUtils.getFilteredOptions({}, {}, [], searchValue, selectedOptions, [], false, false, false, {}, [], true, enabledTags, policyRecentlyUsedTagsList, false).tagOptions, + [searchValue, enabledTags, selectedOptions, policyRecentlyUsedTagsList], ); const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue); + const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (policyTag) => policyTag.searchText === selectedTag)[0], 'keyForList'); + return ( diff --git a/src/components/TagPicker/tagPickerPropTypes.js b/src/components/TagPicker/tagPickerPropTypes.js index 3010ab24a9c1..1221c939b940 100644 --- a/src/components/TagPicker/tagPickerPropTypes.js +++ b/src/components/TagPicker/tagPickerPropTypes.js @@ -20,11 +20,15 @@ const propTypes = { /** List of recently used tags */ policyRecentlyUsedTags: PropTypes.objectOf(PropTypes.arrayOf(PropTypes.string)), + + /** Should show the selected option that is disabled? */ + shouldShowDisabledAndSelectedOption: PropTypes.bool, }; const defaultProps = { policyTags: {}, policyRecentlyUsedTags: {}, + shouldShowDisabledAndSelectedOption: false, }; export {propTypes, defaultProps}; diff --git a/src/components/TaskHeaderActionButton.js b/src/components/TaskHeaderActionButton.js index 3e2a835e0722..09ca427b8e56 100644 --- a/src/components/TaskHeaderActionButton.js +++ b/src/components/TaskHeaderActionButton.js @@ -6,6 +6,7 @@ import compose from '@libs/compose'; import * as ReportUtils from '@libs/ReportUtils'; import reportPropTypes from '@pages/reportPropTypes'; import useThemeStyles from '@styles/useThemeStyles'; +import * as Session from '@userActions/Session'; import * as Task from '@userActions/Task'; import ONYXKEYS from '@src/ONYXKEYS'; import Button from './Button'; @@ -38,7 +39,7 @@ function TaskHeaderActionButton(props) { isDisabled={!Task.canModifyTask(props.report, props.session.accountID)} medium text={props.translate(ReportUtils.isCompletedTaskReport(props.report) ? 'task.markAsIncomplete' : 'task.markAsComplete')} - onPress={() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report))} + onPress={Session.checkIfActionIsAllowed(() => (ReportUtils.isCompletedTaskReport(props.report) ? Task.reopenTask(props.report) : Task.completeTask(props.report)))} style={[styles.flex1]} /> diff --git a/src/components/TextInput/BaseTextInput/index.js b/src/components/TextInput/BaseTextInput/index.js index bfd3a19659bb..a28365480c7a 100644 --- a/src/components/TextInput/BaseTextInput/index.js +++ b/src/components/TextInput/BaseTextInput/index.js @@ -250,7 +250,7 @@ function BaseTextInput(props) { return ( <> @@ -261,7 +261,6 @@ function BaseTextInput(props) { style={[ props.autoGrowHeight && styles.autoGrowHeightInputContainer(textInputHeight, variables.componentSizeLarge, maxHeight), !isMultiline && styles.componentHeightLarge, - ...props.containerStyles, ]} > ; /** Whether the image requires an authToken */ - isAuthTokenRequired: PropTypes.bool.isRequired, + isAuthTokenRequired: boolean; /** Width of the thumbnail image */ - imageWidth: PropTypes.number, + imageWidth?: number; /** Height of the thumbnail image */ - imageHeight: PropTypes.number, + imageHeight?: number; /** Should the image be resized on load or just fit container */ - shouldDynamicallyResize: PropTypes.bool, + shouldDynamicallyResize?: boolean; }; -const defaultProps = { - style: {}, - imageWidth: 200, - imageHeight: 200, - shouldDynamicallyResize: true, +type UpdateImageSizeParams = { + width: number; + height: number; +}; + +type CalculateThumbnailImageSizeResult = { + thumbnailWidth?: number; + thumbnailHeight?: number; }; /** * Compute the thumbnails width and height given original image dimensions. * - * @param {Number} width - Width of the original image. - * @param {Number} height - Height of the original image. - * @param {Number} windowHeight - Height of the device/browser window. - * @returns {Object} - Object containing thumbnails width and height. + * @param width - Width of the original image. + * @param height - Height of the original image. + * @param windowHeight - Height of the device/browser window. + * @returns - Object containing thumbnails width and height. */ -function calculateThumbnailImageSize(width, height, windowHeight) { +function calculateThumbnailImageSize(width: number, height: number, windowHeight: number): CalculateThumbnailImageSizeResult { if (!width || !height) { return {}; } @@ -69,44 +70,42 @@ function calculateThumbnailImageSize(width, height, windowHeight) { return {thumbnailWidth: Math.max(40, thumbnailScreenWidth), thumbnailHeight: Math.max(40, thumbnailScreenHeight)}; } -function ThumbnailImage(props) { +function ThumbnailImage({previewSourceURL, style, isAuthTokenRequired, imageWidth = 200, imageHeight = 200, shouldDynamicallyResize = true}: ThumbnailImageProps) { const styles = useThemeStyles(); const {windowHeight} = useWindowDimensions(); - const initialDimensions = calculateThumbnailImageSize(props.imageWidth, props.imageHeight, windowHeight); - const [imageWidth, setImageWidth] = useState(initialDimensions.thumbnailWidth); - const [imageHeight, setImageHeight] = useState(initialDimensions.thumbnailHeight); + const initialDimensions = calculateThumbnailImageSize(imageWidth, imageHeight, windowHeight); + const [currentImageWidth, setCurrentImageWidth] = useState(initialDimensions.thumbnailWidth); + const [currentImageHeight, setCurrentImageHeight] = useState(initialDimensions.thumbnailHeight); /** * Update the state with the computed thumbnail sizes. - * - * @param {{ width: number, height: number }} Params - width and height of the original image. + * @param Params - width and height of the original image. */ const updateImageSize = useCallback( - ({width, height}) => { + ({width, height}: UpdateImageSizeParams) => { const {thumbnailWidth, thumbnailHeight} = calculateThumbnailImageSize(width, height, windowHeight); - setImageWidth(thumbnailWidth); - setImageHeight(thumbnailHeight); + + setCurrentImageWidth(thumbnailWidth); + setCurrentImageHeight(thumbnailHeight); }, [windowHeight], ); - const sizeStyles = props.shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(imageWidth, imageHeight)] : [styles.w100, styles.h100]; + const sizeStyles = shouldDynamicallyResize ? [StyleUtils.getWidthAndHeightStyle(currentImageWidth ?? 0, currentImageHeight)] : [styles.w100, styles.h100]; return ( - + ); } -ThumbnailImage.propTypes = propTypes; -ThumbnailImage.defaultProps = defaultProps; ThumbnailImage.displayName = 'ThumbnailImage'; export default React.memo(ThumbnailImage); diff --git a/src/components/UnreadActionIndicator.js b/src/components/UnreadActionIndicator.tsx similarity index 53% rename from src/components/UnreadActionIndicator.js rename to src/components/UnreadActionIndicator.tsx index 7555c93c2326..b34f962e57bd 100755 --- a/src/components/UnreadActionIndicator.js +++ b/src/components/UnreadActionIndicator.tsx @@ -1,26 +1,31 @@ import React from 'react'; import {View} from 'react-native'; +import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@styles/useThemeStyles'; import CONST from '@src/CONST'; import Text from './Text'; -import withLocalize, {withLocalizePropTypes} from './withLocalize'; -function UnreadActionIndicator(props) { +type UnreadActionIndicatorProps = { + reportActionID: string; +}; + +function UnreadActionIndicator({reportActionID}: UnreadActionIndicatorProps) { const styles = useThemeStyles(); + const {translate} = useLocalize(); + return ( - {props.translate('common.new')} + {translate('common.new')} ); } -UnreadActionIndicator.propTypes = {...withLocalizePropTypes}; - UnreadActionIndicator.displayName = 'UnreadActionIndicator'; -export default withLocalize(UnreadActionIndicator); + +export default UnreadActionIndicator; diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index d4b12b9cf479..4d2de3275e23 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -148,6 +148,9 @@ const propTypes = { /** Should render the content in HTML format */ shouldRenderAsHTML: PropTypes.bool, + /** Label to be displayed on the right */ + rightLabel: PropTypes.string, + /** Component to be displayed on the right */ rightComponent: PropTypes.node, diff --git a/src/languages/en.ts b/src/languages/en.ts index fe867efc27c0..96e2e99824cd 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -117,6 +117,7 @@ export default { twoFactorCode: 'Two-factor code', workspaces: 'Workspaces', profile: 'Profile', + referral: 'Referral', payments: 'Payments', wallet: 'Wallet', preferences: 'Preferences', @@ -214,8 +215,10 @@ export default { more: 'More', debitCard: 'Debit card', bankAccount: 'Bank account', + personalBankAccount: 'Personal bank account', + businessBankAccount: 'Business bank account', join: 'Join', - joinThread: 'Join thread', + leave: 'Leave', decline: 'Decline', transferBalance: 'Transfer balance', cantFindAddress: "Can't find your address? ", @@ -266,6 +269,7 @@ export default { tbd: 'TBD', selectCurrency: 'Select a currency', card: 'Card', + required: 'Required', }, location: { useCurrent: 'Use current location', @@ -589,6 +593,9 @@ export default { invalidSplit: 'Split amounts do not equal total amount', other: 'Unexpected error, please try again later', genericCreateFailureMessage: 'Unexpected error requesting money, please try again later', + receiptFailureMessage: "The receipt didn't upload. ", + saveFileMessage: 'Download the file ', + loseFileMessage: 'or dismiss this error and lose it', genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later', genericEditFailureMessage: 'Unexpected error editing the money request, please try again later', genericSmartscanFailureMessage: 'Transaction is missing fields', @@ -872,6 +879,7 @@ export default { availableSpend: 'Remaining limit', virtualCardNumber: 'Virtual card number', physicalCardNumber: 'Physical card number', + getPhysicalCard: 'Get physical card', reportFraud: 'Report virtual card fraud', reviewTransaction: 'Review transaction', suspiciousBannerTitle: 'Suspicious transaction', @@ -900,8 +908,31 @@ export default { activatePhysicalCard: 'Activate physical card', error: { thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.", + throttled: + "You've incorrectly entered the last 4 digits of your Expensify Card too many times. If you're sure the numbers are correct, please reach out to Concierge to resolve. Otherwise, try again later.", }, }, + getPhysicalCard: { + header: 'Get physical card', + nameMessage: 'Enter your first and last name, as this will be shown on your card.', + legalName: 'Legal name', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + phoneMessage: 'Enter your phone number.', + phoneNumber: 'Phone number', + address: 'Address', + addressMessage: 'Enter your shipping address.', + streetAddress: 'Street Address', + city: 'City', + state: 'State', + zipPostcode: 'Zip/Postcode', + country: 'Country', + confirmMessage: 'Please confirm your details below.', + estimatedDeliveryMessage: 'Your physical card will arrive in 2-3 business days.', + next: 'Next', + getPhysicalCard: 'Get physical card', + shipCard: 'Ship card', + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transfer${amount ? ` ${amount}` : ''}`, instant: 'Instant (Debit card)', @@ -1908,4 +1939,31 @@ export default { guaranteed: 'Guaranteed eReceipt', transactionDate: 'Transaction date', }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Start a chat, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Start a chat, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Start a chat with a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + buttonText1: 'Request money, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Request money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Request money from a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { + buttonText1: 'Send money, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Send money, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Send money to a new Expensify account. Get $${CONST.REFERRAL_PROGRAM.REVENUE} once they start an annual subscription with two or more active members and make the first two payments toward their Expensify bill.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Refer a friend, ', + buttonText2: `get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + header: `Refer a friend, get $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Send your Expensify referral link to a friend or anyone else you know who spends too much time on expenses. When they start an annual subscription, you'll get $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + }, + copyReferralLink: 'Copy referral link', + }, } satisfies TranslationBase; diff --git a/src/languages/es.ts b/src/languages/es.ts index d86b712104fd..3f8f68977549 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -107,6 +107,7 @@ export default { twoFactorCode: 'Autenticación de dos factores', workspaces: 'Espacios de trabajo', profile: 'Perfil', + referral: 'Remisión', payments: 'Pagos', wallet: 'Billetera', preferences: 'Preferencias', @@ -204,8 +205,10 @@ export default { more: 'Más', debitCard: 'Tarjeta de débito', bankAccount: 'Cuenta bancaria', + personalBankAccount: 'Cuenta bancaria personal', + businessBankAccount: 'Cuenta bancaria comercial', join: 'Unirse', - joinThread: 'Unirse al hilo', + leave: 'Salir', decline: 'Rechazar', transferBalance: 'Transferencia de saldo', cantFindAddress: '¿No encuentras tu dirección? ', @@ -256,6 +259,7 @@ export default { tbd: 'Por determinar', selectCurrency: 'Selecciona una moneda', card: 'Tarjeta', + required: 'Obligatorio', }, location: { useCurrent: 'Usar ubicación actual', @@ -583,6 +587,9 @@ export default { invalidSplit: 'La suma de las partes no equivale al monto total', other: 'Error inesperado, por favor inténtalo más tarde', genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde', + receiptFailureMessage: 'El recibo no se subió. ', + saveFileMessage: 'Guarda el archivo ', + loseFileMessage: 'o descarta este error y piérdelo', genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde', genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde', genericSmartscanFailureMessage: 'La transacción tiene campos vacíos', @@ -867,6 +874,7 @@ export default { availableSpend: 'Límite restante', virtualCardNumber: 'Número de la tarjeta virtual', physicalCardNumber: 'Número de la tarjeta física', + getPhysicalCard: 'Obtener tarjeta física', reportFraud: 'Reportar fraude con la tarjeta virtual', reviewTransaction: 'Revisar transacción', suspiciousBannerTitle: 'Transacción sospechosa', @@ -896,8 +904,32 @@ export default { activatePhysicalCard: 'Activar tarjeta física', error: { thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.', + throttled: + 'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.', }, }, + // TODO: add translation + getPhysicalCard: { + header: 'Obtener tarjeta física', + nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.', + legalName: 'Nombre completo', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + phoneMessage: 'Introduce tu número de teléfono.', + phoneNumber: 'Número de teléfono', + address: 'Dirección', + addressMessage: 'Introduce tu dirección de envío.', + streetAddress: 'Calle de dirección', + city: 'Ciudad', + state: 'Estado', + zipPostcode: 'Código postal', + country: 'País', + confirmMessage: 'Por favor confirma tus datos.', + estimatedDeliveryMessage: 'Tu tarjeta física llegará en 2-3 días laborales.', + next: 'Siguiente', + getPhysicalCard: 'Obtener tarjeta física', + shipCard: 'Enviar tarjeta', + }, transferAmountPage: { transfer: ({amount}: TransferParams) => `Transferir${amount ? ` ${amount}` : ''}`, instant: 'Instante', @@ -2392,4 +2424,31 @@ export default { guaranteed: 'eRecibo garantizado', transactionDate: 'Fecha de transacción', }, + referralProgram: { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: { + buttonText1: 'Inicia un chat y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Inicia un chat y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Inicia un chat con una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: { + buttonText1: 'Pide dinero, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Pide dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Pide dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: { + buttonText1: 'Envía dinero, ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Envía dinero y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Envía dinero a una cuenta nueva de Expensify. Obtiene $${CONST.REFERRAL_PROGRAM.REVENUE} una vez que configuren una suscripción anual con dos o más miembros activos y realicen los dos primeros pagos de su factura Expensify.`, + }, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: { + buttonText1: 'Recomienda a un amigo y ', + buttonText2: `recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + header: `Recomienda a un amigo y recibe $${CONST.REFERRAL_PROGRAM.REVENUE}`, + body: `Envía tu enlace de invitación de Expensify a un amigo o a cualquier otra persona que conozcas que dedique demasiado tiempo a los gastos. Cuando comiencen una suscripción anual, obtendrás $${CONST.REFERRAL_PROGRAM.REVENUE}.`, + }, + copyReferralLink: 'Copiar enlace de invitación', + }, } satisfies EnglishTranslation; diff --git a/src/libs/ComposerUtils/index.ts b/src/libs/ComposerUtils/index.ts index 5a7da7ca08cf..32ebca9afee8 100644 --- a/src/libs/ComposerUtils/index.ts +++ b/src/libs/ComposerUtils/index.ts @@ -23,19 +23,4 @@ function canSkipTriggerHotkeys(isSmallScreenWidth: boolean, isKeyboardShown: boo return (isSmallScreenWidth && DeviceCapabilities.canUseTouchScreen()) || isKeyboardShown; } -/** - * Returns the length of the common suffix between two input strings. - * The common suffix is the number of characters shared by both strings - * at the end (suffix) until a mismatch is encountered. - * - * @returns The length of the common suffix between the strings. - */ -function getCommonSuffixLength(str1: string, str2: string): number { - let i = 0; - while (str1[str1.length - 1 - i] === str2[str2.length - 1 - i]) { - i++; - } - return i; -} - -export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys, getCommonSuffixLength}; +export {getNumberOfLines, updateNumberOfLines, insertText, canSkipTriggerHotkeys}; diff --git a/src/libs/EmojiUtils.ts b/src/libs/EmojiUtils.ts index 1308faa65d20..22e84921b1ee 100644 --- a/src/libs/EmojiUtils.ts +++ b/src/libs/EmojiUtils.ts @@ -13,7 +13,7 @@ import emojisTrie from './EmojiTrie'; type HeaderIndice = {code: string; index: number; icon: React.FC}; type EmojiSpacer = {code: string; spacer: boolean}; type EmojiPickerList = Array; -type ReplacedEmoji = {text: string; emojis: Emoji[]}; +type ReplacedEmoji = {text: string; emojis: Emoji[]; cursorPosition?: number}; type UserReactions = { id: string; skinTones: Record; @@ -333,8 +333,11 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI if (!emojiData || emojiData.length === 0) { return {text: newText, emojis}; } - for (let i = 0; i < emojiData.length; i++) { - const name = emojiData[i].slice(1, -1); + + let cursorPosition; + + for (const emoji of emojiData) { + const name = emoji.slice(1, -1); let checkEmoji = trie.search(name); // If the user has selected a language other than English, and the emoji doesn't exist in that language, // we will check if the emoji exists in English. @@ -346,35 +349,46 @@ function replaceEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKI } } if (checkEmoji?.metaData?.code && checkEmoji?.metaData?.name) { - let emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone); + const emojiReplacement = getEmojiCodeWithSkinColor(checkEmoji.metaData as Emoji, preferredSkinTone); emojis.push({ name, code: checkEmoji.metaData?.code, types: checkEmoji.metaData.types, }); - // If this is the last emoji in the message and it's the end of the message so far, - // add a space after it so the user can keep typing easily. - if (i === emojiData.length - 1) { - emojiReplacement += ' '; - } + // Set the cursor to the end of the last replaced Emoji. Note that we position after + // the extra space, if we added one. + cursorPosition = newText.indexOf(emoji) + emojiReplacement.length; + + newText = newText.replace(emoji, emojiReplacement); + } + } + + // cursorPosition, when not undefined, points to the end of the last emoji that was replaced. + // In that case we want to append a space at the cursor position, but only if the next character + // is not already a space (to avoid double spaces). + if (cursorPosition && cursorPosition > 0) { + const space = ' '; - newText = newText.replace(emojiData[i], emojiReplacement); + if (newText.charAt(cursorPosition) !== space) { + newText = newText.slice(0, cursorPosition) + space + newText.slice(cursorPosition); } + cursorPosition += space.length; } - return {text: newText, emojis}; + return {text: newText, emojis, cursorPosition}; } /** * Find all emojis in a text and replace them with their code. */ function replaceAndExtractEmojis(text: string, preferredSkinTone = CONST.EMOJI_DEFAULT_SKIN_TONE, lang = CONST.LOCALES.DEFAULT): ReplacedEmoji { - const {text: convertedText = '', emojis = []} = replaceEmojis(text, preferredSkinTone, lang); + const {text: convertedText = '', emojis = [], cursorPosition} = replaceEmojis(text, preferredSkinTone, lang); return { text: convertedText, emojis: emojis.concat(extractEmojis(text)), + cursorPosition, }; } diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 5bc8ea1d3508..46bdd510f5c4 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -42,6 +42,14 @@ function getMicroSecondOnyxError(error: string): Record { return {[DateUtils.getMicroseconds()]: error}; } +/** + * Method used to get an error object with microsecond as the key and an object as the value. + * @param error - error key or message to be saved + */ +function getMicroSecondOnyxErrorObject(error: Record): Record> { + return {[DateUtils.getMicroseconds()]: error}; +} + type OnyxDataWithErrors = { errors?: Errors; }; @@ -111,4 +119,4 @@ function addErrorMessage(errors: ErrorsList, inpu } } -export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; +export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage}; diff --git a/src/libs/FormUtils.ts b/src/libs/FormUtils.ts new file mode 100644 index 000000000000..facaf5bfddf4 --- /dev/null +++ b/src/libs/FormUtils.ts @@ -0,0 +1,10 @@ +import {OnyxFormKey} from '@src/ONYXKEYS'; + +type ExcludeDraft = T extends `${string}Draft` ? never : T; +type OnyxFormKeyWithoutDraft = ExcludeDraft; + +function getDraftKey(formID: OnyxFormKeyWithoutDraft): `${OnyxFormKeyWithoutDraft}Draft` { + return `${formID}Draft`; +} + +export default {getDraftKey}; diff --git a/src/libs/GetPhysicalCardUtils.ts b/src/libs/GetPhysicalCardUtils.ts new file mode 100644 index 000000000000..57a9d773cc9d --- /dev/null +++ b/src/libs/GetPhysicalCardUtils.ts @@ -0,0 +1,130 @@ +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import {Login} from '@src/types/onyx'; +import Navigation from './Navigation/Navigation'; +import * as PersonalDetailsUtils from './PersonalDetailsUtils'; +import * as UserUtils from './UserUtils'; + +type DraftValues = { + addressLine1: string; + addressLine2: string; + city: string; + country: string; + legalFirstName: string; + legalLastName: string; + phoneNumber: string; + state: string; + zipPostCode: string; +}; + +type PrivatePersonalDetails = { + address: {street: string; city: string; state: string; country: string; zip: string}; + legalFirstName: string; + legalLastName: string; + phoneNumber: string; +}; + +type LoginList = Record; + +/** + * + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function getCurrentRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const { + address: {street, city, state, country, zip}, + legalFirstName, + legalLastName, + phoneNumber, + } = privatePersonalDetails; + + if (!legalFirstName && !legalLastName) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.getRoute(domain); + } + if (!phoneNumber && !UserUtils.getSecondaryPhoneLogin(loginList)) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.getRoute(domain); + } + if (!(street && city && state && country && zip)) { + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.getRoute(domain); + } + + return ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain); +} + +/** + * + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function goToNextPhysicalCardRoute(domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + Navigation.navigate(getCurrentRoute(domain, privatePersonalDetails, loginList)); +} + +/** + * + * @param currentRoute + * @param domain + * @param privatePersonalDetails + * @param loginList + * @returns + */ +function setCurrentRoute(currentRoute: string, domain: string, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const expectedRoute = getCurrentRoute(domain, privatePersonalDetails, loginList); + + // If the user is on the current route or the current route is confirmation, then he's allowed to stay on the current step + if ([currentRoute, ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.getRoute(domain)].includes(expectedRoute)) { + return; + } + + // Redirect the user if he's not allowed to be on the current step + Navigation.navigate(expectedRoute, CONST.NAVIGATION.ACTION_TYPE.REPLACE); +} + +/** + * + * @param draftValues + * @param privatePersonalDetails + * @returns + */ +function getUpdatedDraftValues(draftValues: DraftValues, privatePersonalDetails: PrivatePersonalDetails, loginList: LoginList) { + const { + address: {city, country, state, street = '', zip}, + legalFirstName, + legalLastName, + phoneNumber, + } = privatePersonalDetails; + + return { + legalFirstName: draftValues.legalFirstName || legalFirstName, + legalLastName: draftValues.legalLastName || legalLastName, + addressLine1: draftValues.addressLine1 || street.split('\n')[0], + addressLine2: draftValues.addressLine2 || street.split('\n')[1] || '', + city: draftValues.city || city, + country: draftValues.country || country, + phoneNumber: draftValues.phoneNumber || (phoneNumber ?? UserUtils.getSecondaryPhoneLogin(loginList) ?? ''), + state: draftValues.state || state, + zipPostCode: draftValues.zipPostCode || zip, + }; +} + +/** + * + * @param draftValues + * @returns + */ +function getUpdatedPrivatePersonalDetails(draftValues: DraftValues) { + const {addressLine1, addressLine2, city, country, legalFirstName, legalLastName, phoneNumber, state, zipPostCode} = draftValues; + return { + legalFirstName, + legalLastName, + phoneNumber, + address: {street: PersonalDetailsUtils.getFormattedStreet(addressLine1, addressLine2), city, country, state, zip: zipPostCode}, + }; +} + +export {getUpdatedDraftValues, getUpdatedPrivatePersonalDetails, goToNextPhysicalCardRoute, setCurrentRoute}; diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.ts similarity index 65% rename from src/libs/HttpUtils.js rename to src/libs/HttpUtils.ts index 2df7421ea91c..859c8624833c 100644 --- a/src/libs/HttpUtils.js +++ b/src/libs/HttpUtils.ts @@ -1,13 +1,16 @@ import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import {ValueOf} from 'type-fest'; import alert from '@components/Alert'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {RequestType} from '@src/types/onyx/Request'; +import type Response from '@src/types/onyx/Response'; import * as ApiUtils from './ApiUtils'; import HttpsError from './Errors/HttpsError'; let shouldFailAllRequests = false; let shouldForceOffline = false; + Onyx.connect({ key: ONYXKEYS.NETWORK, callback: (network) => { @@ -25,14 +28,8 @@ let cancellationController = new AbortController(); /** * Send an HTTP request, and attempt to resolve the json response. * If there is a network error, we'll set the application offline. - * - * @param {String} url - * @param {String} [method] - * @param {Object} [body] - * @param {Boolean} [canCancel] - * @returns {Promise} */ -function processHTTPRequest(url, method = 'get', body = null, canCancel = true) { +function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, canCancel = true): Promise { return fetch(url, { // We hook requests to the same Controller signal, so we can cancel them all at once signal: canCancel ? cancellationController.signal : undefined, @@ -49,40 +46,41 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) if (!response.ok) { // Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred - const serviceInterruptedStatuses = [ + const serviceInterruptedStatuses: Array> = [ CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR, CONST.HTTP_STATUS.BAD_GATEWAY, CONST.HTTP_STATUS.GATEWAY_TIMEOUT, CONST.HTTP_STATUS.UNKNOWN_ERROR, ]; - if (_.contains(serviceInterruptedStatuses, response.status)) { + if (serviceInterruptedStatuses.indexOf(response.status as ValueOf) > -1) { throw new HttpsError({ message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, - status: response.status, + status: response.status.toString(), title: 'Issue connecting to Expensify site', }); - } else if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) { + } + if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) { throw new HttpsError({ message: CONST.ERROR.THROTTLED, - status: response.status, + status: response.status.toString(), title: 'API request throttled', }); } throw new HttpsError({ message: response.statusText, - status: response.status, + status: response.status.toString(), }); } - return response.json(); + return response.json() as Promise; }) .then((response) => { // Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) { throw new HttpsError({ message: CONST.ERROR.DUPLICATE_RECORD, - status: CONST.JSON_CODE.BAD_REQUEST, + status: CONST.JSON_CODE.BAD_REQUEST.toString(), title: CONST.ERROR_TITLE.DUPLICATE_RECORD, }); } @@ -91,43 +89,42 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true) if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) { throw new HttpsError({ message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED, - status: CONST.JSON_CODE.EXP_ERROR, + status: CONST.JSON_CODE.EXP_ERROR.toString(), title: CONST.ERROR_TITLE.SOCKET, }); } if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) { - const {phpCommandName, authWriteCommands} = response.data; - // eslint-disable-next-line max-len - const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( - ', ', - )}. Check the APIWriteCommands class in Web-Expensify`; - alert('Too many auth writes', message); + if (response.data) { + const {phpCommandName, authWriteCommands} = response.data; + // eslint-disable-next-line max-len + const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join( + ', ', + )}. Check the APIWriteCommands class in Web-Expensify`; + alert('Too many auth writes', message); + } } - return response; + return response as Promise; }); } /** * Makes XHR request - * @param {String} command the name of the API command - * @param {Object} data parameters for the API command - * @param {String} type HTTP request type (get/post) - * @param {Boolean} shouldUseSecure should we use the secure server - * @returns {Promise} + * @param command the name of the API command + * @param data parameters for the API command + * @param type HTTP request type (get/post) + * @param shouldUseSecure should we use the secure server */ -function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) { +function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise { const formData = new FormData(); - _.each(data, (val, key) => { - // Do not send undefined request parameters to our API. They will be processed as strings of 'undefined'. - if (_.isUndefined(val)) { + Object.keys(data).forEach((key) => { + if (typeof data[key] === 'undefined') { return; } - - formData.append(key, val); + formData.append(key, data[key] as string | Blob); }); const url = ApiUtils.getCommandURL({shouldUseSecure, command}); - return processHTTPRequest(url, type, formData, data.canCancel); + return processHTTPRequest(url, type, formData, Boolean(data.canCancel)); } function cancelPendingRequests() { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 2f0a75a02cc3..01573cb434b4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -159,9 +159,13 @@ const SettingsModalStackNavigator = createModalStackNavigator({ Settings_Lounge_Access: () => require('../../../pages/settings/Profile/LoungeAccessPage').default, Settings_Wallet: () => require('../../../pages/settings/Wallet/WalletPage').default, Settings_Wallet_Cards_Digital_Details_Update_Address: () => require('../../../pages/settings/Profile/PersonalDetails/AddressPage').default, - Settings_Wallet_DomainCards: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, + Settings_Wallet_DomainCard: () => require('../../../pages/settings/Wallet/ExpensifyCardPage').default, Settings_Wallet_ReportVirtualCardFraud: () => require('../../../pages/settings/Wallet/ReportVirtualCardFraudPage').default, Settings_Wallet_Card_Activate: () => require('../../../pages/settings/Wallet/ActivatePhysicalCardPage').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardName').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardPhone').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardAddress').default, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: () => require('../../../pages/settings/Wallet/Card/GetPhysicalCardConfirm').default, Settings_Wallet_Transfer_Balance: () => require('../../../pages/settings/Wallet/TransferBalancePage').default, Settings_Wallet_Choose_Transfer_Account: () => require('../../../pages/settings/Wallet/ChooseTransferAccountPage').default, Settings_Wallet_EnablePayments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default, @@ -222,6 +226,9 @@ const PrivateNotesModalStackNavigator = createModalStackNavigator({ const SignInModalStackNavigator = createModalStackNavigator({ SignIn_Root: () => require('../../../pages/signin/SignInModal').default, }); +const ReferralModalStackNavigator = createModalStackNavigator({ + Referral_Details: () => require('../../../pages/ReferralDetailsPage').default, +}); export { MoneyRequestModalStackNavigator, @@ -248,4 +255,5 @@ export { SignInModalStackNavigator, RoomMembersModalStackNavigator, RoomInviteModalStackNavigator, + ReferralModalStackNavigator, }; diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js index 7a4cbf7db3c5..44d996282617 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js +++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js @@ -29,6 +29,7 @@ function Overlay(props) { onPress={props.onPress} accessibilityLabel={translate('common.close')} role={CONST.ACCESSIBILITY_ROLE.BUTTON} + nativeID={CONST.OVERLAY.TOP_BUTTON_NATIVE_ID} /> diff --git a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js index 119a429ff155..b8e04a6ff9e8 100644 --- a/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js +++ b/src/libs/Navigation/AppNavigator/Navigators/RightModalNavigator.js @@ -115,6 +115,10 @@ function RightModalNavigator(props) { name="SignIn" component={ModalStackNavigators.SignInModalStackNavigator} /> + } A promise that resolves when the one of the PROTECTED_SCREENS screen is available in the nav tree. + * + * @example + * waitForProtectedRoutes() + * .then(()=> console.log('Protected routes are present!')) + */ +function waitForProtectedRoutes() { + return new Promise((resolve) => { + isNavigationReady().then(() => { + const currentState = navigationRef.current.getState(); + if (navContainsProtectedRoutes(currentState)) { + resolve(); + return; + } + let unsubscribe; + const handleStateChange = ({data}) => { + const state = lodashGet(data, 'state'); + if (navContainsProtectedRoutes(state)) { + unsubscribe(); + resolve(); + } + }; + unsubscribe = navigationRef.current.addListener('state', handleStateChange); + }); + }); +} + export default { setShouldPopAllStateOnUP, canNavigate, @@ -320,6 +370,8 @@ export default { getTopmostReportId, getRouteNameFromStateEvent, getTopmostReportActionId, + waitForProtectedRoutes, + navContainsProtectedRoutes, }; export {navigationRef}; diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js index 286074914cf7..ca87a0d7b79c 100644 --- a/src/libs/Navigation/linkTo.js +++ b/src/libs/Navigation/linkTo.js @@ -41,7 +41,7 @@ function getMinimalAction(action, state) { return currentAction; } -export default function linkTo(navigation, path, type) { +export default function linkTo(navigation, path, type, isActiveRoute) { if (navigation === undefined) { throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?"); } @@ -83,6 +83,17 @@ export default function linkTo(navigation, path, type) { if (action.payload.name === NAVIGATORS.RIGHT_MODAL_NAVIGATOR) { const minimalAction = getMinimalAction(action, navigation.getRootState()); if (minimalAction) { + // There are situations where a route already exists on the current navigation stack + // But we want to push the same route instead of going back in the stack + // Which would break the user navigation history + if (!isActiveRoute && type === CONST.NAVIGATION.ACTION_TYPE.PUSH) { + minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH; + } + // There are situations when the user is trying to access a route which he has no access to + // So we want to redirect him to the right one and replace the one he tried to access + if (type === CONST.NAVIGATION.ACTION_TYPE.REPLACE) { + minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.REPLACE; + } root.dispatch(minimalAction); return; } diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index c017e6c7664e..e0ac35c957a3 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -73,7 +73,7 @@ export default { path: ROUTES.SETTINGS_WALLET, exact: true, }, - Settings_Wallet_DomainCards: { + Settings_Wallet_DomainCard: { path: ROUTES.SETTINGS_WALLET_DOMAINCARD.route, exact: true, }, @@ -81,6 +81,22 @@ export default { path: ROUTES.SETTINGS_REPORT_FRAUD.route, exact: true, }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.NAME]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_NAME.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.PHONE]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_PHONE.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.ADDRESS]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_ADDRESS.route, + exact: true, + }, + [SCREENS.SETTINGS.WALLET_CARD_GET_PHYSICAL.CONFIRM]: { + path: ROUTES.SETTINGS_WALLET_CARD_GET_PHYSICAL_CONFIRM.route, + exact: true, + }, Settings_Wallet_EnablePayments: { path: ROUTES.SETTINGS_ENABLE_PAYMENTS, exact: true, @@ -422,6 +438,11 @@ export default { SignIn_Root: ROUTES.SIGN_IN_MODAL, }, }, + Referral: { + screens: { + Referral_Details: ROUTES.REFERRAL_DETAILS_MODAL.route, + }, + }, }, }, }, diff --git a/src/libs/Notification/PushNotification/backgroundRefresh/index.android.js b/src/libs/Notification/PushNotification/backgroundRefresh/index.android.js index b672069727a5..4502011b459e 100644 --- a/src/libs/Notification/PushNotification/backgroundRefresh/index.android.js +++ b/src/libs/Notification/PushNotification/backgroundRefresh/index.android.js @@ -1,15 +1,17 @@ import Onyx from 'react-native-onyx'; +import Log from '@libs/Log'; import Visibility from '@libs/Visibility'; import * as App from '@userActions/App'; +import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; function getLastOnyxUpdateID() { return new Promise((resolve) => { const connectionID = Onyx.connect({ - key: ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID, - callback: (lastUpdateID) => { + key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT, + callback: (lastUpdateIDAppliedToClient) => { Onyx.disconnect(connectionID); - resolve(lastUpdateID); + resolve(lastUpdateIDAppliedToClient); }, }); }); @@ -26,15 +28,19 @@ export default function backgroundRefresh() { return; } - getLastOnyxUpdateID().then((lastUpdateID) => { - /** - * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered. - * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI, - * so we must manually run confirmReadyToOpenApp here instead. - * - * See more here: https://reactnative.dev/docs/headless-js-android - */ - App.confirmReadyToOpenApp(); - App.reconnectApp(lastUpdateID); - }); + getLastOnyxUpdateID() + .then((lastUpdateIDAppliedToClient) => { + /** + * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered. + * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI, + * so we must manually run confirmReadyToOpenApp here instead. + * + * See more here: https://reactnative.dev/docs/headless-js-android + */ + App.confirmReadyToOpenApp(); + App.reconnectApp(lastUpdateIDAppliedToClient); + }) + .catch((error) => { + Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] backgroundRefresh failed. This should never happen.`, {error}); + }); } diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js index 04fd34bf6075..ede873f79c6e 100644 --- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js +++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js @@ -23,18 +23,20 @@ export default function subscribeToReportCommentPushNotifications() { } Log.info('[PushNotification] onSelected() - called', false, {reportID, reportActionID}); - Navigation.isNavigationReady().then(() => { - try { - // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back - if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { - Navigation.goBack(ROUTES.HOME); - } + Navigation.isNavigationReady() + .then(Navigation.waitForProtectedRoutes) + .then(() => { + try { + // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back + if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) { + Navigation.goBack(ROUTES.HOME); + } - Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); - } catch (error) { - Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message}); - } - }); + Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID}); + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID)); + } catch (error) { + Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message}); + } + }); }); } diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js index 123efb28bf19..b97ae6daed11 100644 --- a/src/libs/OptionsListUtils.js +++ b/src/libs/OptionsListUtils.js @@ -529,7 +529,7 @@ function createOption(accountIDs, personalDetails, report, reportActions = {}, { } reportName = ReportUtils.getReportName(report); } else { - reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]); + reportName = ReportUtils.getDisplayNameForParticipant(accountIDs[0]) || LocalePhoneNumber.formatPhoneNumber(personalDetail.login); result.keyForList = String(accountIDs[0]); result.alternateText = LocalePhoneNumber.formatPhoneNumber(lodashGet(personalDetails, [accountIDs[0], 'login'], '')); } @@ -729,6 +729,21 @@ function sortCategories(categories) { return flatHierarchy(hierarchy); } +/** + * Sorts tags alphabetically by name. + * + * @param {Object} tags + * @returns {Array} + */ +function sortTags(tags) { + const sortedTags = _.chain(tags) + .values() + .sortBy((tag) => tag.name) + .value(); + + return sortedTags; +} + /** * Builds the options for the category tree hierarchy via indents * @@ -895,13 +910,18 @@ function getCategoryListSections(categories, recentlyUsedCategories, selectedOpt * @returns {Array} */ function getTagsOptions(tags) { - return _.map(tags, (tag) => ({ - text: tag.name, - keyForList: tag.name, - searchText: tag.name, - tooltipText: tag.name, - isDisabled: !tag.enabled, - })); + return _.map(tags, (tag) => { + // This is to remove unnecessary escaping backslash in tag name sent from backend. + const tagName = tag.name && tag.name.replace(/\\{1,2}:/g, ':'); + + return { + text: tagName, + keyForList: tagName, + searchText: tagName, + tooltipText: tagName, + isDisabled: !tag.enabled, + }; + }); } /** @@ -919,7 +939,8 @@ function getTagsOptions(tags) { */ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInputValue, maxRecentReportsToShow) { const tagSections = []; - const enabledTags = _.filter(tags, (tag) => tag.enabled); + const sortedTags = sortTags(tags); + const enabledTags = _.filter(sortedTags, (tag) => tag.enabled); const numberOfTags = _.size(enabledTags); let indexOffset = 0; diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index b37db2584394..5bd70fee4d83 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -26,7 +26,7 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData']): string { if (account) { - if (accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && 'accountNumber' in account) { + if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) { return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; } if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD && 'cardNumber' in account) { diff --git a/src/libs/PersonalDetailsUtils.js b/src/libs/PersonalDetailsUtils.js index c99adc32a56a..560480dcec9d 100644 --- a/src/libs/PersonalDetailsUtils.js +++ b/src/libs/PersonalDetailsUtils.js @@ -162,6 +162,26 @@ function formatPiece(piece) { return piece ? `${piece}, ` : ''; } +/** + * + * @param {String} street1 - street line 1 + * @param {String} street2 - street line 2 + * @returns {String} formatted street + */ +function getFormattedStreet(street1 = '', street2 = '') { + return `${street1}\n${street2}`; +} + +/** + * + * @param {*} street - formatted address + * @returns {[string, string]} [street1, street2] + */ +function getStreetLines(street = '') { + const streets = street.split('\n'); + return [streets[0], streets[1]]; +} + /** * Formats an address object into an easily readable string * @@ -170,11 +190,20 @@ function formatPiece(piece) { */ function getFormattedAddress(privatePersonalDetails) { const {address} = privatePersonalDetails; - const [street1, street2] = (address.street || '').split('\n'); + const [street1, street2] = getStreetLines(address.street); const formattedAddress = formatPiece(street1) + formatPiece(street2) + formatPiece(address.city) + formatPiece(address.state) + formatPiece(address.zip) + formatPiece(address.country); // Remove the last comma of the address return formattedAddress.trim().replace(/,$/, ''); } -export {getDisplayNameOrDefault, getPersonalDetailsByIDs, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData, getFormattedAddress}; +export { + getDisplayNameOrDefault, + getPersonalDetailsByIDs, + getAccountIDsByLogins, + getLoginsByAccountIDs, + getNewPersonalDetailsOnyxData, + getFormattedAddress, + getFormattedStreet, + getStreetLines, +}; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 28c92c6e6326..333d621167b7 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -69,7 +69,7 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean { } function isReversedTransaction(reportAction: OnyxEntry) { - return (reportAction?.message?.[0].isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; + return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0; } function isPendingRemove(reportAction: OnyxEntry): boolean { @@ -96,6 +96,15 @@ function isReimbursementQueuedAction(reportAction: OnyxEntry) { return reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.REIMBURSEMENTQUEUED; } +function isChannelLogMemberAction(reportAction: OnyxEntry) { + return ( + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.REMOVE_FROM_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM || + reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.REMOVE_FROM_ROOM + ); +} + /** * Returns whether the comment is a thread parent message/the first message in a thread */ @@ -406,9 +415,12 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions = }; } - const messageText = message?.text ?? ''; + let messageText = message?.text ?? ''; + if (messageText) { + messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(); + } return { - lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(), + lastMessageText: messageText, }; } @@ -460,7 +472,10 @@ function getLastClosedReportAction(reportActions: ReportActions | null): OnyxEnt * 4. We will get the second last action from filtered actions because the last * action is always the created action */ -function getFirstVisibleReportActionID(sortedReportActions: ReportAction[], isOffline: boolean): string { +function getFirstVisibleReportActionID(sortedReportActions: ReportAction[] = [], isOffline = false): string { + if (!Array.isArray(sortedReportActions)) { + return ''; + } const sortedFilterReportActions = sortedReportActions.filter((action) => !isDeletedAction(action) || (action?.childVisibleActionCount ?? 0) > 0 || isOffline); return sortedFilterReportActions.length > 1 ? sortedFilterReportActions[sortedFilterReportActions.length - 2].reportActionID : ''; } @@ -616,6 +631,26 @@ function isNotifiableReportAction(reportAction: OnyxEntry): boolea return actions.includes(reportAction.actionName); } +/** + * Helper method to determine if the provided accountID has made a request on the specified report. + * + * @param reportID + * @param currentAccountID + * @returns + */ +function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number): boolean { + if (!reportID) { + return false; + } + + const reportActions = Object.values(getAllReportActions(reportID)); + if (reportActions.length === 0) { + return false; + } + + return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID); +} + export { extractLinksFromMessageHtml, getAllReportActions, @@ -656,5 +691,7 @@ export { isReimbursementQueuedAction, shouldReportActionBeVisible, shouldReportActionBeVisibleAsLastAction, + hasRequestFromCurrentAccount, getFirstVisibleReportActionID, + isChannelLogMemberAction, }; diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js index 6a34611838c2..2e91a93af7e1 100644 --- a/src/libs/ReportUtils.js +++ b/src/libs/ReportUtils.js @@ -11,10 +11,10 @@ import * as defaultWorkspaceAvatars from '@components/Icon/WorkspaceDefaultAvata import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import * as IOU from './actions/IOU'; import * as CurrencyUtils from './CurrencyUtils'; import DateUtils from './DateUtils'; import isReportMessageAttachment from './isReportMessageAttachment'; +import * as LocalePhoneNumber from './LocalePhoneNumber'; import * as Localize from './Localize'; import linkingConfig from './Navigation/linkingConfig'; import Navigation from './Navigation/Navigation'; @@ -81,6 +81,25 @@ Onyx.connect({ callback: (val) => (loginList = val), }); +let allPolicyTags = {}; + +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY_TAGS, + waitForCollectionCallback: true, + callback: (value) => { + if (!value) { + allPolicyTags = {}; + return; + } + + allPolicyTags = value; + }, +}); + +function getPolicyTags(policyID) { + return lodashGet(allPolicyTags, `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {}); +} + function getChatType(report) { return report ? report.chatType : ''; } @@ -820,8 +839,15 @@ function isOneOnOneChat(report) { * @returns {Object} */ function getReport(reportID) { - // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check - return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {}; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + if (!allReports) { + return {}; + } + + return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {}; } /** @@ -1259,16 +1285,20 @@ function getDisplayNameForParticipant(accountID, shouldUseShortForm = false, sho if (!accountID) { return ''; } + const personalDetails = getPersonalDetailsForAccountID(accountID); - // this is to check if account is an invite/optimistically created one + const formattedLogin = LocalePhoneNumber.formatPhoneNumber(personalDetails.login || ''); + + // This is to check if account is an invite/optimistically created one // and prevent from falling back to 'Hidden', so a correct value is shown - // when searching for a new user + // when searching for a new user while offline if (lodashGet(personalDetails, 'isOptimisticPersonalDetail') === true) { - return personalDetails.login || ''; + return formattedLogin; } - const longName = personalDetails.displayName; + + const longName = personalDetails.displayName || formattedLogin; const shortName = personalDetails.firstName || longName; - if (!longName && !personalDetails.login && shouldFallbackToHidden) { + if (!longName && shouldFallbackToHidden) { return Localize.translateLocal('common.hidden'); } return shouldUseShortForm ? shortName : longName; @@ -1538,14 +1568,25 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) { * @returns {String} */ function getPolicyExpenseChatName(report, policy = undefined) { - const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName; + const ownerAccountID = report.ownerAccountID; + const personalDetails = allPersonalDetails[ownerAccountID]; + const login = personalDetails ? personalDetails.login : null; + const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName; // If the policy expense chat is owned by this user, use the name of the policy as the report name. if (report.isOwnPolicyExpenseChat) { return getPolicyName(report, false, policy); } - const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user'; + let policyExpenseChatRole = 'user'; + /** + * Using typical string concatenation here due to performance issues + * with template literals. + */ + const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID]; + if (policyItem) { + policyExpenseChatRole = policyItem.role || 'user'; + } // If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat // of the account which was merged into the current user's account. Use the name of the policy as the name of the report. @@ -1629,9 +1670,10 @@ function getTransactionDetails(transaction, createdDateFormat = CONST.DATE.FNS_F * - or the user is an admin on the policy the expense report is tied to * * @param {Object} reportAction + * @param {String} fieldToEdit * @returns {Boolean} */ -function canEditMoneyRequest(reportAction) { +function canEditMoneyRequest(reportAction, fieldToEdit = '') { const isDeleted = ReportActionsUtils.isDeletedAction(reportAction); if (isDeleted) { @@ -1653,7 +1695,9 @@ function canEditMoneyRequest(reportAction) { const isReportSettled = isSettled(moneyRequestReport.reportID); const isAdmin = isExpenseReport(moneyRequestReport) && lodashGet(getPolicy(moneyRequestReport.policyID), 'role', '') === CONST.POLICY.ROLE.ADMIN; const isRequestor = currentUserAccountID === reportAction.actorAccountID; - + if (isAdmin && !isRequestor && fieldToEdit === CONST.EDIT_REQUEST_FIELD.RECEIPT) { + return false; + } if (isAdmin) { return true; } @@ -1680,7 +1724,7 @@ function canEditFieldOfMoneyRequest(reportAction, reportID, fieldToEdit) { ]; // Checks if this user has permissions to edit this money request - if (!canEditMoneyRequest(reportAction)) { + if (!canEditMoneyRequest(reportAction, fieldToEdit)) { return false; // User doesn't have permission to edit } @@ -1794,9 +1838,10 @@ function getTransactionReportName(reportAction) { * @param {Object} [reportAction={}] This can be either a report preview action or the IOU action * @param {Boolean} [shouldConsiderReceiptBeingScanned=false] * @param {Boolean} isPreviewMessageForParentChatReport + * @param {Object} [policy] * @returns {String} */ -function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false) { +function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false, policy = undefined) { const reportActionMessage = lodashGet(reportAction, 'message[0].html', ''); if (_.isEmpty(report) || !report.reportID) { @@ -1820,7 +1865,7 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip } const totalAmount = getMoneyRequestReimbursableTotal(report); - const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true); + const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID, true); const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency); if (isReportApproved(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE) { @@ -1921,7 +1966,7 @@ function getModifiedExpenseMessage(reportAction) { } const reportID = lodashGet(reportAction, 'reportID', ''); const policyID = lodashGet(getReport(reportID), 'policyID', ''); - const policyTags = IOU.getPolicyTags(policyID); + const policyTags = getPolicyTags(policyID); const policyTag = PolicyUtils.getTag(policyTags); const policyTagListName = lodashGet(policyTag, 'name', Localize.translateLocal('common.tag')); @@ -2229,6 +2274,19 @@ function navigateToDetailsPage(report) { Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } +/** + * Go back to the details page of a given report + * + * @param {Object} report + */ +function goBackToDetailsPage(report) { + if (isOneOnOneChat(report)) { + Navigation.goBack(ROUTES.PROFILE.getRoute(report.participantAccountIDs[0])); + return; + } + Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID)); +} + /** * Generate a random reportID up to 53 bits aka 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER). * There were approximately 98,000,000 reports with sequential IDs generated before we started using this approach, those make up roughly one billionth of the space for these numbers, @@ -2643,6 +2701,7 @@ function buildOptimisticIOUReportAction( whisperedToAccountIDs: _.contains([CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING], receipt.state) ? [currentUserAccountID] : [], }; } + /** * Builds an optimistic APPROVED report action with a randomly generated reportActionID. * @@ -2681,6 +2740,56 @@ function buildOptimisticApprovedReportAction(amount, currency, expenseReportID) }; } +/** + * Builds an optimistic MOVED report action with a randomly generated reportActionID. + * This action is used when we move reports across workspaces. + * + * @param {String} fromPolicyID + * @param {String} toPolicyID + * @param {Number} newParentReportID + * @param {Number} movedReportID + * + * @returns {Object} + */ +function buildOptimisticMovedReportAction(fromPolicyID, toPolicyID, newParentReportID, movedReportID) { + const originalMessage = { + fromPolicyID, + toPolicyID, + newParentReportID, + movedReportID, + }; + + const policyName = getPolicyName(allReports[`${ONYXKEYS.COLLECTION.REPORT}${newParentReportID}`]); + const movedActionMessage = [ + { + html: `moved the report to the ${policyName} workspace`, + text: `moved the report to the ${policyName} workspace`, + type: CONST.REPORT.MESSAGE.TYPE.COMMENT, + }, + ]; + + return { + actionName: CONST.REPORT.ACTIONS.TYPE.MOVED, + actorAccountID: currentUserAccountID, + automatic: false, + avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatarURL(currentUserAccountID)), + isAttachment: false, + originalMessage, + message: movedActionMessage, + person: [ + { + style: 'strong', + text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail), + type: 'TEXT', + }, + ], + reportActionID: NumberUtils.rand64(), + shouldShow: true, + created: DateUtils.getDBTime(), + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }; +} + /** * Builds an optimistic SUBMITTED report action with a randomly generated reportActionID. * @@ -2915,12 +3024,13 @@ function buildOptimisticChatReport( welcomeMessage = '', ) { const currentTime = DateUtils.getDBTime(); + const isNewlyCreatedWorkspaceChat = chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT && isOwnPolicyExpenseChat; return { type: CONST.REPORT.TYPE.CHAT, chatType, hasOutstandingIOU: false, isOwnPolicyExpenseChat, - isPinned: reportName === CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS, + isPinned: reportName === CONST.REPORT.WORKSPACE_CHAT_ROOMS.ADMINS || isNewlyCreatedWorkspaceChat, lastActorAccountID: 0, lastMessageTranslationKey: '', lastMessageHtml: '', @@ -3939,7 +4049,7 @@ function getWorkspaceChats(policyID, accountIDs) { * @returns {Boolean} */ function shouldDisableRename(report, policy) { - if (isDefaultRoom(report) || isArchivedRoom(report) || isChatThread(report) || isMoneyRequestReport(report) || isPolicyExpenseChat(report)) { + if (isDefaultRoom(report) || isArchivedRoom(report) || isThread(report) || isMoneyRequestReport(report) || isPolicyExpenseChat(report)) { return true; } @@ -3954,6 +4064,15 @@ function shouldDisableRename(report, policy) { return !_.keys(loginList).includes(policy.owner) && policy.role !== CONST.POLICY.ROLE.ADMIN; } +/** + * @param {Object|null} report + * @param {Object|null} policy - the workspace the report is on, null if the user isn't a member of the workspace + * @returns {Boolean} + */ +function canEditWriteCapability(report, policy) { + return PolicyUtils.isPolicyAdmin(policy) && !isAdminRoom(report) && !isArchivedRoom(report) && !isThread(report); +} + /** * Returns the onyx data needed for the task assignee chat * @param {Number} accountID @@ -4144,6 +4263,48 @@ function getIOUReportActionDisplayMessage(reportAction) { }); } +/** + * Return room channel log display message + * + * @param {Object} reportAction + * @returns {String} + */ +function getChannelLogMemberMessage(reportAction) { + const verb = + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? 'invited' + : 'removed'; + + const mentions = _.map(reportAction.originalMessage.targetAccountIDs, (accountID) => { + const personalDetail = lodashGet(allPersonalDetails, accountID); + const displayNameOrLogin = + LocalePhoneNumber.formatPhoneNumber(lodashGet(personalDetail, 'login', '')) || lodashGet(personalDetail, 'displayName', '') || Localize.translateLocal('common.hidden'); + return `@${displayNameOrLogin}`; + }); + + const lastMention = mentions.pop(); + let message = ''; + + if (mentions.length === 0) { + message = `${verb} ${lastMention}`; + } else if (mentions.length === 1) { + message = `${verb} ${mentions[0]} and ${lastMention}`; + } else { + message = `${verb} ${mentions.join(', ')}, and ${lastMention}`; + } + + const roomName = lodashGet(reportAction, 'originalMessage.roomName', ''); + if (roomName) { + const preposition = + reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.ROOMCHANGELOG.INVITE_TO_ROOM || reportAction.actionName === CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG.INVITE_TO_ROOM + ? ' to' + : ' from'; + message += `${preposition} ${roomName}`; + } + + return message; +} + /** * Checks if a report is a group chat. * @@ -4273,6 +4434,7 @@ export { buildOptimisticEditedTaskReportAction, buildOptimisticIOUReport, buildOptimisticApprovedReportAction, + buildOptimisticMovedReportAction, buildOptimisticSubmittedReportAction, buildOptimisticExpenseReport, buildOptimisticIOUReportAction, @@ -4346,6 +4508,7 @@ export { hasSingleParticipant, getReportRecipientAccountIDs, isOneOnOneChat, + goBackToDetailsPage, getTransactionReportName, getTransactionDetails, getTaskAssigneeChatOnyxData, @@ -4366,6 +4529,8 @@ export { parseReportRouteParams, getReimbursementQueuedActionMessage, getPersonalDetailsForAccountID, + getChannelLogMemberMessage, getRoom, shouldDisableWelcomeMessage, + canEditWriteCapability, }; diff --git a/src/libs/Request.ts b/src/libs/Request.ts index 335731763ec9..18fadca467ad 100644 --- a/src/libs/Request.ts +++ b/src/libs/Request.ts @@ -16,7 +16,7 @@ function makeXHR(request: Request): Promise { return new Promise((resolve) => resolve()); } - return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise; + return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure); }); } diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts index 58c4a124335d..763a0000ba35 100644 --- a/src/libs/SidebarUtils.ts +++ b/src/libs/SidebarUtils.ts @@ -158,18 +158,6 @@ function getOrderedReportIDs( } } - // There are a few properties that need to be calculated for the report which are used when sorting reports. - reportsToDisplay.forEach((report) => { - // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. - // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add - // the reportDisplayName property to the report object directly. - // eslint-disable-next-line no-param-reassign - report.displayName = ReportUtils.getReportName(report); - - // eslint-disable-next-line no-param-reassign - report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); - }); - // The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order: // 1. Pinned/GBR - Always sorted by reportDisplayName // 2. Drafts - Always sorted by reportDisplayName @@ -183,7 +171,18 @@ function getOrderedReportIDs( const draftReports: Report[] = []; const nonArchivedReports: Report[] = []; const archivedReports: Report[] = []; + + // There are a few properties that need to be calculated for the report which are used when sorting reports. reportsToDisplay.forEach((report) => { + // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params. + // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add + // the reportDisplayName property to the report object directly. + // eslint-disable-next-line no-param-reassign + report.displayName = ReportUtils.getReportName(report); + + // eslint-disable-next-line no-param-reassign + report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports); + const isPinned = report.isPinned ?? false; if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) { pinnedAndGBRReports.push(report); diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js index 9af74f8313c3..bfa0cd911177 100644 --- a/src/libs/UnreadIndicatorUpdater/index.js +++ b/src/libs/UnreadIndicatorUpdater/index.js @@ -1,3 +1,4 @@ +import {InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ReportUtils from '@libs/ReportUtils'; @@ -5,11 +6,33 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import updateUnread from './updateUnread/index'; +let previousUnreadCount = 0; + Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT, waitForCollectionCallback: true, callback: (reportsFromOnyx) => { - const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); - updateUnread(_.size(unreadReports)); + if (!reportsFromOnyx) { + return; + } + + /** + * We need to wait until after interactions have finished to update the unread count because otherwise + * the unread count will be updated while the interactions/animations are in progress and we don't want + * to put more work on the main thread. + * + * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions + * have finished. + * + * More info: https://reactnative.dev/docs/interactionmanager + */ + InteractionManager.runAfterInteractions(() => { + const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN); + const unreadReportsCount = _.size(unreadReports); + if (previousUnreadCount !== unreadReportsCount) { + previousUnreadCount = unreadReportsCount; + updateUnread(unreadReportsCount); + } + }); }, }); diff --git a/src/libs/UserUtils.ts b/src/libs/UserUtils.ts index 1a5ced6c9f85..b6d061432585 100644 --- a/src/libs/UserUtils.ts +++ b/src/libs/UserUtils.ts @@ -1,3 +1,4 @@ +import Str from 'expensify-common/lib/str'; import {SvgProps} from 'react-native-svg'; import {ValueOf} from 'type-fest'; import * as defaultAvatars from '@components/Icon/DefaultAvatars'; @@ -190,6 +191,14 @@ function generateAccountID(searchValue: string): number { return hashText(searchValue, 2 ** 32); } +/** + * Gets the secondary phone login number + */ +function getSecondaryPhoneLogin(loginList: Record): string | undefined { + const parsedLoginList = Object.keys(loginList).map((login) => Str.removeSMSDomain(login)); + return parsedLoginList.find((login) => Str.isValidPhone(login)); +} + export { hashText, hasLoginListError, @@ -203,5 +212,6 @@ export { getSmallSizeAvatar, getFullSizeAvatar, generateAccountID, + getSecondaryPhoneLogin, }; export type {AvatarSource}; diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index ecaf38dc44f2..29d9ecda9f73 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -1,5 +1,6 @@ import Onyx from 'react-native-onyx'; import {KeyValueMapping, NullishDeep} from 'react-native-onyx/lib/types'; +import FormUtils from '@libs/FormUtils'; import {OnyxFormKey} from '@src/ONYXKEYS'; import {Form} from '@src/types/onyx'; import * as OnyxCommon from '@src/types/onyx/OnyxCommon'; @@ -19,8 +20,15 @@ function setErrorFields(formID: OnyxFormKey, errorFields: OnyxCommon.ErrorFields Onyx.merge(formID, {errorFields} satisfies Form); } -function setDraftValues(formID: T, draftValues: NullishDeep) { - Onyx.merge(`${formID}Draft`, draftValues); +function setDraftValues(formID: OnyxFormKeyWithoutDraft, draftValues: NullishDeep) { + Onyx.merge(FormUtils.getDraftKey(formID), draftValues); } -export {setDraftValues, setErrorFields, setErrors, setIsLoading}; +/** + * @param formID + */ +function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { + Onyx.merge(FormUtils.getDraftKey(formID), undefined); +} + +export {setDraftValues, setErrorFields, setErrors, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js index 158c1960895a..8d44e3898062 100644 --- a/src/libs/actions/IOU.js +++ b/src/libs/actions/IOU.js @@ -141,6 +141,18 @@ function resetMoneyRequestInfo(id = '') { }); } +/** + * Helper function to get the receipt error for money requests, or the generic error if there's no receipt + * + * @param {Object} receipt + * @returns {Object} + */ +function getReceiptError(receipt) { + return _.isEmpty(receipt) + ? ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage') + : ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source, filename: receipt.filename}); +} + function buildOnyxDataForMoneyRequest( chatReport, iouReport, @@ -254,7 +266,10 @@ function buildOnyxDataForMoneyRequest( { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, - value: {pendingAction: null}, + value: { + pendingAction: null, + pendingFields: null, + }, }, { onyxMethod: Onyx.METHOD.MERGE, @@ -301,6 +316,7 @@ function buildOnyxDataForMoneyRequest( hasOutstandingIOU: chatReport.hasOutstandingIOU, iouReportID: chatReport.iouReportID, lastReadTime: chatReport.lastReadTime, + pendingFields: null, ...(isNewChatReport ? { errorFields: { @@ -316,6 +332,7 @@ function buildOnyxDataForMoneyRequest( onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`, value: { + pendingFields: null, errorFields: { createChat: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, @@ -328,6 +345,8 @@ function buildOnyxDataForMoneyRequest( key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, value: { errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + pendingAction: null, + pendingFields: null, }, }, { @@ -337,7 +356,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewChatReport ? { [chatCreatedAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(transaction.receipt), }, [reportPreviewAction.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxError(null), @@ -346,7 +365,7 @@ function buildOnyxDataForMoneyRequest( : { [reportPreviewAction.reportActionID]: { created: reportPreviewAction.created, - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(transaction.receipt), }, }), }, @@ -358,7 +377,7 @@ function buildOnyxDataForMoneyRequest( ...(isNewIOUReport ? { [iouCreatedAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(transaction.receipt), }, [iouAction.reportActionID]: { errors: ErrorUtils.getMicroSecondOnyxError(null), @@ -366,7 +385,7 @@ function buildOnyxDataForMoneyRequest( } : { [iouAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(transaction.receipt), }, }), }, @@ -680,7 +699,7 @@ function createDistanceRequest(report, participant, comment, created, transactio * @param {Object} [transactionChanges.waypoints] * */ -function updateDistanceRequest(transactionID, transactionThreadReportID, transactionChanges) { +function editDistanceMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) { const optimisticData = []; const successData = []; const failureData = []; @@ -787,10 +806,10 @@ function updateDistanceRequest(transactionID, transactionThreadReportID, transac }); if (_.has(transactionChanges, 'waypoints')) { - // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors + // Delete the draft transaction when editing waypoints when the server responds successfully and there are no errors successData.push({ onyxMethod: Onyx.METHOD.SET, - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, value: null, }); } @@ -1426,7 +1445,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`, value: { [splitIOUReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(receipt), }, }, }); @@ -1449,7 +1468,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'), }, [splitIOUReportAction.reportActionID]: { - errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'), + errors: getReceiptError(receipt), }, }, }, @@ -1764,7 +1783,7 @@ function setDraftSplitTransaction(transactionID, transactionChanges = {}) { * @param {Number} transactionThreadReportID * @param {Object} transactionChanges */ -function editMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) { +function editRegularMoneyRequest(transactionID, transactionThreadReportID, transactionChanges) { // STEP 1: Get all collections we're updating const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`]; const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`]; @@ -1978,6 +1997,19 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC ); } +/** + * @param {object} transaction + * @param {Number} transactionThreadReportID + * @param {Object} transactionChanges + */ +function editMoneyRequest(transaction, transactionThreadReportID, transactionChanges) { + if (TransactionUtils.isDistanceRequest(transaction)) { + editDistanceMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges); + } else { + editRegularMoneyRequest(transaction.transactionID, transactionThreadReportID, transactionChanges); + } +} + /** * @param {String} transactionID * @param {Object} reportAction - the money request reportAction we are deleting @@ -2425,7 +2457,7 @@ function getSendMoneyParams(report, amount, currency, comment, paymentMethodType function getPayMoneyRequestParams(chatReport, iouReport, recipient, paymentMethodType) { const optimisticIOUReportAction = ReportUtils.buildOptimisticIOUReportAction( CONST.IOU.REPORT_ACTION_TYPE.PAY, - iouReport.total, + -iouReport.total, iouReport.currency, '', [recipient], @@ -2929,6 +2961,8 @@ function navigateToNextPage(iou, iouType, report, path = '') { .map((accountID) => ({accountID, selected: true})) .value(); setMoneyRequestParticipants(participants); + resetMoneyRequestCategory(); + resetMoneyRequestTag(); } Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID)); return; @@ -2951,13 +2985,8 @@ function getIOUReportID(iou, route) { return lodashGet(route, 'params.reportID') || lodashGet(iou, 'participants.0.reportID', ''); } -function getPolicyTags(policyID) { - return lodashGet(allPolicyTags, `${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyID}`, {}); -} - export { createDistanceRequest, - editMoneyRequest, deleteMoneyRequest, splitBill, splitBillAndOpenReport, @@ -2987,9 +3016,8 @@ export { setMoneyRequestReceipt, setUpDistanceTransaction, navigateToNextPage, - updateDistanceRequest, replaceReceipt, detachReceipt, getIOUReportID, - getPolicyTags, + editMoneyRequest, }; diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 39016b241585..e1e73d425281 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -1,30 +1,38 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '@src/ONYXKEYS'; -let closeModal: ((isNavigating: boolean) => void) | null; +const closeModals: Array<(isNavigating: boolean) => void> = []; + let onModalClose: null | (() => void); /** * Allows other parts of the app to call modal close function */ -function setCloseModal(onClose: (() => void) | null) { - closeModal = onClose; +function setCloseModal(onClose: () => void) { + if (!closeModals.includes(onClose)) { + closeModals.push(onClose); + } + return () => { + const index = closeModals.indexOf(onClose); + if (index === -1) { + return; + } + closeModals.splice(index, 1); + }; } /** * Close modal in other parts of the app */ function close(onModalCloseCallback: () => void, isNavigating = true) { - if (!closeModal) { - // If modal is already closed, no need to wait for modal close. So immediately call callback. - if (onModalCloseCallback) { - onModalCloseCallback(); - } - onModalClose = null; + if (closeModals.length === 0) { + onModalCloseCallback(); return; } onModalClose = onModalCloseCallback; - closeModal(isNavigating); + [...closeModals].reverse().forEach((onClose) => { + onClose(isNavigating); + }); } function onModalDidClose() { diff --git a/src/libs/actions/OnyxUpdates.ts b/src/libs/actions/OnyxUpdates.ts index b44b485ac60f..ce673fa6aaaf 100644 --- a/src/libs/actions/OnyxUpdates.ts +++ b/src/libs/actions/OnyxUpdates.ts @@ -15,6 +15,10 @@ Onyx.connect({ callback: (val) => (lastUpdateIDAppliedToClient = val), }); +// This promise is used to ensure pusher events are always processed in the order they are received, +// even when such events are received over multiple separate pusher updates. +let pusherEventsPromise = Promise.resolve(); + function applyHTTPSOnyxUpdates(request: Request, response: Response) { console.debug('[OnyxUpdateManager] Applying https update'); // For most requests we can immediately update Onyx. For write requests we queue the updates and apply them after the sequential queue has flushed to prevent a replay effect in @@ -44,11 +48,17 @@ function applyHTTPSOnyxUpdates(request: Request, response: Response) { } function applyPusherOnyxUpdates(updates: OnyxUpdateEvent[]) { - console.debug('[OnyxUpdateManager] Applying pusher update'); - const pusherEventPromises = updates.map((update) => PusherUtils.triggerMultiEventHandler(update.eventType, update.data)); - return Promise.all(pusherEventPromises).then(() => { - console.debug('[OnyxUpdateManager] Done applying Pusher update'); + pusherEventsPromise = pusherEventsPromise.then(() => { + console.debug('[OnyxUpdateManager] Applying pusher update'); }); + + pusherEventsPromise = updates + .reduce((promise, update) => promise.then(() => PusherUtils.triggerMultiEventHandler(update.eventType, update.data)), pusherEventsPromise) + .then(() => { + console.debug('[OnyxUpdateManager] Done applying Pusher update'); + }); + + return pusherEventsPromise; } /** diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index bcc5d8142470..02f0b49fe3d2 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -81,7 +81,7 @@ function getMakeDefaultPaymentOnyxData( key: ONYXKEYS.USER_WALLET, value: { walletLinkedAccountID: bankAccountID || fundID, - walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, + walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, // Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server. errors: null, }, @@ -91,7 +91,7 @@ function getMakeDefaultPaymentOnyxData( key: ONYXKEYS.USER_WALLET, value: { walletLinkedAccountID: bankAccountID || fundID, - walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, + walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD, }, }, ]; @@ -99,7 +99,7 @@ function getMakeDefaultPaymentOnyxData( if (previousPaymentMethod?.methodID) { onyxData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, + key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, value: { [previousPaymentMethod.methodID]: { isDefault: !isOptimisticData, @@ -111,7 +111,7 @@ function getMakeDefaultPaymentOnyxData( if (currentPaymentMethod?.methodID) { onyxData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, + key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST, value: { [currentPaymentMethod.methodID]: { isDefault: isOptimisticData, @@ -223,7 +223,8 @@ function clearDebitCardFormErrorAndSubmit() { * */ function transferWalletBalance(paymentMethod: PaymentMethod) { - const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD; + const paymentMethodIDKey = + paymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD; type TransferWalletBalanceParameters = Partial, number | undefined>>; diff --git a/src/libs/actions/PersonalDetails.ts b/src/libs/actions/PersonalDetails.ts index db024e8db4cc..e26cee71dc67 100644 --- a/src/libs/actions/PersonalDetails.ts +++ b/src/libs/actions/PersonalDetails.ts @@ -1,9 +1,10 @@ import Str from 'expensify-common/lib/str'; import Onyx, {OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import * as API from '@libs/API'; -import {CustomRNImageManipulatorResult, FileWithUri} from '@libs/cropOrRotateImage/types'; +import {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; import * as LocalePhoneNumber from '@libs/LocalePhoneNumber'; import Navigation from '@libs/Navigation/Navigation'; +import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -99,16 +100,20 @@ function extractFirstAndLastNameFromAvailableDetails({login, displayName, firstN return {firstName: '', lastName: ''}; } - const firstSpaceIndex = displayName.indexOf(' '); - const lastSpaceIndex = displayName.lastIndexOf(' '); - if (firstSpaceIndex === -1) { - return {firstName: displayName, lastName: ''}; + if (displayName) { + const firstSpaceIndex = displayName.indexOf(' '); + const lastSpaceIndex = displayName.lastIndexOf(' '); + if (firstSpaceIndex === -1) { + return {firstName: displayName, lastName: ''}; + } + + return { + firstName: displayName.substring(0, firstSpaceIndex).trim(), + lastName: displayName.substring(lastSpaceIndex).trim(), + }; } - return { - firstName: displayName.substring(0, firstSpaceIndex).trim(), - lastName: displayName.substring(lastSpaceIndex).trim(), - }; + return {firstName: '', lastName: ''}; } /** @@ -263,7 +268,7 @@ function updateAddress(street: string, street2: string, city: string, state: str key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, value: { address: { - street: `${street}\n${street2}`, + street: PersonalDetailsUtils.getFormattedStreet(street, street2), city, state, zip, @@ -440,7 +445,7 @@ function openPublicProfilePage(accountID: number) { /** * Updates the user's avatar image */ -function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { +function updateAvatar(file: File | CustomRNImageManipulatorResult) { if (!currentUserAccountID) { return; } @@ -496,7 +501,7 @@ function updateAvatar(file: FileWithUri | CustomRNImageManipulatorResult) { ]; type UpdateUserAvatarParams = { - file: FileWithUri | CustomRNImageManipulatorResult; + file: File | CustomRNImageManipulatorResult; }; const parameters: UpdateUserAvatarParams = {file}; diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js index 4df510d44db7..ebc1cdf9a2e1 100644 --- a/src/libs/actions/Policy.js +++ b/src/libs/actions/Policy.js @@ -7,12 +7,15 @@ import lodashUnion from 'lodash/union'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as API from '@libs/API'; +import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; import Log from '@libs/Log'; import * as NumberUtils from '@libs/NumberUtils'; import * as OptionsListUtils from '@libs/OptionsListUtils'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; +import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; +import * as TransactionUtils from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -415,9 +418,10 @@ function removeMembers(accountIDs, policyID) { * * @param {String} policyID * @param {Object} invitedEmailsToAccountIDs + * @param {Boolean} hasOutstandingChildRequest * @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID) */ -function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) { +function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutstandingChildRequest = false) { const workspaceMembersChats = { onyxSuccessData: [], onyxOptimisticData: [], @@ -463,6 +467,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) { createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, isOptimisticReport: true, + hasOutstandingChildRequest, }, }); workspaceMembersChats.onyxOptimisticData.push({ @@ -1458,6 +1463,403 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) { return lodashUnion([category], policyRecentlyUsedCategories); } +/** + * This flow is used for bottom up flow converting IOU report to an expense report. When user takes this action, + * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we + * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report. + * + * @param {Object} iouReport + * @returns {String} policyID of the workspace we have created + */ +function createWorkspaceFromIOUPayment(iouReport) { + // This flow only works for IOU reports + if (!ReportUtils.isIOUReport(iouReport)) { + return; + } + + // Generate new variables for the policy + const policyID = generatePolicyID(); + const workspaceName = generateDefaultWorkspaceName(sessionEmail); + const employeeAccountID = iouReport.ownerAccountID; + const employeeEmail = iouReport.ownerEmail; + const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits(); + const oldPersonalPolicyID = iouReport.policyID; + const iouReportID = iouReport.reportID; + + const { + announceChatReportID, + announceChatData, + announceReportActionData, + announceCreatedReportActionID, + adminsChatReportID, + adminsChatData, + adminsReportActionData, + adminsCreatedReportActionID, + expenseChatReportID: workspaceChatReportID, + expenseChatData: workspaceChatData, + expenseReportActionData: workspaceChatReportActionData, + expenseCreatedReportActionID: workspaceChatCreatedReportActionID, + } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName); + + // Create the workspace chat for the employee whose IOU is being paid + const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true); + const newWorkspace = { + id: policyID, + + // We are creating a collect policy in this case + type: CONST.POLICY.TYPE.TEAM, + name: workspaceName, + role: CONST.POLICY.ROLE.ADMIN, + owner: sessionEmail, + isPolicyExpenseChatEnabled: true, + + // Setting the currency to USD as we can only add the VBBA for this policy currency right now + outputCurrency: CONST.CURRENCY.USD, + pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + customUnits, + }; + + const optimisticData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: newWorkspace, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, + value: { + [sessionAccountID]: { + role: CONST.POLICY.ROLE.ADMIN, + errors: {}, + }, + [employeeAccountID]: { + role: CONST.POLICY.ROLE.USER, + errors: {}, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...announceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: announceReportActionData, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...adminsChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: adminsReportActionData, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + }, + ...workspaceChatData, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: workspaceChatReportActionData, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`, + value: null, + }, + ...employeeWorkspaceChat.onyxOptimisticData, + ]; + + const successData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {pendingAction: null}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: { + [_.keys(announceChatData)[0]]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: { + [_.keys(adminsChatData)[0]]: { + pendingAction: null, + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: { + pendingFields: { + addWorkspaceRoom: null, + }, + pendingAction: null, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: { + [_.keys(workspaceChatData)[0]]: { + pendingAction: null, + }, + }, + }, + ...employeeWorkspaceChat.onyxSuccessData, + ]; + + const failureData = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`, + value: null, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`, + value: null, + }, + ]; + + // Compose the memberData object which is used to add the employee to the workspace and + // optimistically create the workspace chat for them. + const memberData = { + accountID: Number(employeeAccountID), + email: employeeEmail, + workspaceChatReportID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportID, + workspaceChatCreatedReportActionID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportActionID, + }; + + const oldChatReportID = iouReport.chatReportID; + + // Next we need to convert the IOU report to Expense report. + // We need to change: + // - report type + // - change the sign of the report total + // - update its policyID and policyName + // - update the chatReportID to point to the new workspace chat + const expenseReport = { + ...iouReport, + chatReportID: memberData.workspaceChatReportID, + policyID, + policyName: workspaceName, + type: CONST.REPORT.TYPE.EXPENSE, + total: -iouReport.total, + }; + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: expenseReport, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`, + value: iouReport, + }); + + // The expense report transactions need to have the amount reversed to negative values + const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID); + + // For performance reasons, we are going to compose a merge collection data for transactions + const transactionsOptimisticData = {}; + const transactionFailureData = {}; + _.each(reportTransactions, (transaction) => { + transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + ...transaction, + amount: -transaction.amount, + modifiedAmount: transaction.modifiedAmount ? -transaction.modifiedAmount : 0, + }; + + transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction; + }); + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionsOptimisticData, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE_COLLECTION, + key: `${ONYXKEYS.COLLECTION.TRANSACTION}`, + value: transactionFailureData, + }); + + // We need to move the report preview action from the DM to the workspace chat. + const reportPreview = ReportActionsUtils.getParentReportAction(iouReport); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview.reportActionID]: null}, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[reportPreview.reportActionID]: reportPreview}, + }); + + // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: false, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`, + value: { + hasOutstandingChildRequest: true, + }, + }); + + // Update the created timestamp of the report preview action to be after the workspace chat created timestamp. + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: { + [reportPreview.reportActionID]: { + ...reportPreview, + message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace), + created: DateUtils.getDBTime(), + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`, + value: {[reportPreview.reportActionID]: null}, + }); + + // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved + const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID); + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[movedReportAction.reportActionID]: movedReportAction}, + }); + successData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: { + [movedReportAction.reportActionID]: { + ...movedReportAction, + pendingAction: null, + }, + }, + }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`, + value: {[movedReportAction.reportActionID]: null}, + }); + + API.write( + 'CreateWorkspaceFromIOUPayment', + { + policyID, + announceChatReportID, + adminsChatReportID, + expenseChatReportID: workspaceChatReportID, + ownerEmail: '', + makeMeAdmin: false, + policyName: workspaceName, + type: CONST.POLICY.TYPE.TEAM, + announceCreatedReportActionID, + adminsCreatedReportActionID, + expenseCreatedReportActionID: workspaceChatCreatedReportActionID, + customUnitID, + customUnitRateID, + iouReportID, + memberData: JSON.stringify(memberData), + reportActionID: movedReportAction.reportActionID, + }, + {optimisticData, successData, failureData}, + ); + + return policyID; +} + export { removeMembers, addMembersToWorkspace, @@ -1484,6 +1886,7 @@ export { openWorkspaceMembersPage, openWorkspaceInvitePage, removeWorkspace, + createWorkspaceFromIOUPayment, setWorkspaceInviteMembersDraft, clearErrors, dismissAddedWithPrimaryLoginMessages, diff --git a/src/libs/actions/PushNotification.js b/src/libs/actions/PushNotification.ts similarity index 84% rename from src/libs/actions/PushNotification.js rename to src/libs/actions/PushNotification.ts index 7abbd7b94ba0..888892fdc188 100644 --- a/src/libs/actions/PushNotification.js +++ b/src/libs/actions/PushNotification.ts @@ -6,15 +6,18 @@ import * as Device from './Device'; let isUserOptedInToPushNotifications = false; Onyx.connect({ key: ONYXKEYS.PUSH_NOTIFICATIONS_ENABLED, - callback: (val) => (isUserOptedInToPushNotifications = val), + callback: (value) => { + if (value === null) { + return; + } + isUserOptedInToPushNotifications = value; + }, }); /** * Record that user opted-in or opted-out of push notifications on the current device. - * - * @param {Boolean} isOptingIn */ -function setPushNotificationOptInStatus(isOptingIn) { +function setPushNotificationOptInStatus(isOptingIn: boolean) { Device.getDeviceID().then((deviceID) => { const commandName = isOptingIn ? 'OptInToPushNotifications' : 'OptOutOfPushNotifications'; const optimisticData = [ diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 22a1bc5441e6..a03488429405 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -3,7 +3,7 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import lodashDebounce from 'lodash/debounce'; import lodashGet from 'lodash/get'; -import {InteractionManager} from 'react-native'; +import {DeviceEventEmitter, InteractionManager} from 'react-native'; import Onyx from 'react-native-onyx'; import _ from 'underscore'; import * as ActiveClientManager from '@libs/ActiveClientManager'; @@ -937,6 +937,7 @@ function markCommentAsUnread(reportID, reportActionCreated) { ], }, ); + DeviceEventEmitter.emit(`unreadAction_${reportID}`, lastReadTime); } /** @@ -1369,11 +1370,12 @@ function saveReportActionDraftNumberOfLines(reportID, reportActionID, numberOfLi * @param {boolean} navigate * @param {String} parentReportID * @param {String} parentReportActionID + * @param {Object} report */ -function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0) { +function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0, report = {}) { if (previousValue === newValue) { - if (navigate) { - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)); + if (navigate && report.reportID) { + ReportUtils.goBackToDetailsPage(report); } return; } @@ -1405,7 +1407,7 @@ function updateNotificationPreference(reportID, previousValue, newValue, navigat } API.write('UpdateReportNotificationPreference', {reportID, notificationPreference: newValue}, {optimisticData, failureData}); if (navigate) { - Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID)); + ReportUtils.goBackToDetailsPage(report); } } @@ -1541,14 +1543,11 @@ function navigateToConciergeChat(ignoreConciergeReportID = false) { * @param {String} policyID * @param {String} reportName * @param {String} visibility - * @param {Array} policyMembersAccountIDs * @param {String} writeCapability * @param {String} welcomeMessage */ -function addPolicyReport(policyID, reportName, visibility, policyMembersAccountIDs, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL, welcomeMessage = '') { - // The participants include the current user (admin), and for restricted rooms, the policy members. Participants must not be empty. - const members = visibility === CONST.REPORT.VISIBILITY.RESTRICTED ? policyMembersAccountIDs : []; - const participants = _.unique([currentUserAccountID, ...members]); +function addPolicyReport(policyID, reportName, visibility, writeCapability = CONST.REPORT.WRITE_CAPABILITIES.ALL, welcomeMessage = '') { + const participants = [currentUserAccountID]; const parsedWelcomeMessage = ReportUtils.getParsedComment(welcomeMessage); const policyReport = ReportUtils.buildOptimisticChatReport( participants, @@ -1984,7 +1983,6 @@ function toggleEmojiReaction(reportID, reportAction, reactionObject, existingRea * @param {Boolean} isAuthenticated */ function openReportFromDeepLink(url, isAuthenticated) { - const route = ReportUtils.getRouteFromLink(url); const reportID = ReportUtils.getReportIDFromLink(url); if (reportID && !isAuthenticated) { @@ -2003,17 +2001,18 @@ function openReportFromDeepLink(url, isAuthenticated) { // Navigate to the report after sign-in/sign-up. InteractionManager.runAfterInteractions(() => { Session.waitForUserSignIn().then(() => { - if (route === ROUTES.CONCIERGE) { - navigateToConciergeChat(true); - return; - } - if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { - Navigation.isNavigationReady().then(() => { + Navigation.waitForProtectedRoutes().then(() => { + const route = ReportUtils.getRouteFromLink(url); + if (route === ROUTES.CONCIERGE) { + navigateToConciergeChat(true); + return; + } + if (Session.isAnonymousUser() && !Session.canAccessRouteByAnonymousUser(route)) { Session.signOutAndRedirectToSignIn(); - }); - return; - } - Navigation.navigate(route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + return; + } + Navigation.navigate(route, CONST.NAVIGATION.ACTION_TYPE.PUSH); + }); }); }); } diff --git a/src/libs/actions/ReportActions.ts b/src/libs/actions/ReportActions.ts index d7ff96fc6c2e..49d2432277a0 100644 --- a/src/libs/actions/ReportActions.ts +++ b/src/libs/actions/ReportActions.ts @@ -27,7 +27,7 @@ function clearReportActionErrors(reportID: string, reportAction: ReportAction) { // Delete the failed task report too const taskReportID = reportAction.message?.[0]?.taskReportID; - if (taskReportID) { + if (taskReportID && ReportActionUtils.isCreatedTaskReportAction(reportAction)) { Report.deleteReport(taskReportID); } return; diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js index e884a4d7a6b3..c91f6d1a2eec 100644 --- a/src/libs/actions/Task.js +++ b/src/libs/actions/Task.js @@ -222,7 +222,7 @@ function createTaskAndNavigate(parentReportID, title, description, assigneeEmail {optimisticData, successData, failureData}, ); - Navigation.dismissModal(optimisticTaskReport.reportID); + Navigation.dismissModal(parentReportID); } /** @@ -450,6 +450,9 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi pendingFields: { ...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}), }, + notificationPreference: [assigneeAccountID, ownerAccountID].includes(currentUserAccountID) + ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS + : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN, }; const optimisticData = [ diff --git a/src/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts index 285fd5b251df..65795e660df0 100644 --- a/src/libs/actions/Transaction.ts +++ b/src/libs/actions/Transaction.ts @@ -59,7 +59,7 @@ function addStop(transactionID: string) { function saveWaypoint(transactionID: string, index: string, waypoint: RecentWaypoint | null, isEditingWaypoint = false) { Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, { pendingFields: { - comment: isEditingWaypoint ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, + waypoints: isEditingWaypoint ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD, }, comment: { waypoints: { diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js index 78a271f0f8cd..2cb79ac387bd 100644 --- a/src/libs/actions/TransactionEdit.js +++ b/src/libs/actions/TransactionEdit.js @@ -11,7 +11,7 @@ function createBackupTransaction(transaction) { ...transaction, }; // Use set so that it will always fully overwrite any backup transaction that could have existed before - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`, newTransaction); } /** @@ -19,12 +19,12 @@ function createBackupTransaction(transaction) { * @param {String} transactionID */ function removeBackupTransaction(transactionID) { - Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null); + Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, null); } function restoreOriginalTransactionFromBackup(transactionID) { const connectionID = Onyx.connect({ - key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, + key: `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`, callback: (backupTransaction) => { Onyx.disconnect(connectionID); diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index bfc2a7306434..fad529c4b1f5 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -330,6 +330,45 @@ function answerQuestionsForWallet(answers, idNumber) { ); } +function requestPhysicalExpensifyCard(cardID, authToken, privatePersonalDetails) { + const { + legalFirstName, + legalLastName, + phoneNumber, + address: {city, country, state, street, zip}, + } = privatePersonalDetails; + const params = { + authToken, + legalFirstName, + legalLastName, + phoneNumber, + addressCity: city, + addressCountry: country, + addressState: state, + addressStreet: street, + addressZip: zip, + }; + const onyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.CARD_LIST, + value: { + [cardID]: { + state: 4, // NOT_ACTIVATED + }, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS, + value: privatePersonalDetails, + }, + ], + }; + API.write('RequestPhysicalExpensifyCard', params, onyxData); +} + export { openOnfidoFlow, openInitialSettingsPage, @@ -343,4 +382,5 @@ export { verifyIdentity, acceptWalletTerms, setKYCWallSource, + requestPhysicalExpensifyCard, }; diff --git a/src/libs/cropOrRotateImage/index.ts b/src/libs/cropOrRotateImage/index.ts index 6b222c9759b5..a66ddbb40b00 100644 --- a/src/libs/cropOrRotateImage/index.ts +++ b/src/libs/cropOrRotateImage/index.ts @@ -1,4 +1,4 @@ -import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions, FileWithUri} from './types'; +import {CropOptions, CropOrRotateImage, CropOrRotateImageOptions} from './types'; type SizeFromAngle = { width: number; @@ -71,13 +71,13 @@ function cropCanvas(canvas: HTMLCanvasElement, options: CropOptions) { return result; } -function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise { +function convertCanvasToFile(canvas: HTMLCanvasElement, options: CropOrRotateImageOptions): Promise { return new Promise((resolve) => { canvas.toBlob((blob) => { if (!blob) { return; } - const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}) as FileWithUri; + const file = new File([blob], options.name || 'fileName.jpeg', {type: options.type || 'image/jpeg'}); file.uri = URL.createObjectURL(file); resolve(file); }); diff --git a/src/libs/cropOrRotateImage/types.ts b/src/libs/cropOrRotateImage/types.ts index 09f441bd9324..188d557a1258 100644 --- a/src/libs/cropOrRotateImage/types.ts +++ b/src/libs/cropOrRotateImage/types.ts @@ -18,12 +18,8 @@ type Action = { rotate?: number; }; -type FileWithUri = File & { - uri: string; -}; - type CustomRNImageManipulatorResult = RNImageManipulatorResult & {size: number; type: string; name: string}; -type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; +type CropOrRotateImage = (uri: string, actions: Action[], options: CropOrRotateImageOptions) => Promise; -export type {CropOrRotateImage, CropOptions, Action, FileWithUri, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; +export type {CropOrRotateImage, CropOptions, Action, CropOrRotateImageOptions, CustomRNImageManipulatorResult}; diff --git a/src/libs/fileDownload/FileUtils.js b/src/libs/fileDownload/FileUtils.ts similarity index 71% rename from src/libs/fileDownload/FileUtils.js rename to src/libs/fileDownload/FileUtils.ts index b838a81ea550..618571ddf400 100644 --- a/src/libs/fileDownload/FileUtils.js +++ b/src/libs/fileDownload/FileUtils.ts @@ -2,6 +2,7 @@ import {Alert, Linking, Platform} from 'react-native'; import DateUtils from '@libs/DateUtils'; import * as Localize from '@libs/Localize'; import CONST from '@src/CONST'; +import type {ReadFileAsync, SplitExtensionFromFileName} from './types'; /** * Show alert on successful attachment download @@ -43,7 +44,9 @@ function showPermissionErrorAlert() { }, { text: Localize.translateLocal('common.settings'), - onPress: () => Linking.openSettings(), + onPress: () => { + Linking.openSettings(); + }, }, ]); } @@ -62,7 +65,9 @@ function showCameraPermissionsAlert() { }, { text: Localize.translateLocal('common.settings'), - onPress: () => Linking.openSettings(), + onPress: () => { + Linking.openSettings(); + }, }, ], {cancelable: false}, @@ -71,42 +76,36 @@ function showCameraPermissionsAlert() { /** * Generate a random file name with timestamp and file extension - * @param {String} url - * @returns {String} */ -function getAttachmentName(url) { +function getAttachmentName(url: string): string { if (!url) { return ''; } - return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop().trim()}`; + return `${DateUtils.getDBTime()}.${url.split(/[#?]/)[0].split('.').pop()?.trim()}`; } -/** - * @param {String} fileName - * @returns {Boolean} - */ -function isImage(fileName) { +function isImage(fileName: string): boolean { return CONST.FILE_TYPE_REGEX.IMAGE.test(fileName); } -/** - * @param {String} fileName - * @returns {Boolean} - */ -function isVideo(fileName) { +function isVideo(fileName: string): boolean { return CONST.FILE_TYPE_REGEX.VIDEO.test(fileName); } /** * Returns file type based on the uri - * @param {String} fileUrl - * @returns {String} */ -function getFileType(fileUrl) { +function getFileType(fileUrl: string): string | undefined { if (!fileUrl) { return; } - const fileName = fileUrl.split('/').pop().split('?')[0].split('#')[0]; + + const fileName = fileUrl.split('/').pop()?.split('?')[0].split('#')[0]; + + if (!fileName) { + return; + } + if (isImage(fileName)) { return CONST.ATTACHMENT_FILE_TYPE.IMAGE; } @@ -118,32 +117,22 @@ function getFileType(fileUrl) { /** * Returns the filename split into fileName and fileExtension - * - * @param {String} fullFileName - * @returns {Object} */ -function splitExtensionFromFileName(fullFileName) { +const splitExtensionFromFileName: SplitExtensionFromFileName = (fullFileName) => { const fileName = fullFileName.trim(); const splitFileName = fileName.split('.'); const fileExtension = splitFileName.length > 1 ? splitFileName.pop() : ''; - return {fileName: splitFileName.join('.'), fileExtension}; -} + return {fileName: splitFileName.join('.'), fileExtension: fileExtension ?? ''}; +}; /** * Returns the filename replacing special characters with underscore - * - * @param {String} fileName - * @returns {String} */ -function cleanFileName(fileName) { +function cleanFileName(fileName: string): string { return fileName.replace(/[^a-zA-Z0-9\-._]/g, '_'); } -/** - * @param {String} fileName - * @returns {String} - */ -function appendTimeToFileName(fileName) { +function appendTimeToFileName(fileName: string): string { const file = splitExtensionFromFileName(fileName); let newFileName = `${file.fileName}-${DateUtils.getDBTime()}`; // Replace illegal characters before trying to download the attachment. @@ -156,21 +145,17 @@ function appendTimeToFileName(fileName) { /** * Reads a locally uploaded file - * - * @param {String} path - the blob url of the locally uplodaded file - * @param {String} fileName - * @param {Function} onSuccess - * @param {Function} onFailure - * - * @returns {Promise} + * @param path - the blob url of the locally uploaded file + * @param fileName - name of the file to read */ -const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => +const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => new Promise((resolve) => { if (!path) { resolve(); + onFailure('[FileUtils] Path not specified'); + return; } - - return fetch(path) + fetch(path) .then((res) => { // For some reason, fetch is "Unable to read uploaded file" // on Android even though the blob is returned, so we'll ignore @@ -178,19 +163,26 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => if (!res.ok && Platform.OS !== 'android') { throw Error(res.statusText); } - return res.blob(); - }) - .then((blob) => { - const file = new File([blob], cleanFileName(fileName), {type: blob.type}); - file.source = path; - // For some reason, the File object on iOS does not have a uri property - // so images aren't uploaded correctly to the backend - file.uri = path; - onSuccess(file); + res.blob() + .then((blob) => { + const file = new File([blob], cleanFileName(fileName), {type: blob.type}); + file.source = path; + // For some reason, the File object on iOS does not have a uri property + // so images aren't uploaded correctly to the backend + file.uri = path; + onSuccess(file); + resolve(file); + }) + .catch((e) => { + console.debug('[FileUtils] Could not read uploaded file', e); + onFailure(e); + resolve(); + }); }) .catch((e) => { console.debug('[FileUtils] Could not read uploaded file', e); onFailure(e); + resolve(); }); }); @@ -198,16 +190,16 @@ const readFileAsync = (path, fileName, onSuccess, onFailure = () => {}) => * Converts a base64 encoded image string to a File instance. * Adds a `uri` property to the File instance for accessing the blob as a URI. * - * @param {string} base64 - The base64 encoded image string. - * @param {string} filename - Desired filename for the File instance. - * @returns {File} The File instance created from the base64 string with an additional `uri` property. + * @param base64 - The base64 encoded image string. + * @param filename - Desired filename for the File instance. + * @returns The File instance created from the base64 string with an additional `uri` property. * * @example * const base64Image = "data:image/png;base64,..."; // your base64 encoded image * const imageFile = base64ToFile(base64Image, "example.png"); * console.log(imageFile.uri); // Blob URI */ -function base64ToFile(base64, filename) { +function base64ToFile(base64: string, filename: string): File { // Decode the base64 string const byteString = atob(base64.split(',')[1]); diff --git a/src/libs/fileDownload/getAttachmentDetails.js b/src/libs/fileDownload/getAttachmentDetails.ts similarity index 81% rename from src/libs/fileDownload/getAttachmentDetails.js rename to src/libs/fileDownload/getAttachmentDetails.ts index 28b678ffb651..5787979a3795 100644 --- a/src/libs/fileDownload/getAttachmentDetails.js +++ b/src/libs/fileDownload/getAttachmentDetails.ts @@ -1,12 +1,11 @@ import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; import CONST from '@src/CONST'; +import type {GetAttachmentDetails} from './types'; /** * Extract the thumbnail URL, source URL and the original filename from the HTML. - * @param {String} html - * @returns {Object} */ -export default function getAttachmentDetails(html) { +const getAttachmentDetails: GetAttachmentDetails = (html) => { // Files can be rendered either as anchor tag or as an image so based on that we have to form regex. const IS_IMAGE_TAG = //i.test(html); const PREVIEW_SOURCE_REGEX = new RegExp(`${CONST.ATTACHMENT_PREVIEW_ATTRIBUTE}*=*"(.+?)"`, 'i'); @@ -21,10 +20,10 @@ export default function getAttachmentDetails(html) { } // Files created/uploaded/hosted by App should resolve from API ROOT. Other URLs aren't modified - const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)[1]); - const imageURL = IS_IMAGE_TAG && tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)[1]); + const sourceURL = tryResolveUrlFromApiRoot(html.match(SOURCE_REGEX)?.[1] ?? ''); + const imageURL = IS_IMAGE_TAG ? tryResolveUrlFromApiRoot(html.match(PREVIEW_SOURCE_REGEX)?.[1] ?? '') : null; const previewSourceURL = IS_IMAGE_TAG ? imageURL : sourceURL; - const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)[1]; + const originalFileName = html.match(ORIGINAL_FILENAME_REGEX)?.[1] ?? null; // Update the image URL so the images can be accessed depending on the config environment return { @@ -32,4 +31,6 @@ export default function getAttachmentDetails(html) { sourceURL, originalFileName, }; -} +}; + +export default getAttachmentDetails; diff --git a/src/libs/fileDownload/getImageResolution.native.js b/src/libs/fileDownload/getImageResolution.native.ts similarity index 61% rename from src/libs/fileDownload/getImageResolution.native.js rename to src/libs/fileDownload/getImageResolution.native.ts index f291886f4665..3bdff78a93ed 100644 --- a/src/libs/fileDownload/getImageResolution.native.js +++ b/src/libs/fileDownload/getImageResolution.native.ts @@ -1,14 +1,13 @@ +import {Asset} from 'react-native-image-picker'; +import type {GetImageResolution} from './types'; + /** * Get image resolution * Image object is returned as a result of a user selecting image using the react-native-image-picker * Image already has width and height properties coming from library so we just need to return them on native * Opposite to web where we need to create a new Image object and get dimensions from it * - * @param {*} file Picked file blob - * @returns {Promise} */ -function getImageResolution(file) { - return Promise.resolve({width: file.width, height: file.height}); -} +const getImageResolution: GetImageResolution = (file: Asset) => Promise.resolve({width: file.width ?? 0, height: file.height ?? 0}); export default getImageResolution; diff --git a/src/libs/fileDownload/getImageResolution.js b/src/libs/fileDownload/getImageResolution.ts similarity index 80% rename from src/libs/fileDownload/getImageResolution.js rename to src/libs/fileDownload/getImageResolution.ts index 2f9a6d4fbdb4..74dc7401d801 100644 --- a/src/libs/fileDownload/getImageResolution.js +++ b/src/libs/fileDownload/getImageResolution.ts @@ -1,3 +1,5 @@ +import type {GetImageResolution} from './types'; + /** * Get image resolution * File object is returned as a result of a user selecting image using the @@ -7,10 +9,8 @@ * new Image() is used specifically for performance reasons, opposed to using FileReader (5ms vs +100ms) * because FileReader is slow and causes a noticeable delay in the UI when selecting an image. * - * @param {*} file Picked file blob - * @returns {Promise} */ -function getImageResolution(file) { +const getImageResolution: GetImageResolution = (file) => { if (!(file instanceof File)) { return Promise.reject(new Error('Object is not an instance of File')); } @@ -20,14 +20,14 @@ function getImageResolution(file) { const objectUrl = URL.createObjectURL(file); image.onload = function () { resolve({ - width: this.naturalWidth, - height: this.naturalHeight, + width: (this as HTMLImageElement).naturalWidth, + height: (this as HTMLImageElement).naturalHeight, }); URL.revokeObjectURL(objectUrl); }; image.onerror = reject; image.src = objectUrl; }); -} +}; export default getImageResolution; diff --git a/src/libs/fileDownload/index.android.js b/src/libs/fileDownload/index.android.ts similarity index 82% rename from src/libs/fileDownload/index.android.js rename to src/libs/fileDownload/index.android.ts index c3528b579f67..41c7cb29550a 100644 --- a/src/libs/fileDownload/index.android.js +++ b/src/libs/fileDownload/index.android.ts @@ -1,15 +1,15 @@ import {PermissionsAndroid, Platform} from 'react-native'; -import RNFetchBlob from 'react-native-blob-util'; +import RNFetchBlob, {FetchBlobResponse} from 'react-native-blob-util'; import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; /** * Android permission check to store images - * @returns {Promise} */ -function hasAndroidPermission() { +function hasAndroidPermission(): Promise { // On Android API Level 33 and above, these permissions do nothing and always return 'never_ask_again' // More info here: https://stackoverflow.com/a/74296799 - if (Platform.Version >= 33) { + if (Number(Platform.Version) >= 33) { return Promise.resolve(true); } @@ -31,11 +31,8 @@ function hasAndroidPermission() { /** * Handling the download - * @param {String} url - * @param {String} fileName - * @returns {Promise} */ -function handleDownload(url, fileName) { +function handleDownload(url: string, fileName: string): Promise { return new Promise((resolve) => { const dirs = RNFetchBlob.fs.dirs; @@ -46,7 +43,7 @@ function handleDownload(url, fileName) { const isLocalFile = url.startsWith('file://'); let attachmentPath = isLocalFile ? url : undefined; - let fetchedAttachment = Promise.resolve(); + let fetchedAttachment: Promise = Promise.resolve(); if (!isLocalFile) { // Fetching the attachment @@ -69,7 +66,7 @@ function handleDownload(url, fileName) { } if (!isLocalFile) { - attachmentPath = attachment.path(); + attachmentPath = (attachment as FetchBlobResponse).path(); } return RNFetchBlob.MediaCollection.copyToMediaStore( @@ -79,11 +76,13 @@ function handleDownload(url, fileName) { mimeType: null, }, 'Download', - attachmentPath, + attachmentPath ?? '', ); }) .then(() => { - RNFetchBlob.fs.unlink(attachmentPath); + if (attachmentPath) { + RNFetchBlob.fs.unlink(attachmentPath); + } FileUtils.showSuccessAlert(); }) .catch(() => { @@ -95,12 +94,9 @@ function handleDownload(url, fileName) { /** * Checks permission and downloads the file for Android - * @param {String} url - * @param {String} fileName - * @returns {Promise} */ -export default function fileDownload(url, fileName) { - return new Promise((resolve) => { +const fileDownload: FileDownload = (url, fileName) => + new Promise((resolve) => { hasAndroidPermission() .then((hasPermission) => { if (hasPermission) { @@ -113,4 +109,5 @@ export default function fileDownload(url, fileName) { }) .finally(() => resolve()); }); -} + +export default fileDownload; diff --git a/src/libs/fileDownload/index.ios.js b/src/libs/fileDownload/index.ios.ts similarity index 72% rename from src/libs/fileDownload/index.ios.js rename to src/libs/fileDownload/index.ios.ts index 1599e919d28a..fdc4a78e0b9b 100644 --- a/src/libs/fileDownload/index.ios.js +++ b/src/libs/fileDownload/index.ios.ts @@ -1,23 +1,20 @@ import {CameraRoll} from '@react-native-camera-roll/camera-roll'; -import lodashGet from 'lodash/get'; import RNFetchBlob from 'react-native-blob-util'; import CONST from '@src/CONST'; import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; /** * Downloads the file to Documents section in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {Promise} */ -function downloadFile(fileUrl, fileName) { +function downloadFile(fileUrl: string, fileName: string) { const dirs = RNFetchBlob.fs.dirs; // The iOS files will download to documents directory const path = dirs.DocumentDir; // Fetching the attachment - const fetchedAttachment = RNFetchBlob.config({ + return RNFetchBlob.config({ fileCache: true, path: `${path}/${fileName}`, addAndroidDownloads: { @@ -26,60 +23,61 @@ function downloadFile(fileUrl, fileName) { path: `${path}/Expensify/${fileName}`, }, }).fetch('GET', fileUrl); - return fetchedAttachment; } /** * Download the image to photo lib in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {String} URI */ -function downloadImage(fileUrl) { +function downloadImage(fileUrl: string) { return CameraRoll.save(fileUrl); } /** * Download the video to photo lib in iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {String} URI */ -function downloadVideo(fileUrl, fileName) { +function downloadVideo(fileUrl: string, fileName: string): Promise { return new Promise((resolve, reject) => { - let documentPathUri = null; - let cameraRollUri = null; + let documentPathUri: string | null = null; + let cameraRollUri: string | null = null; // Because CameraRoll doesn't allow direct downloads of video with remote URIs, we first download as documents, then copy to photo lib and unlink the original file. downloadFile(fileUrl, fileName) .then((attachment) => { - documentPathUri = lodashGet(attachment, 'data'); + documentPathUri = attachment.data; + if (!documentPathUri) { + throw new Error('Error downloading video'); + } return CameraRoll.save(documentPathUri); }) .then((attachment) => { cameraRollUri = attachment; + if (!documentPathUri) { + throw new Error('Error downloading video'); + } return RNFetchBlob.fs.unlink(documentPathUri); }) - .then(() => resolve(cameraRollUri)) + .then(() => { + if (!cameraRollUri) { + throw new Error('Error downloading video'); + } + resolve(cameraRollUri); + }) .catch((err) => reject(err)); }); } /** * Download the file based on type(image, video, other file types)for iOS - * @param {String} fileUrl - * @param {String} fileName - * @returns {Promise} */ -export default function fileDownload(fileUrl, fileName) { - return new Promise((resolve) => { - let fileDownloadPromise = null; +const fileDownload: FileDownload = (fileUrl, fileName) => + new Promise((resolve) => { + let fileDownloadPromise; const fileType = FileUtils.getFileType(fileUrl); const attachmentName = FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(fileUrl); switch (fileType) { case CONST.ATTACHMENT_FILE_TYPE.IMAGE: - fileDownloadPromise = downloadImage(fileUrl, attachmentName); + fileDownloadPromise = downloadImage(fileUrl); break; case CONST.ATTACHMENT_FILE_TYPE.VIDEO: fileDownloadPromise = downloadVideo(fileUrl, attachmentName); @@ -108,4 +106,5 @@ export default function fileDownload(fileUrl, fileName) { }) .finally(() => resolve()); }); -} + +export default fileDownload; diff --git a/src/libs/fileDownload/index.js b/src/libs/fileDownload/index.js deleted file mode 100644 index d1fa968b665f..000000000000 --- a/src/libs/fileDownload/index.js +++ /dev/null @@ -1,52 +0,0 @@ -import * as ApiUtils from '@libs/ApiUtils'; -import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; -import * as Link from '@userActions/Link'; -import * as FileUtils from './FileUtils'; - -/** - * Downloading attachment in web, desktop - * @param {String} url - * @param {String} fileName - * @returns {Promise} - */ -export default function fileDownload(url, fileName) { - const resolvedUrl = tryResolveUrlFromApiRoot(url); - if (!resolvedUrl.startsWith(ApiUtils.getApiRoot())) { - // Different origin URLs might pose a CORS issue during direct downloads. - // Opening in a new tab avoids this limitation, letting the browser handle the download. - Link.openExternalLink(url); - return Promise.resolve(); - } - - return ( - fetch(url) - .then((response) => response.blob()) - .then((blob) => { - // Create blob link to download - const href = URL.createObjectURL(new Blob([blob])); - - // creating anchor tag to initiate download - const link = document.createElement('a'); - - // adding href to anchor - link.href = href; - link.style.display = 'none'; - link.setAttribute( - 'download', - FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name - ); - - // Append to html link element page - document.body.appendChild(link); - - // Start download - link.click(); - - // Clean up and remove the link - URL.revokeObjectURL(link.href); - link.parentNode.removeChild(link); - }) - // file could not be downloaded, open sourceURL in new tab - .catch(() => Link.openExternalLink(url)) - ); -} diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts new file mode 100644 index 000000000000..ef36647e549d --- /dev/null +++ b/src/libs/fileDownload/index.ts @@ -0,0 +1,53 @@ +import * as ApiUtils from '@libs/ApiUtils'; +import tryResolveUrlFromApiRoot from '@libs/tryResolveUrlFromApiRoot'; +import * as Link from '@userActions/Link'; +import CONST from '@src/CONST'; +import * as FileUtils from './FileUtils'; +import type {FileDownload} from './types'; + +/** + * The function downloads an attachment on web/desktop platforms. + */ +const fileDownload: FileDownload = (url, fileName) => { + const resolvedUrl = tryResolveUrlFromApiRoot(url); + if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix))) { + // Different origin URLs might pose a CORS issue during direct downloads. + // Opening in a new tab avoids this limitation, letting the browser handle the download. + Link.openExternalLink(url); + return Promise.resolve(); + } + + return fetch(url) + .then((response) => response.blob()) + .then((blob) => { + // Create blob link to download + const href = URL.createObjectURL(new Blob([blob])); + + // creating anchor tag to initiate download + const link = document.createElement('a'); + + // adding href to anchor + link.href = href; + link.style.display = 'none'; + link.setAttribute( + 'download', + FileUtils.appendTimeToFileName(fileName) || FileUtils.getAttachmentName(url), // generating the file name + ); + + // Append to html link element page + document.body.appendChild(link); + + // Start download + link.click(); + + // Clean up and remove the link + URL.revokeObjectURL(link.href); + link.parentNode?.removeChild(link); + }) + .catch(() => { + // file could not be downloaded, open sourceURL in new tab + Link.openExternalLink(url); + }); +}; + +export default fileDownload; diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts new file mode 100644 index 000000000000..c7388f2e52a2 --- /dev/null +++ b/src/libs/fileDownload/types.ts @@ -0,0 +1,20 @@ +import {Asset} from 'react-native-image-picker'; + +type FileDownload = (url: string, fileName: string) => Promise; + +type ImageResolution = {width: number; height: number}; +type GetImageResolution = (url: File | Asset) => Promise; + +type ExtensionAndFileName = {fileName: string; fileExtension: string}; +type SplitExtensionFromFileName = (fileName: string) => ExtensionAndFileName; + +type ReadFileAsync = (path: string, fileName: string, onSuccess: (file: File) => void, onFailure: (error?: unknown) => void) => Promise; + +type AttachmentDetails = { + previewSourceURL: null | string; + sourceURL: null | string; + originalFileName: null | string; +}; +type GetAttachmentDetails = (html: string) => AttachmentDetails; + +export type {SplitExtensionFromFileName, GetAttachmentDetails, ReadFileAsync, FileDownload, GetImageResolution}; diff --git a/src/libs/migrateOnyx.js b/src/libs/migrateOnyx.js index b65670819418..5daba3686208 100644 --- a/src/libs/migrateOnyx.js +++ b/src/libs/migrateOnyx.js @@ -3,6 +3,7 @@ import Log from './Log'; import KeyReportActionsDraftByReportActionID from './migrations/KeyReportActionsDraftByReportActionID'; import PersonalDetailsByAccountID from './migrations/PersonalDetailsByAccountID'; import RenameReceiptFilename from './migrations/RenameReceiptFilename'; +import TransactionBackupsToCollection from './migrations/TransactionBackupsToCollection'; export default function () { const startTime = Date.now(); @@ -10,7 +11,7 @@ export default function () { return new Promise((resolve) => { // Add all migrations to an array so they are executed in order - const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID]; + const migrationPromises = [PersonalDetailsByAccountID, RenameReceiptFilename, KeyReportActionsDraftByReportActionID, TransactionBackupsToCollection]; // Reduce all promises down to a single promise. All promises run in a linear fashion, waiting for the // previous promise to finish before moving onto the next one. diff --git a/src/libs/migrations/CheckForPreviousReportActionID.js b/src/libs/migrations/CheckForPreviousReportActionID.ts similarity index 64% rename from src/libs/migrations/CheckForPreviousReportActionID.js rename to src/libs/migrations/CheckForPreviousReportActionID.ts index 35f862fd5b3a..1f31a3586b0a 100644 --- a/src/libs/migrations/CheckForPreviousReportActionID.js +++ b/src/libs/migrations/CheckForPreviousReportActionID.ts @@ -1,12 +1,9 @@ -import Onyx from 'react-native-onyx'; -import _ from 'underscore'; +import Onyx, {OnyxCollection} from 'react-native-onyx'; import Log from '@libs/Log'; import ONYXKEYS from '@src/ONYXKEYS'; +import * as OnyxTypes from '@src/types/onyx'; -/** - * @returns {Promise} - */ -function getReportActionsFromOnyx() { +function getReportActionsFromOnyx(): Promise> { return new Promise((resolve) => { const connectionID = Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, @@ -22,20 +19,19 @@ function getReportActionsFromOnyx() { /** * This migration checks for the 'previousReportActionID' key in the first valid reportAction of a report in Onyx. * If the key is not found then all reportActions for all reports are removed from Onyx. - * - * @returns {Promise} */ -export default function () { +export default function (): Promise { return getReportActionsFromOnyx().then((allReportActions) => { - if (_.isEmpty(allReportActions)) { + if (Object.keys(allReportActions ?? {}).length === 0) { Log.info(`[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no reportActions`); return; } - let firstValidValue; - _.some(_.values(allReportActions), (reportActions) => - _.some(_.values(reportActions), (reportActionData) => { - if (_.has(reportActionData, 'reportActionID')) { + let firstValidValue: OnyxTypes.ReportAction | undefined; + + Object.values(allReportActions ?? {}).some((reportActions) => + Object.values(reportActions ?? {}).some((reportActionData) => { + if ('reportActionID' in reportActionData) { firstValidValue = reportActionData; return true; } @@ -44,12 +40,12 @@ export default function () { }), ); - if (_.isUndefined(firstValidValue)) { + if (!firstValidValue) { Log.info(`[Migrate Onyx] Skipped migration CheckForPreviousReportActionID because there were no valid reportActions`); return; } - if (_.has(firstValidValue, 'previousReportActionID')) { + if (firstValidValue.previousReportActionID) { Log.info(`[Migrate Onyx] CheckForPreviousReportActionID Migration: previousReportActionID found. Migration complete`); return; } @@ -57,11 +53,12 @@ export default function () { // If previousReportActionID not found: Log.info(`[Migrate Onyx] CheckForPreviousReportActionID Migration: removing all reportActions because previousReportActionID not found in the first valid reportAction`); - const onyxData = {}; - _.each(allReportActions, (reportAction, onyxKey) => { + const onyxData: OnyxCollection = {}; + + Object.keys(allReportActions ?? {}).forEach((onyxKey) => { onyxData[onyxKey] = {}; }); - return Onyx.multiSet(onyxData); + return Onyx.multiSet(onyxData as Record<`${typeof ONYXKEYS.COLLECTION.REPORT_ACTIONS}`, Record>); }); } diff --git a/src/libs/migrations/TransactionBackupsToCollection.ts b/src/libs/migrations/TransactionBackupsToCollection.ts new file mode 100644 index 000000000000..ddaa691b8d47 --- /dev/null +++ b/src/libs/migrations/TransactionBackupsToCollection.ts @@ -0,0 +1,58 @@ +import Onyx, {OnyxCollection} from 'react-native-onyx'; +import Log from '@libs/Log'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {Transaction} from '@src/types/onyx'; + +/** + * This migration moves all the transaction backups stored in the transaction collection, ONYXKEYS.COLLECTION.TRANSACTION, to a reserved collection that only + * stores draft transactions, ONYXKEYS.COLLECTION.TRANSACTION_DRAFT. The purpose of the migration is that there is a possibility that transaction backups are + * not filtered by most functions, e.g, getAllReportTransactions (src/libs/TransactionUtils.ts). One problem that arose from storing transaction backups with + * the other transactions is that for every distance request which have their waypoints updated offline, we expect the ReportPreview component to display the + * default image of a pending map. However, due to the presence of the transaction backup, the previous map image will be displayed alongside the pending map. + * The problem was further discussed in this PR. https://github.com/Expensify/App/pull/30232#issuecomment-178110172 + */ +export default function (): Promise { + return new Promise((resolve) => { + const connectionID = Onyx.connect({ + key: ONYXKEYS.COLLECTION.TRANSACTION, + waitForCollectionCallback: true, + callback: (transactions: OnyxCollection) => { + Onyx.disconnect(connectionID); + + // Determine whether any transactions were stored + if (!transactions || Object.keys(transactions).length === 0) { + Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transactions'); + return resolve(); + } + + const onyxData: OnyxCollection = {}; + + // Find all the transaction backups available + Object.keys(transactions).forEach((transactionOnyxKey: string) => { + const transaction: Transaction | null = transactions[transactionOnyxKey]; + + // Determine whether or not the transaction is a backup + if (transactionOnyxKey.endsWith('-backup') && transaction) { + // Create the transaction backup in the draft transaction collection + onyxData[`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transaction.transactionID}`] = transaction; + + // Delete the transaction backup stored in the transaction collection + onyxData[transactionOnyxKey] = null; + } + }); + + // Determine whether any transaction backups are found + if (Object.keys(onyxData).length === 0) { + Log.info('[Migrate Onyx] Skipped TransactionBackupsToCollection migration because there are no transaction backups'); + return resolve(); + } + + // Move the transaction backups to the draft transaction collection + Onyx.multiSet(onyxData as Partial<{string: [Transaction | null]}>).then(() => { + Log.info('[Migrate Onyx] TransactionBackupsToCollection migration: Successfully moved all the transaction backups to the draft transaction collection'); + resolve(); + }); + }, + }); + }); +} diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js index 0fca8aee4be9..48b80890dc49 100644 --- a/src/pages/EditRequestDistancePage.js +++ b/src/pages/EditRequestDistancePage.js @@ -102,7 +102,7 @@ function EditRequestDistancePage({report, route, transaction, transactionBackup} } transactionWasSaved.current = true; - IOU.updateDistanceRequest(transaction.transactionID, report.reportID, {waypoints}); + IOU.editMoneyRequest(transaction, report.reportID, {waypoints}); // If the client is offline, then the modal can be closed as well (because there are no errors or other feedback to show them // until they come online again and sync with the server). @@ -140,6 +140,6 @@ export default withOnyx({ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`, }, transactionBackup: { - key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}-backup`, + key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${props.transactionID}`, }, })(EditRequestDistancePage); diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js index 194cd2855dbd..95313bea142d 100644 --- a/src/pages/EditRequestPage.js +++ b/src/pages/EditRequestPage.js @@ -119,11 +119,7 @@ function EditRequestPage({report, route, parentReport, policyCategories, policyT // Update the transaction object and close the modal function editMoneyRequest(transactionChanges) { - if (TransactionUtils.isDistanceRequest(transaction)) { - IOU.updateDistanceRequest(transaction.transactionID, report.reportID, transactionChanges); - } else { - IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges); - } + IOU.editMoneyRequest(transaction, report.reportID, transactionChanges); Navigation.dismissModal(report.reportID); } diff --git a/src/pages/EditRequestTagPage.js b/src/pages/EditRequestTagPage.js index 6191c8fb8dd8..52b0fea01395 100644 --- a/src/pages/EditRequestTagPage.js +++ b/src/pages/EditRequestTagPage.js @@ -46,6 +46,7 @@ function EditRequestTagPage({defaultTag, policyID, tagName, onSubmit}) { tag={tagName} policyID={policyID} onSubmit={selectTag} + shouldShowDisabledAndSelectedOption /> ); diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index 45738c376181..d7cabe144dd4 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -5,9 +5,10 @@ import React from 'react'; import {View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; -import DatePicker from '@components/DatePicker'; -import Form from '@components/Form'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import NewDatePicker from '@components/NewDatePicker'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; @@ -178,7 +179,7 @@ function AdditionalDetailsStep({walletAdditionalDetails, translate, currentUserP {translate('additionalDetailsStep.helpLink')} -
- - - - - - + ); diff --git a/src/pages/ErrorPage/NotFoundPage.js b/src/pages/ErrorPage/NotFoundPage.js index 9ada6b820e8e..aac2e6d613f9 100644 --- a/src/pages/ErrorPage/NotFoundPage.js +++ b/src/pages/ErrorPage/NotFoundPage.js @@ -1,16 +1,31 @@ +import PropTypes from 'prop-types'; import React from 'react'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ScreenWrapper from '@components/ScreenWrapper'; +const propTypes = { + /** Method to trigger when pressing back button of the header */ + onBackButtonPress: PropTypes.func, +}; + +const defaultProps = { + onBackButtonPress: undefined, +}; + // eslint-disable-next-line rulesdir/no-negated-variables -function NotFoundPage() { +function NotFoundPage({onBackButtonPress}) { return ( - + ); } NotFoundPage.displayName = 'NotFoundPage'; +NotFoundPage.propTypes = propTypes; +NotFoundPage.defaultProps = defaultProps; export default NotFoundPage; diff --git a/src/pages/LogOutPreviousUserPage.js b/src/pages/LogOutPreviousUserPage.js index 8fd0ec144a1f..df38c28e561a 100644 --- a/src/pages/LogOutPreviousUserPage.js +++ b/src/pages/LogOutPreviousUserPage.js @@ -5,10 +5,17 @@ import {Linking} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import * as SessionUtils from '@libs/SessionUtils'; +import Navigation from '@navigation/Navigation'; import * as Session from '@userActions/Session'; import ONYXKEYS from '@src/ONYXKEYS'; const propTypes = { + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** Whether the account data is loading */ + isLoading: PropTypes.bool, + }), + /** The data about the current session which will be set once the user is authenticated and we return to this component as an AuthScreen */ session: PropTypes.shape({ /** The user's email for the current session */ @@ -17,37 +24,43 @@ const propTypes = { }; const defaultProps = { + account: { + isLoading: false, + }, session: { email: null, }, }; function LogOutPreviousUserPage(props) { - useEffect( - () => { - Linking.getInitialURL().then((transitionURL) => { - const sessionEmail = props.session.email; - const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail); - - if (isLoggingInAsNewUser) { - Session.signOutAndRedirectToSignIn(); - } - - // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot - // and their authToken stored in Onyx becomes invalid. - // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot - // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken - const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true'; - if (shouldForceLogin) { - const email = lodashGet(props, 'route.params.email', ''); - const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', ''); - Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken); - } - }); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [], - ); + useEffect(() => { + Linking.getInitialURL().then((transitionURL) => { + const sessionEmail = props.session.email; + const isLoggingInAsNewUser = SessionUtils.isLoggingInAsNewUser(transitionURL, sessionEmail); + + if (isLoggingInAsNewUser) { + Session.signOutAndRedirectToSignIn(); + } + + // We need to signin and fetch a new authToken, if a user was already authenticated in NewDot, and was redirected to OldDot + // and their authToken stored in Onyx becomes invalid. + // This workflow is triggered while setting up VBBA. User is redirected from NewDot to OldDot to set up 2FA, and then redirected back to NewDot + // On Enabling 2FA, authToken stored in Onyx becomes expired and hence we need to fetch new authToken + const shouldForceLogin = lodashGet(props, 'route.params.shouldForceLogin', '') === 'true'; + if (shouldForceLogin) { + const email = lodashGet(props, 'route.params.email', ''); + const shortLivedAuthToken = lodashGet(props, 'route.params.shortLivedAuthToken', ''); + Session.signInWithShortLivedAuthToken(email, shortLivedAuthToken); + } + + const exitTo = lodashGet(props, 'route.params.exitTo', ''); + if (exitTo && !props.account.isLoading && !isLoggingInAsNewUser) { + Navigation.isNavigationReady().then(() => { + Navigation.navigate(exitTo); + }); + } + }); + }, [props]); return ; } @@ -57,6 +70,9 @@ LogOutPreviousUserPage.defaultProps = defaultProps; LogOutPreviousUserPage.displayName = 'LogOutPreviousUserPage'; export default withOnyx({ + account: { + key: ONYXKEYS.ACCOUNT, + }, session: { key: ONYXKEYS.SESSION, }, diff --git a/src/pages/NewChatPage.js b/src/pages/NewChatPage.js index 90b2615f901c..aae61b100cd7 100755 --- a/src/pages/NewChatPage.js +++ b/src/pages/NewChatPage.js @@ -254,6 +254,8 @@ function NewChatPage({betas, isGroupChat, personalDetails, reports, translate, i shouldPreventDefaultFocusOnSelectRow={!Browser.isMobile()} shouldShowOptions={isOptionsDataReady} shouldShowConfirmButton + shouldShowReferralCTA + referralContentType={CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT} confirmButtonText={selectedOptions.length > 1 ? translate('newChatPage.createGroup') : translate('newChatPage.createChat')} textInputAlert={isOffline ? `${translate('common.youAppearToBeOffline')} ${translate('search.resultsAreLimited')}` : ''} onConfirmSelection={createGroup} diff --git a/src/pages/ReferralDetailsPage.js b/src/pages/ReferralDetailsPage.js new file mode 100644 index 000000000000..36fc74836d58 --- /dev/null +++ b/src/pages/ReferralDetailsPage.js @@ -0,0 +1,116 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import _ from 'underscore'; +import Button from '@components/Button'; +import CopyTextToClipboard from '@components/CopyTextToClipboard'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import Icon from '@components/Icon'; +import {PaymentHands} from '@components/Icon/Illustrations'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import Navigation from '@libs/Navigation/Navigation'; +import styles from '@styles/styles'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +const propTypes = { + /** Navigation route context info provided by react navigation */ + route: PropTypes.shape({ + params: PropTypes.shape({ + /** The type of the content from where CTA was called */ + contentType: PropTypes.string, + }), + }).isRequired, + + /** The details about the account that the user is signing in with */ + account: PropTypes.shape({ + /** The primaryLogin associated with the account */ + primaryLogin: PropTypes.string, + }), +}; + +const defaultProps = { + account: null, +}; + +function ReferralDetailsPage({route, account}) { + const {translate} = useLocalize(); + let {contentType} = route.params; + + if (!_.includes(_.values(CONST.REFERRAL_PROGRAM.CONTENT_TYPES), contentType)) { + contentType = CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND; + } + const contentHeader = translate(`referralProgram.${contentType}.header`); + const contentBody = translate(`referralProgram.${contentType}.body`); + + function generateReferralURL(email) { + return `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(email)}`; + } + + function getFallbackRoute() { + const fallbackRoutes = { + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.REQUEST), + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SEND), + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: ROUTES.NEW_CHAT, + [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: ROUTES.SEARCH, + }; + + return fallbackRoutes[contentType]; + } + + return ( + + Navigation.goBack(getFallbackRoute())} + /> + + + {contentHeader} + {contentBody} + {contentType === CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND && ( + + + + )} + {translate('requestorStep.learnMore')} + + +