diff --git a/.github/actions/composite/buildAndroidAPK/action.yml b/.github/actions/composite/buildAndroidAPK/action.yml index 819234df0bc3..fc280ab2a223 100644 --- a/.github/actions/composite/buildAndroidAPK/action.yml +++ b/.github/actions/composite/buildAndroidAPK/action.yml @@ -13,7 +13,7 @@ runs: - uses: ruby/setup-ruby@eae47962baca661befdfd24e4d6c34ade04858f7 with: - ruby-version: '2.7' + ruby-version: "2.7" bundler-cache: true - uses: gradle/gradle-build-action@3fbe033aaae657f011f88f29be9e65ed26bd29ef @@ -26,4 +26,4 @@ runs: uses: actions/upload-artifact@65d862660abb392b8c4a3d1195a2108db131dd05 with: name: ${{ inputs.ARTIFACT_NAME }} - path: android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk + path: android/app/build/outputs/apk/e2e/release/app-e2e-release.apk diff --git a/.github/actions/javascript/getPullRequestDetails/action.yml b/.github/actions/javascript/getPullRequestDetails/action.yml index a59cf55bdf9f..ed2c60f018a1 100644 --- a/.github/actions/javascript/getPullRequestDetails/action.yml +++ b/.github/actions/javascript/getPullRequestDetails/action.yml @@ -13,8 +13,14 @@ inputs: outputs: MERGE_COMMIT_SHA: description: 'The merge_commit_sha of the given pull request' + HEAD_COMMIT_SHA: + description: 'The head_commit_sha of the given pull request' MERGE_ACTOR: description: 'The actor who merged the pull request' + IS_MERGED: + description: 'True if the pull request is merged' + FORKED_REPO_URL: + description: 'Output forked repo URL if PR includes changes from a fork' runs: using: 'node16' main: './index.js' diff --git a/.github/workflows/e2ePerformanceTests.yml b/.github/workflows/e2ePerformanceTests.yml index d8f9cad138d9..f7f1e5fc7ac7 100644 --- a/.github/workflows/e2ePerformanceTests.yml +++ b/.github/workflows/e2ePerformanceTests.yml @@ -84,12 +84,7 @@ jobs: - name: Unmerged PR - Fetch head ref of unmerged PR if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} run: | - if [[ ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }} != '' ]]; then - git remote add pr_remote ${{ steps.getPullRequestDetails.outputs.FORKED_REPO_URL }} - git fetch pr_remote ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1 - else - git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1 - fi + git fetch origin ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} --no-tags --depth=1 - name: Unmerged PR - Set dummy git credentials before merging if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} @@ -101,7 +96,7 @@ jobs: if: ${{ !fromJSON(steps.getPullRequestDetails.outputs.IS_MERGED) }} id: getMergeCommitShaIfUnmergedPR run: | - git merge --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} + git merge --allow-unrelated-histories --no-commit ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} git checkout ${{ steps.getPullRequestDetails.outputs.HEAD_COMMIT_SHA }} env: GITHUB_TOKEN: ${{ github.token }} @@ -140,18 +135,19 @@ jobs: name: baseline-apk-${{ needs.buildBaseline.outputs.VERSION }} path: zip - # The downloaded artifact will be a file named "app-e2eRelease.apk" so we have to rename it + # 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-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk" + run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-baseline.apk" - name: Download delta APK uses: actions/download-artifact@e9ef242655d12993efdcda9058dee2db83a2cb9b + id: downloadDeltaAPK with: name: delta-apk-${{ needs.buildDelta.outputs.DELTA_REF }} path: zip - name: Rename delta APK - run: mv "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease.apk" "${{steps.downloadBaselineAPK.outputs.download-path}}/app-e2eRelease-compare.apk" + run: mv "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2e-release.apk" "${{steps.downloadDeltaAPK.outputs.download-path}}/app-e2eRelease-compare.apk" - name: Copy e2e code into zip folder run: cp -r tests/e2e zip diff --git a/.storybook/preview.js b/.storybook/preview.js index b198c0d2d626..a989960794f2 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -6,7 +6,7 @@ import './fonts.css'; import ComposeProviders from '../src/components/ComposeProviders'; import HTMLEngineProvider from '../src/components/HTMLEngineProvider'; import OnyxProvider from '../src/components/OnyxProvider'; -import {LocaleContextProvider} from '../src/components/withLocalize'; +import {LocaleContextProvider} from '../src/components/LocaleContextProvider'; import {KeyboardStateProvider} from '../src/components/withKeyboardState'; import {EnvironmentProvider} from '../src/components/withEnvironment'; import {WindowDimensionsProvider} from '../src/components/withWindowDimensions'; diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association index a9e2b0383691..d6da0232f2fc 100644 --- a/.well-known/apple-app-site-association +++ b/.well-known/apple-app-site-association @@ -75,6 +75,10 @@ { "/": "/teachersunite/*", "comment": "Teachers Unite!" + }, + { + "/": "/search/*", + "comment": "Search" } ] } diff --git a/android/app/build.gradle b/android/app/build.gradle index afe24fc37700..f0eddd6085bc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -58,7 +58,7 @@ project.ext.envConfigFiles = [ adhocRelease: ".env.adhoc", developmentRelease: ".env", developmentDebug: ".env", - e2eRelease: ".env.production" + e2eRelease: "tests/e2e/.env.e2e" ] /** @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001037403 - versionName "1.3.74-3" + versionCode 1001037502 + versionName "1.3.75-2" } flavorDimensions "default" @@ -136,10 +136,20 @@ android { signingConfig signingConfigs.debug } release { - signingConfig signingConfigs.release productFlavors.production.signingConfig signingConfigs.release minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + + signingConfig null + // buildTypes take precedence over productFlavors when it comes to the signing configuration, + // thus we need to manually set the signing config, so that the e2e uses the debug config again. + // In other words, the signingConfig setting above will be ignored when we build the flavor in release mode. + productFlavors.all { flavor -> + // All release builds should be signed with the release config ... + flavor.signingConfig signingConfigs.release + } + // ... except for the e2e flavor, which we maybe want to build locally: + productFlavors.e2e.signingConfig signingConfigs.debug } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 8d69c62bfd1f..dc135fa9834e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -67,6 +67,7 @@ + @@ -83,6 +84,7 @@ + diff --git a/docs/articles/expensify-classic/account-settings/Account-Access.md b/docs/articles/expensify-classic/account-settings/Account-Access.md deleted file mode 100644 index b3126201715f..000000000000 --- a/docs/articles/expensify-classic/account-settings/Account-Access.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Account Access -description: Account Access ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/account-settings/Account-Details.md b/docs/articles/expensify-classic/account-settings/Account-Details.md new file mode 100644 index 000000000000..46a6c6ba0c25 --- /dev/null +++ b/docs/articles/expensify-classic/account-settings/Account-Details.md @@ -0,0 +1,5 @@ +--- +title: Account Details +description: Account Details +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md index e565e59dc754..7fa714189542 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md @@ -48,11 +48,11 @@ You can also create a number of future 'placeholder' expenses for your recurring # How to Edit Bulk Expenses Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses: Go to the Expenses page. -To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit. +To narrow down your selection, use the filters (e.g. "Merchant" and "Draft") to find the specific expenses you want to edit. Select all the expenses you want to edit. Click on the **Edit Multiple** button at the top of the page. # How to Edit Expenses on a Report -If you’d like to edit expenses within an Open report: +If you’d like to edit expenses within a Draft report: 1. Click on the Report containing all the expenses. 2. Click on **Details**. @@ -61,8 +61,8 @@ If you’d like to edit expenses within an Open report: If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses. - # FAQ + ## Does Expensify account for duplicates? Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report. @@ -71,6 +71,7 @@ If two expenses are SmartScanned on the same day for the same amount, they will The expenses were split from a single expense, The expenses were imported from a credit card, or Matching email receipts sent to receipts@expensify.com were received with different timestamps. + ## How do I resolve a duplicate expense? If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount). @@ -88,12 +89,13 @@ Click the **Undelete** button and you're all set. You’ll find the expense on y ## What are the different Expense statuses? There are a number of different expense statuses in Expensify: -1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner. -2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement. +1. **Personal**: Personal expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner. +2. **Draft**: Draft expenses are seen as still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making this a collaborative step toward reimbursement. 3. **Processing**: Processing expenses are submitted, but waiting for approval. 4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid. 5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid. 6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report. + ## What are Violations? Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission. @@ -101,8 +103,9 @@ Violations represent errors or discrepancies that Expensify has picked up and ne To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off. You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are: -**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission. -**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed. +1. **Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission. +2. **Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed. + ## How to Track Attendees Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending. @@ -116,9 +119,10 @@ External attendees are considered users outside your group policy or domain. To 1. Click or tap the **Attendee** field within your expense. 2. Type in the individual's name or email address. 3. Tap **Add** to include the attendee. -You can continue adding more attendees or save the Expense. +4. You can continue adding more attendees or save the Expense. + To remove an attendee from an expense: -Open the expense. -Click or tap the **Attendees** field to display the list of attendees. -From the list, de-select the attendees you'd like to remove from the expense. +1. Open the expense. +2. Click or tap the **Attendees** field to display the list of attendees. +3. From the list, de-select the attendees you'd like to remove from the expense. diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md index bfbc0773768c..a8444b98c951 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Merge-Expenses.md @@ -14,7 +14,7 @@ Keep in mind: 1. Merging expenses cannot be undone. 2. You can only merge two expenses at a time. 3. You can merge a cash expense with a credit card expense, or two cash expenses - but not two credit card expenses. -4. In order to merge, both expenses will need to be in an Open or Unreported state. +4. In order to merge, both expenses will need to be in a Personal or Draft status. # How to merge expenses on the web app To merge two expenses from the Expenses page: @@ -41,11 +41,12 @@ If the expenses exist on two different reports, you will be asked which report y ## Can you merge expenses across different reports? -You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Open status) in order to merge them. +You cannot merge expenses across different reports. Expenses will only merge if they are on the same report. If you have expenses across different reports that you wish to merge, you’ll need to move both expenses onto the same report (and ensure they are in the Draft status) in order to merge them. ## Can you merge expenses across different accounts? You cannot merge expenses across two separate accounts. You will need to choose one submitter and transfer the expense information to that user's account in order to merge the expense. + ## Can you merge expenses with different currencies? Yes, you can merge expenses with different currencies. The conversion amount will be based on the daily exchange rate for the date of the transaction, as long as the converted rates are within +/- 5%. If the currencies are the same, then the amounts must be an exact match to merge. diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md index a65dc378a793..65238457f1a9 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md @@ -9,34 +9,34 @@ This guide is for those who are part of a **Group Workspace**. Each member has a role that defines what they can see and do in the workspace. Most members will have the role of "Employee." -# How to Manage User Roles +# How to manage user roles -To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > [Your Specific Workspace Name] > Members > Workspace Members** +To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > _[Workspace Name]_ > Members > Workspace Members** Here you'll see the list of members in your group workspace. To change their roles, click **Settings** next to the member’s name and choose the role that the member needs. Next, let’s go over the various user roles that are available on a group workspace. -## The Employee Role +### The Employee Role - **What can they do:** Employees can only see their own expense reports or reports that have been submitted to or shared with them. They can't change settings or invite new users. - **Who is it for:** Regular employees who only need to manage their own expenses, or managers who are reviewing expense reports for a few users but don’t need global visibility. - **Approvers:** Members who approve expenses can either be Employees, Admins, or Workspace Auditors, depending on how much control they need. - **Billable:** Employees are billable actors if they take actions on a report on your Group Workspace (including **SmartScanning** a receipt). -## Workspace Admin Role +### Workspace Admin Role - **What can they do:** Admins have full control. They can change settings, invite members, and view all reports. They can also process reimbursements if they have access to the company’s account. - **Billing Owners:** Billing owners are Admins by default. **Workspace Admins** are assigned by the owner or another admin. - **Billable:** Yes, if they perform actions like changing settings or inviting users. Just viewing reports is not billable. -## Workspace Auditor Role +### Workspace Auditor Role - **What can they do:** Workspace Auditors can see all reports, make comments, and export them. They can also mark reports as reimbursed if they're the final approver. - **Who is it for:** Accountants, bookkeepers, and internal or external audit agents who need to view but not edit workspace settings. - **Billable:** Yes, if they perform any actions like commenting or exporting a report. Viewing alone doesn't incur a charge. -## Technical Contact +### Technical Contact - **What can they do:** In case of connection issues, alerts go to the billing owner by default. You can set a technical contact if you want alerts to go to an IT administrator instead. - **How to set one:** Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > Technical Contact**. diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md index e107734216f5..7c21b12a83e1 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Vacation-Delegate.md @@ -1,8 +1,54 @@ --- -title: Coming Soon -description: Coming Soon +title: Vacation Delegate +description: In Expensify, a vacation delegate is someone you choose to act on your behalf when you're on vacation or taking personal time off. --- -## Resource Coming Soon! + +# Overview + +A delegate is someone who can handle approving expense reports for you, which is especially useful when you're out of the office! + +In Expensify, a **Vacation Delegate** is someone you choose to act on your behalf when you're on vacation or taking personal time off. They will approve expense reports just like you would, and everything moves forward as usual afterward. + +The system keeps a detailed audit trail, showing exactly when your delegate stepped in to approve a report for you. And if your delegate also goes on vacation, they can have their own delegate, so reports keep getting approved. + +By using this feature, you ensure that all reports get the approvals they need, even when you're not around. + +# How to use Vacation Delegate + +If you're planning to take some time off, you can use the **Vacation Delegate** feature to assign someone to approve expense reports for you. The reports will continue on their usual path as if you had approved them yourself. + +## Set a Vacation Delegate for yourself + +1. Go to the Expensify website (note: you can't do this from the mobile app). +2. Navigate to **Settings > Your Account > Account Details** and scroll down to find **Vacation Delegate**. +3. Enter the email address of the person you're designating as your delegate and click **Set Delegate**. + +Voila! You've set a vacation delegate. Any reports that usually come to you will now go to your delegate instead. When you return, you can easily remove the delegate by clicking a link at the top of the Expensify homepage. + +## Setting a Vacation Delegate as a Domain Admin + +1. Head to **Settings > Domains > [Your Domain Name] > Domain Members > Edit Settings** +2. Enter the delegate's email address and click **Save.** + +Your delegate's actions will be noted in the history and comments of each report they approve, so you can keep track of what happened while you were away. + +# Deep Dive + +## An audit trail of delegate actions + +The system records every action your vacation delegate takes on your behalf in the **Report History and Comments**. So, you can see when they approved an expense report for you. + +# FAQs + +## Why can't my Vacation Delegate reimburse reports that they approve? + +If your **Vacation Delegate** also needs to reimburse reports on your behalf whilst you're away, they'll also need access to the reimbursement account. + +If they do not have access to the reimbursement account used on your workspace, they won’t have the option to reimburse reports, even as your **Vacation Delegate**. + +## What if my Vacation Delegate is also on vacation? + +Don't worry, your delegate can also pick their own **Vacation Delegate**. This way, expense reports continue to get approved even if multiple people are away. + -Kayak.md Lyft.md TrainLine.md TravelPerk.md Trip Actions.md TripCatcher.md Uber.md \ No newline at end of file diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md deleted file mode 100644 index cea96cfe2057..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Admins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Admins -description: Admins ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Admins.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md b/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Domain-Members.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md b/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Overview.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md b/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Reports.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md b/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md deleted file mode 100644 index 3ee1c8656b4b..000000000000 --- a/docs/articles/expensify-classic/policy-and-domain-settings/Trips.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -title: Coming Soon -description: Coming Soon ---- -## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md new file mode 100644 index 000000000000..e5c9096fa610 --- /dev/null +++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Currency.md @@ -0,0 +1,5 @@ +--- +title: Currency +description: Currency +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md new file mode 100644 index 000000000000..47b96f495a1c --- /dev/null +++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Report-Fields-And-Titles.md @@ -0,0 +1,5 @@ +--- +title: Report Fields & Titles +description: Report Fields & Titles +--- +## Resource Coming Soon! diff --git a/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md new file mode 100644 index 000000000000..c05df92bbbff --- /dev/null +++ b/docs/articles/expensify-classic/policy-and-domain-settings/reports/Scheduled-Submit.md @@ -0,0 +1,38 @@ +--- +title: Scheduled Submit +description: How to use the Scheduled Submit feature +--- +# Overview + +Scheduled Submit reduces the delay between the time an employee creates an expense to when it is submitted to the admin. This gives admins significantly faster visibility into employee spend. Without Scheduled Submit enabled, expenses can be left Unreported giving workspace admins no visibility into employee spend. + +The biggest delay in expense management is the time it takes for an employee to actually submit the expense after it is incurred. Scheduled Submit allows you to automatically collect employee expenses on a schedule of your choosing without delaying the process while you wait for employees to submit them. + +It works like this: Employee expenses are automatically gathered onto a report. If there is not an existing report, a new one will be created. This report is submitted automatically at the cadence you choose (daily, weekly, monthly, twice month, by trip). + +# How to enable Scheduled Submit + +**For workspace admins**: To enable Scheduled Submit on your group workspace, follow **Settings > Workspaces > Group > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. +For individuals or employees: To enable Scheduled Submit on your individual workspace, follow **Settings > Workspaces > Individual > *[Workspace Name]* > Reports > Scheduled Submit**. From there, toggle Scheduled Submit to Enabled. Then, choose your desired frequency from the dropdown menu. + +## Scheduled Submit frequency options + +**Daily**: Each night, expenses without violations will be submitted. Expenses with violations will remain on an open report until the violations are corrected, after which they will be submitted in the evening (PDT). + +**Weekly**: Expenses that are free of violations will be submitted on a weekly basis. However, expenses with violations will be held in a new open report and combined with any new expenses. They will then be submitted at the end of the following weekly cycle, specifically on Sunday evening (PDT). + +**Twice a month**: Expenses that are violation-free will be submitted on both the 15th and the last day of each month, in the evening (PDT). Expenses with violations will not be submitted, but moved on to a new open report so the employee can resolve the violations and then will be submitted at the conclusion of the next cycle. + +**Monthly**: Expenses that are free from violations will be submitted on a monthly basis. Expenses with violations will be held back and moved to a new Open report so the violations can be resolved, and they will be submitted on the evening (PDT) of the specified date. + +**By trip**: Expenses are grouped by trip. This is calculated by grouping all expenses together that occur in a similar time frame. If two full days pass without any new expenses being created, the trip report will be submitted on the evening of the second day. Any expenses generated after this submission will initiate a new trip report. Please note that the "2-day" period refers to a date-based interval, not a 48-hour time frame. + +**Manually**: An open report will be created, and expenses will be added to it automatically. However, it's important to note that the report will not be submitted automatically; manual submission of reports will be required.This is a great option for automatically gathering all an employee’s expenses on a report for the employee’s convenience, but they will still need to review and submit the report. + +# Deep Dive + +## Schedule Submit Override +If Scheduled Submit is disabled at the group workspace level or configured the frequency as "Manually," the individual workspace settings of a user will take precedence and be applied. This means an employee can still set up Scheduled Submit for themselves even if the admin has not enabled it. We highly recommend Scheduled Submit as it helps put your expenses on auto-pilot! + +## Personal Card Transactions +Personal card transactions are handled differently compared to other expenses. If a user has added a card through Settings > Account > Credit Card Import, they need to make sure it is set as non-reimbursable and transactions must be automatically merged with a SmartScanned receipt. If transactions are set to come in as reimbursable or they aren’t merged with a SmartScanned receipt, Scheduled Submit settings will not apply. diff --git a/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html new file mode 100644 index 000000000000..86641ee60b7d --- /dev/null +++ b/docs/expensify-classic/hubs/policy-and-domain-settings/reports.html @@ -0,0 +1,5 @@ +--- +layout: default +--- + +{% include section.html %} diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c7d0f2f4f0f5..dac53193fdc6 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -17,7 +17,7 @@ platform :android do desc "Generate a new local APK for e2e testing" lane :build_e2e do ENV["ENVFILE"]="tests/e2e/.env.e2e" - ENV["ENTRY_FILE"]="#{Dir.pwd}/../src/libs/E2E/reactNativeLaunchingTest.js" + ENV["ENTRY_FILE"]="src/libs/E2E/reactNativeLaunchingTest.js" ENV["E2E_TESTING"]="true" gradle( diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index 73e22053eda1..8536b4da82b5 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.3.74 + 1.3.75 CFBundleSignature ???? CFBundleURLTypes @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.74.3 + 1.3.75.2 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 5e7f02699579..802ee97145ae 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -15,10 +15,10 @@ CFBundlePackageType BNDL CFBundleShortVersionString - 1.3.74 + 1.3.75 CFBundleSignature ???? CFBundleVersion - 1.3.74.3 + 1.3.75.2 diff --git a/package-lock.json b/package-lock.json index 42755b09f8b6..97b6f6ea7b38 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.74-3", + "version": "1.3.75-2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.74-3", + "version": "1.3.75-2", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -90,7 +90,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.94", + "react-native-onyx": "1.0.97", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", @@ -41204,9 +41204,9 @@ } }, "node_modules/react-native-onyx": { - "version": "1.0.94", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.94.tgz", - "integrity": "sha512-Xoh9LTdoCNLQjyeLB6HkBwyf5ipkSjnETLVijSIWKnecbZS8/fQehUuGz+yEk9I0xVEn43IhmnkQ+yqQvV9vEg==", + "version": "1.0.97", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz", + "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==", "dependencies": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", @@ -77269,9 +77269,9 @@ } }, "react-native-onyx": { - "version": "1.0.94", - "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.94.tgz", - "integrity": "sha512-Xoh9LTdoCNLQjyeLB6HkBwyf5ipkSjnETLVijSIWKnecbZS8/fQehUuGz+yEk9I0xVEn43IhmnkQ+yqQvV9vEg==", + "version": "1.0.97", + "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.97.tgz", + "integrity": "sha512-6w4pp9Ktm4lQ6jIS+ZASQ5tYwRU1lt751yxfddvmN646XZefj4iDvC7uQaUnAgg1xL52dEV5RZWaI3sQ3e9AGQ==", "requires": { "ascii-table": "0.0.9", "fast-equals": "^4.0.3", diff --git a/package.json b/package.json index 3b88d603ba52..8804c2002a10 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.74-3", + "version": "1.3.75-2", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", @@ -133,7 +133,7 @@ "react-native-linear-gradient": "^2.8.1", "react-native-localize": "^2.2.6", "react-native-modal": "^13.0.0", - "react-native-onyx": "1.0.94", + "react-native-onyx": "1.0.97", "react-native-pager-view": "^6.2.0", "react-native-pdf": "^6.7.1", "react-native-performance": "^5.1.0", diff --git a/scripts/android-repackage-app-bundle-and-sign.sh b/scripts/android-repackage-app-bundle-and-sign.sh index fe4ee1e4b8fc..1636edc21388 100755 --- a/scripts/android-repackage-app-bundle-and-sign.sh +++ b/scripts/android-repackage-app-bundle-and-sign.sh @@ -1,4 +1,5 @@ #!/bin/bash +source ./scripts/shellUtils.sh ### # Takes an android app that has been built with the debug keystore, @@ -41,7 +42,7 @@ if [ ! -f "$NEW_BUNDLE_FILE" ]; then echo "Bundle file not found: $NEW_BUNDLE_FILE" exit 1 fi -OUTPUT_APK=$(realpath "$OUTPUT_APK") +OUTPUT_APK=$(get_abs_path "$OUTPUT_APK") # check if "apktool" command is available if ! command -v apktool &> /dev/null then diff --git a/scripts/shellUtils.sh b/scripts/shellUtils.sh index 876933af9766..4c9e2febc34d 100644 --- a/scripts/shellUtils.sh +++ b/scripts/shellUtils.sh @@ -41,3 +41,46 @@ function join_by_string { shift printf "%s" "$first" "${@/#/$separator}" } + +# Usage: get_abs_path +# Will make a path absolute, resolving any relative paths +# example: get_abs_path "./foo/bar" +get_abs_path() { + local the_path=$1 + local -a path_elements + IFS='/' read -ra path_elements <<< "$the_path" + + # If the path is already absolute, start with an empty string. + # We'll prepend the / later when reconstructing the path. + if [[ "$the_path" = /* ]]; then + abs_path="" + else + abs_path="$(pwd)" + fi + + # Handle each path element + for element in "${path_elements[@]}"; do + if [ "$element" = "." ] || [ -z "$element" ]; then + continue + elif [ "$element" = ".." ]; then + # Remove the last element from abs_path + abs_path=$(dirname "$abs_path") + else + # Append element to the absolute path + abs_path="${abs_path}/${element}" + fi + done + + # Remove any trailing '/' + while [[ $abs_path == */ ]]; do + abs_path=${abs_path%/} + done + + # Special case for root + [ -z "$abs_path" ] && abs_path="/" + + # Special case to remove any starting '//' when the input path was absolute + abs_path=${abs_path/#\/\//\/} + + echo "$abs_path" +} \ No newline at end of file diff --git a/src/App.js b/src/App.js index 284c6115d7b8..1d2e07345c24 100644 --- a/src/App.js +++ b/src/App.js @@ -9,7 +9,7 @@ import {PickerStateProvider} from 'react-native-picker-select'; import CustomStatusBar from './components/CustomStatusBar'; import ErrorBoundary from './components/ErrorBoundary'; import Expensify from './Expensify'; -import {LocaleContextProvider} from './components/withLocalize'; +import {LocaleContextProvider} from './components/LocaleContextProvider'; import OnyxProvider from './components/OnyxProvider'; import HTMLEngineProvider from './components/HTMLEngineProvider'; import PopoverContextProvider from './components/PopoverProvider'; diff --git a/src/CONST.ts b/src/CONST.ts index dbe47c6ed1a7..0a262d868de9 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -992,6 +992,11 @@ const CONST = { STATEMENT: 'STATEMENT_NAVIGATE', CONCIERGE: 'CONCIERGE_NAVIGATE', }, + MTL_WALLET_PROGRAM_ID: '760', + PROGRAM_ISSUERS: { + EXPENSIFY_PAYMENTS: 'Expensify Payments LLC', + BANCORP_BANK: 'The Bancorp Bank', + }, }, PLAID: { @@ -1263,6 +1268,8 @@ const CONST = { DATE_TIME_FORMAT: /^\d{2}-\d{2} \d{2}:\d{2} [AP]M$/, ATTACHMENT_ROUTE: /\/r\/(\d*)\/attachment/, ILLEGAL_FILENAME_CHARACTERS: /\/|<|>|\*|"|:|\?|\\|\|/g, + + ENCODE_PERCENT_CHARACTER: /%(25)+/g, }, PRONOUNS: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d2b3031220f1..a1afc4fef2c1 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1,5 +1,4 @@ import {ValueOf} from 'type-fest'; -import {OnyxUpdate} from 'react-native-onyx'; import DeepValueOf from './types/utils/DeepValueOf'; import * as OnyxTypes from './types/onyx'; import CONST from './CONST'; @@ -30,9 +29,6 @@ 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', - /** Onyx updates from a response, or success or failure data from a request. */ - QUEUED_ONYX_UPDATES: 'queuedOnyxUpdates', - /** Stores current date */ CURRENT_DATE: 'currentDate', @@ -307,7 +303,6 @@ type OnyxValues = { [ONYXKEYS.DEVICE_ID]: string; [ONYXKEYS.IS_SIDEBAR_LOADED]: boolean; [ONYXKEYS.PERSISTED_REQUESTS]: OnyxTypes.Request[]; - [ONYXKEYS.QUEUED_ONYX_UPDATES]: OnyxUpdate[]; [ONYXKEYS.CURRENT_DATE]: string; [ONYXKEYS.CREDENTIALS]: OnyxTypes.Credentials; [ONYXKEYS.IOU]: OnyxTypes.IOU; diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 90f5c22e5b3c..5261d1258ad0 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -108,6 +108,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c (values) => { const validateErrors = validate(values); setErrors(validateErrors); + return validateErrors; }, [validate], ); diff --git a/src/components/LocaleContextProvider.js b/src/components/LocaleContextProvider.js new file mode 100644 index 000000000000..b8838f253e74 --- /dev/null +++ b/src/components/LocaleContextProvider.js @@ -0,0 +1,135 @@ +import React, {createContext, useMemo} from 'react'; +import PropTypes from 'prop-types'; +import {withOnyx} from 'react-native-onyx'; +import lodashGet from 'lodash/get'; + +import ONYXKEYS from '../ONYXKEYS'; +import * as Localize from '../libs/Localize'; +import DateUtils from '../libs/DateUtils'; +import * as NumberFormatUtils from '../libs/NumberFormatUtils'; +import * as LocaleDigitUtils from '../libs/LocaleDigitUtils'; +import CONST from '../CONST'; +import compose from '../libs/compose'; +import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails'; +import * as LocalePhoneNumber from '../libs/LocalePhoneNumber'; + +const LocaleContext = createContext(null); + +const localeProviderPropTypes = { + /** The user's preferred locale e.g. 'en', 'es-ES' */ + preferredLocale: PropTypes.string, + + /** Actual content wrapped by this component */ + children: PropTypes.node.isRequired, + + /** The current user's personalDetails */ + currentUserPersonalDetails: PropTypes.shape({ + /** Timezone of the current user */ + timezone: PropTypes.shape({ + /** Value of the selected timezone */ + selected: PropTypes.string, + }), + }), +}; + +const localeProviderDefaultProps = { + preferredLocale: CONST.LOCALES.DEFAULT, + currentUserPersonalDetails: {}, +}; + +function LocaleContextProvider({children, currentUserPersonalDetails, preferredLocale}) { + const selectedTimezone = useMemo(() => lodashGet(currentUserPersonalDetails, 'timezone.selected'), [currentUserPersonalDetails]); + + /** + * @param {String} phrase + * @param {Object} [variables] + * @returns {String} + */ + const translate = useMemo(() => (phrase, variables) => Localize.translate(preferredLocale, phrase, variables), [preferredLocale]); + + /** + * @param {Number} number + * @param {Intl.NumberFormatOptions} options + * @returns {String} + */ + const numberFormat = useMemo(() => (number, options) => NumberFormatUtils.format(preferredLocale, number, options), [preferredLocale]); + + /** + * @param {String} datetime + * @returns {String} + */ + const datetimeToRelative = useMemo(() => (datetime) => DateUtils.datetimeToRelative(preferredLocale, datetime), [preferredLocale]); + + /** + * @param {String} datetime - ISO-formatted datetime string + * @param {Boolean} [includeTimezone] + * @param {Boolean} isLowercase + * @returns {String} + */ + const datetimeToCalendarTime = useMemo( + () => + (datetime, includeTimezone, isLowercase = false) => + DateUtils.datetimeToCalendarTime(preferredLocale, datetime, includeTimezone, selectedTimezone, isLowercase), + [preferredLocale, selectedTimezone], + ); + + /** + * Updates date-fns internal locale to the user preferredLocale + */ + const updateLocale = useMemo(() => () => DateUtils.setLocale(preferredLocale), [preferredLocale]); + + /** + * @param {String} phoneNumber + * @returns {String} + */ + const formatPhoneNumber = LocalePhoneNumber.formatPhoneNumber; + + /** + * @param {String} digit + * @returns {String} + */ + const toLocaleDigit = useMemo(() => (digit) => LocaleDigitUtils.toLocaleDigit(preferredLocale, digit), [preferredLocale]); + + /** + * @param {String} localeDigit + * @returns {String} + */ + const fromLocaleDigit = useMemo(() => (localeDigit) => LocaleDigitUtils.fromLocaleDigit(preferredLocale, localeDigit), [preferredLocale]); + + /** + * The context this component exposes to child components + * @returns {object} translation util functions and locale + */ + const contextValue = useMemo( + () => ({ + translate, + numberFormat, + datetimeToRelative, + datetimeToCalendarTime, + updateLocale, + formatPhoneNumber, + toLocaleDigit, + fromLocaleDigit, + preferredLocale, + }), + [translate, numberFormat, datetimeToRelative, datetimeToCalendarTime, updateLocale, formatPhoneNumber, toLocaleDigit, fromLocaleDigit, preferredLocale], + ); + + return {children}; +} + +LocaleContextProvider.propTypes = localeProviderPropTypes; +LocaleContextProvider.defaultProps = localeProviderDefaultProps; + +const Provider = compose( + withCurrentUserPersonalDetails, + withOnyx({ + preferredLocale: { + key: ONYXKEYS.NVP_PREFERRED_LOCALE, + }, + }), +)(LocaleContextProvider); + +Provider.displayName = 'withOnyx(LocaleContextProvider)'; + +export {Provider as LocaleContextProvider, LocaleContext}; diff --git a/src/components/withLocalize.js b/src/components/withLocalize.js index 5ce1b0bc6d74..65e98f78f312 100755 --- a/src/components/withLocalize.js +++ b/src/components/withLocalize.js @@ -1,19 +1,7 @@ -import React, {createContext, forwardRef} from 'react'; +import React, {forwardRef} from 'react'; import PropTypes from 'prop-types'; -import {withOnyx} from 'react-native-onyx'; -import lodashGet from 'lodash/get'; +import {LocaleContext} from './LocaleContextProvider'; import getComponentDisplayName from '../libs/getComponentDisplayName'; -import ONYXKEYS from '../ONYXKEYS'; -import * as Localize from '../libs/Localize'; -import DateUtils from '../libs/DateUtils'; -import * as NumberFormatUtils from '../libs/NumberFormatUtils'; -import * as LocaleDigitUtils from '../libs/LocaleDigitUtils'; -import CONST from '../CONST'; -import compose from '../libs/compose'; -import withCurrentUserPersonalDetails from './withCurrentUserPersonalDetails'; -import * as LocalePhoneNumber from '../libs/LocalePhoneNumber'; - -const LocaleContext = createContext(null); const withLocalizePropTypes = { /** Returns translated string for given locale and phrase */ @@ -42,140 +30,6 @@ const withLocalizePropTypes = { toLocaleDigit: PropTypes.func.isRequired, }; -const localeProviderPropTypes = { - /** The user's preferred locale e.g. 'en', 'es-ES' */ - preferredLocale: PropTypes.string, - - /** Actual content wrapped by this component */ - children: PropTypes.node.isRequired, - - /** The current user's personalDetails */ - currentUserPersonalDetails: PropTypes.shape({ - /** Timezone of the current user */ - timezone: PropTypes.shape({ - /** Value of the selected timezone */ - selected: PropTypes.string, - }), - }), -}; - -const localeProviderDefaultProps = { - preferredLocale: CONST.LOCALES.DEFAULT, - currentUserPersonalDetails: {}, -}; - -class LocaleContextProvider extends React.Component { - shouldComponentUpdate(nextProps) { - return ( - nextProps.preferredLocale !== this.props.preferredLocale || - lodashGet(nextProps, 'currentUserPersonalDetails.timezone.selected') !== lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected') - ); - } - - /** - * The context this component exposes to child components - * @returns {object} translation util functions and locale - */ - getContextValue() { - return { - translate: this.translate.bind(this), - numberFormat: this.numberFormat.bind(this), - datetimeToRelative: this.datetimeToRelative.bind(this), - datetimeToCalendarTime: this.datetimeToCalendarTime.bind(this), - updateLocale: this.updateLocale.bind(this), - formatPhoneNumber: this.formatPhoneNumber.bind(this), - fromLocaleDigit: this.fromLocaleDigit.bind(this), - toLocaleDigit: this.toLocaleDigit.bind(this), - preferredLocale: this.props.preferredLocale, - }; - } - - /** - * @param {String} phrase - * @param {Object} [variables] - * @returns {String} - */ - translate(phrase, variables) { - return Localize.translate(this.props.preferredLocale, phrase, variables); - } - - /** - * @param {Number} number - * @param {Intl.NumberFormatOptions} options - * @returns {String} - */ - numberFormat(number, options) { - return NumberFormatUtils.format(this.props.preferredLocale, number, options); - } - - /** - * @param {String} datetime - * @returns {String} - */ - datetimeToRelative(datetime) { - return DateUtils.datetimeToRelative(this.props.preferredLocale, datetime); - } - - /** - * @param {String} datetime - ISO-formatted datetime string - * @param {Boolean} [includeTimezone] - * @param {Boolean} isLowercase - * @returns {String} - */ - datetimeToCalendarTime(datetime, includeTimezone, isLowercase = false) { - return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected'), isLowercase); - } - - /** - * Updates date-fns internal locale to the user preferredLocale - */ - updateLocale() { - DateUtils.setLocale(this.props.preferredLocale); - } - - /** - * @param {String} phoneNumber - * @returns {String} - */ - formatPhoneNumber(phoneNumber) { - return LocalePhoneNumber.formatPhoneNumber(phoneNumber); - } - - /** - * @param {String} digit - * @returns {String} - */ - toLocaleDigit(digit) { - return LocaleDigitUtils.toLocaleDigit(this.props.preferredLocale, digit); - } - - /** - * @param {String} localeDigit - * @returns {String} - */ - fromLocaleDigit(localeDigit) { - return LocaleDigitUtils.fromLocaleDigit(this.props.preferredLocale, localeDigit); - } - - render() { - return {this.props.children}; - } -} - -LocaleContextProvider.propTypes = localeProviderPropTypes; -LocaleContextProvider.defaultProps = localeProviderDefaultProps; - -const Provider = compose( - withCurrentUserPersonalDetails, - withOnyx({ - preferredLocale: { - key: ONYXKEYS.NVP_PREFERRED_LOCALE, - }, - }), -)(LocaleContextProvider); - -Provider.displayName = 'withOnyx(LocaleContextProvider)'; - export default function withLocalize(WrappedComponent) { const WithLocalize = forwardRef((props, ref) => ( @@ -196,4 +50,4 @@ export default function withLocalize(WrappedComponent) { return WithLocalize; } -export {withLocalizePropTypes, Provider as LocaleContextProvider, LocaleContext}; +export {withLocalizePropTypes}; diff --git a/src/hooks/useLocalize.js b/src/hooks/useLocalize.js index 9ad5048729bd..7f7a610fca8b 100644 --- a/src/hooks/useLocalize.js +++ b/src/hooks/useLocalize.js @@ -1,5 +1,5 @@ import {useContext} from 'react'; -import {LocaleContext} from '../components/withLocalize'; +import {LocaleContext} from '../components/LocaleContextProvider'; export default function useLocalize() { return useContext(LocaleContext); diff --git a/src/languages/en.ts b/src/languages/en.ts index 9c49e2907702..f16097aa03d1 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -73,6 +73,7 @@ import type { RequestedAmountMessageParams, TagSelectionParams, TranslationBase, + WalletProgramParams, } from './types'; import * as ReportActionsUtils from '../libs/ReportActionsUtils'; @@ -909,7 +910,7 @@ export default { phrase2: 'Terms of Service', phrase3: 'and', phrase4: 'Privacy', - phrase5: 'Money transmission is provided by Expensify Payments LLC (NMLS ID:2017010) pursuant to its', + phrase5: `Money transmission is provided by ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) pursuant to its`, phrase6: 'licenses', }, validateCodeForm: { @@ -1170,7 +1171,7 @@ export default { electronicFundsWithdrawal: 'Electronic funds withdrawal', standard: 'Standard', shortTermsForm: { - expensifyPaymentsAccount: 'The Expensify Wallet is issued by The Bancorp Bank.', + expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `The Expensify Wallet is issued by ${walletProgram}.`, perPurchase: 'Per purchase', atmWithdrawal: 'ATM withdrawal', cashReload: 'Cash reload', @@ -1212,10 +1213,10 @@ export default { 'several minutes. The fee is 1.5% of the transfer amount (with a minimum fee of $0.25).', fdicInsuranceBancorp: 'Your funds are eligible for FDIC insurance. Your funds will be held at or ' + - 'transferred to The Bancorp Bank, an FDIC-insured institution. Once there, your funds are insured up ' + - 'to $250,000 by the FDIC in the event The Bancorp Bank fails. See', + `transferred to ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, an FDIC-insured institution. Once there, your funds are insured up ` + + `to $250,000 by the FDIC in the event ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} fails. See`, fdicInsuranceBancorp2: 'for details.', - contactExpensifyPayments: 'Contact Expensify Payments by calling +1 833-400-0904, by email at', + contactExpensifyPayments: `Contact ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} by calling +1 833-400-0904, by email at`, contactExpensifyPayments2: 'or sign in at', generalInformation: 'For general information about prepaid accounts, visit', generalInformation2: 'If you have a complaint about a prepaid account, call the Consumer Financial Protection Bureau at 1-855-411-2372 or visit', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2be3f9e96265..3860c34f6ef1 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -73,6 +73,7 @@ import type { RequestedAmountMessageParams, TagSelectionParams, EnglishTranslation, + WalletProgramParams, } from './types'; /* eslint-disable max-len */ @@ -905,7 +906,7 @@ export default { phrase2: 'Términos de Servicio', phrase3: 'y', phrase4: 'Privacidad', - phrase5: 'El envío de dinero es brindado por Expensify Payments LLC (NMLS ID:2017010) de conformidad con sus', + phrase5: `El envío de dinero es brindado por ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} (NMLS ID:2017010) de conformidad con sus`, phrase6: 'licencias', }, validateCodeForm: { @@ -1187,7 +1188,7 @@ export default { electronicFundsWithdrawal: 'Retiro electrónico de fondos', standard: 'Estándar', shortTermsForm: { - expensifyPaymentsAccount: 'La billetera Expensify es emitida por The Bancorp Bank.', + expensifyPaymentsAccount: ({walletProgram}: WalletProgramParams) => `La billetera Expensify es emitida por ${walletProgram}.`, perPurchase: 'Por compra', atmWithdrawal: 'Retiro de cajero automático', cashReload: 'Recarga de efectivo', @@ -1230,10 +1231,10 @@ export default { 'transferencia (con una tarifa mínima de $ 0.25). ', fdicInsuranceBancorp: 'Sus fondos son elegibles para el seguro de la FDIC. Sus fondos se mantendrán en o ' + - 'transferido a The Bancorp Bank, una institución asegurada por la FDIC. Una vez allí, sus fondos ' + - 'están asegurados a $ 250,000 por la FDIC en caso de que The Bancorp Bank quiebre. Ver', + `transferido a ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK}, una institución asegurada por la FDIC. Una vez allí, sus fondos ` + + `están asegurados a $ 250,000 por la FDIC en caso de que ${CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK} quiebre. Ver`, fdicInsuranceBancorp2: 'para detalles.', - contactExpensifyPayments: 'Comuníquese con Expensify Payments llamando al + 1833-400-0904, por correoelectrónico a', + contactExpensifyPayments: `Comuníquese con ${CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS} llamando al + 1833-400-0904, por correoelectrónico a`, contactExpensifyPayments2: 'o inicie sesión en', generalInformation: 'Para obtener información general sobre cuentas prepagas, visite', generalInformation2: 'Si tiene una queja sobre una cuenta prepaga, llame al Consumer Financial Oficina de Protección al 1-855-411-2372 o visite', diff --git a/src/languages/types.ts b/src/languages/types.ts index 70bf2e4cae3d..52f2df8b3765 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -194,6 +194,8 @@ type FormattedMaxLengthParams = {formattedMaxLength: string}; type TagSelectionParams = {tagName: string}; +type WalletProgramParams = {walletProgram: string}; + /* Translation Object types */ // eslint-disable-next-line @typescript-eslint/no-explicit-any type TranslationBaseValue = string | string[] | ((...args: any[]) => string); @@ -307,4 +309,5 @@ export type { RemovedTheRequestParams, FormattedMaxLengthParams, TagSelectionParams, + WalletProgramParams, }; diff --git a/src/libs/E2E/API.mock.js b/src/libs/E2E/API.mock.js index 501108025979..47f445f72222 100644 --- a/src/libs/E2E/API.mock.js +++ b/src/libs/E2E/API.mock.js @@ -19,6 +19,7 @@ const mocks = { BeginSignIn: mockBeginSignin, SigninUser: mockSigninUser, OpenApp: mockOpenApp, + ReconnectApp: mockOpenApp, OpenReport: mockOpenReport, AuthenticatePusher: mockAuthenticatePusher, }; diff --git a/src/libs/E2E/reactNativeLaunchingTest.js b/src/libs/E2E/reactNativeLaunchingTest.js index 869f5d1f1f1a..13183c1044db 100644 --- a/src/libs/E2E/reactNativeLaunchingTest.js +++ b/src/libs/E2E/reactNativeLaunchingTest.js @@ -6,11 +6,7 @@ */ import Performance from '../Performance'; - -// start the usual app -Performance.markStart('regularAppStart'); -import '../../../index'; -Performance.markEnd('regularAppStart'); +import * as Metrics from '../Metrics'; import E2EConfig from '../../../tests/e2e/config'; import E2EClient from './client'; @@ -19,6 +15,11 @@ console.debug('=========================='); console.debug('==== Running e2e test ===='); console.debug('=========================='); +// Check if the performance module is available +if (!Metrics.canCapturePerformanceMetrics()) { + throw new Error('Performance module not available! Please set CAPTURE_METRICS=true in your environment file!'); +} + // import your test here, define its name and config first in e2e/config.js const tests = { [E2EConfig.TEST_NAMES.AppStartTime]: require('./tests/appStartTimeTest.e2e').default, @@ -36,20 +37,33 @@ const appReady = new Promise((resolve) => { }); }); -E2EClient.getTestConfig().then((config) => { - const test = tests[config.name]; - if (!test) { - // instead of throwing, report the error to the server, which is better for DX - return E2EClient.submitTestResults({ - name: config.name, - error: `Test '${config.name}' not found`, - }); - } - console.debug(`[E2E] Configured for test ${config.name}. Waiting for app to become ready`); - - appReady.then(() => { - console.debug('[E2E] App is ready, running test…'); - Performance.measureFailSafe('appStartedToReady', 'regularAppStart'); - test(); +E2EClient.getTestConfig() + .then((config) => { + const test = tests[config.name]; + if (!test) { + // instead of throwing, report the error to the server, which is better for DX + return E2EClient.submitTestResults({ + name: config.name, + error: `Test '${config.name}' not found`, + }); + } + + console.debug(`[E2E] Configured for test ${config.name}. Waiting for app to become ready`); + appReady + .then(() => { + console.debug('[E2E] App is ready, running test…'); + Performance.measureFailSafe('appStartedToReady', 'regularAppStart'); + test(); + }) + .catch((error) => { + console.error('[E2E] Error while waiting for app to become ready', error); + }); + }) + .catch((error) => { + console.error("[E2E] Error while running test. Couldn't get test config!", error); }); -}); + +// start the usual app +Performance.markStart('regularAppStart'); +import '../../../index'; +Performance.markEnd('regularAppStart'); diff --git a/src/libs/E2E/tests/openSearchPageTest.e2e.js b/src/libs/E2E/tests/openSearchPageTest.e2e.js index 2f0f72f35bdd..3b2d91322cf0 100644 --- a/src/libs/E2E/tests/openSearchPageTest.e2e.js +++ b/src/libs/E2E/tests/openSearchPageTest.e2e.js @@ -7,24 +7,41 @@ import CONST from '../../../CONST'; const test = () => { // check for login (if already logged in the action will simply resolve) + console.debug('[E2E] Logging in for search'); + E2ELogin().then((neededLogin) => { if (neededLogin) { // we don't want to submit the first login to the results return E2EClient.submitTestDone(); } + console.debug('[E2E] Logged in, getting search metrics and submitting them…'); + Performance.subscribeToMeasurements((entry) => { + if (entry.name === CONST.TIMING.SIDEBAR_LOADED) { + console.debug(`[E2E] Sidebar loaded, navigating to search route…`); + Navigation.navigate(ROUTES.SEARCH); + return; + } + + console.debug(`[E2E] Entry: ${JSON.stringify(entry)}`); if (entry.name !== CONST.TIMING.SEARCH_RENDER) { return; } + console.debug(`[E2E] Submitting!`); E2EClient.submitTestResults({ name: 'Open Search Page TTI', duration: entry.duration, - }).then(E2EClient.submitTestDone); + }) + .then(() => { + console.debug('[E2E] Done with search, exiting…'); + E2EClient.submitTestDone(); + }) + .catch((err) => { + console.debug('[E2E] Error while submitting test results:', err); + }); }); - - Navigation.navigate(ROUTES.SEARCH); }); }; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 80b15690ac46..b94c240b6e92 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -314,8 +314,8 @@ function isReservedRoomName(roomName: string): boolean { /** * Checks if the room name already exists. */ -function isExistingRoomName(roomName: string, reports: Report[], policyID: string): boolean { - return reports.some((report) => report && report.policyID === policyID && report.reportName === roomName); +function isExistingRoomName(roomName: string, reports: Record, policyID: string): boolean { + return Object.values(reports).some((report) => report && report.policyID === policyID && report.reportName === roomName); } /** diff --git a/src/libs/actions/QueuedOnyxUpdates.ts b/src/libs/actions/QueuedOnyxUpdates.ts index ac94e6f335e3..1707bebd6cb2 100644 --- a/src/libs/actions/QueuedOnyxUpdates.ts +++ b/src/libs/actions/QueuedOnyxUpdates.ts @@ -1,27 +1,21 @@ import Onyx, {OnyxUpdate} from 'react-native-onyx'; -import ONYXKEYS from '../../ONYXKEYS'; // In this file we manage a queue of Onyx updates while the SequentialQueue is processing. There are functions to get the updates and clear the queue after saving the updates in Onyx. let queuedOnyxUpdates: OnyxUpdate[] = []; -Onyx.connect({ - key: ONYXKEYS.QUEUED_ONYX_UPDATES, - callback: (val) => (queuedOnyxUpdates = val ?? []), -}); /** * @param updates Onyx updates to queue for later */ function queueOnyxUpdates(updates: OnyxUpdate[]): Promise { - return Onyx.set(ONYXKEYS.QUEUED_ONYX_UPDATES, [...queuedOnyxUpdates, ...updates]); -} - -function clear() { - Onyx.set(ONYXKEYS.QUEUED_ONYX_UPDATES, null); + queuedOnyxUpdates = queuedOnyxUpdates.concat(updates); + return Promise.resolve(); } function flushQueue(): Promise { - return Onyx.update(queuedOnyxUpdates).then(clear); + return Onyx.update(queuedOnyxUpdates).then(() => { + queuedOnyxUpdates = []; + }); } export {queueOnyxUpdates, flushQueue}; diff --git a/src/pages/EditRequestReceiptPage.js b/src/pages/EditRequestReceiptPage.js index f45085a052e9..c5dd69624159 100644 --- a/src/pages/EditRequestReceiptPage.js +++ b/src/pages/EditRequestReceiptPage.js @@ -37,11 +37,11 @@ function EditRequestReceiptPage({route, transactionID}) { shouldEnableMaxHeight testID={EditRequestReceiptPage.displayName} > - + { + if (isOffline) { return; } Wallet.openEnablePaymentsPage(); - } + }, [isOffline]); - render() { - if (_.isEmpty(this.props.userWallet)) { - return ; - } - - return ( - - {() => { - if (this.props.userWallet.errorCode === CONST.WALLET.ERROR.KYC) { - return ( - <> - Navigation.goBack(ROUTES.SETTINGS_WALLET)} - /> - - - ); - } - - if (this.props.userWallet.shouldShowWalletActivationSuccess) { - return ; - } - - const currentStep = this.props.userWallet.currentStep || CONST.WALLET.STEP.ADDITIONAL_DETAILS; + if (_.isEmpty(userWallet)) { + return ; + } + return ( + + {() => { + if (userWallet.errorCode === CONST.WALLET.ERROR.KYC) { return ( <> - {(currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS || currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS_KBA) && } - {currentStep === CONST.WALLET.STEP.ONFIDO && } - {currentStep === CONST.WALLET.STEP.TERMS && } - {currentStep === CONST.WALLET.STEP.ACTIVATE && } + Navigation.goBack(ROUTES.SETTINGS_WALLET)} + /> + ); - }} - - ); - } + } + + if (userWallet.shouldShowWalletActivationSuccess) { + return ; + } + + const currentStep = userWallet.currentStep || CONST.WALLET.STEP.ADDITIONAL_DETAILS; + + switch (currentStep) { + case CONST.WALLET.STEP.ADDITIONAL_DETAILS: + case CONST.WALLET.STEP.ADDITIONAL_DETAILS_KBA: + return ; + case CONST.WALLET.STEP.ONFIDO: + return ; + case CONST.WALLET.STEP.TERMS: + return ; + case CONST.WALLET.STEP.ACTIVATE: + return ; + default: + return null; + } + }} + + ); } +EnablePaymentsPage.displayName = 'EnablePaymentsPage'; EnablePaymentsPage.propTypes = propTypes; EnablePaymentsPage.defaultProps = defaultProps; -export default compose( - withLocalize, - withOnyx({ - userWallet: { - key: ONYXKEYS.USER_WALLET, +export default withOnyx({ + userWallet: { + key: ONYXKEYS.USER_WALLET, - // We want to refresh the wallet each time the user attempts to activate the wallet so we won't use the - // stored values here. - initWithStoredValues: false, - }, - }), - withNetwork(), -)(EnablePaymentsPage); + // We want to refresh the wallet each time the user attempts to activate the wallet so we won't use the + // stored values here. + initWithStoredValues: false, + }, +})(EnablePaymentsPage); diff --git a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js index a6f685fcb562..1b693add95b7 100644 --- a/src/pages/EnablePayments/TermsPage/ShortTermsForm.js +++ b/src/pages/EnablePayments/TermsPage/ShortTermsForm.js @@ -5,11 +5,26 @@ import Text from '../../../components/Text'; import * as Localize from '../../../libs/Localize'; import CONST from '../../../CONST'; import TextLink from '../../../components/TextLink'; +import userWalletPropTypes from '../userWalletPropTypes'; -function ShortTermsForm() { +const propTypes = { + /** The user's wallet */ + userWallet: userWalletPropTypes, +}; + +const defaultProps = { + userWallet: {}, +}; + +function ShortTermsForm(props) { return ( <> - {Localize.translateLocal('termsStep.shortTermsForm.expensifyPaymentsAccount')} + + {Localize.translateLocal('termsStep.shortTermsForm.expensifyPaymentsAccount', { + walletProgram: + props.userWallet.walletProgramID === CONST.WALLET.MTL_WALLET_PROGRAM_ID ? CONST.WALLET.PROGRAM_ISSUERS.EXPENSIFY_PAYMENTS : CONST.WALLET.PROGRAM_ISSUERS.BANCORP_BANK, + })} + @@ -132,6 +147,8 @@ function ShortTermsForm() { ); } +ShortTermsForm.propTypes = propTypes; +ShortTermsForm.defaultProps = defaultProps; ShortTermsForm.displayName = 'ShortTermsForm'; export default ShortTermsForm; diff --git a/src/pages/EnablePayments/TermsStep.js b/src/pages/EnablePayments/TermsStep.js index e96d93dc4de9..39f4826ec0b2 100644 --- a/src/pages/EnablePayments/TermsStep.js +++ b/src/pages/EnablePayments/TermsStep.js @@ -15,8 +15,12 @@ import LongTermsForm from './TermsPage/LongTermsForm'; import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; import walletTermsPropTypes from './walletTermsPropTypes'; import * as ErrorUtils from '../../libs/ErrorUtils'; +import userWalletPropTypes from './userWalletPropTypes'; const propTypes = { + /** The user's wallet */ + userWallet: userWalletPropTypes, + /** Comes from Onyx. Information about the terms for the wallet */ walletTerms: walletTermsPropTypes, @@ -24,6 +28,7 @@ const propTypes = { }; const defaultProps = { + userWallet: {}, walletTerms: {}, }; @@ -59,7 +64,7 @@ function TermsStep(props) { style={styles.flex1} contentContainerStyle={styles.ph5} > - + - + ReportActions.clearReportActionErrors(props.report.reportID, props.action)} pendingAction={props.draftMessage ? null : props.action.pendingAction} diff --git a/src/pages/home/report/ReportActionItemFragment.js b/src/pages/home/report/ReportActionItemFragment.js index 1f61b44841bc..24501e307759 100644 --- a/src/pages/home/report/ReportActionItemFragment.js +++ b/src/pages/home/report/ReportActionItemFragment.js @@ -1,5 +1,4 @@ import React, {memo} from 'react'; -import {ActivityIndicator, View} from 'react-native'; import PropTypes from 'prop-types'; import Str from 'expensify-common/lib/str'; import reportActionFragmentPropTypes from './reportActionFragmentPropTypes'; @@ -27,9 +26,6 @@ const propTypes = { /** The message fragment needing to be displayed */ fragment: reportActionFragmentPropTypes.isRequired, - /** Is this fragment an attachment? */ - isAttachment: PropTypes.bool, - /** If this fragment is attachment than has info? */ attachmentInfo: PropTypes.shape({ /** The file name of attachment */ @@ -48,9 +44,6 @@ const propTypes = { /** Message(text) of an IOU report action */ iouMessage: PropTypes.string, - /** Does this fragment belong to a reportAction that has not yet loaded? */ - loading: PropTypes.bool, - /** The reportAction's source */ source: PropTypes.oneOf(['Chronos', 'email', 'ios', 'android', 'web', 'email', '']), @@ -76,7 +69,6 @@ const propTypes = { }; const defaultProps = { - isAttachment: false, attachmentInfo: { name: '', size: 0, @@ -84,7 +76,6 @@ const defaultProps = { source: '', }, iouMessage: '', - loading: false, isSingleLine: false, source: '', style: [], @@ -96,20 +87,6 @@ const defaultProps = { function ReportActionItemFragment(props) { switch (props.fragment.type) { case 'COMMENT': { - // If this is an attachment placeholder, return the placeholder component - if (props.isAttachment && props.loading) { - return Str.isImage(props.attachmentInfo.name) ? ( - `} /> - ) : ( - - - - ); - } const {html, text} = props.fragment; const isPendingDelete = props.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && props.network.isOffline; diff --git a/src/pages/home/report/ReportActionItemMessage.js b/src/pages/home/report/ReportActionItemMessage.js index bc92889158d0..a3d8494c38de 100644 --- a/src/pages/home/report/ReportActionItemMessage.js +++ b/src/pages/home/report/ReportActionItemMessage.js @@ -54,14 +54,12 @@ function ReportActionItemMessage(props) { )) diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js index ca0467143e98..162f28021b94 100644 --- a/src/pages/home/report/ReportActionItemSingle.js +++ b/src/pages/home/report/ReportActionItemSingle.js @@ -241,8 +241,6 @@ function ReportActionItemSingle(props) { key={`person-${props.action.reportActionID}-${index}`} accountID={actorAccountID} fragment={fragment} - isAttachment={props.action.isAttachment} - isLoading={props.action.isLoading} delegateAccountID={props.action.delegateAccountID} isSingleLine actorIcon={icon} diff --git a/src/pages/home/report/reportActionPropTypes.js b/src/pages/home/report/reportActionPropTypes.js index e0c3aebe718c..4d4809cd781f 100644 --- a/src/pages/home/report/reportActionPropTypes.js +++ b/src/pages/home/report/reportActionPropTypes.js @@ -23,9 +23,6 @@ export default { IOUTransactionID: PropTypes.string, }), - /** Whether we have received a response back from the server */ - isLoading: PropTypes.bool, - /** Error message that's come back from the server. */ error: PropTypes.string, diff --git a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js index 170ee042bffa..05b206ce4147 100755 --- a/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js +++ b/src/pages/iou/steps/MoneyRequstParticipantsPage/MoneyRequestParticipantsSelector.js @@ -143,7 +143,10 @@ function MoneyRequestParticipantsSelector({ if (newChatOptions.userToInvite && !OptionsListUtils.isCurrentUser(newChatOptions.userToInvite)) { newSections.push({ undefined, - data: [newChatOptions.userToInvite], + data: _.map([newChatOptions.userToInvite], (participant) => { + const isPolicyExpenseChat = lodashGet(participant, 'isPolicyExpenseChat', false); + return isPolicyExpenseChat ? OptionsListUtils.getPolicyExpenseReportOption(participant) : OptionsListUtils.getParticipantsOption(participant, personalDetails); + }), shouldShow: true, indexOffset, }); @@ -201,30 +204,8 @@ function MoneyRequestParticipantsSelector({ } onAddParticipants(newSelectedOptions); - - const chatOptions = OptionsListUtils.getFilteredOptions( - reports, - personalDetails, - betas, - isOptionInList ? searchTerm : '', - newSelectedOptions, - CONST.EXPENSIFY_EMAILS, - - // If we are using this component in the "Request money" flow then we pass the includeOwnedWorkspaceChats argument so that the current user - // sees the option to request money from their admin on their own Workspace Chat. - iouType === CONST.IOU.MONEY_REQUEST_TYPE.REQUEST, - - // We don't want to include any P2P options like personal details or reports that are not workspace chats for certain features. - !isDistanceRequest, - ); - - setNewChatOptions({ - recentReports: chatOptions.recentReports, - personalDetails: chatOptions.personalDetails, - userToInvite: chatOptions.userToInvite, - }); }, - [participants, onAddParticipants, reports, personalDetails, betas, searchTerm, iouType, isDistanceRequest], + [participants, onAddParticipants], ); const headerMessage = OptionsListUtils.getHeaderMessage( diff --git a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js index b8c817350a38..7e8baba5a9ce 100644 --- a/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js +++ b/src/pages/settings/Profile/Contacts/ContactMethodDetailsPage.js @@ -25,6 +25,7 @@ import ValidateCodeForm from './ValidateCodeForm'; import ROUTES from '../../../../ROUTES'; import FullscreenLoadingIndicator from '../../../../components/FullscreenLoadingIndicator'; import FullPageNotFoundView from '../../../../components/BlockingViews/FullPageNotFoundView'; +import CONST from '../../../../CONST'; const propTypes = { /* Onyx Props */ @@ -131,7 +132,22 @@ class ContactMethodDetailsPage extends Component { * @returns {string} */ getContactMethod() { - return decodeURIComponent(lodashGet(this.props.route, 'params.contactMethod')); + const contactMethod = lodashGet(this.props.route, 'params.contactMethod'); + + // We find the number of times the url is encoded based on the last % sign and remove them. + const lastPercentIndex = contactMethod.lastIndexOf('%'); + const encodePercents = contactMethod.substring(lastPercentIndex).match(new RegExp('25', 'g')); + let numberEncodePercents = encodePercents ? encodePercents.length : 0; + const beforeAtSign = contactMethod.substring(0, lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, (match) => { + if (numberEncodePercents > 0) { + numberEncodePercents--; + return '%'; + } + return match; + }); + const afterAtSign = contactMethod.substring(lastPercentIndex).replace(CONST.REGEX.ENCODE_PERCENT_CHARACTER, '%'); + + return decodeURIComponent(beforeAtSign + afterAtSign); } /** @@ -230,6 +246,7 @@ class ContactMethodDetailsPage extends Component { const isDefaultContactMethod = this.props.session.email === loginData.partnerUserID; const hasMagicCodeBeenSent = lodashGet(this.props.loginList, [contactMethod, 'validateCodeSent'], false); const isFailedAddContactMethod = Boolean(lodashGet(loginData, 'errorFields.addedLogin')); + const isFailedRemovedContactMethod = Boolean(lodashGet(loginData, 'errorFields.deletedLogin')); return ( {isFailedAddContactMethod && ( @@ -289,9 +306,9 @@ class ContactMethodDetailsPage extends Component { {isDefaultContactMethod ? ( User.clearContactMethodErrors(contactMethod, 'defaultLogin')} + onClose={() => User.clearContactMethodErrors(contactMethod, isFailedRemovedContactMethod ? 'deletedLogin' : 'defaultLogin')} > {this.props.translate('contacts.yourDefaultContactMethod')} diff --git a/src/styles/StyleUtils.ts b/src/styles/StyleUtils.ts index 190f18f8d969..ad1b59ec2253 100644 --- a/src/styles/StyleUtils.ts +++ b/src/styles/StyleUtils.ts @@ -577,7 +577,7 @@ function getEmojiPickerStyle(isSmallScreenWidth: boolean): ViewStyle | CSSProper /** * Generate the styles for the ReportActionItem wrapper view. */ -function getReportActionItemStyle(isHovered = false, isLoading = false): ViewStyle | CSSProperties { +function getReportActionItemStyle(isHovered = false): ViewStyle | CSSProperties { // TODO: Remove this "eslint-disable-next" once the theme switching migration is done and styles are fully typed (GH Issue: https://github.com/Expensify/App/issues/27337) // eslint-disable-next-line @typescript-eslint/no-unsafe-return return { @@ -587,7 +587,7 @@ function getReportActionItemStyle(isHovered = false, isLoading = false): ViewSty ? themeColors.hoverComponentBG : // Warning: Setting this to a non-transparent color will cause unread indicator to break on Android themeColors.transparent, - opacity: isLoading ? 0.5 : 1, + opacity: 1, ...styles.cursorInitial, }; } diff --git a/src/styles/styles.js b/src/styles/styles.js index 56868f930735..cef87c531972 100644 --- a/src/styles/styles.js +++ b/src/styles/styles.js @@ -3674,6 +3674,7 @@ const styles = (theme) => ({ borderRadius: variables.componentBorderRadiusLarge, }, userReportStatusEmoji: { + flexShrink: 0, fontSize: variables.fontSizeNormal, marginRight: 4, }, diff --git a/src/types/onyx/UserWallet.ts b/src/types/onyx/UserWallet.ts index 8624f16000c9..c6ab5dbf1f67 100644 --- a/src/types/onyx/UserWallet.ts +++ b/src/types/onyx/UserWallet.ts @@ -34,6 +34,9 @@ type UserWallet = { /** The type of the linked account (debitCard or bankAccount) */ walletLinkedAccountType: WalletLinkedAccountType; + /** The wallet's programID, used to show the correct terms. */ + walletProgramID?: string; + /** The user's bank account ID */ bankAccountID?: number; diff --git a/tests/e2e/ADDING_TESTS.md b/tests/e2e/ADDING_TESTS.md index 9704e4ea706d..dcd08aeee441 100644 --- a/tests/e2e/ADDING_TESTS.md +++ b/tests/e2e/ADDING_TESTS.md @@ -1,4 +1,51 @@ -# Add E2E Tests +# Adding new E2E Tests + +## Running your new test in development mode + +Typically you'd run all the tests with `npm run test:e2e` on your machine, +this will run the tests with some local settings, however that is not +optimal when you add a new test for which you want to quickly test if it works, as it +still runs the release version of the app. + +I recommend doing the following. + +> [!NOTE] +> All of the steps can be executed at once by running XXX (todo) + +1. Rename `./index.js` to `./appIndex.js` +2. Create a new `./index.js` with the following content: +```js +requrire("./src/libs/E2E/reactNativeLaunchingTest.js"); +``` +3. In `./src/libs/E2E/reactNativeLaunchingTest.js` change the main app import to the new `./appIndex.js` file: +```diff +- import '../../../index'; ++ import '../../../appIndex'; +``` + +> [!WARNING] +> Make sure to not commit these changes to the repository! + +Now you can start the metro bundler in e2e mode with: + +``` +CAPTURE_METRICS=TRUE E2E_Testing=true npm start -- --reset-cache +``` + +Then we can execute our test with: + +``` +npm run test:e2e -- --development --skipInstallDeps --buildMode skip --includes "My new test name" +``` + +> - `--development` will run the tests with a local config, which will run the tests with fewer iterations +> - `--skipInstallDeps` will skip the `npm install` step, which you probably don't need +> - `--buildMode skip` will skip rebuilding the app, and just run the existing app +> - `--includes "MyTestName"` will only run the test with the name "MyTestName" + + + +## Creating a new test Tests are executed on device, inside the app code. @@ -97,6 +144,10 @@ Done! When you now start the test runner, your new test will be executed as well ## Quickly test your test To check your new test you can simply run `npm run test:e2e`, which uses the -`--development` flag. This will run the tests on the branch you are currently on -and will do fewer iterations. +`--development` flag. This will run the tests on the branch you are currently on, runs fewer iterations and most importantly, it tries to reuse the existing APK and just patch into the new app bundle, instead of rebuilding the release app from scratch. + +## Debugging your test + +You can use regular console statements to debug your test. The output will be visible +in logcat. I recommend opening the android studio logcat window and filter for `ReactNativeJS` to see the output you'd otherwise typically see in your metro bundler instance. diff --git a/tests/e2e/config.js b/tests/e2e/config.js index d322fb970b2d..d7844a29f3e4 100644 --- a/tests/e2e/config.js +++ b/tests/e2e/config.js @@ -21,7 +21,7 @@ const TEST_NAMES = { * ``` */ module.exports = { - APP_PACKAGE: 'com.expensify.chat', + APP_PACKAGE: 'com.expensify.chat.adhoc', APP_PATHS: { baseline: './app-e2eRelease-baseline.apk', diff --git a/tests/e2e/config.local.js b/tests/e2e/config.local.js index cd0b04d7c3cf..0c38c3f1056f 100644 --- a/tests/e2e/config.local.js +++ b/tests/e2e/config.local.js @@ -1,8 +1,10 @@ module.exports = { + APP_PACKAGE: 'com.expensify.chat.dev', + WARM_UP_RUNS: 1, RUNS: 8, APP_PATHS: { - baseline: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk', - compare: './android/app/build/outputs/apk/e2eRelease/app-e2eRelease.apk', + baseline: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk', + compare: './android/app/build/outputs/apk/e2e/release/app-e2e-release.apk', }, }; diff --git a/tests/e2e/testRunner.js b/tests/e2e/testRunner.js index db421ae64ef1..2a5aee78715f 100644 --- a/tests/e2e/testRunner.js +++ b/tests/e2e/testRunner.js @@ -73,9 +73,9 @@ if (isDevMode) { const restartApp = async () => { Logger.log('Killing app …'); - await killApp('android'); + await killApp('android', config.APP_PACKAGE); Logger.log('Launching app …'); - await launchApp('android'); + await launchApp('android', config.APP_PACKAGE); }; const runTestsOnBranch = async (baselineOrCompare, branch) => { @@ -89,6 +89,8 @@ const runTestsOnBranch = async (baselineOrCompare, branch) => { const appExists = fs.existsSync(appPath); if (!appExists) { Logger.warn(`Build mode "${buildMode}" is not possible, because the app does not exist. Falling back to build mode "full".`); + Logger.note(`App path: ${appPath}`); + buildMode = 'full'; } } @@ -125,7 +127,7 @@ const runTestsOnBranch = async (baselineOrCompare, branch) => { // Install app and reverse port let progressLog = Logger.progressInfo('Installing app and reversing port'); - await installApp('android', appPath); + await installApp('android', config.APP_PACKAGE, appPath); await reversePort(); progressLog.done(); diff --git a/tests/e2e/utils/installApp.js b/tests/e2e/utils/installApp.js index 136602375f85..ff961940826a 100644 --- a/tests/e2e/utils/installApp.js +++ b/tests/e2e/utils/installApp.js @@ -7,16 +7,17 @@ const Logger = require('./logger'); * It removes the app first if it already exists, so it's a clean installation. * * @param {String} platform + * @param {String} packageName * @param {String} path * @returns {Promise} */ -module.exports = function (platform = 'android', path) { +module.exports = function (platform = 'android', packageName = APP_PACKAGE, path) { if (platform !== 'android') { throw new Error(`installApp() missing implementation for platform: ${platform}`); } // Uninstall first, then install - return execAsync(`adb uninstall ${APP_PACKAGE}`) + return execAsync(`adb uninstall ${packageName}`) .catch((e) => { // Ignore errors Logger.warn('Failed to uninstall app:', e); diff --git a/tests/e2e/utils/killApp.js b/tests/e2e/utils/killApp.js index 9761ee7fc66e..bdef215bf752 100644 --- a/tests/e2e/utils/killApp.js +++ b/tests/e2e/utils/killApp.js @@ -1,11 +1,11 @@ const {APP_PACKAGE} = require('../config'); const execAsync = require('./execAsync'); -module.exports = function (platform = 'android') { +module.exports = function (platform = 'android', packageName = APP_PACKAGE) { if (platform !== 'android') { throw new Error(`killApp() missing implementation for platform: ${platform}`); } // Use adb to kill the app - return execAsync(`adb shell am force-stop ${APP_PACKAGE}`); + return execAsync(`adb shell am force-stop ${packageName}`); }; diff --git a/tests/e2e/utils/launchApp.js b/tests/e2e/utils/launchApp.js index dce17c7fbb3b..e0726d081086 100644 --- a/tests/e2e/utils/launchApp.js +++ b/tests/e2e/utils/launchApp.js @@ -1,11 +1,11 @@ const {APP_PACKAGE} = require('../config'); const execAsync = require('./execAsync'); -module.exports = function (platform = 'android') { +module.exports = function (platform = 'android', packageName = APP_PACKAGE) { if (platform !== 'android') { throw new Error(`launchApp() missing implementation for platform: ${platform}`); } // Use adb to start the app - return execAsync(`adb shell monkey -p ${APP_PACKAGE} -c android.intent.category.LAUNCHER 1`); + return execAsync(`adb shell monkey -p ${packageName} -c android.intent.category.LAUNCHER 1`); }; diff --git a/tests/e2e/utils/logger.js b/tests/e2e/utils/logger.js index aa198aec3004..1f2fff315bfc 100644 --- a/tests/e2e/utils/logger.js +++ b/tests/e2e/utils/logger.js @@ -61,19 +61,16 @@ const progressInfo = (textParam) => { }; const info = (...args) => { - console.debug('> ', ...args); - log(...args); + log('> ', ...args); }; const warn = (...args) => { const lines = [`\n${COLOR_YELLOW}⚠️`, ...args, `${COLOR_RESET}\n`]; - console.debug(...lines); log(...lines); }; const note = (...args) => { const lines = [`\n💡${COLOR_DIM}`, ...args, `${COLOR_RESET}\n`]; - console.debug(...lines); log(...lines); }; diff --git a/tests/utils/LHNTestUtils.js b/tests/utils/LHNTestUtils.js index 43090cf024e2..7cb69b23a578 100644 --- a/tests/utils/LHNTestUtils.js +++ b/tests/utils/LHNTestUtils.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import {render} from '@testing-library/react-native'; import ComposeProviders from '../../src/components/ComposeProviders'; import OnyxProvider from '../../src/components/OnyxProvider'; -import {LocaleContextProvider} from '../../src/components/withLocalize'; +import {LocaleContextProvider} from '../../src/components/LocaleContextProvider'; import SidebarLinksData from '../../src/pages/home/sidebar/SidebarLinksData'; import {EnvironmentProvider} from '../../src/components/withEnvironment'; import CONST from '../../src/CONST';