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..69bbfa380234 --- /dev/null +++ b/.github/workflows/buildAndroid.yml @@ -0,0 +1,179 @@ +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/buildIOS.yml b/.github/workflows/buildIOS.yml new file mode 100644 index 000000000000..2386d01da793 --- /dev/null +++ b/.github/workflows/buildIOS.yml @@ -0,0 +1,155 @@ +name: Build iOS app + +on: + workflow_call: + inputs: + type: + description: 'What type of build to run. Must be one of ["release", "adhoc"]' + type: string + required: true + ref: + description: Git ref to checkout and build + type: string + required: true + pull_request_number: + description: The pull request number associated with this build, if relevant. + type: string + required: false + outputs: + IPA_FILE_NAME: + value: ${{ jobs.build.outputs.IPA_FILE_NAME }} + DSYM_FILE_NAME: + value: ${{ jobs.build.outputs.DSYM_FILE_NAME }} + + workflow_dispatch: + inputs: + type: + description: What type of build do you want to run? + required: true + type: choice + options: + - release + - adhoc + 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 iOS app + runs-on: macos-13-xlarge + env: + DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer + outputs: + IPA_FILE_NAME: ${{ steps.build.outputs.IPA_FILE_NAME }} + DSYM_FILE_NAME: ${{ steps.build.outputs.DSYM_FILE_NAME }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + + - name: Create .env.adhoc file based on staging and add PULL_REQUEST_NUMBER env to it + if: ${{ inputs.type == 'adhoc' }} + run: | + cp .env.staging .env.adhoc + sed -i '' 's/ENVIRONMENT=staging/ENVIRONMENT=adhoc/' .env.adhoc + echo "PULL_REQUEST_NUMBER=${{ inputs.pull_request_number }}" >> .env.adhoc + + - name: Configure MapBox SDK + run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} + + - name: Setup Node + id: setup-node + uses: ./.github/actions/composite/setupNode + + - name: Setup Ruby + uses: ruby/setup-ruby@v1.190.0 + with: + bundler-cache: true + + - name: Cache Pod dependencies + uses: actions/cache@v4 + id: pods-cache + with: + path: ios/Pods + key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} + + - name: Compare Podfile.lock and Manifest.lock + id: compare-podfile-and-manifest + run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" + + - name: Install cocoapods + uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 + if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + with: + timeout_minutes: 10 + max_attempts: 5 + command: scripts/pod-install.sh + + - name: Decrypt provisioning profiles + run: | + cd ios + provisioningProfile='' + if [ '${{ inputs.type }}' == 'release' ]; then + provisioningProfile='NewApp_AppStore' + else + provisioningProfile='NewApp_AdHoc' + fi + echo "Using provisioning profile: $provisioningProfile" + gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output "$provisioningProfile.mobileprovision" "$provisioningProfile.mobileprovision.gpg" + gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output "${provisioningProfile}_Notification_Service.mobileprovision" "${provisioningProfile}_Notification_Service.mobileprovision.gpg" + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Decrypt code signing certificate + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Build iOS ${{ inputs.type }} app + id: build + run: | + lane='' + if [ '${{ inputs.type }}' == 'release' ]; then + lane='build' + else + lane='build_adhoc' + fi + + bundle exec fastlane ios "$lane" + + # Reload environment variables from GITHUB_ENV + # shellcheck disable=SC1090 + source "$GITHUB_ENV" + + { + # ipaPath and dsymPath are environment variables set within the Fastfile + echo "IPA_PATH=$ipaPath" + echo "IPA_FILE_NAME=$(basename "$ipaPath")" + echo "DSYM_PATH=$dsymPath" + echo "DSYM_FILE_NAME=$(basename "$dsymPath")" + } >> "$GITHUB_OUTPUT" + + - name: Upload iOS build artifact + uses: actions/upload-artifact@v4 + with: + name: ios-artifact-ipa + path: ${{ steps.build.outputs.IPA_PATH }} + + - name: Upload iOS debug symbols artifact + uses: actions/upload-artifact@v4 + with: + name: ios-artifact-dsym + path: ${{ steps.build.outputs.DSYM_PATH }} + + - name: Upload iOS sourcemaps + uses: actions/upload-artifact@v4 + with: + name: ios-artifact-sourcemaps + path: ./main.jsbundle.map diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 99cd0c1dabc5..2053ce44aadb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -67,84 +67,82 @@ 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: 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 @@ -205,115 +203,90 @@ jobs: name: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) && 'desktop-build-artifact' || 'desktop-staging-build-artifact' }} path: ./desktop-build/NewExpensify.dmg - iOS: - name: Build and deploy iOS + buildIOS: + name: Build iOS app + uses: ./.github/workflows/buildIOS.yml + if: ${{ github.ref == 'refs/heads/staging' }} needs: prep - env: - DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer - runs-on: macos-13-xlarge + secrets: inherit + with: + type: release + ref: staging + + uploadIOS: + name: Upload iOS App to TestFlight + needs: buildIOS + runs-on: ubuntu-latest 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 - id: setup-node - uses: ./.github/actions/composite/setupNode - - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - - name: Cache Pod dependencies - uses: actions/cache@v4 - id: pods-cache - with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} - - - name: Compare Podfile.lock and Manifest.lock - id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' - with: - timeout_minutes: 10 - max_attempts: 5 - command: scripts/pod-install.sh - - - name: Decrypt AppStore profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore.mobileprovision NewApp_AppStore.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt AppStore Notification Service profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AppStore_Notification_Service.mobileprovision NewApp_AppStore_Notification_Service.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt certificate - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - name: Decrypt App Store Connect API key run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg 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: Download iOS build artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/artifacts + pattern: ios-artifact-* + merge-multiple: true - - name: Get iOS native version - id: getIOSVersion - run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT" + - name: Log downloaded artifact paths + run: ls -R /tmp/artifacts - - name: Build iOS release app - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios build - - - name: Upload release build to TestFlight - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + - name: Upload iOS app to TestFlight run: bundle exec fastlane ios upload_testflight env: APPLE_CONTACT_EMAIL: ${{ secrets.APPLE_CONTACT_EMAIL }} APPLE_CONTACT_PHONE: ${{ secrets.APPLE_CONTACT_PHONE }} APPLE_DEMO_EMAIL: ${{ secrets.APPLE_DEMO_EMAIL }} APPLE_DEMO_PASSWORD: ${{ secrets.APPLE_DEMO_PASSWORD }} - - - name: Submit build for App Store review - if: ${{ fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - run: bundle exec fastlane ios submit_for_review - env: - VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} + ipaPath: /tmp/artifacts/${{ needs.buildIOS.outputs.IPA_FILE_NAME }} + dsymPath: /tmp/artifacts/${{ needs.buildIOS.outputs.DSYM_FILE_NAME }} - name: Upload iOS 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=@/Users/runner/work/App/App/New Expensify.ipa" + run: curl -u "$BROWSERSTACK" -X POST "https://api-cloud.browserstack.com/app-live/upload" -F "file=@/tmp/artifacts/${{ needs.buildIOS.outputs.IPA_FILE_NAME }}" env: BROWSERSTACK: ${{ secrets.BROWSERSTACK }} - - name: Upload iOS sourcemaps artifact - if: ${{ !fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} - uses: actions/upload-artifact@v4 - with: - name: ios-sourcemaps-artifact - path: ./main.jsbundle.map + submitIOS: + name: Submit iOS app for Apple review + needs: prep + if: ${{ github.ref == 'refs/heads/production' }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 - - name: Upload iOS 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: ios-build-artifact - path: /Users/runner/work/App/App/New\ Expensify.ipa + bundler-cache: true + + - name: Decrypt App Store Connect API key + run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output ios-fastlane-json-key.json ios-fastlane-json-key.json.gpg + env: + LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} + + - name: Get iOS native version + id: getIOSVersion + run: echo "IOS_VERSION=$(echo '${{ needs.prep.outputs.APP_VERSION }}' | tr '-' '.')" >> "$GITHUB_OUTPUT" + + - name: Submit build for App Store review + run: bundle exec fastlane ios submit_for_review + env: + VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }} - name: Warn deployers if iOS production deploy failed - if: ${{ failure() && fromJSON(env.SHOULD_DEPLOY_PRODUCTION) }} + if: ${{ failure() }} uses: 8398a7/action-slack@v3 with: status: custom @@ -381,9 +354,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 +389,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, buildIOS, uploadIOS, submitIOS, web] steps: - name: Checkout uses: actions/checkout@v4 @@ -444,40 +414,61 @@ 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, buildIOS, uploadIOS, submitIOS, 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" ] || \ - [ "${{ needs.desktop.result }}" == "success" ] || \ - [ "${{ needs.web.result }}" == "success" ]; then + if [ ${{ github.ref }} == 'refs/heads/production' ]; then + if [ '${{ needs.submitAndroid.result }}' == 'success' ] || \ + [ '${{ needs.submitIOS.result }}' == 'success' ]; then + isAtLeastOnePlatformDeployed="true" + fi + else + if [ '${{ needs.uploadAndroid.result }}' == 'success' ] || \ + [ '${{ needs.uploadIOS.result }}' == 'success' ]; then + isAtLeastOnePlatformDeployed="true" + fi + fi + + if [ '${{ needs.desktop.result }}' == 'success' ] || \ + [ '${{ needs.web.result }}' == 'success' ]; then isAtLeastOnePlatformDeployed="true" fi echo "IS_AT_LEAST_ONE_PLATFORM_DEPLOYED=$isAtLeastOnePlatformDeployed" >> "$GITHUB_OUTPUT" 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" ] && \ - [ "${{ needs.desktop.result }}" == "success" ] && \ - [ "${{ needs.web.result }}" == "success" ]; then + isAllPlatformsDeployed='false' + if [ '${{ needs.desktop.result }}' == 'success' ] && \ + [ '${{ needs.web.result }}' == 'success' ]; then isAllPlatformsDeployed="true" fi + + if [ ${{ github.ref }} == 'refs/heads/production' ]; then + if [ '${{ needs.submitAndroid.result }}' != 'success' ] || \ + [ '${{ needs.submitIOS.result }}' != 'success' ]; then + isAllPlatformsDeployed="false" + fi + else + if [ '${{ needs.uploadAndroid.result }}' != 'success' ] || \ + [ '${{ needs.uploadIOS.result }}' != 'success' ]; then + isAllPlatformsDeployed="false" + fi + fi + echo "IS_ALL_PLATFORMS_DEPLOYED=$isAllPlatformsDeployed" >> "$GITHUB_OUTPUT" echo "IS_ALL_PLATFORMS_DEPLOYED is $isAllPlatformsDeployed" createPrerelease: runs-on: ubuntu-latest if: ${{ always() && github.ref == 'refs/heads/staging' && fromJSON(needs.checkDeploymentSuccess.outputs.IS_AT_LEAST_ONE_PLATFORM_DEPLOYED) }} - needs: [prep, checkDeploymentSuccess] + needs: [prep, checkDeploymentSuccess, buildAndroid, buildIOS] steps: - name: Download all workflow run artifacts uses: actions/download-artifact@v4 @@ -504,12 +495,12 @@ jobs: continue-on-error: true run: | gh release upload ${{ needs.prep.outputs.APP_VERSION }} --repo ${{ github.repository }} --clobber \ - ./android-sourcemaps-artifact/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ - ./android-build-artifact/app-production-release.aab \ + ./android-artifact-sourcemaps/index.android.bundle.map#android-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./android-artifact-aab/${{ needs.buildAndroid.outputs.AAB_FILE_NAME }} \ ./desktop-staging-sourcemaps-artifact/desktop-staging-merged-source-map.js.map#desktop-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./desktop-staging-build-artifact/NewExpensify.dmg#NewExpensifyStaging.dmg \ - ./ios-sourcemaps-artifact/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ - ./ios-build-artifact/New\ Expensify.ipa \ + ./ios-artifact-sourcemaps/main.jsbundle.map#ios-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ + ./ios-artifact-ipa/${{ needs.buildIOS.outputs.IPA_FILE_NAME }} \ ./web-staging-sourcemaps-artifact/web-staging-merged-source-map.js.map#web-staging-sourcemap-${{ needs.prep.outputs.APP_VERSION }} \ ./web-staging-build-tar-gz-artifact/webBuild.tar.gz#stagingWebBuild.tar.gz \ ./web-staging-build-zip-artifact/webBuild.zip#stagingWebBuild.zip @@ -590,7 +581,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, buildIOS, uploadIOS, submitIOS, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] steps: - name: 'Announces the deploy in the #announce Slack room' uses: 8398a7/action-slack@v3 @@ -644,11 +635,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, buildIOS, uploadIOS, submitIOS, desktop, web, checkDeploymentSuccess, createPrerelease, finalizeRelease] with: version: ${{ needs.prep.outputs.APP_VERSION }} env: ${{ github.ref == 'refs/heads/production' && 'production' || 'staging' }} - android: ${{ needs.android.result }} - ios: ${{ needs.iOS.result }} + android: ${{ github.ref == 'refs/heads/production' && needs.submitAndroid.result || needs.uploadAndroid.result }} + ios: ${{ github.ref == 'refs/heads/production' && needs.submitIOS.result || needs.uploadIOS.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..ac20a8d09141 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,96 +105,56 @@ 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 - needs: [validateActor, getBranchRef] + buildIOS: + name: Build iOS app for testing + uses: ./.github/workflows/buildIOS.yml if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} - env: - DEVELOPER_DIR: /Applications/Xcode_15.2.0.app/Contents/Developer - runs-on: macos-13-xlarge + 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 }} + + uploadIOS: + name: Upload IOS app to S3 + needs: buildIOS + runs-on: ubuntu-latest + outputs: + S3_IPA_PATH: ${{ steps.exportS3Path.outputs.S3_IPA_PATH }} steps: - name: Checkout uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} - - - name: Configure MapBox SDK - run: ./scripts/setup-mapbox-sdk.sh ${{ secrets.MAPBOX_SDK_DOWNLOAD_TOKEN }} - - - 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 - id: setup-node - uses: ./.github/actions/composite/setupNode - - - name: Setup XCode - run: sudo xcode-select -switch /Applications/Xcode_15.2.0.app - name: Setup Ruby uses: ruby/setup-ruby@v1.190.0 with: bundler-cache: true - - name: Cache Pod dependencies - uses: actions/cache@v4 - id: pods-cache - with: - path: ios/Pods - key: ${{ runner.os }}-pods-cache-${{ hashFiles('ios/Podfile.lock', 'firebase.json') }} - - - name: Compare Podfile.lock and Manifest.lock - id: compare-podfile-and-manifest - run: echo "IS_PODFILE_SAME_AS_MANIFEST=${{ hashFiles('ios/Podfile.lock') == hashFiles('ios/Pods/Manifest.lock') }}" >> "$GITHUB_OUTPUT" - - - name: Install cocoapods - uses: nick-fields/retry@3f757583fb1b1f940bc8ef4bf4734c8dc02a5847 - if: steps.pods-cache.outputs.cache-hit != 'true' || steps.compare-podfile-and-manifest.outputs.IS_PODFILE_SAME_AS_MANIFEST != 'true' || steps.setup-node.outputs.cache-hit != 'true' + - name: Download IOS build artifacts + uses: actions/download-artifact@v4 with: - timeout_minutes: 10 - max_attempts: 5 - command: scripts/pod-install.sh + path: /tmp/artifacts + pattern: ios-artifact-* + merge-multiple: true - - name: Decrypt AdHoc profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AdHoc.mobileprovision NewApp_AdHoc.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt AdHoc Notification Service profile - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output NewApp_AdHoc_Notification_Service.mobileprovision NewApp_AdHoc_Notification_Service.mobileprovision.gpg - env: - LARGE_SECRET_PASSPHRASE: ${{ secrets.LARGE_SECRET_PASSPHRASE }} - - - name: Decrypt certificate - run: cd ios && gpg --quiet --batch --yes --decrypt --passphrase="$LARGE_SECRET_PASSPHRASE" --output Certificates.p12 Certificates.p12.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 @@ -208,22 +163,20 @@ jobs: aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: us-east-1 - - name: Build AdHoc app - run: bundle exec fastlane ios build_adhoc - - name: Upload AdHoc build to S3 run: bundle exec fastlane ios upload_s3 env: + ipaPath: /tmp/artifacts/${{ needs.buildIOS.outputs.IPA_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: ios - path: ./ios_paths.json + - name: Export S3 paths + id: exportS3Path + run: | + # $s3IpaPath is set from within the Fastfile, ios upload_s3 lane + echo "S3_IPA_PATH=$s3IpaPath" >> "$GITHUB_OUTPUT" desktop: name: Build and deploy Desktop for testing @@ -304,52 +257,27 @@ jobs: postGithubComment: runs-on: ubuntu-latest name: Post a GitHub comment with app download links for testing - needs: [validateActor, getBranchRef, android, iOS, desktop, web] - if: ${{ always() }} + needs: [validateActor, getBranchRef, uploadAndroid, uploadIOS, desktop, web] + if: ${{ always() && fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} steps: - name: Checkout uses: actions/checkout@v4 - if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} with: ref: ${{ github.event.pull_request.head.sha || needs.getBranchRef.outputs.REF }} - name: Download Artifact 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' }} - run: | - content_ios="$(cat ./ios/ios_paths.json)" - content_ios="${content_ios//'%'/'%25'}" - content_ios="${content_ios//$'\n'/'%0A'}" - content_ios="${content_ios//$'\r'/'%0D'}" - ios_path=$(echo "$content_ios" | jq -r '.html_path') - echo "ios_path=$ios_path" >> "$GITHUB_OUTPUT" - name: Publish links to apps for download - if: ${{ fromJSON(needs.validateActor.outputs.READY_TO_BUILD) }} uses: ./.github/actions/javascript/postTestBuildComment 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 }} + IOS: ${{ needs.uploadIOS.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: ${{ needs.uploadIOS.outputs.S3_IPA_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 3a620c00fd95..870f9d81e108 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 1009004103 - versionName "9.0.41-3" + versionCode 1009004408 + versionName "9.0.44-8" // 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" 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/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/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..1a7499a2a2c3 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,14 +15,15 @@ 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_S3_IPA_PATH = "s3IpaPath" 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 +34,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 +96,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 +133,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" @@ -219,7 +216,7 @@ platform :ios do build_app( workspace: "./ios/NewExpensify.xcworkspace", scheme: "New Expensify", - output_name: "New Expensify.ipa", + output_name: "NewExpensify.ipa", export_options: { provisioningProfiles: { "com.chat.expensify.chat" => "(NewApp) AppStore", @@ -260,6 +257,7 @@ platform :ios do workspace: "./ios/NewExpensify.xcworkspace", skip_profile_detection: true, scheme: "New Expensify AdHoc", + output_name: "NewExpensify_AdHoc.ipa", export_method: "ad-hoc", export_options: { method: "ad-hoc", @@ -284,12 +282,16 @@ platform :ios do ipa: ENV[KEY_IPA_PATH], app_directory: "ios/#{ENV['PULL_REQUEST_NUMBER']}", ) - sh("echo '{\"ipa_path\": \"#{lane_context[SharedValues::S3_IPA_OUTPUT_PATH]}\",\"html_path\": \"#{lane_context[SharedValues::S3_HTML_OUTPUT_PATH]}\"}' > ../ios_paths.json") + puts "Saving S3 outputs in env..." + exportEnvVars({ + KEY_S3_IPA_PATH => lane_context[SharedValues::S3_HTML_OUTPUT_PATH], + }) end desc "Upload app to TestFlight" lane :upload_testflight do upload_to_testflight( + ipa: ENV[KEY_IPA_PATH], api_key_path: "./ios/ios-fastlane-json-key.json", distribute_external: true, notify_external_testers: true, 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 35d71276f8ef..51caecb7c81a 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 9.0.41 + 9.0.44 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 9.0.41.3 + 9.0.44.8 FullStory OrgId diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index ca0c99bb87a2..c2ee6978f322 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 9.0.41 + 9.0.44 CFBundleSignature ???? CFBundleVersion - 9.0.41.3 + 9.0.44.8 diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist index 2d97209598e2..1f812f0eff46 100644 --- a/ios/NotificationServiceExtension/Info.plist +++ b/ios/NotificationServiceExtension/Info.plist @@ -11,9 +11,9 @@ CFBundleName $(PRODUCT_NAME) CFBundleShortVersionString - 9.0.41 + 9.0.44 CFBundleVersion - 9.0.41.3 + 9.0.44.8 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 a83d0550ad6f..cf9978bae510 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.44-8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.44-8", "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", @@ -117,7 +117,6 @@ "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", @@ -36653,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 5c6a0635e819..baf05e92111b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "9.0.41-3", + "version": "9.0.44-8", "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", @@ -174,22 +172,12 @@ "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..9a6bf21db303 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}).`, }, 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/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/ReportActionItemMessage.tsx b/src/pages/home/report/ReportActionItemMessage.tsx index c0002d1a72e7..da2f3dd151c8 100644 --- a/src/pages/home/report/ReportActionItemMessage.tsx +++ b/src/pages/home/report/ReportActionItemMessage.tsx @@ -2,8 +2,7 @@ import type {ReactElement} from 'react'; import React from 'react'; import type {StyleProp, TextStyle, ViewStyle} from 'react-native'; import {View} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import Button from '@components/Button'; import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; @@ -14,16 +13,11 @@ import * as ReportUtils from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {ReportAction, Transaction} from '@src/types/onyx'; +import type {ReportAction} from '@src/types/onyx'; import TextCommentFragment from './comment/TextCommentFragment'; import ReportActionItemFragment from './ReportActionItemFragment'; -type ReportActionItemMessageOnyxProps = { - /** The transaction linked to the report action. */ - transaction: OnyxEntry; -}; - -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..0ddddf7ff878 100644 --- a/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx +++ b/src/pages/iou/request/step/withFullTransactionOrNotFound.tsx @@ -2,8 +2,8 @@ import type {RouteProp} from '@react-navigation/native'; import {useIsFocused} from '@react-navigation/native'; import type {ComponentType, ForwardedRef, RefAttributes} from 'react'; import React, {forwardRef} from 'react'; +import {useOnyx} from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import getComponentDisplayName from '@libs/getComponentDisplayName'; import * as IOUUtils from '@libs/IOUUtils'; @@ -38,14 +38,25 @@ type MoneyRequestRouteName = | typeof SCREENS.MONEY_REQUEST.STEP_SEND_FROM | typeof SCREENS.MONEY_REQUEST.STEP_COMPANY_INFO; -type Route = RouteProp; +type Route = RouteProp; -type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & {route: Route}; +type WithFullTransactionOrNotFoundProps = WithFullTransactionOrNotFoundOnyxProps & { + route: Route; +}; -export default function , TRef>(WrappedComponent: ComponentType>) { +export default function , TRef>( + WrappedComponent: ComponentType>, +): React.ComponentType & RefAttributes> { // eslint-disable-next-line rulesdir/no-negated-variables - function WithFullTransactionOrNotFound(props: TProps, ref: ForwardedRef) { - const transactionID = props.transaction?.transactionID; + function WithFullTransactionOrNotFound(props: Omit, ref: ForwardedRef) { + const {route} = props; + const transactionID = route.params.transactionID ?? -1; + const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; + + const shouldUseTransactionDraft = IOUUtils.shouldUseTransactionDraft(userAction); + + const [transaction] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`); + const [transactionDraft] = useOnyx(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}`); const isFocused = useIsFocused(); @@ -59,7 +70,8 @@ export default function ); @@ -67,19 +79,7 @@ export default function , WithFullTransactionOrNotFoundOnyxProps>({ - transaction: { - key: ({route}) => { - const transactionID = route.params.transactionID ?? -1; - const userAction = 'action' in route.params && route.params.action ? route.params.action : CONST.IOU.ACTION.CREATE; - - if (IOUUtils.shouldUseTransactionDraft(userAction)) { - return `${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${transactionID}` as `${typeof ONYXKEYS.COLLECTION.TRANSACTION}${string}`; - } - return `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`; - }, - }, - })(forwardRef(WithFullTransactionOrNotFound)); + return forwardRef(WithFullTransactionOrNotFound); } export type {WithFullTransactionOrNotFoundProps}; 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 ? ( +