diff --git a/.eslintrc.js b/.eslintrc.js index 761a62b8314b..5f450f3ae6c2 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -291,5 +291,11 @@ module.exports = { 'rulesdir/use-periods-for-error-messages': 'error', }, }, + { + files: ['*.ts', '*.tsx'], + rules: { + 'rulesdir/prefer-at': 'error', + }, + }, ], }; diff --git a/.github/ISSUE_TEMPLATE/Standard.md b/.github/ISSUE_TEMPLATE/Standard.md index 5c96d8736bcd..663c6004a534 100644 --- a/.github/ISSUE_TEMPLATE/Standard.md +++ b/.github/ISSUE_TEMPLATE/Standard.md @@ -43,8 +43,10 @@ Which of our officially supported platforms is this issue occurring on? ## Screenshots/Videos -Add any screenshot/video evidence +
+ Add any screenshot/video evidence +
[View all open jobs on GitHub](https://github.com/Expensify/App/issues?q=is%3Aopen+is%3Aissue+label%3A%22Help+Wanted%22) diff --git a/.github/actions/composite/buildAndroidE2EAPK/action.yml b/.github/actions/composite/buildAndroidE2EAPK/action.yml deleted file mode 100644 index b86b68cc7d7d..000000000000 --- a/.github/actions/composite/buildAndroidE2EAPK/action.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Build an Android apk for e2e tests -description: Build an Android apk for an E2E test build and upload it as an artifact - -inputs: - ARTIFACT_NAME: - description: The name of the workflow artifact where the APK should be uploaded - required: true - ARTIFACT_RETENTION_DAYS: - description: The number of days to retain the artifact - required: false - # Thats github default: - default: "90" - PACKAGE_SCRIPT_NAME: - description: The name of the npm script to run to build the APK - required: true - APP_OUTPUT_PATH: - description: The path to the built APK - required: true - MAPBOX_SDK_DOWNLOAD_TOKEN: - description: The token to use to download the MapBox SDK - required: true - PATH_ENV_FILE: - description: The path to the .env file to use for the build - required: true - EXPENSIFY_PARTNER_NAME: - description: The name of the Expensify partner to use for the build - required: true - EXPENSIFY_PARTNER_PASSWORD: - description: The password of the Expensify partner to use for the build - required: true - EXPENSIFY_PARTNER_USER_ID: - description: The user ID of the Expensify partner to use for the build - required: true - EXPENSIFY_PARTNER_USER_SECRET: - description: The user secret of the Expensify partner to use for the build - required: true - EXPENSIFY_PARTNER_PASSWORD_EMAIL: - description: The email address of the Expensify partner to use for the build - required: true - SLACK_WEBHOOK_URL: - description: 'URL of the slack webhook' - required: true - -runs: - using: composite - steps: - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ inputs.MAPBOX_SDK_DOWNLOAD_TOKEN }} - shell: bash - - - uses: Expensify/App/.github/actions/composite/setupNode@main - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: "oracle" - java-version: "17" - - - uses: ruby/setup-ruby@v1.190.0 - with: - bundler-cache: true - - - uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef - - - name: Append environment variables to env file - shell: bash - run: | - echo "EXPENSIFY_PARTNER_NAME=${{ inputs.EXPENSIFY_PARTNER_NAME }}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD=${{ inputs.EXPENSIFY_PARTNER_PASSWORD }}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_ID=${{ inputs.EXPENSIFY_PARTNER_USER_ID }}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_USER_SECRET=${{ inputs.EXPENSIFY_PARTNER_USER_SECRET }}" >> ${{ inputs.PATH_ENV_FILE }} - echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${{ inputs.EXPENSIFY_PARTNER_PASSWORD_EMAIL }}" >> ${{ inputs.PATH_ENV_FILE }} - - - name: Build APK - run: npm run ${{ inputs.PACKAGE_SCRIPT_NAME }} - shell: bash - env: - RUBYOPT: '-rostruct' - - - name: Announce failed workflow in Slack - if: failure() - uses: 8398a7/action-slack@v3 - with: - status: custom - custom_payload: | - { - channel: '#e2e-announce', - attachments: [{ - color: 'danger', - text: `šŸš§ ${process.env.AS_REPO} E2E APK build run failed on workflow šŸš§`, - }] - } - env: - GITHUB_TOKEN: ${{ github.token }} - SLACK_WEBHOOK_URL: ${{ inputs.SLACK_WEBHOOK_URL }} - - - name: Upload APK - uses: actions/upload-artifact@v4 - with: - name: ${{ inputs.ARTIFACT_NAME }} - path: ${{ inputs.APP_OUTPUT_PATH }} - retention-days: ${{ inputs.ARTIFACT_RETENTION_DAYS }} diff --git a/.github/actions/composite/setupGitForOSBotify/action.yml b/.github/actions/composite/setupGitForOSBotify/action.yml index 0c06e2f4e169..c61fa7e934fd 100644 --- a/.github/actions/composite/setupGitForOSBotify/action.yml +++ b/.github/actions/composite/setupGitForOSBotify/action.yml @@ -20,7 +20,7 @@ runs: - name: Set up git for OSBotify shell: bash run: | - git config user.signingkey 367811D53E34168C + git config user.signingkey AEE1036472A782AB git config commit.gpgsign true git config user.name OSBotify git config user.email infra+osbotify@expensify.com diff --git a/.github/actions/composite/setupGitForOSBotifyApp/action.yml b/.github/actions/composite/setupGitForOSBotifyApp/action.yml index a6c487705c56..404ddc55e954 100644 --- a/.github/actions/composite/setupGitForOSBotifyApp/action.yml +++ b/.github/actions/composite/setupGitForOSBotifyApp/action.yml @@ -50,7 +50,7 @@ runs: - name: Set up git for OSBotify shell: bash run: | - git config user.signingkey 367811D53E34168C + git config user.signingkey AEE1036472A782AB git config commit.gpgsign true git config user.name OSBotify git config user.email infra+osbotify@expensify.com diff --git a/.github/actions/javascript/authorChecklist/authorChecklist.ts b/.github/actions/javascript/authorChecklist/authorChecklist.ts index f855c135cd39..f8c775a84930 100644 --- a/.github/actions/javascript/authorChecklist/authorChecklist.ts +++ b/.github/actions/javascript/authorChecklist/authorChecklist.ts @@ -54,8 +54,8 @@ function partitionWithChecklist(body: string): string[] { async function getNumberOfItemsFromAuthorChecklist(): Promise { const response = await fetch(pathToAuthorChecklist); const fileContents = await response.text(); - const checklist = partitionWithChecklist(fileContents)[1]; - const numberOfChecklistItems = (checklist.match(/\[ \]/g) ?? []).length; + const checklist = partitionWithChecklist(fileContents).at(1); + const numberOfChecklistItems = (checklist?.match(/\[ \]/g) ?? []).length ?? 0; return numberOfChecklistItems; } diff --git a/.github/actions/javascript/authorChecklist/index.js b/.github/actions/javascript/authorChecklist/index.js index e09b95d572ff..22d8805e2201 100644 --- a/.github/actions/javascript/authorChecklist/index.js +++ b/.github/actions/javascript/authorChecklist/index.js @@ -16771,8 +16771,8 @@ function partitionWithChecklist(body) { async function getNumberOfItemsFromAuthorChecklist() { const response = await fetch(pathToAuthorChecklist); const fileContents = await response.text(); - const checklist = partitionWithChecklist(fileContents)[1]; - const numberOfChecklistItems = (checklist.match(/\[ \]/g) ?? []).length; + const checklist = partitionWithChecklist(fileContents).at(1); + const numberOfChecklistItems = (checklist?.match(/\[ \]/g) ?? []).length ?? 0; return numberOfChecklistItems; } function checkPRForCompletedChecklist(expectedNumberOfChecklistItems, checklist) { @@ -17180,7 +17180,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -17258,7 +17262,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -17342,7 +17346,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -17416,7 +17420,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -17484,7 +17488,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/awaitStagingDeploys/index.js b/.github/actions/javascript/awaitStagingDeploys/index.js index 561cc980a4e5..cfc7e8b4cc4a 100644 --- a/.github/actions/javascript/awaitStagingDeploys/index.js +++ b/.github/actions/javascript/awaitStagingDeploys/index.js @@ -12421,7 +12421,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -12499,7 +12503,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -12583,7 +12587,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12657,7 +12661,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12725,7 +12729,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/bumpVersion/index.js b/.github/actions/javascript/bumpVersion/index.js index 43bd09558c26..c96bc1bcf884 100644 --- a/.github/actions/javascript/bumpVersion/index.js +++ b/.github/actions/javascript/bumpVersion/index.js @@ -3536,7 +3536,7 @@ exports.updateAndroidVersion = updateAndroidVersion; * Updates the CFBundleShortVersionString and the CFBundleVersion. */ function updateiOSVersion(version) { - const shortVersion = version.split('-')[0]; + const shortVersion = version.split('-').at(0); const cfVersion = version.includes('-') ? version.replace('-', '.') : `${version}.0`; console.log('Updating iOS', `CFBundleShortVersionString: ${shortVersion}`, `CFBundleVersion: ${cfVersion}`); // Update Plists diff --git a/.github/actions/javascript/checkDeployBlockers/index.js b/.github/actions/javascript/checkDeployBlockers/index.js index 74cd1509fbfa..1e9626511e5e 100644 --- a/.github/actions/javascript/checkDeployBlockers/index.js +++ b/.github/actions/javascript/checkDeployBlockers/index.js @@ -11704,7 +11704,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11782,7 +11786,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11866,7 +11870,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11940,7 +11944,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12008,7 +12012,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts index caff455e9fa5..1964b143146d 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/createOrUpdateStagingDeploy.ts @@ -29,15 +29,24 @@ async function run(): Promise { // Look at the state of the most recent StagingDeployCash, // if it is open then we'll update the existing one, otherwise, we'll create a new one. - const mostRecentChecklist = recentDeployChecklists[0]; + const mostRecentChecklist = recentDeployChecklists.at(0); + + if (!mostRecentChecklist) { + throw new Error('Could not find the most recent checklist'); + } + const shouldCreateNewDeployChecklist = mostRecentChecklist.state !== 'open'; - const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists[1]; + const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists.at(1); if (shouldCreateNewDeployChecklist) { console.log('Latest StagingDeployCash is closed, creating a new one.', mostRecentChecklist); } else { console.log('Latest StagingDeployCash is open, updating it instead of creating a new one.', 'Current:', mostRecentChecklist, 'Previous:', previousChecklist); } + if (!previousChecklist) { + throw new Error('Could not find the previous checklist'); + } + // Parse the data from the previous and current checklists into the format used to generate the checklist const previousChecklistData = GithubUtils.getStagingDeployCashData(previousChecklist); const currentChecklistData: StagingDeployCashData | undefined = shouldCreateNewDeployChecklist ? undefined : GithubUtils.getStagingDeployCashData(mostRecentChecklist); diff --git a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js index d3e249ee6f47..1b7cccb730ff 100644 --- a/.github/actions/javascript/createOrUpdateStagingDeploy/index.js +++ b/.github/actions/javascript/createOrUpdateStagingDeploy/index.js @@ -14229,15 +14229,21 @@ async function run() { }); // Look at the state of the most recent StagingDeployCash, // if it is open then we'll update the existing one, otherwise, we'll create a new one. - const mostRecentChecklist = recentDeployChecklists[0]; + const mostRecentChecklist = recentDeployChecklists.at(0); + if (!mostRecentChecklist) { + throw new Error('Could not find the most recent checklist'); + } const shouldCreateNewDeployChecklist = mostRecentChecklist.state !== 'open'; - const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists[1]; + const previousChecklist = shouldCreateNewDeployChecklist ? mostRecentChecklist : recentDeployChecklists.at(1); if (shouldCreateNewDeployChecklist) { console.log('Latest StagingDeployCash is closed, creating a new one.', mostRecentChecklist); } else { console.log('Latest StagingDeployCash is open, updating it instead of creating a new one.', 'Current:', mostRecentChecklist, 'Previous:', previousChecklist); } + if (!previousChecklist) { + throw new Error('Could not find the previous checklist'); + } // Parse the data from the previous and current checklists into the format used to generate the checklist const previousChecklistData = GithubUtils_1.default.getStagingDeployCashData(previousChecklist); const currentChecklistData = shouldCreateNewDeployChecklist ? undefined : GithubUtils_1.default.getStagingDeployCashData(mostRecentChecklist); @@ -14735,7 +14741,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -14813,7 +14823,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -14897,7 +14907,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -14971,7 +14981,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -15039,7 +15049,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getArtifactInfo/index.js b/.github/actions/javascript/getArtifactInfo/index.js index 82bf90ef6d2b..76cacef0221f 100644 --- a/.github/actions/javascript/getArtifactInfo/index.js +++ b/.github/actions/javascript/getArtifactInfo/index.js @@ -11665,7 +11665,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11743,7 +11747,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11827,7 +11831,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11901,7 +11905,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11969,7 +11973,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getDeployPullRequestList/index.js b/.github/actions/javascript/getDeployPullRequestList/index.js index 918d631778d3..cde96b76b6e6 100644 --- a/.github/actions/javascript/getDeployPullRequestList/index.js +++ b/.github/actions/javascript/getDeployPullRequestList/index.js @@ -12027,7 +12027,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -12105,7 +12109,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -12189,7 +12193,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12263,7 +12267,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12331,7 +12335,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/getPullRequestDetails/index.js b/.github/actions/javascript/getPullRequestDetails/index.js index 8580842b380c..b1c096ed0be8 100644 --- a/.github/actions/javascript/getPullRequestDetails/index.js +++ b/.github/actions/javascript/getPullRequestDetails/index.js @@ -11767,7 +11767,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11845,7 +11849,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11929,7 +11933,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12003,7 +12007,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12071,7 +12075,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/isStagingDeployLocked/index.js b/.github/actions/javascript/isStagingDeployLocked/index.js index 9e823e8da5ae..d7196cad32f7 100644 --- a/.github/actions/javascript/isStagingDeployLocked/index.js +++ b/.github/actions/javascript/isStagingDeployLocked/index.js @@ -11665,7 +11665,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11743,7 +11747,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11827,7 +11831,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11901,7 +11905,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11969,7 +11973,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/index.js b/.github/actions/javascript/markPullRequestsAsDeployed/index.js index b38b04141395..62d326c9af3a 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/index.js +++ b/.github/actions/javascript/markPullRequestsAsDeployed/index.js @@ -12742,7 +12742,10 @@ async function run() { labels: CONST_1.default.LABELS.STAGING_DEPLOY, state: 'closed', }); - const previousChecklistID = deployChecklists[0].number; + const previousChecklistID = deployChecklists.at(0)?.number; + if (!previousChecklistID) { + throw new Error('Could not find the previous checklist ID'); + } // who closed the last deploy checklist? const deployer = await GithubUtils_1.default.getActorWhoClosedIssue(previousChecklistID); // Create comment on each pull request (one at a time to avoid throttling issues) @@ -13058,7 +13061,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -13136,7 +13143,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -13220,7 +13227,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -13294,7 +13301,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -13362,7 +13369,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts index e6424c89833a..9c2defebd01d 100644 --- a/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts +++ b/.github/actions/javascript/markPullRequestsAsDeployed/markPullRequestsAsDeployed.ts @@ -89,7 +89,10 @@ async function run() { labels: CONST.LABELS.STAGING_DEPLOY, state: 'closed', }); - const previousChecklistID = deployChecklists[0].number; + const previousChecklistID = deployChecklists.at(0)?.number; + if (!previousChecklistID) { + throw new Error('Could not find the previous checklist ID'); + } // who closed the last deploy checklist? const deployer = await GithubUtils.getActorWhoClosedIssue(previousChecklistID); diff --git a/.github/actions/javascript/postTestBuildComment/index.js b/.github/actions/javascript/postTestBuildComment/index.js index 4f62879a4419..265d62c4b321 100644 --- a/.github/actions/javascript/postTestBuildComment/index.js +++ b/.github/actions/javascript/postTestBuildComment/index.js @@ -11764,7 +11764,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11842,7 +11846,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11926,7 +11930,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12000,7 +12004,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12068,7 +12072,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/proposalPoliceComment/index.js b/.github/actions/javascript/proposalPoliceComment/index.js index 9b5b56f11a11..2a41f49f654f 100644 --- a/.github/actions/javascript/proposalPoliceComment/index.js +++ b/.github/actions/javascript/proposalPoliceComment/index.js @@ -18026,7 +18026,7 @@ async function run() { if (assistantResponse.includes(`[${CONST_1.default.NO_ACTION}]`)) { // extract the text after [NO_ACTION] from assistantResponse since this is a // bot related action keyword - const noActionContext = assistantResponse.split(`[${CONST_1.default.NO_ACTION}] `)?.[1]?.replace('"', ''); + const noActionContext = assistantResponse.split(`[${CONST_1.default.NO_ACTION}] `).at(1)?.replace('"', ''); console.log('[NO_ACTION] w/ context: ', noActionContext); return; } @@ -18047,10 +18047,10 @@ async function run() { else if (assistantResponse.includes('[EDIT_COMMENT]') && !payload.comment?.body.includes('Edited by **proposal-police**')) { // extract the text after [EDIT_COMMENT] from assistantResponse since this is a // bot related action keyword - let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ')?.[1]?.replace('"', ''); + let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ').at(1)?.replace('"', ''); // format the date like: 2024-01-24 13:15:24 UTC not 2024-01-28 18:18:28.000 UTC - const formattedDate = `${date.toISOString()?.split('.')?.[0]?.replace('T', ' ')} UTC`; - extractedNotice = extractedNotice.replace('{updated_timestamp}', formattedDate); + const formattedDate = `${date.toISOString()?.split('.').at(0)?.replace('T', ' ')} UTC`; + extractedNotice = extractedNotice?.replace('{updated_timestamp}', formattedDate); console.log('ProposalPoliceā„¢ editing issue comment...', payload.comment.id); await GithubUtils_1.default.octokit.issues.updateComment({ ...github_1.context.repo, @@ -18253,7 +18253,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -18331,7 +18335,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -18415,7 +18419,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -18489,7 +18493,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -18557,7 +18561,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts index 19d3037a80a5..94d79a504653 100644 --- a/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts +++ b/.github/actions/javascript/proposalPoliceComment/proposalPoliceComment.ts @@ -64,7 +64,7 @@ async function run() { if (assistantResponse.includes(`[${CONST.NO_ACTION}]`)) { // extract the text after [NO_ACTION] from assistantResponse since this is a // bot related action keyword - const noActionContext = assistantResponse.split(`[${CONST.NO_ACTION}] `)?.[1]?.replace('"', ''); + const noActionContext = assistantResponse.split(`[${CONST.NO_ACTION}] `).at(1)?.replace('"', ''); console.log('[NO_ACTION] w/ context: ', noActionContext); return; } @@ -88,10 +88,10 @@ async function run() { } else if (assistantResponse.includes('[EDIT_COMMENT]') && !payload.comment?.body.includes('Edited by **proposal-police**')) { // extract the text after [EDIT_COMMENT] from assistantResponse since this is a // bot related action keyword - let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ')?.[1]?.replace('"', ''); + let extractedNotice = assistantResponse.split('[EDIT_COMMENT] ').at(1)?.replace('"', ''); // format the date like: 2024-01-24 13:15:24 UTC not 2024-01-28 18:18:28.000 UTC - const formattedDate = `${date.toISOString()?.split('.')?.[0]?.replace('T', ' ')} UTC`; - extractedNotice = extractedNotice.replace('{updated_timestamp}', formattedDate); + const formattedDate = `${date.toISOString()?.split('.').at(0)?.replace('T', ' ')} UTC`; + extractedNotice = extractedNotice?.replace('{updated_timestamp}', formattedDate); console.log('ProposalPoliceā„¢ editing issue comment...', payload.comment.id); await GithubUtils.octokit.issues.updateComment({ ...context.repo, diff --git a/.github/actions/javascript/reopenIssueWithComment/index.js b/.github/actions/javascript/reopenIssueWithComment/index.js index 83131f363ef8..9c97e3c612a9 100644 --- a/.github/actions/javascript/reopenIssueWithComment/index.js +++ b/.github/actions/javascript/reopenIssueWithComment/index.js @@ -11675,7 +11675,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11753,7 +11757,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11837,7 +11841,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11911,7 +11915,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -11979,7 +11983,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/reviewerChecklist/index.js b/.github/actions/javascript/reviewerChecklist/index.js index 2a0977db8016..93a3ccf1a0f3 100644 --- a/.github/actions/javascript/reviewerChecklist/index.js +++ b/.github/actions/javascript/reviewerChecklist/index.js @@ -11549,14 +11549,14 @@ function checkIssueForCompletedChecklist(numberOfChecklistItems) { break; } const whitespace = /([\n\r])/gm; - const comment = combinedComments[i].replace(whitespace, ''); - console.log(`Comment ${i} starts with: ${comment.slice(0, 20)}...`); + const comment = combinedComments.at(i)?.replace(whitespace, ''); + console.log(`Comment ${i} starts with: ${comment?.slice(0, 20)}...`); // Found the reviewer checklist, so count how many completed checklist items there are - if (comment.indexOf(reviewerChecklistContains) !== -1) { + if (comment?.indexOf(reviewerChecklistContains) !== -1) { console.log('Found the reviewer checklist!'); foundReviewerChecklist = true; - numberOfFinishedChecklistItems = (comment.match(/- \[x\]/gi) ?? []).length; - numberOfUnfinishedChecklistItems = (comment.match(/- \[ \]/g) ?? []).length; + numberOfFinishedChecklistItems = (comment?.match(/- \[x\]/gi) ?? []).length; + numberOfUnfinishedChecklistItems = (comment?.match(/- \[ \]/g) ?? []).length; } } if (!foundReviewerChecklist) { @@ -11767,7 +11767,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11845,7 +11849,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11929,7 +11933,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -12003,7 +12007,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12071,7 +12075,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts index f57ef6c36a04..2d2f3978fa1d 100644 --- a/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts +++ b/.github/actions/javascript/reviewerChecklist/reviewerChecklist.ts @@ -55,16 +55,16 @@ function checkIssueForCompletedChecklist(numberOfChecklistItems: number) { } const whitespace = /([\n\r])/gm; - const comment = combinedComments[i].replace(whitespace, ''); + const comment = combinedComments.at(i)?.replace(whitespace, ''); - console.log(`Comment ${i} starts with: ${comment.slice(0, 20)}...`); + console.log(`Comment ${i} starts with: ${comment?.slice(0, 20)}...`); // Found the reviewer checklist, so count how many completed checklist items there are - if (comment.indexOf(reviewerChecklistContains) !== -1) { + if (comment?.indexOf(reviewerChecklistContains) !== -1) { console.log('Found the reviewer checklist!'); foundReviewerChecklist = true; - numberOfFinishedChecklistItems = (comment.match(/- \[x\]/gi) ?? []).length; - numberOfUnfinishedChecklistItems = (comment.match(/- \[ \]/g) ?? []).length; + numberOfFinishedChecklistItems = (comment?.match(/- \[x\]/gi) ?? []).length; + numberOfUnfinishedChecklistItems = (comment?.match(/- \[ \]/g) ?? []).length; } } diff --git a/.github/actions/javascript/validateReassureOutput/index.js b/.github/actions/javascript/validateReassureOutput/index.js index 99881e8ad9db..9eff1ee3101e 100644 --- a/.github/actions/javascript/validateReassureOutput/index.js +++ b/.github/actions/javascript/validateReassureOutput/index.js @@ -2735,7 +2735,10 @@ const run = () => { } console.log(`Processing ${regressionOutput.countChanged.length} measurements...`); for (let i = 0; i < regressionOutput.countChanged.length; i++) { - const measurement = regressionOutput.countChanged[i]; + const measurement = regressionOutput.countChanged.at(i); + if (!measurement) { + continue; + } const baseline = measurement.baseline; const current = measurement.current; console.log(`Processing measurement ${i + 1}: ${measurement.name}`); diff --git a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts index 7e5cfd0bd9f9..24901ca55aee 100644 --- a/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts +++ b/.github/actions/javascript/validateReassureOutput/validateReassureOutput.ts @@ -15,7 +15,12 @@ const run = (): boolean => { console.log(`Processing ${regressionOutput.countChanged.length} measurements...`); for (let i = 0; i < regressionOutput.countChanged.length; i++) { - const measurement = regressionOutput.countChanged[i]; + const measurement = regressionOutput.countChanged.at(i); + + if (!measurement) { + continue; + } + const baseline: MeasureEntry = measurement.baseline; const current: MeasureEntry = measurement.current; diff --git a/.github/actions/javascript/verifySignedCommits/index.js b/.github/actions/javascript/verifySignedCommits/index.js index 49a4341b84af..8920086eea46 100644 --- a/.github/actions/javascript/verifySignedCommits/index.js +++ b/.github/actions/javascript/verifySignedCommits/index.js @@ -11707,7 +11707,11 @@ class GithubUtils { if (data.length > 1) { throw new Error(`Found more than one ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + if (!issue) { + throw new Error(`Found an undefined ${CONST_1.default.LABELS.STAGING_DEPLOY} issue.`); + } + return this.getStagingDeployCashData(issue); }); } /** @@ -11785,7 +11789,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST_1.default.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -11869,7 +11873,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers) { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate(this.octokit.pulls.list, { owner: CONST_1.default.GITHUB_OWNER, repo: CONST_1.default.APP_REPO, @@ -11943,7 +11947,7 @@ class GithubUtils { repo: CONST_1.default.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** * Generate the URL of an New Expensify pull request given the PR number. @@ -12011,7 +12015,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** * Given an artifact ID, returns the download URL to a zip file containing the artifact. diff --git a/.github/libs/GithubUtils.ts b/.github/libs/GithubUtils.ts index 36363684a351..ae74621b356a 100644 --- a/.github/libs/GithubUtils.ts +++ b/.github/libs/GithubUtils.ts @@ -168,7 +168,13 @@ class GithubUtils { throw new Error(`Found more than one ${CONST.LABELS.STAGING_DEPLOY} issue.`); } - return this.getStagingDeployCashData(data[0]); + const issue = data.at(0); + + if (!issue) { + throw new Error(`Found an undefined ${CONST.LABELS.STAGING_DEPLOY} issue.`); + } + + return this.getStagingDeployCashData(issue); }); } @@ -254,7 +260,7 @@ class GithubUtils { } internalQASection = internalQASection[1]; const internalQAPRs = [...internalQASection.matchAll(new RegExp(`- \\[([ x])]\\s(${CONST.PULL_REQUEST_REGEX.source})`, 'g'))].map((match) => ({ - url: match[2].split('-')[0].trim(), + url: match[2].split('-').at(0)?.trim() ?? '', number: Number.parseInt(match[3], 10), isResolved: match[1] === 'x', })); @@ -367,7 +373,7 @@ class GithubUtils { * Fetch all pull requests given a list of PR numbers. */ static fetchAllPullRequests(pullRequestNumbers: number[]): Promise { - const oldestPR = pullRequestNumbers.sort((a, b) => a - b)[0]; + const oldestPR = pullRequestNumbers.sort((a, b) => a - b).at(0); return this.paginate( this.octokit.pulls.list, { @@ -459,7 +465,7 @@ class GithubUtils { repo: CONST.APP_REPO, workflow_id: workflow, }) - .then((response) => response.data.workflow_runs[0]?.id); + .then((response) => response.data.workflow_runs.at(0)?.id ?? -1); } /** @@ -533,7 +539,7 @@ class GithubUtils { per_page: 1, name: artifactName, }) - .then((response) => response.data.artifacts[0]); + .then((response) => response.data.artifacts.at(0)); } /** diff --git a/.github/libs/nativeVersionUpdater.ts b/.github/libs/nativeVersionUpdater.ts index 4ecf9d64966c..1684614b059e 100644 --- a/.github/libs/nativeVersionUpdater.ts +++ b/.github/libs/nativeVersionUpdater.ts @@ -59,7 +59,7 @@ function updateAndroidVersion(versionName: string, versionCode: string): Promise * Updates the CFBundleShortVersionString and the CFBundleVersion. */ function updateiOSVersion(version: string): string { - const shortVersion = version.split('-')[0]; + const shortVersion = version.split('-').at(0); const cfVersion = version.includes('-') ? version.replace('-', '.') : `${version}.0`; console.log('Updating iOS', `CFBundleShortVersionString: ${shortVersion}`, `CFBundleVersion: ${cfVersion}`); diff --git a/.github/scripts/createDocsRoutes.ts b/.github/scripts/createDocsRoutes.ts index a8ac3d511ff9..264422e27b99 100644 --- a/.github/scripts/createDocsRoutes.ts +++ b/.github/scripts/createDocsRoutes.ts @@ -93,7 +93,10 @@ function pushOrCreateEntry(hubs: Hub[], hub: string, } function getOrderFromArticleFrontMatter(path: string): number | undefined { - const frontmatter = fs.readFileSync(path, 'utf8').split('---')[1]; + const frontmatter = fs.readFileSync(path, 'utf8').split('---').at(1); + if (!frontmatter) { + return; + } const frontmatterObject = yaml.load(frontmatter) as Record; return frontmatterObject.order as number | undefined; } diff --git a/.github/workflows/OSBotify-private-key.asc.gpg b/.github/workflows/OSBotify-private-key.asc.gpg index c19d5c97866c..03f06222d0fe 100644 Binary files a/.github/workflows/OSBotify-private-key.asc.gpg and b/.github/workflows/OSBotify-private-key.asc.gpg differ diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml new file mode 100644 index 000000000000..403a40214b40 --- /dev/null +++ b/.github/workflows/buildAndroid.yml @@ -0,0 +1,180 @@ +name: Build Android app + +on: + workflow_call: + inputs: + type: + description: 'What type of build to run. Must be one of ["release", "adhoc", "e2e", "e2eDelta"]' + type: string + required: true + ref: + description: Git ref to checkout and build + type: string + required: true + artifact-prefix: + description: 'The prefix for build artifact names. This is useful if you need to call multiple builds from the same workflow' + type: string + required: false + default: '' + pull_request_number: + description: The pull request number associated with this build, if relevant. + type: string + required: false + outputs: + AAB_FILE_NAME: + value: ${{ jobs.build.outputs.AAB_FILE_NAME }} + APK_FILE_NAME: + value: ${{ jobs.build.outputs.APK_FILE_NAME }} + + workflow_dispatch: + inputs: + type: + description: What type of build do you want to run? + required: true + type: choice + options: + - release + - adhoc + - e2e + - e2eDelta + ref: + description: Git ref to checkout and build + required: true + type: string + + pull_request_number: + description: The pull request number associated with this build, if relevant. + type: number + required: false + +jobs: + build: + name: Build Android app + runs-on: ubuntu-latest-xl + env: + RUBYOPT: '-rostruct' + outputs: + AAB_FILE_NAME: ${{ steps.build.outputs.AAB_FILE_NAME }} + APK_FILE_NAME: ${{ steps.build.outputs.APK_FILE_NAME }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + uses: ./.github/actions/composite/setupNode + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: oracle + java-version: 17 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Decrypt keystore to sign the APK/AAB + run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output my-upload-key.keystore my-upload-key.keystore.gpg + working-directory: android/app + + - name: Get package version + id: getPackageVersion + run: echo "VERSION=$(jq -r .version < package.json)" >> "$GITHUB_OUTPUT" + + - name: Get Android native version + id: getAndroidVersion + run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" + + - name: Setup DotEnv + if: ${{ inputs.type != 'release' }} + run: | + if [ '${{ inputs.type }}' == 'adhoc' ]; then + cp .env.staging .env.adhoc + sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc + echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc + else + envFile='' + if [ '${{ inputs.type }}' == 'e2e' ]; then + envFile='tests/e2e/.env.e2e' + else + envFile=tests/e2e/.env.e2edelta + fi + { + echo "EXPENSIFY_PARTNER_NAME=${{ secrets.EXPENSIFY_PARTNER_NAME }}" + echo "EXPENSIFY_PARTNER_PASSWORD=${{ secrets.EXPENSIFY_PARTNER_PASSWORD }}" + echo "EXPENSIFY_PARTNER_USER_ID=${{ secrets.EXPENSIFY_PARTNER_USER_ID }}" + echo "EXPENSIFY_PARTNER_USER_SECRET=${{ secrets.EXPENSIFY_PARTNER_USER_SECRET }}" + echo "EXPENSIFY_PARTNER_PASSWORD_EMAIL=${{ secrets.EXPENSIFY_PARTNER_PASSWORD_EMAIL }}" + } >> "$envFile" + fi + + - name: Build Android app + id: build + run: | + lane='' + case '${{ inputs.type }}' in + 'release') + lane='build';; + 'adhoc') + lane='build_adhoc';; + 'e2e') + lane='build_e2e';; + 'e2eDelta') + lane='build_e2eDelta';; + esac + bundle exec fastlane android "$lane" + + # Refresh environment variables from GITHUB_ENV that are updated when running fastlane + # shellcheck disable=SC1090 + source "$GITHUB_ENV" + + SHOULD_UPLOAD_SOURCEMAPS='false' + if [ -f ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map ]; then + SHOULD_UPLOAD_SOURCEMAPS='true' + fi + + { + # aabPath and apkPath are environment varibles set within the Fastfile + echo "AAB_PATH=$aabPath" + echo "AAB_FILE_NAME=$(basename "$aabPath")" + echo "APK_PATH=$apkPath" + echo "APK_FILE_NAME=$(basename "$apkPath")" + echo "SHOULD_UPLOAD_SOURCEMAPS=$SHOULD_UPLOAD_SOURCEMAPS" + } >> "$GITHUB_OUTPUT" + env: + MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} + MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + + - name: Upload Android AAB artifact + if: ${{ steps.build.outputs.AAB_PATH != '' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-prefix }}android-artifact-aab + path: ${{ steps.build.outputs.AAB_PATH }} + + - name: Upload Android APK artifact + if: ${{ steps.build.outputs.APK_PATH != '' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-prefix }}android-artifact-apk + path: ${{ steps.build.outputs.APK_PATH }} + + - name: Upload Android sourcemaps artifact + if: ${{ steps.build.outputs.SHOULD_UPLOAD_SOURCEMAPS == 'true' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: ${{ inputs.artifact-prefix }}android-artifact-sourcemaps + path: ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99cd0c1dabc5..4ff1a2004d8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -67,84 +67,86 @@ jobs: needs: prep secrets: inherit - android: - name: Build and deploy Android + buildAndroid: + name: Build Android app + uses: ./.github/workflows/buildAndroid.yml + if: ${{ github.ref == 'refs/heads/staging' }} needs: prep - runs-on: ubuntu-latest-xl + secrets: inherit + with: + type: release + ref: staging + + uploadAndroid: + name: Upload Android build to Google Play Store + needs: buildAndroid + runs-on: ubuntu-latest env: RUBYOPT: '-rostruct' steps: - name: Checkout uses: actions/checkout@v4 - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'oracle' - java-version: '17' - - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - - name: Decrypt keystore - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt json key - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + - name: Download Android build artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/artifacts + pattern: android-artifact-* + merge-multiple: true - - name: Get Android native version - id: getAndroidVersion - run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" + - name: Log downloaded artifact paths + run: ls -R /tmp/artifacts - - name: Build Android app - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane android build - env: - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} + - name: Decrypt json w/ Google Play credentials + run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg + working-directory: android/app - name: Upload Android app to Google Play - run: bundle exec fastlane android ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'upload_google_play_production' || 'upload_google_play_internal' }} + run: bundle exec fastlane android upload_google_play_internal env: - VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} + aabPath: /tmp/artifacts/${{ needs.buildAndroid.outputs.AAB_FILE_NAME }} - name: Upload Android build to Browser Stack - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@./android/app/build/outputs/bundle/productionRelease/app-production-release.aab" + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/tmp/artifacts/${{ needs.buildAndroid.outputs.AAB_FILE_NAME }}" env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - name: Upload Android sourcemaps artifact - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v4 - with: - name: android-sourcemaps-artifact - path: ./android/app/build/generated/sourcemaps/react/productionRelease/index.android.bundle.map + submitAndroid: + name: Submit Android app for production review + needs: prep + if: ${{ github.ref == 'refs/heads/production' }} + runs-on: ubuntu-latest + env: + RUBYOPT: '-rostruct' + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Upload Android build artifact - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v4 + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 with: - name: android-build-artifact - path: ./android/app/build/outputs/bundle/productionRelease/app-production-release.aab + bundler-cache: true + + - name: Get Android native version + id: getAndroidVersion + run: echo "VERSION_CODE=$(grep -o 'versionCode\s\+[0-9]\+' android/app/build.gradle | awk '{ print $2 }')" >> "$GITHUB_OUTPUT" - - name: Set current App version in Env - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" + - name: Decrypt json w/ Google Play credentials + run: gpg --batch --yes --decrypt --passphrase="${{ secrets.LARGE_SECRET_PASSPHRASE }}" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg + working-directory: android/app + + - name: Submit Android build for review + run: bundle exec fastlane android upload_google_play_production + env: + VERSION: ${{ steps.getAndroidVersion.outputs.VERSION_CODE }} - name: Warn deployers if Android production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + if: ${{ failure() }} uses: 8398a7/action-slack@v3 with: status: custom @@ -266,9 +268,6 @@ jobs: env: LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - name: Set current App version in Env - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: Get iOS native version id: getIOSVersion run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT" @@ -381,9 +380,6 @@ jobs: env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - name: Set current App version in Env - run: echo "VERSION=$(npm run print-version --silent)" >> "$GITHUB_ENV" - - name: Verify staging deploy if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} run: ./.github/scripts/verifyDeploy.sh staging ${{ needs.prep.outputs.APP_VERSION }} @@ -419,7 +415,7 @@ jobs: name: Post a Slack message when any platform fails to build or deploy runs-on: ubuntu-latest if: ${{ failure() }} - needs: [android, desktop, iOS, web] + needs: [buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -444,16 +440,25 @@ jobs: runs-on: ubuntu-latest outputs: IS_AT_LEAST_ONE_PLATFORM_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastOnePlatform.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED }} - IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAtLeastAllPlatform.outputs.IS_ALL_PLATFORMS_DEPLOYED }} - needs: [android, desktop, iOS, web] + IS_ALL_PLATFORMS_DEPLOYED: ${{ steps.checkDeploymentSuccessOnAllPlatforms.outputs.IS_ALL_PLATFORMS_DEPLOYED }} + needs: [buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web] if: ${{ always() }} steps: - name: Check deployment success on at least one platform id: checkDeploymentSuccessOnAtLeastOnePlatform run: | isAtLeastOnePlatformDeployed="false" - if [ "${{ needs.android.result }}" == "success" ] || \ - [ "${{ needs.iOS.result }}" == "success" ] || \ + if [ ${{ github.ref }} == 'refs/heads/production' ]; then + if [ "${{ needs.submitAndroid.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" + fi + else + if [ "${{ needs.uploadAndroid.result }}" == "success" ]; then + isAtLeastOnePlatformDeployed="true" + fi + fi + + if [ "${{ needs.iOS.result }}" == "success" ] || \ [ "${{ needs.desktop.result }}" == "success" ] || \ [ "${{ needs.web.result }}" == "success" ]; then isAtLeastOnePlatformDeployed="true" @@ -462,15 +467,25 @@ jobs: echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED is $isAtLeastOnePlatformDeployed" - name: Check deployment success on all platforms - id: checkDeploymentSuccessOnAtLeastAllPlatform + id: checkDeploymentSuccessOnAllPlatforms run: | isAllPlatformsDeployed="false" - if [ "${{ needs.android.result }}" == "success" ] && \ - [ "${{ needs.iOS.result }}" == "success" ] && \ + if [ "${{ needs.iOS.result }}" == "success" ] && \ [ "${{ needs.desktop.result }}" == "success" ] && \ [ "${{ needs.web.result }}" == "success" ]; then isAllPlatformsDeployed="true" fi + + if [ ${{ github.ref }} == 'refs/heads/production' ]; then + if [ "${{ needs.submitAndroid.result }}" != "success" ]; then + isAllPlatformsDeployed="false" + fi + else + if [ "${{ needs.uploadAndroid.result }}" != "success" ]; then + isAllPlatformsDeployed="false" + fi + fi + echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" echo "IS_ALL_PLATFORMS_DEPLOYED is $isAllPlatformsDeployed" @@ -590,7 +605,7 @@ jobs: name: Post a Slack message when all platforms deploy successfully runs-on: ubuntu-latest if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_ALL_PLATFORMS_DEPLOYED) }} - needs: [prep, android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 @@ -644,11 +659,11 @@ jobs: postGithubComments: uses: ./.github/workflows/postDeployComments.yml if: ${{ always() && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, android, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] + needs: [prep, buildAndroid, uploadAndroid, submitAndroid, desktop, iOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] with: version: ${{ needs.prep.outputs.APP_VERSION }} env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} - android: ${{ needs.android.result }} + android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} ios: ${{ needs.iOS.result }} web: ${{ needs.web.result }} desktop: ${{ needs.desktop.result }} diff --git a/.github/workflows/deployNewHelp.yml b/.github/workflows/deployNewHelp.yml index 8c4d0fb0ae3b..ec91f593e4b1 100644 --- a/.github/workflows/deployNewHelp.yml +++ b/.github/workflows/deployNewHelp.yml @@ -1,21 +1,24 @@ name: Deploy New Help Site on: - # Run on any push to main that has changes to the help directory -# TEST: Verify Cloudflare picks this up even if not run when merged to main -# push: -# branches: -# - main -# paths: -# - 'help/**' + # Run on any push to main that has changes to the help directory. This will cause this + # to deploy the latest code to newhelp.expensify.com + push: + branches: + - main + paths: + - 'help/**' + - './.github/workflows/deployNewHelp.yml' - # Run on any pull request (except PRs against staging or production) that has changes to the help directory + # Run on any pull request (except PRs against staging or production) that has + # changes to the help directory. This will cause it to deploy this unmerged branch to + # a Cloudflare "preview" environment pull_request: types: [opened, synchronize] branches-ignore: [staging, production] paths: - 'help/**' - + - './.github/workflows/deployNewHelp.yml' # Run on any manual trigger workflow_dispatch: @@ -27,10 +30,17 @@ concurrency: jobs: build: env: + # Open source contributors do not have write access to the Expensify/App repo, + # so must submit PRs from forks. This variable detects if the PR is coming + # from a fork, and thus is from an outside contributor. IS_PR_FROM_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork }} + + # Set up a clean Ubuntu build environment runs-on: ubuntu-latest steps: + # We start by checking out the entire repo into a clean build environment within + # the Github Action - name: Checkout code uses: actions/checkout@v4 @@ -41,34 +51,60 @@ jobs: bundler-cache: true working-directory: ./help + # Manually run Jekyll, bypassing Github Pages - name: Build Jekyll site run: bundle exec jekyll build --source ./ --destination ./_site working-directory: ./help # Ensure Jekyll is building the site in /help + # This will copy the contents of /help/_site to Cloudflare. The pages-action will + # evaluate the current branch to determine into which CF environment to deploy: + # - If you are on 'main', it will deploy to 'production' in Cloudflare + # - Otherwise it will deploy to a 'preview' environment made for this branch - name: Deploy to Cloudflare Pages uses: cloudflare/pages-action@v1 - id: deploy - if: env.IS_PR_FROM_FORK != 'true' + id: cloudflarePagesAction + if: ${{ env.IS_PR_FROM_FORK != 'true' }} with: apiToken: ${{ secrets.CLOUDFLARE_PAGES_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} projectName: newhelp directory: ./help/_site # Deploy the built site + # After deploying Cloudflare preview build, share wherever it deployed to in the PR comment. + - name: Leave a comment on the PR + uses: actions-cool/maintain-one-comment@v3.2.0 + if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} + with: + token: ${{ github.token }} + body: ${{ format('Your New Help changes have been deployed to {0} :zap:ļø', steps.cloudflarePagesAction.outputs.alias) }} + + - name: Get merged pull request + if: ${{ github.event_name == 'push' }} + id: getMergedPullRequest + uses: actions-ecosystem/action-get-merged-pull-request@59afe90821bb0b555082ce8ff1e36b03f91553d9 + with: + github_token: ${{ github.token }} + + - name: Leave a comment on the PR after it's merged + if: ${{ github.event_name == 'push' }} + run: | + gh pr comment ${{ steps.getMergedPullRequest.outputs.number }} --body "$(cat <<'EOF' + šŸš€Deployed to [NewHelp production](https://newhelp.expensify.com)! šŸš€ + + ([_View deploy workflow run_](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})) + EOF + )" + env: + GITHUB_TOKEN: ${{ github.token }} + + # Use the Cloudflare CLI... - name: Setup Cloudflare CLI - if: env.IS_PR_FROM_FORK != 'true' + if: ${{ env.IS_PR_FROM_FORK != 'true' }} run: pip3 install cloudflare==2.19.0 + # ... to purge the cache, such that all users will see the latest content. - name: Purge Cloudflare cache - if: env.IS_PR_FROM_FORK != 'true' + if: ${{ env.IS_PR_FROM_FORK != 'true' }} run: /home/runner/.local/bin/cli4 --verbose --delete hosts=["newhelp.expensify.com"] /zones/:9ee042e6cfc7fd45e74aa7d2f78d617b/purge_cache env: CF_API_KEY: ${{ secrets.CLOUDFLARE_TOKEN }} - - - name: Leave a comment on the PR - uses: actions-cool/maintain-one-comment@v3.2.0 - if: ${{ github.event_name == 'pull_request' && env.IS_PR_FROM_FORK != 'true' }} - with: - token: ${{ github.token }} - body: ${{ format('A preview of your New Help changes have been deployed to {0} :zap:ļø', steps.deploy.outputs.alias) }} - diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index b9352d406feb..f88e841617bb 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -20,13 +20,15 @@ concurrency: cancel-in-progress: true jobs: - buildBaseline: - runs-on: ubuntu-latest-xl - name: Build apk from latest release as a baseline + prep: + runs-on: ubuntu-latest + name: Find the baseline and delta refs, and check for an existing build artifact for that commit outputs: - VERSION: ${{ steps.getMostRecentRelease.outputs.VERSION }} - ARTIFACT_FOUND: ${{ steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND }} - ARTIFACT_WORKFLOW_ID: ${{ steps.checkForExistingArtifact.outputs.ARTIFACT_WORKFLOW_ID }} + BASELINE_ARTIFACT_FOUND: ${{ steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND }} + BASELINE_ARTIFACT_WORKFLOW_ID: ${{ steps.checkForExistingArtifact.outputs.ARTIFACT_WORKFLOW_ID }} + BASELINE_VERSION: ${{ steps.getMostRecentRelease.outputs.VERSION }} + DELTA_REF: ${{ steps.getDeltaRef.outputs.DELTA_REF }} + IS_PR_MERGED: ${{ steps.getPullRequestDetails.outputs.IS_MERGED }} steps: - uses: actions/checkout@v4 with: @@ -44,41 +46,12 @@ jobs: uses: ./.github/actions/javascript/getArtifactInfo with: GITHUB_TOKEN: ${{ secrets.OS_BOTIFY_COMMIT_TOKEN }} - ARTIFACT_NAME: baseline-apk-${{ steps.getMostRecentRelease.outputs.VERSION }} + ARTIFACT_NAME: baseline-${{ steps.getMostRecentRelease.outputs.VERSION }}-android-artifact-apk - name: Skip build if there's already an existing artifact for the baseline if: ${{ fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }} run: echo 'APK for baseline ${{ steps.getMostRecentRelease.outputs.VERSION }} already exists, reusing existing build' - - name: Checkout "Baseline" commit (last release) - if: ${{ !fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }} - run: | - git fetch origin tag ${{ steps.getMostRecentRelease.outputs.VERSION }} --no-tags --depth=1 - git switch --detach ${{ steps.getMostRecentRelease.outputs.VERSION }} - - - uses: Expensify/App/.github/actions/composite/buildAndroidE2EAPK@main - if: ${{ !fromJSON(steps.checkForExistingArtifact.outputs.ARTIFACT_FOUND) }} - with: - ARTIFACT_NAME: baseline-apk-${{ steps.getMostRecentRelease.outputs.VERSION }} - PACKAGE_SCRIPT_NAME: android-build-e2e - APP_OUTPUT_PATH: android/app/build/outputs/apk/e2e/release/app-e2e-release.apk - MAPBOX_SDK_DOWNLOAD_TOKEN: ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - EXPENSIFY_PARTNER_NAME: ${{ secrets.EXPENSIFY_PARTNER_NAME }} - EXPENSIFY_PARTNER_PASSWORD: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD }} - EXPENSIFY_PARTNER_USER_ID: ${{ secrets.EXPENSIFY_PARTNER_USER_ID }} - EXPENSIFY_PARTNER_USER_SECRET: ${{ secrets.EXPENSIFY_PARTNER_USER_SECRET }} - EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - PATH_ENV_FILE: tests/e2e/.env.e2e - - buildDelta: - runs-on: ubuntu-latest-xl - name: Build apk from delta ref - outputs: - DELTA_REF: ${{ steps.getDeltaRef.outputs.DELTA_REF }} - steps: - - uses: actions/checkout@v4 - - name: Get pull request details id: getPullRequestDetails uses: ./.github/actions/javascript/getPullRequestDetails @@ -87,63 +60,54 @@ jobs: PULL_REQUEST_NUMBER: ${{ inputs.PR_NUMBER }} USER: ${{ github.actor }} - - name: Merged PR - Get merge commit sha for the pull request - if: ${{ fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} - id: getMergeCommitShaIfMergedPR - run: | - MERGE_COMMIT_SHA=${{ steps.getPullRequestDetails.outputs.MERGE_COMMIT_SHA }} - git fetch origin "$MERGE_COMMIT_SHA" --no-tags --depth=1 - echo "MERGE_COMMIT_SHA=$MERGE_COMMIT_SHA" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ github.token }} - - - name: Unmerged PR - Fetch head ref of unmerged PR - if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} + - name: Determine "delta ref" + id: getDeltaRef run: | - git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1 + if [ '${{ steps.getPullRequestDetails.outputs.IS_MERGED }}' == 'true' ]; then + echo "DELTA_REF=${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}" >> "$GITHUB_OUTPUT" + else + # Set dummy git credentials + git config --global user.email "test@test.com" + git config --global user.name "Test" - - name: Unmerged PR - Set dummy git credentials before merging - if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} - run: | - git config --global user.email "test@test.com" - git config --global user.name "Test" + # Fetch head_ref of unmerged PR + git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1 - - name: Unmerged PR - Merge pull request locally and get merge commit sha - if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} - id: getMergeCommitShaIfUnmergedPR - run: | - git merge --allow-unrelated-histories -X ours --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} - git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} - env: - GITHUB_TOKEN: ${{ github.token }} + # Merge pull request locally and get merge commit sha + git merge --allow-unrelated-histories -X ours --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} - - name: Determine "delta ref" - id: getDeltaRef - run: echo "DELTA_REF=${{ steps.getMergeCommitShaIfMergedPR.outputs.MERGE_COMMIT_SHA || steps.getMergeCommitShaIfUnmergedPR.outputs.MERGE_COMMIT_SHA }}" >> "$GITHUB_OUTPUT" - env: - GITHUB_TOKEN: ${{ github.token }} + # Create and push a branch so it can be checked out in another runner + git checkout -b e2eDelta-${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + git push origin e2eDelta-${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + echo "DELTA_REF=e2eDelta-${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }}" >> "$GITHUB_OUTPUT" + fi - - name: Checkout "delta ref" - run: git checkout ${{ steps.getDeltaRef.outputs.DELTA_REF }} + buildBaseline: + name: Build apk from latest release as a baseline + uses: ./.github/workflows/buildAndroid.yml + needs: prep + if: ${{ !fromJSON(needs.prep.outputs.BASELINE_ARTIFACT_FOUND) }} + secrets: inherit + with: + type: e2e + ref: ${{ needs.prep.outputs.BASELINE_VERSION }} + artifact-prefix: baseline-${{ needs.prep.outputs.BASELINE_VERSION }} - - uses: Expensify/App/.github/actions/composite/buildAndroidE2EAPK@main - with: - ARTIFACT_NAME: delta-apk-${{ steps.getDeltaRef.outputs.DELTA_REF }} - ARTIFACT_RETENTION_DAYS: 3 # We don't need to store the delta apk for long, its only really needed for the next job in this workflow - PACKAGE_SCRIPT_NAME: android-build-e2edelta - APP_OUTPUT_PATH: android/app/build/outputs/apk/e2edelta/release/app-e2edelta-release.apk - MAPBOX_SDK_DOWNLOAD_TOKEN: ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - EXPENSIFY_PARTNER_NAME: ${{ secrets.EXPENSIFY_PARTNER_NAME }} - EXPENSIFY_PARTNER_PASSWORD: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD }} - EXPENSIFY_PARTNER_USER_ID: ${{ secrets.EXPENSIFY_PARTNER_USER_ID }} - EXPENSIFY_PARTNER_USER_SECRET: ${{ secrets.EXPENSIFY_PARTNER_USER_SECRET }} - EXPENSIFY_PARTNER_PASSWORD_EMAIL: ${{ secrets.EXPENSIFY_PARTNER_PASSWORD_EMAIL }} - SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - PATH_ENV_FILE: tests/e2e/.env.e2edelta + buildDelta: + name: Build apk from delta ref + uses: ./.github/workflows/buildAndroid.yml + needs: prep + secrets: inherit + with: + type: e2eDelta + ref: ${{ needs.prep.outputs.DELTA_REF }} + artifact-prefix: delta-${{ needs.prep.outputs.DELTA_REF }} runTestsInAWS: runs-on: ubuntu-latest - needs: [buildBaseline, buildDelta] + needs: [prep, buildBaseline, buildDelta] + if: ${{ always() }} name: Run E2E tests in AWS device farm steps: - uses: actions/checkout@v4 @@ -161,25 +125,25 @@ jobs: uses: actions/download-artifact@v4 id: downloadBaselineAPK with: - name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }} + name: baseline-${{ needs.prep.outputs.BASELINE_VERSION }}-android-artifact-apk path: zip # Set github-token only if the baseline was built in this workflow run: - github-token: ${{ needs.buildBaseline.outputs.ARTIFACT_WORKFLOW_ID && github.token }} - run-id: ${{ needs.buildBaseline.outputs.ARTIFACT_WORKFLOW_ID }} + github-token: ${{ needs.prep.outputs.BASELINE_ARTIFACT_WORKFLOW_ID && github.token }} + run-id: ${{ needs.prep.outputs.BASELINE_ARTIFACT_WORKFLOW_ID }} # The downloaded artifact will be a file named "app-e2e-release.apk" so we have to rename it - name: Rename baseline APK - run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" + run: mv "${{ steps.downloadBaselineAPK.outputs.download-path }}/app-e2e-release.apk" "${{ steps.downloadBaselineAPK.outputs.download-path }}/app-e2eRelease.apk" - name: Download delta APK uses: actions/download-artifact@v4 id: downloadDeltaAPK with: - name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }} + name: delta-${{ needs.prep.outputs.DELTA_REF }}-android-artifact-apk path: zip - name: Rename delta APK - run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2edelta-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2edeltaRelease.apk" + run: mv "${{ steps.downloadDeltaAPK.outputs.download-path }}/app-e2edelta-release.apk" "${{ steps.downloadDeltaAPK.outputs.download-path }}/app-e2edeltaRelease.apk" - name: Compile test runner to be executable in a nodeJS environment run: npm run e2e-test-runner-build @@ -289,3 +253,13 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} + + cleanupDeltaRef: + needs: [prep, runTestsInAWS] + if: ${{ always() && needs.prep.outputs.IS_PR_MERGED != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Delete temporary merge branch created for delta ref + run: git push -d origin ${{ needs.prep.outputs.DELTA_REF }} diff --git a/.github/workflows/testBuild.yml b/.github/workflows/testBuild.yml index f523faf785c0..672d468ed3b1 100644 --- a/.github/workflows/testBuild.yml +++ b/.github/workflows/testBuild.yml @@ -45,7 +45,7 @@ jobs: needs: validateActor if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} outputs: - REF: ${{steps.getHeadRef.outputs.REF}} + REF: ${{ steps.getHeadRef.outputs.REF }} steps: - name: Checkout if: ${{ github.event_name == 'workflow_dispatch' }} @@ -60,48 +60,43 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - android: - name: Build and deploy Android for testing - needs: [validateActor, getBranchRef] + buildAndroid: + name: Build Android app for testing + uses: ./.github/workflows/buildAndroid.yml if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - runs-on: ubuntu-latest-xl + needs: [validateActor, getBranchRef] + secrets: inherit + with: + type: adhoc + ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} + pull_request_number: ${{ github.event.number || github.event.inputs.PULL_REQUEST_NUMBER }} + + uploadAndroid: + name: Upload Android app to S3 + needs: [buildAndroid] + runs-on: ubuntu-latest env: RUBYOPT: '-rostruct' + outputs: + S3_APK_PATH: ${{ steps.exportS3Path.outputs.S3_APK_PATH }} steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} - - - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it - run: | - cp .env.staging .env.adhoc - sed -i 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc - echo "PULL_REQUEST_NUMBER=$PULL_REQUEST_NUMBER" >> .env.adhoc - - - name: Setup Node - uses: ./.github/actions/composite/setupNode - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'oracle' - java-version: '17' - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - - name: Decrypt keystore - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output my-upload-key.keystore my-upload-key.keystore.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + - name: Download Android build artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/artifacts + pattern: android-artifact-* + merge-multiple: true - - name: Decrypt json key - run: cd android/app && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output android-fastlane-json-key.json android-fastlane-json-key.json.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + - name: Log downloaded artifact paths + run: ls -R /tmp/artifacts - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 @@ -110,28 +105,20 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - name: Run AdHoc build - run: bundle exec fastlane android build_adhoc - env: - MYAPP_UPLOAD_STORE_PASSWORD: ${{ secrets.MYAPP_UPLOAD_STORE_PASSWORD }} - MYAPP_UPLOAD_KEY_PASSWORD: ${{ secrets.MYAPP_UPLOAD_KEY_PASSWORD }} - - name: Upload AdHoc build to S3 run: bundle exec fastlane android upload_s3 env: + apkPath: /tmp/artifacts/${{ needs.buildAndroid.outputs.APK_FILE_NAME }} S3_ACCESS_KEY: ${{ secrets.AWS_ACCESS_KEY_ID }} S3_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} S3_BUCKET: ad-hoc-expensify-cash S3_REGION: us-east-1 - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: android - path: ./android_paths.json + - name: Export S3 paths + id: exportS3Path + run: | + # $s3APKPath is set from within the Fastfile, android upload_s3 lane + echo "S3_APK_PATH=$s3APKPath" >> "$GITHUB_OUTPUT" iOS: name: Build and deploy iOS for testing @@ -304,7 +291,7 @@ jobs: postGithubComment: runs-on: ubuntu-latest name: Post a GitHub comment with app download links for testing - needs: [validateActor, getBranchRef, android, iOS, desktop, web] + needs: [validateActor, getBranchRef, uploadAndroid, iOS, desktop, web] if: ${{ always() }} steps: - name: Checkout @@ -317,17 +304,6 @@ jobs: uses: actions/download-artifact@v4 if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - - name: Read JSONs with android paths - id: get_android_path - if: ${{ needs.android.result == 'success' }} - run: | - content_android="$(cat ./android/android_paths.json)" - content_android="${content_android//'%'/'%25'}" - content_android="${content_android//$'\n'/'%0A'}" - content_android="${content_android//$'\r'/'%0D'}" - android_path=$(echo "$content_android" | jq -r '.html_path') - echo "android_path=$android_path" >> "$GITHUB_OUTPUT" - - name: Read JSONs with iOS paths id: get_ios_path if: ${{ needs.iOS.result == 'success' }} @@ -345,11 +321,11 @@ jobs: with: PR_NUMBER: ${{ env.PULL_REQUEST_NUMBER }} GITHUB_TOKEN: ${{ github.token }} - ANDROID: ${{ needs.android.result }} + ANDROID: ${{ needs.uploadAndroid.result }} DESKTOP: ${{ needs.desktop.result }} IOS: ${{ needs.iOS.result }} WEB: ${{ needs.web.result }} - ANDROID_LINK: ${{steps.get_android_path.outputs.android_path}} + ANDROID_LINK: ${{ needs.uploadAndroid.outputs.S3_APK_PATH }} DESKTOP_LINK: https://ad-hoc-expensify-cash.s3.amazonaws.com/desktop/${{ env.PULL_REQUEST_NUMBER }}/NewExpensify.dmg - IOS_LINK: ${{steps.get_ios_path.outputs.ios_path}} + IOS_LINK: ${{ steps.get_ios_path.outputs.ios_path }} WEB_LINK: https://${{ env.PULL_REQUEST_NUMBER }}.pr-testing.expensify.com diff --git a/.storybook/webpack.config.ts b/.storybook/webpack.config.ts index f0fff8bda698..92cea8666bc2 100644 --- a/.storybook/webpack.config.ts +++ b/.storybook/webpack.config.ts @@ -67,8 +67,8 @@ const webpackConfig = ({config}: {config: Configuration}) => { // Necessary to overwrite the values in the existing DefinePlugin hardcoded to the Config staging values const definePluginIndex = config.plugins.findIndex((plugin) => plugin instanceof DefinePlugin); - if (definePluginIndex !== -1 && config.plugins[definePluginIndex] instanceof DefinePlugin) { - const definePlugin = config.plugins[definePluginIndex] as DefinePlugin; + if (definePluginIndex !== -1 && config.plugins.at(definePluginIndex) instanceof DefinePlugin) { + const definePlugin = config.plugins.at(definePluginIndex) as DefinePlugin; if (definePlugin.definitions) { definePlugin.definitions.__REACT_WEB_CONFIG__ = JSON.stringify(env); } @@ -76,8 +76,8 @@ const webpackConfig = ({config}: {config: Configuration}) => { config.resolve.extensions = custom.resolve.extensions; const babelRulesIndex = custom.module.rules.findIndex((rule) => rule.loader === 'babel-loader'); - const babelRule = custom.module.rules[babelRulesIndex]; - if (babelRule) { + const babelRule = custom.module.rules.at(babelRulesIndex); + if (babelRulesIndex !== -1 && babelRule) { config.module.rules?.push(babelRule); } diff --git a/android/app/build.gradle b/android/app/build.gradle index 2491cc21a400..56ae4bd0a873 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -110,8 +110,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1009004102 - versionName "9.0.41-2" + versionCode 1009004502 + versionName "9.0.45-2" // Supported language variants must be declared here to avoid from being removed during the compilation. // This also helps us to not include unnecessary language variants in the APK. resConfigs "en", "es" @@ -229,11 +229,11 @@ dependencies { implementation 'com.facebook.fresco:fresco:2.5.0' implementation 'com.facebook.fresco:animated-gif:2.5.0' - // Android support library - implementation 'com.android.support:support-core-utils:28.0.0' + // AndroidX support library + implementation 'androidx.legacy:legacy-support-core-utils:1.0.0' // Multi Dex Support: https://developer.android.com/studio/build/multidex#mdex-gradle - implementation 'com.android.support:multidex:1.0.3' + implementation 'androidx.multidex:multidex:2.0.1' // Plaid SDK implementation project(':react-native-plaid-link-sdk') diff --git a/android/gradle.properties b/android/gradle.properties index 46cd98554d29..038fb5c392e8 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -21,8 +21,8 @@ org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=512m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +# Disabled Jetifier to improve build performance as we're not using libraries that require Jetifier for AndroidX compatibility. +android.enableJetifier=false # Increase storage capacity (the default is 6 MB) AsyncStorage_db_size_in_MB=10 diff --git a/assets/emojis/index.ts b/assets/emojis/index.ts index d230e8eec2be..4b05c8caddfa 100644 --- a/assets/emojis/index.ts +++ b/assets/emojis/index.ts @@ -33,7 +33,7 @@ const localeEmojis: LocaleEmojis = { }; const importEmojiLocale = (locale: Locale) => { - const normalizedLocale = locale.toLowerCase().split('-')[0] as Locale; + const normalizedLocale = locale.toLowerCase().split('-').at(0) as Locale; if (!localeEmojis[normalizedLocale]) { const emojiImportPromise = normalizedLocale === 'en' ? import('./en') : import('./es'); return emojiImportPromise.then((esEmojiModule) => { diff --git a/assets/images/table.svg b/assets/images/table.svg index 36d4ced774f1..dea1e990b97d 100644 --- a/assets/images/table.svg +++ b/assets/images/table.svg @@ -1,3 +1,3 @@ - - + + diff --git a/config/webpack/webpack.common.ts b/config/webpack/webpack.common.ts index 1bab57905d0e..91fc4b1bf528 100644 --- a/config/webpack/webpack.common.ts +++ b/config/webpack/webpack.common.ts @@ -47,7 +47,7 @@ const environmentToLogoSuffixMap: Record = { }; function mapEnvironmentToLogoSuffix(environmentFile: string): string { - let environment = environmentFile.split('.')[2]; + let environment = environmentFile.split('.').at(2); if (typeof environment === 'undefined') { environment = 'dev'; } diff --git a/contributingGuides/PERFORMANCE_METRICS.md b/contributingGuides/PERFORMANCE_METRICS.md new file mode 100644 index 000000000000..6c40e346a3ce --- /dev/null +++ b/contributingGuides/PERFORMANCE_METRICS.md @@ -0,0 +1,49 @@ +# Performance Metrics + +This project tracks various performance metrics to monitor and improve the application's efficiency and user experience. + +## Tracked Metrics + +The following table shows the key performance metrics that are being monitored in the application. + +Project is using Firebase for tracking these metrics. However, not all of them are sent there - some of them are only used internally by the Performance module. + +| Metric name | Sent to Firebase | Description | Start time | End time | +|----------|----------|----------|----------|----------| +| `_app_start` | āœ… | The time between when the user opens the app and when the app is responsive.

**Platforms:** Android | Starts when the app's `FirebasePerfProvider` `ContentProvider` completes its `onCreate` method. | Stops when the first activity's `onResume()` method is called. | +| `js_loaded` | āœ… | The time it takes for the JavaScript bundle to load.

**Platforms:** Android, iOS | **Android:** Starts in the `onCreate` method.

**iOS:** Starts in the AppDelegate's `didFinishLaunchingWithOptions` method. | Stops at the first render of the app via native module on the JS side. | +| `_app_in_foreground` | āœ… | The time when the app is running in the foreground and available to the user.

**Platforms:** Android, iOS | **Android:** Starts when the first activity to reach the foreground has its `onResume()` method called.

**iOS:** Starts when the application receives the `UIApplicationDidBecomeActiveNotification` notification. | **Android:** Stops when the last activity to leave the foreground has its `onStop()` method called.

**iOS:** Stops when it receives the `UIApplicationWillResignActiveNotification` notification. | +| `_app_in_background` | āœ… | Time when the app is running in the background.

**Platforms:** Android, iOS | **Android:** Starts when the last activity to leave the foreground has its `onStop()` method called.

**iOS:** Starts when the application receives the `UIApplicationWillResignActiveNotification` notification. | **Android:** Stops when the first activity to reach the foreground has its `onResume()` method called.

**iOS:** Stops when it receives the `UIApplicationDidBecomeActiveNotification` notification. | +| `homepage_initial_render` | āœ… | Time taken for the initial render of the app for a logged in user.

**Platforms:** All | Starts with the first render of the `AuthScreens` component. | Stops once the `AuthScreens` component is mounted. | +| `sidebar_loaded` | āŒ | Time taken for the Sidebar to load.

**Platforms:** All | Starts when the Sidebar is mounted. | Stops when the Splash Screen is hidden. | +| `calc_most_recent_last_modified_action` | āœ… | Time taken to find the most recently modified report action or report.

**Platforms:** All | Starts when the app reconnects to the network | Ends when the app reconnects to the network and the most recent report action or report is found. | +| `search_render` | āœ… | Time taken to render the Chat Finder page.

**Platforms:** All | Starts when the Chat Finder icon in LHN is pressed. | Stops when the list of available options is rendered for the first time. | +| `load_search_options` | āœ… | Time taken to generate the list of options used in Chat Finder.

**Platforms:** All | Starts when the `getSearchOptions` function is called. | Stops when the list of available options is generated. | +| `search_filter_options` | āœ… | Time taken to filter search options in Chat Finder by given search value.

**Platforms:** All | Starts when user types something in the Chat Finder search input. | Stops when the list of filtered options is generated. | +| `trie_initialization` | āœ… | Time taken to build the emoji trie.

**Platforms:** All | Starts when emoji trie begins to build. | Stops when emoji trie building is complete. | +| `open_report` | āŒ | Time taken to open a report.

**Platforms:** All | Starts when the row in the `LHNOptionsList` is pressed. | Stops when the `ReportActionsList` finishes laying out. | +| `switch_report` | āœ… | Time taken to open report.

**Platforms:** All | Starts when the chat in the LHN is pressed. | Stops when the `ReportActionsList` finishes laying out. | +| `open_report_from_preview` | āœ… | Time taken to open a report from preview.

(previously `switch_report_from_preview`)

**Platforms:** All | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | +| `switch_report_from_preview` | āŒ | **[REMOVED]** Time taken to open a report from preview. | Starts when the user presses the Report Preview. | Stops when the `ReportActionsList` finishes laying out. | +| `chat_render` | āœ… | Time taken to render the Report screen.

**Platforms:** All | Starts when the `ReportScreen` is being rendered for the first time. | Stops once the `ReportScreen` component is mounted. | +| `report_initial_render` | āŒ | Time taken to render the Report screen.

**Platforms:** All | Starts when the first item is rendered in the `LHNOptionsList`. | Stops when the `ReportActionsList` finishes laying out. | +| `open_report_thread` | āœ… | Time taken to open a thread in a report.

**Platforms:** All | Starts when user presses Report Action Item. | Stops when the `ReportActionsList` finishes laying out. | +| `message_sent` | āŒ | Time taken to send a message.

**Platforms:** All | Starts when the new message is sent. | Stops when the message is being rendered in the chat. | + +## Documentation Maintenance + +To ensure this documentation remains accurate and useful, please adhere to the following guidelines when updating performance metrics: + +1. **New Metrics**: When a new metric is introduced in the codebase, add it to the table with all relevant details. + +2. **Metric Renaming**: If a metric is renamed, update the table entry. Mark the old name as deprecated and include a reference to the new name. + +3. **Metric Removal**: If a metric is no longer used, don't delete its entry. Instead, mark it as deprecated in the table and provide a brief explanation. + +4. **Code Location Changes**: If the placement of a metric in the code changes, update the "Start time" and "End time" columns to reflect the new location. + + +## Additional Resources + +- [Firebase Documentation](https://firebase.google.com/docs) +- [Firebase Performance Monitoring](https://firebase.google.com/docs/perf-mon) \ No newline at end of file diff --git a/desktop/package-lock.json b/desktop/package-lock.json index 152ad1a4c5ba..4803d189074c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -9,7 +9,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.4", + "electron-updater": "^6.3.5", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, @@ -59,9 +59,10 @@ } }, "node_modules/builder-util-runtime": { - "version": "9.2.5", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz", - "integrity": "sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==", + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.6.tgz", + "integrity": "sha512-sCNP0uykVxn1vdYdPGW3+8D4kMOF8PR9eL5HgUcQXhpoIoUGxdD03yQgZcuMQt4iGLKb5DD62evElwGq1ylEag==", + "license": "MIT", "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -102,11 +103,12 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "node_modules/debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -154,12 +156,12 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "node_modules/electron-updater": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.4.tgz", - "integrity": "sha512-uZUo7p1Y53G4tl6Cgw07X1yF8Jlz6zhaL7CQJDZ1fVVkOaBfE2cWtx80avwDVi8jHp+I/FWawrMgTAeCCNIfAg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.5.tgz", + "integrity": "sha512-8bUtfe3UHnM+D7971N/zo3NCG2TIHuE4GFUtHRVmVOQg0prXEd+uZoVekakdPiTDkkXJv4b09CZMw/ZJJfag1A==", "license": "MIT", "dependencies": { - "builder-util-runtime": "9.2.5", + "builder-util-runtime": "9.2.6", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -304,9 +306,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/node-machine-id": { "version": "1.1.12", @@ -335,7 +338,8 @@ "node_modules/sax": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==" + "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", + "license": "ISC" }, "node_modules/semver": { "version": "7.6.3", @@ -465,9 +469,9 @@ "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==" }, "builder-util-runtime": { - "version": "9.2.5", - "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.5.tgz", - "integrity": "sha512-HjIDfhvqx/8B3TDN4GbABQcgpewTU4LMRTQPkVpKYV3lsuxEJoIfvg09GyWTNmfVNSUAYf+fbTN//JX4TH20pg==", + "version": "9.2.6", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.2.6.tgz", + "integrity": "sha512-sCNP0uykVxn1vdYdPGW3+8D4kMOF8PR9eL5HgUcQXhpoIoUGxdD03yQgZcuMQt4iGLKb5DD62evElwGq1ylEag==", "requires": { "debug": "^4.3.4", "sax": "^1.2.4" @@ -496,11 +500,11 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" }, "debug": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", - "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "requires": { - "ms": "2.1.2" + "ms": "^2.1.3" } }, "electron-context-menu": { @@ -534,11 +538,11 @@ "integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA==" }, "electron-updater": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.4.tgz", - "integrity": "sha512-uZUo7p1Y53G4tl6Cgw07X1yF8Jlz6zhaL7CQJDZ1fVVkOaBfE2cWtx80avwDVi8jHp+I/FWawrMgTAeCCNIfAg==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.3.5.tgz", + "integrity": "sha512-8bUtfe3UHnM+D7971N/zo3NCG2TIHuE4GFUtHRVmVOQg0prXEd+uZoVekakdPiTDkkXJv4b09CZMw/ZJJfag1A==", "requires": { - "builder-util-runtime": "9.2.5", + "builder-util-runtime": "9.2.6", "fs-extra": "^10.1.0", "js-yaml": "^4.1.0", "lazy-val": "^1.0.5", @@ -651,9 +655,9 @@ "integrity": "sha1-mi3sg4Bvuy2XXyK+7IWcoms5OqE=" }, "ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node-machine-id": { "version": "1.1.12", diff --git a/desktop/package.json b/desktop/package.json index 6c2158a74978..b8e1e175b0fe 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -6,7 +6,7 @@ "dependencies": { "electron-context-menu": "^2.3.0", "electron-log": "^4.4.8", - "electron-updater": "^6.3.4", + "electron-updater": "^6.3.5", "mime-types": "^2.1.35", "node-machine-id": "^1.1.12" }, diff --git a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md similarity index 97% rename from docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md rename to docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md index 402337140419..a7b7ed1c4f4f 100644 --- a/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account.md +++ b/docs/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account.md @@ -1,5 +1,5 @@ --- -title: Connect personal U.S. bank account +title: Connect personal bank account description: Receive reimbursements for expense reports submitted to your employer ---
diff --git a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md index 03dd3d722d82..14b5225801d0 100644 --- a/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md +++ b/docs/articles/expensify-classic/domains/Add-Domain-Members-and-Admins.md @@ -36,10 +36,6 @@ Once the member verifies their email address, all Domain Admins will be notified 3. Click the **Domain Members** tab on the left. 4. Under the Domain Members section, enter the first part of the memberā€™s email address and click **Invite**. -{% include info.html %} -This can be any email addressā€”it does not have to be an email address under the domain. If someone who is not a Domain Admin invites a new member to a workspace, that member must validate their account via email before they will have access to it. -{% include end-info.html %} - # Add Domain Admin 1. Hover over Settings, then click **Domains**. diff --git a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md index 3927ec5b7a33..54314e0edb4d 100644 --- a/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md +++ b/docs/articles/expensify-classic/settings/Enable-two-factor-authentication.md @@ -6,6 +6,18 @@ description: Use 2FA for extra login security Add an extra layer of security to help keep your financial data safe and secure by enabling two-factor authentication (2FA). This will require you to enter a code generated by your preferred authenticator app (like Google Authenticator or Microsoft Authenticator) when you log in. +Expensify's Two-Factor Authentication (2FA) is implemented via a Time-based One-Time Password (TOTP) algorithm. This requires you to use an Authenticator app to generate a unique code each time you log in, adding a second ā€œfactorā€ to your login. + +You can choose to use whichever authenticator you prefer, but here are a few we recommend: +- [1Password](https://support.1password.com/one-time-passwords/) +- [Authy](https://authy.com/) +- [Google Authenticator](https://support.google.com/accounts/answer/1066447) +- [Microsoft Authenticator](https://www.microsoft.com/en-us/security/mobile-authenticator-app) + +You will need to select an authenticator app to use before proceeding. + +## Enable and Set Up Two-factor authentication + 1. Hover over Settings, then click **Account**. 2. Under the Account Details tab, scroll down to the Two Factor Authentication section and enable the toggle. 3. Save a copy of your backup codes. @@ -19,8 +31,32 @@ This step is criticalā€”You will lose access to your account if you cannot use y 4. Click **Continue**. 5. Download or open your authenticator app and either: - Scan the QR code shown on your computer screen. - - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**. + - Enter the 6-digit code from your authenticator app into Expensify and click **Verify**. When you log in to Expensify in the future, youā€™ll be emailed a magic code that youā€™ll use to log in with. Then youā€™ll be prompted to open your authenticator app to get the 6-digit code and enter it into Expensify. A new code regenerates every few seconds, so the code is always different. If the code time runs out, you can generate a new code as needed. +## Lost recovery codes and authenticator app + +If you have lost your mobile device and canā€™t find your recovery codes, you can have your Domain Admin complete the steps below to reset your 2FA **only if you use a company email address or an email address on a domain that you own**: + +Go to Settings > Domains > Domain Members and click **Edit Settings** for your email address. +They then click **Reset** to reset two-factor authentication (2FA) on your account. + +This will allow you to gain access to your account on the web or mobile app and reconfigure 2FA again. + +{% include info.html %} +If you use a public email address such as gmail, hotmail, or yahoo, we unfortunately canā€™t help you disable your 2FA setting. If you are unable to find your recovery codes, you may need to create a new Expensify account with a different email address. +{% include end-info.html %} + +If you donā€™t have a Domain Admin, follow the steps in this [guide](https://help.expensify.com/articles/expensify-classic/domains/Claim-And-Verify-A-Domain) to verify the domain. + +## General troubleshooting + +Make sure your phoneā€™s time is set to automatically update (a manual time thatā€™s fractionally different can cause issues). +Try disabling 2FA using a device that you are still logged into. For example, if youā€™re having trouble logging in with your computer, try to see if your mobile device is still logged in. If so, +Hover over Settings, then click Account. +Under the Account Details tab, scroll down to the Two Factor Authentication section and disable the toggle. +Try logging in with your other device. +Once youā€™ve logged in again, you can re-enable 2FA. +
diff --git a/docs/articles/expensify-classic/settings/General-product-troubleshooting.md b/docs/articles/expensify-classic/settings/General-product-troubleshooting.md new file mode 100644 index 000000000000..57126628e04f --- /dev/null +++ b/docs/articles/expensify-classic/settings/General-product-troubleshooting.md @@ -0,0 +1,48 @@ +--- +title: General Product Troubleshooting +description: How to troubleshoot a website issue +--- + + +# Issues with a specific feature +If you're having issues with a specific feature, please reffer to the corresponding section of the help docs for detailed explinations of common errors and troubleshooting steps. If you cannot find an answer to your question, please reach out to Concierge via in-product chat or by emailing us at concierge@expensify.com. + +# Troubleshooting local issues +Is your webpage not loading? Try these steps: +- Try clicking [here](https://www.expensify.com/signout.php?clean=true), which will force a clean sign-out from the site, which can be very helpful in removing any stale data that can cause issues. +- Clear cookies & cache on your browser. +- Try using an Incognito or Private browsing window. +- Try on a different browser. + +# JavaScript Console +A developer console is a tool that logs information about the backend operations of the sites you visit and the applications you run. This information can help our developers solve any issue that you may experience. + +If you've been asked to provide a screenshot of your developer console, scroll down to find the instructions for the browser or application you're using. + +## Chrome + +- Keyboard shortcut + - Mac: Cmd + Option + J + - Windows: Ctrl + Shift + J +- From the menu: View > Developer > JavaScript Console + +## Firefox + +- Keyboard shortcut: + - Mac: Cmd + Option + K + - Windows: Ctrl + Shift + J +- From the menu: Menu Bar > More Tools > Web Developer Tools > Console tab + +## Safari + +Before opening the console you will need to enable it in Safari by clicking the Safari Menu > Settings > Advanced > and selecting the "Show features for web developers" checkbox. Once enabled, you can locate the console in the developer menu or open it using the keyboard shortcut: + +- Keyboard shortcut: Cmd + Option + C +- From the menu: Develop Menu > Show JavaScript Console + +## Microsoft Edge + +- Keyboard shortcut: + - Mac: Cmd + Option + J + - Windows: Ctrl + Shift + J +- From the menu: Right-click a webpage > Inspect > Console diff --git a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md index 2d2f1b5afddc..87b03e2e69ee 100644 --- a/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md +++ b/docs/articles/expensify-classic/workspaces/Enable-per-diem-expenses.md @@ -16,6 +16,7 @@ To enable and set per diem rates, 6. Create a .csv, .txt, .xls, or .xlsx spreadsheet containing four columns: Destination, Sub-rate, Amount, and Currency. Youā€™ll want a different row for each location that an employee may travel to, which may include states and/or countries to help account for cost differences across various locations. Here are some example templates you can use: - [Germany rates]({{site.url}}/assets/Files/Germany-per-diem.csv) - [Sweden rates]({{site.url}}/assets/Files/Sweden-per-diem.csv) + - [Finland rates]({{site.url}}/assets/Files/Finland-per-diem.csv) - [South Africa single rates]({{site.url}}/assets/Files/South-Africa-per-diem.csv) 7. Click **Import from spreadsheet**. 8. Click **Upload** to select your spreadsheet. diff --git a/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md b/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md new file mode 100644 index 000000000000..d30fa06bc059 --- /dev/null +++ b/docs/articles/new-expensify/billing-and-subscriptions/adding-payment-card-subscription-overview.md @@ -0,0 +1,31 @@ +Subscription Management +Under the subscriptions section of your account, you can manage your payment card details, view your current plan, add a billing card, and adjust your subscription size and renewal date. +To view or manage your subscription in New Expensify: +**Open the App**: Launch New Expensify on your device. +**Go to Account Settings**: Click your profile icon in the bottom-left corner. +**Find Workspaces**: Navigate to the Workspaces section. +**Open Subscriptions**: Click Subscription under Workspaces to view your subscription. + +## Add a Payment Card + +Look for the option to **Add Payment Card**. Enter your payment card details securely to ensure uninterrupted service. +[PLACEHOLDER for design image- default] +## Subscription Overview + +This is where you can view your current subscription plan and see details like the number of seats, billing information, and the next renewal date. + +**Subscription Settings**: + - **Auto-renew**: See when your subscription will automatically renew (e.g., **Renews on Nov 1, 2024**). +- **Auto-increase annual seats**: Here you can see how much you could save by automatically increasing seats to accommodate team members who exceed the current subscription size. + +**Note**: This will extend your annual subscription end date. +[PLACEHOLDER for design image- your plan] +## Early Cancellation Requests + +If you need to cancel your subscription early, you can find the **Request Early Cancellation** option in the same Subscriptions section. + +**Note**: Not all customers are eligible to cancel their subscription early. +[PLACEHOLDER for design image- billing] +## Pricing Information + +For more details on pricing plans, visit Billing Page [coming soon!] diff --git a/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md b/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md index 28cdc71ed80f..d5bc3ee20000 100644 --- a/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md +++ b/docs/articles/new-expensify/connections/sage-intacct/Configure-Sage-Intacct.md @@ -4,17 +4,16 @@ description: Configure the Import, Export, and Advanced settings for Expensify's order: 3 --- -# Configure Sage Intacct integration -## Step 1: Select entity (multi-entity setups only) +# Step 1: Select entity (multi-entity setups only) If you have a multi-entity setup in Sage Intacct, you will be able to select in Expensify which Sage Intacct entity to connect each workspace to. Each Expensify workspace can either be connected to a single entity or connected at the Top Level. To select or change the Sage Intacct entity that your Expensify workspace is connected to, navigate to the Accounting settings for your workspace and click **Entity** under the Sage Intacct connection. -## Step 2: Configure import settings +# Step 2: Configure import settings The following section will help you determine how data will be imported from Sage Intacct into Expensify. To change your import settings, navigate to the Accounting settings for your workspace, then click **Import** under the Sage Intacct connection. -### Expense Types / Chart of Accounts +## Expense Types / Chart of Accounts The categories in Expensify depend on how you choose to export out-of-pocket expenses: - If you choose to export out-of-pocket expenses as Expense Reports, your categories in Expensify will be imported from your Sage Intacct Expense Types @@ -22,13 +21,13 @@ The categories in Expensify depend on how you choose to export out-of-pocket exp You can disable unnecessary categories in Expensify by going to **Settings > Workspaces > [Workspace Name] > Categories**. Note that every expense must be coded with a Category, or it will fail to export. -### Billable Expenses +## Billable Expenses Enabling billable expenses allows you to map your expense types or accounts to items in Sage Intacct. To do this, youā€™ll need to enable the correct permissions on your Sage Intacct user or role. This may vary based on the modules you use in Sage Intacct, so you should enable read-only permissions for relevant modules such as Projects, Purchasing, Inventory Control, and Order Entry. Once permissions are set, you can map categories to specific items, which will then export to Sage Intacct. When an expense is marked as Billable in Expensify, users must select the correct billable Category (Item), or there will be an error during export. -### Standard dimensions: Departments, Classes, and Locations +## Standard dimensions: Departments, Classes, and Locations The Sage Intacct integration allows you to import standard dimensions into Expensify as tags, report fields, or using the Sage Intacct employee default. - **Sage Intacct Employee default:** This option is only available when exporting as expense reports. When this option is selected, nothing will be imported into Expensify - instead, the employee default will be applied to each expense upon export. @@ -39,7 +38,7 @@ New departments, classes, and locations must be added in Sage Intacct. Once impo Please note that when importing departments as tags, expense reports may show the tag name as "Tag" instead of "Department." -### Customers and Projects +## Customers and Projects The Sage Intacct integration allows you to import customers and projects into Expensify as Tags or Report Fields. - **Tags:** Employees can select the customer or project on each individual expense. @@ -48,12 +47,12 @@ The Sage Intacct integration allows you to import customers and projects into Ex New customers and projects must be added in Sage Intacct. Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**. -### Tax +## Tax The Sage Intacct integration supports native VAT and GST tax. To enable this feature, go to **Settings > Workspaces > [Workspace Name] > Accounting**, click **Import** under Sage Intacct, and enable Tax. Enabling this option will import your native tax rates from Sage Intacct into Expensify. From there, you can select default rates for each category under **Settings > Workspaces > [Workspace Name] > Categories**. For older Sage Intacct connections that don't show the Tax option, simply resync the connection by going to **Settings > Workspaces > [Workspace Name] > Accounting** and clicking the three dots next to Sage Intacct, and the tax toggle will appear. -### User-Defined Dimensions +## User-Defined Dimensions You can add User-Defined Dimensions (UDDs) to your workspace by locating the ā€œIntegration Nameā€ in Sage Intacct. Please note that you must be logged in as an administrator in Sage Intacct to find the required fields. To find the Integration Name in Sage Intacct: @@ -68,23 +67,23 @@ To find the Integration Name in Sage Intacct: Once imported, you can turn specific tags on or off under **Settings > Workspaces > [Workspace Name] > Tags**. You can turn specific report fields on or off under **Settings > Workspaces > [Workspace Name] > Report Fields**. -## Step 5: Configure export settings +# Step 3: Configure export settings To access export settings, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Export** under Sage Intacct. -### Preferred exporter +## Preferred exporter Any workspace admin can export reports to Sage Intacct. For auto-export, Concierge will export on behalf of the preferred exporter. The preferred exporter will also be notified of any expense reports that fail to export to Sage Intacct due to an error. -### Export date +## Export date You can choose which date to use for the records created in Sage Intacct. There are three date options: 1. **Date of last expense:** This will use the date of the previous expense on the report 1. **Export date:** The date you export the report to Sage Intacct 1. **Submitted date:** The date the employee submitted the report -### Export out-of-pocket expenses as +## Export out-of-pocket expenses as Out-of-pocket expenses can be exported to Sage Intacct as **expense reports** or as **vendor bills**. If you choose to export as expense reports, you can optionally select a **default vendor**, which will apply to reimbursable expenses that don't have a matching vendor in Sage Intacct. -### Export company card expenses as +## Export company card expenses as Company Card expenses are exported separately from out-of-pocket expenses, and can be exported to Sage Intacct as credit card charges** or as **vendor bills**. - **Credit card charges:** When exporting as credit card charges, you must select a credit card account. You can optionally select a default vendor, which will apply to company card expenses that don't have a matching vendor in Sage Intacct. @@ -93,13 +92,13 @@ Company Card expenses are exported separately from out-of-pocket expenses, and c If you centrally manage your company cards through Domains in Expensify Classic, you can export expenses from each individual card to a specific account in Sage Intacct in the Expensify Company Card settings. -### 6. Configure advanced settings +# Step 4: Configure advanced settings To access the advanced settings of the Sage Intacct integration, head to **Settings > Workspaces > [Workspace name] > Accounting** and click **Advanced** under Sage Intacct. Letā€™s review the different advanced settings and how they interact with the integration. -### Auto-sync +## Auto-sync We strongly recommend enabling auto-sync to ensure that the information in Sage Intacct and Expensify is always in sync. The following will occur when auto-sync is enabled: **Daily sync from Sage Intacct to Expensify:** Once a day, Expensify will sync any changes from Sage Intacct into Expensify. This includes any changes or additions to your Sage Intacct dimensions. @@ -108,7 +107,7 @@ We strongly recommend enabling auto-sync to ensure that the information in Sage **Reimbursement-sync:** If Sync Reimbursed Reports (more details below) is enabled, then we will sync the reimbursement status of reports between Expensify and Sage Intacct. -### Invite employees +## Invite employees Enabling this feature will invite all employees from the connected Sage Intacct entity to your Expensify workspace. Once imported, each employee who has not already been invited to that Expensify workspace will receive an email letting them know theyā€™ve been added to the workspace. In addition to inviting employees, this feature enables a custom set of approval workflow options, which you can manage in Expensify Classic: @@ -118,7 +117,7 @@ In addition to inviting employees, this feature enables a custom set of approval - **Configure Manually:** Employees will be imported, but all levels of approval must be manually configured in Expensify. If you enable this setting, you can configure approvals by going to **Settings > Workspaces > [Workspace Name] > People**. -### Sync reimbursed reports +## Sync reimbursed reports When Sync reimbursed reports is enabled, the reimbursement status will be synced between Expensify and Sage Intacct. **If you reimburse employees through Expensify:** Reimbursing an expense report will trigger auto-export to Sage Intacct. When the expense report is exported to Sage Intacct, a corresponding bill payment will also be created in Sage Intacct in the selected Cash and Cash Equivalents account. If you don't see the account you'd like to select in the dropdown list, please confirm that the account type is Cash and Cash Equivalents. @@ -127,7 +126,7 @@ When Sync reimbursed reports is enabled, the reimbursement status will be synced To ensure this feature works properly for expense reports, make sure that the account you choose within the settings matches the default account for Bill Payments in NetSuite. When exporting invoices, once marked as Paid, the payment is marked against the account selected after enabling the Collection Account setting. -## FAQ +# FAQ -### Will enabling auto-sync affect existing approved and reimbursed reports? +## Will enabling auto-sync affect existing approved and reimbursed reports? Auto-sync will only export newly approved reports to Sage Intacct. Any reports that were approved or reimbursed before enabling auto-sync will need to be manually exported in order to sync them to Sage Intacct. diff --git a/docs/articles/new-expensify/settings/General-product-troubleshooting.md b/docs/articles/new-expensify/settings/General-product-troubleshooting.md new file mode 100644 index 000000000000..57126628e04f --- /dev/null +++ b/docs/articles/new-expensify/settings/General-product-troubleshooting.md @@ -0,0 +1,48 @@ +--- +title: General Product Troubleshooting +description: How to troubleshoot a website issue +--- + + +# Issues with a specific feature +If you're having issues with a specific feature, please reffer to the corresponding section of the help docs for detailed explinations of common errors and troubleshooting steps. If you cannot find an answer to your question, please reach out to Concierge via in-product chat or by emailing us at concierge@expensify.com. + +# Troubleshooting local issues +Is your webpage not loading? Try these steps: +- Try clicking [here](https://www.expensify.com/signout.php?clean=true), which will force a clean sign-out from the site, which can be very helpful in removing any stale data that can cause issues. +- Clear cookies & cache on your browser. +- Try using an Incognito or Private browsing window. +- Try on a different browser. + +# JavaScript Console +A developer console is a tool that logs information about the backend operations of the sites you visit and the applications you run. This information can help our developers solve any issue that you may experience. + +If you've been asked to provide a screenshot of your developer console, scroll down to find the instructions for the browser or application you're using. + +## Chrome + +- Keyboard shortcut + - Mac: Cmd + Option + J + - Windows: Ctrl + Shift + J +- From the menu: View > Developer > JavaScript Console + +## Firefox + +- Keyboard shortcut: + - Mac: Cmd + Option + K + - Windows: Ctrl + Shift + J +- From the menu: Menu Bar > More Tools > Web Developer Tools > Console tab + +## Safari + +Before opening the console you will need to enable it in Safari by clicking the Safari Menu > Settings > Advanced > and selecting the "Show features for web developers" checkbox. Once enabled, you can locate the console in the developer menu or open it using the keyboard shortcut: + +- Keyboard shortcut: Cmd + Option + C +- From the menu: Develop Menu > Show JavaScript Console + +## Microsoft Edge + +- Keyboard shortcut: + - Mac: Cmd + Option + J + - Windows: Ctrl + Shift + J +- From the menu: Right-click a webpage > Inspect > Console diff --git a/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md b/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md new file mode 100644 index 000000000000..3ae1af36482b --- /dev/null +++ b/docs/articles/new-expensify/workspaces/Enable-Report-Fields.md @@ -0,0 +1,51 @@ +--- +title: Enable-Report-Fields.md +description: Enable and create Report Fields for your Workspaces +--- + +{% include info.html %} +Report fields are only available on the Control plan. You cannot create these report fields directly in Expensify if you are connected to an accounting integration (QuickBooks Online, QuickBooks Desktop, Intacct, Xero, or NetSuite). Please refer to the relevant article for instructions on creating fields within that system. +{% include end-info.html %} + +If you are not connected to an accounting integration, workspace Admins can add additional required report fields that allow you to specify header-level details like specific project names, business trip information, locations, and more. + +## Enable Report Fields +To enable report fields on a Workspace: + +1. Click Settings in the bottom left menu +2. Click Workspaces from the left-hand menu +3. Select the Workspace you want to enable Report Fields for +4. Go to More Features and toggle on Report Fields + +{% include info.html %} +If you are not already on a Control plan, you will be prompted to upgrade +{% include end-info.html %} + +## Create New Report Fields +To create new Report Fields: + +1. Click Settings in the bottom left menu +2. Click Workspaces from the left-hand menu +3. Select the Workspace you want to create Report Fields on +4. Click Report Fields on the lefthand menu (if you do not see this option, enable Report Fields by following the Enable Report Fields process above this) +5. Click ā€œAdd Fieldā€ in the top right corner +6. Click ā€œNameā€ and add a name your your Report Field +7. Click ā€œTypeā€ to select the Report Field type; you will have the following options: + - Text - Add a field for free-text input + - Date - Add a calendar for date selection + - List - Add a list of options to choose from + - To create values for your list, click List Vales > Add Values +8. Once you have added a Name and the Type, click Save at the bottom of the page + +## Edit or Delete Existing Report Fields +To edit or delete existing report fields Report Fields: + +1. Click Settings in the bottom left menu +2. Click Workspaces from the left-hand menu +3. Select the Workspace you want to edit Report Fields on +4. Click Report Fields on the lefthand menu +5. Click the Report Field you wish to edit or delete +6. Make the required edits in the right-hand panel, or select ā€œDeleteā€ + + + diff --git a/docs/articles/new-expensify/workspaces/Set-up-workflows.md b/docs/articles/new-expensify/workspaces/Set-up-workflows.md index 07d770d3ad50..7c44e3792122 100644 --- a/docs/articles/new-expensify/workspaces/Set-up-workflows.md +++ b/docs/articles/new-expensify/workspaces/Set-up-workflows.md @@ -17,6 +17,10 @@ Workflows are available for Collect and Control workspaces. Additionally, you mu 4. Click **More features** in the left menu. 5. Under the Spend section, enable the Workflows toggle. +![Click Account Settings > Workspaces > click on the workspace]({{site.url}}/assets/images/ExpensifyHelp-Workflows-1.png){:width="100%"} + +![Click More Features > Enable Workflows]({{site.url}}/assets/images/ExpensifyHelp-Workflows-2.png){:width="100%"} + # Select workflows You can choose to require additional approvals and/or allow delayed submissions. @@ -29,6 +33,8 @@ You can choose to require additional approvals and/or allow delayed submissions. -- With delayed submission **enabled**, all reimbursable and non-reimbursable expenses will be submitted at a designated frequency. -- If delay submission is **disabled**, all reimbursable and non-reimbursable expenses are submitted instantly. +![Enable workflow features]({{site.url}}/assets/images/ExpensifyHelp-Workflows-3.png){:width="100%"} + # Set up payment account The payments section is where youā€™ll set up your business bank account for payments of expenses and invoices. diff --git a/docs/articles/new-expensify/workspaces/Track-taxes.md b/docs/articles/new-expensify/workspaces/Track-taxes.md index fb4077679350..a8ea82873b9e 100644 --- a/docs/articles/new-expensify/workspaces/Track-taxes.md +++ b/docs/articles/new-expensify/workspaces/Track-taxes.md @@ -4,15 +4,13 @@ description: Set up tax rates in your Expensify workspace ---
-# Track taxes - Each Expensify workspace can be configured with one or more tax rates. Once tax rates are enabled on your workspace, all expenses will have a default tax rate applied based on the currency, and employees will be able to select the correct tax rate for each expense. -Tax rates are only available on the Control plan. Collect plan users will need to upgrade to Control for access to tag tax codes. +Tax rates are available on Collect and Control plans. -## Enable taxes on a workspace +# Enable taxes on a workspace -Tax codes are only available on the Control plan. Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below. +Taxes can be enabled on any workspace where the default currency is not USD. Please note that if you have a direct accounting integration, tax rates will be managed through the integration and cannot be manually enabled or disabled using the instructions below. **To enable taxes on your workspace:** @@ -24,7 +22,7 @@ Tax codes are only available on the Control plan. Taxes can be enabled on any wo After toggling on taxes, you will see a new **Taxes** option in the left menu. -## Manually add, delete, or edit tax rates +# Manually add, delete, or edit tax rates **To manually add a tax rate:** @@ -53,7 +51,7 @@ Please note: The workspace currency default rate cannot be deleted or disabled. Please note: The workspace currency default rate cannot be deleted or disabled. -## Change the default tax rates +# Change the default tax rates After enabling taxes in your workspace, you can set two default rates: diff --git a/docs/assets/Files/Finland-per-diem.csv b/docs/assets/Files/Finland-per-diem.csv new file mode 100644 index 000000000000..beb7abc5ef62 --- /dev/null +++ b/docs/assets/Files/Finland-per-diem.csv @@ -0,0 +1,1071 @@ +Destination,Amount,Currency,Subrate +*Exceptional,12.75,EUR,1 meal (no destination) +*Exceptional,15.5,EUR,2+ Meals (no destination) +*Exceptional,18,EUR,Travel (no destination) +*Finland,51,EUR,Full day (over 10 hours) +*Finland,24,EUR,Partial day (over 6 hours) +*Finland,51,EUR,Final day (over 6 hours) +*Finland,24,EUR,Final day (over 2 hours) +*Finland,16,EUR,Night Travel supplement +*Finland,-24,EUR,1 meal +*Finland,-51,EUR,2+ Meals +Afghanistan,59,EUR,Full day (over 24 hours) +Afghanistan,59,EUR,Final day (over 10 hours) +Afghanistan,29.5,EUR,Final day (over 2 hours) +Afghanistan,-29.5,EUR,2+ Meals +Afghanistan,16,EUR,Night Travel supplement +Albania,81,EUR,Full day (over 24 hours) +Albania,81,EUR,Final day (over 10 hours) +Albania,40.5,EUR,Final day (over 2 hours) +Albania,-40.5,EUR,2+ Meals +Albania,16,EUR,Night Travel supplement +Algeria,78,EUR,Full day (over 24 hours) +Algeria,78,EUR,Final day (over 10 hours) +Algeria,39,EUR,Final day (over 2 hours) +Algeria,-39,EUR,2+ Meals +Algeria,16,EUR,Night Travel supplement +Andorra,63,EUR,Full day (over 24 hours) +Andorra,63,EUR,Final day (over 10 hours) +Andorra,31.5,EUR,Final day (over 2 hours) +Andorra,-31.5,EUR,2+ Meals +Andorra,16,EUR,Night Travel supplement +Angola,71,EUR,Full day (over 24 hours) +Angola,71,EUR,Final day (over 10 hours) +Angola,35.5,EUR,Final day (over 2 hours) +Angola,-35.5,EUR,2+ Meals +Angola,16,EUR,Night Travel supplement +Antiqua and Barbuda,94,EUR,Full day (over 24 hours) +Antiqua and Barbuda,94,EUR,Final day (over 10 hours) +Antiqua and Barbuda,47,EUR,Final day (over 2 hours) +Antiqua and Barbuda,-47,EUR,2+ Meals +Antiqua and Barbuda,16,EUR,Night Travel supplement +"Any other country, not specified above",52,EUR,Full day (over 24 hours) +"Any other country, not specified above",52,EUR,Final day (over 10 hours) +"Any other country, not specified above",26,EUR,Final day (over 2 hours) +"Any other country, not specified above",-26,EUR,2+ Meals +"Any other country, not specified above",16,EUR,Night Travel supplement +Argentina,38,EUR,Full day (over 24 hours) +Argentina,38,EUR,Final day (over 10 hours) +Argentina,19,EUR,Final day (over 2 hours) +Argentina,-19,EUR,2+ Meals +Argentina,16,EUR,Night Travel supplement +Armenia,61,EUR,Full day (over 24 hours) +Armenia,61,EUR,Final day (over 10 hours) +Armenia,30.5,EUR,Final day (over 2 hours) +Armenia,-30.5,EUR,2+ Meals +Armenia,16,EUR,Night Travel supplement +Aruba,70,EUR,Full day (over 24 hours) +Aruba,70,EUR,Final day (over 10 hours) +Aruba,35,EUR,Final day (over 2 hours) +Aruba,-35,EUR,2+ Meals +Aruba,16,EUR,Night Travel supplement +Australia,74,EUR,Full day (over 24 hours) +Australia,74,EUR,Final day (over 10 hours) +Australia,37,EUR,Final day (over 2 hours) +Australia,-37,EUR,2+ Meals +Australia,16,EUR,Night Travel supplement +Austria,80,EUR,Full day (over 24 hours) +Austria,80,EUR,Final day (over 10 hours) +Austria,40,EUR,Final day (over 2 hours) +Austria,-40,EUR,2+ Meals +Austria,16,EUR,Night Travel supplement +Azerbaidzhan,70,EUR,Full day (over 24 hours) +Azerbaidzhan,70,EUR,Final day (over 10 hours) +Azerbaidzhan,35,EUR,Final day (over 2 hours) +Azerbaidzhan,-35,EUR,2+ Meals +Azerbaidzhan,16,EUR,Night Travel supplement +Azores,69,EUR,Full day (over 24 hours) +Azores,69,EUR,Final day (over 10 hours) +Azores,34.5,EUR,Final day (over 2 hours) +Azores,-34.5,EUR,2+ Meals +Azores,16,EUR,Night Travel supplement +Bahamas,91,EUR,Full day (over 24 hours) +Bahamas,91,EUR,Final day (over 10 hours) +Bahamas,45.5,EUR,Final day (over 2 hours) +Bahamas,-45.5,EUR,2+ Meals +Bahamas,16,EUR,Night Travel supplement +Bahrain,80,EUR,Full day (over 24 hours) +Bahrain,80,EUR,Final day (over 10 hours) +Bahrain,40,EUR,Final day (over 2 hours) +Bahrain,-40,EUR,2+ Meals +Bahrain,16,EUR,Night Travel supplement +Bangladesh,57,EUR,Full day (over 24 hours) +Bangladesh,57,EUR,Final day (over 10 hours) +Bangladesh,28.5,EUR,Final day (over 2 hours) +Bangladesh,-28.5,EUR,2+ Meals +Bangladesh,16,EUR,Night Travel supplement +Barbados,83,EUR,Full day (over 24 hours) +Barbados,83,EUR,Final day (over 10 hours) +Barbados,41.5,EUR,Final day (over 2 hours) +Barbados,-41.5,EUR,2+ Meals +Barbados,16,EUR,Night Travel supplement +Belarus,63,EUR,Full day (over 24 hours) +Belarus,63,EUR,Final day (over 10 hours) +Belarus,31.5,EUR,Final day (over 2 hours) +Belarus,-31.5,EUR,2+ Meals +Belarus,16,EUR,Night Travel supplement +Belgium,77,EUR,Full day (over 24 hours) +Belgium,77,EUR,Final day (over 10 hours) +Belgium,38.5,EUR,Final day (over 2 hours) +Belgium,-38.5,EUR,2+ Meals +Belgium,16,EUR,Night Travel supplement +Belize,52,EUR,Full day (over 24 hours) +Belize,52,EUR,Final day (over 10 hours) +Belize,26,EUR,Final day (over 2 hours) +Belize,-26,EUR,2+ Meals +Belize,16,EUR,Night Travel supplement +Benin,47,EUR,Full day (over 24 hours) +Benin,47,EUR,Final day (over 10 hours) +Benin,23.5,EUR,Final day (over 2 hours) +Benin,-23.5,EUR,2+ Meals +Benin,16,EUR,Night Travel supplement +Bermuda,90,EUR,Full day (over 24 hours) +Bermuda,90,EUR,Final day (over 10 hours) +Bermuda,45,EUR,Final day (over 2 hours) +Bermuda,-45,EUR,2+ Meals +Bermuda,16,EUR,Night Travel supplement +Bhutan,49,EUR,Full day (over 24 hours) +Bhutan,49,EUR,Final day (over 10 hours) +Bhutan,24.5,EUR,Final day (over 2 hours) +Bhutan,-24.5,EUR,2+ Meals +Bhutan,16,EUR,Night Travel supplement +Bolivia,48,EUR,Full day (over 24 hours) +Bolivia,48,EUR,Final day (over 10 hours) +Bolivia,24,EUR,Final day (over 2 hours) +Bolivia,-24,EUR,2+ Meals +Bolivia,16,EUR,Night Travel supplement +Bosnia and Hercegovina,54,EUR,Full day (over 24 hours) +Bosnia and Hercegovina,54,EUR,Final day (over 10 hours) +Bosnia and Hercegovina,27,EUR,Final day (over 2 hours) +Bosnia and Hercegovina,-27,EUR,2+ Meals +Bosnia and Hercegovina,16,EUR,Night Travel supplement +Botswana,41,EUR,Full day (over 24 hours) +Botswana,41,EUR,Final day (over 10 hours) +Botswana,20.5,EUR,Final day (over 2 hours) +Botswana,-20.5,EUR,2+ Meals +Botswana,16,EUR,Night Travel supplement +Brazil,80,EUR,Full day (over 24 hours) +Brazil,80,EUR,Final day (over 10 hours) +Brazil,40,EUR,Final day (over 2 hours) +Brazil,-40,EUR,2+ Meals +Brazil,16,EUR,Night Travel supplement +Brunei,45,EUR,Full day (over 24 hours) +Brunei,45,EUR,Final day (over 10 hours) +Brunei,22.5,EUR,Final day (over 2 hours) +Brunei,-22.5,EUR,2+ Meals +Brunei,16,EUR,Night Travel supplement +Bulgaria,64,EUR,Full day (over 24 hours) +Bulgaria,64,EUR,Final day (over 10 hours) +Bulgaria,32,EUR,Final day (over 2 hours) +Bulgaria,-32,EUR,2+ Meals +Bulgaria,16,EUR,Night Travel supplement +Burkina Faso,40,EUR,Full day (over 24 hours) +Burkina Faso,40,EUR,Final day (over 10 hours) +Burkina Faso,20,EUR,Final day (over 2 hours) +Burkina Faso,-20,EUR,2+ Meals +Burkina Faso,16,EUR,Night Travel supplement +Burundi,46,EUR,Full day (over 24 hours) +Burundi,46,EUR,Final day (over 10 hours) +Burundi,23,EUR,Final day (over 2 hours) +Burundi,-23,EUR,2+ Meals +Burundi,16,EUR,Night Travel supplement +Cambodia,67,EUR,Full day (over 24 hours) +Cambodia,67,EUR,Final day (over 10 hours) +Cambodia,33.5,EUR,Final day (over 2 hours) +Cambodia,-33.5,EUR,2+ Meals +Cambodia,16,EUR,Night Travel supplement +Cameroon,59,EUR,Full day (over 24 hours) +Cameroon,59,EUR,Final day (over 10 hours) +Cameroon,29.5,EUR,Final day (over 2 hours) +Cameroon,-29.5,EUR,2+ Meals +Cameroon,16,EUR,Night Travel supplement +Canada,82,EUR,Full day (over 24 hours) +Canada,82,EUR,Final day (over 10 hours) +Canada,41,EUR,Final day (over 2 hours) +Canada,-41,EUR,2+ Meals +Canada,16,EUR,Night Travel supplement +Canary Islands,71,EUR,Full day (over 24 hours) +Canary Islands,71,EUR,Final day (over 10 hours) +Canary Islands,35.5,EUR,Final day (over 2 hours) +Canary Islands,-35.5,EUR,2+ Meals +Canary Islands,16,EUR,Night Travel supplement +Cape Verde,45,EUR,Full day (over 24 hours) +Cape Verde,45,EUR,Final day (over 10 hours) +Cape Verde,22.5,EUR,Final day (over 2 hours) +Cape Verde,-22.5,EUR,2+ Meals +Cape Verde,16,EUR,Night Travel supplement +Central African Republic,101,EUR,Full day (over 24 hours) +Central African Republic,101,EUR,Final day (over 10 hours) +Central African Republic,50.5,EUR,Final day (over 2 hours) +Central African Republic,-50.5,EUR,2+ Meals +Central African Republic,16,EUR,Night Travel supplement +Chad,47,EUR,Full day (over 24 hours) +Chad,47,EUR,Final day (over 10 hours) +Chad,23.5,EUR,Final day (over 2 hours) +Chad,-23.5,EUR,2+ Meals +Chad,16,EUR,Night Travel supplement +Chile,56,EUR,Full day (over 24 hours) +Chile,56,EUR,Final day (over 10 hours) +Chile,28,EUR,Final day (over 2 hours) +Chile,-28,EUR,2+ Meals +Chile,16,EUR,Night Travel supplement +China,74,EUR,Full day (over 24 hours) +China,74,EUR,Final day (over 10 hours) +China,37,EUR,Final day (over 2 hours) +China,-37,EUR,2+ Meals +China,16,EUR,Night Travel supplement +Colombia,64,EUR,Full day (over 24 hours) +Colombia,64,EUR,Final day (over 10 hours) +Colombia,32,EUR,Final day (over 2 hours) +Colombia,-32,EUR,2+ Meals +Colombia,16,EUR,Night Travel supplement +Comoros,42,EUR,Full day (over 24 hours) +Comoros,42,EUR,Final day (over 10 hours) +Comoros,21,EUR,Final day (over 2 hours) +Comoros,-21,EUR,2+ Meals +Comoros,16,EUR,Night Travel supplement +Congo (Congo-Brazzaville),64,EUR,Full day (over 24 hours) +Congo (Congo-Brazzaville),64,EUR,Final day (over 10 hours) +Congo (Congo-Brazzaville),32,EUR,Final day (over 2 hours) +Congo (Congo-Brazzaville),-32,EUR,2+ Meals +Congo (Congo-Brazzaville),16,EUR,Night Travel supplement +"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Full day (over 24 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",51,EUR,Final day (over 10 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",25.5,EUR,Final day (over 2 hours) +"Congo, Democratic Republic of (Congo-Kinshasa)",-25.5,EUR,2+ Meals +"Congo, Democratic Republic of (Congo-Kinshasa)",16,EUR,Night Travel supplement +Cook Islands,70,EUR,Full day (over 24 hours) +Cook Islands,70,EUR,Final day (over 10 hours) +Cook Islands,35,EUR,Final day (over 2 hours) +Cook Islands,-35,EUR,2+ Meals +Cook Islands,16,EUR,Night Travel supplement +Costa Rica,65,EUR,Full day (over 24 hours) +Costa Rica,65,EUR,Final day (over 10 hours) +Costa Rica,32.5,EUR,Final day (over 2 hours) +Costa Rica,-32.5,EUR,2+ Meals +Costa Rica,16,EUR,Night Travel supplement +"CĆ“te dā€™Ivoire, Ivory Coast",80,EUR,Full day (over 24 hours) +"CĆ“te dā€™Ivoire, Ivory Coast",80,EUR,Final day (over 10 hours) +"CĆ“te dā€™Ivoire, Ivory Coast",40,EUR,Final day (over 2 hours) +"CĆ“te dā€™Ivoire, Ivory Coast",-40,EUR,2+ Meals +"CĆ“te dā€™Ivoire, Ivory Coast",16,EUR,Night Travel supplement +Croatia,69,EUR,Full day (over 24 hours) +Croatia,69,EUR,Final day (over 10 hours) +Croatia,34.5,EUR,Final day (over 2 hours) +Croatia,-34.5,EUR,2+ Meals +Croatia,16,EUR,Night Travel supplement +Cuba,68,EUR,Full day (over 24 hours) +Cuba,68,EUR,Final day (over 10 hours) +Cuba,34,EUR,Final day (over 2 hours) +Cuba,-34,EUR,2+ Meals +Cuba,16,EUR,Night Travel supplement +CuraƧao,58,EUR,Full day (over 24 hours) +CuraƧao,58,EUR,Final day (over 10 hours) +CuraƧao,29,EUR,Final day (over 2 hours) +CuraƧao,-29,EUR,2+ Meals +CuraƧao,16,EUR,Night Travel supplement +Cyprus,65,EUR,Full day (over 24 hours) +Cyprus,65,EUR,Final day (over 10 hours) +Cyprus,32.5,EUR,Final day (over 2 hours) +Cyprus,-32.5,EUR,2+ Meals +Cyprus,16,EUR,Night Travel supplement +Czech Republic,89,EUR,Full day (over 24 hours) +Czech Republic,89,EUR,Final day (over 10 hours) +Czech Republic,44.5,EUR,Final day (over 2 hours) +Czech Republic,-44.5,EUR,2+ Meals +Czech Republic,16,EUR,Night Travel supplement +Denmark,79,EUR,Full day (over 24 hours) +Denmark,79,EUR,Final day (over 10 hours) +Denmark,39.5,EUR,Final day (over 2 hours) +Denmark,-39.5,EUR,2+ Meals +Denmark,16,EUR,Night Travel supplement +Djibouti,83,EUR,Full day (over 24 hours) +Djibouti,83,EUR,Final day (over 10 hours) +Djibouti,41.5,EUR,Final day (over 2 hours) +Djibouti,-41.5,EUR,2+ Meals +Djibouti,16,EUR,Night Travel supplement +Dominica,61,EUR,Full day (over 24 hours) +Dominica,61,EUR,Final day (over 10 hours) +Dominica,30.5,EUR,Final day (over 2 hours) +Dominica,-30.5,EUR,2+ Meals +Dominica,16,EUR,Night Travel supplement +Dominican Republic,53,EUR,Full day (over 24 hours) +Dominican Republic,53,EUR,Final day (over 10 hours) +Dominican Republic,26.5,EUR,Final day (over 2 hours) +Dominican Republic,-26.5,EUR,2+ Meals +Dominican Republic,16,EUR,Night Travel supplement +East Timor,46,EUR,Full day (over 24 hours) +East Timor,46,EUR,Final day (over 10 hours) +East Timor,23,EUR,Final day (over 2 hours) +East Timor,-23,EUR,2+ Meals +East Timor,16,EUR,Night Travel supplement +Ecuador,63,EUR,Full day (over 24 hours) +Ecuador,63,EUR,Final day (over 10 hours) +Ecuador,31.5,EUR,Final day (over 2 hours) +Ecuador,-31.5,EUR,2+ Meals +Ecuador,16,EUR,Night Travel supplement +Egypt,66,EUR,Full day (over 24 hours) +Egypt,66,EUR,Final day (over 10 hours) +Egypt,33,EUR,Final day (over 2 hours) +Egypt,-33,EUR,2+ Meals +Egypt,16,EUR,Night Travel supplement +El Salvador,60,EUR,Full day (over 24 hours) +El Salvador,60,EUR,Final day (over 10 hours) +El Salvador,30,EUR,Final day (over 2 hours) +El Salvador,-30,EUR,2+ Meals +El Salvador,16,EUR,Night Travel supplement +Eritrea,95,EUR,Full day (over 24 hours) +Eritrea,95,EUR,Final day (over 10 hours) +Eritrea,47.5,EUR,Final day (over 2 hours) +Eritrea,-47.5,EUR,2+ Meals +Eritrea,16,EUR,Night Travel supplement +Estonia,75,EUR,Full day (over 24 hours) +Estonia,75,EUR,Final day (over 10 hours) +Estonia,37.5,EUR,Final day (over 2 hours) +Estonia,-37.5,EUR,2+ Meals +Estonia,16,EUR,Night Travel supplement +Eswatini,37,EUR,Full day (over 24 hours) +Eswatini,37,EUR,Final day (over 10 hours) +Eswatini,18.5,EUR,Final day (over 2 hours) +Eswatini,-18.5,EUR,2+ Meals +Eswatini,16,EUR,Night Travel supplement +Ethiopia,49,EUR,Full day (over 24 hours) +Ethiopia,49,EUR,Final day (over 10 hours) +Ethiopia,24.5,EUR,Final day (over 2 hours) +Ethiopia,-24.5,EUR,2+ Meals +Ethiopia,16,EUR,Night Travel supplement +Faroe Islands,61,EUR,Full day (over 24 hours) +Faroe Islands,61,EUR,Final day (over 10 hours) +Faroe Islands,30.5,EUR,Final day (over 2 hours) +Faroe Islands,-30.5,EUR,2+ Meals +Faroe Islands,16,EUR,Night Travel supplement +Fiji,52,EUR,Full day (over 24 hours) +Fiji,52,EUR,Final day (over 10 hours) +Fiji,26,EUR,Final day (over 2 hours) +Fiji,-26,EUR,2+ Meals +Fiji,16,EUR,Night Travel supplement +France,78,EUR,Full day (over 24 hours) +France,78,EUR,Final day (over 10 hours) +France,39,EUR,Final day (over 2 hours) +France,-39,EUR,2+ Meals +France,16,EUR,Night Travel supplement +Gabon,92,EUR,Full day (over 24 hours) +Gabon,92,EUR,Final day (over 10 hours) +Gabon,46,EUR,Final day (over 2 hours) +Gabon,-46,EUR,2+ Meals +Gabon,16,EUR,Night Travel supplement +Gambia,46,EUR,Full day (over 24 hours) +Gambia,46,EUR,Final day (over 10 hours) +Gambia,23,EUR,Final day (over 2 hours) +Gambia,-23,EUR,2+ Meals +Gambia,16,EUR,Night Travel supplement +Georgia,49,EUR,Full day (over 24 hours) +Georgia,49,EUR,Final day (over 10 hours) +Georgia,24.5,EUR,Final day (over 2 hours) +Georgia,-24.5,EUR,2+ Meals +Georgia,16,EUR,Night Travel supplement +Germany,76,EUR,Full day (over 24 hours) +Germany,76,EUR,Final day (over 10 hours) +Germany,38,EUR,Final day (over 2 hours) +Germany,-38,EUR,2+ Meals +Germany,16,EUR,Night Travel supplement +Ghana,47,EUR,Full day (over 24 hours) +Ghana,47,EUR,Final day (over 10 hours) +Ghana,23.5,EUR,Final day (over 2 hours) +Ghana,-23.5,EUR,2+ Meals +Ghana,16,EUR,Night Travel supplement +Greece,68,EUR,Full day (over 24 hours) +Greece,68,EUR,Final day (over 10 hours) +Greece,34,EUR,Final day (over 2 hours) +Greece,-34,EUR,2+ Meals +Greece,16,EUR,Night Travel supplement +Greenland,63,EUR,Full day (over 24 hours) +Greenland,63,EUR,Final day (over 10 hours) +Greenland,31.5,EUR,Final day (over 2 hours) +Greenland,-31.5,EUR,2+ Meals +Greenland,16,EUR,Night Travel supplement +Grenada,73,EUR,Full day (over 24 hours) +Grenada,73,EUR,Final day (over 10 hours) +Grenada,36.5,EUR,Final day (over 2 hours) +Grenada,-36.5,EUR,2+ Meals +Grenada,16,EUR,Night Travel supplement +Guadeloupe,53,EUR,Full day (over 24 hours) +Guadeloupe,53,EUR,Final day (over 10 hours) +Guadeloupe,26.5,EUR,Final day (over 2 hours) +Guadeloupe,-26.5,EUR,2+ Meals +Guadeloupe,16,EUR,Night Travel supplement +Guatemala,76,EUR,Full day (over 24 hours) +Guatemala,76,EUR,Final day (over 10 hours) +Guatemala,38,EUR,Final day (over 2 hours) +Guatemala,-38,EUR,2+ Meals +Guatemala,16,EUR,Night Travel supplement +Guinea,83,EUR,Full day (over 24 hours) +Guinea,83,EUR,Final day (over 10 hours) +Guinea,41.5,EUR,Final day (over 2 hours) +Guinea,-41.5,EUR,2+ Meals +Guinea,16,EUR,Night Travel supplement +Guinea-Bissau,41,EUR,Full day (over 24 hours) +Guinea-Bissau,41,EUR,Final day (over 10 hours) +Guinea-Bissau,20.5,EUR,Final day (over 2 hours) +Guinea-Bissau,-20.5,EUR,2+ Meals +Guinea-Bissau,16,EUR,Night Travel supplement +Guyana,51,EUR,Full day (over 24 hours) +Guyana,51,EUR,Final day (over 10 hours) +Guyana,25.5,EUR,Final day (over 2 hours) +Guyana,-25.5,EUR,2+ Meals +Guyana,16,EUR,Night Travel supplement +Haiti,62,EUR,Full day (over 24 hours) +Haiti,62,EUR,Final day (over 10 hours) +Haiti,31,EUR,Final day (over 2 hours) +Haiti,-31,EUR,2+ Meals +Haiti,16,EUR,Night Travel supplement +Honduras,58,EUR,Full day (over 24 hours) +Honduras,58,EUR,Final day (over 10 hours) +Honduras,29,EUR,Final day (over 2 hours) +Honduras,-29,EUR,2+ Meals +Honduras,16,EUR,Night Travel supplement +Hong Kong,86,EUR,Full day (over 24 hours) +Hong Kong,86,EUR,Final day (over 10 hours) +Hong Kong,43,EUR,Final day (over 2 hours) +Hong Kong,-43,EUR,2+ Meals +Hong Kong,16,EUR,Night Travel supplement +Hungary,69,EUR,Full day (over 24 hours) +Hungary,69,EUR,Final day (over 10 hours) +Hungary,34.5,EUR,Final day (over 2 hours) +Hungary,-34.5,EUR,2+ Meals +Hungary,16,EUR,Night Travel supplement +Iceland,92,EUR,Full day (over 24 hours) +Iceland,92,EUR,Final day (over 10 hours) +Iceland,46,EUR,Final day (over 2 hours) +Iceland,-46,EUR,2+ Meals +Iceland,16,EUR,Night Travel supplement +India,62,EUR,Full day (over 24 hours) +India,62,EUR,Final day (over 10 hours) +India,31,EUR,Final day (over 2 hours) +India,-31,EUR,2+ Meals +India,16,EUR,Night Travel supplement +Indonesia,57,EUR,Full day (over 24 hours) +Indonesia,57,EUR,Final day (over 10 hours) +Indonesia,28.5,EUR,Final day (over 2 hours) +Indonesia,-28.5,EUR,2+ Meals +Indonesia,16,EUR,Night Travel supplement +Iran,102,EUR,Full day (over 24 hours) +Iran,102,EUR,Final day (over 10 hours) +Iran,51,EUR,Final day (over 2 hours) +Iran,-51,EUR,2+ Meals +Iran,16,EUR,Night Travel supplement +Iraq,70,EUR,Full day (over 24 hours) +Iraq,70,EUR,Final day (over 10 hours) +Iraq,35,EUR,Final day (over 2 hours) +Iraq,-35,EUR,2+ Meals +Iraq,16,EUR,Night Travel supplement +Ireland,78,EUR,Full day (over 24 hours) +Ireland,78,EUR,Final day (over 10 hours) +Ireland,39,EUR,Final day (over 2 hours) +Ireland,-39,EUR,2+ Meals +Ireland,16,EUR,Night Travel supplement +Israel,88,EUR,Full day (over 24 hours) +Israel,88,EUR,Final day (over 10 hours) +Israel,44,EUR,Final day (over 2 hours) +Israel,-44,EUR,2+ Meals +Israel,16,EUR,Night Travel supplement +Istanbul,37,EUR,Full day (over 24 hours) +Istanbul,37,EUR,Final day (over 10 hours) +Istanbul,18.5,EUR,Final day (over 2 hours) +Istanbul,-18.5,EUR,2+ Meals +Istanbul,16,EUR,Night Travel supplement +Italy,76,EUR,Full day (over 24 hours) +Italy,76,EUR,Final day (over 10 hours) +Italy,38,EUR,Final day (over 2 hours) +Italy,-38,EUR,2+ Meals +Italy,16,EUR,Night Travel supplement +"Ivory Coast, CĆ“te dā€™Ivoire",80,EUR,Full day (over 24 hours) +"Ivory Coast, CĆ“te dā€™Ivoire",80,EUR,Final day (over 10 hours) +"Ivory Coast, CĆ“te dā€™Ivoire",40,EUR,Final day (over 2 hours) +"Ivory Coast, CĆ“te dā€™Ivoire",-40,EUR,2+ Meals +"Ivory Coast, CĆ“te dā€™Ivoire",16,EUR,Night Travel supplement +Jamaica,62,EUR,Full day (over 24 hours) +Jamaica,62,EUR,Final day (over 10 hours) +Jamaica,31,EUR,Final day (over 2 hours) +Jamaica,-31,EUR,2+ Meals +Jamaica,16,EUR,Night Travel supplement +Japan,66,EUR,Full day (over 24 hours) +Japan,66,EUR,Final day (over 10 hours) +Japan,33,EUR,Final day (over 2 hours) +Japan,-33,EUR,2+ Meals +Japan,16,EUR,Night Travel supplement +Jordania,90,EUR,Full day (over 24 hours) +Jordania,90,EUR,Final day (over 10 hours) +Jordania,45,EUR,Final day (over 2 hours) +Jordania,-45,EUR,2+ Meals +Jordania,16,EUR,Night Travel supplement +Kazakhstan,59,EUR,Full day (over 24 hours) +Kazakhstan,59,EUR,Final day (over 10 hours) +Kazakhstan,29.5,EUR,Final day (over 2 hours) +Kazakhstan,-29.5,EUR,2+ Meals +Kazakhstan,16,EUR,Night Travel supplement +Kenya,70,EUR,Full day (over 24 hours) +Kenya,70,EUR,Final day (over 10 hours) +Kenya,35,EUR,Final day (over 2 hours) +Kenya,-35,EUR,2+ Meals +Kenya,16,EUR,Night Travel supplement +"Korea, Democratic People's Republic (North Korea)",70,EUR,Full day (over 24 hours) +"Korea, Democratic People's Republic (North Korea)",70,EUR,Final day (over 10 hours) +"Korea, Democratic People's Republic (North Korea)",35,EUR,Final day (over 2 hours) +"Korea, Democratic People's Republic (North Korea)",-35,EUR,2+ Meals +"Korea, Democratic People's Republic (North Korea)",16,EUR,Night Travel supplement +"Korea, Republic of (South Korea)",87,EUR,Full day (over 24 hours) +"Korea, Republic of (South Korea)",87,EUR,Final day (over 10 hours) +"Korea, Republic of (South Korea)",43.5,EUR,Final day (over 2 hours) +"Korea, Republic of (South Korea)",-43.5,EUR,2+ Meals +"Korea, Republic of (South Korea)",16,EUR,Night Travel supplement +Kosovo,58,EUR,Full day (over 24 hours) +Kosovo,58,EUR,Final day (over 10 hours) +Kosovo,29,EUR,Final day (over 2 hours) +Kosovo,-29,EUR,2+ Meals +Kosovo,16,EUR,Night Travel supplement +Kuwait,84,EUR,Full day (over 24 hours) +Kuwait,84,EUR,Final day (over 10 hours) +Kuwait,42,EUR,Final day (over 2 hours) +Kuwait,-42,EUR,2+ Meals +Kuwait,16,EUR,Night Travel supplement +Kyrgystan,41,EUR,Full day (over 24 hours) +Kyrgystan,41,EUR,Final day (over 10 hours) +Kyrgystan,20.5,EUR,Final day (over 2 hours) +Kyrgystan,-20.5,EUR,2+ Meals +Kyrgystan,16,EUR,Night Travel supplement +Laos,32,EUR,Full day (over 24 hours) +Laos,32,EUR,Final day (over 10 hours) +Laos,16,EUR,Final day (over 2 hours) +Laos,-16,EUR,2+ Meals +Laos,16,EUR,Night Travel supplement +Latvia,73,EUR,Full day (over 24 hours) +Latvia,73,EUR,Final day (over 10 hours) +Latvia,36.5,EUR,Final day (over 2 hours) +Latvia,-36.5,EUR,2+ Meals +Latvia,16,EUR,Night Travel supplement +Lebanon,102,EUR,Full day (over 24 hours) +Lebanon,102,EUR,Final day (over 10 hours) +Lebanon,51,EUR,Final day (over 2 hours) +Lebanon,-51,EUR,2+ Meals +Lebanon,16,EUR,Night Travel supplement +Lesotho,34,EUR,Full day (over 24 hours) +Lesotho,34,EUR,Final day (over 10 hours) +Lesotho,17,EUR,Final day (over 2 hours) +Lesotho,-17,EUR,2+ Meals +Lesotho,16,EUR,Night Travel supplement +Liberia,60,EUR,Full day (over 24 hours) +Liberia,60,EUR,Final day (over 10 hours) +Liberia,30,EUR,Final day (over 2 hours) +Liberia,-30,EUR,2+ Meals +Liberia,16,EUR,Night Travel supplement +Libya,52,EUR,Full day (over 24 hours) +Libya,52,EUR,Final day (over 10 hours) +Libya,26,EUR,Final day (over 2 hours) +Libya,-26,EUR,2+ Meals +Libya,16,EUR,Night Travel supplement +Liechtenstein,79,EUR,Full day (over 24 hours) +Liechtenstein,79,EUR,Final day (over 10 hours) +Liechtenstein,39.5,EUR,Final day (over 2 hours) +Liechtenstein,-39.5,EUR,2+ Meals +Liechtenstein,16,EUR,Night Travel supplement +Lithuania,72,EUR,Full day (over 24 hours) +Lithuania,72,EUR,Final day (over 10 hours) +Lithuania,36,EUR,Final day (over 2 hours) +Lithuania,-36,EUR,2+ Meals +Lithuania,16,EUR,Night Travel supplement +London and Edinburgh,83,EUR,Full day (over 24 hours) +London and Edinburgh,83,EUR,Final day (over 10 hours) +London and Edinburgh,41.5,EUR,Final day (over 2 hours) +London and Edinburgh,-41.5,EUR,2+ Meals +London and Edinburgh,16,EUR,Night Travel supplement +Luxembourg,77,EUR,Full day (over 24 hours) +Luxembourg,77,EUR,Final day (over 10 hours) +Luxembourg,38.5,EUR,Final day (over 2 hours) +Luxembourg,-38.5,EUR,2+ Meals +Luxembourg,16,EUR,Night Travel supplement +Madagascar,45,EUR,Full day (over 24 hours) +Madagascar,45,EUR,Final day (over 10 hours) +Madagascar,22.5,EUR,Final day (over 2 hours) +Madagascar,-22.5,EUR,2+ Meals +Madagascar,16,EUR,Night Travel supplement +Madeira,68,EUR,Full day (over 24 hours) +Madeira,68,EUR,Final day (over 10 hours) +Madeira,34,EUR,Final day (over 2 hours) +Madeira,-34,EUR,2+ Meals +Madeira,16,EUR,Night Travel supplement +Malawi,77,EUR,Full day (over 24 hours) +Malawi,77,EUR,Final day (over 10 hours) +Malawi,38.5,EUR,Final day (over 2 hours) +Malawi,-38.5,EUR,2+ Meals +Malawi,16,EUR,Night Travel supplement +Malaysia,50,EUR,Full day (over 24 hours) +Malaysia,50,EUR,Final day (over 10 hours) +Malaysia,25,EUR,Final day (over 2 hours) +Malaysia,-25,EUR,2+ Meals +Malaysia,16,EUR,Night Travel supplement +Maldives,68,EUR,Full day (over 24 hours) +Maldives,68,EUR,Final day (over 10 hours) +Maldives,34,EUR,Final day (over 2 hours) +Maldives,-34,EUR,2+ Meals +Maldives,16,EUR,Night Travel supplement +Mali,47,EUR,Full day (over 24 hours) +Mali,47,EUR,Final day (over 10 hours) +Mali,23.5,EUR,Final day (over 2 hours) +Mali,-23.5,EUR,2+ Meals +Mali,16,EUR,Night Travel supplement +Malta,71,EUR,Full day (over 24 hours) +Malta,71,EUR,Final day (over 10 hours) +Malta,35.5,EUR,Final day (over 2 hours) +Malta,-35.5,EUR,2+ Meals +Malta,16,EUR,Night Travel supplement +Marshall Islands,65,EUR,Full day (over 24 hours) +Marshall Islands,65,EUR,Final day (over 10 hours) +Marshall Islands,32.5,EUR,Final day (over 2 hours) +Marshall Islands,-32.5,EUR,2+ Meals +Marshall Islands,16,EUR,Night Travel supplement +Martinique,55,EUR,Full day (over 24 hours) +Martinique,55,EUR,Final day (over 10 hours) +Martinique,27.5,EUR,Final day (over 2 hours) +Martinique,-27.5,EUR,2+ Meals +Martinique,16,EUR,Night Travel supplement +Mauritania,52,EUR,Full day (over 24 hours) +Mauritania,52,EUR,Final day (over 10 hours) +Mauritania,26,EUR,Final day (over 2 hours) +Mauritania,-26,EUR,2+ Meals +Mauritania,16,EUR,Night Travel supplement +Mauritius,53,EUR,Full day (over 24 hours) +Mauritius,53,EUR,Final day (over 10 hours) +Mauritius,26.5,EUR,Final day (over 2 hours) +Mauritius,-26.5,EUR,2+ Meals +Mauritius,16,EUR,Night Travel supplement +Mexico,81,EUR,Full day (over 24 hours) +Mexico,81,EUR,Final day (over 10 hours) +Mexico,40.5,EUR,Final day (over 2 hours) +Mexico,-40.5,EUR,2+ Meals +Mexico,16,EUR,Night Travel supplement +Micronesia,59,EUR,Full day (over 24 hours) +Micronesia,59,EUR,Final day (over 10 hours) +Micronesia,29.5,EUR,Final day (over 2 hours) +Micronesia,-29.5,EUR,2+ Meals +Micronesia,16,EUR,Night Travel supplement +Moldova,73,EUR,Full day (over 24 hours) +Moldova,73,EUR,Final day (over 10 hours) +Moldova,36.5,EUR,Final day (over 2 hours) +Moldova,-36.5,EUR,2+ Meals +Moldova,16,EUR,Night Travel supplement +Monaco,92,EUR,Full day (over 24 hours) +Monaco,92,EUR,Final day (over 10 hours) +Monaco,46,EUR,Final day (over 2 hours) +Monaco,-46,EUR,2+ Meals +Monaco,16,EUR,Night Travel supplement +Mongolia,42,EUR,Full day (over 24 hours) +Mongolia,42,EUR,Final day (over 10 hours) +Mongolia,21,EUR,Final day (over 2 hours) +Mongolia,-21,EUR,2+ Meals +Mongolia,16,EUR,Night Travel supplement +Montenegro,66,EUR,Full day (over 24 hours) +Montenegro,66,EUR,Final day (over 10 hours) +Montenegro,33,EUR,Final day (over 2 hours) +Montenegro,-33,EUR,2+ Meals +Montenegro,16,EUR,Night Travel supplement +Morocco,71,EUR,Full day (over 24 hours) +Morocco,71,EUR,Final day (over 10 hours) +Morocco,35.5,EUR,Final day (over 2 hours) +Morocco,-35.5,EUR,2+ Meals +Morocco,16,EUR,Night Travel supplement +Moscow,82,EUR,Full day (over 24 hours) +Moscow,82,EUR,Final day (over 10 hours) +Moscow,41,EUR,Final day (over 2 hours) +Moscow,-41,EUR,2+ Meals +Moscow,16,EUR,Night Travel supplement +Mozambique,53,EUR,Full day (over 24 hours) +Mozambique,53,EUR,Final day (over 10 hours) +Mozambique,26.5,EUR,Final day (over 2 hours) +Mozambique,-26.5,EUR,2+ Meals +Mozambique,16,EUR,Night Travel supplement +Myanmar (formerly Burma),58,EUR,Full day (over 24 hours) +Myanmar (formerly Burma),58,EUR,Final day (over 10 hours) +Myanmar (formerly Burma),29,EUR,Final day (over 2 hours) +Myanmar (formerly Burma),-29,EUR,2+ Meals +Myanmar (formerly Burma),16,EUR,Night Travel supplement +Namibia,36,EUR,Full day (over 24 hours) +Namibia,36,EUR,Final day (over 10 hours) +Namibia,18,EUR,Final day (over 2 hours) +Namibia,-18,EUR,2+ Meals +Namibia,16,EUR,Night Travel supplement +Nepal,51,EUR,Full day (over 24 hours) +Nepal,51,EUR,Final day (over 10 hours) +Nepal,25.5,EUR,Final day (over 2 hours) +Nepal,-25.5,EUR,2+ Meals +Nepal,16,EUR,Night Travel supplement +Netherlands,83,EUR,Full day (over 24 hours) +Netherlands,83,EUR,Final day (over 10 hours) +Netherlands,41.5,EUR,Final day (over 2 hours) +Netherlands,-41.5,EUR,2+ Meals +Netherlands,16,EUR,Night Travel supplement +"New York, Los Angeles, Washington",97,EUR,Full day (over 24 hours) +"New York, Los Angeles, Washington",97,EUR,Final day (over 10 hours) +"New York, Los Angeles, Washington",48.5,EUR,Final day (over 2 hours) +"New York, Los Angeles, Washington",-48.5,EUR,2+ Meals +"New York, Los Angeles, Washington",16,EUR,Night Travel supplement +New Zealand,74,EUR,Full day (over 24 hours) +New Zealand,74,EUR,Final day (over 10 hours) +New Zealand,37,EUR,Final day (over 2 hours) +New Zealand,-37,EUR,2+ Meals +New Zealand,16,EUR,Night Travel supplement +Nicaragua,51,EUR,Full day (over 24 hours) +Nicaragua,51,EUR,Final day (over 10 hours) +Nicaragua,25.5,EUR,Final day (over 2 hours) +Nicaragua,-25.5,EUR,2+ Meals +Nicaragua,16,EUR,Night Travel supplement +Niger,50,EUR,Full day (over 24 hours) +Niger,50,EUR,Final day (over 10 hours) +Niger,25,EUR,Final day (over 2 hours) +Niger,-25,EUR,2+ Meals +Niger,16,EUR,Night Travel supplement +Nigeria,78,EUR,Full day (over 24 hours) +Nigeria,78,EUR,Final day (over 10 hours) +Nigeria,39,EUR,Final day (over 2 hours) +Nigeria,-39,EUR,2+ Meals +Nigeria,16,EUR,Night Travel supplement +North Macedonia,64,EUR,Full day (over 24 hours) +North Macedonia,64,EUR,Final day (over 10 hours) +North Macedonia,32,EUR,Final day (over 2 hours) +North Macedonia,-32,EUR,2+ Meals +North Macedonia,16,EUR,Night Travel supplement +Norway,70,EUR,Full day (over 24 hours) +Norway,70,EUR,Final day (over 10 hours) +Norway,35,EUR,Final day (over 2 hours) +Norway,-35,EUR,2+ Meals +Norway,16,EUR,Night Travel supplement +Oman,74,EUR,Full day (over 24 hours) +Oman,74,EUR,Final day (over 10 hours) +Oman,37,EUR,Final day (over 2 hours) +Oman,-37,EUR,2+ Meals +Oman,16,EUR,Night Travel supplement +Pakistan,29,EUR,Full day (over 24 hours) +Pakistan,29,EUR,Final day (over 10 hours) +Pakistan,14.5,EUR,Final day (over 2 hours) +Pakistan,-14.5,EUR,2+ Meals +Pakistan,16,EUR,Night Travel supplement +Palau,99,EUR,Full day (over 24 hours) +Palau,99,EUR,Final day (over 10 hours) +Palau,49.5,EUR,Final day (over 2 hours) +Palau,-49.5,EUR,2+ Meals +Palau,16,EUR,Night Travel supplement +Palestinian territory,76,EUR,Full day (over 24 hours) +Palestinian territory,76,EUR,Final day (over 10 hours) +Palestinian territory,38,EUR,Final day (over 2 hours) +Palestinian territory,-38,EUR,2+ Meals +Palestinian territory,16,EUR,Night Travel supplement +Panama,61,EUR,Full day (over 24 hours) +Panama,61,EUR,Final day (over 10 hours) +Panama,30.5,EUR,Final day (over 2 hours) +Panama,-30.5,EUR,2+ Meals +Panama,16,EUR,Night Travel supplement +Papua New Guinea,76,EUR,Full day (over 24 hours) +Papua New Guinea,76,EUR,Final day (over 10 hours) +Papua New Guinea,38,EUR,Final day (over 2 hours) +Papua New Guinea,-38,EUR,2+ Meals +Papua New Guinea,16,EUR,Night Travel supplement +Paraguay,36,EUR,Full day (over 24 hours) +Paraguay,36,EUR,Final day (over 10 hours) +Paraguay,18,EUR,Final day (over 2 hours) +Paraguay,-18,EUR,2+ Meals +Paraguay,16,EUR,Night Travel supplement +Peru,52,EUR,Full day (over 24 hours) +Peru,52,EUR,Final day (over 10 hours) +Peru,26,EUR,Final day (over 2 hours) +Peru,-26,EUR,2+ Meals +Peru,16,EUR,Night Travel supplement +Philippines,69,EUR,Full day (over 24 hours) +Philippines,69,EUR,Final day (over 10 hours) +Philippines,34.5,EUR,Final day (over 2 hours) +Philippines,-34.5,EUR,2+ Meals +Philippines,16,EUR,Night Travel supplement +Poland,72,EUR,Full day (over 24 hours) +Poland,72,EUR,Final day (over 10 hours) +Poland,36,EUR,Final day (over 2 hours) +Poland,-36,EUR,2+ Meals +Poland,16,EUR,Night Travel supplement +Portugal,70,EUR,Full day (over 24 hours) +Portugal,70,EUR,Final day (over 10 hours) +Portugal,35,EUR,Final day (over 2 hours) +Portugal,-35,EUR,2+ Meals +Portugal,16,EUR,Night Travel supplement +Puerto Rico,70,EUR,Full day (over 24 hours) +Puerto Rico,70,EUR,Final day (over 10 hours) +Puerto Rico,35,EUR,Final day (over 2 hours) +Puerto Rico,-35,EUR,2+ Meals +Puerto Rico,16,EUR,Night Travel supplement +Qatar,78,EUR,Full day (over 24 hours) +Qatar,78,EUR,Final day (over 10 hours) +Qatar,39,EUR,Final day (over 2 hours) +Qatar,-39,EUR,2+ Meals +Qatar,16,EUR,Night Travel supplement +Romania,68,EUR,Full day (over 24 hours) +Romania,68,EUR,Final day (over 10 hours) +Romania,34,EUR,Final day (over 2 hours) +Romania,-34,EUR,2+ Meals +Romania,16,EUR,Night Travel supplement +Russian Federation,66,EUR,Full day (over 24 hours) +Russian Federation,66,EUR,Final day (over 10 hours) +Russian Federation,33,EUR,Final day (over 2 hours) +Russian Federation,-33,EUR,2+ Meals +Russian Federation,16,EUR,Night Travel supplement +Rwanda,37,EUR,Full day (over 24 hours) +Rwanda,37,EUR,Final day (over 10 hours) +Rwanda,18.5,EUR,Final day (over 2 hours) +Rwanda,-18.5,EUR,2+ Meals +Rwanda,16,EUR,Night Travel supplement +Saint Kitts and Nevis,68,EUR,Full day (over 24 hours) +Saint Kitts and Nevis,68,EUR,Final day (over 10 hours) +Saint Kitts and Nevis,34,EUR,Final day (over 2 hours) +Saint Kitts and Nevis,-34,EUR,2+ Meals +Saint Kitts and Nevis,16,EUR,Night Travel supplement +Saint Lucia,86,EUR,Full day (over 24 hours) +Saint Lucia,86,EUR,Final day (over 10 hours) +Saint Lucia,43,EUR,Final day (over 2 hours) +Saint Lucia,-43,EUR,2+ Meals +Saint Lucia,16,EUR,Night Travel supplement +Saint Vincent and the Grenadines,85,EUR,Full day (over 24 hours) +Saint Vincent and the Grenadines,85,EUR,Final day (over 10 hours) +Saint Vincent and the Grenadines,42.5,EUR,Final day (over 2 hours) +Saint Vincent and the Grenadines,-42.5,EUR,2+ Meals +Saint Vincent and the Grenadines,16,EUR,Night Travel supplement +Samoa,61,EUR,Full day (over 24 hours) +Samoa,61,EUR,Final day (over 10 hours) +Samoa,30.5,EUR,Final day (over 2 hours) +Samoa,-30.5,EUR,2+ Meals +Samoa,16,EUR,Night Travel supplement +San Marino,59,EUR,Full day (over 24 hours) +San Marino,59,EUR,Final day (over 10 hours) +San Marino,29.5,EUR,Final day (over 2 hours) +San Marino,-29.5,EUR,2+ Meals +San Marino,16,EUR,Night Travel supplement +Sao Tome and Principe,102,EUR,Full day (over 24 hours) +Sao Tome and Principe,102,EUR,Final day (over 10 hours) +Sao Tome and Principe,51,EUR,Final day (over 2 hours) +Sao Tome and Principe,-51,EUR,2+ Meals +Sao Tome and Principe,16,EUR,Night Travel supplement +Saudi Arabia,80,EUR,Full day (over 24 hours) +Saudi Arabia,80,EUR,Final day (over 10 hours) +Saudi Arabia,40,EUR,Final day (over 2 hours) +Saudi Arabia,-40,EUR,2+ Meals +Saudi Arabia,16,EUR,Night Travel supplement +Senegal,58,EUR,Full day (over 24 hours) +Senegal,58,EUR,Final day (over 10 hours) +Senegal,29,EUR,Final day (over 2 hours) +Senegal,-29,EUR,2+ Meals +Senegal,16,EUR,Night Travel supplement +Serbia,75,EUR,Full day (over 24 hours) +Serbia,75,EUR,Final day (over 10 hours) +Serbia,37.5,EUR,Final day (over 2 hours) +Serbia,-37.5,EUR,2+ Meals +Serbia,16,EUR,Night Travel supplement +Seychelles,87,EUR,Full day (over 24 hours) +Seychelles,87,EUR,Final day (over 10 hours) +Seychelles,43.5,EUR,Final day (over 2 hours) +Seychelles,-43.5,EUR,2+ Meals +Seychelles,16,EUR,Night Travel supplement +Sierra Leone,47,EUR,Full day (over 24 hours) +Sierra Leone,47,EUR,Final day (over 10 hours) +Sierra Leone,23.5,EUR,Final day (over 2 hours) +Sierra Leone,-23.5,EUR,2+ Meals +Sierra Leone,16,EUR,Night Travel supplement +Singapore,79,EUR,Full day (over 24 hours) +Singapore,79,EUR,Final day (over 10 hours) +Singapore,39.5,EUR,Final day (over 2 hours) +Singapore,-39.5,EUR,2+ Meals +Singapore,16,EUR,Night Travel supplement +Slovakia,79,EUR,Full day (over 24 hours) +Slovakia,79,EUR,Final day (over 10 hours) +Slovakia,39.5,EUR,Final day (over 2 hours) +Slovakia,-39.5,EUR,2+ Meals +Slovakia,16,EUR,Night Travel supplement +Slovenia,72,EUR,Full day (over 24 hours) +Slovenia,72,EUR,Final day (over 10 hours) +Slovenia,36,EUR,Final day (over 2 hours) +Slovenia,-36,EUR,2+ Meals +Slovenia,16,EUR,Night Travel supplement +Solomon Islands,63,EUR,Full day (over 24 hours) +Solomon Islands,63,EUR,Final day (over 10 hours) +Solomon Islands,31.5,EUR,Final day (over 2 hours) +Solomon Islands,-31.5,EUR,2+ Meals +Solomon Islands,16,EUR,Night Travel supplement +Somalia,86,EUR,Full day (over 24 hours) +Somalia,86,EUR,Final day (over 10 hours) +Somalia,43,EUR,Final day (over 2 hours) +Somalia,-43,EUR,2+ Meals +Somalia,16,EUR,Night Travel supplement +South Africa,50,EUR,Full day (over 24 hours) +South Africa,50,EUR,Final day (over 10 hours) +South Africa,25,EUR,Final day (over 2 hours) +South Africa,-25,EUR,2+ Meals +South Africa,16,EUR,Night Travel supplement +South Sudan,102,EUR,Full day (over 24 hours) +South Sudan,102,EUR,Final day (over 10 hours) +South Sudan,51,EUR,Final day (over 2 hours) +South Sudan,-51,EUR,2+ Meals +South Sudan,16,EUR,Night Travel supplement +Spain,74,EUR,Full day (over 24 hours) +Spain,74,EUR,Final day (over 10 hours) +Spain,37,EUR,Final day (over 2 hours) +Spain,-37,EUR,2+ Meals +Spain,16,EUR,Night Travel supplement +Sri Lanka,29,EUR,Full day (over 24 hours) +Sri Lanka,29,EUR,Final day (over 10 hours) +Sri Lanka,14.5,EUR,Final day (over 2 hours) +Sri Lanka,-14.5,EUR,2+ Meals +Sri Lanka,16,EUR,Night Travel supplement +St. Petersburg,76,EUR,Full day (over 24 hours) +St. Petersburg,76,EUR,Final day (over 10 hours) +St. Petersburg,38,EUR,Final day (over 2 hours) +St. Petersburg,-38,EUR,2+ Meals +St. Petersburg,16,EUR,Night Travel supplement +Sudan,83,EUR,Full day (over 24 hours) +Sudan,83,EUR,Final day (over 10 hours) +Sudan,41.5,EUR,Final day (over 2 hours) +Sudan,-41.5,EUR,2+ Meals +Sudan,16,EUR,Night Travel supplement +Suriname,78,EUR,Full day (over 24 hours) +Suriname,78,EUR,Final day (over 10 hours) +Suriname,39,EUR,Final day (over 2 hours) +Suriname,-39,EUR,2+ Meals +Suriname,16,EUR,Night Travel supplement +Sweden,64,EUR,Full day (over 24 hours) +Sweden,64,EUR,Final day (over 10 hours) +Sweden,32,EUR,Final day (over 2 hours) +Sweden,-32,EUR,2+ Meals +Sweden,16,EUR,Night Travel supplement +Switzerland,93,EUR,Full day (over 24 hours) +Switzerland,93,EUR,Final day (over 10 hours) +Switzerland,46.5,EUR,Final day (over 2 hours) +Switzerland,-46.5,EUR,2+ Meals +Switzerland,16,EUR,Night Travel supplement +Syria,91,EUR,Full day (over 24 hours) +Syria,91,EUR,Final day (over 10 hours) +Syria,45.5,EUR,Final day (over 2 hours) +Syria,-45.5,EUR,2+ Meals +Syria,16,EUR,Night Travel supplement +Tadzhikistan,35,EUR,Full day (over 24 hours) +Tadzhikistan,35,EUR,Final day (over 10 hours) +Tadzhikistan,17.5,EUR,Final day (over 2 hours) +Tadzhikistan,-17.5,EUR,2+ Meals +Tadzhikistan,16,EUR,Night Travel supplement +Taiwan,69,EUR,Full day (over 24 hours) +Taiwan,69,EUR,Final day (over 10 hours) +Taiwan,34.5,EUR,Final day (over 2 hours) +Taiwan,-34.5,EUR,2+ Meals +Taiwan,16,EUR,Night Travel supplement +Tanzania,54,EUR,Full day (over 24 hours) +Tanzania,54,EUR,Final day (over 10 hours) +Tanzania,27,EUR,Final day (over 2 hours) +Tanzania,-27,EUR,2+ Meals +Tanzania,16,EUR,Night Travel supplement +Thailand,63,EUR,Full day (over 24 hours) +Thailand,63,EUR,Final day (over 10 hours) +Thailand,31.5,EUR,Final day (over 2 hours) +Thailand,-31.5,EUR,2+ Meals +Thailand,16,EUR,Night Travel supplement +Togo,58,EUR,Full day (over 24 hours) +Togo,58,EUR,Final day (over 10 hours) +Togo,29,EUR,Final day (over 2 hours) +Togo,-29,EUR,2+ Meals +Togo,16,EUR,Night Travel supplement +Tonga,62,EUR,Full day (over 24 hours) +Tonga,62,EUR,Final day (over 10 hours) +Tonga,31,EUR,Final day (over 2 hours) +Tonga,-31,EUR,2+ Meals +Tonga,16,EUR,Night Travel supplement +Trinidad and Tobago,83,EUR,Full day (over 24 hours) +Trinidad and Tobago,83,EUR,Final day (over 10 hours) +Trinidad and Tobago,41.5,EUR,Final day (over 2 hours) +Trinidad and Tobago,-41.5,EUR,2+ Meals +Trinidad and Tobago,16,EUR,Night Travel supplement +Tunisia,61,EUR,Full day (over 24 hours) +Tunisia,61,EUR,Final day (over 10 hours) +Tunisia,30.5,EUR,Final day (over 2 hours) +Tunisia,-30.5,EUR,2+ Meals +Tunisia,16,EUR,Night Travel supplement +Turkey,35,EUR,Full day (over 24 hours) +Turkey,35,EUR,Final day (over 10 hours) +Turkey,17.5,EUR,Final day (over 2 hours) +Turkey,-17.5,EUR,2+ Meals +Turkey,16,EUR,Night Travel supplement +Turkmenistan,92,EUR,Full day (over 24 hours) +Turkmenistan,92,EUR,Final day (over 10 hours) +Turkmenistan,46,EUR,Final day (over 2 hours) +Turkmenistan,-46,EUR,2+ Meals +Turkmenistan,16,EUR,Night Travel supplement +Uganda,49,EUR,Full day (over 24 hours) +Uganda,49,EUR,Final day (over 10 hours) +Uganda,24.5,EUR,Final day (over 2 hours) +Uganda,-24.5,EUR,2+ Meals +Uganda,16,EUR,Night Travel supplement +Ukraine,64,EUR,Full day (over 24 hours) +Ukraine,64,EUR,Final day (over 10 hours) +Ukraine,32,EUR,Final day (over 2 hours) +Ukraine,-32,EUR,2+ Meals +Ukraine,16,EUR,Night Travel supplement +United Arab Emirates,73,EUR,Full day (over 24 hours) +United Arab Emirates,73,EUR,Final day (over 10 hours) +United Arab Emirates,36.5,EUR,Final day (over 2 hours) +United Arab Emirates,-36.5,EUR,2+ Meals +United Arab Emirates,16,EUR,Night Travel supplement +United Kingdom,79,EUR,Full day (over 24 hours) +United Kingdom,79,EUR,Final day (over 10 hours) +United Kingdom,39.5,EUR,Final day (over 2 hours) +United Kingdom,-39.5,EUR,2+ Meals +United Kingdom,16,EUR,Night Travel supplement +United States,89,EUR,Full day (over 24 hours) +United States,89,EUR,Final day (over 10 hours) +United States,44.5,EUR,Final day (over 2 hours) +United States,-44.5,EUR,2+ Meals +United States,16,EUR,Night Travel supplement +Uruguay,59,EUR,Full day (over 24 hours) +Uruguay,59,EUR,Final day (over 10 hours) +Uruguay,29.5,EUR,Final day (over 2 hours) +Uruguay,-29.5,EUR,2+ Meals +Uruguay,16,EUR,Night Travel supplement +Uzbekistan,32,EUR,Full day (over 24 hours) +Uzbekistan,32,EUR,Final day (over 10 hours) +Uzbekistan,16,EUR,Final day (over 2 hours) +Uzbekistan,-16,EUR,2+ Meals +Uzbekistan,16,EUR,Night Travel supplement +Vanuatu,70,EUR,Full day (over 24 hours) +Vanuatu,70,EUR,Final day (over 10 hours) +Vanuatu,35,EUR,Final day (over 2 hours) +Vanuatu,-35,EUR,2+ Meals +Vanuatu,16,EUR,Night Travel supplement +Venezuela,102,EUR,Full day (over 24 hours) +Venezuela,102,EUR,Final day (over 10 hours) +Venezuela,51,EUR,Final day (over 2 hours) +Venezuela,-51,EUR,2+ Meals +Venezuela,16,EUR,Night Travel supplement +Viet Nam,69,EUR,Full day (over 24 hours) +Viet Nam,69,EUR,Final day (over 10 hours) +Viet Nam,34.5,EUR,Final day (over 2 hours) +Viet Nam,-34.5,EUR,2+ Meals +Viet Nam,16,EUR,Night Travel supplement +Virgin Islands (USA),64,EUR,Full day (over 24 hours) +Virgin Islands (USA),64,EUR,Final day (over 10 hours) +Virgin Islands (USA),32,EUR,Final day (over 2 hours) +Virgin Islands (USA),-32,EUR,2+ Meals +Virgin Islands (USA),16,EUR,Night Travel supplement +Yemen,102,EUR,Full day (over 24 hours) +Yemen,102,EUR,Final day (over 10 hours) +Yemen,51,EUR,Final day (over 2 hours) +Yemen,-51,EUR,2+ Meals +Yemen,16,EUR,Night Travel supplement +Zambia,55,EUR,Full day (over 24 hours) +Zambia,55,EUR,Final day (over 10 hours) +Zambia,27.5,EUR,Final day (over 2 hours) +Zambia,-27.5,EUR,2+ Meals +Zambia,16,EUR,Night Travel supplement +Zimbabwe,102,EUR,Full day (over 24 hours) +Zimbabwe,102,EUR,Final day (over 10 hours) +Zimbabwe,51,EUR,Final day (over 2 hours) +Zimbabwe,-51,EUR,2+ Meals +Zimbabwe,16,EUR,Night Travel supplement diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Billing.png b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png new file mode 100644 index 000000000000..8a8c430e8020 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Billing.png differ diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Default.png b/docs/assets/images/ExpensifyHelp-Subscription-Default.png new file mode 100644 index 000000000000..ae289a8f29f8 Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Default.png differ diff --git a/docs/assets/images/ExpensifyHelp-Subscription-Details.png b/docs/assets/images/ExpensifyHelp-Subscription-Details.png new file mode 100644 index 000000000000..c96b39c4a3ec Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-Details.png differ diff --git a/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png new file mode 100644 index 000000000000..3d958edefd3c Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription-YourPlan.png differ diff --git a/docs/assets/images/ExpensifyHelp-Subscription.png b/docs/assets/images/ExpensifyHelp-Subscription.png new file mode 100644 index 000000000000..403dd276743f Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Subscription.png differ diff --git a/docs/assets/images/ExpensifyHelp-Workflows-1.png b/docs/assets/images/ExpensifyHelp-Workflows-1.png new file mode 100644 index 000000000000..b0841232f77c Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-1.png differ diff --git a/docs/assets/images/ExpensifyHelp-Workflows-2.png b/docs/assets/images/ExpensifyHelp-Workflows-2.png new file mode 100644 index 000000000000..f7e845fbe81c Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-2.png differ diff --git a/docs/assets/images/ExpensifyHelp-Workflows-3.png b/docs/assets/images/ExpensifyHelp-Workflows-3.png new file mode 100644 index 000000000000..dc3358ab484e Binary files /dev/null and b/docs/assets/images/ExpensifyHelp-Workflows-3.png differ diff --git a/docs/redirects.csv b/docs/redirects.csv index b47d6f2ae25c..783e13f8de07 100644 --- a/docs/redirects.csv +++ b/docs/redirects.csv @@ -412,7 +412,7 @@ https://community.expensify.com/discussion/5732/deep-dive-all-about-policy-categ https://community.expensify.com/discussion/5469/deep-dive-auto-categorize-card-expenses-with-default-categories,https://help.expensify.com/articles/expensify-classic/workspaces/Set-up-category-automation https://community.expensify.com/discussion/4708/how-to-set-up-and-add-single-tags,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags https://community.expensify.com/discussion/5756/how-to-set-up-and-manage-multi-level-tagging/,https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#multi-level-tags -https://community.expensify.com/discussion/5044/how-to-set-up-multiple-taxes-on-indirect-connections,https://help.expensify.com/articles/expensify-classic/workspaces/Tax-Tracking +https://community.expensify.com/discussion/5044/how-to-set-up-multiple-taxes-on-indirect-connections,https://help.expensify.com/articles/expensify-classic/connections/Indirect-Accounting-Integrations https://community.expensify.com/discussion/4643/how-to-invite-people-to-your-policy-using-a-join-link/,https://help.expensify.com/articles/expensify-classic/workspaces/Invite-members-and-assign-roles#invite-with-a-link https://community.expensify.com/discussion/5700/deep-dive-approval-workflow-overview,https://help.expensify.com/articles/expensify-classic/reports/Create-a-report-approval-workflow https://community.expensify.com/discussion/4804/how-to-set-up-concierge-report-approval,https://help.expensify.com/articles/expensify-classic/reports/Require-review-for-over-limit-expenses @@ -498,7 +498,7 @@ https://community.expensify.com/discussion/6827/what-s-happening-to-my-expensify https://community.expensify.com/discussion/6898/deep-dive-guide-to-billing,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview https://community.expensify.com/discussion/7231/how-to-export-invoices-to-netsuite,https://help.expensify.com/articles/new-expensify/connections/netsuite/Connect-to-NetSuite#export-invoices-to https://community.expensify.com/discussion/7335/faq-what-is-the-expensify-card-auto-reconciliation-process,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Send-an-invoice -https://community.expensify.com/discussion/7524/how-to-set-up-disable-2fa-for-your-domain,https://help.expensify.com/articles/expensify-classic/domains/Add-Domain-Members-and-Admins +https://community.expensify.com/discussion/7524/how-to-set-up-disable-2fa-for-your-domain,https://help.expensify.com/articles/expensify-classic/settings/Enable-two-factor-authentication https://community.expensify.com/discussion/7736/faq-troubleshooting-two-factor-authentication-issues,https://help.expensify.com/articles/expensify-classic/settings/Enable-two-factor-authentication https://community.expensify.com/discussion/7862/introducing-expensify-cash-open-source-financial-group-chat-built-with-react-native,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills https://community.expensify.com/discussion/7931/how-to-become-an-expensify-org-donor,https://www.expensify.org/donate @@ -575,6 +575,11 @@ https://help.expensify.com/articles/new-expensify/getting-started/Upgrade-to-a-C https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports-Invoices-and-Bills,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/payments/Reimburse-Reports https://help.expensify.com/articles/new-expensify/expenses-&-payments/pay-an-invoice.html,https://help.expensify.com/articles/new-expensify/expenses-&-payments/Pay-an-invoice https://community.expensify.com/discussion/4707/how-to-set-up-your-mobile-app,https://help.expensify.com/articles/expensify-classic/getting-started/Join-your-company's-workspace#download-the-mobile-app -https://community.expensify.com//discussion/6927/deep-dive-how-can-i-estimate-the-savings-applied-to-my-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview#savings-calculator https://community.expensify.com/discussion/5179/faq-what-does-a-policy-for-which-you-are-an-admin-has-out-of-date-billing-information-mean,https://help.expensify.com/articles/expensify-classic/expensify-billing/Out-of-date-Billing -https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations \ No newline at end of file +https://community.expensify.com/discussion/6179/setting-up-a-receipt-or-travel-integration-with-expensify,https://help.expensify.com/articles/expensify-classic/connections/Additional-Travel-Integrations +https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-US-Bank-Account,https://help.expensify.com/articles/expensify-classic/bank-accounts-and-payments/bank-accounts/Connect-Personal-Bank-Account +https://community.expensify.com//discussion/6927/deep-dive-how-can-i-estimate-the-savings-applied-to-my-bill,https://help.expensify.com/articles/expensify-classic/expensify-billing/Billing-Overview#savings-calculator +https://community.expensify.com/discussion/47/auto-sync-best-practices,https://help.expensify.com/expensify-classic/hubs/connections +https://community.expensify.com/discussion/6699/faq-troubleshooting-known-bank-specific-issues,https://help.expensify.com/expensify-classic/hubs/bank-accounts-and-payments/bank-accounts +https://community.expensify.com/discussion/4730/faq-expenses-are-exporting-to-the-wrong-accounts-whys-that,https://help.expensify.com/articles/expensify-classic/connect-credit-cards/company-cards/Company-Card-Settings +https://community.expensify.com/discussion/9000/how-to-integrate-with-deel,https://help.expensify.com/articles/expensify-classic/connections/Deel diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 15eb36c819b5..eed84acdc916 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,14 +15,14 @@ require 'ostruct' skip_docs opt_out_usage -KEY_GRADLE_APK_PATH = "gradleAPKOutputPath" -KEY_GRADLE_AAB_PATH = "gradleAABOutputPath" +KEY_GRADLE_APK_PATH = "apkPath" +KEY_S3_APK_PATH = "s3APKPath" +KEY_GRADLE_AAB_PATH = "aabPath" KEY_IPA_PATH = "ipaPath" KEY_DSYM_PATH = "dsymPath" -# Export environment variables in the parent shell. -# In a GitHub Actions environment, it will save the environment variables in the GITHUB_ENV file. -# In any other environment, it will save them to the current shell environment using the `export` command. +# Export environment variables to GITHUB_ENV +# If there's no GITHUB_ENV file set in the env, then this is a no-op def exportEnvVars(env_vars) github_env_path = ENV['GITHUB_ENV'] if github_env_path && File.exist?(github_env_path) @@ -33,13 +33,6 @@ def exportEnvVars(env_vars) file.puts "#{key}=#{value}" end end - else - puts "Saving environment variables in parent shell..." - env_vars.each do |key, value| - puts "#{key}=#{value}" - command = "export #{key}=#{value}" - system(command) - end end end @@ -102,7 +95,7 @@ platform :android do setGradleOutputsInEnv() end - lane :build_e2edelta do + lane :build_e2eDelta do ENV["ENVFILE"]="tests/e2e/.env.e2edelta" ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.ts" ENV["E2E_TESTING"]="true" @@ -139,7 +132,10 @@ platform :android do apk: ENV[KEY_GRADLE_APK_PATH], app_directory: "android/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"apk_path\": \"#{lane_context[SharedValues::S3_APK_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../android_paths.json") + puts "Saving S3 outputs in env..." + exportEnvVars({ + KEY_S3_APK_PATH => lane_context[SharedValues::S3_HTML_OUTPUT_PATH], + }) end desc "Upload app to Google Play for internal testing" diff --git a/help/README.md b/help/README.md new file mode 100644 index 000000000000..5145954923de --- /dev/null +++ b/help/README.md @@ -0,0 +1,50 @@ +# Welcome to New Help! +Here are some instructions on how to get started with New Help... + +## How to contribute +Expensify is an open source app, with its public Github repo hosted at https://github.com/Expensify/App. The newhelp.expensify.com website is a part of that same open source project. You can contribute to this helpsite in one of two ways: + +### The hard way: local dev environment +If you are a developer comfortable working on the command line, you can edit these files as follows: + +1. Fork https://github.com/Expensify/App repo + * `...tbd...` +2. Install Homebrew: https://brew.sh/ +3. Install `rbenv` using brew: + * `brew install rbenv` +4. Install ruby v3.3.4 using + * `rbenv install 3.3.4` +5. Set the your default ruby version using + * `rbenv global 3.3.4` +6. Install Jekyll and bundler gem + * `cd help` + * `gem install jekyll bundler` +7. Create a branch for your changes +8. Make your changes +9. Locally build and test your changes: + * `bundle exec jekyll build` +10. Push your changes + +### The easy way: edit on Github +If you don't want to set up your own local dev environment, feel free to just edit the help materials directly from Github: + +1. Open whatever file you want. +2. Replace `github.com` with `github.dev` in the URL +3. Edit away! + +## How to add a page +The current design of NewHelp.expensify.com is only to have a very small handful pages (one for each "product"), each of which is a markdown file stored in `/help` using the `product` template (defined in `/help/_layouts/product.html`). Accordingly, it's very unlikely you'll be adding a new page. + +The goal is to use a system named Jekyll to do the heavy lifting of not just converting that Markdown into HTML, but also allowing for deep linking of the headers, auto-linking mentions of those titles elsewhere, and a ton more. So, just write a basic Markdown file, and it should handle the rest. + +## How to preview the site online +Every PR pushed by an authorized Expensify employee or representative will automatically trigger a "build" of the site using a Github Action. This will [follow these steps](../.github/workflows/deployNewHelp.yml) to: +1. Start a new Ubuntu server +2. Check out the repo +3. Install Ruby and Jekyll +4. Build the entire site using Jekyll +5. Create a "preview" of the newly built site in Cloudflare +6. Record a link to that preview in the PR. + +## How to deploy the site for real +Whenever a PR that touches the `/help` directory is merged, it will re-run the build just like before. However, it will detect that this build is being run from the `main` branch, and thus push the changes to the `production` Cloudflare environment -- meaning, it will replace the contents hosted at https://newhelp.expensify.com diff --git a/help/_config.yml b/help/_config.yml index 9135a372964e..11091b1a8b7c 100644 --- a/help/_config.yml +++ b/help/_config.yml @@ -5,3 +5,6 @@ url: https://newhelp.expensify.com twitter_username: expensify github_username: expensify +# Ignore what's only used for the Github repo +exclude: + - README.md diff --git a/help/index.md b/help/index.md index e5d075402ecb..b198c5e20781 100644 --- a/help/index.md +++ b/help/index.md @@ -1,5 +1,7 @@ --- title: New Expensify Help --- + Pages: -* [Expensify Superapp](/superapp.html) + +- [Expensify Superapp](/superapp.html) diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 2de5297dd7fb..9b44440ea8ce 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.41 + 9.0.45 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.41.2 + 9.0.45.2 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 31fc4454214c..f3fe791cc8a1 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.41 + 9.0.45 CFBundleSignature ???? CFBundleVersion - 9.0.41.2 + 9.0.45.2 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 0abd6fae99d5..747676c49fc0 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.41 + 9.0.45 CFBundleVersion - 9.0.41.2 + 9.0.45.2 NSExtension NSExtensionPointIdentifier diff --git a/jest/setup.ts b/jest/setup.ts index 51385ad19e45..6901ad3c66f3 100644 --- a/jest/setup.ts +++ b/jest/setup.ts @@ -35,7 +35,7 @@ jest.mock('react-native/Libraries/LogBox/LogBox', () => ({ // Turn off the console logs for timing events. They are not relevant for unit tests and create a lot of noise jest.spyOn(console, 'debug').mockImplementation((...params: string[]) => { - if (params[0].startsWith('Timing:')) { + if (params.at(0)?.startsWith('Timing:')) { return; } diff --git a/package-lock.json b/package-lock.json index de12a7d768a9..22385023374c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.45-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.45-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -71,7 +71,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.14", + "react-fast-pdf": "1.0.15", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -93,7 +93,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.68", + "react-native-onyx": "2.0.71", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -112,12 +112,11 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "4.0.0-alpha.3", + "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", - "react-pdf": "9.1.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", @@ -210,13 +209,14 @@ "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", "csv-parse": "^5.5.5", + "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.4.6", "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.58", + "eslint-config-expensify": "^2.0.60", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", @@ -21225,6 +21225,12 @@ "dev": true, "license": "MIT" }, + "node_modules/csv-writer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/csv-writer/-/csv-writer-1.6.0.tgz", + "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==", + "dev": true + }, "node_modules/dag-map": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/dag-map/-/dag-map-1.0.2.tgz", @@ -22788,9 +22794,9 @@ } }, "node_modules/eslint-config-expensify": { - "version": "2.0.58", - "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.58.tgz", - "integrity": "sha512-iLDJeXwMYLcBRDnInVReHWjMUsNrHMnWfyoQbvuDTChcJANc+QzuDU0gdsDpBx2xjxVF0vckwEXnzmWcUW1Bpw==", + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/eslint-config-expensify/-/eslint-config-expensify-2.0.60.tgz", + "integrity": "sha512-VlulvhEasWeX2g+AXC4P91KA9czzX+aI3VSdJlZwm99GLOdfv7mM0JyO8vbqomjWNUxvLyJeJjmI02t2+fL/5Q==", "dev": true, "dependencies": { "@lwc/eslint-plugin-lwc": "^1.7.2", @@ -34250,10 +34256,11 @@ } }, "node_modules/react-fast-pdf": { - "version": "1.0.14", - "license": "MIT", + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/react-fast-pdf/-/react-fast-pdf-1.0.15.tgz", + "integrity": "sha512-xXrwIfRUD3KSRrBdfAeGnLZTf0kYUa+d6GGee1Hu0PFAv5QPBeF3tcV+DU+Cm/JMjSuR7s5g0KK9bePQ/xiQ+w==", "dependencies": { - "react-pdf": "^7.7.0", + "react-pdf": "^9.1.1", "react-window": "^1.8.10" }, "engines": { @@ -34262,7 +34269,7 @@ }, "peerDependencies": { "lodash": "4.x", - "prop-types": "15.x", + "pdfjs-dist": "4.x", "react": "18.x", "react-dom": "18.x" } @@ -35374,9 +35381,9 @@ } }, "node_modules/react-native-onyx": { - "version": "2.0.68", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.68.tgz", - "integrity": "sha512-KzcG8r6oIHRZhtiGu2XtHwYLm6eTp74r4NyhIawinfJEgcd1YMC6KdrVMqd1J7zFLTuBXPhtjiugTbUhXraFag==", + "version": "2.0.71", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-2.0.71.tgz", + "integrity": "sha512-LE3CYMdyRrXFrd+PbPpYFqQAQ5CE7EzibdM2ljhHrnTp3pDjtOjhXBjjVNV1rujgkvX56QXfX63ag/DRfqPMNw==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -35667,9 +35674,8 @@ } }, "node_modules/react-native-view-shot": { - "version": "4.0.0-alpha.3", - "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.0-alpha.3.tgz", - "integrity": "sha512-o0KVgC6XZqWmLUKVc4q6Ev1QW1kA4g/TF45wj8CgYS13wJuWYJ+nPGCHT9C2jvX/L65mtTollKXp2L8hbDnelg==", + "version": "3.8.0", + "license": "MIT", "dependencies": { "html2canvas": "^1.4.1" }, @@ -36654,9 +36660,9 @@ } }, "node_modules/react-pdf": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.0.tgz", - "integrity": "sha512-KhPDQE3QshkLdS3b48S5Bldv0N5flob6qwvsiADWdZOS5TMDaIrkRtEs+Dyl6ubRf2jTf9jWmFb6RjWu46lSSg==", + "version": "9.1.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-9.1.1.tgz", + "integrity": "sha512-Cn3RTJZMqVOOCgLMRXDamLk4LPGfyB2Np3OwQAUjmHIh47EpuGW1OpAA1Z1GVDLoHx4d5duEDo/YbUkDbr4QFQ==", "dependencies": { "clsx": "^2.0.0", "dequal": "^2.0.3", diff --git a/package.json b/package.json index 4c1bf98cc976..1387bda002d6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.41-2", + "version": "9.0.45-2", "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.", @@ -30,10 +30,8 @@ "createDocsRoutes": "ts-node .github/scripts/createDocsRoutes.ts", "detectRedirectCycle": "ts-node .github/scripts/detectRedirectCycle.ts", "desktop-build-adhoc": "./scripts/build-desktop.sh adhoc", - "ios-build": "fastlane ios build_unsigned", - "android-build": "fastlane android build_local", - "android-build-e2e": "bundle exec fastlane android build_e2e", - "android-build-e2edelta": "bundle exec fastlane android build_e2edelta", + "ios-build": "bundle exec fastlane ios build_unsigned", + "android-build": "bundle exec fastlane android build_local", "test": "TZ=utc NODE_OPTIONS=--experimental-vm-modules jest", "typecheck": "NODE_OPTIONS=--max_old_space_size=8192 tsc", "lint": "NODE_OPTIONS=--max_old_space_size=8192 eslint . --max-warnings=0 --cache --cache-location=node_modules/.cache/eslint", @@ -128,7 +126,7 @@ "react-content-loader": "^7.0.0", "react-dom": "18.3.1", "react-error-boundary": "^4.0.11", - "react-fast-pdf": "1.0.14", + "react-fast-pdf": "1.0.15", "react-map-gl": "^7.1.3", "react-native": "0.75.2", "react-native-android-location-enabler": "^2.0.1", @@ -150,7 +148,7 @@ "react-native-launch-arguments": "^4.0.2", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "2.0.68", + "react-native-onyx": "2.0.71", "react-native-pager-view": "6.4.1", "react-native-pdf": "6.7.3", "react-native-performance": "^5.1.0", @@ -169,27 +167,17 @@ "react-native-svg": "15.6.0", "react-native-tab-view": "^3.5.2", "react-native-url-polyfill": "^2.0.0", - "react-native-view-shot": "4.0.0-alpha.3", + "react-native-view-shot": "3.8.0", "react-native-vision-camera": "4.0.0-beta.13", "react-native-web": "^0.19.12", "react-native-web-sound": "^0.1.3", "react-native-webview": "13.8.6", - "react-pdf": "9.1.0", "react-plaid-link": "3.3.2", "react-web-config": "^1.0.0", "react-webcam": "^7.1.1", "react-window": "^1.8.9" }, "devDependencies": { - "@fullstory/babel-plugin-react-native": "^1.2.1", - "@kie/act-js": "^2.6.2", - "@kie/mock-github": "2.0.1", - "@vue/preload-webpack-plugin": "^2.0.0", - "jest-expo": "51.0.4", - "jest-when": "^3.5.2", - "react-compiler-runtime": "file:./lib/react-compiler-runtime", - "semver": "7.5.2", - "xlsx": "file:vendor/xlsx-0.20.3.tgz", "@actions/core": "1.10.0", "@actions/github": "5.1.1", "@babel/core": "^7.20.0", @@ -198,6 +186,7 @@ "@babel/plugin-proposal-export-namespace-from": "^7.18.9", "@babel/plugin-proposal-private-methods": "^7.18.6", "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-transform-class-properties": "^7.25.4", "@babel/preset-env": "^7.20.0", "@babel/preset-flow": "^7.12.13", "@babel/preset-react": "^7.10.4", @@ -209,7 +198,10 @@ "@dword-design/eslint-plugin-import-alias": "^5.0.0", "@electron/notarize": "^2.1.0", "@fullstory/babel-plugin-annotate-react": "^2.3.0", + "@fullstory/babel-plugin-react-native": "^1.2.1", "@jest/globals": "^29.5.0", + "@kie/act-js": "^2.6.2", + "@kie/mock-github": "2.0.1", "@ngneat/falso": "^7.1.1", "@octokit/core": "4.0.4", "@octokit/plugin-paginate-rest": "3.1.0", @@ -258,6 +250,7 @@ "@typescript-eslint/eslint-plugin": "^7.13.1", "@typescript-eslint/parser": "^7.13.1", "@vercel/ncc": "0.38.1", + "@vue/preload-webpack-plugin": "^2.0.0", "@welldone-software/why-did-you-render": "7.0.1", "ajv-cli": "^5.0.0", "babel-jest": "29.4.1", @@ -265,37 +258,39 @@ "babel-plugin-module-resolver": "^5.0.0", "babel-plugin-react-compiler": "0.0.0-experimental-334f00b-20240725", "babel-plugin-react-native-web": "^0.18.7", - "@babel/plugin-transform-class-properties": "^7.25.4", "babel-plugin-transform-remove-console": "^6.9.4", "clean-webpack-plugin": "^4.0.0", "concurrently": "^8.2.2", "copy-webpack-plugin": "^10.1.0", "css-loader": "^6.7.2", "csv-parse": "^5.5.5", + "csv-writer": "^1.6.0", "diff-so-fancy": "^1.3.0", "dotenv": "^16.0.3", "electron": "^29.4.6", "electron-builder": "25.0.0", "eslint": "^8.57.0", "eslint-config-airbnb-typescript": "^18.0.0", - "eslint-config-expensify": "^2.0.58", + "eslint-config-expensify": "^2.0.60", "eslint-config-prettier": "^9.1.0", "eslint-plugin-deprecation": "^3.0.0", "eslint-plugin-jest": "^28.6.0", "eslint-plugin-jsdoc": "^46.2.6", + "eslint-plugin-lodash": "^7.4.0", "eslint-plugin-react-compiler": "0.0.0-experimental-9ed098e-20240725", "eslint-plugin-react-native-a11y": "^3.3.0", "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^6.2.2", "eslint-plugin-you-dont-need-lodash-underscore": "^6.14.0", - "eslint-plugin-lodash": "^7.4.0", "html-webpack-plugin": "^5.5.0", "http-server": "^14.1.1", "jest": "29.4.1", "jest-circus": "29.4.1", "jest-cli": "29.4.1", "jest-environment-jsdom": "^29.4.1", + "jest-expo": "51.0.4", "jest-transformer-svg": "^2.0.1", + "jest-when": "^3.5.2", "link": "^2.1.1", "memfs": "^4.6.0", "onchange": "^7.1.0", @@ -306,10 +301,12 @@ "prettier": "^2.8.8", "pusher-js-mock": "^0.3.3", "react-compiler-healthcheck": "^0.0.0-experimental-ab3118d-20240725", + "react-compiler-runtime": "file:./lib/react-compiler-runtime", "react-is": "^18.3.1", "react-native-clean-project": "^4.0.0-alpha4.0", "react-test-renderer": "18.3.1", "reassure": "^1.0.0-rc.4", + "semver": "7.5.2", "setimmediate": "^1.0.5", "shellcheck": "^1.1.0", "source-map": "^0.7.4", @@ -326,7 +323,8 @@ "webpack-bundle-analyzer": "^4.5.0", "webpack-cli": "^5.0.4", "webpack-dev-server": "^5.0.4", - "webpack-merge": "^5.8.0" + "webpack-merge": "^5.8.0", + "xlsx": "file:vendor/xlsx-0.20.3.tgz" }, "overrides": { "react-native": "0.75.2", @@ -338,7 +336,6 @@ "yargs-parser": "21.1.1", "@expo/config-plugins": "8.0.4", "ws": "8.17.1", - "react-pdf": "9.1.0", "micromatch": "4.0.8", "json5": "2.2.2", "loader-utils": "2.0.4", diff --git a/patches/react-fast-pdf+1.0.14.patch b/patches/react-fast-pdf+1.0.14.patch deleted file mode 100644 index 78a47bfb1b58..000000000000 --- a/patches/react-fast-pdf+1.0.14.patch +++ /dev/null @@ -1,13 +0,0 @@ -diff --git a/node_modules/react-fast-pdf/dist/PDFPreviewer.js b/node_modules/react-fast-pdf/dist/PDFPreviewer.js -index 4407807..ea3964d 100644 ---- a/node_modules/react-fast-pdf/dist/PDFPreviewer.js -+++ b/node_modules/react-fast-pdf/dist/PDFPreviewer.js -@@ -28,7 +28,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { - Object.defineProperty(exports, "__esModule", { value: true }); - // @ts-expect-error - This line imports a module from 'pdfjs-dist' package which lacks TypeScript typings. - // eslint-disable-next-line import/no-extraneous-dependencies --const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker")); -+const pdf_worker_1 = __importDefault(require("pdfjs-dist/legacy/build/pdf.worker.mjs")); - const react_1 = __importStar(require("react")); - const times_1 = __importDefault(require("lodash/times")); - const prop_types_1 = __importDefault(require("prop-types")); diff --git a/patches/react-native-pdf+6.7.3.patch b/patches/react-native-pdf+6.7.3+001+initial.patch similarity index 100% rename from patches/react-native-pdf+6.7.3.patch rename to patches/react-native-pdf+6.7.3+001+initial.patch diff --git a/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch new file mode 100644 index 000000000000..1061335b85fe --- /dev/null +++ b/patches/react-native-pdf+6.7.3+002+fix-incorrect-decoding.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/react-native-pdf/index.js b/node_modules/react-native-pdf/index.js +index bea7af8..bf767c9 100644 +--- a/node_modules/react-native-pdf/index.js ++++ b/node_modules/react-native-pdf/index.js +@@ -233,7 +233,7 @@ export default class Pdf extends Component { + } else { + if (this._mounted) { + this.setState({ +- path: unescape(uri.replace(/file:\/\//i, '')), ++ path: decodeURIComponent(uri.replace(/file:\/\//i, '')), + isDownloaded: true, + }); + } diff --git a/patches/react-native-vision-camera+4.0.0-beta.13.patch b/patches/react-native-vision-camera+4.0.0-beta.13+001+rn75-compatibility.patch similarity index 100% rename from patches/react-native-vision-camera+4.0.0-beta.13.patch rename to patches/react-native-vision-camera+4.0.0-beta.13+001+rn75-compatibility.patch diff --git a/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch b/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch new file mode 100644 index 000000000000..ac9bda68f9d9 --- /dev/null +++ b/patches/react-native-vision-camera+4.0.0-beta.13+002+native-stack-unmount-recycle-camera-session.patch @@ -0,0 +1,55 @@ +diff --git a/node_modules/react-native-vision-camera/ios/RNCameraView.mm b/node_modules/react-native-vision-camera/ios/RNCameraView.mm +index b90427e..0be4171 100644 +--- a/node_modules/react-native-vision-camera/ios/RNCameraView.mm ++++ b/node_modules/react-native-vision-camera/ios/RNCameraView.mm +@@ -34,26 +34,43 @@ + (ComponentDescriptorProvider)componentDescriptorProvider + return concreteComponentDescriptorProvider(); + } + +-- (instancetype)initWithFrame:(CGRect)frame +-{ +- self = [super initWithFrame:frame]; +-if (self) { +- static const auto defaultProps = std::make_shared(); ++- (void) initCamera { ++ static const auto defaultProps = std::make_shared(); + _props = defaultProps; + +- //The remaining part of the initializer is standard Objective-C code to create views and layout them with AutoLayout. Here we can change whatever we want to. ++ // The remaining part of the initializer is standard bjective-C code to create views and layout them with utoLayout. Here we can change whatever we want to. + _view = [[CameraView alloc] init]; + _view.delegate = self; + + self.contentView = _view; + } + +-return self; ++- (instancetype)initWithFrame:(CGRect)frame ++{ ++ self = [super initWithFrame:frame]; ++ if (self) { ++ [self initCamera]; ++ } ++ ++ return self; ++} ++ ++- (void) prepareForRecycle { ++ [super prepareForRecycle]; ++ ++ self.contentView = _view; ++ _view.delegate = nil; ++ _view = nil; ++ self.contentView = nil; + } + + // why we need this func -> https://reactnative.dev/docs/next/the-new-architecture/pillars-fabric-components#write-the-native-ios-code + - (void)updateProps:(Props::Shared const &)props oldProps:(Props::Shared const &)oldProps + { ++ if (_view == nil) { ++ [self initCamera]; ++ } ++ + const auto &newViewProps = *std::static_pointer_cast(props); + const auto &oldViewProps = *std::static_pointer_cast(_props); + diff --git a/patches/react-pdf+9.1.0.patch b/patches/react-pdf+9.1.0.patch deleted file mode 100644 index f046202de9c2..000000000000 --- a/patches/react-pdf+9.1.0.patch +++ /dev/null @@ -1,24 +0,0 @@ -diff --git a/node_modules/react-pdf/dist/cjs/Document.js b/node_modules/react-pdf/dist/cjs/Document.js -index ed7114d..43d648b 100644 ---- a/node_modules/react-pdf/dist/cjs/Document.js -+++ b/node_modules/react-pdf/dist/cjs/Document.js -@@ -281,6 +281,7 @@ const Document = (0, react_1.forwardRef)(function Document(_a, ref) { - pdfDispatch({ type: 'REJECT', error }); - }); - return () => { -+ loadingTask._worker.destroy(); - loadingTask.destroy(); - }; - }, [options, pdfDispatch, source]); -diff --git a/node_modules/react-pdf/dist/esm/Document.js b/node_modules/react-pdf/dist/esm/Document.js -index 997a370..894e3c9 100644 ---- a/node_modules/react-pdf/dist/esm/Document.js -+++ b/node_modules/react-pdf/dist/esm/Document.js -@@ -253,6 +253,7 @@ const Document = forwardRef(function Document(_a, ref) { - pdfDispatch({ type: 'REJECT', error }); - }); - return () => { -+ loadingTask._worker.destroy(); - loadingTask.destroy(); - }; - }, [options, pdfDispatch, source]); diff --git a/scripts/aggregateGitHubDataFromUpwork.ts b/scripts/aggregateGitHubDataFromUpwork.ts new file mode 100644 index 000000000000..f47b2b43e5cc --- /dev/null +++ b/scripts/aggregateGitHubDataFromUpwork.ts @@ -0,0 +1,175 @@ +/** + * This script is used for categorizing upwork costs into cost buckets for accounting purposes. + * + * To run this script from the root of E/App: + * + * ts-node ./scripts/aggregateGitHubDataFromUpwork.js + * + * The input file must be a CSV with a single column containing just the GitHub issue number. The CSV must have a single header row. + */ +import {getOctokitOptions, GitHub} from '@actions/github/lib/utils'; +import {paginateRest} from '@octokit/plugin-paginate-rest'; +import {throttling} from '@octokit/plugin-throttling'; +import {createObjectCsvWriter} from 'csv-writer'; +import fs from 'fs'; + +type OctokitOptions = {method: string; url: string; request: {retryCount: number}}; +type IssueType = 'bug' | 'feature' | 'other'; + +if (process.argv.length < 3) { + throw new Error('Error: must provide filepath for CSV data'); +} + +if (process.argv.length < 4) { + throw new Error('Error: must provide GitHub token'); +} + +if (process.argv.length < 5) { + throw new Error('Error: must provide output file path'); +} + +// Get filepath for csv +const inputFilepath = process.argv.at(2); +if (!inputFilepath) { + throw new Error('Error: must provide filepath for CSV data'); +} + +// Get GitHub token +const token = (process.argv.at(3) ?? '').trim(); +if (!token) { + throw new Error('Error: must provide GitHub token'); +} + +const Octokit = GitHub.plugin(throttling, paginateRest); +const octokit = new Octokit( + getOctokitOptions(token, { + throttle: { + onRateLimit: (retryAfter: number, options: OctokitOptions) => { + console.warn(`Request quota exhausted for request ${options.method} ${options.url}`); + + // Retry once after hitting a rate limit error, then give up + if (options.request.retryCount <= 1) { + console.log(`Retrying after ${retryAfter} seconds!`); + return true; + } + }, + onAbuseLimit: (retryAfter: number, options: OctokitOptions) => { + // does not retry, only logs a warning + console.warn(`Abuse detected for request ${options.method} ${options.url}`); + }, + }, + }), +); + +// Get output filepath +const outputFilepath = process.argv.at(4); +if (!outputFilepath) { + throw new Error('Error: must provide output file path'); +} + +// Get data from csv +const issues = fs + .readFileSync(inputFilepath) + .toString() + .split('\n') + .reduce((acc, issue) => { + if (!issue) { + return acc; + } + const issueNum = Number(issue.trim()); + if (!issueNum) { + return acc; + } + acc.push(issueNum); + return acc; + }, [] as number[]); + +const csvWriter = createObjectCsvWriter({ + path: outputFilepath, + header: [ + {id: 'number', title: 'number'}, + {id: 'title', title: 'title'}, + {id: 'labels', title: 'labels'}, + {id: 'type', title: 'type'}, + {id: 'capSWProjects', title: 'capSWProjects'}, + ], +}); + +function getIssueTypeFromLabels(labels: string[]): IssueType { + if (labels.includes('NewFeature')) { + return 'feature'; + } + if (labels.includes('Bug')) { + return 'bug'; + } + return 'other'; +} + +/** + * Returns a comma-delimited string with all projects associated with the given issue. + */ +async function getProjectsForIssue(issueNumber: number): Promise { + const response = await octokit.graphql( + ` + { + repository(owner: "Expensify", name: "App") { + issue(number: ${issueNumber}) { + projectsV2(last: 30) { + nodes { + title + } + } + } + } + } + `, + ); + return (response as {repository: {issue: {projectsV2: {nodes: Array<{title: string}>}}}}).repository.issue.projectsV2.nodes.map((node) => node.title).join(','); +} + +async function getGitHubData() { + const gitHubData = []; + // Note: we fetch issues in a loop rather than in parallel to help address rate limiting issues with a PAT + for (const issueNumber of issues) { + console.info(`Fetching ${issueNumber}`); + const result = await octokit.rest.issues + .get({ + owner: 'Expensify', + repo: 'App', + // eslint-disable-next-line @typescript-eslint/naming-convention + issue_number: issueNumber, + }) + .catch(() => { + console.warn(`Error getting issue ${issueNumber}`); + }); + if (result) { + const issue = result.data; + const labels = issue.labels.reduce((acc, label) => { + if (typeof label === 'string') { + acc.push(label); + } else if (label.name) { + acc.push(label.name); + } + return acc; + }, [] as string[]); + const type = getIssueTypeFromLabels(labels); + let capSWProjects = ''; + if (type === 'feature') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + capSWProjects = await getProjectsForIssue(issueNumber); + } + gitHubData.push({ + number: issue.number, + title: issue.title, + labels, + type, + capSWProjects, + }); + } + } + return gitHubData; +} + +getGitHubData() + .then((gitHubData) => csvWriter.writeRecords(gitHubData)) + .then(() => console.info(`Done āœ… Wrote file to ${outputFilepath}`)); diff --git a/scripts/release-profile.ts b/scripts/release-profile.ts index a83fb55fa5ff..615f009d743d 100755 --- a/scripts/release-profile.ts +++ b/scripts/release-profile.ts @@ -36,7 +36,7 @@ if (cpuProfiles.length === 0) { process.exit(1); } else { // Construct the command - const cpuprofileName = cpuProfiles[0]; + const cpuprofileName = cpuProfiles.at(0); const command = `npx react-native-release-profiler --local "${cpuprofileName}" --sourcemap-path "${sourcemapPath}"`; console.log(`Executing: ${command}`); diff --git a/src/CONST.ts b/src/CONST.ts index 4ca9b45f13df..4f177d2294de 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -84,6 +84,12 @@ const onboardingChoices = { ...backendOnboardingChoices, } as const; +const signupQualifiers = { + INDIVIDUAL: 'individual', + VSB: 'vsb', + SMB: 'smb', +} as const; + const onboardingEmployerOrSubmitMessage: OnboardingMessageType = { message: 'Getting paid back is as easy as sending a message. Letā€™s go over the basics.', video: { @@ -171,7 +177,7 @@ const CONST = { }, // Note: Group and Self-DM excluded as these are not tied to a Workspace - WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT, chatTypes.INVOICE], + WORKSPACE_ROOM_TYPES: [chatTypes.POLICY_ADMINS, chatTypes.POLICY_ANNOUNCE, chatTypes.DOMAIN_ALL, chatTypes.POLICY_ROOM, chatTypes.POLICY_EXPENSE_CHAT], ANDROID_PACKAGE_NAME, WORKSPACE_ENABLE_FEATURE_REDIRECT_DELAY: 100, ANIMATED_HIGHLIGHT_ENTRY_DELAY: 50, @@ -839,6 +845,7 @@ const CONST = { SHARE: 'SHARE', // OldDot Action STRIPE_PAID: 'STRIPEPAID', // OldDot Action SUBMITTED: 'SUBMITTED', + SUBMITTED_AND_CLOSED: 'SUBMITTEDCLOSED', TAKE_CONTROL: 'TAKECONTROL', // OldDot Action TASK_CANCELLED: 'TASKCANCELLED', TASK_COMPLETED: 'TASKCOMPLETED', @@ -1839,6 +1846,7 @@ const CONST = { DATE_OF_BIRTH: 1, ADDRESS: 2, PHONE_NUMBER: 3, + CONFIRM: 4, }, INDEX_LIST: ['1', '2', '3', '4'], }, @@ -2058,6 +2066,7 @@ const CONST = { INVOICE: 'invoice', SUBMIT: 'submit', TRACK: 'track', + CREATE: 'create', }, REQUEST_TYPE: { DISTANCE: 'distance', @@ -2788,6 +2797,7 @@ const CONST = { TITLE_CHARACTER_LIMIT: 100, DESCRIPTION_LIMIT: 1000, WORKSPACE_NAME_CHARACTER_LIMIT: 80, + STATE_CHARACTER_LIMIT: 32, AVATAR_CROP_MODAL: { // The next two constants control what is min and max value of the image crop scale. @@ -4203,7 +4213,7 @@ const CONST = { PADDING: 32, DEFAULT_ZOOM: 15, SINGLE_MARKER_ZOOM: 15, - DEFAULT_COORDINATE: [-122.4021, 37.7911], + DEFAULT_COORDINATE: [-122.4021, 37.7911] as [number, number], STYLE_URL: 'mapbox://styles/expensify/cllcoiqds00cs01r80kp34tmq', ANIMATION_DURATION_ON_CENTER_ME: 1000, CENTER_BUTTON_FADE_DURATION: 300, @@ -4215,7 +4225,6 @@ const CONST = { }, EVENTS: { SCROLLING: 'scrolling', - ON_RETURN_TO_OLD_DOT: 'onReturnToOldDot', }, CHAT_HEADER_LOADER_HEIGHT: 36, @@ -4459,9 +4468,11 @@ const CONST = { WELCOME_VIDEO_URL: `${CLOUDFRONT_URL}/videos/intro-1280.mp4`, + QUALIFIER_PARAM: 'signupQualifier', ONBOARDING_INTRODUCTION: 'Letā€™s get you set up šŸ”§', ONBOARDING_CHOICES: {...onboardingChoices}, SELECTABLE_ONBOARDING_CHOICES: {...selectableOnboardingChoices}, + ONBOARDING_SIGNUP_QUALIFIERS: {...signupQualifiers}, ONBOARDING_INVITE_TYPES: {...onboardingInviteTypes}, ACTIONABLE_TRACK_EXPENSE_WHISPER_MESSAGE: 'What would you like to do with this expense?', ONBOARDING_CONCIERGE: { @@ -4550,12 +4561,12 @@ const CONST = { 'Hereā€™s how to set up categories:\n' + '\n' + '1. Click your profile picture.\n' + - '2. Go to Workspaces.\n' + + '2. Go to *Workspaces*.\n' + '3. Select your workspace.\n' + '4. Click *Categories*.\n' + - '5. Enable and disable default categories.\n' + - '6. Click *Add categories* to make your own.\n' + - '7. For more controls like requiring a category for every expense, click *Settings*.\n' + + '5. Add or import your own categories.\n' + + "6. Disable any default categories you don't need.\n" + + '7. Require a category for every expense in *Settings*.\n' + '\n' + `[Take me to workspace category settings](${workspaceCategoriesLink}).`, }, @@ -5441,6 +5452,7 @@ const CONST = { INITIAL_URL: 'INITIAL_URL', ACTIVE_WORKSPACE_ID: 'ACTIVE_WORKSPACE_ID', RETRY_LAZY_REFRESHED: 'RETRY_LAZY_REFRESHED', + LAST_REFRESH_TIMESTAMP: 'LAST_REFRESH_TIMESTAMP', }, RESERVATION_TYPE: { @@ -5774,6 +5786,9 @@ const CONST = { TAGS_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#import-a-spreadsheet-1', }, + // The timeout duration (1 minute) (in milliseconds) before the window reloads due to an error. + ERROR_WINDOW_RELOAD_TIMEOUT: 60000, + DEBUG: { DETAILS: 'details', JSON: 'json', diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 62e7839b21f0..f5d4655c4861 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -20,7 +20,6 @@ import {updateLastRoute} from './libs/actions/App'; import * as EmojiPickerAction from './libs/actions/EmojiPickerAction'; import * as Report from './libs/actions/Report'; import * as User from './libs/actions/User'; -import {handleHybridAppOnboarding} from './libs/actions/Welcome'; import * as ActiveClientManager from './libs/ActiveClientManager'; import FS from './libs/Fullstory'; import * as Growl from './libs/Growl'; @@ -99,7 +98,6 @@ function Expensify({ const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const [lastRoute] = useOnyx(ONYXKEYS.LAST_ROUTE); - const [tryNewDotData] = useOnyx(ONYXKEYS.NVP_TRYNEWDOT); const [shouldShowRequire2FAModal, setShouldShowRequire2FAModal] = useState(false); useEffect(() => { @@ -118,14 +116,6 @@ function Expensify({ setAttemptedToOpenPublicRoom(true); }, [isCheckingPublicRoom]); - useEffect(() => { - if (splashScreenState !== CONST.BOOT_SPLASH_STATE.HIDDEN || tryNewDotData === undefined) { - return; - } - - handleHybridAppOnboarding(); - }, [splashScreenState, tryNewDotData]); - const isAuthenticated = useMemo(() => !!(session?.authToken ?? null), [session]); const autoAuthState = useMemo(() => session?.autoAuthState ?? '', [session]); diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cb8bf2fdb5d3..df1413620c20 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -32,6 +32,7 @@ const ONYXKEYS = { /** Note: These are Persisted Requests - not all requests in the main queue as the key name might lead one to believe */ PERSISTED_REQUESTS: 'networkRequestQueue', + PERSISTED_ONGOING_REQUESTS: 'networkOngoingRequestQueue', /** Stores current date */ CURRENT_DATE: 'currentDate', @@ -329,6 +330,9 @@ const ONYXKEYS = { /** Onboarding Purpose selected by the user during Onboarding flow */ ONBOARDING_PURPOSE_SELECTED: 'onboardingPurposeSelected', + /** Onboarding customized choices to display to the user based on their profile when signing up */ + ONBOARDING_CUSTOM_CHOICES: 'onboardingCustomChoices', + /** Onboarding error message to be displayed to the user */ ONBOARDING_ERROR_MESSAGE: 'onboardingErrorMessage', @@ -419,9 +423,15 @@ const ONYXKEYS = { /** Stores the route to open after changing app permission from settings */ LAST_ROUTE: 'lastRoute', + /** Stores the information if user loaded the Onyx state through Import feature */ + IS_USING_IMPORTED_STATE: 'isUsingImportedState', + /** Stores the information about the saved searches */ SAVED_SEARCHES: 'nvp_savedSearches', + /** Stores the information about the recent searches */ + RECENT_SEARCHES: 'nvp_recentSearches', + /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', @@ -849,12 +859,14 @@ type OnyxValuesMapping = { // ONYXKEYS.NVP_TRYNEWDOT is HybridApp onboarding data [ONYXKEYS.NVP_TRYNEWDOT]: OnyxTypes.TryNewDot; + [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[]; + [ONYXKEYS.PERSISTED_ONGOING_REQUESTS]: OnyxTypes.Request; [ONYXKEYS.CURRENT_DATE]: string; [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials; [ONYXKEYS.STASHED_CREDENTIALS]: OnyxTypes.Credentials; @@ -944,6 +956,7 @@ type OnyxValuesMapping = { [ONYXKEYS.MAX_CANVAS_HEIGHT]: number; [ONYXKEYS.MAX_CANVAS_WIDTH]: number; [ONYXKEYS.ONBOARDING_PURPOSE_SELECTED]: OnboardingPurposeType; + [ONYXKEYS.ONBOARDING_CUSTOM_CHOICES]: OnboardingPurposeType[] | []; [ONYXKEYS.ONBOARDING_ERROR_MESSAGE]: string; [ONYXKEYS.ONBOARDING_POLICY_ID]: string; [ONYXKEYS.ONBOARDING_ADMINS_CHAT_REPORT_ID]: string; @@ -983,9 +996,9 @@ type OnyxValuesMapping = { [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; [ONYXKEYS.IMPORTED_SPREADSHEET]: OnyxTypes.ImportedSpreadsheet; [ONYXKEYS.LAST_ROUTE]: string; + [ONYXKEYS.IS_USING_IMPORTED_STATE]: boolean; [ONYXKEYS.SHOULD_SHOW_SAVED_SEARCH_RENAME_TOOLTIP]: boolean; }; - type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; type OnyxCollectionKey = keyof OnyxCollectionValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index dfcb42d3c4fe..9c429dd3e909 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -149,6 +149,7 @@ const ROUTES = { SETTINGS_ABOUT: 'settings/about', SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', + SETTINGS_WALLET_VERIFY_ACCOUNT: {route: 'settings/wallet/verify', getRoute: (backTo?: string) => getUrlWithBackToParam('settings/wallet/verify', backTo)}, SETTINGS_WALLET_DOMAINCARD: { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, @@ -1274,10 +1275,7 @@ const ROUTES = { route: 'restricted-action/workspace/:policyID', getRoute: (policyID: string) => `restricted-action/workspace/${policyID}` as const, }, - MISSING_PERSONAL_DETAILS: { - route: 'missing-personal-details/workspace/:policyID', - getRoute: (policyID: string) => `missing-personal-details/workspace/${policyID}` as const, - }, + MISSING_PERSONAL_DETAILS: 'missing-personal-details', POLICY_ACCOUNTING_NETSUITE_SUBSIDIARY_SELECTOR: { route: 'settings/workspaces/:policyID/accounting/netsuite/subsidiary-selector', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/netsuite/subsidiary-selector` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 395f1c4d5fb1..9a94d612dc80 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -112,6 +112,7 @@ const SCREENS = { CARD_ACTIVATE: 'Settings_Wallet_Card_Activate', REPORT_VIRTUAL_CARD_FRAUD: 'Settings_Wallet_ReportVirtualCardFraud', CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS: 'Settings_Wallet_Cards_Digital_Details_Update_Address', + VERIFY_ACCOUNT: 'Settings_Wallet_Verify_Account', }, EXIT_SURVEY: { diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index 8d3e311c7c61..9b5d21743bef 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -50,7 +50,7 @@ function AccountSwitcher() { const canSwitchAccounts = canUseNewDotCopilot && (delegators.length > 0 || isActingAsDelegate); const createBaseMenuItem = (personalDetails: PersonalDetails | undefined, errors?: Errors, additionalProps: MenuItemWithLink = {}): MenuItemWithLink => { - const error = Object.values(errors ?? {})[0] ?? ''; + const error = Object.values(errors ?? {}).at(0) ?? ''; return { title: personalDetails?.displayName ?? personalDetails?.login, description: Str.removeSMSDomain(personalDetails?.login ?? ''), diff --git a/src/components/AddPaymentMethodMenu.tsx b/src/components/AddPaymentMethodMenu.tsx index 5621c031f959..0057438e3913 100644 --- a/src/components/AddPaymentMethodMenu.tsx +++ b/src/components/AddPaymentMethodMenu.tsx @@ -2,26 +2,22 @@ import type {RefObject} from 'react'; import React, {useEffect, useState} from 'react'; import type {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; +import {completePaymentOnboarding} from '@libs/actions/IOU'; import * as ReportActionsUtils from '@libs/ReportActionsUtils'; import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {AnchorPosition} from '@src/styles'; -import type {Report, Session} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; import type AnchorAlignment from '@src/types/utils/AnchorAlignment'; import * as Expensicons from './Icon/Expensicons'; import type {PaymentMethod} from './KYCWall/types'; import type BaseModalProps from './Modal/types'; import PopoverMenu from './PopoverMenu'; -type AddPaymentMethodMenuOnyxProps = { - /** Session info for the currently logged-in user. */ - session: OnyxEntry; -}; - -type AddPaymentMethodMenuProps = AddPaymentMethodMenuOnyxProps & { +type AddPaymentMethodMenuProps = { /** Should the component be visible? */ isVisible: boolean; @@ -58,11 +54,11 @@ function AddPaymentMethodMenu({ anchorRef, iouReport, onItemSelected, - session, shouldShowPersonalBankAccountOption = false, }: AddPaymentMethodMenuProps) { const {translate} = useLocalize(); const [restoreFocusType, setRestoreFocusType] = useState(); + const [session] = useOnyx(ONYXKEYS.SESSION); // Users can choose to pay with business bank account in case of Expense reports or in case of P2P IOU report // which then starts a bottom up flow and creates a Collect workspace where the payer is an admin and payee is an employee. @@ -80,6 +76,7 @@ function AddPaymentMethodMenu({ return; } + completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA); onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); }, [isPersonalOnlyOption, isVisible, onItemSelected]); @@ -108,6 +105,7 @@ function AddPaymentMethodMenu({ text: translate('common.personalBankAccount'), icon: Expensicons.Bank, onSelected: () => { + completePaymentOnboarding(CONST.PAYMENT_SELECTED.PBA); onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT); }, }, @@ -118,7 +116,10 @@ function AddPaymentMethodMenu({ { text: translate('common.businessBankAccount'), icon: Expensicons.Building, - onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT), + onSelected: () => { + completePaymentOnboarding(CONST.PAYMENT_SELECTED.BBA); + onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT); + }, }, ] : []), @@ -140,8 +141,4 @@ function AddPaymentMethodMenu({ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu'; -export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(AddPaymentMethodMenu); +export default AddPaymentMethodMenu; diff --git a/src/components/AddPlaidBankAccount.tsx b/src/components/AddPlaidBankAccount.tsx index 4de286183ea8..11b0010ed253 100644 --- a/src/components/AddPlaidBankAccount.tsx +++ b/src/components/AddPlaidBankAccount.tsx @@ -173,7 +173,7 @@ function AddPlaidBankAccount({ const {icon, iconSize, iconStyles} = getBankIcon({styles}); const plaidErrors = plaidData?.errors; // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style - const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors)[0] as string) : ''; + const plaidDataErrorMessage = !isEmptyObject(plaidErrors) ? (Object.values(plaidErrors).at(0) as string) : ''; const bankName = plaidData?.bankName; /** diff --git a/src/components/AddressForm.tsx b/src/components/AddressForm.tsx index 8ba50e395019..4470481d2be6 100644 --- a/src/components/AddressForm.tsx +++ b/src/components/AddressForm.tsx @@ -206,7 +206,7 @@ function AddressForm({ aria-label={translate('common.stateOrProvince')} role={CONST.ROLE.PRESENTATION} value={state} - maxLength={CONST.FORM_CHARACTER_LIMIT} + maxLength={CONST.STATE_CHARACTER_LIMIT} spellCheck={false} onValueChange={onAddressChanged} shouldSaveDraft={shouldSaveDraft} diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx index 366366423324..975ea6c548c0 100644 --- a/src/components/AttachmentPicker/index.native.tsx +++ b/src/components/AttachmentPicker/index.native.tsx @@ -22,12 +22,7 @@ import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import type IconAsset from '@src/types/utils/IconAsset'; import launchCamera from './launchCamera/launchCamera'; -import type BaseAttachmentPickerProps from './types'; - -type AttachmentPickerProps = BaseAttachmentPickerProps & { - /** If this value is true, then we exclude Camera option. */ - shouldHideCameraOption?: boolean; -}; +import type AttachmentPickerProps from './types'; type Item = { /** The icon associated with the item. */ @@ -112,7 +107,13 @@ const getDataForUpload = (fileData: FileResponse): Promise => { * a callback. This is the ios/android implementation * opening a modal with attachment options */ -function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, shouldHideCameraOption = false}: AttachmentPickerProps) { +function AttachmentPicker({ + type = CONST.ATTACHMENT_PICKER_TYPE.FILE, + children, + shouldHideCameraOption = false, + shouldHideGalleryOption = false, + shouldValidateImage = true, +}: AttachmentPickerProps) { const styles = useThemeStyles(); const [isVisible, setIsVisible] = useState(false); @@ -177,7 +178,10 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s const uri = manipResult.uri; const convertedAsset = { uri, - name: uri.substring(uri.lastIndexOf('/') + 1).split('?')[0], + name: uri + .substring(uri.lastIndexOf('/') + 1) + .split('?') + .at(0), type: 'image/jpeg', width: manipResult.width, height: manipResult.height, @@ -218,17 +222,19 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s const menuItemData: Item[] = useMemo(() => { const data: Item[] = [ - { - icon: Expensicons.Gallery, - textTranslationKey: 'attachmentPicker.chooseFromGallery', - pickAttachment: () => showImagePicker(launchImageLibrary), - }, { icon: Expensicons.Paperclip, textTranslationKey: 'attachmentPicker.chooseDocument', pickAttachment: showDocumentPicker, }, ]; + if (!shouldHideGalleryOption) { + data.unshift({ + icon: Expensicons.Gallery, + textTranslationKey: 'attachmentPicker.chooseFromGallery', + pickAttachment: () => showImagePicker(launchImageLibrary), + }); + } if (!shouldHideCameraOption) { data.unshift({ icon: Expensicons.Camera, @@ -238,7 +244,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s } return data; - }, [showDocumentPicker, showImagePicker, shouldHideCameraOption]); + }, [showDocumentPicker, shouldHideGalleryOption, shouldHideCameraOption, showImagePicker]); const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({initialFocusedIndex: -1, maxIndex: menuItemData.length - 1, isActive: isVisible}); @@ -318,6 +324,26 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s width: ('width' in fileData && fileData.width) || undefined, height: ('height' in fileData && fileData.height) || undefined, }; + + if (!shouldValidateImage && fileDataName && Str.isImage(fileDataName)) { + ImageSize.getSize(fileDataUri) + .then(({width, height}) => { + fileDataObject.width = width; + fileDataObject.height = height; + return fileDataObject; + }) + .then((file) => { + getDataForUpload(file) + .then((result) => { + completeAttachmentSelection.current(result); + }) + .catch((error: Error) => { + showGeneralAlert(error.message); + throw error; + }); + }); + return; + } /* eslint-enable @typescript-eslint/prefer-nullish-coalescing */ if (fileDataName && Str.isImage(fileDataName)) { ImageSize.getSize(fileDataUri) @@ -331,7 +357,7 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s return validateAndCompleteAttachmentSelection(fileDataObject); } }, - [validateAndCompleteAttachmentSelection, showImageCorruptionAlert], + [validateAndCompleteAttachmentSelection, showImageCorruptionAlert, shouldValidateImage, showGeneralAlert], ); /** @@ -363,8 +389,11 @@ function AttachmentPicker({type = CONST.ATTACHMENT_PICKER_TYPE.FILE, children, s if (focusedIndex === -1) { return; } - selectItem(menuItemData[focusedIndex]); - setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + const item = menuItemData.at(focusedIndex); + if (item) { + selectItem(item); + setFocusedIndex(-1); // Reset the focusedIndex on selecting any menu + } }, { isActive: isVisible, diff --git a/src/components/AttachmentPicker/types.ts b/src/components/AttachmentPicker/types.ts index 057ec72de27e..ee9d39aabef3 100644 --- a/src/components/AttachmentPicker/types.ts +++ b/src/components/AttachmentPicker/types.ts @@ -42,6 +42,13 @@ type AttachmentPickerProps = { type?: ValueOf; acceptedFileTypes?: Array>; + + shouldHideCameraOption?: boolean; + + shouldHideGalleryOption?: boolean; + + /** Whether to validate the image and show the alert or not. */ + shouldValidateImage?: boolean; }; export default AttachmentPickerProps; diff --git a/src/components/Attachments/AttachmentCarousel/index.native.tsx b/src/components/Attachments/AttachmentCarousel/index.native.tsx index e0f7571af8c7..a8eb614202a7 100644 --- a/src/components/Attachments/AttachmentCarousel/index.native.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.native.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -15,46 +15,56 @@ import CarouselButtons from './CarouselButtons'; import extractAttachments from './extractAttachments'; import type {AttachmentCarouselPagerHandle} from './Pager'; import AttachmentCarouselPager from './Pager'; -import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps} from './types'; +import type {AttachmentCarouselProps} from './types'; import useCarouselArrows from './useCarouselArrows'; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, onClose, type, accountID}: AttachmentCarouselProps) { +function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, onClose, type, accountID}: AttachmentCarouselProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const pagerRef = useRef(null); + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); const [page, setPage] = useState(); const [attachments, setAttachments] = useState([]); const {shouldShowArrows, setShouldShowArrows, autoHideArrows, cancelAutoHideArrows} = useCarouselArrows(); const [activeSource, setActiveSource] = useState(source); - const compareImage = useCallback((attachment: Attachment) => attachment.source === source, [source]); useEffect(() => { const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; - let targetAttachments: Attachment[] = []; + let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); } else { - targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions}); } - const initialPage = targetAttachments.findIndex(compareImage); + let newIndex = newAttachments.findIndex(compareImage); + const index = attachments.findIndex(compareImage); + + // If newAttachments includes an attachment with the same index, update newIndex to that index. + // Previously, uploading an attachment offline would dismiss the modal when the image was previewed and the connection was restored. + // Now, instead of dismissing the modal, we replace it with the new attachment that has the same index. + if (newIndex === -1 && index !== -1 && newAttachments.at(index)) { + newIndex = index; + } - // Dismiss the modal when deleting an attachment during its display in preview. - if (initialPage === -1 && attachments.find(compareImage)) { + // If no matching attachment with the same index, dismiss the modal + if (newIndex === -1 && index !== -1 && attachments.at(index)) { Navigation.dismissModal(); } else { - setPage(initialPage); - setAttachments(targetAttachments); + setPage(newIndex); + setAttachments(newAttachments); // Update the download button visibility in the parent modal if (setDownloadButtonVisibility) { - setDownloadButtonVisibility(initialPage !== -1); + setDownloadButtonVisibility(newIndex !== -1); } + const attachment = newAttachments.at(newIndex); // Update the parent modal's state with the source and name from the mapped attachments - if (targetAttachments[initialPage] !== undefined && onNavigate) { - onNavigate(targetAttachments[initialPage]); + if (newIndex !== -1 && attachment !== undefined && onNavigate) { + onNavigate(attachment); } } // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps @@ -66,13 +76,15 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, Keyboard.dismiss(); setShouldShowArrows(true); - const item = attachments[newPageIndex]; + const item = attachments.at(newPageIndex); setPage(newPageIndex); - setActiveSource(item.source); - - if (onNavigate) { - onNavigate(item); + if (newPageIndex >= 0 && item) { + setActiveSource(item.source); + if (onNavigate) { + onNavigate(item); + } + onNavigate?.(item); } }, [setShouldShowArrows, attachments, onNavigate], @@ -144,13 +156,4 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, AttachmentCarousel.displayName = 'AttachmentCarousel'; -export default withOnyx({ - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - canEvict: false, - }, - reportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - }, -})(AttachmentCarousel); +export default AttachmentCarousel; diff --git a/src/components/Attachments/AttachmentCarousel/index.tsx b/src/components/Attachments/AttachmentCarousel/index.tsx index 72e0f17aa310..a1408aaf400e 100644 --- a/src/components/Attachments/AttachmentCarousel/index.tsx +++ b/src/components/Attachments/AttachmentCarousel/index.tsx @@ -5,7 +5,7 @@ import type {ListRenderItemInfo} from 'react-native'; import {Keyboard, PixelRatio, View} from 'react-native'; import type {GestureType} from 'react-native-gesture-handler'; import {Gesture, GestureDetector} from 'react-native-gesture-handler'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import BlockingView from '@components/BlockingViews/BlockingView'; @@ -26,7 +26,7 @@ import CarouselButtons from './CarouselButtons'; import CarouselItem from './CarouselItem'; import extractAttachments from './extractAttachments'; import AttachmentCarouselPagerContext from './Pager/AttachmentCarouselPagerContext'; -import type {AttachmentCaraouselOnyxProps, AttachmentCarouselProps, UpdatePageProps} from './types'; +import type {AttachmentCarouselProps, UpdatePageProps} from './types'; import useCarouselArrows from './useCarouselArrows'; import useCarouselContextEvents from './useCarouselContextEvents'; @@ -38,7 +38,7 @@ const viewabilityConfig = { const MIN_FLING_VELOCITY = 500; -function AttachmentCarousel({report, reportActions, parentReportActions, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) { +function AttachmentCarousel({report, source, onNavigate, setDownloadButtonVisibility, type, accountID, onClose}: AttachmentCarouselProps) { const theme = useTheme(); const {translate} = useLocalize(); const {windowWidth} = useWindowDimensions(); @@ -48,7 +48,8 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, const scrollRef = useAnimatedRef>>(); const nope = useSharedValue(false); const pagerRef = useRef(null); - + const [parentReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, {canEvict: false}); + const [reportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, {canEvict: false}); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true); @@ -73,14 +74,14 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, useEffect(() => { const parentReportAction = report.parentReportActionID && parentReportActions ? parentReportActions[report.parentReportActionID] : undefined; - let targetAttachments: Attachment[] = []; + let newAttachments: Attachment[] = []; if (type === CONST.ATTACHMENT_TYPE.NOTE && accountID) { - targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.NOTE, {privateNotes: report.privateNotes, accountID}); } else { - targetAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined}); + newAttachments = extractAttachments(CONST.ATTACHMENT_TYPE.REPORT, {parentReportAction, reportActions: reportActions ?? undefined}); } - if (isEqual(attachments, targetAttachments)) { + if (isEqual(attachments, newAttachments)) { if (attachments.length === 0) { setPage(-1); setDownloadButtonVisibility?.(false); @@ -88,23 +89,32 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, return; } - const initialPage = targetAttachments.findIndex(compareImage); + let newIndex = newAttachments.findIndex(compareImage); + const index = attachments.findIndex(compareImage); + + // If newAttachments includes an attachment with the same index, update newIndex to that index. + // Previously, uploading an attachment offline would dismiss the modal when the image was previewed and the connection was restored. + // Now, instead of dismissing the modal, we replace it with the new attachment that has the same index. + if (newIndex === -1 && index !== -1 && newAttachments.at(index)) { + newIndex = index; + } - // Dismiss the modal when deleting an attachment during its display in preview. - if (initialPage === -1 && attachments.find(compareImage)) { + // If no matching attachment with the same index, dismiss the modal + if (newIndex === -1 && index !== -1 && attachments.at(index)) { Navigation.dismissModal(); } else { - setPage(initialPage); - setAttachments(targetAttachments); + setPage(newIndex); + setAttachments(newAttachments); // Update the download button visibility in the parent modal if (setDownloadButtonVisibility) { - setDownloadButtonVisibility(initialPage !== -1); + setDownloadButtonVisibility(newIndex !== -1); } + const attachment = newAttachments.at(newIndex); // Update the parent modal's state with the source and name from the mapped attachments - if (targetAttachments[initialPage] !== undefined && onNavigate) { - onNavigate(targetAttachments[initialPage]); + if (newIndex !== -1 && attachment !== undefined && onNavigate) { + onNavigate(attachment); } } }, [report.privateNotes, reportActions, parentReportActions, compareImage, report.parentReportActionID, attachments, setDownloadButtonVisibility, onNavigate, accountID, type]); @@ -131,7 +141,7 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, // Since we can have only one item in view at a time, we can use the first item in the array // to get the index of the current page - const entry = viewableItems[0]; + const entry = viewableItems.at(0); if (!entry) { setActiveSource(null); return; @@ -158,9 +168,9 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, } const nextIndex = page + deltaSlide; - const nextItem = attachments[nextIndex]; + const nextItem = attachments.at(nextIndex); - if (!nextItem || !scrollRef.current) { + if (!nextItem || nextIndex < 0 || !scrollRef.current) { return; } @@ -306,13 +316,4 @@ function AttachmentCarousel({report, reportActions, parentReportActions, source, AttachmentCarousel.displayName = 'AttachmentCarousel'; -export default withOnyx({ - parentReportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`, - canEvict: false, - }, - reportActions: { - key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, - canEvict: false, - }, -})(AttachmentCarousel); +export default AttachmentCarousel; diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts index d31ebbd328cd..c77e7b0f79d5 100644 --- a/src/components/Attachments/AttachmentCarousel/types.ts +++ b/src/components/Attachments/AttachmentCarousel/types.ts @@ -1,23 +1,14 @@ import type {ViewToken} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type {Attachment, AttachmentSource} from '@components/Attachments/types'; import type CONST from '@src/CONST'; -import type {Report, ReportActions} from '@src/types/onyx'; +import type {Report} from '@src/types/onyx'; type UpdatePageProps = { viewableItems: ViewToken[]; }; -type AttachmentCaraouselOnyxProps = { - /** Object of report actions for this report */ - reportActions: OnyxEntry; - - /** The report actions of the parent report */ - parentReportActions: OnyxEntry; -}; - -type AttachmentCarouselProps = AttachmentCaraouselOnyxProps & { +type AttachmentCarouselProps = { /** Source is used to determine the starting index in the array of attachments */ source: AttachmentSource; @@ -40,4 +31,4 @@ type AttachmentCarouselProps = AttachmentCaraouselOnyxProps & { onClose: () => void; }; -export type {AttachmentCarouselProps, UpdatePageProps, AttachmentCaraouselOnyxProps}; +export type {AttachmentCarouselProps, UpdatePageProps}; diff --git a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx index 8c4af3275bd8..1e3cded92bd5 100644 --- a/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx +++ b/src/components/Attachments/AttachmentView/AttachmentViewPdf/index.android.tsx @@ -33,8 +33,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { .manualActivation(true) .onTouchesMove((evt) => { if (offsetX.value !== 0 && offsetY.value !== 0 && isScrollEnabled) { - const translateX = Math.abs(evt.allTouches[0].absoluteX - offsetX.value); - const translateY = Math.abs(evt.allTouches[0].absoluteY - offsetY.value); + const translateX = Math.abs((evt.allTouches.at(0)?.absoluteX ?? 0) - offsetX.value); + const translateY = Math.abs((evt.allTouches.at(0)?.absoluteY ?? 0) - offsetY.value); const allowEnablingScroll = !isPanGestureActive.value || isScrollEnabled.value; // if the value of X is greater than Y and the pdf is not zoomed in, @@ -49,8 +49,8 @@ function AttachmentViewPdf(props: AttachmentViewPdfProps) { } isPanGestureActive.value = true; - offsetX.value = evt.allTouches[0].absoluteX; - offsetY.value = evt.allTouches[0].absoluteY; + offsetX.value = evt.allTouches.at(0)?.absoluteX ?? 0; + offsetY.value = evt.allTouches.at(0)?.absoluteY ?? 0; }) .onTouchesUp(() => { isPanGestureActive.value = false; diff --git a/src/components/AvatarSkeleton.tsx b/src/components/AvatarSkeleton.tsx index a88304b15fc3..6e0a4f407d70 100644 --- a/src/components/AvatarSkeleton.tsx +++ b/src/components/AvatarSkeleton.tsx @@ -17,6 +17,7 @@ function AvatarSkeleton({size = CONST.AVATAR_SIZE.SMALL}: {size?: ValueOf diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 07845eca37ba..4f518452d3be 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -13,8 +13,10 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {PersonalDetailsList, Policy, Report, ReportActions} from '@src/types/onyx'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; import CaretWrapper from './CaretWrapper'; import DisplayNames from './DisplayNames'; +import {FallbackAvatar} from './Icon/Expensicons'; import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; @@ -46,6 +48,13 @@ type AvatarWithDisplayNameProps = AvatarWithDisplayNamePropsWithOnyx & { shouldEnableDetailPageNavigation?: boolean; }; +const fallbackIcon: Icon = { + source: FallbackAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: '', + id: -1, +}; + function AvatarWithDisplayName({ policy, report, @@ -126,8 +135,8 @@ function AvatarWithDisplayName({ {shouldShowSubscriptAvatar ? ( ) : ( diff --git a/src/components/AvatarWithImagePicker.tsx b/src/components/AvatarWithImagePicker.tsx index 011b7f510275..cdd43cb2555e 100644 --- a/src/components/AvatarWithImagePicker.tsx +++ b/src/components/AvatarWithImagePicker.tsx @@ -232,26 +232,31 @@ function AvatarWithImagePicker({ return; } - 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, - maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, + FileUtils.validateImageForCorruption(image) + .then(() => 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, + maxWidthInPx: CONST.AVATAR_MAX_WIDTH_PX, + }); + return; + } + + setIsAvatarCropModalOpen(true); + setError(null, {}); + setIsMenuVisible(false); + setImageData({ + uri: image.uri ?? '', + name: image.name ?? '', + type: image.type ?? '', }); - return; - } - - setIsAvatarCropModalOpen(true); - setError(null, {}); - setIsMenuVisible(false); - setImageData({ - uri: image.uri ?? '', - name: image.name ?? '', - type: image.type ?? '', + }) + .catch(() => { + setError('attachmentPicker.errorWhileSelectingCorruptedAttachment', {}); }); - }); }, [isValidExtension, isValidSize], ); @@ -339,7 +344,11 @@ function AvatarWithImagePicker({ maybeIcon={isUsingDefaultAvatar} > {({show}) => ( - + {({openPicker}) => { const menuItems = createMenuItems(openPicker); @@ -383,7 +392,7 @@ function AvatarWithImagePicker({ {source ? ( & { /** Whether button's content should be centered */ isContentCentered?: boolean; + + /** Whether the Enter keyboard listening is active whether or not the screen that contains the button is focused */ + isPressOnEnterActive?: boolean; }; -type KeyboardShortcutComponentProps = Pick; +type KeyboardShortcutComponentProps = Pick; const accessibilityRoles: string[] = Object.values(CONST.ROLE); -function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPress = () => {}, pressOnEnter, allowBubble, enterKeyEventListenerPriority}: KeyboardShortcutComponentProps) { +function KeyboardShortcutComponent({ + isDisabled = false, + isLoading = false, + onPress = () => {}, + pressOnEnter, + allowBubble, + enterKeyEventListenerPriority, + isPressOnEnterActive = false, +}: KeyboardShortcutComponentProps) { const isFocused = useIsFocused(); const activeElementRole = useActiveElementRole(); @@ -163,7 +174,7 @@ function KeyboardShortcutComponent({isDisabled = false, isLoading = false, onPre const config = useMemo( () => ({ - isActive: pressOnEnter && !shouldDisableEnterShortcut && isFocused, + isActive: pressOnEnter && !shouldDisableEnterShortcut && (isFocused || isPressOnEnterActive), shouldBubble: allowBubble, priority: enterKeyEventListenerPriority, shouldPreventDefault: false, @@ -230,6 +241,7 @@ function Button( isSplitButton = false, link = false, isContentCentered = false, + isPressOnEnterActive, ...rest }: ButtonProps, ref: ForwardedRef, @@ -329,6 +341,7 @@ function Button( onPress={onPress} pressOnEnter={pressOnEnter} enterKeyEventListenerPriority={enterKeyEventListenerPriority} + isPressOnEnterActive={isPressOnEnterActive} /> )} ({ const {windowWidth, windowHeight} = useWindowDimensions(); const dropdownAnchor = useRef(null); const dropdownButtonRef = isSplitButton ? buttonRef : mergeRefs(buttonRef, dropdownAnchor); - const selectedItem = options[selectedItemIndex] || options[0]; + const selectedItem = options.at(selectedItemIndex) ?? options.at(0); const innerStyleDropButton = StyleUtils.getDropDownButtonHeight(buttonSize); const isButtonSizeLarge = buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE; const nullCheckRef = (ref: MutableRefObject) => ref ?? null; @@ -86,9 +87,14 @@ function ButtonWithDropdownMenu({ setIsMenuVisible(!isMenuVisible); return; } - onPress(e, selectedItem?.value); + if (selectedItem?.value) { + onPress(e, selectedItem.value); + } } else { - onPress(e, options[0]?.value); + const option = options.at(0); + if (option?.value) { + onPress(e, option.value); + } } }, { @@ -99,6 +105,17 @@ function ButtonWithDropdownMenu({ ); const splitButtonWrapperStyle = isSplitButton ? [styles.flexRow, styles.justifyContentBetween, styles.alignItemsCenter] : {}; + const handlePress = useCallback( + (event?: GestureResponderEvent | KeyboardEvent) => { + if (!isSplitButton) { + setIsMenuVisible(!isMenuVisible); + } else if (selectedItem?.value) { + onPress(event, selectedItem.value); + } + }, + [isMenuVisible, isSplitButton, onPress, selectedItem?.value], + ); + return ( {shouldAlwaysShowDropdownMenu || options.length > 1 ? ( @@ -107,8 +124,8 @@ function ButtonWithDropdownMenu({ success={success} pressOnEnter={pressOnEnter} ref={dropdownButtonRef} - onPress={(event) => (!isSplitButton ? setIsMenuVisible(!isMenuVisible) : onPress(event, selectedItem.value))} - text={customText ?? selectedItem.text} + onPress={handlePress} + text={customText ?? selectedItem?.text ?? ''} isDisabled={isDisabled || !!selectedItem?.disabled} isLoading={isLoading} shouldRemoveRightBorderRadius @@ -156,12 +173,15 @@ function ButtonWithDropdownMenu({ success={success} ref={buttonRef} pressOnEnter={pressOnEnter} - isDisabled={isDisabled || !!options[0].disabled} + isDisabled={isDisabled || !!options.at(0)?.disabled} style={[styles.w100, style]} disabledStyle={disabledStyle} isLoading={isLoading} - text={selectedItem.text} - onPress={(event) => onPress(event, options[0].value)} + text={selectedItem?.text} + onPress={(event) => { + const option = options.at(0); + return option ? onPress(event, option.value) : undefined; + }} large={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.LARGE} medium={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.MEDIUM} small={buttonSize === CONST.DROPDOWN_BUTTON_SIZE.SMALL} diff --git a/src/components/CategoryPicker.tsx b/src/components/CategoryPicker.tsx index e5c85a8f5f6d..73d07cfba229 100644 --- a/src/components/CategoryPicker.tsx +++ b/src/components/CategoryPicker.tsx @@ -62,7 +62,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC false, ); - const categoryData = categoryOptions?.[0]?.data ?? []; + const categoryData = categoryOptions?.at(0)?.data ?? []; const header = OptionsListUtils.getHeaderMessageForNonUserList(categoryData.length > 0, debouncedSearchValue); const categoriesCount = OptionsListUtils.getEnabledCategoriesCount(categories); const isCategoriesCountBelowThreshold = categoriesCount < CONST.CATEGORY_LIST_THRESHOLD; @@ -71,7 +71,7 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC return [categoryOptions, header, showInput]; }, [policyRecentlyUsedCategories, debouncedSearchValue, selectedOptions, policyCategories, policyCategoriesDraft]); - const selectedOptionKey = useMemo(() => (sections?.[0]?.data ?? []).filter((category) => category.searchText === selectedCategory)[0]?.keyForList, [sections, selectedCategory]); + const selectedOptionKey = useMemo(() => (sections?.at(0)?.data ?? []).filter((category) => category.searchText === selectedCategory).at(0)?.keyForList, [sections, selectedCategory]); return ( ) => { - const clipboardContent = e.nativeEvent.items[0]; - if (clipboardContent.type === 'text/plain') { + const clipboardContent = e.nativeEvent.items.at(0); + if (clipboardContent?.type === 'text/plain') { return; } - const mimeType = clipboardContent.type; - const fileURI = clipboardContent.data; - const baseFileName = fileURI.split('/').pop() ?? 'file'; + const mimeType = clipboardContent?.type ?? ''; + const fileURI = clipboardContent?.data; + const baseFileName = fileURI?.split('/').pop() ?? 'file'; const {fileName: stem, fileExtension: originalFileExtension} = FileUtils.splitExtensionFromFileName(baseFileName); const fileExtension = originalFileExtension || (mimeDb[mimeType].extensions?.[0] ?? 'bin'); const fileName = `${stem}.${fileExtension}`; diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index 55b1ec5aed3b..bda78b9b320d 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -95,6 +95,9 @@ type ConfirmContentProps = { /** Image to display with content */ image?: IconAsset; + + /** Whether the modal is visibile */ + isVisible: boolean; }; function ConfirmContent({ @@ -123,6 +126,7 @@ function ConfirmContent({ image, titleContainerStyles, shouldReverseStackedButtons = false, + isVisible, }: ConfirmContentProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -200,6 +204,7 @@ function ConfirmContent({ style={shouldReverseStackedButtons ? styles.mt3 : styles.mt4} onPress={onConfirm} pressOnEnter + isPressOnEnterActive={isVisible} large text={confirmText || translate('common.yes')} isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline} @@ -228,6 +233,7 @@ function ConfirmContent({ style={[styles.flex1]} onPress={onConfirm} pressOnEnter + isPressOnEnterActive={isVisible} text={confirmText || translate('common.yes')} isDisabled={isOffline && shouldDisableConfirmButtonWhenOffline} /> diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 9d6bd3a0a76a..e63b8bb91874 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -164,6 +164,7 @@ function ConfirmModal({ prompt={prompt} success={success} danger={danger} + isVisible={isVisible} shouldDisableConfirmButtonWhenOffline={shouldDisableConfirmButtonWhenOffline} shouldShowCancelButton={shouldShowCancelButton} shouldCenterContent={shouldCenterContent} diff --git a/src/components/ConfirmedRoute.tsx b/src/components/ConfirmedRoute.tsx index 4940a276cbf8..e1580dcae7d0 100644 --- a/src/components/ConfirmedRoute.tsx +++ b/src/components/ConfirmedRoute.tsx @@ -112,7 +112,7 @@ function ConfirmedRoute({mapboxAccessToken, transaction, isSmallerIcon, shouldHa pitchEnabled={false} initialState={{ zoom: CONST.MAPBOX.DEFAULT_ZOOM, - location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]), + location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE, }} directionCoordinates={coordinates as Array<[number, number]>} style={[styles.mapView, shouldHaveBorderRadius && styles.br4]} diff --git a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts index 21e7ff752794..9a5cf7d7f741 100644 --- a/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts +++ b/src/components/DatePicker/CalendarPicker/generateMonthMatrix.ts @@ -51,8 +51,8 @@ export default function generateMonthMatrix(year: number, month: number) { } // Add null values for days before the first day of the month - for (let i = matrix[0].length; i < 7; i++) { - matrix[0].unshift(undefined); + for (let i = matrix.at(0)?.length ?? 0; i < 7; i++) { + matrix.at(0)?.unshift(undefined); } return matrix; diff --git a/src/components/DatePicker/CalendarPicker/index.tsx b/src/components/DatePicker/CalendarPicker/index.tsx index 533586d4bdbf..287ec3359175 100644 --- a/src/components/DatePicker/CalendarPicker/index.tsx +++ b/src/components/DatePicker/CalendarPicker/index.tsx @@ -170,7 +170,7 @@ function CalendarPicker({ testID="currentMonthText" accessibilityLabel={translate('common.currentMonth')} > - {monthNames[currentMonthView]} + {monthNames.at(currentMonthView)} ))} - {calendarDaysMatrix.map((week) => ( + {calendarDaysMatrix?.map((week) => ( ; -}; - -type DeeplinkRedirectLoadingIndicatorProps = DeeplinkRedirectLoadingIndicatorOnyxProps & { +type DeeplinkRedirectLoadingIndicatorProps = { /** Opens the link in the browser */ openLinkInBrowser: (value: boolean) => void; }; -function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: DeeplinkRedirectLoadingIndicatorProps) { +function DeeplinkRedirectLoadingIndicator({openLinkInBrowser}: DeeplinkRedirectLoadingIndicatorProps) { const {translate} = useLocalize(); const theme = useTheme(); const styles = useThemeStyles(); - + const [currentUserLogin] = useOnyx(ONYXKEYS.SESSION, {selector: (session) => session?.email}); return ( @@ -41,7 +34,7 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink {translate('deeplinkWrapper.launching')} - {translate('deeplinkWrapper.loggedInAs', {email: session?.email ?? ''})} + {translate('deeplinkWrapper.loggedInAs', {email: currentUserLogin ?? ''})} {translate('deeplinkWrapper.doNotSeePrompt')} openLinkInBrowser(true)}>{translate('deeplinkWrapper.tryAgain')} {translate('deeplinkWrapper.or')} Navigation.goBack()}>{translate('deeplinkWrapper.continueInWeb')}. @@ -62,8 +55,4 @@ function DeeplinkRedirectLoadingIndicator({openLinkInBrowser, session}: Deeplink DeeplinkRedirectLoadingIndicator.displayName = 'DeeplinkRedirectLoadingIndicator'; -export default withOnyx({ - session: { - key: ONYXKEYS.SESSION, - }, -})(DeeplinkRedirectLoadingIndicator); +export default DeeplinkRedirectLoadingIndicator; diff --git a/src/components/DeeplinkWrapper/index.website.tsx b/src/components/DeeplinkWrapper/index.website.tsx index b395eb12c5fe..6f6562f97c17 100644 --- a/src/components/DeeplinkWrapper/index.website.tsx +++ b/src/components/DeeplinkWrapper/index.website.tsx @@ -22,6 +22,11 @@ function promptToOpenInDesktopApp(initialUrl = '') { // 2. There may be non-idempotent operations (e.g. create a new workspace), which obviously should not be executed again in the desktop app. // So we need to wait until after sign-in and navigation are complete before starting the deeplink redirect. if (Str.startsWith(window.location.pathname, Str.normalizeUrl(ROUTES.TRANSITION_BETWEEN_APPS))) { + const params = new URLSearchParams(window.location.search); + // If the user is redirected from the desktop app, don't prompt the user to open in desktop. + if (params.get('referrer') === 'desktop') { + return; + } App.beginDeepLinkRedirectAfterTransition(); } else { // Match any magic link (/v//<6 digit code>) diff --git a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx index 0b0c3ddf27ca..86edbb3b4c5e 100644 --- a/src/components/DisplayNames/DisplayNamesWithTooltip.tsx +++ b/src/components/DisplayNames/DisplayNamesWithTooltip.tsx @@ -31,13 +31,13 @@ function DisplayNamesWithToolTip({shouldUseFullTitle, fullTitle, displayNamesWit */ const getTooltipShiftX = useCallback((index: number) => { // Only shift the tooltip in case the containerLayout or Refs to the text node are available - if (!containerRef.current || !childRefs.current[index]) { + if (!containerRef.current || index < 0 || !childRefs.current.at(index)) { return 0; } const {width: containerWidth, left: containerLeft} = containerRef.current.getBoundingClientRect(); // We have to return the value as Number so we can't use `measureWindow` which takes a callback - const {width: textNodeWidth, left: textNodeLeft} = childRefs.current[index].getBoundingClientRect(); + const {width: textNodeWidth, left: textNodeLeft} = childRefs.current.at(index)?.getBoundingClientRect() ?? {width: 0, left: 0}; const tooltipX = textNodeWidth / 2 + textNodeLeft; const containerRight = containerWidth + containerLeft; const textNodeRight = textNodeWidth + textNodeLeft; diff --git a/src/components/DistanceRequest/DistanceRequestFooter.tsx b/src/components/DistanceRequest/DistanceRequestFooter.tsx index 43dd88f6e36c..8a4455e02bd6 100644 --- a/src/components/DistanceRequest/DistanceRequestFooter.tsx +++ b/src/components/DistanceRequest/DistanceRequestFooter.tsx @@ -107,7 +107,7 @@ function DistanceRequestFooter({waypoints, transaction, mapboxAccessToken, navig pitchEnabled={false} initialState={{ zoom: CONST.MAPBOX.DEFAULT_ZOOM, - location: waypointMarkers?.[0]?.coordinate ?? (CONST.MAPBOX.DEFAULT_COORDINATE as [number, number]), + location: waypointMarkers?.at(0)?.coordinate ?? CONST.MAPBOX.DEFAULT_COORDINATE, }} directionCoordinates={(transaction?.routes?.route0?.geometry?.coordinates as Array<[number, number]>) ?? []} style={[styles.mapView, styles.mapEditView]} diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx index 3ca4d3bb5545..ee4858bb0be0 100644 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx @@ -91,7 +91,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r ); } - const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types?.[preferredSkinTone] : code; + const emojiCode = typeof preferredSkinTone === 'number' && preferredSkinTone !== -1 && types?.at(preferredSkinTone) ? types.at(preferredSkinTone) : code; const shouldEmojiBeHighlighted = !!activeEmoji && EmojiUtils.getRemovedSkinToneEmoji(emojiCode) === EmojiUtils.getRemovedSkinToneEmoji(activeEmoji); return ( @@ -102,7 +102,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } onEmojiSelected(emoji, item); })} - emoji={emojiCode} + emoji={emojiCode ?? ''} isHighlighted={shouldEmojiBeHighlighted} /> ); diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx index d6c1e1f92551..afcea4f3856a 100755 --- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx +++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx @@ -175,13 +175,13 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r indexToSelect = 0; } - const item = filteredEmojis[indexToSelect]; - if (!item) { + const item = filteredEmojis.at(indexToSelect); + if (indexToSelect === -1 || !item) { return; } if ('types' in item || 'name' in item) { - const emoji = typeof preferredSkinTone === 'number' && item?.types?.[preferredSkinTone] ? item?.types?.[preferredSkinTone] : item.code; - onEmojiSelected(emoji, item); + const emoji = typeof preferredSkinTone === 'number' && preferredSkinTone !== -1 && item?.types?.at(preferredSkinTone) ? item.types.at(preferredSkinTone) : item.code; + onEmojiSelected(emoji ?? '', item); } }, {shouldPreventDefault: true, shouldStopPropagation: true}, @@ -266,7 +266,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r ); } - const emojiCode = typeof preferredSkinTone === 'number' && types?.[preferredSkinTone] ? types[preferredSkinTone] : code; + const emojiCode = typeof preferredSkinTone === 'number' && types?.at(preferredSkinTone) && preferredSkinTone !== -1 ? types.at(preferredSkinTone) : code; const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement; const shouldEmojiBeHighlighted = @@ -289,7 +289,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji}: EmojiPickerMenuProps, r } setIsUsingKeyboardMovement(false); }} - emoji={emojiCode} + emoji={emojiCode ?? ''} onFocus={() => setFocusedIndex(index)} isFocused={isEmojiFocused} isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted} diff --git a/src/components/ExplanationModal.tsx b/src/components/ExplanationModal.tsx index 73290c43d39a..d846dd4d28ba 100644 --- a/src/components/ExplanationModal.tsx +++ b/src/components/ExplanationModal.tsx @@ -1,30 +1,12 @@ -import React, {useCallback} from 'react'; +import React from 'react'; import useLocalize from '@hooks/useLocalize'; -import Navigation from '@libs/Navigation/Navigation'; -import variables from '@styles/variables'; import * as Welcome from '@userActions/Welcome'; -import * as OnboardingFlow from '@userActions/Welcome/OnboardingFlow'; import CONST from '@src/CONST'; import FeatureTrainingModal from './FeatureTrainingModal'; function ExplanationModal() { const {translate} = useLocalize(); - const onClose = useCallback(() => { - Welcome.completeHybridAppOnboarding(); - - // We need to check if standard NewDot onboarding is completed. - Welcome.isOnboardingFlowCompleted({ - onNotCompleted: () => { - setTimeout(() => { - Navigation.isNavigationReady().then(() => { - OnboardingFlow.startOnboardingFlow(); - }); - }, variables.welcomeVideoDelay); - }, - }); - }, []); - return ( ); } diff --git a/src/components/FilePicker/index.native.tsx b/src/components/FilePicker/index.native.tsx index b74a68432cab..cc9c73d72c56 100644 --- a/src/components/FilePicker/index.native.tsx +++ b/src/components/FilePicker/index.native.tsx @@ -112,7 +112,7 @@ function FilePicker({children}: FilePickerProps) { onCanceled.current(); return Promise.resolve(); } - const fileData = files[0]; + const fileData = files.at(0); if (!fileData) { onCanceled.current(); diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 80f52c8053da..1d66953c1070 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -237,8 +237,16 @@ function FormProvider( }, [inputValues], ); + + const resetErrors = useCallback(() => { + FormActions.clearErrors(formID); + FormActions.clearErrorFields(formID); + setErrors({}); + }, [formID]); + useImperativeHandle(forwardedRef, () => ({ resetForm, + resetErrors, })); const registerInput = useCallback( diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index e9f14315486d..d26276d0418b 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -3,8 +3,7 @@ import type {RefObject} from 'react'; // eslint-disable-next-line no-restricted-imports import type {ScrollView as RNScrollView, StyleProp, View, ViewStyle} from 'react-native'; import {Keyboard} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FormElement from '@components/FormElement'; import SafeAreaConsumer from '@components/SafeAreaConsumer'; @@ -13,18 +12,13 @@ import ScrollView from '@components/ScrollView'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ErrorUtils from '@libs/ErrorUtils'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {FormInputErrors, FormProps, InputRefs} from './types'; -type FormWrapperOnyxProps = { - /** Contains the form state that must be accessed outside the component */ - formState: OnyxEntry
; -}; - type FormWrapperProps = ChildrenProps & - FormWrapperOnyxProps & FormProps & { /** Submit button styles */ submitButtonStyles?: StyleProp; @@ -48,7 +42,6 @@ type FormWrapperProps = ChildrenProps & function FormWrapper({ onSubmit, children, - formState, errors, inputRefs, submitButtonText, @@ -69,6 +62,9 @@ function FormWrapper({ const styles = useThemeStyles(); const formRef = useRef(null); const formContentRef = useRef(null); + + const [formState] = useOnyx(`${formID}`); + const errorMessage = useMemo(() => (formState ? ErrorUtils.getLatestErrorMessage(formState) : undefined), [formState]); const onFixTheErrorsLinkPressed = useCallback(() => { @@ -189,10 +185,4 @@ function FormWrapper({ FormWrapper.displayName = 'FormWrapper'; -export default withOnyx({ - formState: { - // withOnyx typings are not able to handle such generic cases like this one, since it's a generic component we need to cast the keys to any - // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-explicit-any - key: (props) => props.formID as any, - }, -})(FormWrapper); +export default FormWrapper; diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 45899065e1ba..4ddd816af423 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -157,6 +157,7 @@ type FormProps = { type FormRef = { resetForm: (optionalValue: FormOnyxValues) => void; + resetErrors: () => void; }; type InputRefs = Record>; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx index 7892d8624699..d2e407ff8b55 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/AnchorRenderer.tsx @@ -23,7 +23,7 @@ function AnchorRenderer({tnode, style, key}: AnchorRendererProps) { const {environmentURL} = useEnvironment(); // An auth token is needed to download Expensify chat attachments const isAttachment = !!htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE]; - const tNodeChild = tnode?.domNode?.children?.[0]; + const tNodeChild = tnode?.domNode?.children?.at(0); const displayName = tNodeChild && 'data' in tNodeChild && typeof tNodeChild.data === 'string' ? tNodeChild.data : ''; const parentStyle = tnode.parent?.styles?.nativeTextRet ?? {}; const attrHref = htmlAttribs.href || htmlAttribs[CONST.ATTACHMENT_SOURCE_ATTRIBUTE] || ''; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx index 9ad138444b9c..31d092800d20 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/EmojiRenderer.tsx @@ -3,9 +3,9 @@ import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-ht import EmojiWithTooltip from '@components/EmojiWithTooltip'; import useThemeStyles from '@hooks/useThemeStyles'; -function EmojiRenderer({tnode}: CustomRendererProps) { +function EmojiRenderer({tnode, style: styleProp}: CustomRendererProps) { const styles = useThemeStyles(); - const style = 'islarge' in tnode.attributes ? styles.onlyEmojisText : {}; + const style = {...styleProp, ...('islarge' in tnode.attributes ? styles.onlyEmojisText : {})}; return ( { - if (!NativeModules.HybridAppModule) { - return; - } - const HybridAppEvents = new NativeEventEmitter(NativeModules.HybridAppModule as unknown as NativeModule); - const listener = HybridAppEvents.addListener(CONST.EVENTS.ON_RETURN_TO_OLD_DOT, () => { - Log.info('[HybridApp] `onReturnToOldDot` event received. Resetting state of HybridAppMiddleware', true); - setSplashScreenState(CONST.BOOT_SPLASH_STATE.VISIBLE); - }); - - return () => { - listener.remove(); - }; - }, [setSplashScreenState]); - - return children; -} - -HybridAppMiddleware.displayName = 'HybridAppMiddleware'; - -export default HybridAppMiddleware; diff --git a/src/components/HybridAppMiddleware/index.tsx b/src/components/HybridAppMiddleware/index.tsx deleted file mode 100644 index 74e018bcfa5a..000000000000 --- a/src/components/HybridAppMiddleware/index.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type React from 'react'; - -type HybridAppMiddlewareProps = { - children: React.ReactNode; -}; - -function HybridAppMiddleware({children}: HybridAppMiddlewareProps) { - return children; -} - -HybridAppMiddleware.displayName = 'HybridAppMiddleware'; - -export default HybridAppMiddleware; diff --git a/src/components/ImportColumn.tsx b/src/components/ImportColumn.tsx index 37651e58bb79..1f8dbe729578 100644 --- a/src/components/ImportColumn.tsx +++ b/src/components/ImportColumn.tsx @@ -161,7 +161,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu const columnValuesString = column.slice(containsHeader ? 1 : 0).join(', '); - const colName = findColumnName(column[0]); + const colName = findColumnName(column.at(0) ?? ''); const defaultSelectedIndex = columnRoles.findIndex((item) => item.value === colName); useEffect(() => { @@ -172,7 +172,7 @@ function ImportColumn({column, columnName, columnRoles, columnIndex}: ImportColu // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps -- we don't want this effect to run again }, []); - const columnHeader = containsHeader ? column[0] : translate('spreadsheet.column', {name: columnName}); + const columnHeader = containsHeader ? column.at(0) : translate('spreadsheet.column', {name: columnName}); return ( diff --git a/src/components/ImportOnyxState/BaseImportOnyxState.tsx b/src/components/ImportOnyxState/BaseImportOnyxState.tsx new file mode 100644 index 000000000000..216a6ddf76e4 --- /dev/null +++ b/src/components/ImportOnyxState/BaseImportOnyxState.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import type {FileObject} from '@components/AttachmentModal'; +import AttachmentPicker from '@components/AttachmentPicker'; +import DecisionModal from '@components/DecisionModal'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import useLocalize from '@hooks/useLocalize'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useThemeStyles from '@hooks/useThemeStyles'; + +function BaseImportOnyxState({ + onFileRead, + isErrorModalVisible, + setIsErrorModalVisible, +}: { + onFileRead: (file: FileObject) => void; + isErrorModalVisible: boolean; + setIsErrorModalVisible: (value: boolean) => void; +}) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {isSmallScreenWidth} = useResponsiveLayout(); + + return ( + <> + + {({openPicker}) => { + return ( + { + openPicker({ + onPicked: onFileRead, + }); + }} + /> + ); + }} + + setIsErrorModalVisible(false)} + secondOptionText={translate('common.ok')} + isVisible={isErrorModalVisible} + onClose={() => setIsErrorModalVisible(false)} + /> + + ); +} + +export default BaseImportOnyxState; diff --git a/src/components/ImportOnyxState/index.native.tsx b/src/components/ImportOnyxState/index.native.tsx new file mode 100644 index 000000000000..b07f47d3a5de --- /dev/null +++ b/src/components/ImportOnyxState/index.native.tsx @@ -0,0 +1,105 @@ +import React, {useState} from 'react'; +import RNFS from 'react-native-fs'; +import Onyx from 'react-native-onyx'; +import type {FileObject} from '@components/AttachmentModal'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {setShouldForceOffline} from '@libs/actions/Network'; +import Navigation from '@libs/Navigation/Navigation'; +import type {OnyxValues} from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseImportOnyxState from './BaseImportOnyxState'; +import type ImportOnyxStateProps from './types'; +import {cleanAndTransformState} from './utils'; + +const CHUNK_SIZE = 100; + +function readFileInChunks(fileUri: string, chunkSize = 1024 * 1024) { + const filePath = decodeURIComponent(fileUri.replace('file://', '')); + + return RNFS.exists(filePath) + .then((exists) => { + if (!exists) { + throw new Error('File does not exist'); + } + return RNFS.stat(filePath); + }) + .then((fileStats) => { + const fileSize = fileStats.size; + let fileContent = ''; + const promises = []; + + // Chunk the file into smaller parts to avoid memory issues + for (let i = 0; i < fileSize; i += chunkSize) { + promises.push(RNFS.read(filePath, chunkSize, i, 'utf8').then((chunk) => chunk)); + } + + // After all chunks have been read, join them together + return Promise.all(promises).then((chunks) => { + fileContent = chunks.join(''); + + return fileContent; + }); + }); +} + +function chunkArray(array: T[], size: number): T[][] { + const result = []; + for (let i = 0; i < array.length; i += size) { + result.push(array.slice(i, i + size)); + } + return result; +} + +function applyStateInChunks(state: OnyxValues) { + const entries = Object.entries(state); + const chunks = chunkArray(entries, CHUNK_SIZE); + + let promise = Promise.resolve(); + chunks.forEach((chunk) => { + const partialOnyxState = Object.fromEntries(chunk) as Partial; + promise = promise.then(() => Onyx.multiSet(partialOnyxState)); + }); + + return promise; +} + +export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + + const handleFileRead = (file: FileObject) => { + if (!file.uri) { + return; + } + + setIsLoading(true); + readFileInChunks(file.uri) + .then((fileContent) => { + const transformedState = cleanAndTransformState(fileContent); + setShouldForceOffline(true); + Onyx.clear(KEYS_TO_PRESERVE).then(() => { + applyStateInChunks(transformedState).then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }); + }); + }) + .catch(() => { + setIsErrorModalVisible(true); + }) + .finally(() => { + setIsLoading(false); + }); + + if (isLoading) { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/ImportOnyxState/index.tsx b/src/components/ImportOnyxState/index.tsx new file mode 100644 index 000000000000..8add2d9172fd --- /dev/null +++ b/src/components/ImportOnyxState/index.tsx @@ -0,0 +1,59 @@ +import React, {useState} from 'react'; +import Onyx from 'react-native-onyx'; +import type {FileObject} from '@components/AttachmentModal'; +import {KEYS_TO_PRESERVE, setIsUsingImportedState} from '@libs/actions/App'; +import {setShouldForceOffline} from '@libs/actions/Network'; +import Navigation from '@libs/Navigation/Navigation'; +import type {OnyxValues} from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import BaseImportOnyxState from './BaseImportOnyxState'; +import type ImportOnyxStateProps from './types'; +import {cleanAndTransformState} from './utils'; + +export default function ImportOnyxState({setIsLoading, isLoading}: ImportOnyxStateProps) { + const [isErrorModalVisible, setIsErrorModalVisible] = useState(false); + + const handleFileRead = (file: FileObject) => { + if (!file.uri) { + return; + } + + setIsLoading(true); + const blob = new Blob([file as BlobPart]); + const response = new Response(blob); + + response + .text() + .then((text) => { + const fileContent = text; + const transformedState = cleanAndTransformState(fileContent); + setShouldForceOffline(true); + Onyx.clear(KEYS_TO_PRESERVE).then(() => { + Onyx.multiSet(transformedState) + .then(() => { + setIsUsingImportedState(true); + Navigation.navigate(ROUTES.HOME); + }) + .finally(() => { + setIsLoading(false); + }); + }); + }) + .catch(() => { + setIsErrorModalVisible(true); + setIsLoading(false); + }); + + if (isLoading) { + setIsLoading(false); + } + }; + + return ( + + ); +} diff --git a/src/components/ImportOnyxState/types.ts b/src/components/ImportOnyxState/types.ts new file mode 100644 index 000000000000..8e504c493529 --- /dev/null +++ b/src/components/ImportOnyxState/types.ts @@ -0,0 +1,6 @@ +type ImportOnyxStateProps = { + isLoading: boolean; + setIsLoading: (isLoading: boolean) => void; +}; + +export default ImportOnyxStateProps; diff --git a/src/components/ImportOnyxState/utils.ts b/src/components/ImportOnyxState/utils.ts new file mode 100644 index 000000000000..a5f24fa80714 --- /dev/null +++ b/src/components/ImportOnyxState/utils.ts @@ -0,0 +1,53 @@ +import cloneDeep from 'lodash/cloneDeep'; +import type {UnknownRecord} from 'type-fest'; +import ONYXKEYS from '@src/ONYXKEYS'; + +// List of Onyx keys from the .txt file we want to keep for the local override +const keysToOmit = [ONYXKEYS.ACTIVE_CLIENTS, ONYXKEYS.FREQUENTLY_USED_EMOJIS, ONYXKEYS.NETWORK, ONYXKEYS.CREDENTIALS, ONYXKEYS.SESSION, ONYXKEYS.PREFERRED_THEME]; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && !Array.isArray(value) && value !== null; +} + +function transformNumericKeysToArray(data: UnknownRecord): UnknownRecord | unknown[] { + const dataCopy = cloneDeep(data); + if (!isRecord(dataCopy)) { + return Array.isArray(dataCopy) ? (dataCopy as UnknownRecord[]).map(transformNumericKeysToArray) : (dataCopy as UnknownRecord); + } + + const keys = Object.keys(dataCopy); + + if (keys.length === 0) { + return dataCopy; + } + const allKeysAreNumeric = keys.every((key) => !Number.isNaN(Number(key))); + const keysAreSequential = keys.every((key, index) => parseInt(key, 10) === index); + if (allKeysAreNumeric && keysAreSequential) { + return keys.map((key) => transformNumericKeysToArray(dataCopy[key] as UnknownRecord)); + } + + for (const key in dataCopy) { + if (key in dataCopy) { + dataCopy[key] = transformNumericKeysToArray(dataCopy[key] as UnknownRecord); + } + } + + return dataCopy; +} + +function cleanAndTransformState(state: string): T { + const parsedState = JSON.parse(state) as UnknownRecord; + + Object.keys(parsedState).forEach((key) => { + const shouldOmit = keysToOmit.some((onyxKey) => key.startsWith(onyxKey)); + + if (shouldOmit) { + delete parsedState[key]; + } + }); + + const transformedState = transformNumericKeysToArray(parsedState) as T; + return transformedState; +} + +export {transformNumericKeysToArray, cleanAndTransformState}; diff --git a/src/components/ImportSpreadsheetColumns.tsx b/src/components/ImportSpreadsheetColumns.tsx index 9ba0597bd3d6..7df36dd80c9d 100644 --- a/src/components/ImportSpreadsheetColumns.tsx +++ b/src/components/ImportSpreadsheetColumns.tsx @@ -70,9 +70,9 @@ function ImportSpreadsheetColumns({spreadsheetColumns, columnNames, columnRoles, {spreadsheetColumns.map((column, index) => { return ( diff --git a/src/components/ImportedStateIndicator.tsx b/src/components/ImportedStateIndicator.tsx new file mode 100644 index 000000000000..029c0f51cd33 --- /dev/null +++ b/src/components/ImportedStateIndicator.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {View} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearOnyxAndResetApp} from '@libs/actions/App'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Button from './Button'; + +function ImportedStateIndicator() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [isUsingImportedState] = useOnyx(ONYXKEYS.IS_USING_IMPORTED_STATE); + + if (!isUsingImportedState) { + return null; + } + + return ( + + - )} - {/** + + {isHidden ? translate('moderation.revealMessage') : translate('moderation.hideMessage')} + + + )} + {/** These are the actionable buttons that appear at the bottom of a Concierge message for example: Invite a user mentioned but not a member of the room https://github.com/Expensify/App/issues/32741 */} - {actionableItemButtons.length > 0 && ( - - )} - - ) : ( - - )} - - + {actionableItemButtons.length > 0 && ( + + )} + + ) : ( + + )} + + + ); } const numberOfThreadReplies = action.childVisibleActionCount ?? 0; diff --git a/src/pages/home/report/ReportActionItemContentCreated.tsx b/src/pages/home/report/ReportActionItemContentCreated.tsx index ad40df3d5213..69e27701edd8 100644 --- a/src/pages/home/report/ReportActionItemContentCreated.tsx +++ b/src/pages/home/report/ReportActionItemContentCreated.tsx @@ -106,15 +106,17 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans } return ( - - - - {renderThreadDivider} - - + + + + + {renderThreadDivider} + + + ); } @@ -157,6 +159,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans report={report} policy={policy} isCombinedReport + pendingAction={action.pendingAction} shouldShowTotal={transaction ? transactionCurrency !== report.currency : false} shouldHideThreadDividerLine={shouldHideThreadDividerLine} /> @@ -174,6 +177,7 @@ function ReportActionItemContentCreated({contextValue, parentReportAction, trans )} diff --git a/src/pages/home/report/ReportActionItemCreated.tsx b/src/pages/home/report/ReportActionItemCreated.tsx index 700c87ee3529..1adb24fa23a7 100644 --- a/src/pages/home/report/ReportActionItemCreated.tsx +++ b/src/pages/home/report/ReportActionItemCreated.tsx @@ -6,7 +6,6 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; import ReportWelcomeText from '@components/ReportWelcomeText'; import useLocalize from '@hooks/useLocalize'; -import usePolicy from '@hooks/usePolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; @@ -24,14 +23,14 @@ type ReportActionItemCreatedProps = { // eslint-disable-next-line react/no-unused-prop-types policyID: string | undefined; }; -function ReportActionItemCreated({policyID, reportID}: ReportActionItemCreatedProps) { - const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const policy = usePolicy(policyID); +function ReportActionItemCreated({reportID, policyID}: ReportActionItemCreatedProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const {shouldUseNarrowLayout, isLargeScreenWidth} = useResponsiveLayout(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`); + const [policy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`); if (!ReportUtils.isChatReport(report)) { @@ -68,7 +67,8 @@ function ReportActionItemCreated({policyID, reportID}: ReportActionItemCreatedPr > ; -}; - -type ReportActionItemMessageProps = ReportActionItemMessageOnyxProps & { +type ReportActionItemMessageProps = { /** The report action */ action: ReportAction; @@ -40,9 +34,10 @@ type ReportActionItemMessageProps = ReportActionItemMessageOnyxProps & { reportID: string; }; -function ReportActionItemMessage({action, transaction, displayAsGroup, reportID, style, isHidden = false}: ReportActionItemMessageProps) { +function ReportActionItemMessage({action, displayAsGroup, reportID, style, isHidden = false}: ReportActionItemMessageProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.getLinkedTransactionID(action) ?? -1}`); const fragments = ReportActionsUtils.getReportActionMessageFragments(action); const isIOUReport = ReportActionsUtils.isMoneyRequestAction(action); @@ -133,9 +128,7 @@ function ReportActionItemMessage({action, transaction, displayAsGroup, reportID, return; } - // TODO: Uncomment the following line when the invoices screen is ready - https://github.com/Expensify/App/issues/45175. - // Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)) - Navigation.navigate(ROUTES.WORKSPACE_MORE_FEATURES.getRoute(policyID)); + Navigation.navigate(ROUTES.WORKSPACE_INVOICES.getRoute(policyID)); }; return ( @@ -161,8 +154,4 @@ function ReportActionItemMessage({action, transaction, displayAsGroup, reportID, ReportActionItemMessage.displayName = 'ReportActionItemMessage'; -export default withOnyx({ - transaction: { - key: ({action}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${ReportActionsUtils.getLinkedTransactionID(action) ?? -1}`, - }, -})(ReportActionItemMessage); +export default ReportActionItemMessage; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 91f12339ee07..ca11a1b02d26 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -85,21 +85,20 @@ function ReportActionItemSingle({ const personalDetails = usePersonalDetails() ?? CONST.EMPTY_OBJECT; const policy = usePolicy(report?.policyID); const delegatePersonalDetails = personalDetails[action?.delegateAccountID ?? '']; - const actorAccountID = ReportUtils.getReportActionActorAccountID(action); + const actorAccountID = ReportUtils.getReportActionActorAccountID(action, iouReport); const [invoiceReceiverPolicy] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? report.invoiceReceiver.policyID : -1}`); + let displayName = ReportUtils.getDisplayNameForParticipant(actorAccountID); - const icons = ReportUtils.getIcons(iouReport ?? null, personalDetails); const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails[actorAccountID ?? -1] ?? {}; const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const isTripRoom = ReportUtils.isTripRoom(report); const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; - const displayAllActors = isReportPreviewAction && !isTripRoom && ReportUtils.isIOUReport(iouReport ?? null) && icons.length > 1; + const displayAllActors = isReportPreviewAction && !isTripRoom; const isInvoiceReport = ReportUtils.isInvoiceReport(iouReport ?? null); const isWorkspaceActor = isInvoiceReport || (ReportUtils.isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors)); const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; - const managerID = iouReport?.managerID ?? action?.childManagerAccountID; let avatarSource = avatar; let avatarId: number | string | undefined = actorAccountID; @@ -131,9 +130,10 @@ function ReportActionItemSingle({ }; } else { // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice - const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? managerID : ownerAccountID; + const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; const secondaryDisplayName = ReportUtils.getDisplayNameForParticipant(secondaryAccountId); + secondaryAvatar = { source: secondaryUserAvatar, type: CONST.ICON_TYPE_AVATAR, @@ -146,45 +146,28 @@ function ReportActionItemSingle({ const avatarIconIndex = report?.isOwnPolicyExpenseChat || ReportUtils.isPolicyExpenseChat(report) ? 0 : 1; const reportIcons = ReportUtils.getIcons(report, {}); - secondaryAvatar = reportIcons[avatarIconIndex]; + secondaryAvatar = reportIcons.at(avatarIconIndex) ?? {name: '', source: '', type: CONST.ICON_TYPE_AVATAR}; } else { secondaryAvatar = {name: '', source: '', type: 'avatar'}; } - - const icon = useMemo( - () => ({ - source: avatarSource ?? FallbackAvatar, - type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR, - name: primaryDisplayName ?? '', - id: avatarId, - }), - [avatarSource, isWorkspaceActor, primaryDisplayName, avatarId], - ); + const icon = { + source: avatarSource ?? FallbackAvatar, + type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR, + name: primaryDisplayName ?? '', + id: avatarId, + }; // Since the display name for a report action message is delivered with the report history as an array of fragments // we'll need to take the displayName from personal details and have it be in the same format for now. Eventually, // we should stop referring to the report history items entirely for this information. - const personArray = useMemo(() => { - const baseArray = displayName - ? [ - { - type: 'TEXT', - text: displayName, - }, - ] - : action?.person ?? []; - - if (displayAllActors) { - return [ - ...baseArray, - { - type: 'TEXT', - text: secondaryAvatar.name ?? '', - }, - ]; - } - return baseArray; - }, [displayName, action?.person, displayAllActors, secondaryAvatar?.name]); + const personArray = displayName + ? [ + { + type: 'TEXT', + text: displayName, + }, + ] + : action?.person; const reportID = report?.reportID; const iouReportID = iouReport?.reportID; @@ -209,130 +192,45 @@ function ReportActionItemSingle({ [action, isWorkspaceActor, actorAccountID], ); - const getAvatar = useMemo(() => { - return () => { - if (displayAllActors) { - return ( - - ); - } - if (shouldShowSubscriptAvatar) { - return ( - - ); - } + const getAvatar = () => { + if (displayAllActors) { return ( - - - - - + ); - }; - }, [ - displayAllActors, - shouldShowSubscriptAvatar, - actorAccountID, - action?.delegateAccountID, - icon, - styles.actionAvatar, - fallbackIcon, - icons, - StyleUtils, - theme.appBG, - theme.hoverComponentBG, - theme.componentBG, - isHovered, - secondaryAvatar, - ]); - - const getHeading = useMemo(() => { - return () => { - if (displayAllActors && secondaryAvatar.name && isReportPreviewAction) { - return ( - - - - {` & `} - - - - ); - } + } + if (shouldShowSubscriptAvatar) { return ( + + ); + } + return ( + - {personArray?.map((fragment) => ( - - ))} + - ); - }; - }, [ - displayAllActors, - secondaryAvatar, - isReportPreviewAction, - personArray, - styles.flexRow, - styles.flex1, - styles.chatItemMessageHeaderSender, - styles.pre, - action, - actorAccountID, - displayName, - icon, - ]); - + + ); + }; const hasEmojiStatus = !displayAllActors && status?.emojiCode; const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? ''); const statusText = status?.text ?? ''; @@ -363,7 +261,18 @@ function ReportActionItemSingle({ accessibilityLabel={actorHint} role={CONST.ROLE.BUTTON} > - {getHeading()} + {personArray?.map((fragment, index) => ( + + ))} {!!hasEmojiStatus && ( @@ -384,5 +293,7 @@ function ReportActionItemSingle({ ); } + ReportActionItemSingle.displayName = 'ReportActionItemSingle'; + export default ReportActionItemSingle; diff --git a/src/pages/home/report/ReportActionsList.tsx b/src/pages/home/report/ReportActionsList.tsx index 8f5fc907a962..ce925d4375af 100644 --- a/src/pages/home/report/ReportActionsList.tsx +++ b/src/pages/home/report/ReportActionsList.tsx @@ -11,8 +11,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import InvertedFlatList from '@components/InvertedFlatList'; import {AUTOSCROLL_TO_TOP_THRESHOLD} from '@components/InvertedFlatList/BaseInvertedFlatList'; import {usePersonalDetails} from '@components/OnyxProvider'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useReportScrollManager from '@hooks/useReportScrollManager'; @@ -39,7 +38,7 @@ import ReportActionsListItemRenderer from './ReportActionsListItemRenderer'; type LoadNewerChats = DebouncedFunc<(params: {distanceFromStart: number}) => void>; -type ReportActionsListProps = WithCurrentUserPersonalDetailsProps & { +type ReportActionsListProps = { /** The report currently being looked at */ report: OnyxTypes.Report; @@ -146,7 +145,6 @@ function ReportActionsList({ sortedReportActions, onScroll, mostRecentIOUReportActionID = '', - currentUserPersonalDetails, loadNewerChats, loadOlderChats, onLayout, @@ -156,6 +154,7 @@ function ReportActionsList({ shouldEnableAutoScrollToTopThreshold, parentReportActionForTransactionThread, }: ReportActionsListProps) { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const personalDetailsList = usePersonalDetails() || CONST.EMPTY_OBJECT; const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -219,7 +218,7 @@ function ReportActionsList({ */ const unreadMarkerReportActionID = useMemo(() => { const shouldDisplayNewMarker = (reportAction: OnyxTypes.ReportAction, index: number): boolean => { - const nextMessage = sortedVisibleReportActions[index + 1]; + const nextMessage = sortedVisibleReportActions.at(index + 1); const isCurrentMessageUnread = isMessageUnread(reportAction, unreadMarkerTime); const isNextMessageRead = !nextMessage || !isMessageUnread(nextMessage, unreadMarkerTime); const shouldDisplay = isCurrentMessageUnread && isNextMessageRead && !ReportActionsUtils.shouldHideNewMarker(reportAction); @@ -229,8 +228,9 @@ function ReportActionsList({ // Scan through each visible report action until we find the appropriate action to show the unread marker for (let index = 0; index < sortedVisibleReportActions.length; index++) { - const reportAction = sortedVisibleReportActions[index]; - if (shouldDisplayNewMarker(reportAction, index)) { + const reportAction = sortedVisibleReportActions.at(index); + + if (reportAction && shouldDisplayNewMarker(reportAction, index)) { return reportAction.reportActionID; } } @@ -267,7 +267,7 @@ function ReportActionsList({ return; } - const mostRecentReportActionCreated = sortedVisibleReportActions[0]?.created ?? ''; + const mostRecentReportActionCreated = sortedVisibleReportActions.at(0)?.created ?? ''; if (mostRecentReportActionCreated <= unreadMarkerTime) { return; } @@ -277,14 +277,14 @@ function ReportActionsList({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [sortedVisibleReportActions]); - const lastActionIndex = sortedVisibleReportActions[0]?.reportActionID; + const lastActionIndex = sortedVisibleReportActions.at(0)?.reportActionID; const reportActionSize = useRef(sortedVisibleReportActions.length); - const hasNewestReportAction = sortedVisibleReportActions[0]?.created === report.lastVisibleActionCreated; + const hasNewestReportAction = sortedVisibleReportActions.at(0)?.created === report.lastVisibleActionCreated; const hasNewestReportActionRef = useRef(hasNewestReportAction); hasNewestReportActionRef.current = hasNewestReportAction; const previousLastIndex = useRef(lastActionIndex); - const isLastPendingActionIsDelete = sortedReportActions?.[0]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + const isLastPendingActionIsDelete = sortedReportActions?.at(0)?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false); @@ -444,7 +444,7 @@ function ReportActionsList({ const firstVisibleReportActionID = useMemo(() => ReportActionsUtils.getFirstVisibleReportActionID(sortedReportActions, isOffline), [sortedReportActions, isOffline]); const shouldUseThreadDividerLine = useMemo(() => { - const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions[sortedVisibleReportActions.length - 1] : null; + const topReport = sortedVisibleReportActions.length > 0 ? sortedVisibleReportActions.at(sortedVisibleReportActions.length - 1) : null; if (topReport && topReport.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { return false; @@ -468,7 +468,7 @@ function ReportActionsList({ if (!isVisible || !isFocused) { if (!lastMessageTime.current) { - lastMessageTime.current = sortedVisibleReportActions[0]?.created ?? ''; + lastMessageTime.current = sortedVisibleReportActions.at(0)?.created ?? ''; } return; } @@ -664,6 +664,6 @@ function ReportActionsList({ ReportActionsList.displayName = 'ReportActionsList'; -export default withCurrentUserPersonalDetails(memo(ReportActionsList)); +export default memo(ReportActionsList); export type {LoadNewerChats, ReportActionsListProps}; diff --git a/src/pages/home/report/ReportActionsListItemRenderer.tsx b/src/pages/home/report/ReportActionsListItemRenderer.tsx index 63b2cb43d836..ff1c2431ca8b 100644 --- a/src/pages/home/report/ReportActionsListItemRenderer.tsx +++ b/src/pages/home/report/ReportActionsListItemRenderer.tsx @@ -171,7 +171,7 @@ function ReportActionsListItemRenderer({ displayAsGroup={displayAsGroup} shouldDisplayNewMarker={shouldDisplayNewMarker} shouldShowSubscriptAvatar={ - (ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isInvoiceRoom(report)) && + ReportUtils.isPolicyExpenseChat(report) && [ CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, diff --git a/src/pages/home/report/ReportActionsView.tsx b/src/pages/home/report/ReportActionsView.tsx index 270a241778e1..8f4395fdb715 100755 --- a/src/pages/home/report/ReportActionsView.tsx +++ b/src/pages/home/report/ReportActionsView.tsx @@ -150,9 +150,9 @@ function ReportActionsView({ } const actions = [...allReportActions]; - const lastAction = allReportActions[allReportActions.length - 1]; + const lastAction = allReportActions.at(-1); - if (!ReportActionsUtils.isCreatedAction(lastAction)) { + if (lastAction && !ReportActionsUtils.isCreatedAction(lastAction)) { const optimisticCreatedAction = ReportUtils.buildOptimisticCreatedReportAction(String(report?.ownerAccountID), DateUtils.subtractMillisecondsFromDateTime(lastAction.created, 1)); optimisticCreatedAction.pendingAction = null; actions.push(optimisticCreatedAction); @@ -183,7 +183,7 @@ function ReportActionsView({ false, false, false, - DateUtils.subtractMillisecondsFromDateTime(actions[actions.length - 1].created, 1), + DateUtils.subtractMillisecondsFromDateTime(actions.at(-1)?.created ?? '', 1), ) as OnyxTypes.ReportAction; moneyRequestActions.push(optimisticIOUAction); actions.splice(actions.length - 1, 0, optimisticIOUAction); @@ -274,10 +274,10 @@ function ReportActionsView({ ); const hasMoreCached = reportActions.length < combinedReportActions.length; - const newestReportAction = useMemo(() => reportActions?.[0], [reportActions]); + const newestReportAction = useMemo(() => reportActions?.at(0), [reportActions]); const mostRecentIOUReportActionID = useMemo(() => ReportActionsUtils.getMostRecentIOURequestActionID(reportActions), [reportActions]); const hasCachedActionOnFirstRender = useInitialValue(() => reportActions.length > 0); - const hasNewestReportAction = reportActions[0]?.created === report.lastVisibleActionCreated || reportActions[0]?.created === transactionThreadReport?.lastVisibleActionCreated; + const hasNewestReportAction = reportActions.at(0)?.created === report.lastVisibleActionCreated || reportActions.at(0)?.created === transactionThreadReport?.lastVisibleActionCreated; const oldestReportAction = useMemo(() => reportActions?.at(-1), [reportActions]); const hasCreatedAction = oldestReportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED; @@ -312,7 +312,9 @@ function ReportActionsView({ // This function is a placeholder as the actual pagination is handled by visibleReportActions if (!hasMoreCached && !hasNewestReportAction) { isFirstLinkedActionRender.current = false; - fetchNewerAction(newestReportAction); + if (newestReportAction) { + fetchNewerAction(newestReportAction); + } } if (isFirstLinkedActionRender.current) { isFirstLinkedActionRender.current = false; @@ -384,7 +386,7 @@ function ReportActionsView({ // If there was an error only try again once on initial mount. We should also still load // more in case we have cached messages. (!hasMoreCached && didLoadNewerChats.current && hasLoadingNewerReportActionsError) || - newestReportAction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) + newestReportAction?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) ) { return; } @@ -392,7 +394,7 @@ function ReportActionsView({ didLoadNewerChats.current = true; if ((reportActionID && indexOfLinkedAction > -1) || !reportActionID) { - handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID}); + handleReportActionPagination({firstReportActionID: newestReportAction?.reportActionID ?? '-1'}); } }, [ diff --git a/src/pages/home/report/ReportFooter.tsx b/src/pages/home/report/ReportFooter.tsx index 2bf744868a9a..7c4ec786b633 100644 --- a/src/pages/home/report/ReportFooter.tsx +++ b/src/pages/home/report/ReportFooter.tsx @@ -1,3 +1,4 @@ +import {Str} from 'expensify-common'; import lodashIsEqual from 'lodash/isEqual'; import React, {memo, useCallback} from 'react'; import {Keyboard, View} from 'react-native'; @@ -129,32 +130,42 @@ function ReportFooter({ * Group 2: Optional email group between \s+....\s* start rule with @+valid email or short mention * Group 3: Title is remaining characters */ - const taskRegex = /^\[\]\s+(?:@([^\s@]+(?:@\w+\.\w+)?))?\s*([\s\S]*)/; + // The regex is copied from the expensify-common CONST file, but the domain is optional to accept short mention + const emailWithOptionalDomainRegex = + /(?=((?=[\w'#%+-]+(?:\.[\w'#%+-]+)*@?)[\w.'#%+-]{1,64}(?:@(?:(?=[a-z\d]+(?:-+[a-z\d]+)*\.)(?:[a-z\d-]{1,63}\.)+[a-z]{2,63}))?(?= |_|\b))(?.*))\S{3,254}(?=\k$)/; + const taskRegex = `^\\[\\]\\s+(?:@(?:${emailWithOptionalDomainRegex.source}))?\\s*([\\s\\S]*)`; const match = text.match(taskRegex); if (!match) { return false; } - const title = match[2] ? match[2].trim().replace(/\n/g, ' ') : undefined; + let title = match[3] ? match[3].trim().replace(/\n/g, ' ') : undefined; if (!title) { return false; } - const mention = match[1] ? match[1].trim() : undefined; - const mentionWithDomain = ReportUtils.addDomainToShortMention(mention ?? '') ?? mention; + const mention = match[1] ? match[1].trim() : ''; + const mentionWithDomain = ReportUtils.addDomainToShortMention(mention) ?? mention; + const isValidMention = Str.isValidEmail(mentionWithDomain); let assignee: OnyxEntry; let assigneeChatReport; if (mentionWithDomain) { - assignee = Object.values(allPersonalDetails).find((value) => value?.login === mentionWithDomain) ?? undefined; - if (!Object.keys(assignee ?? {}).length) { - const assigneeAccountID = UserUtils.generateAccountID(mentionWithDomain); - const optimisticDataForNewAssignee = Task.setNewOptimisticAssignee(mentionWithDomain, assigneeAccountID); - assignee = optimisticDataForNewAssignee.assignee; - assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + if (isValidMention) { + assignee = Object.values(allPersonalDetails).find((value) => value?.login === mentionWithDomain) ?? undefined; + if (!Object.keys(assignee ?? {}).length) { + const assigneeAccountID = UserUtils.generateAccountID(mentionWithDomain); + const optimisticDataForNewAssignee = Task.setNewOptimisticAssignee(mentionWithDomain, assigneeAccountID); + assignee = optimisticDataForNewAssignee.assignee; + assigneeChatReport = optimisticDataForNewAssignee.assigneeReport; + } + } else { + // If the mention is not valid, include it on the title. + // The mention could be invalid if it's a short mention and failed to be converted to a full mention. + title = `@${mentionWithDomain} ${title}`; } } - Task.createTaskAndNavigate(report.reportID, title, '', assignee?.login ?? '', assignee?.accountID, assigneeChatReport, report.policyID); + Task.createTaskAndNavigate(report.reportID, title, '', assignee?.login ?? '', assignee?.accountID, assigneeChatReport, report.policyID, true); return true; }, [allPersonalDetails, report.policyID, report.reportID], diff --git a/src/pages/home/report/ReportTypingIndicator.tsx b/src/pages/home/report/ReportTypingIndicator.tsx index 3ff8f2b0eb8e..a04a7700ec98 100755 --- a/src/pages/home/report/ReportTypingIndicator.tsx +++ b/src/pages/home/report/ReportTypingIndicator.tsx @@ -1,6 +1,5 @@ import React, {memo, useMemo} from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Text from '@components/Text'; import TextWithEllipsis from '@components/TextWithEllipsis'; import useLocalize from '@hooks/useLocalize'; @@ -8,25 +7,19 @@ import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ReportUtils from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReportUserIsTyping} from '@src/types/onyx'; -type ReportTypingIndicatorOnyxProps = { - /** Key-value pairs of user accountIDs/logins and whether or not they are typing. Keys are accountIDs or logins. */ - userTypingStatuses: OnyxEntry; -}; - -type ReportTypingIndicatorProps = ReportTypingIndicatorOnyxProps & { - // eslint-disable-next-line react/no-unused-prop-types -- This is used by withOnyx +type ReportTypingIndicatorProps = { reportID: string; }; -function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) { +function ReportTypingIndicator({reportID}: ReportTypingIndicatorProps) { const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const [userTypingStatuses] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`); const styles = useThemeStyles(); const usersTyping = useMemo(() => Object.keys(userTypingStatuses ?? {}).filter((loginOrAccountID) => userTypingStatuses?.[loginOrAccountID]), [userTypingStatuses]); - const firstUserTyping = usersTyping[0]; + const firstUserTyping = usersTyping.at(0); const isUserTypingADisplayName = Number.isNaN(Number(firstUserTyping)); @@ -63,8 +56,4 @@ function ReportTypingIndicator({userTypingStatuses}: ReportTypingIndicatorProps) ReportTypingIndicator.displayName = 'ReportTypingIndicator'; -export default withOnyx({ - userTypingStatuses: { - key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`, - }, -})(memo(ReportTypingIndicator)); +export default memo(ReportTypingIndicator); diff --git a/src/pages/home/report/UserTypingEventListener.tsx b/src/pages/home/report/UserTypingEventListener.tsx index 57eb51df137d..fa0eed4d57c5 100644 --- a/src/pages/home/report/UserTypingEventListener.tsx +++ b/src/pages/home/report/UserTypingEventListener.tsx @@ -2,7 +2,7 @@ import type {RouteProp} from '@react-navigation/native'; import {useIsFocused, useRoute} from '@react-navigation/native'; import {useEffect, useRef} from 'react'; import {InteractionManager} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Navigation from '@libs/Navigation/Navigation'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import * as Report from '@userActions/Report'; @@ -10,16 +10,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; -type UserTypingEventListenerOnyxProps = { - /** Stores last visited path */ - lastVisitedPath?: string; -}; - -type UserTypingEventListenerProps = UserTypingEventListenerOnyxProps & { +type UserTypingEventListenerProps = { /** The report currently being looked at */ report: OnyxTypes.Report; }; -function UserTypingEventListener({report, lastVisitedPath}: UserTypingEventListenerProps) { +function UserTypingEventListener({report}: UserTypingEventListenerProps) { + const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {selector: (path) => path ?? ''}); const didSubscribeToReportTypingEvents = useRef(false); const reportID = report.reportID; const isFocused = useIsFocused(); @@ -83,9 +79,4 @@ function UserTypingEventListener({report, lastVisitedPath}: UserTypingEventListe UserTypingEventListener.displayName = 'UserTypingEventListener'; -export default withOnyx({ - lastVisitedPath: { - key: ONYXKEYS.LAST_VISITED_PATH, - selector: (path) => path ?? '', - }, -})(UserTypingEventListener); +export default UserTypingEventListener; diff --git a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx index 05f4c5aec343..204b2255b8eb 100644 --- a/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx +++ b/src/pages/home/sidebar/AvatarWithDelegateAvatar.tsx @@ -1,7 +1,10 @@ import React from 'react'; import {View} from 'react-native'; +import type {StyleProp} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {ViewStyle} from 'react-native/Libraries/StyleSheet/StyleSheetTypes'; import Avatar from '@components/Avatar'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import * as UserUtils from '@libs/UserUtils'; import CONST from '@src/CONST'; @@ -14,20 +17,24 @@ type AvatarWithDelegateAvatarProps = { /** Whether the avatar is selected */ isSelected?: boolean; + + /** Style for the Avatar container */ + containerStyle?: StyleProp; }; -function AvatarWithDelegateAvatar({delegateEmail, isSelected = false}: AvatarWithDelegateAvatarProps) { +function AvatarWithDelegateAvatar({delegateEmail, isSelected = false, containerStyle}: AvatarWithDelegateAvatarProps) { const styles = useThemeStyles(); + const {isSmallScreenWidth} = useResponsiveLayout(); const personalDetails = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const delegatePersonalDetail = Object.values(personalDetails[0] ?? {}).find((personalDetail) => personalDetail?.login?.toLowerCase() === delegateEmail); return ( - + ; }; -function AvatarWithOptionalStatus({emojiStatus = '', isSelected = false}: AvatarWithOptionalStatusProps) { +function AvatarWithOptionalStatus({emojiStatus = '', isSelected = false, containerStyle}: AvatarWithOptionalStatusProps) { const styles = useThemeStyles(); return ( - + - + ); } else if (emojiStatus) { @@ -53,10 +55,16 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT ); } else { - children = ; + children = ( + + ); } return ( @@ -66,9 +74,12 @@ function BottomTabAvatar({isCreateMenuOpen = false, isSelected = false}: BottomT role={CONST.ROLE.BUTTON} accessibilityLabel={translate('sidebarScreen.buttonMySettings')} wrapperStyle={styles.flex1} - style={styles.bottomTabBarItem} + style={[styles.bottomTabBarItem]} > {children} + + {translate('common.settings')} + ); diff --git a/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx b/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx index 833c76f6f071..4a87d0285070 100644 --- a/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx +++ b/src/pages/home/sidebar/ProfileAvatarWithIndicator.tsx @@ -1,6 +1,8 @@ import React from 'react'; import {View} from 'react-native'; +import type {StyleProp} from 'react-native'; import {useOnyx} from 'react-native-onyx'; +import type {ViewStyle} from 'react-native/Libraries/StyleSheet/StyleSheetTypes'; import AvatarWithIndicator from '@components/AvatarWithIndicator'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -10,16 +12,23 @@ import ONYXKEYS from '@src/ONYXKEYS'; type ProfileAvatarWithIndicatorProps = { /** Whether the avatar is selected */ isSelected?: boolean; + + /** Avatar Container styles */ + containerStyles?: StyleProp; }; -function ProfileAvatarWithIndicator({isSelected = false}: ProfileAvatarWithIndicatorProps) { +function ProfileAvatarWithIndicator({isSelected = false, containerStyles}: ProfileAvatarWithIndicatorProps) { const styles = useThemeStyles(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [isLoading = true] = useOnyx(ONYXKEYS.IS_LOADING_APP); return ( - - + + + PolicyUtils.canSendInvoice(allPolicies as OnyxCollection, session?.email), [allPolicies, session?.email]); const quickActionAvatars = useMemo(() => { @@ -214,7 +215,7 @@ function FloatingActionButtonAndPopover( return ''; } if (quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && quickActionAvatars.length > 0) { - const name: string = ReportUtils.getDisplayNameForParticipant(+(quickActionAvatars[0]?.id ?? -1), true) ?? ''; + const name: string = ReportUtils.getDisplayNameForParticipant(+(quickActionAvatars.at(0)?.id ?? -1), true) ?? ''; return translate('quickAction.paySomeone', {name}); } const titleKey = getQuickActionTitle(quickAction?.action ?? ('' as QuickActionName)); @@ -228,7 +229,7 @@ function FloatingActionButtonAndPopover( if (quickActionAvatars.length === 0) { return false; } - const displayName = personalDetails?.[quickActionAvatars[0]?.id ?? -1]?.firstName ?? ''; + const displayName = personalDetails?.[quickActionAvatars.at(0)?.id ?? -1]?.firstName ?? ''; return quickAction?.action === CONST.QUICK_ACTIONS.SEND_MONEY && displayName.length === 0; }, [personalDetails, quickActionReport, quickAction?.action, quickActionAvatars]); @@ -348,9 +349,70 @@ function FloatingActionButtonAndPopover( showCreateMenu(); } }; + // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), [isLoading]); + const expenseMenuItems = useMemo((): PopoverMenuItem[] => { + if (canUseCombinedTrackSubmit) { + return [ + { + icon: getIconForAction(CONST.IOU.TYPE.CREATE), + text: translate('iou.createExpense'), + onSelected: () => + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.CREATE, + // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used + // for all of the routes in the creation flow. + ReportUtils.generateReportID(), + ), + ), + }, + ]; + } + + return [ + ...(selfDMReportID + ? [ + { + icon: getIconForAction(CONST.IOU.TYPE.TRACK), + text: translate('iou.trackExpense'), + onSelected: () => { + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.TRACK, + // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. + // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), + ), + ); + if (!hasSeenTrackTraining && !isOffline) { + setTimeout(() => { + Navigation.navigate(ROUTES.TRACK_TRAINING_MODAL); + }, CONST.ANIMATED_TRANSITION); + } + }, + }, + ] + : []), + { + icon: getIconForAction(CONST.IOU.TYPE.REQUEST), + text: translate('iou.submitExpense'), + onSelected: () => + interceptAnonymousUser(() => + IOU.startMoneyRequest( + CONST.IOU.TYPE.SUBMIT, + // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used + // for all of the routes in the creation flow. + ReportUtils.generateReportID(), + ), + ), + }, + ]; + }, [canUseCombinedTrackSubmit, translate, selfDMReportID, hasSeenTrackTraining, isOffline]); + return ( interceptAnonymousUser(Report.startNewChat), }, - ...(selfDMReportID - ? [ - { - icon: getIconForAction(CONST.IOU.TYPE.TRACK), - text: translate('iou.trackExpense'), - onSelected: () => { - interceptAnonymousUser(() => - IOU.startMoneyRequest( - CONST.IOU.TYPE.TRACK, - // When starting to create a track expense from the global FAB, we need to retrieve selfDM reportID. - // If it doesn't exist, we generate a random optimistic reportID and use it for all of the routes in the creation flow. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ReportUtils.findSelfDMReportID() || ReportUtils.generateReportID(), - ), - ); - if (!hasSeenTrackTraining && !isOffline) { - setTimeout(() => { - Navigation.navigate(ROUTES.TRACK_TRAINING_MODAL); - }, CONST.ANIMATED_TRANSITION); - } - }, - }, - ] - : []), - { - icon: getIconForAction(CONST.IOU.TYPE.REQUEST), - text: translate('iou.submitExpense'), - onSelected: () => - interceptAnonymousUser(() => - IOU.startMoneyRequest( - CONST.IOU.TYPE.SUBMIT, - // When starting to create an expense from the global FAB, there is not an existing report yet. A random optimistic reportID is generated and used - // for all of the routes in the creation flow. - ReportUtils.generateReportID(), - ), - ), - }, + ...expenseMenuItems, ...(canSendInvoice ? [ { diff --git a/src/pages/iou/SplitBillDetailsPage.tsx b/src/pages/iou/SplitBillDetailsPage.tsx index b696fb38ff00..d0ca6b41e779 100644 --- a/src/pages/iou/SplitBillDetailsPage.tsx +++ b/src/pages/iou/SplitBillDetailsPage.tsx @@ -73,7 +73,7 @@ function SplitBillDetailsPage({personalDetails, report, route, reportActions, tr let participants: Array; if (ReportUtils.isPolicyExpenseChat(report)) { participants = [ - OptionsListUtils.getParticipantsOption({accountID: participantAccountIDs[0], selected: true, reportID: ''}, personalDetails), + OptionsListUtils.getParticipantsOption({accountID: participantAccountIDs.at(0), selected: true, reportID: ''}, personalDetails), OptionsListUtils.getPolicyExpenseReportOption({...report, selected: true, reportID}), ]; } else { diff --git a/src/pages/iou/request/IOURequestStartPage.tsx b/src/pages/iou/request/IOURequestStartPage.tsx index eac30b8839d2..c4abf714502a 100644 --- a/src/pages/iou/request/IOURequestStartPage.tsx +++ b/src/pages/iou/request/IOURequestStartPage.tsx @@ -54,9 +54,10 @@ function IOURequestStartPage({ [CONST.IOU.TYPE.SPLIT]: translate('iou.splitExpense'), [CONST.IOU.TYPE.TRACK]: translate('iou.trackExpense'), [CONST.IOU.TYPE.INVOICE]: translate('workspace.invoices.sendInvoice'), + [CONST.IOU.TYPE.CREATE]: translate('iou.createExpense'), }; const transactionRequestType = useRef(TransactionUtils.getRequestType(transaction)); - const {canUseP2PDistanceRequests} = usePermissions(iouType); + const {canUseP2PDistanceRequests, canUseCombinedTrackSubmit} = usePermissions(iouType); const isFromGlobalCreate = isEmptyObject(report?.reportID); // Clear out the temporary expense if the reportID in the URL has changed from the transaction's reportID @@ -69,7 +70,8 @@ function IOURequestStartPage({ const isExpenseChat = ReportUtils.isPolicyExpenseChat(report); const isExpenseReport = ReportUtils.isExpenseReport(report); - const shouldDisplayDistanceRequest = !!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.SPLIT); + const shouldDisplayDistanceRequest = + !!canUseCombinedTrackSubmit || !!canUseP2PDistanceRequests || isExpenseChat || isExpenseReport || (isFromGlobalCreate && iouType !== CONST.IOU.TYPE.SPLIT); const navigateBack = () => { Navigation.closeRHPFlow(); diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx index 0168133154ee..a2652b8693ee 100644 --- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx +++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx @@ -7,6 +7,8 @@ import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import EmptySelectionListContent from '@components/EmptySelectionListContent'; import FormHelpMessage from '@components/FormHelpMessage'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; import {usePersonalDetails} from '@components/OnyxProvider'; import {useOptionsList} from '@components/OptionListContextProvider'; import ReferralProgramCTA from '@components/ReferralProgramCTA'; @@ -41,6 +43,9 @@ type MoneyRequestParticipantsSelectorProps = { /** Callback to add participants in MoneyRequestModal */ onParticipantsAdded: (value: Participant[]) => void; + /** Callback to navigate to Track Expense confirmation flow */ + onTrackExpensePress?: () => void; + /** Selected participants from MoneyRequestModal with login */ participants?: Participant[] | typeof CONST.EMPTY_ARRAY; @@ -52,9 +57,21 @@ type MoneyRequestParticipantsSelectorProps = { /** The action of the IOU, i.e. create, split, move */ action: IOUAction; + + /** Whether we should display the Track Expense button at the top of the participants list */ + shouldDisplayTrackExpenseButton?: boolean; }; -function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onFinish, onParticipantsAdded, iouType, iouRequestType, action}: MoneyRequestParticipantsSelectorProps) { +function MoneyRequestParticipantsSelector({ + participants = CONST.EMPTY_ARRAY, + onTrackExpensePress, + onFinish, + onParticipantsAdded, + iouType, + iouRequestType, + action, + shouldDisplayTrackExpenseButton, +}: MoneyRequestParticipantsSelectorProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState(''); @@ -105,9 +122,9 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF participants as Participant[], CONST.EXPENSIFY_EMAILS, - // If we are using this component in the "Submit expense" flow then we pass the includeOwnedWorkspaceChats argument so that the current user + // If we are using this component in the "Submit expense" or the combined submit/track flow then we pass the includeOwnedWorkspaceChats argument so that the current user // sees the option to submit an expense from their admin on their own Workspace Chat. - (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.SPLIT) && action !== CONST.IOU.ACTION.SUBMIT, + (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.CREATE || iouType === CONST.IOU.TYPE.SPLIT) && action !== CONST.IOU.ACTION.SUBMIT, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing (canUseP2PDistanceRequests || iouRequestType !== CONST.IOU.REQUEST_TYPE.DISTANCE) && !isCategorizeOrShareAction, @@ -364,6 +381,22 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF const shouldShowReferralBanner = !isDismissed && iouType !== CONST.IOU.TYPE.INVOICE && !shouldShowListEmptyContent; + const headerContent = useMemo(() => { + if (!shouldDisplayTrackExpenseButton) { + return; + } + + // We only display the track expense button if the user is coming from the combined submit/track flow. + return ( + + ); + }, [shouldDisplayTrackExpenseButton, translate, onTrackExpensePress]); + const footerContent = useMemo(() => { if (isDismissed && !shouldShowSplitBillErrorMessage && !participants.length) { return; @@ -449,6 +482,7 @@ function MoneyRequestParticipantsSelector({participants = CONST.EMPTY_ARRAY, onF shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()} onSelectRow={onSelectRow} shouldSingleExecuteRowSelect + headerContent={headerContent} footerContent={footerContent} listEmptyContent={} headerMessage={header} diff --git a/src/pages/iou/request/step/IOURequestStepAmount.tsx b/src/pages/iou/request/step/IOURequestStepAmount.tsx index 2a0aa438fe98..4e2cc03b8cc4 100644 --- a/src/pages/iou/request/step/IOURequestStepAmount.tsx +++ b/src/pages/iou/request/step/IOURequestStepAmount.tsx @@ -178,10 +178,13 @@ function IOURequestStepAmount({ return; } - // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer - // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight + // If a reportID exists in the report object, it's because either: + // - The user started this flow from using the + button in the composer inside a report. + // - The user started this flow from using the global create menu by selecting the Track expense option. + // In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight // to the confirm step. - if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs)) { + // If the user is started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. + if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && iouType !== CONST.IOU.TYPE.CREATE) { const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { const participantAccountID = participant?.accountID ?? -1; @@ -212,11 +215,11 @@ function IOURequestStepAmount({ if (iouType === CONST.IOU.TYPE.PAY || iouType === CONST.IOU.TYPE.SEND) { if (paymentMethod && paymentMethod === CONST.IOU.PAYMENT_TYPE.EXPENSIFY) { - IOU.sendMoneyWithWallet(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants[0]); + IOU.sendMoneyWithWallet(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants.at(0) ?? {}); return; } - IOU.sendMoneyElsewhere(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants[0]); + IOU.sendMoneyElsewhere(report, backendAmount, currency, '', currentUserPersonalDetails.accountID, participants.at(0) ?? {}); return; } if (iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.REQUEST) { @@ -228,7 +231,7 @@ function IOURequestStepAmount({ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participants.at(0) ?? {}, '', {}, ); @@ -243,7 +246,7 @@ function IOURequestStepAmount({ CONST.TRANSACTION.PARTIAL_TRANSACTION_MERCHANT, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participants.at(0) ?? {}, '', ); return; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index b08f9a6ced5f..752a5082250e 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -231,6 +231,11 @@ function IOURequestStepConfirmation({ return; } + const participant = selectedParticipants.at(0); + if (!participant) { + return; + } + IOU.requestMoney( report, transaction.amount, @@ -239,7 +244,7 @@ function IOURequestStepConfirmation({ transaction.merchant, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - selectedParticipants[0], + participant, trimmedComment, receiptObj, transaction.category, @@ -265,6 +270,10 @@ function IOURequestStepConfirmation({ if (!report || !transaction) { return; } + const participant = selectedParticipants.at(0); + if (!participant) { + return; + } IOU.trackExpense( report, transaction.amount, @@ -273,7 +282,7 @@ function IOURequestStepConfirmation({ transaction.merchant, currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - selectedParticipants[0], + participant, trimmedComment, receiptObj, transaction.category, @@ -538,7 +547,7 @@ function IOURequestStepConfirmation({ (paymentMethod: PaymentMethodType | undefined) => { const currency = transaction?.currency; const trimmedComment = transaction?.comment?.comment?.trim() ?? ''; - const participant = participants?.[0]; + const participant = participants?.at(0); if (!participant || !transaction?.amount || !currency) { return; diff --git a/src/pages/iou/request/step/IOURequestStepDistance.tsx b/src/pages/iou/request/step/IOURequestStepDistance.tsx index 14597df8e313..b5cda2e497d3 100644 --- a/src/pages/iou/request/step/IOURequestStepDistance.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistance.tsx @@ -6,7 +6,7 @@ import {View} from 'react-native'; import type {ScrollView as RNScrollView} from 'react-native'; import type {RenderItemParams} from 'react-native-draggable-flatlist/lib/typescript/types'; import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import DistanceRequestFooter from '@components/DistanceRequest/DistanceRequestFooter'; import DistanceRequestRenderItem from '@components/DistanceRequest/DistanceRequestRenderItem'; @@ -17,6 +17,7 @@ import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentU import useFetchRoute from '@hooks/useFetchRoute'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import usePolicy from '@hooks/usePolicy'; import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; @@ -42,22 +43,7 @@ import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepDistanceOnyxProps = { - /** backup version of the original transaction */ - transactionBackup: OnyxEntry; - - /** The policy which the user has access to and which the report is tied to */ - policy: OnyxEntry; - - /** Personal details of all users */ - personalDetails: OnyxEntry; - - /** Whether the confirmation step should be skipped */ - skipConfirmation: OnyxEntry; -}; - -type IOURequestStepDistanceProps = IOURequestStepDistanceOnyxProps & - WithCurrentUserPersonalDetailsProps & +type IOURequestStepDistanceProps = WithCurrentUserPersonalDetailsProps & WithWritableReportOrNotFoundProps & { /** The transaction object being modified in Onyx */ transaction: OnyxEntry; @@ -65,21 +51,20 @@ type IOURequestStepDistanceProps = IOURequestStepDistanceOnyxProps & function IOURequestStepDistance({ report, - policy, route: { params: {action, iouType, reportID, transactionID, backTo}, }, transaction, - transactionBackup, - personalDetails, currentUserPersonalDetails, - skipConfirmation, }: IOURequestStepDistanceProps) { const styles = useThemeStyles(); const {isOffline} = useNetwork(); const {translate} = useLocalize(); const [reportNameValuePairs] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report?.reportID ?? -1}`); - + const [transactionBackup] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`); + const policy = usePolicy(report?.policyID); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); + const [skipConfirmation] = useOnyx(`${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`); const [optimisticWaypoints, setOptimisticWaypoints] = useState(null); const waypoints = useMemo( () => @@ -240,15 +225,21 @@ function IOURequestStepDistance({ }, [iouType, reportID, transactionID]); const navigateToNextStep = useCallback(() => { + if (transaction?.splitShares) { + IOU.resetSplitShares(transaction); + } if (backTo) { Navigation.goBack(backTo); return; } - // If a reportID exists in the report object, it's because the user started this flow from using the + button in the composer - // inside a report. In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight + // If a reportID exists in the report object, it's because either: + // - The user started this flow from using the + button in the composer inside a report. + // - The user started this flow from using the global create menu by selecting the Track expense option. + // In this case, the participants can be automatically assigned from the report and the user can skip the participants step and go straight // to the confirm step. - if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs)) { + // If the user started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. + if (report?.reportID && !ReportUtils.isArchivedRoom(report, reportNameValuePairs) && iouType !== CONST.IOU.TYPE.CREATE) { const selectedParticipants = IOU.setMoneyRequestParticipantsFromReport(transactionID, report); const participants = selectedParticipants.map((participant) => { const participantAccountID = participant?.accountID ?? -1; @@ -275,7 +266,8 @@ function IOURequestStepDistance({ } IOU.setMoneyRequestPendingFields(transactionID, {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}); IOU.setMoneyRequestMerchant(transactionID, translate('iou.fieldPending'), false); - if (iouType === CONST.IOU.TYPE.TRACK) { + const participant = participants.at(0); + if (iouType === CONST.IOU.TYPE.TRACK && participant) { IOU.trackExpense( report, 0, @@ -284,7 +276,7 @@ function IOURequestStepDistance({ translate('iou.fieldPending'), currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', {}, '', @@ -520,26 +512,7 @@ function IOURequestStepDistance({ IOURequestStepDistance.displayName = 'IOURequestStepDistance'; -const IOURequestStepDistanceWithOnyx = withOnyx({ - transactionBackup: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - return `${ONYXKEYS.COLLECTION.TRANSACTION_BACKUP}${transactionID}`; - }, - }, - policy: { - key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report?.policyID : '-1'}`, - }, - personalDetails: { - key: ONYXKEYS.PERSONAL_DETAILS_LIST, - }, - skipConfirmation: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - return `${ONYXKEYS.COLLECTION.SKIP_CONFIRMATION}${transactionID}`; - }, - }, -})(IOURequestStepDistance); +const IOURequestStepDistanceWithOnyx = IOURequestStepDistance; const IOURequestStepDistanceWithCurrentUserPersonalDetails = withCurrentUserPersonalDetails(IOURequestStepDistanceWithOnyx); // eslint-disable-next-line rulesdir/no-negated-variables diff --git a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx index 3cabcae9f79e..59e1591a23ff 100644 --- a/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx +++ b/src/pages/iou/request/step/IOURequestStepDistanceRate.tsx @@ -83,7 +83,7 @@ function IOURequestStepDistanceRate({ }; }); - const unit = (Object.values(rates)[0]?.unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer')) as Unit; + const unit = (Object.values(rates).at(0)?.unit === CONST.CUSTOM_UNITS.DISTANCE_UNIT_MILES ? translate('common.mile') : translate('common.kilometer')) as Unit; const initiallyFocusedOption = sections.find((item) => item.isSelected)?.keyForList; diff --git a/src/pages/iou/request/step/IOURequestStepParticipants.tsx b/src/pages/iou/request/step/IOURequestStepParticipants.tsx index 552ad4d54e39..e8f02f0c1975 100644 --- a/src/pages/iou/request/step/IOURequestStepParticipants.tsx +++ b/src/pages/iou/request/step/IOURequestStepParticipants.tsx @@ -49,7 +49,7 @@ function IOURequestStepParticipants({ const {canUseP2PDistanceRequests} = usePermissions(iouType); // We need to set selectedReportID if user has navigated back from confirmation page and navigates to confirmation page with already selected participant - const selectedReportID = useRef(participants?.length === 1 ? participants[0]?.reportID ?? reportID : reportID); + const selectedReportID = useRef(participants?.length === 1 ? participants.at(0)?.reportID ?? reportID : reportID); const numberOfParticipants = useRef(participants?.length ?? 0); const iouRequestType = TransactionUtils.getRequestType(transaction); const isSplitRequest = iouType === CONST.IOU.TYPE.SPLIT; @@ -75,6 +75,9 @@ function IOURequestStepParticipants({ return translate('iou.submitExpense'); }, [iouType, translate, isSplitRequest, action]); + const selfDMReportID = useMemo(() => ReportUtils.findSelfDMReportID(), []); + const shouldDisplayTrackExpenseButton = !!selfDMReportID && action === CONST.IOU.ACTION.CREATE; + const receiptFilename = transaction?.filename; const receiptPath = transaction?.receipt?.source; const receiptType = transaction?.receipt?.type; @@ -94,7 +97,7 @@ function IOURequestStepParticipants({ (val: Participant[]) => { HttpUtils.cancelPendingRequests(READ_COMMANDS.SEARCH_FOR_REPORTS); - const firstParticipantReportID = val[0]?.reportID ?? ''; + const firstParticipantReportID = val.at(0)?.reportID ?? ''; const rateID = DistanceRequestUtils.getCustomUnitRateID(firstParticipantReportID, !canUseP2PDistanceRequests); const isInvoice = iouType === CONST.IOU.TYPE.INVOICE && ReportUtils.isInvoiceRoomWithID(firstParticipantReportID); numberOfParticipants.current = val.length; @@ -132,7 +135,14 @@ function IOURequestStepParticipants({ return; } - const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, selectedReportID.current || reportID); + // If coming from the combined submit/track flow and the user proceeds to submit the expense + // we will use the submit IOU type in the confirmation flow. + const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute( + action, + iouType === CONST.IOU.TYPE.CREATE ? CONST.IOU.TYPE.SUBMIT : iouType, + transactionID, + selectedReportID.current || reportID, + ); if (isCategorizing) { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CATEGORY.getRoute(action, iouType, transactionID, selectedReportID.current || reportID, iouConfirmationPageRoute)); } else { @@ -144,6 +154,18 @@ function IOURequestStepParticipants({ IOUUtils.navigateToStartMoneyRequestStep(iouRequestType, iouType, transactionID, reportID, action); }, [iouRequestType, iouType, transactionID, reportID, action]); + const trackExpense = () => { + // If coming from the combined submit/track flow and the user proceeds to just track the expense, + // we will use the track IOU type in the confirmation flow. + if (!selfDMReportID) { + return; + } + + IOU.setMoneyRequestParticipantsFromReport(transactionID, ReportUtils.getReport(selfDMReportID)); + const iouConfirmationPageRoute = ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, CONST.IOU.TYPE.TRACK, transactionID, selfDMReportID); + Navigation.navigate(iouConfirmationPageRoute); + }; + useEffect(() => { const isCategorizing = action === CONST.IOU.ACTION.CATEGORIZE; const isShareAction = action === CONST.IOU.ACTION.SHARE; @@ -173,9 +195,11 @@ function IOURequestStepParticipants({ participants={isSplitRequest ? participants : []} onParticipantsAdded={addParticipant} onFinish={goToNextStep} + onTrackExpensePress={trackExpense} iouType={iouType} iouRequestType={iouRequestType} action={action} + shouldDisplayTrackExpenseButton={shouldDisplayTrackExpenseButton} /> ); diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx index 05ebdc1dfc62..a5b473e6f649 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.native.tsx @@ -63,6 +63,7 @@ function IOURequestStepScan({ physicalDevices: ['wide-angle-camera', 'ultra-wide-angle-camera'], }); + const isEditing = action === CONST.IOU.ACTION.EDIT; const hasFlash = !!device?.hasFlash; const camera = useRef(null); const [flash, setFlash] = useState(false); @@ -233,7 +234,9 @@ function IOURequestStepScan({ } // If the transaction was created from the global create, the person needs to select participants, so take them there. - if (transaction?.isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK && !report?.reportID) { + // If the user started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if ((transaction?.isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK && !report?.reportID) || iouType === CONST.IOU.TYPE.CREATE) { navigateToParticipantPage(); return; } @@ -269,6 +272,10 @@ function IOURequestStepScan({ } getCurrentPosition( (successData) => { + const participant = participants.at(0); + if (!participant) { + return; + } if (iouType === CONST.IOU.TYPE.TRACK && report) { IOU.trackExpense( report, @@ -278,7 +285,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, '', @@ -303,7 +310,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, '', @@ -322,6 +329,10 @@ function IOURequestStepScan({ } }, (errorData) => { + const participant = participants.at(0); + if (!participant) { + return; + } Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates if (iouType === CONST.IOU.TYPE.TRACK && report) { @@ -333,7 +344,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, ); @@ -346,7 +357,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, ); @@ -411,9 +422,9 @@ function IOURequestStepScan({ // Store the receipt on the transaction object in Onyx // On Android devices, fetching blob for a file with name containing spaces fails to retrieve the type of file. // So, let us also save the file type in receipt for later use during blob fetch - IOU.setMoneyRequestReceipt(transactionID, file?.uri ?? '', file.name ?? '', action !== CONST.IOU.ACTION.EDIT, file.type); + IOU.setMoneyRequestReceipt(transactionID, file?.uri ?? '', file.name ?? '', !isEditing, file.type); - if (action === CONST.IOU.ACTION.EDIT) { + if (isEditing) { updateScanAndNavigate(file, file?.uri ?? ''); return; } @@ -448,10 +459,10 @@ function IOURequestStepScan({ .then((photo: PhotoFile) => { // Store the receipt on the transaction object in Onyx const source = getPhotoSource(photo.path); - IOU.setMoneyRequestReceipt(transactionID, source, photo.path, action !== CONST.IOU.ACTION.EDIT); + IOU.setMoneyRequestReceipt(transactionID, source, photo.path, !isEditing); FileUtils.readFileAsync(source, photo.path, (file) => { - if (action === CONST.IOU.ACTION.EDIT) { + if (isEditing) { updateScanAndNavigate(file, source); return; } @@ -464,7 +475,7 @@ function IOURequestStepScan({ showCameraAlert(); Log.warn('Error taking photo', error); }); - }, [cameraPermissionStatus, didCapturePhoto, flash, hasFlash, user?.isMutedAllSounds, translate, transactionID, action, navigateToConfirmationStep, updateScanAndNavigate]); + }, [isEditing, cameraPermissionStatus, didCapturePhoto, flash, hasFlash, user?.isMutedAllSounds, translate, transactionID, navigateToConfirmationStep, updateScanAndNavigate]); // Wait for camera permission status to render if (cameraPermissionStatus == null) { @@ -476,7 +487,7 @@ function IOURequestStepScan({ includeSafeAreaPaddingBottom headerTitle={translate('common.receipt')} onBackButtonPress={navigateBack} - shouldShowWrapper={!!backTo} + shouldShowWrapper={!!backTo || isEditing} testID={IOURequestStepScan.displayName} > {isLoadingReceipt && } diff --git a/src/pages/iou/request/step/IOURequestStepScan/index.tsx b/src/pages/iou/request/step/IOURequestStepScan/index.tsx index 58f20b281937..6d9c2fd303c5 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/index.tsx +++ b/src/pages/iou/request/step/IOURequestStepScan/index.tsx @@ -86,6 +86,7 @@ function IOURequestStepScan({ const tabIndex = 1; const isTabActive = useTabNavigatorFocus({tabIndex}); + const isEditing = action === CONST.IOU.ACTION.EDIT; const defaultTaxCode = TransactionUtils.getDefaultTaxCode(policy, transaction); const transactionTaxCode = (transaction?.taxCode ? transaction?.taxCode : defaultTaxCode) ?? ''; const transactionTaxAmount = transaction?.taxAmount ?? 0; @@ -139,8 +140,8 @@ function IOURequestStepScan({ navigator.mediaDevices.enumerateDevices().then((devices) => { let lastBackDeviceId = ''; for (let i = devices.length - 1; i >= 0; i--) { - const device = devices[i]; - if (device.kind === 'videoinput') { + const device = devices.at(i); + if (device?.kind === 'videoinput') { lastBackDeviceId = device.deviceId; break; } @@ -265,7 +266,9 @@ function IOURequestStepScan({ } // If the transaction was created from the global create, the person needs to select participants, so take them there. - if (transaction?.isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK && !report?.reportID) { + // If the user started this flow using the Create expense option (combined submit/track flow), they should be redirected to the participants page. + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + if ((transaction?.isFromGlobalCreate && iouType !== CONST.IOU.TYPE.TRACK && !report?.reportID) || iouType === CONST.IOU.TYPE.CREATE) { navigateToParticipantPage(); return; } @@ -301,6 +304,10 @@ function IOURequestStepScan({ } getCurrentPosition( (successData) => { + const participant = participants.at(0); + if (!participant) { + return; + } if (iouType === CONST.IOU.TYPE.TRACK && report) { IOU.trackExpense( report, @@ -310,7 +317,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, '', @@ -335,7 +342,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, '', @@ -354,6 +361,10 @@ function IOURequestStepScan({ } }, (errorData) => { + const participant = participants.at(0); + if (!participant) { + return; + } Log.info('[IOURequestStepScan] getCurrentPosition failed', false, errorData); // When there is an error, the money can still be requested, it just won't include the GPS coordinates if (iouType === CONST.IOU.TYPE.TRACK && report) { @@ -365,7 +376,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, ); @@ -378,7 +389,7 @@ function IOURequestStepScan({ '', currentUserPersonalDetails.login, currentUserPersonalDetails.accountID, - participants[0], + participant, '', receipt, ); @@ -444,9 +455,9 @@ function IOURequestStepScan({ // Store the receipt on the transaction object in Onyx const source = URL.createObjectURL(file as Blob); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - IOU.setMoneyRequestReceipt(transactionID, source, file.name || '', action !== CONST.IOU.ACTION.EDIT); + IOU.setMoneyRequestReceipt(transactionID, source, file.name || '', !isEditing); - if (action === CONST.IOU.ACTION.EDIT) { + if (isEditing) { updateScanAndNavigate(file, source); return; } @@ -478,15 +489,15 @@ function IOURequestStepScan({ const filename = `receipt_${Date.now()}.png`; const file = FileUtils.base64ToFile(imageBase64 ?? '', filename); const source = URL.createObjectURL(file); - IOU.setMoneyRequestReceipt(transactionID, source, file.name, action !== CONST.IOU.ACTION.EDIT); + IOU.setMoneyRequestReceipt(transactionID, source, file.name, !isEditing); - if (action === CONST.IOU.ACTION.EDIT) { + if (isEditing) { updateScanAndNavigate(file, source); return; } navigateToConfirmationStep(file, source); - }, [action, transactionID, updateScanAndNavigate, navigateToConfirmationStep, requestCameraPermission]); + }, [isEditing, transactionID, updateScanAndNavigate, navigateToConfirmationStep, requestCameraPermission]); const clearTorchConstraints = useCallback(() => { if (!trackRef.current) { @@ -695,7 +706,7 @@ function IOURequestStepScan({ {(isDraggingOverWrapper) => ( diff --git a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx index 491c37c9a402..66736dc80b52 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -66,7 +66,7 @@ export default function , WithFullTransactionOrNotFoundOnyxProps>({ transaction: { key: ({route}) => { diff --git a/src/pages/settings/InitialSettingsPage.tsx b/src/pages/settings/InitialSettingsPage.tsx index 8dce84f8b470..48873a342a6f 100755 --- a/src/pages/settings/InitialSettingsPage.tsx +++ b/src/pages/settings/InitialSettingsPage.tsx @@ -131,20 +131,6 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr sectionStyle: styles.accountSettingsSectionContainer, sectionTranslationKey: 'initialSettingsPage.account', items: [ - { - translationKey: 'exitSurvey.goToExpensifyClassic', - icon: Expensicons.ExpensifyLogoNew, - ...(NativeModules.HybridAppModule - ? { - action: () => { - NativeModules.HybridAppModule.closeReactNativeApp(false, true); - setInitialURL(undefined); - }, - } - : { - routeName: ROUTES.SETTINGS_EXIT_SURVEY_REASON, - }), - }, { translationKey: 'common.profile', icon: Expensicons.Profile, @@ -174,7 +160,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }; return defaultMenu; - }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors, setInitialURL]); + }, [loginList, fundList, styles.accountSettingsSectionContainer, bankAccountList, userWallet?.errors, walletTerms?.errors]); /** * Retuns a list of menu items data for workspace section @@ -240,6 +226,20 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr shouldShowRightIcon: true, link: CONST.NEWHELP_URL, }, + { + translationKey: 'exitSurvey.goToExpensifyClassic', + icon: Expensicons.ExpensifyLogoNew, + ...(NativeModules.HybridAppModule + ? { + action: () => { + NativeModules.HybridAppModule.closeReactNativeApp(false, true); + setInitialURL(undefined); + }, + } + : { + routeName: ROUTES.SETTINGS_EXIT_SURVEY_REASON, + }), + }, { translationKey: 'initialSettingsPage.about', icon: Expensicons.Info, @@ -264,7 +264,7 @@ function InitialSettingsPage({currentUserPersonalDetails}: InitialSettingsPagePr }, ], }; - }, [styles.pt4, signOut]); + }, [styles.pt4, signOut, setInitialURL]); /** * Retuns JSX.Element with menu items diff --git a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx index 4ea878e82987..fe07dcc8c99b 100644 --- a/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx +++ b/src/pages/settings/Profile/Contacts/NewContactMethodPage.tsx @@ -2,8 +2,7 @@ import type {StackScreenProps} from '@react-navigation/stack'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import DotIndicatorMessage from '@components/DotIndicatorMessage'; import FormProvider from '@components/Form/FormProvider'; import InputWrapper from '@components/Form/InputWrapper'; @@ -20,30 +19,25 @@ import * as ErrorUtils from '@libs/ErrorUtils'; import * as LoginUtils from '@libs/LoginUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; +import * as UserUtils from '@libs/UserUtils'; import * as User from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/NewContactMethodForm'; -import type {LoginList} from '@src/types/onyx'; import type {Errors} from '@src/types/onyx/OnyxCommon'; -type NewContactMethodPageOnyxProps = { - /** Login list for the user that is signed in */ - loginList: OnyxEntry; -}; +type NewContactMethodPageProps = StackScreenProps; -type NewContactMethodPageProps = NewContactMethodPageOnyxProps & StackScreenProps; - -function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const contactMethod = account?.primaryLogin ?? ''; +function NewContactMethodPage({route}: NewContactMethodPageProps) { + const contactMethod = UserUtils.getContactMethod(); const styles = useThemeStyles(); const {translate} = useLocalize(); const loginInputRef = useRef(null); const [isValidateCodeActionModalVisible, setIsValidateCodeActionModalVisible] = useState(false); const [pendingContactAction] = useOnyx(ONYXKEYS.PENDING_CONTACT_ACTION); + const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST); const loginData = loginList?.[pendingContactAction?.contactMethod ?? contactMethod]; const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); @@ -161,6 +155,4 @@ function NewContactMethodPage({loginList, route}: NewContactMethodPageProps) { NewContactMethodPage.displayName = 'NewContactMethodPage'; -export default withOnyx({ - loginList: {key: ONYXKEYS.LOGIN_LIST}, -})(NewContactMethodPage); +export default NewContactMethodPage; diff --git a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx b/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx index 157588a67397..302017adcbe9 100644 --- a/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx +++ b/src/pages/settings/Profile/Contacts/ValidateContactActionPage.tsx @@ -8,13 +8,14 @@ import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as User from '@libs/actions/User'; import Navigation from '@libs/Navigation/Navigation'; +import * as UserUtils from '@libs/UserUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import ValidateCodeForm from './ValidateCodeForm'; import type {ValidateCodeFormHandle} from './ValidateCodeForm/BaseValidateCodeForm'; function ValidateContactActionPage() { - const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const contactMethod = UserUtils.getContactMethod(); const themeStyles = useThemeStyles(); const {translate} = useLocalize(); const validateCodeFormRef = useRef(null); @@ -45,7 +46,7 @@ function ValidateContactActionPage() { offlineIndicatorStyle={themeStyles.mtAuto} > @@ -53,14 +54,14 @@ function ValidateContactActionPage() { type="success" style={[themeStyles.mb3]} // eslint-disable-next-line @typescript-eslint/naming-convention - messages={{0: translate('contacts.enterMagicCode', {contactMethod: account?.primaryLogin ?? ''})}} + messages={{0: translate('contacts.enterMagicCode', {contactMethod})}} /> diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx index 26c2a9092131..c9858738906d 100644 --- a/src/pages/settings/Profile/CustomStatus/StatusPage.tsx +++ b/src/pages/settings/Profile/CustomStatus/StatusPage.tsx @@ -1,7 +1,6 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {InteractionManager, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown'; import FormProvider from '@components/Form/FormProvider'; @@ -15,9 +14,8 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextInput from '@components/TextInput'; -import type {WithCurrentUserPersonalDetailsProps} from '@components/withCurrentUserPersonalDetails'; -import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; @@ -30,21 +28,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import INPUT_IDS from '@src/types/form/SettingsStatusSetForm'; -import type {CustomStatusDraft} from '@src/types/onyx'; - -type StatusPageOnyxProps = { - draftStatus: OnyxEntry; -}; - -type StatusPageProps = StatusPageOnyxProps & WithCurrentUserPersonalDetailsProps; const initialEmoji = 'šŸ’¬'; -function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) { +function StatusPage() { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); + const [draftStatus] = useOnyx(ONYXKEYS.CUSTOM_STATUS_DRAFT); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const formRef = useRef(null); const [brickRoadIndicator, setBrickRoadIndicator] = useState>(); const currentUserEmojiCode = currentUserPersonalDetails?.status?.emojiCode ?? ''; @@ -97,6 +90,9 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack(), []); const updateStatus = useCallback( ({emojiCode, statusText}: FormOnyxValues) => { + if (navigateBackToPreviousScreenTask.current) { + return; + } // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const clearAfterTime = draftClearAfter || currentUserClearAfter || CONST.CUSTOM_STATUS_TYPES.NEVER; const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime}); @@ -118,6 +114,9 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) ); const clearStatus = () => { + if (navigateBackToPreviousScreenTask.current) { + return; + } User.clearCustomStatus(); User.updateDraftCustomStatus({ text: '', @@ -229,10 +228,4 @@ function StatusPage({draftStatus, currentUserPersonalDetails}: StatusPageProps) StatusPage.displayName = 'StatusPage'; -export default withCurrentUserPersonalDetails( - withOnyx({ - draftStatus: { - key: () => ONYXKEYS.CUSTOM_STATUS_DRAFT, - }, - })(StatusPage), -); +export default StatusPage; diff --git a/src/pages/settings/Profile/ProfilePage.tsx b/src/pages/settings/Profile/ProfilePage.tsx index 46f280abf191..f42be1385ecf 100755 --- a/src/pages/settings/Profile/ProfilePage.tsx +++ b/src/pages/settings/Profile/ProfilePage.tsx @@ -127,7 +127,7 @@ function ProfilePage() { childrenStyles={styles.pt5} titleStyles={styles.accountSettingsSectionTitle} > - + {isEmptyObject(currentUserPersonalDetails) || accountID === -1 || !avatarURL ? ( ) : ( diff --git a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx index c9816862ad35..5b01568d018e 100644 --- a/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx +++ b/src/pages/settings/Security/AddDelegate/ValidateCodeForm/BaseValidateCodeForm.tsx @@ -161,7 +161,7 @@ function BaseValidateCodeForm({autoComplete = 'one-time-code', innerRef = () => name="validateCode" value={validateCode} onChangeText={onTextInput} - errorText={formError?.validateCode ? translate(formError?.validateCode) : Object.values(validateLoginError ?? {})[0] ?? ''} + errorText={formError?.validateCode ? translate(formError?.validateCode) : Object.values(validateLoginError ?? {}).at(0) ?? ''} hasError={!isEmptyObject(validateLoginError)} onFulfill={validateAndSubmitForm} autoFocus={false} diff --git a/src/pages/settings/Security/SecuritySettingsPage.tsx b/src/pages/settings/Security/SecuritySettingsPage.tsx index b23ac04d4972..47061e1c1482 100644 --- a/src/pages/settings/Security/SecuritySettingsPage.tsx +++ b/src/pages/settings/Security/SecuritySettingsPage.tsx @@ -9,6 +9,7 @@ import LottieAnimations from '@components/LottieAnimations'; import MenuItem from '@components/MenuItem'; import type {MenuItemProps} from '@components/MenuItem'; import MenuItemList from '@components/MenuItemList'; +import {usePersonalDetails} from '@components/OnyxProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; @@ -36,6 +37,8 @@ function SecuritySettingsPage() { const waitForNavigate = useWaitForNavigation(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const {canUseNewDotCopilot} = usePermissions(); + const personalDetails = usePersonalDetails(); + const [account] = useOnyx(ONYXKEYS.ACCOUNT); const isActingAsDelegate = !!account?.delegatedAccess?.delegate ?? false; @@ -70,60 +73,71 @@ function SecuritySettingsPage() { })); }, [translate, waitForNavigate, styles]); - const delegateMenuItems: MenuItemProps[] = delegates - .filter((d) => !d.optimisticAccountID) - .map(({email, role, pendingAction, errorFields}) => { - const personalDetail = getPersonalDetailByEmail(email); - - const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate'); + const delegateMenuItems: MenuItemProps[] = useMemo( + () => + delegates + .filter((d) => !d.optimisticAccountID) + .map(({email, role, pendingAction, errorFields}) => { + const personalDetail = getPersonalDetailByEmail(email); + const error = ErrorUtils.getLatestErrorField({errorFields}, 'addDelegate'); - const onPress = () => { - if (isEmptyObject(pendingAction)) { - return; - } - if (!role) { - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(email)); - return; - } - Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(email, role)); - }; + const onPress = () => { + if (isEmptyObject(pendingAction)) { + return; + } + if (!role) { + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_ROLE.getRoute(email)); + return; + } + Navigation.navigate(ROUTES.SETTINGS_DELEGATE_MAGIC_CODE.getRoute(email, role)); + }; - const formattedEmail = formatPhoneNumber(email); - return { - title: personalDetail?.displayName ?? formattedEmail, - description: personalDetail?.displayName ? formattedEmail : '', - badgeText: translate('delegate.role', {role}), - avatarID: personalDetail?.accountID ?? -1, - icon: personalDetail?.avatar ?? FallbackAvatar, - iconType: CONST.ICON_TYPE_AVATAR, - numberOfLinesDescription: 1, - wrapperStyle: [styles.sectionMenuItemTopDescription], - iconRight: Expensicons.ThreeDots, - shouldShowRightIcon: true, - pendingAction, - shouldForceOpacity: !!pendingAction, - onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'), - error, - onPress, - }; - }); + const formattedEmail = formatPhoneNumber(email); + return { + title: personalDetail?.displayName ?? formattedEmail, + description: personalDetail?.displayName ? formattedEmail : '', + badgeText: translate('delegate.role', {role}), + avatarID: personalDetail?.accountID ?? -1, + icon: personalDetail?.avatar ?? FallbackAvatar, + iconType: CONST.ICON_TYPE_AVATAR, + numberOfLinesDescription: 1, + wrapperStyle: [styles.sectionMenuItemTopDescription], + iconRight: Expensicons.ThreeDots, + shouldShowRightIcon: true, + pendingAction, + shouldForceOpacity: !!pendingAction, + onPendingActionDismiss: () => clearAddDelegateErrors(email, 'addDelegate'), + error, + onPress, + }; + }), + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [delegates, translate, styles, personalDetails], + ); - const delegatorMenuItems: MenuItemProps[] = delegators.map(({email, role}) => { - const personalDetail = getPersonalDetailByEmail(email); - const formattedEmail = formatPhoneNumber(email); + const delegatorMenuItems: MenuItemProps[] = useMemo( + () => + delegators.map(({email, role}) => { + const personalDetail = getPersonalDetailByEmail(email); + const formattedEmail = formatPhoneNumber(email); - return { - title: personalDetail?.displayName ?? formattedEmail, - description: personalDetail?.displayName ? formattedEmail : '', - badgeText: translate('delegate.role', {role}), - avatarID: personalDetail?.accountID ?? -1, - icon: personalDetail?.avatar ?? FallbackAvatar, - iconType: CONST.ICON_TYPE_AVATAR, - numberOfLinesDescription: 1, - wrapperStyle: [styles.sectionMenuItemTopDescription], - interactive: false, - }; - }); + return { + title: personalDetail?.displayName ?? formattedEmail, + description: personalDetail?.displayName ? formattedEmail : '', + badgeText: translate('delegate.role', {role}), + avatarID: personalDetail?.accountID ?? -1, + icon: personalDetail?.avatar ?? FallbackAvatar, + iconType: CONST.ICON_TYPE_AVATAR, + numberOfLinesDescription: 1, + wrapperStyle: [styles.sectionMenuItemTopDescription], + interactive: false, + }; + }), + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + [delegators, styles, translate, personalDetails], + ); return ( (null); const {setStep} = useTwoFactorAuthContext(); @@ -65,7 +63,7 @@ function VerifyStep({account}: VerifyStepProps) { * so it can be detected by authenticator apps */ function buildAuthenticatorUrl() { - return `otpauth://totp/Expensify:${account?.primaryLogin ?? session?.email}?secret=${account?.twoFactorAuthSecretKey}&issuer=Expensify`; + return `otpauth://totp/Expensify:${contactMethod}?secret=${account?.twoFactorAuthSecretKey}&issuer=Expensify`; } return ( @@ -138,6 +136,4 @@ function VerifyStep({account}: VerifyStepProps) { VerifyStep.displayName = 'VerifyStep'; -export default withOnyx({ - account: {key: ONYXKEYS.ACCOUNT}, -})(VerifyStep); +export default VerifyStep; diff --git a/src/pages/settings/Subscription/CardAuthenticationModal/index.tsx b/src/pages/settings/Subscription/CardAuthenticationModal/index.tsx index 6f49e9bd0508..b323c668b0b5 100644 --- a/src/pages/settings/Subscription/CardAuthenticationModal/index.tsx +++ b/src/pages/settings/Subscription/CardAuthenticationModal/index.tsx @@ -7,14 +7,17 @@ import Modal from '@components/Modal'; import ScreenWrapper from '@components/ScreenWrapper'; import useThemeStyles from '@hooks/useThemeStyles'; import * as PaymentMethods from '@userActions/PaymentMethods'; +import * as PolicyActions from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; type CardAuthenticationModalProps = { /** Title shown in the header of the modal */ headerTitle?: string; + + policyID?: string; }; -function CardAuthenticationModal({headerTitle}: CardAuthenticationModalProps) { +function CardAuthenticationModal({headerTitle, policyID}: CardAuthenticationModalProps) { const styles = useThemeStyles(); const [authenticationLink] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -37,11 +40,15 @@ function CardAuthenticationModal({headerTitle}: CardAuthenticationModalProps) { (event: MessageEvent) => { const message = event.data; if (message === CONST.GBP_AUTHENTICATION_COMPLETE) { - PaymentMethods.verifySetupIntent(session?.accountID ?? -1, true); + if (policyID) { + PolicyActions.verifySetupIntentAndRequestPolicyOwnerChange(policyID); + } else { + PaymentMethods.verifySetupIntent(session?.accountID ?? -1, true); + } onModalClose(); } }, - [onModalClose, session?.accountID], + [onModalClose, policyID, session?.accountID], ); useEffect(() => { diff --git a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx index c7c2ca956ae1..69293fe894d4 100644 --- a/src/pages/settings/Troubleshoot/TroubleshootPage.tsx +++ b/src/pages/settings/Troubleshoot/TroubleshootPage.tsx @@ -1,12 +1,14 @@ import React, {useCallback, useMemo, useState} from 'react'; import {View} from 'react-native'; -import Onyx, {useOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {SvgProps} from 'react-native-svg'; import ClientSideLoggingToolMenu from '@components/ClientSideLoggingToolMenu'; import ConfirmModal from '@components/ConfirmModal'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import * as Expensicons from '@components/Icon/Expensicons'; import * as Illustrations from '@components/Icon/Illustrations'; +import ImportOnyxState from '@components/ImportOnyxState'; import LottieAnimations from '@components/LottieAnimations'; import MenuItemList from '@components/MenuItemList'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -23,10 +25,9 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {setShouldMaskOnyxState} from '@libs/actions/MaskOnyx'; -import * as PersistedRequests from '@libs/actions/PersistedRequests'; import ExportOnyxState from '@libs/ExportOnyxState'; import Navigation from '@libs/Navigation/Navigation'; -import * as App from '@userActions/App'; +import {clearOnyxAndResetApp} from '@userActions/App'; import * as Report from '@userActions/Report'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -47,7 +48,7 @@ function TroubleshootPage() { const waitForNavigate = useWaitForNavigation(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const illustrationStyle = getLightbulbIllustrationStyle(); - + const [isLoading, setIsLoading] = useState(false); const [shouldStoreLogs] = useOnyx(ONYXKEYS.SHOULD_STORE_LOGS); const [shouldMaskOnyxState = true] = useOnyx(ONYXKEYS.SHOULD_MASK_ONYX_STATE); @@ -106,6 +107,7 @@ function TroubleshootPage() { onBackButtonPress={() => Navigation.goBack(ROUTES.SETTINGS)} icon={Illustrations.Lightbulb} /> + {isLoading && }
+ { setIsConfirmationModalVisible(false); - // Requests in a sequential queue should be called even if the Onyx state is reset, so we do not lose any pending data. - // However, the OpenApp request must be called before any other request in a queue to ensure data consistency. - // To do that, sequential queue is cleared together with other keys, and then it's restored once the OpenApp request is resolved. - const sequentialQueue = PersistedRequests.getAll(); - Onyx.clear(App.KEYS_TO_PRESERVE).then(() => { - App.openApp().then(() => { - if (!sequentialQueue) { - return; - } - - sequentialQueue.forEach((request) => { - PersistedRequests.save(request); - }); - }); - }); + clearOnyxAndResetApp(); }} onCancel={() => setIsConfirmationModalVisible(false)} prompt={translate('initialSettingsPage.troubleshoot.confirmResetDescription')} diff --git a/src/pages/settings/Wallet/ExpensifyCardPage.tsx b/src/pages/settings/Wallet/ExpensifyCardPage.tsx index df6b2b0c642c..1146f876860e 100644 --- a/src/pages/settings/Wallet/ExpensifyCardPage.tsx +++ b/src/pages/settings/Wallet/ExpensifyCardPage.tsx @@ -146,8 +146,8 @@ function ExpensifyCardPage({ const hasDetectedDomainFraud = cardsToShow?.some((card) => card?.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN); const hasDetectedIndividualFraud = cardsToShow?.some((card) => card?.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL); - const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(cardsToShow?.[0]?.availableSpend); - const {limitNameKey, limitTitleKey} = getLimitTypeTranslationKeys(cardsToShow?.[0]?.nameValuePairs?.limitType); + const formattedAvailableSpendAmount = CurrencyUtils.convertToDisplayString(cardsToShow?.at(0)?.availableSpend); + const {limitNameKey, limitTitleKey} = getLimitTypeTranslationKeys(cardsToShow?.at(0)?.nameValuePairs?.limitType); const goToGetPhysicalCardFlow = () => { let updatedDraftValues = draftValues; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index b28b88e1ba83..46f6ded27cbd 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -3,8 +3,7 @@ import type {ReactElement, Ref} from 'react'; import React, {useCallback, useMemo} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {FlatList, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; -import {useOnyx, withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import type {SvgProps} from 'react-native-svg/lib/typescript/ReactNativeSVG'; import type {ValueOf} from 'type-fest'; import type {RenderSuggestionMenuItemProps} from '@components/AutoCompleteSuggestions/types'; @@ -18,6 +17,7 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import type {FormattedSelectedPaymentMethodIcon} from '@hooks/usePaymentMethodState/types'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import * as CardUtils from '@libs/CardUtils'; @@ -30,29 +30,14 @@ import * as PaymentMethods from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {AccountData, BankAccountList, CardList} from '@src/types/onyx'; +import type {AccountData} from '@src/types/onyx'; import type {BankIcon} from '@src/types/onyx/Bank'; import type {Errors} from '@src/types/onyx/OnyxCommon'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import type {FilterMethodPaymentType} from '@src/types/onyx/WalletTransfer'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {FormattedSelectedPaymentMethodIcon} from './WalletPage/types'; -type PaymentMethodListOnyxProps = { - /** List of bank accounts */ - bankAccountList: OnyxEntry; - - /** List of assigned cards */ - cardList: OnyxEntry; - - /** List of user's cards */ - // fundList: OnyxEntry; - - /** Are we loading payment methods? */ - isLoadingPaymentMethods: OnyxEntry; -}; - -type PaymentMethodListProps = PaymentMethodListOnyxProps & { +type PaymentMethodListProps = { /** Type of active/highlighted payment method */ actionPaymentMethodType?: string; @@ -92,6 +77,9 @@ type PaymentMethodListProps = PaymentMethodListOnyxProps & { /** Whether the add Payment button be shown on the list */ shouldShowAddPaymentMethodButton?: boolean; + /** Whether the add Bank account button be shown on the list */ + shouldShowAddBankAccountButton?: boolean; + /** Whether the assigned cards should be shown on the list */ shouldShowAssignedCards?: boolean; @@ -110,6 +98,9 @@ type PaymentMethodListProps = PaymentMethodListOnyxProps & { isDefault?: boolean, methodID?: number, ) => void; + + /** The policy invoice's transfer bank accountID */ + invoiceTransferBankAccountID?: number; }; type PaymentMethodItem = PaymentMethod & { @@ -173,17 +164,13 @@ function keyExtractor(item: PaymentMethod) { function PaymentMethodList({ actionPaymentMethodType = '', activePaymentMethodID = '', - bankAccountList = {}, buttonRef = () => {}, - cardList = {}, - // Temporarily disabled because P2P debit cards are disabled. - // fundList = {}, filterType = '', listHeaderComponent, - isLoadingPaymentMethods = true, onPress, shouldShowSelectedState = false, shouldShowAddPaymentMethodButton = true, + shouldShowAddBankAccountButton = false, shouldShowAddBankAccount = true, shouldShowEmptyListMessage = true, shouldShowAssignedCards = false, @@ -193,12 +180,19 @@ function PaymentMethodList({ style = {}, listItemStyle = {}, shouldShowRightIcon = true, + invoiceTransferBankAccountID, }: PaymentMethodListProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); const {translate} = useLocalize(); const {isOffline} = useNetwork(); + const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); + const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [cardList = {}] = useOnyx(ONYXKEYS.CARD_LIST); + // Temporarily disabled because P2P debit cards are disabled. + // const [fundList = {}] = useOnyx(ONYXKEYS.FUND_LIST); + const [isLoadingPaymentMethods = true] = useOnyx(ONYXKEYS.IS_LOADING_PAYMENT_METHODS); const getDescriptionForPolicyDomainCard = (domainName: string): string => { // A domain name containing a policyID indicates that this is a workspace feed @@ -244,9 +238,12 @@ function PaymentMethodList({ // The card should be grouped to a specific domain and such domain already exists in a assignedCardsGrouped if (assignedCardsGrouped.some((item) => item.isGroupedCardDomain && item.description === card.domainName) && !isAdminIssuedVirtualCard) { const domainGroupIndex = assignedCardsGrouped.findIndex((item) => item.isGroupedCardDomain && item.description === card.domainName); - assignedCardsGrouped[domainGroupIndex].errors = {...assignedCardsGrouped[domainGroupIndex].errors, ...card.errors}; - if (card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL) { - assignedCardsGrouped[domainGroupIndex].brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + const assignedCardsGroupedItem = assignedCardsGrouped.at(domainGroupIndex); + if (domainGroupIndex >= 0 && assignedCardsGroupedItem) { + assignedCardsGroupedItem.errors = {...assignedCardsGrouped.at(domainGroupIndex)?.errors, ...card.errors}; + if (card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL) { + assignedCardsGroupedItem.brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + } } return; } @@ -323,19 +320,37 @@ function PaymentMethodList({ */ const renderListEmptyComponent = () => {translate('paymentMethodList.addFirstPaymentMethod')}; + const onPressItem = useCallback(() => { + if (!isUserValidated) { + Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT.getRoute(ROUTES.SETTINGS_ADD_BANK_ACCOUNT)); + return; + } + onPress(); + }, [isUserValidated, onPress]); + const renderListFooterComponent = useCallback( - () => ( - - ), + () => + shouldShowAddBankAccountButton ? ( +